菜单栏 menubar
导航组件。三层同源:无头内核给出解剖与状态机,Vue 组件与自定义元素只是它的两层外壳,行为完全一致。
示例
基础用法
collection 是入口与条目的事实源:顶层节点铺成一排入口,它的 items 铺成那张菜单里的条目
<script setup lang="ts">
import { ref } from "vue";
import { XhMenubarRoot } from "@xihan-ui/vue";
const menus = [
{
value: "file",
label: "文件",
items: [
{ value: "new", label: "新建" },
{ value: "open", label: "打开" },
{ value: "close", label: "关闭", disabled: true },
],
},
{
value: "edit",
label: "编辑",
items: [
{ value: "undo", label: "撤销" },
{ value: "redo", label: "重做" },
],
},
{
value: "view",
label: "视图",
items: [
{ value: "zoom-in", label: "放大" },
{ value: "zoom-out", label: "缩小" },
],
},
];
const picked = ref("");
function onSelect(details: { menu: string; value: string }): void {
picked.value = `${details.menu} / ${details.value}`;
}
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; justify-items: start">
<XhMenubarRoot :collection="menus" @select="onSelect" />
<span>最近选中:{{ picked || "(无)" }}</span>
</div>
</template>受控
value 是当前展开的那一项,null 表示都收起;给了它就由宿主说了算
<script setup lang="ts">
import { ref } from "vue";
import {
XhMenubarContent,
XhMenubarItem,
XhMenubarItemText,
XhMenubarPositioner,
XhMenubarRoot,
XhMenubarTrigger,
} from "@xihan-ui/vue";
const value = ref<string | null>(null);
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; justify-items: start">
<XhMenubarRoot v-model:value="value">
<XhMenubarTrigger value="file">文件</XhMenubarTrigger>
<XhMenubarTrigger value="help">帮助</XhMenubarTrigger>
<XhMenubarPositioner value="file">
<XhMenubarContent>
<XhMenubarItem value="save">
<XhMenubarItemText>保存</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarContent>
</XhMenubarPositioner>
<XhMenubarPositioner value="help">
<XhMenubarContent>
<XhMenubarItem value="about">
<XhMenubarItemText>关于</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarContent>
</XhMenubarPositioner>
</XhMenubarRoot>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap">
<button type="button" @click="value = 'file'">展开「文件」</button>
<button type="button" @click="value = 'help'">展开「帮助」</button>
<button type="button" @click="value = null">全部收起</button>
<span>当前:{{ value ?? "(都收起)" }}</span>
</div>
</div>
</template>分组与标记位
group 用 value 跟自己的 group-label 配对,item-indicator 是纯装饰的勾选位
<script setup lang="ts">
import { ref } from "vue";
import {
XhMenubarContent,
XhMenubarGroup,
XhMenubarGroupLabel,
XhMenubarItem,
XhMenubarItemIndicator,
XhMenubarItemText,
XhMenubarPositioner,
XhMenubarRoot,
XhMenubarSeparator,
XhMenubarTrigger,
} from "@xihan-ui/vue";
const theme = ref("light");
function onSelect(details: { menu: string; value: string }): void {
if (details.menu === "view") theme.value = details.value;
}
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; justify-items: start">
<XhMenubarRoot @select="onSelect">
<XhMenubarTrigger value="view">视图</XhMenubarTrigger>
<XhMenubarPositioner value="view">
<XhMenubarContent>
<XhMenubarGroup value="theme">
<XhMenubarGroupLabel>主题</XhMenubarGroupLabel>
<XhMenubarItem value="light">
<XhMenubarItemIndicator>
{{ theme === "light" ? "✓" : "" }}
</XhMenubarItemIndicator>
<XhMenubarItemText>浅色</XhMenubarItemText>
</XhMenubarItem>
<XhMenubarItem value="dark">
<XhMenubarItemIndicator>
{{ theme === "dark" ? "✓" : "" }}
</XhMenubarItemIndicator>
<XhMenubarItemText>深色</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarGroup>
<XhMenubarSeparator />
<XhMenubarGroup value="panel">
<XhMenubarGroupLabel>面板</XhMenubarGroupLabel>
<XhMenubarItem value="sidebar">
<XhMenubarItemText>侧栏</XhMenubarItemText>
</XhMenubarItem>
<XhMenubarItem value="terminal">
<XhMenubarItemText>终端</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarGroup>
</XhMenubarContent>
</XhMenubarPositioner>
</XhMenubarRoot>
<span>当前主题:{{ theme === "light" ? "浅色" : "深色" }}</span>
</div>
</template>语气
tone 换的是高亮底色,静止态一样:悬停到 trigger 上、或展开菜单后把焦点移到条目上才显现
<script setup lang="ts">
import { XhMenubarRoot } from "@xihan-ui/vue";
const tones = [
{ value: "brand", label: "brand(缺省)" },
{ value: "neutral", label: "neutral" },
{ value: "success", label: "success" },
{ value: "warning", label: "warning" },
{ value: "danger", label: "danger" },
{ value: "info", label: "info" },
];
const menus = [
{
value: "file",
label: "文件",
items: [
{ value: "new", label: "新建" },
{ value: "open", label: "打开" },
{ value: "save", label: "保存" },
],
},
{
value: "edit",
label: "编辑",
items: [
{ value: "undo", label: "撤销" },
{ value: "redo", label: "重做" },
],
},
];
</script>
<template>
<!-- 菜单浮层往下落位,给容器底部留出它展开的空间 -->
<div style="inline-size: 100%; display: grid; gap: 8px; padding-block-end: 160px">
<div
v-for="t in tones"
:key="t.value"
style="display: flex; align-items: center; gap: 12px"
>
<span style="inline-size: 120px; flex: none">{{ t.label }}</span>
<XhMenubarRoot :tone="t.value" :collection="menus" />
</div>
</div>
</template>尺寸
size 一档换掉 trigger 与菜单条目的字号与内边距,写在 root 上、浮层里的条目一并跟着变
<script setup lang="ts">
import { XhMenubarRoot } from "@xihan-ui/vue";
const sizes = [
{ value: "sm", label: "sm" },
{ value: undefined, label: "缺省" },
{ value: "lg", label: "lg" },
];
const menus = [
{
value: "file",
label: "文件",
items: [
{ value: "new", label: "新建" },
{ value: "open", label: "打开" },
],
},
{
value: "view",
label: "视图",
items: [
{ value: "zoom-in", label: "放大" },
{ value: "zoom-out", label: "缩小" },
],
},
];
</script>
<template>
<!-- 菜单浮层往下落位,给容器底部留出它展开的空间 -->
<div style="inline-size: 100%; display: grid; gap: 12px; padding-block-end: 180px">
<div
v-for="s in sizes"
:key="s.label"
style="display: flex; align-items: center; gap: 12px"
>
<span style="inline-size: 60px; flex: none">{{ s.label }}</span>
<XhMenubarRoot :size="s.value" :collection="menus" />
</div>
</div>
</template>竖排菜单栏
orientation 决定主轴:竖排时上下键在入口之间走,左右键改为展开本项的菜单
<script setup lang="ts">
import { XhMenubarRoot } from "@xihan-ui/vue";
const menus = [
{
value: "file",
label: "文件",
items: [
{ value: "new", label: "新建" },
{ value: "open", label: "打开" },
{ value: "save", label: "保存" },
],
},
{
value: "edit",
label: "编辑",
items: [
{ value: "undo", label: "撤销" },
{ value: "redo", label: "重做" },
],
},
{ value: "help", label: "帮助", items: [{ value: "about", label: "关于" }] },
];
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 60px">
<!-- 竖排时菜单该从入口侧边长出来,placement 一并改掉 -->
<XhMenubarRoot
orientation="vertical"
placement="right-start"
:offset="6"
:collection="menus"
style="inline-size: 160px"
/>
</div>
</template>入口与条目的图标
图标是插槽里的普通节点:入口里排在文字前,条目里排在 item-text 前,逐项自己写
<script setup lang="ts">
import {
XhIcon,
XhMenubarContent,
XhMenubarItem,
XhMenubarItemText,
XhMenubarPositioner,
XhMenubarRoot,
XhMenubarSeparator,
XhMenubarTrigger,
} from "@xihan-ui/vue";
// 描边取 currentColor,图标颜色随入口与条目当下的文字色走
const strokeAttrs = {
"fill": "none",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
} as const;
const FileIcon = {
name: "file",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "path", attrs: { d: "M13 3H7A2 2 0 0 0 5 5V19A2 2 0 0 0 7 21H17A2 2 0 0 0 19 19V9Z" } },
{ tag: "path", attrs: { d: "M13 3V9H19" } },
],
} 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 PlusIcon = {
name: "plus",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [{ tag: "path", attrs: { d: "M12 5V19M5 12H19" } }],
} as const;
const FolderIcon = {
name: "folder",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "path", attrs: { d: "M3 7A2 2 0 0 1 5 5H9L11 8H19A2 2 0 0 1 21 10V17A2 2 0 0 1 19 19H5A2 2 0 0 1 3 17Z" } },
],
} as const;
const SaveIcon = {
name: "save",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "path", attrs: { d: "M5 4H16L20 8V19A1 1 0 0 1 19 20H5A1 1 0 0 1 4 19V5A1 1 0 0 1 5 4Z" } },
{ tag: "path", attrs: { d: "M8 4V9H15" } },
],
} as const;
</script>
<template>
<div style="inline-size: 100%; padding-block-end: 160px">
<XhMenubarRoot>
<XhMenubarTrigger value="file">
<XhIcon :icon="FileIcon" size="sm" />
文件
</XhMenubarTrigger>
<XhMenubarTrigger value="edit">
<XhIcon :icon="EditIcon" size="sm" />
编辑
</XhMenubarTrigger>
<XhMenubarPositioner value="file">
<XhMenubarContent>
<XhMenubarItem value="new">
<XhIcon :icon="PlusIcon" size="sm" />
<XhMenubarItemText>新建</XhMenubarItemText>
</XhMenubarItem>
<XhMenubarItem value="open">
<XhIcon :icon="FolderIcon" size="sm" />
<XhMenubarItemText>打开</XhMenubarItemText>
</XhMenubarItem>
<XhMenubarSeparator />
<XhMenubarItem value="save">
<XhIcon :icon="SaveIcon" size="sm" />
<XhMenubarItemText>保存</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarContent>
</XhMenubarPositioner>
<XhMenubarPositioner value="edit">
<XhMenubarContent>
<XhMenubarItem value="undo">
<XhMenubarItemText>撤销</XhMenubarItemText>
</XhMenubarItem>
<XhMenubarItem value="redo">
<XhMenubarItemText>重做</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarContent>
</XhMenubarPositioner>
</XhMenubarRoot>
</div>
</template>禁用
禁用走 aria-disabled 而非原生 disabled:禁用的入口仍聚焦得上、仍是方向键的起点,只是展不开菜单
<script setup lang="ts">
import { ref } from "vue";
import { XhMenubarRoot, XhSwitch } from "@xihan-ui/vue";
const menus = [
{
value: "file",
label: "文件",
items: [
{ value: "new", label: "新建" },
{ value: "open", label: "打开" },
],
},
// 单项禁用:整条没锁时,也只有这一项展不开
{
value: "edit",
label: "编辑",
disabled: true,
items: [{ value: "undo", label: "撤销" }],
},
{ value: "help", label: "帮助", items: [{ value: "about", label: "关于" }] },
];
const locked = ref(false);
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; padding-block-end: 140px">
<XhMenubarRoot :disabled="locked" :collection="menus" />
<label style="display: flex; align-items: center; gap: 8px">
<XhSwitch v-model:checked="locked" />
整条禁用(展开与选中都不再发生)
</label>
</div>
</template>装不下就收进「更多」
宿主自己观测容器宽度,一次收起一个入口直到这排不再溢出;收起来的那几张菜单在「更多」里各占一组
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import {
XhButton,
XhMenubarContent,
XhMenubarGroup,
XhMenubarGroupLabel,
XhMenubarItem,
XhMenubarItemText,
XhMenubarPositioner,
XhMenubarRoot,
XhMenubarTrigger,
} from "@xihan-ui/vue";
const menus = [
{
value: "file",
label: "文件",
items: [
{ value: "new", label: "新建" },
{ value: "open", label: "打开" },
],
},
{
value: "edit",
label: "编辑",
items: [
{ value: "undo", label: "撤销" },
{ value: "redo", label: "重做" },
],
},
{
value: "view",
label: "视图",
items: [
{ value: "zoom-in", label: "放大" },
{ value: "zoom-out", label: "缩小" },
],
},
{
value: "insert",
label: "插入",
items: [
{ value: "image", label: "图片" },
{ value: "table", label: "表格" },
],
},
{
value: "format",
label: "格式",
items: [
{ value: "bold", label: "加粗" },
{ value: "italic", label: "倾斜" },
],
},
{ value: "tools", label: "工具", items: [{ value: "spell", label: "拼写检查" }] },
{ value: "help", label: "帮助", items: [{ value: "about", label: "关于" }] },
];
const widths = [560, 380, 240];
const boxRef = ref<HTMLElement | null>(null);
const boxWidth = ref(560);
const visible = ref(menus.length);
const shown = computed(() => menus.slice(0, visible.value));
const folded = computed(() => menus.slice(visible.value));
const picked = ref("");
let observer: ResizeObserver | undefined;
let reflowing = false;
// 先全铺开,再一次收一个,直到这排不再溢出
async function reflow(): Promise<void> {
const box = boxRef.value;
if (!box || reflowing) return;
reflowing = true;
visible.value = menus.length;
await nextTick();
while (visible.value > 1 && box.scrollWidth > box.clientWidth + 1) {
visible.value -= 1;
await nextTick();
}
reflowing = false;
}
function onSelect(details: { menu: string; value: string }): void {
picked.value = `${details.menu} / ${details.value}`;
}
onMounted(() => {
const box = boxRef.value;
if (!box) return;
observer = new ResizeObserver(() => void reflow());
observer.observe(box);
});
onBeforeUnmount(() => observer?.disconnect());
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px; justify-items: start">
<div style="display: flex; flex-wrap: wrap; gap: 8px">
<XhButton
v-for="w in widths"
:key="w"
size="sm"
:variant="boxWidth === w ? 'solid' : 'outline'"
@click="boxWidth = w"
>
{{ w }} 像素
</XhButton>
</div>
<!-- 溢出裁在这一层,量的也是这一层 -->
<div
ref="boxRef"
:style="{ inlineSize: boxWidth + 'px', maxInlineSize: '100%', overflow: 'hidden' }"
>
<XhMenubarRoot @select="onSelect">
<XhMenubarTrigger v-for="m in shown" :key="m.value" :value="m.value">
{{ m.label }}
</XhMenubarTrigger>
<XhMenubarTrigger v-if="folded.length" value="more">更多</XhMenubarTrigger>
<XhMenubarPositioner v-for="m in shown" :key="m.value" :value="m.value">
<XhMenubarContent>
<XhMenubarItem v-for="item in m.items" :key="item.value" :value="item.value">
<XhMenubarItemText>{{ item.label }}</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarContent>
</XhMenubarPositioner>
<XhMenubarPositioner v-if="folded.length" value="more">
<XhMenubarContent>
<XhMenubarGroup v-for="m in folded" :key="m.value" :value="m.value">
<XhMenubarGroupLabel>{{ m.label }}</XhMenubarGroupLabel>
<XhMenubarItem
v-for="item in m.items"
:key="item.value"
:value="`${m.value}:${item.value}`"
>
<XhMenubarItemText>{{ item.label }}</XhMenubarItemText>
</XhMenubarItem>
</XhMenubarGroup>
</XhMenubarContent>
</XhMenubarPositioner>
</XhMenubarRoot>
</div>
<span>
在场入口 {{ shown.length }} / {{ menus.length }};最近选中:{{ picked || "(无)" }}
</span>
</div>
</template>产物
| 层 | 值 |
|---|---|
| 自定义元素 | <xh-menubar> |
| Vue 组件 | XhMenubarContent XhMenubarGroup XhMenubarGroupLabel XhMenubarItem XhMenubarItemIndicator XhMenubarItemText XhMenubarPositioner XhMenubarRoot XhMenubarSeparator XhMenubarTrigger |
| 组合式函数 | useMenubar |
| 状态机 | menubarMachine |
| 皮肤 | @xihan-ui/styles/menubar.css |
解剖
部件名即 data-part 属性值,也是皮肤的选择器。加粗的是必备部件,不渲染它组件不工作(Web Components 适配器会在诊断通道上报 wc.missing-part)。
data-scope="menubar":root · trigger · positioner · content · item · item-text · item-indicator · separator · group · group-label
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
collection | MenubarNode[] | 菜单栏数据,显示文本与禁用的事实源。给了它,入口与条目部件只需报 value。 缺省即回到「文本与禁用逐个写在部件上」的老路。 | |
value | string | null | 当前展开项,给定即受控;null 表示都收起。 | |
defaultValue | string | null | ||
orientation | Orientation | 菜单栏排布轴,默认 horizontal。 | |
loop | boolean | 方向键走到尽头是否回绕,默认 true。 | |
dir | Direction | 文字方向,默认 ltr。 | |
disabled | boolean | 整条菜单栏禁用,展开与选中都不发生。 | |
typeahead | boolean | 菜单内的连打检索,默认开。 | |
placement | Placement | ||
offset | number | ||
tone | Tone | 语气:brand / neutral / success / warning / danger / info,决定用哪族颜色。 | |
size | Size | 尺寸:sm / md / lg。 | |
onValueChange | (details: MenubarValueChangeDetails) => void | value 变化回调。 | |
onSelect | (details: MenubarSelectDetails) => void | 条目被选中;菜单随之收起。 |
状态机
状态:idle · open
事件:TRIGGER.TOGGLE · TRIGGER.OPEN · TRIGGER.POINTER · TRIGGER.FOCUS · CLOSE · MENUBAR.BLUR · VALUE.SET · ITEM.FOCUS · ITEM.LOST · ITEM.SELECT · SYNC.OPEN · SYNC.CLOSE
判据:hasValue · isCurrent · shouldAbsorbToggle · shouldSwitch
connect API
useMenubar 产出的对象。getXxxProps() 铺到对应部件的宿主元素上,其余是可读状态与操作入口。
| 成员 | 类型 | 说明 |
|---|---|---|
value | string | null | 当前展开的那一项;都收起时为 null。 |
collection | readonly MenubarNodeMeta[] | collection 推出的入口元信息(各自带着它那张菜单的条目),按数据顺序排列;没给 collection 即空数组。 |
open | boolean | 有没有菜单展开着。 |
focusedValue | string | null | trigger 的 roving 锚点;焦点不在菜单栏内时为 null。 |
focusedItem | string | null | 展开菜单内持有焦点的条目;无锚点时为 null。 |
orientation | Orientation | |
disabled | boolean | |
isOpen | (value: string) => boolean | |
setValue | (next: string | null) => void | |
getRootProps | () => T['element'] | |
getTriggerProps | (props: MenubarTriggerProps) => T['button'] | |
getPositionerProps | (props: MenubarContentProps) => T['element'] | |
getContentProps | (props: MenubarContentProps) => T['element'] | |
getItemProps | (props: MenubarItemProps) => T['element'] | |
getItemTextProps | (props: MenubarItemProps) => T['element'] | |
getItemIndicatorProps | (props: MenubarItemProps) => T['element'] | |
getSeparatorProps | () => T['element'] | |
getGroupProps | (props: MenubarGroupProps) => T['element'] | |
getGroupLabelProps | (props: MenubarGroupProps) => T['element'] |
键盘
规格出处:W3C APG
| 按键 | 生效条件 | 行为 |
|---|---|---|
ArrowRight | focus in trigger, horizontal | 焦点移到下一个 trigger(禁用项跳过、尽头按 loop 回绕);已有菜单展开着则展开项跟着切过去 |
ArrowLeft | focus in trigger, horizontal | 焦点移到上一个 trigger(禁用项跳过、尽头按 loop 回绕);已有菜单展开着则展开项跟着切过去 |
Home | focus in trigger | 焦点移到首个可用 trigger |
End | focus in trigger | 焦点移到末个可用 trigger |
ArrowDown / Enter / Space | focus in trigger, horizontal | 展开本项的菜单;方向键入口把焦点落到首个可用条目,Enter/Space 让焦点留在 trigger 上 |
ArrowUp | focus in trigger, horizontal | 展开本项的菜单并把焦点落到末个可用条目 |
ArrowDown | open, focus in content | 焦点移到下一个条目(禁用项跳过、尽头按 loop 回绕) |
ArrowUp | open, focus in content | 焦点移到上一个条目(禁用项跳过、尽头按 loop 回绕) |
Home | open, focus in content | 焦点移到本张菜单的首个可用条目 |
End | open, focus in content | 焦点移到本张菜单的末个可用条目 |
ArrowRight / ArrowLeft | open, focus in content | 切到相邻菜单并保持展开,焦点落到那一项的 trigger 上 |
a-z / 0-9 | open, focus in content | 连打检索:焦点跳到首字母匹配的条目(同字符连打则在候选间轮换) |
Enter / Space | focus in item, not disabled | 派发选中详情并收起菜单,焦点归还 trigger |
Escape | open | 收起菜单并把焦点留在 trigger 上 |
Tab / Shift+Tab | open | 收起菜单,焦点不被抢回 trigger,按 Tab 序列自然离开 |
