跳到主要内容

Tooltip/HoverCard focus-open 收紧为仅 :focus-visible(DES-134)

版本: 0.4.2 · 类型: 🐛 Bug Fix

DES-134DES-128 Overlay composable triggers 的后续修复)

关联方案:docs/overlay-composable-triggers.md

问题

<Tooltip><DropdownMenu items>…</DropdownMenu></Tooltip>(或反向嵌套)组合时,两个 overlay 的 trigger 合并到同一个真实 DOM。点击 DropdownMenu 里的 item 关闭菜单后,Tooltip 又出现了

根因:菜单关闭时 Radix Menu 的 onCloseAutoFocus 会把焦点编程式还原回共享 trigger。而「focus 即打开」型的 overlay 会把这次焦点还原当成键盘聚焦、重新打开浮层。该问题与嵌套方向无关,对所有「从这类 trigger 打开弹层(DropdownMenu / Dialog / Popover),弹层关闭后焦点还原」的场景普遍存在。

受影响范围(按 trigger 是否「focus 即打开」判定):

  • TooltiponFocus → onOpen,仅用 isPointerDownRef 挡掉鼠标按下的 focus,挡不住编程式 focus → 有问题。
  • HoverCardonFocus → onOpenreact-hover-card Trigger),isPointerDownRef 都没有,任何 focus 都打开 → 有问题,且更严重。
  • Popover / DropdownMenu:click / keydown 触发,无 onFocus 打开 → 焦点还原不会误开,不受影响。

改动文件

  • src/utils/focus-visible-trigger.ts(新增,共享 helper)
  • test/focus-visible-mock.ts(新增,单测共享 :focus-visible 打桩工具)
  • src/components/Tooltip/Tooltip.tsx
  • src/components/Tooltip/__tests__/Tooltip.test.tsx
  • src/components/HoverCard/HoverCard.tsx
  • src/components/HoverCard/__tests__/HoverCard.test.tsx
  • CLAUDE.md(§8.3 补「focus-open overlay 仅 :focus-visible 放行」约定)

改动内容

  • 新增内部共享 helper createFocusVisibleTriggerGuardsrc/utils/focus-visible-trigger.ts,不对外导出):返回一个 onFocus 守卫——仅当焦点是键盘可见焦点(event.currentTarget.matches(':focus-visible'))时才放行让 Radix 打开浮层;鼠标 / 编程式焦点调用 event.preventDefault()
  • 利用 Radix Tooltip / HoverCard Trigger 的 onFocus: composeEventHandlers(props.onFocus, onOpen)(默认 checkForDefaultPrevented: true)特性——传入的 onFocus 一旦 preventDefault,后续的 onOpen 即被跳过。这是稳定的官方注入点,无需 fork / 受控。
  • Tooltip / HoverCard 分别把该守卫接到内部 TooltipTrigger / HoverCardTriggeronFocus。守卫先执行组合注入的 forwarded.onFocus(保留其可能的 preventDefault 语义)再做判断,不覆盖可组合注入项。
  • :focus-visible 不被环境支持时(如 happy-dom)try/catch 兜底为「保持 Radix 默认行为」,不抛错。
  • 单测:Tooltip / HoverCard 各新增「focus-visible 守卫」分块 2 条——可见焦点(matches(':focus-visible') 桩为 true)时仍因 focus 打开;非可见焦点(桩为 false)时不打开。打桩逻辑提取为共享工具 test/focus-visible-mock.tsmockFocusVisible(visible):仅拦截 :focus-visible、其余选择器委托原实现,避免破坏 getByRole 角色查询。

边界与说明

  • 这是面向 Tooltip / HoverCard 所有用法的 focus-open 行为收紧(非仅组合场景):外层嵌套方向(overlay 在外)无法可靠探知 child 是否为 menu,只有全局收紧才能两个方向都覆盖,且「仅在键盘可见焦点时显示」本就更符合无障碍语义。
  • hover 打开(走 onPointerMove / onPointerEnter)与键盘 Tab 聚焦(:focus-visible 为真)打开均不受影响;唯一变化是编程式 / 非可见焦点不再打开浮层。
  • 用键盘 Enter 选择 menu item 关闭时,焦点还原是可见焦点 → 浮层显示,对键盘用户是合理反馈,不视为问题。
  • happy-dom 测不到真实 :focus-visible,单测通过对 Element.prototype.matches 打桩验证守卫逻辑;真实浏览器行为建议后续以 CT 用例(菜单关闭后浮层不出现)补充。