radix-vue 子菜单完全指南:ContextMenuSub 的受控状态、嵌套层级与交互原理
【免费下载链接】radix-vueAn open-source UI component library for building high-quality, accessible design systems and web apps for Vue. Previously Radix Vue项目地址: https://gitcode.com/GitHub_Trending/ra/radix-vue
本篇技术指南聚焦 radix-vue(原 Radix Vue,现文档中以reka-ui包名发布)无障碍 UI 组件库中的ContextMenuSub及其配套部件ContextMenuSubTrigger、ContextMenuSubContent。子菜单是右键上下文菜单中最常用的进阶结构,本文将从 API 属性、受控/非受控状态管理、嵌套层级组合,到源码层级的展开/收起交互原理(悬停延迟、指针宽限区、方向键、RTL)做系统拆解,读完即可在真实项目中落地一个完整、可嵌套、键盘可访问的子菜单。
ContextMenuSub 是什么
在 radix-vue 中,右键上下文菜单由ContextMenuRoot统领,ContextMenuSub则用于在当前菜单内部再派生出一层二级(乃至多级)菜单。根据 ContextMenu 官方文档 的定义,ContextMenuSub"Contains all the parts of a submenu",即包含一个子菜单的所有部件,通常需要与以下两个部件配合:
| 部件 | 作用 |
|---|---|
ContextMenuSubTrigger | 一个可以打开子菜单的菜单项,必须渲染在ContextMenu.Sub内部 |
ContextMenuSubContent | 子菜单打开时弹出的内容面板,必须渲染在ContextMenu.Sub内部 |
三者缺一不可:ContextMenuSub负责维护子菜单的开合状态并向下传递上下文,SubTrigger是触发入口,SubContent是弹出的面板容器。
核心 API 一览
ContextMenuSub的完整 API 由 ContextMenuSub.md 定义,其本质是基础菜单MenuSub的一层薄封装(见 ContextMenuSub.vue),仅额外补充defaultOpen属性。完整清单如下:
Props
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
defaultOpen | The open state of the submenu when it is initially rendered. Use when you do not need to control its open state. | boolean | No | - |
open | The controlled open state of the menu. Can be used as v-model:open. | boolean | No | - |
Events
| Name | Description | Type |
|---|---|---|
update:open | Event handler called when the open state of the submenu changes. | [payload: boolean] |
Slots
| Name | Description | Type |
|---|---|---|
open | Current open state | boolean |
受控与非受控两种模式
- 非受控:只传
defaultOpen,子菜单的展开/收起由内部状态自行管理。例如<ContextMenuSub default-open>表示首次渲染时默认展开。 - 受控:通过
v-model:open绑定布尔值,父组件完全接管开合状态,可据此实现"展开子菜单时联动关闭其他菜单"等业务逻辑:
<script setup lang="ts"> import { ref } from 'vue' const subOpen = ref(false) </script> <template> <ContextMenuSub v-model:open="subOpen"> <ContextMenuSubTrigger>More Tools</ContextMenuSubTrigger> <ContextMenuPortal> <ContextMenuSubContent> <ContextMenuItem>Save Page As…</ContextMenuItem> </ContextMenuSubContent> </ContextMenuPortal> </ContextMenuSub> </template>从源码看,这两种模式由useVModel统一收敛:在 MenuSub.vue 中,open以props.open === undefined判断是否为受控,非受控时回退到默认值false;在ContextMenuSub层则以props.defaultOpen作为初始默认值(见 ContextMenuSub.vue)。
作用域插槽:访问当前开合状态
ContextMenuSub暴露一个默认插槽,参数为当前的open布尔值。你可以利用它在不引入额外响应式变量的情况下完成联动渲染,例如给子菜单触发器动态切换箭头图标方向:
<ContextMenuSub v-slot="{ open }"> <ContextMenuSubTrigger> More Tools <ChevronRightIcon :class="open ? 'rotate-90' : ''" /> </ContextMenuSubTrigger> <ContextMenuPortal> <ContextMenuSubContent>…</ContextMenuSubContent> </ContextMenuPortal> </ContextMenuSub>完整实战示例:三级嵌套子菜单
仓库自带的 story 演示(packages/core/src/ContextMenu/story/_ContextMenu.vue)以及 官方文档示例 给出了子菜单的完整组合方式——注意子菜单面板同样需要包在ContextMenuPortal中,且SubContent支持继续嵌套下一级ContextMenuSub,从而实现多级级联:
<script setup lang="ts"> import { ContextMenuContent, ContextMenuItem, ContextMenuPortal, ContextMenuRoot, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from 'reka-ui' </script> <template> <ContextMenuRoot> <ContextMenuTrigger>Right click here.</ContextMenuTrigger> <ContextMenuPortal> <ContextMenuContent :side-offset="5"> <ContextMenuItem>New Tab</ContextMenuItem> <ContextMenuItem>New Window</ContextMenuItem> <ContextMenuSeparator /> <ContextMenuSub> <ContextMenuSubTrigger> More Tools <ChevronRightIcon /> </ContextMenuSubTrigger> <ContextMenuPortal> <ContextMenuSubContent :side-offset="2" :align-offset="-5"> <ContextMenuItem>Save Page As…</ContextMenuItem> <ContextMenuItem>Create Shortcut…</ContextMenuItem> <ContextMenuSeparator /> <!-- 支持无限层级嵌套 --> <ContextMenuSub> <ContextMenuSubTrigger>Advanced</ContextMenuSubTrigger> <ContextMenuPortal> <ContextMenuSubContent> <ContextMenuItem>Developer Tools</ContextMenuItem> </ContextMenuSubContent> </ContextMenuPortal> </ContextMenuSub> <ContextMenuArrow /> </ContextMenuSubContent> </ContextMenuPortal> </ContextMenuSub> <ContextMenuSeparator /> <ContextMenuItem>Exit</ContextMenuItem> </ContextMenuContent> </ContextMenuPortal> </ContextMenuRoot> </template>注意示例中ContextMenuSubContent通过:side-offset与:align-offset微调面板与触发器的间距和对齐偏移,这是子菜单悬浮定位最常用的两个样式参数。
状态与数据属性:无障碍样式的基石
radix-vue 的无障碍设计依赖data-*属性向 CSS 暴露内部状态,子菜单的触发器与面板分别暴露不同的属性集合(见 文档 DataAttributesTable)。
ContextMenuSubTrigger 数据属性
| Attribute | Values |
|---|---|
[data-state] | open,closed |
[data-highlighted] | Present when highlighted |
[data-disabled] | Present when disabled |
ContextMenuSubContent 数据属性
| Attribute | Values |
|---|---|
[data-state] | open,closed |
[data-side] | left,right,bottom,top |
[data-align] | start,end,center |
在 story 演示中可以看到这些属性的典型用法,例如给触发器的高亮与展开态叠加样式:
/* 触发项被键盘高亮时 */ .data-[highlighted]:bg-violet9 /* 子菜单展开时 */ .data-[state=open]:bg-violet4 /* 面板出现在不同方向时的入场动画 */ .data-[side=right]:animate-slideLeftAndFade面板侧边计算逻辑在 MenuSubContent.vue 中写死为side="right"(RTL 环境为left)、align="start",即子菜单面板默认向右弹出、与触发器顶部对齐,因此data-side的取值会随视口边界自动在left/right间切换。
源码视角:子菜单的交互原理
ContextMenuSub系列组件在实现上全部转发给Menu模块的基础实现,因此其交互行为与DropdownMenu、Menubar的子菜单完全一致。下面拆解三个关键机制。
1. 悬停 100ms 延迟打开 + 300ms 指针宽限区
在 MenuSubTrigger.vue 中,鼠标移入触发器并不会立即展开子菜单,而是启动一个 100ms 的定时器后再调用menuContext.onOpenChange(true);鼠标移出时则先清理该定时器,并基于当前内容面板的矩形区域构造一个"指针宽限区(pointer grace area)"——当鼠标从触发器移向面板的路径穿过该区域时,即使暂时"脱离"了触发器也不会立刻收起子菜单,宽限区在 300ms 后失效。这套机制保证了鼠标从触发器斜向移入面板的经典操作路径不会造成面板闪烁关闭。
2. 方向键开合与焦点管理
- 展开:在 handleKeyDown 中,按下
SUB_OPEN_KEYS[dir](LTR 为ArrowRight,RTL 为ArrowLeft)会打开子菜单,并在nextTick后将焦点移入内容面板,保证纯键盘用户的操作连续性。 - 收起:面板内的
keydown处理(见 MenuSubContent.vue)监听SUB_CLOSE_KEYS[dir](LTR 为ArrowLeft),命中后将焦点交还给SubTrigger,并调用scrollIntoView({ block: 'nearest' })确保触发器在视口内可见。 - Esc:
@escape-key-down中调用rootContext.onClose()并preventDefault,保证在子菜单中按 Esc 不会连带触发浏览器全屏退出等副作用。 - 焦点移出:
@focus-outside处理会放行"焦点回到触发器"和"焦点进入父级菜单过滤元素"这两种情况,避免指针交互时触发重复的打开动画(见 MenuSubContent.vue)。
3. 父菜单关闭时自动收起
MenuSub.vue 中通过watchEffect监听父级MenuContext.open:一旦父菜单关闭,子菜单的open会被强制重置为false,并在清理函数中再次兜底置空,避免出现"父菜单已关闭、子菜单仍悬浮"的脏状态。
4. Popper 定位与 CSS 变量透传
MenuSub用PopperRoot包裹子菜单内容(MenuSub.vue);而 ContextMenuSubContent.vue 在转发属性时,将 Popper 计算出的五个定位值映射为--reka-context-menu-*系列 CSS 变量,供开发者实现"跟随触发器与箭头位置"的入场动画。
ContextMenuSubContent 暴露的 CSS 变量
| CSS Variable | Description |
|---|---|
--reka-context-menu-content-transform-origin | Thetransform-origincomputed from the content and arrow positions/offsets |
--reka-context-menu-content-available-width | The remaining width between the trigger and the boundary edge |
--reka-context-menu-content-available-height | The remaining height between the trigger and the boundary edge |
--reka-context-menu-trigger-width | The width of the trigger |
--reka-context-menu-trigger-height | The height of the trigger |
键盘交互一览
根据 文档 KeyboardTable,与子菜单相关的完整键盘约定如下:
| Keys | Description |
|---|---|
Space/Enter | Activates the focused item. |
ArrowDown/ArrowUp | Moves focus to the next / previous item. |
ArrowRight/ArrowLeft | When focus is onContextMenu.SubTrigger, opens or closes the submenu depending on reading direction. |
Esc | Closes the context menu |
其中ArrowRight/ArrowLeft的方向语义会随dir(阅读方向)翻转,源码中SUB_OPEN_KEYS/SUB_CLOSE_KEYS正是依据 MenuRoot 的 dir 配置 在 LTR 与 RTL 两套键位间切换,这是国际化应用必须留意的细节。
小结与延伸阅读
ContextMenuSub是一个"小而精"的组合式部件:对外只暴露open、defaultOpen两个属性与一个作用域插槽,却通过转发Menu模块的成熟实现,获得了悬停延迟、指针宽限、焦点管理与 RTL 适配等全套无障碍交互能力。建议在实际项目中遵循官方推荐结构——ContextMenuSub包裹SubTrigger与Portal + SubContent,并将菜单项继续放入SubContent中实现级联。
可继续深入阅读的仓库资料:
- 完整组件 API:docs/content/meta/ContextMenuSub.md、ContextMenuSubTrigger 元数据、ContextMenuSubContent 元数据
- 官方组件文档与示例:docs/content/docs/components/context-menu.md
- 运行时演示(含嵌套子菜单):packages/core/src/ContextMenu/story/_ContextMenu.vue
- 基础实现:packages/core/src/Menu/MenuSub.vue、MenuSubTrigger.vue、MenuSubContent.vue
- ContextMenu 层封装:packages/core/src/ContextMenu/ContextMenuSub.vue
【免费下载链接】radix-vueAn open-source UI component library for building high-quality, accessible design systems and web apps for Vue. Previously Radix Vue项目地址: https://gitcode.com/GitHub_Trending/ra/radix-vue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考