菜单 menu
导航组件。三层同源:无头内核给出解剖与状态机,Vue 组件与自定义元素只是它的两层外壳,行为完全一致。
示例
基础用法
collection 是条目的事实源:文本与禁用都写在数据里,trigger / positioner / content / item 由组件铺开
<script setup lang="ts">
import { ref } from "vue";
import { XhMenuRoot } from "@xihan-ui/vue";
const actions = [
{ value: "copy", label: "复制" },
{ value: "paste", label: "粘贴" },
// 禁用项会被方向键跳过,也选不中;separatorBefore 在它前面隔一道
{ value: "delete", label: "删除", disabled: true, separatorBefore: true },
];
const picked = ref("");
function onSelect(details: { value: string }): void {
picked.value = details.value;
}
</script>
<template>
<!-- 触发器的内容归作者,走 trigger 插槽 -->
<XhMenuRoot :collection="actions" @select="onSelect">
<template #trigger>操作</template>
</XhMenuRoot>
<span>最近选中:{{ picked || "(无)" }}</span>
</template>受控
传了 open 就由宿主说了算,组件只发 open-change 不自己改展开态
<script setup lang="ts">
import { ref } from "vue";
import {
XhMenuContent,
XhMenuItem,
XhMenuPositioner,
XhMenuRoot,
XhMenuTrigger,
} from "@xihan-ui/vue";
const open = ref(false);
</script>
<template>
<!-- 外部按钮直接改 open,菜单照样展开 -->
<button type="button" @click="open = !open">
{{ open ? "从外面收起" : "从外面展开" }}
</button>
<XhMenuRoot v-model:open="open">
<XhMenuTrigger>操作</XhMenuTrigger>
<XhMenuPositioner>
<XhMenuContent>
<XhMenuItem value="rename">重命名</XhMenuItem>
<XhMenuItem value="duplicate">创建副本</XhMenuItem>
</XhMenuContent>
</XhMenuPositioner>
</XhMenuRoot>
<span>当前:{{ open ? "展开" : "收起" }}</span>
</template>放置位与箭头
placement 只是首选位,空间不够时定位引擎会自动翻面;arrow 指回触发器
<script setup lang="ts">
import {
XhMenuArrow,
XhMenuContent,
XhMenuItem,
XhMenuPositioner,
XhMenuRoot,
XhMenuTrigger,
} from "@xihan-ui/vue";
</script>
<template>
<XhMenuRoot placement="right-start" :offset="12">
<XhMenuTrigger>贴右侧展开</XhMenuTrigger>
<XhMenuPositioner>
<XhMenuContent>
<XhMenuItem value="profile">个人资料</XhMenuItem>
<XhMenuItem value="settings">偏好设置</XhMenuItem>
<XhMenuItem value="logout">退出登录</XhMenuItem>
</XhMenuContent>
<!-- 箭头挂在 positioner 上,位置由引擎回填 -->
<XhMenuArrow />
</XhMenuPositioner>
</XhMenuRoot>
</template>语气
tone 决定条目高亮用哪族颜色;静止态看不出来,展开后悬停条目、或用方向键把焦点移上去才显现
<script setup lang="ts">
import { XhMenuRoot } from "@xihan-ui/vue";
const tones = ["brand", "neutral", "success", "warning", "danger", "info"] as const;
const actions = [
{ value: "copy", label: "复制" },
{ value: "rename", label: "重命名" },
{ value: "delete", label: "删除", separatorBefore: true },
];
</script>
<template>
<!-- 六个各自独立的菜单,逐个展开对比条目高亮底色 -->
<div style="display: flex; flex-wrap: wrap; gap: 8px">
<XhMenuRoot v-for="tone in tones" :key="tone" :collection="actions" :tone="tone">
<template #trigger>{{ tone }}</template>
</XhMenuRoot>
</div>
</template>尺寸
size 换的是条目的内边距、间距与字号;三档各挂一个菜单,逐个展开对比
<script setup lang="ts">
import { XhMenuRoot } from "@xihan-ui/vue";
const account = [
{ value: "profile", label: "个人资料" },
{ value: "settings", label: "偏好设置" },
{ value: "logout", label: "退出登录" },
];
</script>
<template>
<div style="display: flex; flex-wrap: wrap; gap: 8px">
<XhMenuRoot :collection="account" size="sm">
<template #trigger>sm</template>
</XhMenuRoot>
<!-- 不写 size 就是缺省档 -->
<XhMenuRoot :collection="account">
<template #trigger>缺省</template>
</XhMenuRoot>
<XhMenuRoot :collection="account" size="lg">
<template #trigger>lg</template>
</XhMenuRoot>
</div>
</template>条目里的图标与快捷键
条目内容归作者:前面挂图标、后面挂快捷键,皮肤把它们按 flex 排开
<script setup lang="ts">
import {
XhIcon,
XhMenuContent,
XhMenuItem,
XhMenuPositioner,
XhMenuRoot,
XhMenuSeparator,
XhMenuTrigger,
} from "@xihan-ui/vue";
// 三个图标共用一套描边呈现属性,stroke 取 currentColor,颜色随条目的文字色走
const strokeAttrs = {
"fill": "none",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
} as const;
const CopyIcon = {
name: "copy",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "rect", attrs: { x: "9", y: "9", width: "11", height: "11", rx: "2" } },
{ tag: "path", attrs: { d: "M5 15H4A2 2 0 0 1 2 13V4A2 2 0 0 1 4 2H13A2 2 0 0 1 15 4V5" } },
],
} as const;
const EditIcon = {
name: "edit",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [{ tag: "path", attrs: { d: "M4 20H8L19 9A2.8 2.8 0 0 0 15 5L4 16Z" } }],
} as const;
const TrashIcon = {
name: "trash",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "path", attrs: { d: "M4 7H20" } },
{ tag: "path", attrs: { d: "M10 11V17M14 11V17" } },
{ tag: "path", attrs: { d: "M6 7L7 20H17L18 7" } },
],
} as const;
// 快捷键提示推到条目末端,颜色压暗一档
const hintStyle = {
marginInlineStart: "auto",
color: "var(--xh-fg-muted)",
};
</script>
<template>
<XhMenuRoot>
<XhMenuTrigger>编辑</XhMenuTrigger>
<XhMenuPositioner>
<XhMenuContent>
<XhMenuItem value="copy">
<XhIcon :icon="CopyIcon" size="sm" />
<span>复制</span>
<span :style="hintStyle">Ctrl+C</span>
</XhMenuItem>
<XhMenuItem value="rename">
<XhIcon :icon="EditIcon" size="sm" />
<span>重命名</span>
<span :style="hintStyle">F2</span>
</XhMenuItem>
<XhMenuSeparator />
<XhMenuItem value="delete">
<XhIcon :icon="TrashIcon" size="sm" />
<span>删除</span>
<span :style="hintStyle">Del</span>
</XhMenuItem>
</XhMenuContent>
</XhMenuPositioner>
</XhMenuRoot>
</template>菜单里的非条目内容
content 里可以直接放任意节点;不是 item 就不进方向键行程,也选不中
<script setup lang="ts">
import {
XhMenuContent,
XhMenuItem,
XhMenuPositioner,
XhMenuRoot,
XhMenuSeparator,
XhMenuTrigger,
} from "@xihan-ui/vue";
</script>
<template>
<XhMenuRoot>
<XhMenuTrigger>账号</XhMenuTrigger>
<XhMenuPositioner>
<XhMenuContent>
<!-- 这一块是普通节点:方向键从首个条目起步,不会停在它上面 -->
<div style="display: grid; gap: 4px; padding: 8px 8px 6px">
<span style="font-weight: 600">曦寒</span>
<span style="font-size: 12px; color: var(--xh-fg-muted)">
已用 6.2 GB / 20 GB
</span>
</div>
<XhMenuSeparator />
<XhMenuItem value="profile">个人资料</XhMenuItem>
<XhMenuItem value="billing">账单与用量</XhMenuItem>
<XhMenuSeparator />
<XhMenuItem value="logout">退出登录</XhMenuItem>
</XhMenuContent>
</XhMenuPositioner>
</XhMenuRoot>
</template>条目自带的属性与事件
写在条目上的属性直接落到那一层 DOM:原生属性照样透传,自己的 click 与内部的选中处理并存
- (还没动过)
<script setup lang="ts">
import { ref } from "vue";
import {
XhMenuContent,
XhMenuItem,
XhMenuPositioner,
XhMenuRoot,
XhMenuTrigger,
} from "@xihan-ui/vue";
const trace = ref<string[]>([]);
function push(text: string): void {
trace.value = [text, ...trace.value].slice(0, 4);
}
function onSelect(details: { value: string }): void {
push(`菜单的 select:${details.value}`);
}
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; justify-items: start">
<XhMenuRoot @select="onSelect">
<XhMenuTrigger>导出</XhMenuTrigger>
<XhMenuPositioner>
<XhMenuContent>
<!-- title 是原生属性,悬停就出提示;@click 与内部的选中处理两边都会跑 -->
<XhMenuItem
value="csv"
title="逗号分隔,表格软件直接打得开"
@click="push('条目自己的 click:csv')"
>
导出 CSV
</XhMenuItem>
<XhMenuItem value="json" title="结构化数据,留给程序读">
导出 JSON
</XhMenuItem>
<XhMenuItem value="pdf" disabled title="当前视图不支持">
导出 PDF
</XhMenuItem>
</XhMenuContent>
</XhMenuPositioner>
</XhMenuRoot>
<ol style="display: grid; gap: 4px; margin: 0; padding-inline-start: 20px">
<li v-for="(line, index) in trace" :key="index">{{ line }}</li>
<li v-if="trace.length === 0">(还没动过)</li>
</ol>
</div>
</template>悬停展开
触发器与浮层各挂一对进出事件,进出各自延时;两边的延时都由宿主的定时器管
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
import {
XhMenuContent,
XhMenuItem,
XhMenuPositioner,
XhMenuRoot,
XhMenuSeparator,
XhMenuTrigger,
} from "@xihan-ui/vue";
type SetOpen = (next: boolean) => void;
const OPEN_DELAY = 120;
const CLOSE_DELAY = 240;
const picked = ref("");
let openTimer: ReturnType<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function enter(setOpen: SetOpen): void {
clearTimeout(closeTimer);
openTimer = setTimeout(() => setOpen(true), OPEN_DELAY);
}
// 指针从触发器挪到浮层要跨过一段空隙,收起延时就是给这段路留的余量
function leave(setOpen: SetOpen): void {
clearTimeout(openTimer);
closeTimer = setTimeout(() => setOpen(false), CLOSE_DELAY);
}
function stay(): void {
clearTimeout(closeTimer);
}
function onSelect(details: { value: string }): void {
picked.value = details.value;
}
onBeforeUnmount(() => {
clearTimeout(openTimer);
clearTimeout(closeTimer);
});
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; justify-items: start">
<XhMenuRoot v-slot="{ setOpen }" placement="bottom-start" :offset="6" @select="onSelect">
<XhMenuTrigger @pointerenter="enter(setOpen)" @pointerleave="leave(setOpen)">
更多操作
</XhMenuTrigger>
<XhMenuPositioner>
<XhMenuContent @pointerenter="stay" @pointerleave="leave(setOpen)">
<XhMenuItem value="rename">重命名</XhMenuItem>
<XhMenuItem value="duplicate">创建副本</XhMenuItem>
<XhMenuSeparator />
<XhMenuItem value="archive">归档</XhMenuItem>
</XhMenuContent>
</XhMenuPositioner>
</XhMenuRoot>
<span>最近选中:{{ picked || "(无)" }}</span>
</div>
</template>分组与标记位
组标题与组内条目用 role="group" 加 aria-labelledby 对上;中间包一层不影响方向键行程,条目里标记位与文字各占一段
<script setup lang="ts">
import { ref } from "vue";
import {
XhMenuContent,
XhMenuItem,
XhMenuPositioner,
XhMenuRoot,
XhMenuSeparator,
XhMenuTrigger,
} from "@xihan-ui/vue";
const groups = [
{
value: "density",
label: "行高",
items: [
{ value: "compact", label: "紧凑" },
{ value: "comfortable", label: "宽松" },
],
},
{
value: "panel",
label: "面板",
items: [
{ value: "sidebar", label: "侧栏" },
{ value: "inspector", label: "属性面板" },
],
},
];
const density = ref("comfortable");
const panels = ref<string[]>(["sidebar"]);
function checked(group: string, value: string): boolean {
return group === "density" ? density.value === value : panels.value.includes(value);
}
function onSelect(details: { value: string }): void {
if (details.value === "compact" || details.value === "comfortable") {
density.value = details.value;
return;
}
panels.value = panels.value.includes(details.value)
? panels.value.filter(v => v !== details.value)
: [...panels.value, details.value];
}
const labelStyle = {
padding: "6px 8px",
fontSize: "12px",
color: "var(--xh-fg-muted)",
};
// 标记位恒占一格,勾不勾都不推动后面的文字
const markStyle = {
flex: "none",
inlineSize: "14px",
};
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; justify-items: start">
<XhMenuRoot @select="onSelect">
<XhMenuTrigger>视图</XhMenuTrigger>
<XhMenuPositioner>
<XhMenuContent>
<template v-for="(g, index) in groups" :key="g.value">
<XhMenuSeparator v-if="index > 0" />
<div role="group" :aria-labelledby="`menu-group-${g.value}`">
<div :id="`menu-group-${g.value}`" :style="labelStyle">{{ g.label }}</div>
<XhMenuItem v-for="item in g.items" :key="item.value" :value="item.value">
<span :style="markStyle">{{ checked(g.value, item.value) ? "✓" : "" }}</span>
<span>{{ item.label }}</span>
</XhMenuItem>
</div>
</template>
</XhMenuContent>
</XhMenuPositioner>
</XhMenuRoot>
<span>
行高:{{ density === "compact" ? "紧凑" : "宽松" }};面板:{{
panels.length ? panels.length + " 个" : "都收起了"
}}
</span>
</div>
</template>产物
| 层 | 值 |
|---|---|
| 自定义元素 | <xh-menu> |
| Vue 组件 | XhMenuArrow XhMenuContent XhMenuItem XhMenuPositioner XhMenuRoot XhMenuSeparator XhMenuTrigger |
| 组合式函数 | useMenu |
| 状态机 | menuMachine |
| 皮肤 | @xihan-ui/styles/menu.css |
解剖
部件名即 data-part 属性值,也是皮肤的选择器。加粗的是必备部件,不渲染它组件不工作(Web Components 适配器会在诊断通道上报 wc.missing-part)。
data-scope="menu":trigger · positioner · content · item · separator · arrow
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
collection | MenuNode[] | 条目数据,显示文本与禁用的事实源。给了它,条目部件只需报 value。 缺省即回到「文本与禁用都写在条目部件上」的老路。 | |
open | boolean | 展开态,给定即受控;受控下内部不自改,只发 onOpenChange。 | |
defaultOpen | boolean | ||
placement | Placement | ||
offset | number | ||
loop | boolean | 方向键走到尽头是否回绕,默认 true。 | |
dir | Direction | 文字方向,默认 ltr。 | |
tone | Tone | 语气:brand / neutral / success / warning / danger / info,决定条目高亮用哪族颜色。 | |
size | Size | 尺寸:sm / md / lg,决定条目高度、内边距与字号档位。 | |
onOpenChange | (details: MenuOpenChangeDetails) => void | open 变化回调。 | |
onSelect | (details: MenuSelectDetails) => void | 条目被选中;菜单随之关闭。 |
状态机
状态:open · closed
事件:OPEN · TOGGLE · CLOSE · CONTROLLED.OPEN · CONTROLLED.CLOSE · ITEM.FOCUS · ITEM.LOST · ITEM.SELECT
判据:isOpenControlled
connect API
useMenu 产出的对象。getXxxProps() 铺到对应部件的宿主元素上,其余是可读状态与操作入口。
| 成员 | 类型 | 说明 |
|---|---|---|
open | boolean | |
collection | readonly MenuNodeMeta[] | collection 推出的条目元信息,按数据顺序排列;没给 collection 即空数组。 |
focusedValue | string | null | 焦点锚点;收起时为 null。 |
setOpen | (next: boolean) => void | |
getTriggerProps | () => T['button'] | |
getPositionerProps | () => T['element'] | |
getContentProps | () => T['element'] | |
getItemProps | (props: MenuItemProps) => T['element'] | |
getSeparatorProps | () => T['element'] | |
getArrowProps | () => T['element'] |
键盘
规格出处:W3C APG
| 按键 | 生效条件 | 行为 |
|---|---|---|
Enter / Space / ArrowDown | focus in trigger | 展开菜单并把焦点落到首个可用条目 |
ArrowUp | focus in trigger | 展开菜单并把焦点落到末个可用条目 |
ArrowDown | open, focus in content | 焦点移到下一个条目(禁用项跳过、尽头按 loop 回绕) |
ArrowUp | open, focus in content | 焦点移到上一个条目(禁用项跳过、尽头按 loop 回绕) |
Home | open, focus in content | 焦点移到首个可用条目 |
End | open, focus in content | 焦点移到末个可用条目 |
Enter / Space | focus in item, not disabled | 派发选中详情并关闭菜单,焦点归还 trigger |
Escape | open | 关闭菜单并把焦点归还 trigger |
Tab / Shift+Tab | open | 关闭菜单,焦点不归还 trigger,按 Tab 序列自然离开 |
