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.
This commit is contained in:
creatixchu
2026-07-31 14:14:22 +08:00
parent 2cac565383
commit 51fd1d2b41
5 changed files with 33 additions and 7 deletions

View File

@@ -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)

View File

@@ -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)