Hover popup pointer grace
悬浮弹层的指针宽限期
Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in
English
Problem
Both popups the workspace browser rows raise floated out of reach of the pointer. HoverCard closed on the first pointerleave from its anchor and rendered its card pointer-events: none, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed closeOnPointerLeave, whose handler sat on the portaled list: aiming back at the ... trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back.
Decision
usePointerGrace (packages/client/ui-primitives/src/pointer-grace.ts) owns one cancelable delayed close, shared by both atoms, with POINTER_GRACE_MS at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup.
HoverCard arms the grace on leave instead of closing, and its card no longer sets pointer-events: none, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping disabled dismiss immediately, ahead of the grace.
Menu moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on open alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh onClose closure each time.
Alternatives considered
Close the popups only on outside click and Escape. Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content.
Widen the anchor's hit area to abut the popup. Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize.
Keep the hover card pointer-events: none and only add the grace. Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached.
Give each atom its own timer. Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart.
Consequences
The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without closeOnPointerLeave are untouched — the wrapper handlers are only attached when it is set.
Testing
packages/client/ui-primitives/tests/hover-card.client.spec.tsx and tests/atoms.spec.tsx pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by apps/web/tests/workspace-management.e2e.ts, since they depend on hit testing and layout that jsdom does not model.
中文
问题
工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。HoverCard 在指针离开锚点的第一个 pointerleave 上就关闭,其卡片还设置了 pointer-events: none;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 closeOnPointerLeave,而其处理器挂在传送后的列表上:把指针移回打开该列表的 ... 触发按钮会关闭列表,指针越过列表边缘同样会将其关闭,且没有任何折返窗口。
决策
usePointerGrace(packages/client/ui-primitives/src/pointer-grace.ts)持有唯一一个可取消的延迟关闭,由两个原子组件共享,POINTER_GRACE_MS 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。
HoverCard 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 pointer-events: none,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有在锚点区域内发生按下操作,或所有者将 disabled 置真时,才会抢在宽限期之前立即关闭卡片。
Menu 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 open 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 onClose 闭包。
考虑过的替代方案
仅通过外部点击和 Escape 关闭这两种弹层。 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。
扩大锚点的命中区域,使其与弹层相接。 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和调整窗口大小时执行的每一次重新定位。
保留悬浮卡片的 pointer-events: none,只加入宽限期。 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。
让两个原子组件各自持有计时器。 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。
后果
悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单重新打开后,也不会被残留的待执行关闭关掉。未设置 closeOnPointerLeave 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。
测试
packages/client/ui-primitives/tests/hover-card.client.spec.tsx 与 tests/atoms.spec.tsx 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 apps/web/tests/workspace-management.e2e.ts 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。