右键菜单 context-menu
导航组件。三层同源:无头内核给出解剖与状态机,Vue 组件与自定义元素只是它的两层外壳,行为完全一致。
示例
基础用法
collection 是条目的事实源,结构由组件铺开;在触发区上右键(触摸端长按),菜单钉在按下去的那一点上
<script setup lang="ts">
import { ref } from "vue";
import { XhContextMenuRoot } from "@xihan-ui/vue";
const commands = [
{ value: "copy", label: "复制" },
{ value: "paste", label: "粘贴", disabled: true },
{ value: "rename", label: "重命名" },
{ value: "delete", label: "删除", separatorBefore: true },
];
const picked = ref("");
function onSelect(details: { value: string }): void {
picked.value = details.value;
}
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px">
<XhContextMenuRoot :collection="commands" @select="onSelect">
<!-- 触发区的尺寸与排布归作者,皮肤只管它的交互观感 -->
<template #trigger>
<span style="display: grid; place-items: center; min-block-size: 120px">
在这块区域上右键
</span>
</template>
</XhContextMenuRoot>
<span>最近选中:{{ picked || "(无)" }}</span>
</div>
</template>分组与标记位
group 用 value 跟自己的 group-label 配对,item-indicator 是纯装饰的勾选位
<script setup lang="ts">
import { ref } from "vue";
import {
XhContextMenuContent,
XhContextMenuGroup,
XhContextMenuGroupLabel,
XhContextMenuItem,
XhContextMenuItemIndicator,
XhContextMenuItemText,
XhContextMenuPositioner,
XhContextMenuRoot,
XhContextMenuSeparator,
XhContextMenuTrigger,
} from "@xihan-ui/vue";
const sortBy = ref("name");
function onSelect(details: { value: string }): void {
if (details.value === "name" || details.value === "time")
sortBy.value = details.value;
}
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px">
<XhContextMenuRoot @select="onSelect">
<XhContextMenuTrigger
style="display: grid; place-items: center; min-block-size: 120px"
>
<span>右键看看排序与视图两组</span>
</XhContextMenuTrigger>
<XhContextMenuPositioner>
<XhContextMenuContent>
<XhContextMenuGroup value="sort">
<XhContextMenuGroupLabel>排序方式</XhContextMenuGroupLabel>
<XhContextMenuItem value="name">
<XhContextMenuItemIndicator>
{{ sortBy === "name" ? "✓" : "" }}
</XhContextMenuItemIndicator>
<XhContextMenuItemText>按名称</XhContextMenuItemText>
</XhContextMenuItem>
<XhContextMenuItem value="time">
<XhContextMenuItemIndicator>
{{ sortBy === "time" ? "✓" : "" }}
</XhContextMenuItemIndicator>
<XhContextMenuItemText>按时间</XhContextMenuItemText>
</XhContextMenuItem>
</XhContextMenuGroup>
<XhContextMenuSeparator />
<XhContextMenuGroup value="view">
<XhContextMenuGroupLabel>视图</XhContextMenuGroupLabel>
<XhContextMenuItem value="list">
<XhContextMenuItemText>列表</XhContextMenuItemText>
</XhContextMenuItem>
<XhContextMenuItem value="grid">
<XhContextMenuItemText>网格</XhContextMenuItemText>
</XhContextMenuItem>
</XhContextMenuGroup>
</XhContextMenuContent>
</XhContextMenuPositioner>
</XhContextMenuRoot>
<span>当前排序:{{ sortBy === "name" ? "按名称" : "按时间" }}</span>
</div>
</template>受控与锚点
传了 open 就由宿主说了算;root 的插槽给出锚点坐标与 openAt,可以从任意位置弹出
<script setup lang="ts">
import { ref } from "vue";
import {
XhContextMenuContent,
XhContextMenuItem,
XhContextMenuItemText,
XhContextMenuPositioner,
XhContextMenuRoot,
XhContextMenuTrigger,
} from "@xihan-ui/vue";
const open = ref(false);
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px">
<XhContextMenuRoot v-slot="{ point, openAt }" v-model:open="open">
<XhContextMenuTrigger
style="display: grid; place-items: center; min-block-size: 120px"
>
<span>右键这里,或者用下面的按钮从固定坐标弹出</span>
</XhContextMenuTrigger>
<XhContextMenuPositioner>
<XhContextMenuContent>
<XhContextMenuItem value="open">
<XhContextMenuItemText>打开</XhContextMenuItemText>
</XhContextMenuItem>
<XhContextMenuItem value="share">
<XhContextMenuItemText>分享</XhContextMenuItemText>
</XhContextMenuItem>
</XhContextMenuContent>
</XhContextMenuPositioner>
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap">
<button type="button" @click="openAt(120, 200)">在 (120, 200) 弹出</button>
<button type="button" @click="open = false">收起</button>
<span>
{{ open ? `展开中 · 锚点 (${point?.x}, ${point?.y})` : "已收起" }}
</span>
</div>
</XhContextMenuRoot>
</div>
</template>语气
tone 决定条目高亮与标记位用哪族颜色;高亮静止态看不出来,右键弹出后悬停条目、或用方向键把焦点移上去才显现
<script setup lang="ts">
import { XhContextMenuRoot } from "@xihan-ui/vue";
const tones = ["brand", "neutral", "success", "warning", "danger", "info"] as const;
// 标记位的强调色也随语气走,这一处不必悬停就能看出来
const commands = [
{ value: "star", label: "标记", indicator: "✓" },
{ value: "rename", label: "重命名" },
{ value: "delete", label: "删除", separatorBefore: true },
];
const triggerStyle = {
display: "grid",
placeItems: "center",
minBlockSize: "76px",
border: "1px dashed var(--xh-border-default)",
borderRadius: "8px",
};
</script>
<template>
<!-- 六块各自独立的触发区,逐块右键对比条目高亮底色 -->
<div
style="
inline-size: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
"
>
<XhContextMenuRoot
v-for="tone in tones"
:key="tone"
:tone="tone"
:collection="commands"
>
<template #trigger>
<span :style="triggerStyle">{{ tone }}</span>
</template>
</XhContextMenuRoot>
</div>
</template>尺寸
size 换的是条目的内边距、间距与字号;三档各挂一块触发区,逐块右键对比
<script setup lang="ts">
import { XhContextMenuRoot } from "@xihan-ui/vue";
// 中间一档不写 size,用 undefined 表达
const sizes = [
{ size: "sm", label: "sm" },
{ size: undefined, label: "缺省" },
{ size: "lg", label: "lg" },
] as const;
const commands = [
{ value: "copy", label: "复制" },
{ value: "rename", label: "重命名" },
{ value: "delete", label: "删除", separatorBefore: true },
];
// 三块触发区共用一份外观,尺寸差别只由 size 造成
const triggerStyle = {
display: "grid",
placeItems: "center",
minBlockSize: "96px",
border: "1px dashed var(--xh-border-default)",
borderRadius: "8px",
};
</script>
<template>
<div
style="
inline-size: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
"
>
<XhContextMenuRoot
v-for="s in sizes"
:key="s.label"
:size="s.size"
:collection="commands"
>
<template #trigger>
<span :style="triggerStyle">{{ s.label }}</span>
</template>
</XhContextMenuRoot>
</div>
</template>放置位与箭头
placement 是相对光标那一点的首选位,offset 把浮层从光标推开;arrow 指回那一点
<script setup lang="ts">
import {
XhContextMenuArrow,
XhContextMenuContent,
XhContextMenuItem,
XhContextMenuItemText,
XhContextMenuPositioner,
XhContextMenuRoot,
XhContextMenuSeparator,
XhContextMenuTrigger,
} from "@xihan-ui/vue";
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px">
<!-- 缺省是贴着光标的 bottom-start,这里改成落在光标右侧并推开 12px -->
<XhContextMenuRoot placement="right-start" :offset="12">
<XhContextMenuTrigger
style="display: grid; place-items: center; min-block-size: 120px"
>
<span>在这块区域上右键:菜单落在光标右侧,箭头指回光标</span>
</XhContextMenuTrigger>
<XhContextMenuPositioner>
<XhContextMenuContent>
<XhContextMenuItem value="open">
<XhContextMenuItemText>打开</XhContextMenuItemText>
</XhContextMenuItem>
<XhContextMenuItem value="share">
<XhContextMenuItemText>分享</XhContextMenuItemText>
</XhContextMenuItem>
<XhContextMenuSeparator />
<XhContextMenuItem value="delete">
<XhContextMenuItemText>删除</XhContextMenuItemText>
</XhContextMenuItem>
</XhContextMenuContent>
<!-- 箭头挂在 positioner 上,位置由定位引擎回填 -->
<XhContextMenuArrow />
</XhContextMenuPositioner>
</XhContextMenuRoot>
</div>
</template>条目里的图标与快捷键
item-text 只是文字那一段,图标与快捷键提示作为兄弟节点排在它两侧
<script setup lang="ts">
import {
XhContextMenuContent,
XhContextMenuItem,
XhContextMenuItemText,
XhContextMenuPositioner,
XhContextMenuRoot,
XhContextMenuSeparator,
XhContextMenuTrigger,
XhIcon,
} from "@xihan-ui/vue";
// 描边取 currentColor,图标颜色随条目文字色走,禁用态也一并跟着变淡
const strokeAttrs = {
"fill": "none",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
} as const;
const CutIcon = {
name: "cut",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "circle", attrs: { cx: "6", cy: "18", r: "3" } },
{ tag: "circle", attrs: { cx: "18", cy: "18", r: "3" } },
{ tag: "path", attrs: { d: "M8 16L18 4M16 16L6 4" } },
],
} as const;
const PasteIcon = {
name: "paste",
viewBox: "0 0 24 24",
attrs: strokeAttrs,
nodes: [
{ tag: "rect", attrs: { x: "5", y: "4", width: "14", height: "17", rx: "2" } },
{ tag: "path", attrs: { d: "M9 4V3H15V4" } },
],
} 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;
// item-text 会撑满剩余宽度,快捷键提示自然被顶到条目末端
const hintStyle = {
color: "var(--xh-fg-muted)",
};
</script>
<template>
<div style="inline-size: 100%; display: grid; gap: 12px">
<XhContextMenuRoot>
<XhContextMenuTrigger
style="display: grid; place-items: center; min-block-size: 120px"
>
<span>右键看带图标与快捷键的条目</span>
</XhContextMenuTrigger>
<XhContextMenuPositioner>
<XhContextMenuContent>
<XhContextMenuItem value="cut">
<XhIcon :icon="CutIcon" size="sm" />
<XhContextMenuItemText>剪切</XhContextMenuItemText>
<span :style="hintStyle">Ctrl+X</span>
</XhContextMenuItem>
<XhContextMenuItem value="paste" disabled>
<XhIcon :icon="PasteIcon" size="sm" />
<XhContextMenuItemText>粘贴</XhContextMenuItemText>
<span :style="hintStyle">Ctrl+V</span>
</XhContextMenuItem>
<XhContextMenuSeparator />
<XhContextMenuItem value="delete">
<XhIcon :icon="TrashIcon" size="sm" />
<XhContextMenuItemText>删除</XhContextMenuItemText>
<span :style="hintStyle">Del</span>
</XhContextMenuItem>
</XhContextMenuContent>
</XhContextMenuPositioner>
</XhContextMenuRoot>
</div>
</template>触发与连打
longPressDelay 是触摸端按住多久算触发;typeahead 决定展开后的可打印字符是拿去检索还是放行给页面
<script setup lang="ts">
import type { CSSProperties } from "vue";
import { XhContextMenuRoot } from "@xihan-ui/vue";
const formats = [
{ value: "pdf", label: "PDF 预览" },
{ value: "excel", label: "Excel 导出" },
{ value: "markdown", label: "Markdown 源码" },
];
// 两块触发区共用一份外观,差别只由 root 上那一个属性造成
const triggerStyle: CSSProperties = {
display: "grid",
placeItems: "center",
minBlockSize: "108px",
border: "1px dashed var(--xh-border-default)",
borderRadius: "8px",
padding: "8px",
textAlign: "center",
};
</script>
<template>
<div
style="
inline-size: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
"
>
<!-- 长按 300ms 就触发,比缺省的 700ms 灵敏;按住期间指针挪动超过容差算取消 -->
<XhContextMenuRoot :long-press-delay="300" :collection="formats">
<template #trigger>
<span :style="triggerStyle">
触摸端按住 300ms 即弹出;展开后敲 E 跳到 Excel 那一条
</span>
</template>
</XhContextMenuRoot>
<!-- 关掉连打检索:同样敲 E,焦点不再移动,字符原样放行给页面 -->
<XhContextMenuRoot :typeahead="false" :collection="formats">
<template #trigger>
<span :style="triggerStyle">连打检索关掉:展开后敲 E 焦点不动</span>
</template>
</XhContextMenuRoot>
</div>
</template>产物
| 层 | 值 |
|---|---|
| 自定义元素 | <xh-context-menu> |
| Vue 组件 | XhContextMenuArrow XhContextMenuContent XhContextMenuGroup XhContextMenuGroupLabel XhContextMenuItem XhContextMenuItemIndicator XhContextMenuItemText XhContextMenuPositioner XhContextMenuRoot XhContextMenuSeparator XhContextMenuTrigger |
| 组合式函数 | useContextMenu |
| 状态机 | contextMenuMachine |
| 皮肤 | @xihan-ui/styles/context-menu.css |
解剖
部件名即 data-part 属性值,也是皮肤的选择器。加粗的是必备部件,不渲染它组件不工作(Web Components 适配器会在诊断通道上报 wc.missing-part)。
data-scope="context-menu":root · trigger · positioner · content · item · item-text · item-indicator · separator · group · group-label · arrow
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
collection | ContextMenuNode[] | 条目数据,显示文本、禁用、标记位与分组的事实源。给了它,条目部件只需报 value。 缺省即回到「文本与禁用全写在条目部件上」的老路。 | |
open | boolean | 展开态。给定即受控:内部不再自改,只发 onOpenChange。 | |
defaultOpen | boolean | ||
placement | Placement | 相对光标那一点的首选放置位,默认 bottom-start。 | |
offset | number | 浮层与光标的间距(px),默认 0——右键菜单要贴着光标。 | |
loop | boolean | 方向键走到尽头是否回绕,默认 true。 | |
dir | Direction | 文字方向,默认 ltr。 | |
typeahead | boolean | 连打检索,默认开。关掉后可打印字符一律放行给页面。 | |
longPressDelay | number | 触摸端长按多久算触发(ms),默认 700。 | |
tone | Tone | 语气:brand / neutral / success / warning / danger / info,决定条目高亮与标记位用哪族颜色。 | |
size | Size | 尺寸:sm / md / lg,决定条目高度、内边距与字号档位。 | |
onOpenChange | (details: ContextMenuOpenChangeDetails) => void | open 变化意图回调;受控时是唯一出口,非受控时随内部转移一并通知。 | |
onSelect | (details: ContextMenuSelectDetails) => void | 条目被选中;菜单随之关闭。 |
状态机
状态:closed · pressing · open
事件:CONTEXT.MENU · OPEN · CLOSE · PRESS.START · PRESS.MOVE · PRESS.END · after.longPressDelay · CONTROLLED.OPEN · CONTROLLED.CLOSE · ITEM.FOCUS · ITEM.LOST · ITEM.SELECT
判据:isOpenControlled · movedBeyondTolerance
connect API
useContextMenu 产出的对象。getXxxProps() 铺到对应部件的宿主元素上,其余是可读状态与操作入口。
| 成员 | 类型 | 说明 |
|---|---|---|
open | boolean | |
collection | readonly ContextMenuNodeMeta[] | collection 推出的条目元信息,按数据顺序排列;没给 collection 即空数组。 |
pressing | boolean | 长按计时进行中;触发区据此给按压反馈。 |
point | ContextMenuPoint | null | 当前锚点坐标;一次都没打开过时为 null。 |
focusedValue | string | null | 焦点锚点;收起时为 null。 |
setOpen | (next: boolean) => void | 收起走 CLOSE;展开落在最近一次锚点坐标上(从未打开过则是原点)。 |
openAt | (x: number, y: number) => void | 命令式展开到指定视口坐标。 |
getRootProps | () => T['element'] | |
getTriggerProps | () => T['element'] | |
getPositionerProps | () => T['element'] | |
getContentProps | () => T['element'] | |
getItemProps | (props: ContextMenuItemProps) => T['element'] | |
getItemTextProps | (props: ContextMenuItemProps) => T['element'] | |
getItemIndicatorProps | (props: ContextMenuItemProps) => T['element'] | |
getSeparatorProps | () => T['element'] | |
getGroupProps | (props: ContextMenuGroupProps) => T['element'] | |
getGroupLabelProps | (props: ContextMenuGroupProps) => T['element'] | |
getArrowProps | () => T['element'] |
键盘
规格出处:W3C APG
| 按键 | 生效条件 | 行为 |
|---|---|---|
ContextMenu / Shift+F10 | 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 | 焦点移到末个可用条目 |
单个可打印字符 | open, typeahead 未关 | 连打检索把焦点移到首字母匹配的条目,不选中它 |
Enter / Space | focus in item, not disabled | 派发选中详情并关闭菜单,焦点归还触发区 |
Escape | open | 关闭菜单并把焦点归还触发区 |
Tab / Shift+Tab | open | 关闭菜单,焦点不归还触发区,按 Tab 序列自然离开 |
