fix(web): let the pointer reach hover cards and row menus

The workspace browser's two hover-raised popups both died on the way to
them. HoverCard closed on the first pointerleave and rendered its card
pointer-events:none, but the card sits 8px off the anchor, so every path
to it crossed ground belonging to neither. The row action menus put
closeOnPointerLeave's handler on the portaled list, so aiming back at the
... trigger that opened it, or overshooting a list edge, closed it with no
window to come back.

usePointerGrace owns one cancelable delayed close (200ms) shared by both
atoms: leaving arms it, returning cancels it. The hover card becomes
hit-testable so resting on it holds it open, and Menu moves pointer-leave
dismissal to the wrapper span, where React's enter/leave traversal makes
trigger and portaled list one region.

Both gestures are pinned in the real browser lane; each fails without the
corresponding fix.
This commit is contained in:
creatixchu
2026-07-30 20:25:02 +08:00
parent 2e8c82634e
commit 2cac565383
10 changed files with 341 additions and 38 deletions

View File

@@ -2,6 +2,7 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
afterEach(cleanup)
beforeEach(() => { vi.useFakeTimers() })
@@ -54,18 +55,47 @@ describe('HoverCard', () => {
expect(screen.queryByText('card body')).toBeNull()
})
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
expect(screen.getByText('card body')).toBeTruthy()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByText('card body')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('reaching the card inside the grace keeps it open without restarting the dwell', () => {
// The portaled card is a React child of the wrapper, so the pointer
// arriving on it re-enters the wrapper — the gesture the 8px anchor gap
// used to make impossible.
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('re-entering while open does not queue a second dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.pointerEnter(wrapper)
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
// A dwell restarted by the redundant enter would reopen the card here.
act(() => { vi.advanceTimersByTime(500) })
expect(screen.queryByText('card body')).toBeNull()
})
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
@@ -135,6 +165,7 @@ describe('HoverCard', () => {
expect(card.style.left).toBe('308px')
expect(card.style.top).toBe('90px')
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByText('card body')).toBeNull()
})