- 前端
- UI组件
【免费下载链接】zag
Build your design system in React, Solid, Vue, Svelte or Vanilla. Powered by finite state machines
本篇文章以 Zag.js 仓库中packages/machines/navigation-menu/CHANGELOG.md为骨架,结合该机器人的源码、示例与端到端测试,系统梳理@zag-js/navigation-menu从 1.3.0 到 1.46.0 的版本演进脉络:包括破坏性 API 调整、类型系统修复、无障碍(a11y)改进、国际化支持,以及它作为"有限状态机驱动的导航菜单"在 hover/click/键盘交互、viewport 定位、tab 顺序管理等方面的底层实现。读完本文,你将掌握该组件的完整 API 能力、关键设计取舍,并能基于仓库源码快速定位到对应实现。
一、模块定位:状态机驱动的导航菜单
@zag-js/navigation-menu是 Zag.js 仓库中 Navigation Menu(导航菜单)组件的核心逻辑包,按 README 的描述,它是"以状态机形式实现的 navigation-menu 部件核心逻辑"。与传统的 UI 组件库不同,它不直接渲染任何 DOM,而是提供:
machine:有限状态机定义(navigation-menu.machine.ts);connect:把机器状态连接到框架无关的 props API(navigation-menu.connect.ts);anatomy:部件结构定义(navigation-menu.anatomy.ts);- 完整的 TypeScript 类型(navigation-menu.types.ts)。
从 anatomy 源码 可以看到该部件由 11 个 part 组成:root、viewportPositioner、viewport、trigger、content、list、item、link、indicator、itemIndicator、arrow。其中indicator是随选中项滑动的背景指示条,viewport是用于承载多个下拉内容、实现"内容集中浮出"效果的面板,这两者共同构成了导航菜单区别于普通下拉菜单的核心交互形态。
二、版本演进总览:依赖更新与功能变更的模式
通读整份 CHANGELOG 可以发现,@zag-js/navigation-menu的发布节奏遵循 monorepo 同步版本策略:绝大多数版本条目是 "Updated dependencies"(依赖同步更新),涉及@zag-js/anatomy、@zag-js/core、@zag-js/types、@zag-js/utils、@zag-js/dom-query,早期版本(1.27.0 及之前)还依赖@zag-js/interact-outside,后期则切换为@zag-js/dismissable。从 1.46.0 的依赖清单看,当前版本固定依赖@zag-js/dismissable、@zag-js/anatomy、@zag-js/core、@zag-js/types、@zag-js/utils、@zag-js/dom-query六个基础包。
在这些同步更新之外,真正属于 navigation-menu 自身的功能性变更主要有:
| 版本 | 变更类型 | 核心内容 |
|---|---|---|
| 1.30.0 | Minor | 新增getItemIndicatorProps;BREAKING:移除getIndicatorTrackProps;新增closeOnClick;hover 不再聚焦 trigger |
| 1.36.0 | Patch | 新增translationsprop,本地化硬编码的无障碍标签 |
| 1.40.0 | Patch | 移除冗余的默认aria-label |
| 1.43.0 | Patch | 合并 props 时组合data-ownedby,DOM 查询按 owner 匹配,暴露isOwnedBy |
| 1.43.1 | Patch | getTriggerProps()添加type="button",修复表单隐式提交问题 |
| 1.43.3 | Patch | 类型修复:可选属性改为显式?: T \| undefined |
| 1.27.1 | Patch | 导出缺失的类型 |
| 1.18.5 | Patch | 修复解构返回的 api 触发 ESLintunbound-method警告的问题 |
| 1.3.0 | Patch | 修复持有复杂对象的机器问题 |
下面逐项深入。
三、1.30.0:一次聚焦指示条的破坏性 API 演进
1.30.0 是这份 CHANGELOG 中唯一标注Minor(次要版本)的发布,包含两处值得注意的变化:
3.1 移除getIndicatorTrackProps,getListProps承担轨道职责
CHANGELOG 给出了明确的迁移示例:
// Before <div {...api.getIndicatorTrackProps()}> <div {...api.getListProps()}> {/* items */} </div> </div> // After <div {...api.getListProps()}> {/* now acts as indicator track */} {/* items */} </div>在现有源码中,这一设计得到了落实:getListProps返回的 list 元素强制带有style: { position: "relative" },为position: "absolute"的 indicator 提供了定位基准;而getIndicatorProps正是以绝对定位方式渲染在 list 内,并在首次打开时通过preventTransition跳过过渡动画。从源码结构看,这正是当初"list 兼任 indicator track"这一破坏性变更的直接实现依据。
3.2 新增getItemIndicatorProps与closeOnClick
getItemIndicatorProps(props):渲染在单个 item 内的指示元素,仅当该项被选中时可见(hidden: !itemState.selected),并携带data-state: open/closed、data-orientation、data-value等属性,见 connect 实现。closeOnClick:新增到LinkProps,默认true(保持原有行为),用于控制点击链接后导航菜单是否关闭。在getLinkProps中可以看到其完整逻辑:点击链接时会派发可取消的link.select自定义事件,只有closeOnClick && !linkSelectEvent.defaultPrevented && !event.metaKey时才会发送CLOSE事件——这意味着你可以通过onSelect回调preventDefault()或按住 Meta 键来阻止关闭。- hover 不再聚焦 trigger:同版本还移除了"鼠标悬停即聚焦 trigger"的行为,避免悬停移动焦点干扰键盘用户的焦点预期。这与 e2e 测试中"hover 打开但不聚焦、键盘打开才聚焦"的交互设计一脉相承。
四、无障碍(a11y)与国际化演进
4.1 1.36.0:新增translationsprop
在 1.36.0 中,navigation-menu 与 clipboard、popover、select、timer、toast、tree-view 等机器一起引入了translationsprop,用于"硬编码无障碍标签的本地化"(修复 issue #3004)。对应类型为 IntlTranslations,目前只暴露一个可配置字段:
export interface IntlTranslations { rootLabel?: string | undefined }该值在getRootProps中被消费,作为 root 元素的aria-label。这意味着导航菜单的根标签可以由使用方完全控制,而不是写死在组件内部。
4.2 1.40.0:移除冗余默认 aria-label
1.40.0(PR #3062)移除了"激进且冗余的默认 aria-label"。结合 1.36.0 引入的translations,这一演进路线很清晰:无障碍标签从"组件替你生成默认值"逐步走向"由使用方按需提供",避免默认标签在不同语言环境或语义场景下造成重复朗读与误导。
4.3 1.43.1:type="button"修复隐式表单提交
这个修复(steps、navigation-menu 的getTriggerProps(),drawer、tour 的getCloseTriggerProps())虽然改动只有一行,但价值极高:没有显式type的<button>默认是type="submit",一旦导航菜单被放入某个<form>内,点击 trigger 就会提交整个表单。当前源码中getTriggerProps已固定返回type: "button",配合disabled、aria-controls、aria-expanded、data-*系列属性,构成一个完整、语义正确的触发按钮。
五、类型系统与框架兼容性修复
5.1 1.43.3:Partial导出遮蔽问题
这是一个典型的 monorepo 类型污染案例:@zag-js/types中某导出遮蔽了内置Partial,导致每个导入它的文件里的Partial<T>含义被改变,并破坏了@vue/compiler-sfc对interface X extends Partial<Y>的解析。修复方式是把IntlTranslations、ElementIds等类型从"包装在Partial中"改为"可选的显式?: T | undefined",同时保持"只传单个 translation key 依然可用"的兼容性。查看当前 types 源码 可以看到ElementIds正是以显式可选属性形式声明的接口:
export interface ElementIds { root?: string | undefined list?: string | undefined item?: string | undefined trigger?: ((value: string) => string) | undefined content?: ((value: string) => string) | undefined viewport?: string | undefined }5.2 1.27.1:导出缺失类型;1.18.5:unbound-method修复
- 1.27.1(PR #2822)补齐了导出缺失的类型,保证下游消费者能拿到完整的类型签名。当前 index.ts 导出了
Api、Machine、Props、Service以及ItemProps、ItemState、LinkProps、ContentProps、ViewportProps、ArrowProps、ElementIds、IntlTranslations、ValueChangeDetails等全部类型。 - 1.18.5 修复了"解构返回的 api 会触发 ESLint
unbound-method警告"的问题。在 React 等框架中,const { getTriggerProps } = api这类解构非常常见,若不将 api 方法绑定到机器实例,解构后的方法调用可能丢失this上下文,从而被 ESLint 标记。该修复保证了connect返回的 api 方法可以安全解构使用——这也是 basic.tsx 示例 中大量直接使用api.getXXXProps()的前提。
5.3 1.43.0:data-ownedby归属机制
1.43.0 在底层 DOM 查询体系中引入了"归属(ownership)"概念:合并 props 时组合data-ownedby值、DOM 查询按 owner 匹配 token、并暴露isOwnedBy做成员检查。在 navigation-menu 中,getLinkProps会给链接写入data-ownedby: getContentId(scope, props.value),而 navigation-menu.dom.ts 的getLinkEls正是通过getByOwnerId(getContentId(ctx, value))精确查询属于某内容面板的链接。这一机制让"多实例共存、面板内链接精确归属"成为可能。
六、核心实现:从 props 到视图的完整链路
6.1 机器配置与 props 默认值
machine在props()中固化了一套默认配置,见 navigation-menu.props.ts 与 types:
| 配置项 | 默认值 | 说明 |
|---|---|---|
dir | "ltr" | 文本方向,支持rtl |
orientation | "horizontal" | 菜单方向,支持vertical |
openDelay | 200 | 悬停后打开菜单的延迟(毫秒) |
closeDelay | 300 | 移出后关闭菜单的延迟(毫秒) |
defaultValue | "" | 非受控模式下的默认选中值 |
value/onValueChange | — | 受控模式下的值与变更回调 |
disableClickTrigger | false | 禁用点击触发 |
disableHoverTrigger | false | 禁用悬停触发 |
disablePointerLeaveClose | false | 禁用指针移出关闭 |
translations | — | 无障碍标签本地化 |
ids | — | 自定义元素 id(root/list/item/trigger/content/viewport) |
这些默认值与 shared/src/controls.ts 中 playground 控制面板的默认值保持一致(dir: ltr、openDelay: 200、closeDelay: 300),方便在演示页直接验证。
6.2 打开/关闭的延时机制
navigation-menu.utils.ts集中实现了延时控制:setOpenTimeout/clearOpenTimeout按 value 分别管理每个菜单的打开定时器(存于openTimeoutIds记录),setCloseTimeout/clearCloseTimeout管理统一的关闭定时器。机器层的setValueWithDelay还有一个值得注意的优化:如果菜单已处于打开状态(值非空),再次切换菜单会跳过openDelay,实现"即时切换"。这一点被 e2e 测试 "switching menus is instant when one is open" 与 "multiple menus rapid switching"(见 navigation-menu.e2e.ts)直接验证。
6.3 viewport 定位算法
当使用 viewport 模式时,机器会测量 trigger 与 content 的几何信息,通过 setViewportPosition 计算 viewport 的x/y坐标:
- 支持
start/center(默认)/end三种对齐方式,由ViewportProps.align控制; - 计算后强制保留 10px 屏幕边距,超出屏幕时向反方向回退,并再次校验越界;
- 最终
Math.round取整,避免小数坐标导致字体模糊。
定位结果通过 CSS 变量(--viewport-x、--viewport-y、--viewport-width、--viewport-height、--trigger-width等)暴露给样式层,见 getRootProps 与 getViewportProps。机器还通过trackDocumentResize效果监听 document 与 root 的尺寸变化并重新定位。
6.4 tab 顺序与焦点管理
导航菜单最复杂的部分之一是焦点管理。机器维护restoreContentTabOrder引用,配合 dom.ts 的 removeFromTabOrder 实现"关闭时把内容链接移出 Tab 顺序、打开时恢复";getTriggerProxyProps提供视觉隐藏的代理元素,让焦点在内容末尾可以自然流出到下一个触发项;getViewportProxyProps则通过aria-owns把 viewport 中的内容关联回对应的 item。e2e 测试中的 "focus restoration after escape"、"tab navigation"、"shift+tab navigation (backwards)"(见 navigation-menu.e2e.ts)完整覆盖了这些路径。
七、实战示例:两种典型用法
7.1 基础模式(inline 内容)
basic.tsx展示了最经典的用法:item内嵌trigger+content,每个内容面板直接挂在 item 下;同时演示了getIndicatorProps(背景指示条)与getArrowProps(小箭头)的组合,以及"pricing"这种直接使用getLinkProps的顶级链接。示例还通过 Presence 组件 包裹 content 以实现进出场动画。
7.2 viewport 模式(集中浮出面板)
viewport.tsx演示了高级用法:所有 item 的 content 统一放到getViewportPositionerProps()内的getViewportProps()面板中,配合每个 item 内的getTriggerProxyProps与getViewportProxyProps代理元素,实现"移动端常见的顶部集中式 mega menu"效果,不同 content 可设置不同的宽度与栅格列(如width: 600、gridTemplateColumns: "1fr 2fr")。机器通过checkViewportNode在入口检测 viewport 节点是否存在,从而决定是否进入 viewport 渲染模式(见 machine.ts)。
八、可靠性保障:端到端测试矩阵
e2e/navigation-menu.e2e.ts 是理解该组件行为契约的最佳文档,它覆盖了:
- 打开方式:hover 打开、click 打开、Enter/Space 键盘打开;
- 关闭方式:点击外部关闭、Escape 关闭、hover 切换关闭、
closeDelay期间的 Escape 取消关闭; - 焦点与键盘导航:trigger 间的 ArrowLeft/Right/Home/End 导航,content 内 ArrowUp/Down/Home/End 导航(且不循环),Shift+Tab 反向导航,Escape 后焦点回到 trigger;
- 状态边界:hover 打开后点击关闭再悬停不会立即重开、多个菜单快速切换、打开/关闭循环后的状态一致性。
这些测试与 CHANGELOG 中 1.30.0(hover 不聚焦)、1.43.1(type="button")等修复一一呼应,构成了"变更记录—源码实现—测试验证"的完整闭环。
九、小结:从 CHANGELOG 中读出的设计哲学
回看整份 CHANGELOG,可以提炼出@zag-js/navigation-menu的几条演进主线:
- API 精简:1.30.0 合并 indicator track 与 list,移除冗余 part,让渲染结构更扁平;
- 无障碍优先:从默认
aria-label到translations可配置,再到修复按钮type,每一步都在消除辅助技术下的错误体验; - 类型严谨性:
Partial遮蔽修复、缺失类型补导,保证跨框架(React/Solid/Vue/Svelte)与工具链(@vue/compiler-sfc、ESLint)的兼容; - 底层查询体系演进:
data-ownedby归属机制让多实例与复杂嵌套下的 DOM 查询更精确; - 依赖同步演进:monorepo 内与
core、dom-query、dismissable等基础包保持同版本发布,组件行为随基础设施共同升级。
对于想要深入源码的读者,推荐按types → machine → connect → dom → utils的顺序阅读 src 目录,再对照 basic.tsx 与 viewport.tsx 两个示例,最后用 e2e 测试 验证理解——这条路径足以让你完整掌握 Zag.js 导航菜单的状态机设计。
- 前端
- UI组件
【免费下载链接】zag
Build your design system in React, Solid, Vue, Svelte or Vanilla. Powered by finite state machines
相关推荐
@zag-js/menu 状态机深度解读:从 CHANGELOG 看 Zag 菜单组件的演进与实现原理
@zag js/menu 状态机深度解读:从 CHANGELOG 看 Zag 菜单组件的演进与实现原理 @zag js/menu 是 Zag 生态中基于有限状态
前端UI组件zag Scroll Area 状态机:从 CHANGELOG 版本演进看 `@zag-js/scroll-area` 的完整实现原理
zag Scroll Area 状态机:从 CHANGELOG 版本演进看 @zag js/scroll area 的完整实现原理 在 zag( @zag js
前端UI组件@zag-js/marquee 演进全解析:从 CHANGELOG 看 zag 滚动字幕状态机的设计与实现
@zag js/marquee 演进全解析:从 CHANGELOG 看 zag 滚动字幕状态机的设计与实现 本文以 packages/machines/marq
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考