From 51fd1d2b41a6a3525ecc238871fb12fec41e885c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 14:14:22 +0800 Subject: [PATCH] fix(web): only anchor presses dismiss the hover card The card is a React child of the wrapper, so capture-phase presses on it reached the wrapper's dismissal handler: the first pointerdown of a text selection closed the card, contradicting its JSDoc contract. Restrict the immediate close to presses outside the card, keeping it mounted under a held press (and the browser click with it), and align onPointerLeave's grace arming with Menu (only while open). Pin both new behaviors in hover-card.spec and update the bilingual Agent Note. --- ...-07-30-hover-popup-pointer-grace.i18n.yaml | 4 ++-- .../2026-07-30-hover-popup-pointer-grace.md | 2 +- ...2026-07-30-hover-popup-pointer-grace.zh.md | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 12 ++++++++--- .../ui-primitives/tests/hover-card.spec.tsx | 20 +++++++++++++++++++ 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml index b87f5de3b1..ed2f7646bc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md -2026-07-30-hover-popup-pointer-grace.md: e999fdea482c14b3b7864df4ba4cba55a89cd7b2 -2026-07-30-hover-popup-pointer-grace.zh.md: 100dfc5b37ed547a8615b2f0f9c3c225a1b592b5 +2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 +2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md index e999fdea48..3f60c98ec6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -12,7 +12,7 @@ Both popups the workspace browser rows raise floated out of reach of the pointer `usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../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 inside the anchor and an owner flipping `disabled` still dismiss immediately, ahead of the grace. +`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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md index 100dfc5b37..db10e15610 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -12,7 +12,7 @@ Status: implemented `usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。 -`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在锚点内按下指针以及所有者将 `disabled` 置真,仍会抢在宽限期之前立即关闭卡片。 +`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。 `Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 3a1ce462b3..a768606d86 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -109,11 +109,17 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }} onPointerLeave={() => { clearTimer() - armClose() + // Leaving a closed card schedules a no-op close; only arm while + // open, matching Menu's shape. + if (open) armClose() }} - // Any press inside the anchor (row click, menu trigger) dismisses the + // A press inside the anchor (row click, menu trigger) dismisses the // card immediately, without waiting for the owner to flip `disabled`. - onPointerDownCapture={() => { + // Capture presses reach this handler from the card too — it is a React + // child of the wrapper — but a press there starts a selection, so the + // card must stay mounted under it (and the browser's click with it). + onPointerDownCapture={(e) => { + if (cardRef.current?.contains(e.target as Node)) return clearTimer() cancelClose() setOpen(false) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 3826fdf79a..631bad78a0 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -108,6 +108,26 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) + it('a press on the card starts a selection instead of dismissing it', () => { + // The card is a React child of the wrapper, so capture-phase presses on + // it reach the wrapper's dismissal handler too; they must not close it, + // or the first pointerdown of a text-selection drag would kill the card. + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerDown(screen.getByText('card body')) + // Still mounted after a grace's worth of time: no close was armed either. + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('a press while closed leaves the card closed', () => { + mount() + fireEvent.pointerDown(screen.getByText('row')) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('disabled suppresses opening entirely', () => { const { wrapper } = mount({ disabled: true }) fireEvent.pointerEnter(wrapper)