radix-vue 中 DropdownMenuSub 组件详解:子菜单状态控制的 API 与源码实现
【免费下载链接】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(现以reka-ui包名发布)的DropdownMenuSub组件为核心,完整解析其defaultOpen、open属性、update:open事件与open插槽的 API 语义,并结合 DropdownMenuSub.vue、MenuSub.vue 等源码,说明子菜单受控/非受控模式的实现原理、与父级菜单的联动关闭机制,以及DropdownMenuSubTrigger、DropdownMenuSubContent的悬停、键盘与焦点行为,帮助你在下拉菜单中可靠地构建多级子菜单。
组件定位:DropdownMenuSub 在子菜单中的角色
DropdownMenuSub是下拉菜单子菜单(submenu)的容器组件,与DropdownMenuSubTrigger(触发子菜单的菜单项)和DropdownMenuSubContent(弹出的子菜单面板)配合使用。官方组件文档 dropdown-menu.md 给出的完整组件结构(Anatomy)如下,其中DropdownMenuSub部分即构成一个可嵌套的下拉层:
<script setup lang="ts"> import { DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, } from 'reka-ui' </script> <template> <DropdownMenuRoot> <DropdownMenuTrigger>…</DropdownMenuTrigger> <DropdownMenuPortal> <DropdownMenuContent> <DropdownMenuItem>…</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuSub> <DropdownMenuSubTrigger>Sub menu →</DropdownMenuSubTrigger> <DropdownMenuPortal> <DropdownMenuSubContent> <DropdownMenuItem>Sub menu item</DropdownMenuItem> <DropdownMenuItem>Sub menu item</DropdownMenuItem> </DropdownMenuSubContent> </DropdownMenuPortal> </DropdownMenuSub> <DropdownMenuSeparator /> <DropdownMenuItem>…</DropdownMenuItem> </DropdownMenuContent> </DropdownMenuPortal> </DropdownMenuRoot> </template>使用要点:
DropdownMenuSub必须包裹在DropdownMenuRoot的子树中(通常为DropdownMenuContent内部),因为它依赖父级菜单提供的 Context;DropdownMenuSubTrigger与DropdownMenuSubContent必须渲染在DropdownMenuSub内部;DropdownMenuSubContent建议再包一层DropdownMenuPortal,将其传送到body下渲染,避免被父级容器的overflow裁剪;- 三个组件统一从
reka-ui包导入,导出入口见 DropdownMenu/index.ts。
DropdownMenuSub 完整 API 参考
以下 API 以官方元数据文档 DropdownMenuSub.md 为准。
Props
| 名称 | 说明 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
defaultOpen | 子菜单初始渲染时的打开状态。用于无需控制其打开状态的场景。 | boolean | 否 | - |
open | 子菜单的受控打开状态。可用作v-model:open。 | boolean | 否 | - |
Events
| 名称 | 说明 | 类型 |
|---|---|---|
update:open | 子菜单打开状态变化时触发的事件处理函数。 | [payload: boolean] |
Slots
| 名称 | 说明 | 类型 |
|---|---|---|
open | 当前打开状态 | boolean |
配套的DropdownMenuSubTrigger支持as、asChild、disabled、textValue属性(见 DropdownMenuSubTrigger.md);DropdownMenuSubContent则继承 Popper 定位类属性(sideOffset、alignOffset、avoidCollisions、collisionBoundary、collisionPadding、forceMount等)与focusOutside、escapeKeyDown、interactOutside等事件(见 DropdownMenuSubContent.md),这些在子菜单的场景中与DropdownMenuSub的状态控制共同决定了交互手感。
受控与非受控:open 状态的实现原理
DropdownMenuSub只有两个状态类属性,但覆盖了两种使用模式,其核心实现在 DropdownMenuSub.vue 中。组件内部使用 VueUse 的useVModel把属性、事件和内部响应式状态三合一:
// packages/core/src/DropdownMenu/DropdownMenuSub.vue#L29-L32 const open = useVModel(props, 'open', emit, { passive: (props.open === undefined) as false, defaultValue: props.defaultOpen ?? false, }) as Ref<boolean>从源码结构看,这段实现揭示了三个关键行为:
- 非受控模式(默认):当
open属性未传入(undefined)时,passive: true启用被动模式——组件内部自由维护open的初始值,默认取defaultOpen ?? false。此时你只需要给defaultOpen设置初始打开状态,后续开合完全由组件自行驱动(悬停、键盘、焦点移出等)。 - 受控模式:传入
open或使用v-model:open后,passive为false,内部状态以外部绑定为唯一事实来源。用户交互引起的开合只会触发update:open事件,必须由你更新绑定的open值,子菜单才会真正开合——这是受控组件的标准契约。 - 状态外泄:组件把当前状态通过插槽作用域属性暴露给模板,便于在触发项上做视觉切换(如箭头旋转):
<DropdownMenuSub v-model:open="subOpen"> <template #default="{ open }"> <DropdownMenuSubTrigger> Sort options <!-- open 为 true 时可旋转箭头 --> </DropdownMenuSubTrigger> <DropdownMenuPortal> <DropdownMenuSubContent>…</DropdownMenuSubContent> </DropdownMenuPortal> </template> </DropdownMenuSub>对应的插槽类型声明位于 DropdownMenuSub.vue#L22-L27,模板则把该状态同时挂到内部MenuSub上(<MenuSub v-model:open="open">),保证 API 层与底层 Menu 模块的单一状态源一致。组件还通过useForwardExpose把内部实例转发出去,配合useForwardPropsEmits(在 DropdownMenuSubContent.vue 中同样可见)方便二次封装。
底层机制:MenuSub 如何驱动子菜单
DropdownMenuSub是一个薄封装,真正的行为在共享的 Menu 模块中,模板层渲染为 PopperRoot 包裹的子菜单上下文:
<!-- packages/core/src/Menu/MenuSub.vue --> <template> <PopperRoot> <slot /> </PopperRoot> </template>MenuSub.vue 提供了三层上下文:
- MenuContext:向
SubTrigger/SubContent提供open、onOpenChange和内容元素引用,DropdownMenuSub的open状态最终写入这里; - MenuSubContext:管理
triggerId、contentId和 trigger 元素引用,用于生成aria-expanded、aria-controls、aria-labelledby等无障碍属性; - 父级菜单联动:这是子菜单最容易踩坑的地方——父菜单关闭时,所有已打开的子菜单必须同步关闭:
// packages/core/src/Menu/MenuSub.vue#L51-L56 // Prevent the parent menu from reopening with open submenus. watchEffect((cleanupFn) => { if (parentMenuContext?.open.value === false) open.value = false cleanupFn(() => (open.value = false)) })从源码结构看,这个watchEffect在父菜单open变为false时强制把子菜单open置为false,并在依赖清理时兜底关闭,从而避免"父菜单关闭后,子菜单仍悬浮在页面上"的状态泄漏。
交互行为:悬停打开、优雅区与键盘导航
子菜单的触发项 MenuSubTrigger.vue 实现了三个层次的行为,直接决定了open状态的实际变化时机。
1. 鼠标悬停 100ms 后打开
// packages/core/src/Menu/MenuSubTrigger.vue#L59-L65 if (!props.disabled && !menuContext.open.value && !openTimerRef.value) { contentContext.onPointerGraceIntentChange(null) openTimerRef.value = window.setTimeout(() => { menuContext.onOpenChange(true) clearOpenTimer() }, 100) }只有鼠标类型事件(pointerType === 'mouse')才会启动计时;用户悬停 100ms 后通过menuContext.onOpenChange(true)更新打开状态——这正是DropdownMenuSub的open变为true的来源之一。计时器在组件卸载时清理(onUnmounted中的clearOpenTimer),避免内存泄漏。
2. 指针优雅区(Pointer Grace Area)
pointerleave时并不会立刻关闭子菜单。源码会计算一个由"离开点 + 内容边缘"围成的多边形区域(handlePointerLeave中构造area并设置 300ms 的pointerGraceTimerRef),配合 utils.ts 中的isPointInPolygon判断指针是否仍在"优雅区"内:指针在 300ms 内进入子菜单内容区域时,子菜单保持打开。这就是鼠标从触发项快速移动到子菜单内容时不会"闪烁关闭"的原因。该实现代码中也保留了与 Radix UI 原始定位逻辑的对照注释,说明其移植自 Radix 的实现策略。
3. 键盘打开/关闭键与方向感知
键盘交互的方向键由 utils.ts#L12-L19 按阅读方向定义:
export const SUB_OPEN_KEYS: Record<Direction, string[]> = { ltr: [...SELECTION_KEYS, 'ArrowRight'], rtl: [...SELECTION_KEYS, 'ArrowLeft'], } export const SUB_CLOSE_KEYS: Record<Direction, string[]> = { ltr: ['ArrowLeft'], rtl: ['ArrowRight'], }即 LTR 布局下Enter/Space/ArrowRight打开子菜单,ArrowLeft关闭;RTL 布局下方向键对调。MenuSubTrigger.vue#L113-L127 的handleKeyDown在打开子菜单后会nextTick并把焦点移入子菜单内容,同时preventDefault防止窗口滚动——这解释了为何键盘操作子菜单时页面不会跟着滚动。
4. 子菜单内容的焦点与 Escape 处理
MenuSubContent.vue 是子菜单面板的行为核心:
- 通过
Presence组件按forceMount || menuContext.open.value决定挂载,默认prioritizePosition: true保证子菜单内容始终位于视口内; - 子菜单侧别由阅读方向决定:
side取dir === 'rtl' ? 'left' : 'right',即 LTR 下子菜单弹出在触发项右侧; open-auto-focus被.prevent拦截,仅在键盘导航(isUsingKeyboardRef为真)时才聚焦子菜单内容,避免鼠标悬停打开时抢焦点;focus-outside中做了两个豁免:焦点回到触发项、或焦点移向父级内容的过滤输入框时不关闭,防止在父菜单与子菜单之间悬停时反复触发关闭/重开动画;escape-key-down直接调用rootContext.onClose()并preventDefault,即Escape 关闭的是整个下拉菜单(而非仅当前子菜单),且防止页面退出全屏等副作用。
这些行为与官方文档 dropdown-menu.md 中"Accessibility"小节的键盘表一致:ArrowRight/ArrowLeft在DropdownMenuSubTrigger上按阅读方向开/关子菜单,Esc关闭整个菜单并将焦点移回DropdownMenuTrigger。
实战:受控子菜单示例
非受控用法即上文 Anatomy 示例,开箱即用。若需要在业务逻辑中响应该状态(例如记录用户展开过哪些分组、或在外部逻辑强制收起子菜单),使用受控模式:
<script setup lang="ts"> import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from 'reka-ui' import { ref, watch } from 'vue' const subOpen = ref(false) const lastOpenedAt = ref<number | null>(null) // 受控模式:交互只触发 update:open,需同步更新绑定 watch(subOpen, (open) => { if (open) lastOpenedAt.value = Date.now() }) </script> <template> <DropdownMenuRoot> <DropdownMenuTrigger>Actions</DropdownMenuTrigger> <DropdownMenuPortal> <DropdownMenuContent> <DropdownMenuSub v-model:open="subOpen"> <DropdownMenuSubTrigger> {{ subOpen ? 'Collapse' : 'Expand' }} → </DropdownMenuSubTrigger> <DropdownMenuPortal> <DropdownMenuSubContent> <DropdownMenuItem>Sub item 1</DropdownMenuItem> <DropdownMenuItem>Sub item 2</DropdownMenuItem> </DropdownMenuSubContent> </DropdownMenuPortal> </DropdownMenuSub> <DropdownMenuItem>Other item</DropdownMenuItem> </DropdownMenuContent> </DropdownMenuPortal> </DropdownMenuRoot> </template>几点注意事项:
- 受控模式下若只监听
update:open而不回写open,子菜单会"点了没反应",这是受控组件的常见误区; - 父菜单关闭时子菜单会被底层
watchEffect强制关闭,此时受控的subOpen会通过update:open(false)同步回来,无需手动处理; - 需要固定初始打开(如进入页面即展开)时,非受控模式用
default-open,受控模式把初始值设为true传入open。
小结
DropdownMenuSub的 API 面虽小(两个属性、一个事件、一个作用域插槽),但它背后串联了完整的子菜单机制:useVModel实现的受控/非受控双模式、MenuSub提供的上下文与父菜单联动关闭、MenuSubTrigger的 100ms 悬停计时与指针优雅区、MenuSubContent的焦点管理与 Escape 语义,以及按 LTR/RTL 方向切换的键盘开/关键。掌握 DropdownMenuSub.vue、MenuSub.vue、MenuSubTrigger.vue、MenuSubContent.vue 与 utils.ts 这几处源码后,你就可以在 radix-vue 中构建既符合 WAI-ARIA Menu Button 模式、又可深度定制的多级下拉菜单。
【免费下载链接】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),仅供参考