radix-vue MenubarGroup 组件详解:Menubar 条目分组、Props 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(前身为 Radix Vue)Menubar 组件体系中的MenubarGroup部分:它用于在弹出菜单中对多个条目进行语义分组。读完本文,你将完整掌握MenubarGroup的as/asChild两个 Props 的取值与行为、它在MenubarRoot组合结构中的正确位置,以及它底层渲染role="group"、通过aria-labelledby关联分组标签的无障碍实现原理。
Menubar 组合结构中的位置
MenubarGroup是 Menubar(视觉常驻、类似桌面应用顶部菜单条的组件)弹出内容(MenubarContent)内部的一个组成部分。官方文档 menubar.md 的 Anatomy 章节给出了完整组合方式——MenubarGroup必须放在MenubarContent内、与MenubarItem等条目组件平级使用,用来包裹一组逻辑相关的条目:
<script setup lang="ts"> import { MenubarArrow, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarItemIndicator, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup, MenubarRadioItem, MenubarRoot, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, } from 'reka-ui' </script> <template> <MenubarRoot> <MenubarMenu> <MenubarTrigger /> <MenubarPortal> <MenubarContent> <MenubarLabel /> <MenubarItem /> <!-- 用 Group 把逻辑相关的条目包起来 --> <MenubarGroup> <MenubarItem /> </MenubarGroup> <MenubarCheckboxItem> <MenubarItemIndicator /> </MenubarCheckboxItem> <MenubarRadioGroup> <MenubarRadioItem> <MenubarItemIndicator /> </MenubarRadioItem> </MenubarRadioGroup> <MenubarSub> <MenubarSubTrigger /> <MenubarPortal> <MenubarSubContent /> </MenubarPortal> </MenubarSub> <MenubarSeparator /> <MenubarArrow /> </MenubarContent> </MenubarPortal> </MenubarMenu> </MenubarRoot> </template>官方文档对Group部分的描述为:Used to group multipleMenubarItems(用于把多个MenubarItem归为一组)。它不承担任何交互逻辑,而是一个纯结构/语义层组件。
API Reference:Props 一览
MenubarGroup的 API 文档由 MenubarGroup.md 自动维护(文件头部标注 "This file was automatically generated. Do not edit it manually"),其完整 Props 如下表:
| Name | Description | Type | Required | Default |
|---|---|---|---|---|
as | The element or component this component should render as. Can be overwritten byasChild. | AsTag \| Component | No | "div" |
asChild | Change the default rendered element for the one passed as a child, merging their props and behavior.(Composition 组合模式,将 props 与行为合并到子元素上) | boolean | No | - |
两点说明:
as的默认值是"div",即不传任何 props 时,MenubarGroup会渲染成一个div。AsTag类型的可选标签见 Primitive.ts,包括a、button、div、ul、li、nav等常用标签,也允许任意字符串标签(({} & string))。asChild为true时,MenubarGroup不再渲染自己的包裹元素,而是把自身行为"合并"进它唯一的子元素。这一机制由底层Primitive实现:当asChild生效时,Primitive 会走Slot分支(h(Slot, attrs, { default: slots.default }))完成 props 合并。
从源码结构看,MenubarGroup的全部 Props 都来自MenuGroupProps,而MenuGroupProps又直接继承PrimitiveProps——也就是说它没有自己独有的配置项,能力边界就是as与asChild这两个组合控制参数。
源码实现:一个薄封装如何复用 Menu 体系
Menubar系列组件并非独立实现,而是对通用Menu体系的封装。MenubarGroup.vue 的完整实现只有二十行:
<script lang="ts"> import type { MenuGroupProps } from '@/Menu' export interface MenubarGroupProps extends MenuGroupProps {} </script> <script setup lang="ts"> import { MenuGroup } from '@/Menu' import { useForwardExpose } from '@/shared' const props = defineProps<MenubarGroupProps>() useForwardExpose() </script> <template> <MenuGroup v-bind="props"> <slot /> </MenuGroup> </template>它的职责只有三件事:声明MenubarGroupProps(空的extends,纯命名空间)、把 props 原样透传给 MenuGroup、用useForwardExpose()把MenuGroup的内部引用转发出去。
真正干活的是 MenuGroup.vue:
<script setup lang="ts"> import { Primitive } from '@/Primitive' const props = defineProps<MenuGroupProps>() const id = useId(undefined, 'reka-menu-group') provideMenuGroupContext({ id }) </script> <template> <Primitive role="group" v-bind="props" :aria-labelledby="id" > <slot /> </Primitive> </template>这里有三层值得展开的实现细节:
- 无障碍语义:
MenuGroup固定渲染role="group",并挂上aria-labelledby。在 WAI-ARIA 的菜单模式中,grouprole 正是"菜单内条目分组"的语义载体,配合aria-labelledby指向分组标题后,读屏器能够把组内条目作为一组整体播报。 - ID 与 Context 机制:
useId(undefined, 'reka-menu-group')生成一个带reka-menu-group前缀的唯一 id,再通过createContext创建的provideMenuGroupContext向下提供。 - 谁消费这个 Context:仓库内唯一注入
injectMenuGroupContext的是 MenuLabel.vue。它在模板里把:id="groupContext.id || undefined"绑定到自身渲染元素上——即当MenubarLabel位于MenubarGroup内部时,标签元素自动拿到分组的 id,从而成为该分组aria-labelledby的目标。这就是"在分组内放一个 Label,分组就自动获得可访问名称"的实现链路。
as / asChild 的行为边界
as与asChild的优先级在 Primitive.ts 中写得很直白:
const asTag = props.asChild ? 'template' : props.as- 默认(两者都不传):渲染
div,即文档中声明的默认值"div"。 - 传
as="ul":渲染ul,适用于你希望用列表语义组织分组条目、并自行给条目写li样式的情形。 - 传
asChild:as被忽略,Primitive切换为Slot分支,把role="group"、aria-labelledby等属性合并到子元素上。此时分组不再有独立 DOM 节点,你自定义的子元素承担了分组的角色与无障碍属性。
需要注意的是:MenuGroup在v-bind="props"的同时硬编码了role="group"与aria-labelledby,这两个属性不受as/asChild影响,会始终保留(asChild模式下则被合并到子元素上)。
在 Menubar 中的实际使用示例
以一个"文件"下拉菜单为例,把"新建类"与"打开类"操作分别分组:
<script setup lang="ts"> import { MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRoot, MenubarSeparator, MenubarTrigger, } from 'reka-ui' </script> <template> <MenubarRoot> <MenubarMenu> <MenubarTrigger>File</MenubarTrigger> <MenubarPortal> <MenubarContent> <MenubarGroup> <MenubarLabel>New</MenubarLabel> <MenubarItem>New Tab</MenubarItem> <MenubarItem>New Window</MenubarItem> </MenubarGroup> <MenubarSeparator /> <MenubarGroup> <MenubarLabel>Open</MenubarLabel> <MenubarItem>Recent</MenubarItem> <MenubarItem>Open…</MenubarItem> </MenubarGroup> </MenubarContent> </MenubarPortal> </MenubarMenu> </MenubarRoot> </template>配合上面的 Context 机制,分组内的MenubarLabel会自动获得分组的 id,无需手工写aria-labelledby。官方文档 menubar.md 的 "With labels" 示例也演示了 Label 为区段提供标题的用法,将其放入MenubarGroup内即完成"分组 + 可访问标题"的组合。
键盘交互与无障碍保证
MenubarGroup本身不响应键盘,但它所处的 Menubar 整体遵循 WAI-ARIA Menu Button 设计模式,并使用 roving tabindex 管理焦点(见 menubar.md 的 Accessibility 章节)。相关按键行为:
| 按键 | 行为 |
|---|---|
Space | 焦点在MenubarTrigger上时打开菜单并聚焦第一个条目;焦点在条目上时激活该条目 |
Enter | 焦点在MenubarTrigger上时打开对应菜单;焦点在条目上时激活该条目 |
ArrowDown/ArrowUp | 在条目间向下/向上移动焦点(在 Trigger 上时ArrowDown打开菜单) |
ArrowRight/ArrowLeft | 在 Trigger 间移动焦点;在MenubarSubTrigger上按阅读方向开/关子菜单;在MenubarContent内切换到菜单条中的下一个菜单 |
Esc | 关闭当前打开的菜单,焦点回到对应MenubarTrigger |
role="group"的存在让读屏软件在进入分组时能感知结构变化,这也是把条目用MenubarGroup组织起来(而非仅靠MenubarSeparator视觉分隔)的价值所在。组件的无障碍表现由测试用例持续守护:Menubar.test.ts 使用vitest-axe断言"菜单收起"与"菜单打开"两种状态下axe均无违反项(toHaveNoViolations),覆盖了 Trigger 渲染、role="menu"挂载与条目选择关闭菜单等核心流程。
小结与延伸阅读路径
MenubarGroup是一个"零配置、强语义"的分组组件:
- 对外只有
as(默认div)与asChild两个 Props,API 文档见 MenubarGroup.md; - 实现上是对 MenuGroup 的薄封装(MenubarGroup.vue),核心产出是
role="group"元素 +aria-labelledby+ 向MenuLabel提供分组的 id Context; - 与
MenubarLabel组合可获得带可访问名称的分组,整个 Menubar 的键盘导航由 roving tabindex 统一接管。
如需继续深入,可直接查看:组合总览 menubar.md、通用菜单分组实现 MenuGroup.vue、标签联动实现 MenuLabel.vue、组合机制底层 Primitive.ts,以及无障碍回归测试 Menubar.test.ts。
【免费下载链接】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),仅供参考