导航菜单 navigation-menu
导航组件。三层同源:无头内核给出解剖与状态机,Vue 组件与自定义元素只是它的两层外壳,行为完全一致。
示例
基础用法
入口写在 collection 里、面板内容走 panel 插槽;面板落在同一个 li 里、紧跟 trigger 之后,展开时按 Tab 就走得进去,里面的条目是链接不是命令,点了就跳走
<script setup lang="ts">
import { XhNavigationMenuLink, XhNavigationMenuRoot } from "@xihan-ui/vue";
const entries = [
{ value: "products", label: "产品" },
{ value: "docs", label: "文档" },
{ value: "about", label: "关于" },
];
const panels: Record<string, Array<{ href: string; label: string }>> = {
products: [
{ href: "#/products/runtime", label: "运行时内核" },
{ href: "#/products/vue", label: "Vue 适配器" },
{ href: "#/products/wc", label: "Web Components 适配器" },
],
docs: [
{ href: "#/docs/guide", label: "上手指南" },
{ href: "#/docs/anatomy", label: "部件解剖" },
],
about: [{ href: "#/about/team", label: "团队" }],
};
// 指向当前页面的那一条:拿它比对即可
const currentHref = "#/docs/guide";
</script>
<template>
<!-- 面板是绝对定位的浮层,这里给下方留出它落位的空间 -->
<div style="inline-size: 100%; padding-block-end: 180px">
<XhNavigationMenuRoot :collection="entries">
<template #panel="node">
<XhNavigationMenuLink
v-for="l in panels[node.value]"
:key="l.href"
:href="l.href"
:current="l.href === currentHref"
>
{{ l.label }}
</XhNavigationMenuLink>
</template>
</XhNavigationMenuRoot>
</div>
</template>受控
传了 value 就由宿主说了算,null 表示都收起;v-model:value 是它的语法糖
<script setup lang="ts">
import { ref } from "vue";
import {
XhButton,
XhNavigationMenuContent,
XhNavigationMenuItem,
XhNavigationMenuLink,
XhNavigationMenuList,
XhNavigationMenuRoot,
XhNavigationMenuTrigger,
} from "@xihan-ui/vue";
const groups = [
{
value: "solution",
label: "解决方案",
links: [
{ href: "#/solution/saas", label: "多租户 SaaS" },
{ href: "#/solution/portal", label: "门户站点" },
],
},
{
value: "support",
label: "支持",
links: [
{ href: "#/support/faq", label: "常见问题" },
{ href: "#/support/contact", label: "联系我们" },
],
},
];
const open = ref<string | null>(null);
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 150px">
<XhNavigationMenuRoot v-model:value="open">
<XhNavigationMenuList>
<XhNavigationMenuItem v-for="g in groups" :key="g.value">
<XhNavigationMenuTrigger :value="g.value">
{{ g.label }}
</XhNavigationMenuTrigger>
<XhNavigationMenuContent :value="g.value">
<XhNavigationMenuLink
v-for="l in g.links"
:key="l.href"
:href="l.href"
>
{{ l.label }}
</XhNavigationMenuLink>
</XhNavigationMenuContent>
</XhNavigationMenuItem>
</XhNavigationMenuList>
</XhNavigationMenuRoot>
<div style="display: flex; align-items: center; gap: 8px; margin-block-start: 12px">
<XhButton variant="outline" @click="open = 'support'">展开「支持」</XhButton>
<XhButton variant="outline" @click="open = null">全部收起</XhButton>
<span>展开的面板:{{ open ?? "(都收着)" }}</span>
</div>
</div>
</template>展开延时
delay-duration 是悬停多久才展开,防的是指针横穿导航时一路闪出面板;skip-delay-duration 是收起后的静默窗口,窗口内再碰任意入口直接展开
<script setup lang="ts">
import { XhNavigationMenuLink, XhNavigationMenuRoot } from "@xihan-ui/vue";
const entries = [
{ value: "cloud", label: "云服务" },
{ value: "data", label: "数据" },
{ value: "ai", label: "智能" },
];
const panels: Record<string, Array<{ href: string; label: string }>> = {
cloud: [
{ href: "#/cloud/host", label: "云主机" },
{ href: "#/cloud/storage", label: "对象存储" },
],
data: [
{ href: "#/data/warehouse", label: "数据仓库" },
{ href: "#/data/pipeline", label: "数据管道" },
],
ai: [{ href: "#/ai/agent", label: "智能体" }],
};
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 150px">
<XhNavigationMenuRoot
:collection="entries"
:delay-duration="600"
:skip-delay-duration="800"
>
<template #panel="node">
<XhNavigationMenuLink
v-for="l in panels[node.value]"
:key="l.href"
:href="l.href"
>
{{ l.label }}
</XhNavigationMenuLink>
</template>
</XhNavigationMenuRoot>
</div>
</template>竖排
orientation="vertical" 把入口排成一列、面板改从侧边长出来,方向键随之改收上下键
<script setup lang="ts">
import { XhNavigationMenuLink, XhNavigationMenuRoot } from "@xihan-ui/vue";
const entries = [
{ value: "system", label: "系统管理" },
{ value: "monitor", label: "运行监控" },
{ value: "tool", label: "系统工具" },
];
const panels: Record<string, Array<{ href: string; label: string }>> = {
system: [
{ href: "#/system/user", label: "用户" },
{ href: "#/system/role", label: "角色" },
],
monitor: [
{ href: "#/monitor/online", label: "在线用户" },
{ href: "#/monitor/job", label: "定时任务" },
],
tool: [{ href: "#/tool/codegen", label: "代码生成" }],
};
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 40px">
<XhNavigationMenuRoot
:collection="entries"
orientation="vertical"
style="inline-size: 180px"
>
<template #panel="node">
<XhNavigationMenuLink
v-for="l in panels[node.value]"
:key="l.href"
:href="l.href"
>
{{ l.label }}
</XhNavigationMenuLink>
</template>
</XhNavigationMenuRoot>
</div>
</template>语气
tone 换的是入口的高亮底与指示条、当前链接的文字色,静止态一样:悬停到入口上、或用方向键把焦点移过去才显现
<script setup lang="ts">
import { XhNavigationMenuLink, XhNavigationMenuRoot } from "@xihan-ui/vue";
// 每档语气一个 root,root 里就一个入口,入口名即语气名
const tones = ["brand", "neutral", "success", "warning", "danger", "info"].map(
(tone) => ({ tone, entries: [{ value: tone, label: tone }] }),
);
</script>
<template>
<!-- 面板是绝对定位的浮层,这里给下方留出它落位的空间 -->
<div
style="inline-size: 100%; display: flex; flex-wrap: wrap; gap: 8px; padding-block-end: 180px"
>
<XhNavigationMenuRoot
v-for="t in tones"
:key="t.tone"
:collection="t.entries"
:tone="t.tone"
>
<template #panel>
<!-- 当前链接的文字色也吃这一档语气 -->
<XhNavigationMenuLink href="#/docs/guide" current>
上手指南
</XhNavigationMenuLink>
<XhNavigationMenuLink href="#/docs/anatomy">
部件解剖
</XhNavigationMenuLink>
</template>
</XhNavigationMenuRoot>
</div>
</template>尺寸
size 一档换掉入口的高度、内边距与字号,写在 root 上、面板里的链接一并跟着变
<script setup lang="ts">
import { XhNavigationMenuLink, XhNavigationMenuRoot } from "@xihan-ui/vue";
const sizes = [
{ value: "sm", label: "sm" },
{ value: undefined, label: "缺省" },
{ value: "lg", label: "lg" },
];
const entries = [
{ value: "products", label: "产品" },
{ value: "docs", label: "文档" },
];
const panels: Record<string, Array<{ href: string; label: string }>> = {
products: [
{ href: "#/products/runtime", label: "运行时内核" },
{ href: "#/products/vue", label: "Vue 适配器" },
],
docs: [{ href: "#/docs/guide", label: "上手指南" }],
};
</script>
<template>
<!-- 面板是绝对定位的浮层,这里给下方留出它落位的空间 -->
<div
style="
inline-size: 100%;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 24px;
padding-block-end: 180px;
"
>
<div v-for="s in sizes" :key="s.label" style="display: grid; gap: 6px">
<span>{{ s.label }}</span>
<XhNavigationMenuRoot :collection="entries" :size="s.value">
<template #panel="node">
<XhNavigationMenuLink
v-for="l in panels[node.value]"
:key="l.href"
:href="l.href"
>
{{ l.label }}
</XhNavigationMenuLink>
</template>
</XhNavigationMenuRoot>
</div>
</div>
</template>直达入口
没有下级的去处不必套面板:数据里写了 href 的那一项铺成一条 link,它不进方向键那一组(那一组只认 trigger),按 Tab 一样到得了
<script setup lang="ts">
import { XhNavigationMenuLink, XhNavigationMenuRoot } from "@xihan-ui/vue";
const entries = [
{ value: "products", label: "产品" },
{ value: "docs", label: "文档" },
// 直达入口:给了 href 就没有 trigger 也没有面板,点了就跳走
{ value: "changelog", label: "更新日志", href: "#/changelog" },
];
const panels: Record<string, Array<{ href: string; label: string }>> = {
products: [
{ href: "#/products/runtime", label: "运行时内核" },
{ href: "#/products/vue", label: "Vue 适配器" },
],
docs: [
{ href: "#/docs/guide", label: "上手指南" },
{ href: "#/docs/anatomy", label: "部件解剖" },
],
};
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 150px">
<XhNavigationMenuRoot :collection="entries">
<template #panel="node">
<XhNavigationMenuLink
v-for="l in panels[node.value]"
:key="l.href"
:href="l.href"
>
{{ l.label }}
</XhNavigationMenuLink>
</template>
</XhNavigationMenuRoot>
</div>
</template>共享面板外壳
面板整批塞进 viewport 后落位归外壳管:几个入口的面板落在同一处,宽窄不同也不再各贴各的入口
<script setup lang="ts">
import {
XhNavigationMenuContent,
XhNavigationMenuItem,
XhNavigationMenuLink,
XhNavigationMenuList,
XhNavigationMenuRoot,
XhNavigationMenuTrigger,
XhNavigationMenuViewport,
} from "@xihan-ui/vue";
const groups = [
{
value: "products",
label: "产品",
links: [
{ href: "#/products/runtime", label: "运行时内核" },
{ href: "#/products/vue", label: "Vue 适配器" },
{ href: "#/products/wc", label: "Web Components 适配器" },
],
},
{
value: "docs",
label: "文档",
links: [{ href: "#/docs/guide", label: "上手指南" }],
},
{
value: "about",
label: "关于",
links: [
{ href: "#/about/team", label: "团队" },
{ href: "#/about/contact", label: "联系我们" },
],
},
];
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 180px">
<XhNavigationMenuRoot>
<XhNavigationMenuList>
<XhNavigationMenuItem v-for="g in groups" :key="g.value">
<XhNavigationMenuTrigger :value="g.value">
{{ g.label }}
</XhNavigationMenuTrigger>
</XhNavigationMenuItem>
</XhNavigationMenuList>
<!-- 外壳放在 root 内、list 之后;里面装哪一份面板由各自的 value 决定。
面板不再住在各自那一项里,按 Tab 走进面板要先走完全部入口 -->
<XhNavigationMenuViewport>
<XhNavigationMenuContent
v-for="g in groups"
:key="g.value"
:value="g.value"
>
<XhNavigationMenuLink
v-for="l in g.links"
:key="l.href"
:href="l.href"
>
{{ l.label }}
</XhNavigationMenuLink>
</XhNavigationMenuContent>
</XhNavigationMenuViewport>
</XhNavigationMenuRoot>
</div>
</template>默认展开项
defaultValue 只定首帧展开哪一项,之后照常由交互接管;指针移开、Escape 或点回入口都收得起来
<script setup lang="ts">
import { XhNavigationMenuLink, XhNavigationMenuRoot } from "@xihan-ui/vue";
const entries = [
{ value: "guide", label: "指南" },
{ value: "components", label: "组件" },
];
const panels: Record<string, Array<{ href: string; label: string }>> = {
guide: [
{ href: "#/guide/install", label: "安装" },
{ href: "#/guide/quick-start", label: "快速开始" },
],
components: [
{ href: "#/components/menu", label: "菜单" },
{ href: "#/components/toolbar", label: "工具栏" },
],
};
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 150px">
<!-- 首帧就展开「指南」,指示条也一并落在它下面 -->
<XhNavigationMenuRoot :collection="entries" default-value="guide">
<template #panel="node">
<XhNavigationMenuLink
v-for="l in panels[node.value]"
:key="l.href"
:href="l.href"
>
{{ l.label }}
</XhNavigationMenuLink>
</template>
</XhNavigationMenuRoot>
</div>
</template>收窄成一列图标
竖排时面板本就从入口侧边长出来;收窄只是把文字从入口里撤掉、把它挪进面板,指针停上去才露出来
<script setup lang="ts">
import { ref } from "vue";
import {
XhButton,
XhIcon,
XhNavigationMenuContent,
XhNavigationMenuIndicator,
XhNavigationMenuItem,
XhNavigationMenuLink,
XhNavigationMenuList,
XhNavigationMenuRoot,
XhNavigationMenuTrigger,
} from "@xihan-ui/vue";
// 三个图标共用一套描边呈现属性,stroke 取 currentColor
const strokeAttrs = {
"fill": "none",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
} as const;
const UsersIcon = {
name: "users",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "circle", attrs: { cx: "9", cy: "8", r: "3" } },
{ tag: "path", attrs: { d: "M3 20A6 6 0 0 1 15 20" } },
{ tag: "path", attrs: { d: "M17 11A3 3 0 0 0 17 5" } },
],
} as const;
const PulseIcon = {
name: "pulse",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [{ tag: "path", attrs: { d: "M3 12H7L10 5L14 19L17 12H21" } }],
} as const;
const ToolIcon = {
name: "tool",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "path", attrs: { d: "M14 6A4 4 0 1 0 18 10L20 8V4H16L14 6Z" } },
{ tag: "path", attrs: { d: "M13 11L5 19L7 21L15 13" } },
],
} as const;
const groups = [
{
value: "system",
label: "系统管理",
icon: UsersIcon,
links: [
{ href: "#/system/user", label: "用户" },
{ href: "#/system/role", label: "角色" },
],
},
{
value: "monitor",
label: "运行监控",
icon: PulseIcon,
links: [
{ href: "#/monitor/online", label: "在线用户" },
{ href: "#/monitor/job", label: "定时任务" },
],
},
{
value: "tool",
label: "系统工具",
icon: ToolIcon,
links: [{ href: "#/tool/codegen", label: "代码生成" }],
},
];
const collapsed = ref(true);
</script>
<template>
<div style="inline-size: 100%; display: flex; gap: 16px; padding-block-end: 40px">
<XhNavigationMenuRoot
orientation="vertical"
:style="{ inlineSize: collapsed ? '56px' : '190px' }"
>
<XhNavigationMenuList>
<XhNavigationMenuItem v-for="g in groups" :key="g.value">
<XhNavigationMenuTrigger
:value="g.value"
:style="{ justifyContent: collapsed ? 'center' : 'flex-start' }"
>
<XhIcon :icon="g.icon" size="sm" :label="collapsed ? g.label : undefined" />
<span v-if="!collapsed">{{ g.label }}</span>
</XhNavigationMenuTrigger>
<XhNavigationMenuContent :value="g.value">
<span
v-if="collapsed"
style="padding: 2px 8px; color: var(--xh-fg-muted); font-size: 12px"
>
{{ g.label }}
</span>
<XhNavigationMenuLink v-for="l in g.links" :key="l.href" :href="l.href">
{{ l.label }}
</XhNavigationMenuLink>
</XhNavigationMenuContent>
</XhNavigationMenuItem>
<XhNavigationMenuIndicator />
</XhNavigationMenuList>
</XhNavigationMenuRoot>
<XhButton size="sm" variant="outline" @click="collapsed = !collapsed">
{{ collapsed ? "展开侧栏" : "收窄侧栏" }}
</XhButton>
</div>
</template>产物
| 层 | 值 |
|---|---|
| 自定义元素 | <xh-navigation-menu> |
| Vue 组件 | XhNavigationMenuContent XhNavigationMenuIndicator XhNavigationMenuItem XhNavigationMenuLink XhNavigationMenuList XhNavigationMenuRoot XhNavigationMenuTrigger XhNavigationMenuViewport |
| 组合式函数 | useNavigationMenu |
| 状态机 | navigationMenuMachine |
| 皮肤 | @xihan-ui/styles/navigation-menu.css |
解剖
部件名即 data-part 属性值,也是皮肤的选择器。加粗的是必备部件,不渲染它组件不工作(Web Components 适配器会在诊断通道上报 wc.missing-part)。
data-scope="navigation-menu":root · list · item · trigger · content · link · indicator · viewport
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
collection | NavigationMenuNode[] | 入口数据,入口文本与禁用的事实源。给了它,trigger 部件只需报 value。 缺省即回到「文本与禁用都写在部件上」的老路。 | |
value | string | null | 当前展开项,给定即受控;null 表示都收起。 | |
defaultValue | string | null | ||
orientation | Orientation | 方向键轴向,默认 horizontal。 | |
delayDuration | number | 悬停/聚焦到 trigger 后等多久才展开,默认 200ms。 | |
skipDelayDuration | number | 收起之后的静默窗口,默认 300ms;窗口内再碰任意 trigger 直接展开。 | |
dir | Direction | 文字方向,默认 ltr。 | |
loop | boolean | 方向键走到尽头是否回绕,默认 true。 | |
translations | Partial<NavigationMenuTranslations> | ||
tone | Tone | 语气:brand / neutral / success / warning / danger / info,决定用哪族颜色。 | |
size | Size | 尺寸:sm / md / lg。 | |
onValueChange | (details: NavigationMenuValueChangeDetails) => void | value 变化回调。 |
状态机
状态:idle · opening · skipping
事件:TRIGGER.POINTER · TRIGGER.FOCUS · TRIGGER.TOGGLE · DISMISS · VALUE.SET · after.delayDuration · after.skipDelayDuration
判据:hasValue · isCurrent · shouldKeepOpen
connect API
useNavigationMenu 产出的对象。getXxxProps() 铺到对应部件的宿主元素上,其余是可读状态与操作入口。
| 成员 | 类型 | 说明 |
|---|---|---|
value | string | null | 当前展开的那一项;都收起时为 null。 |
collection | readonly NavigationMenuNodeMeta[] | collection 推出的入口元信息,按数据顺序排列;没给 collection 即空数组。 |
open | boolean | 有没有面板展开着。 |
isOpen | (value: string) => boolean | |
setValue | (next: string | null) => void | |
getRootProps | () => T['element'] | |
getListProps | () => T['element'] | |
getItemProps | () => T['element'] | |
getTriggerProps | (props: NavigationMenuTriggerProps) => T['button'] | |
getContentProps | (props: NavigationMenuContentProps) => T['element'] | |
getLinkProps | (props: NavigationMenuLinkProps) => T['element'] | |
getIndicatorProps | () => T['element'] | |
getViewportProps | () => T['element'] |
键盘
规格出处:W3C APG
| 按键 | 生效条件 | 行为 |
|---|---|---|
ArrowRight / ArrowDown | focus in trigger, 按键与 orientation 同轴 | 焦点移到下一个 trigger(禁用项跳过、尽头按 loop 回绕);随后的自动展开走 delayDuration |
ArrowLeft / ArrowUp | focus in trigger, 按键与 orientation 同轴 | 焦点移到上一个 trigger |
Home | focus in trigger | 焦点移到首个可停留 trigger |
End | focus in trigger | 焦点移到末个可停留 trigger |
Enter / Space | focus in trigger, not disabled | 立即展开对应面板(不走 delayDuration);面板是自动弹出来的那一次不收起,再按一次才收起 |
Escape | open | 收起面板并把焦点归还对应 trigger;静默窗口内这一次归还不会把面板重新弹出来 |
Tab / Shift+Tab | open, focus in trigger | 走进展开的面板:面板就在 trigger 之后,收起的面板带 hidden 因而被整个跳过 |
