返回博客

HoverCard 无限闪烁:焦点乒乓死循环与 initialFocus= 的救场

某个管理后台需要一张富信息悬停卡片(HoverCard),在鼠标悬停时弹出详细信息。由于所用的组件库(Base UI rc.0)没有内置 openOnHover,我选择在 Popover.Root 上手动接线:触发器的 onMouseEnter / onFocusscheduleOpenonMouseLeave / onBlurscheduleClose。逻辑听起来无懈可击,实测一放鼠标——卡片以肉眼可见的频率反复开关,控制台里 open 的变化序列是 0,1,0,1,0,1,...,永无停歇。

现象

  • 鼠标悬停触发器,HoverCard 疯狂闪烁,无法稳定展示。
  • 把鼠标停在触发器上完全没用,卡片始终在「刚打开就关掉」的循环里。
  • onFocus / onBlur 拆掉之后,鼠标路径下的闪烁消失——但键盘用户彻底无法使用。

根因

Base UI Popover(以及 Radix Popover/Dialog、任何带 focus trap 的浮层原语)在打开时会把焦点自动移入弹层,关闭时再把焦点还给触发器。这是为 Modal 类交互设计的无障碍行为,用在 HoverCard 上却造成死循环:

  1. 触发器获得焦点 → onFocusopen = true
  2. Popover 打开,焦点移入弹层 → 触发器触发 onBluropen = false
  3. Popover 关闭,焦点还给触发器onFocusopen = true
  4. 回到第 2 步,无限往复

即便完全靠鼠标操作,onMouseEnter 打开弹层 → 弹层抢焦点 → 触发器 onBlur → 关闭 → 焦点还回来 → onFocus 再开——同样中招。

坑:Popover 的焦点管理是为 Modal 设计的,HoverCard 不需要

HoverCard 是只读的信息展示面(类 Tooltip 语义),用户不需要把焦点移进去操作。但底层 Popover 原语默认会在 open 时把焦点挪进弹层、close 时还给触发器——这个「善意」的无障碍行为,配上触发器的 onFocus/onBlur 处理器,构成完美死循环。

调延迟(closeDelay / openDelay)、调 sideOffset、改 modal={false}无法打破这个循环,因为根本原因是焦点在触发器和弹层之间来回传递。

正解

在弹层元素上同时关掉两个方向的焦点移动,让焦点始终留在触发器上:

<BasePopover.Popup
  initialFocus={false}   // open 时:不把焦点移入弹层
  finalFocus={false}     // close 时:不把焦点还给触发器
  onMouseEnter={ctx.open}
  onMouseLeave={ctx.close}
>
  {/* HoverCard 内容 */}
</BasePopover.Popup>

如果使用 Radix UI,对应的写法是在 Content 上阻止默认行为:

<PopoverContent
  onOpenAutoFocus={(e) => e.preventDefault()}
  onCloseAutoFocus={(e) => e.preventDefault()}
>

关掉两个方向后,焦点的状态变为:

  • 鼠标悬停 → 卡片打开,焦点留在触发器,不触发 onBlur,循环断开
  • 键盘 Tab 到触发器 → onFocus → 卡片打开,焦点留在触发器(Tooltip 语义);Tab 离开 → onBlur → 卡片关闭,行为符合预期
正解:initialFocus={false} + finalFocus={false} 切断乒乓

initialFocus={false} 阻止开启时抢焦点,finalFocus={false} 阻止关闭时还焦点,两个方向都切掉,焦点全程钉在触发器上。HoverCard 从此安静展示,键盘无障碍也保留完好(Tab 到触发器开、Tab 离开关)。

排查陷阱:Playwright 看不到这个 bug

用 Playwright 自动化测试时,mouse.move() 模拟鼠标悬停,但合成鼠标事件不会真正给触发器 DOM 分配焦点,所以在自动化测试里一切看起来都正常。想复现这个 bug,必须走焦点路径:

await page.evaluate(() => triggerButton.focus()); // 驱动 focus 路径才能触发死循环

这个「自动化看不见、真实用户立刻踩」的差异,是排查悬停类 bug 时要优先测试焦点路径的信号。

一句话外卖

把 HoverCard 搭在 Popover 原语上时,一定要关掉弹层的焦点劫持(initialFocus={false} + finalFocus={false});Modal 级焦点管理是 Tooltip 类场景的隐形炸弹。