mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
458 lines
17 KiB
Vue
458 lines
17 KiB
Vue
<!-- Copyright 2026 OpenObserve Inc.
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
-->
|
|
|
|
<template>
|
|
<!-- Always wrapped (disabled when silent) so the tile element the rail measures never remounts. -->
|
|
<OTooltip :disabled="!tooltipText" :side="tooltipSide" :delay="300">
|
|
<!-- Single dynamic root so external links (<a>), internal links (<router-link>)
|
|
and submenu-group triggers (<button>, used by ONavGroup) all share the
|
|
exact same tile markup and styling — a group tile is literally a MenuLink. -->
|
|
<component
|
|
:is="rootComponent"
|
|
v-bind="{ ...$attrs, ...rootProps }"
|
|
:class="rootClass"
|
|
@click="onRootClick"
|
|
>
|
|
<div
|
|
class="nav-menu-item-avatar flex w-full flex-col items-center gap-0.5 max-md:flex-row max-md:items-center max-md:gap-3 max-md:px-1"
|
|
>
|
|
<div
|
|
class="icon-wrapper rounded-default relative inline-flex items-center justify-center p-0.5 transition-colors duration-250"
|
|
:class="iconClass"
|
|
>
|
|
<!-- Rail icons are a hair smaller than the md (24px) default. -->
|
|
<OIcon v-if="icon" :name="icon" size="md" class="size-5.5!" />
|
|
<component v-else-if="hasIconComponent" :is="iconComponent" class="o-icon size-5.5" />
|
|
<div
|
|
v-if="locked"
|
|
class="bg-surface-base absolute -top-1 -right-1 z-1 flex h-3.5 w-3.5 items-center justify-center rounded-full"
|
|
>
|
|
<OIcon name="lock" size="xs" class="text-warning-600" />
|
|
</div>
|
|
<div
|
|
v-if="badge && badge > 0"
|
|
class="menu-badge text-3xs text-text-inverse shadow-error-500/50 border-menu-badge-ring bg-gradient-notification absolute -top-1 -right-2 z-1 flex h-4 min-w-4 animate-pulse items-center justify-center rounded-full border-2 px-1 leading-none font-bold shadow-md"
|
|
aria-live="polite"
|
|
:aria-label="t('common.notificationsCount', { count: badge })"
|
|
>
|
|
{{ badge > 99 ? "99+" : badge }}
|
|
</div>
|
|
<span
|
|
v-else-if="paywalled"
|
|
:data-test="`menu-link-${link}-lock`"
|
|
class="menu-lock text-text-secondary absolute -end-1.5 -top-1 z-1 inline-flex"
|
|
aria-hidden="true"
|
|
>
|
|
<OIcon name="lock" size="xs" class="size-3!" />
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref="labelRef"
|
|
class="nav-menu-item-label line-clamp-2 w-full text-center text-xs leading-tight tracking-[0.01em] break-normal wrap-normal text-ellipsis [hyphens:none] transition-colors duration-250 max-md:text-start max-md:text-sm"
|
|
:class="labelClass"
|
|
>
|
|
{{ title }}
|
|
</div>
|
|
</div>
|
|
<TooltipSilencer :silent="!tooltipText" />
|
|
<span v-if="paywalled" :id="paywallDescriptionId" class="sr-only">{{ paywallText }}</span>
|
|
|
|
<!-- Submenu affordance: hidden at rest so a group/link-with-subnav tile is
|
|
indistinguishable from a plain tile (consistency). It fades in only on
|
|
hover, or stays lit while the flyout is open / the section is active. -->
|
|
<span
|
|
v-if="asTrigger || submenu"
|
|
class="absolute end-1 top-3 transition-opacity duration-150 max-md:end-3 max-md:top-1/2 max-md:-translate-y-1/2 max-md:opacity-100"
|
|
:class="[
|
|
isActive || expanded
|
|
? 'text-accent opacity-100'
|
|
: 'text-tabs-inactive-text opacity-70 group-hover:opacity-100',
|
|
expanded ? 'max-md:rotate-90 max-md:rtl:-rotate-90' : '',
|
|
]"
|
|
aria-hidden="true"
|
|
>
|
|
<OIcon name="chevron-right" size="xs" class="max-md:size-4.5!" />
|
|
</span>
|
|
</component>
|
|
<template #content>
|
|
<span :data-test="`menu-link-${link}-tooltip`" class="flex flex-col">
|
|
<span>{{ tooltipText }}</span>
|
|
<span v-if="paywalled">{{ t("menu.trialEndedTitle") }}</span>
|
|
</span>
|
|
</template>
|
|
</OTooltip>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, computed, inject, nextTick, ref, useId, watch, type PropType } from "vue";
|
|
import { useStore } from "vuex";
|
|
import { useRouter, RouterLink } from "vue-router";
|
|
import { injectTooltipRootContext } from "reka-ui";
|
|
import { useTheme } from "@/composables/useTheme";
|
|
import { raw, type I18nText, useI18nTyped } from "@/types/i18n";
|
|
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
|
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
|
import { useIsTruncated } from "@/lib/overlay/Tooltip/useIsTruncated";
|
|
import { RailIndicatorActiveKey } from "@/lib/core/Navbar/ONavbar.types";
|
|
|
|
// A disabled reka tooltip drops its trigger listeners but keeps a pending hover-open timer; onClose() is the only way to cancel it.
|
|
const TooltipSilencer = defineComponent({
|
|
name: "MenuLinkTooltipSilencer",
|
|
props: {
|
|
silent: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
},
|
|
setup(props) {
|
|
const tooltip = injectTooltipRootContext(null);
|
|
watch(
|
|
() => props.silent,
|
|
(silent) => {
|
|
if (silent) tooltip?.onClose();
|
|
},
|
|
);
|
|
return () => null;
|
|
},
|
|
});
|
|
|
|
export default defineComponent({
|
|
name: "MenuLink",
|
|
components: { OIcon, OTooltip, TooltipSilencer },
|
|
// Attrs are re-bound onto the tile element by hand: the tooltip wrapper is a fragment.
|
|
inheritAttrs: false,
|
|
props: {
|
|
title: {
|
|
type: String as unknown as PropType<I18nText>,
|
|
required: true,
|
|
},
|
|
|
|
caption: {
|
|
type: String as unknown as PropType<I18nText>,
|
|
default: raw(""),
|
|
},
|
|
|
|
link: {
|
|
type: String,
|
|
default: "#",
|
|
},
|
|
|
|
icon: {
|
|
type: String,
|
|
default: "",
|
|
},
|
|
|
|
iconComponent: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
|
|
mini: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
|
|
target: {
|
|
type: String,
|
|
default: "_self",
|
|
},
|
|
|
|
external: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
|
|
badge: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
|
|
// Trigger mode — render as a non-navigating <button> that opens a submenu
|
|
// (used by ONavGroup for a group header). In this mode `active` is supplied
|
|
// by the parent (the group is active when any child route is active) and
|
|
// `expanded` reflects the open flyout for aria + the caret state.
|
|
asTrigger: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
active: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
expanded: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
// Show the submenu caret on a normal link (it reveals sub-pages on hover but
|
|
// still navigates on click). `asTrigger` implies this for pure groups.
|
|
submenu: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
// The destination is behind the trial paywall: muted, locked, still clickable.
|
|
paywalled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
// Present but unreachable — an enterprise/cloud-only item in a build that
|
|
// doesn't unlock it (see utils/enterpriseFeatures.ts). Dimmed, inert on
|
|
// click, and shows `lockedMessage` as a tooltip instead of navigating.
|
|
locked: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
lockedMessage: {
|
|
type: String as unknown as PropType<I18nText>,
|
|
default: raw(""),
|
|
},
|
|
},
|
|
emits: ["trigger"],
|
|
setup(props, { emit }) {
|
|
const { t } = useI18nTyped();
|
|
const store = useStore();
|
|
const router: any = useRouter();
|
|
|
|
const openWebPage = (url: string) => {
|
|
window.open(url, "_blank");
|
|
};
|
|
|
|
// Compute the tile's active state.
|
|
// In trigger mode the parent owns the active flag; otherwise we derive it
|
|
// from the route. Home (link === '/') needs special handling because every
|
|
// path starts with '/' — match by route name "home". Others prefix-match.
|
|
const isActive = computed(() => {
|
|
if (props.asTrigger) return props.active;
|
|
// Explicit override — link+subnav group tiles (e.g. Data) pass `active`
|
|
// computed from "is ANY child route active", since their children live
|
|
// under different path roots than the tile's own link.
|
|
if (props.active) return true;
|
|
const route = router.currentRoute.value;
|
|
if (props.link === "/") {
|
|
return route.name === "home" || route.path === "/" || route.path === "";
|
|
}
|
|
return route.path.indexOf(props.link) === 0;
|
|
});
|
|
|
|
// Active-state styling is theme-aware: LIGHT keeps the classic white pill
|
|
// with primary-coloured text/icon; DARK uses the tinted "selected" pill
|
|
// (matching the dashboard-folder selection) with white text/icon — because
|
|
// surface-base is black in dark mode, a white pill there would vanish.
|
|
const { isDark } = useTheme();
|
|
|
|
// When the rail draws a single sliding pill (ONavbar provides this), an active
|
|
// tile defers its fill (background + coloured left accent) to that pill and
|
|
// keeps only its active text colour — the transparent left border stays so
|
|
// there is no width shift between states. Falls back to the self-painted pill
|
|
// when the indicator isn't active (default `false` off the rail, or before it
|
|
// is positioned), so the nav always shows a selection.
|
|
const railIndicatorActive = inject(RailIndicatorActiveKey, undefined);
|
|
const slideActive = computed(() => Boolean(railIndicatorActive?.value));
|
|
|
|
const activePillClass = computed(() => {
|
|
if (slideActive.value) {
|
|
return isDark.value
|
|
? "text-tabs-active-text border-s-2 border-transparent"
|
|
: "text-accent border-s-2 border-transparent";
|
|
}
|
|
return isDark.value
|
|
? "text-tabs-active-text bg-tabs-active-bg border-s-2 border-accent"
|
|
: "text-accent bg-surface-base border-s-2 border-accent";
|
|
});
|
|
const activeIconClass = computed(() =>
|
|
isDark.value ? "text-tabs-active-text!" : "text-accent!",
|
|
);
|
|
// Light active and hover labels use the darker label token; the icon keeps the accent.
|
|
const activeLabelClass = computed(() =>
|
|
isDark.value
|
|
? "font-semibold text-tabs-active-text!"
|
|
: "font-semibold text-nav-label-accent!",
|
|
);
|
|
const iconClass = computed(() => {
|
|
if (props.paywalled) return "text-text-secondary";
|
|
return isActive.value
|
|
? activeIconClass.value
|
|
: "text-tabs-inactive-text group-hover:text-accent";
|
|
});
|
|
const labelClass = computed(() => {
|
|
if (props.paywalled) return "text-text-body font-medium";
|
|
return isActive.value
|
|
? activeLabelClass.value
|
|
: "text-tabs-inactive-text group-hover:text-nav-label-accent font-medium";
|
|
});
|
|
|
|
// aria-current already announces the page, so the name carries no suffix.
|
|
const ariaLabel = computed(() => {
|
|
const label: string = props.title || t("menu.navigationLink");
|
|
if (props.badge && props.badge > 0) {
|
|
return t("menu.ariaWithNotifications", { label, count: props.badge });
|
|
}
|
|
return label;
|
|
});
|
|
|
|
const paywallDescriptionId = useId();
|
|
const paywallText = computed(() => t("menu.trialPageNeedsPlan", { page: props.title }));
|
|
|
|
const labelRef = ref<HTMLElement | null>(null);
|
|
const { isTruncated, update: updateTruncated } = useIsTruncated(labelRef, { clamp: true });
|
|
// A new label in the same box never resizes it, so the observer would miss a language switch.
|
|
watch(
|
|
() => props.title,
|
|
async () => {
|
|
await nextTick();
|
|
updateTruncated();
|
|
},
|
|
);
|
|
|
|
// A group tile never gets a tooltip: its flyout opens on the same edge and shows the title.
|
|
const tooltipText = computed<I18nText | "">(() => {
|
|
if (props.asTrigger || props.submenu) return "";
|
|
if (props.paywalled) return paywallText.value;
|
|
return isTruncated.value ? props.title : "";
|
|
});
|
|
const tooltipSide = computed(() => (document.documentElement.dir === "rtl" ? "left" : "right"));
|
|
|
|
// The default prop is an empty object `{}`; only render a custom icon
|
|
// component when a real one was passed.
|
|
const hasIconComponent = computed(() => {
|
|
const c: any = props.iconComponent;
|
|
return !!c && (typeof c === "function" || Object.keys(c).length > 0);
|
|
});
|
|
|
|
// Locked wins over everything else. A real `RouterLink` can't be made
|
|
// inert via `onRootClick`'s preventDefault — RouterLink's own click
|
|
// handler runs before the parent's `@click` fallthrough listener (vue-
|
|
// router merges its `navigate` first) — so a plain `div` is the only way
|
|
// to guarantee no navigation.
|
|
const rootComponent = computed(() =>
|
|
props.locked ? "div" : props.external ? "a" : props.asTrigger ? "button" : RouterLink,
|
|
);
|
|
|
|
// Attributes bound to the root, per mode. Kept in one place so the three
|
|
// modes can never drift apart visually.
|
|
const rootProps = computed<Record<string, any>>(() => {
|
|
const common: Record<string, any> = {
|
|
"data-test": props.locked
|
|
? `menu-link-${props.link}-item-locked`
|
|
: `menu-link-${props.link}-item`,
|
|
"aria-label": ariaLabel.value,
|
|
"data-truncated": isTruncated.value ? "true" : undefined,
|
|
};
|
|
if (props.locked) {
|
|
// A plain `div` carries no native tabindex — add one so keyboard
|
|
// users can still reach it (and trigger its click) by Tab.
|
|
return { ...common, role: "link", tabindex: 0 };
|
|
}
|
|
// Set only when present: an explicit undefined would erase the tooltip's own describedby.
|
|
if (props.paywalled) {
|
|
common["data-paywalled"] = "true";
|
|
common["aria-describedby"] = paywallDescriptionId;
|
|
}
|
|
if (props.external) {
|
|
return {
|
|
...common,
|
|
href: "#",
|
|
target: props.target,
|
|
"aria-current": isActive.value ? "page" : undefined,
|
|
};
|
|
}
|
|
if (props.asTrigger) {
|
|
return {
|
|
...common,
|
|
type: "button",
|
|
"aria-haspopup": "menu",
|
|
"aria-expanded": props.expanded,
|
|
};
|
|
}
|
|
return {
|
|
...common,
|
|
to: {
|
|
path: props.link,
|
|
query: { org_identifier: store.state.selectedOrganization?.identifier },
|
|
},
|
|
target: props.target,
|
|
"aria-current": isActive.value ? "page" : undefined,
|
|
...(props.submenu ? { "aria-haspopup": "menu", "aria-expanded": props.expanded } : {}),
|
|
};
|
|
});
|
|
|
|
const rootClass = computed(() => [
|
|
"nav-menu-item",
|
|
"group relative block [text-decoration:none]! text-inherit shrink-0 mx-1 px-0 py-1 min-h-0 rounded-surface transition-colors duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-chrome-deeper",
|
|
// Drawer rows are tap targets, so they clear the 44px touch-target minimum.
|
|
"max-md:flex max-md:min-h-11 max-md:items-center max-md:px-2 max-md:py-2",
|
|
// Sit above the rail's sliding pill so icon/label stay readable.
|
|
slideActive.value ? "z-10" : "",
|
|
props.locked
|
|
? "cursor-pointer text-tabs-inactive-text border-s-2 border-transparent bg-transparent hover:bg-tabs-hover-bg"
|
|
: isActive.value
|
|
? activePillClass.value
|
|
: "text-tabs-inactive-text border-s-2 border-transparent bg-transparent hover:bg-tabs-hover-bg",
|
|
isActive.value ? "nav-menu-item--active" : "",
|
|
props.title === "Functions" ? "menu-link-function" : "",
|
|
// Reset native <button> chrome so the trigger looks EXACTLY like a link.
|
|
// NOTE: do NOT set a background here — the active/inactive branch above owns
|
|
// it. `appearance-none` clears the native button background; adding
|
|
// `bg-transparent` here would override the active pill and break the
|
|
// selected-state highlight. `font:inherit` keeps the label font identical.
|
|
props.asTrigger
|
|
? "w-full appearance-none border-0 cursor-pointer text-left [font-family:inherit] [font-size:inherit] [line-height:inherit] [letter-spacing:inherit]"
|
|
: "",
|
|
]);
|
|
|
|
const onRootClick = (event: MouseEvent) => {
|
|
if (props.locked) {
|
|
event.preventDefault();
|
|
router.push({ name: "enterpriseFeatures" }).catch(() => {});
|
|
return;
|
|
}
|
|
if (props.external) {
|
|
event.preventDefault();
|
|
openWebPage(props.link);
|
|
return;
|
|
}
|
|
if (props.asTrigger) {
|
|
emit("trigger", event);
|
|
}
|
|
};
|
|
|
|
return {
|
|
t,
|
|
store,
|
|
router,
|
|
openWebPage,
|
|
isActive,
|
|
ariaLabel,
|
|
activePillClass,
|
|
activeIconClass,
|
|
activeLabelClass,
|
|
iconClass,
|
|
labelClass,
|
|
labelRef,
|
|
isTruncated,
|
|
tooltipText,
|
|
tooltipSide,
|
|
paywallDescriptionId,
|
|
paywallText,
|
|
hasIconComponent,
|
|
rootComponent,
|
|
rootProps,
|
|
rootClass,
|
|
onRootClick,
|
|
};
|
|
},
|
|
});
|
|
</script>
|