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:
@@ -7,7 +7,9 @@
|
||||
|
||||
/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
|
||||
* menu card's elevation. Surface is #2C2C2E in both themes (figma value,
|
||||
* light/dark identical), so a component-level variable, not a theme token. */
|
||||
* light/dark identical), so a component-level variable, not a theme token.
|
||||
* Hit-testable on purpose: resting the pointer on the card holds it open
|
||||
* (HoverCard's grace close), which a `pointer-events: none` card cannot do. */
|
||||
.card {
|
||||
--dsw-hovercard-bg: #2C2C2E;
|
||||
position: fixed;
|
||||
@@ -18,5 +20,4 @@
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-hovercard-bg);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
// HoverCard: delayed hover-preview card portaled to document.body.
|
||||
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
|
||||
// the card is fixed-positioned at its right edge and repositions on
|
||||
// scroll/resize while open. Display-only — the card ignores pointer events
|
||||
// and closes the instant the pointer leaves the anchor (no close delay).
|
||||
// scroll/resize while open. The card is reachable: it takes pointer events,
|
||||
// and leaving the anchor only arms a grace-delayed close, so the pointer can
|
||||
// cross the 8px gap and settle on the card to read a clipped path or title.
|
||||
// The portaled card is a React child of the wrapper, so React's enter/leave
|
||||
// traversal already treats it as inside — one pair of wrapper handlers covers
|
||||
// anchor and card alike.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { usePointerGrace } from './pointer-grace.ts'
|
||||
import css from './HoverCard.module.css'
|
||||
|
||||
/**
|
||||
* Render an anchor with a hover-triggered preview card.
|
||||
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
|
||||
* @param props.content - card content (display-only, no pointer interaction).
|
||||
* @param props.content - card content; the pointer may rest on it, so it is
|
||||
* readable and selectable, but it carries no dismissal affordance of its own.
|
||||
* @param props.openDelayMs - hover dwell before the card shows (default 500).
|
||||
* @param props.disabled - suppress opening; turning true closes an open card.
|
||||
* @returns anchor wrapper with the conditional portaled card.
|
||||
@@ -29,6 +35,8 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) })
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
@@ -40,8 +48,9 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
useEffect(() => {
|
||||
if (!disabled) return
|
||||
clearTimer()
|
||||
cancelClose()
|
||||
setOpen(false)
|
||||
}, [disabled])
|
||||
}, [disabled, cancelClose])
|
||||
|
||||
useEffect(() => clearTimer, [])
|
||||
|
||||
@@ -91,17 +100,22 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
className={css.root}
|
||||
onPointerEnter={() => {
|
||||
if (disabled) return
|
||||
// Coming back inside during the grace (the gap, or the card itself)
|
||||
// keeps the current card rather than restarting the dwell.
|
||||
cancelClose()
|
||||
if (open) return
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
armClose()
|
||||
}}
|
||||
// Any press inside the anchor (row click, menu trigger) dismisses the
|
||||
// card immediately, without waiting for the owner to flip `disabled`.
|
||||
onPointerDownCapture={() => {
|
||||
clearTimer()
|
||||
cancelClose()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16 } from './icons/index.tsx'
|
||||
import { usePointerGrace } from './pointer-grace.ts'
|
||||
import css from './Menu.module.css'
|
||||
|
||||
/** Selectable row (optionally with a nested submenu). */
|
||||
@@ -69,8 +70,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* from the anchor rect (repositions on scroll/resize while open). Use when an
|
||||
* ancestor's overflow clipping would crop the in-place list; default false
|
||||
* keeps the pure-CSS in-place behavior.
|
||||
* @param props.closeOnPointerLeave - close the list when the pointer leaves
|
||||
* it (default false keeps it open until outside click/Escape/selection).
|
||||
* @param props.closeOnPointerLeave - close the list once the pointer has left
|
||||
* both trigger and list for the pointer grace (default false keeps it open
|
||||
* until outside click/Escape/selection). The grace makes the 4px trigger->list
|
||||
* gap and a brief overshoot survivable; coming back cancels the close.
|
||||
* @param props.compact - use reduced menu typography and spacing.
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
@@ -102,6 +105,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
|
||||
const [fixedPos, setFixedPos] = useState<CSSProperties | null>(null)
|
||||
const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
|
||||
|
||||
// Portal mode: fixed-position the list from the anchor rect before paint;
|
||||
// track the anchor while open (capture-phase scroll catches nested panes).
|
||||
@@ -179,6 +183,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
// A close from selection/Escape/outside click outruns a pending grace close;
|
||||
// left armed it would shut a list reopened inside the grace window. Its own
|
||||
// effect, not the listener effect above: that one re-runs on every `onClose`
|
||||
// identity change and would cancel the grace mid-transit.
|
||||
useEffect(() => {
|
||||
if (!open) cancelClose()
|
||||
}, [open, cancelClose])
|
||||
|
||||
// The submenu card is absolutely positioned outside the list box; the
|
||||
// scroll clip would crop it, so only submenu-free menus get the height cap.
|
||||
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
|
||||
@@ -251,7 +263,6 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
// React portals bubble synthetic events through the REACT tree: without
|
||||
// this stop, an item click re-fires the anchor row's own onClick
|
||||
// (open/toggle) after onSelect.
|
||||
@@ -268,8 +279,17 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
</div>
|
||||
)
|
||||
|
||||
// Pointer-leave dismissal watches the WRAPPER, not the list: React's
|
||||
// enter/leave traversal runs over the React tree, so trigger and portaled
|
||||
// list are one region here. Aiming back at the trigger, or crossing the 4px
|
||||
// gap between them, therefore never counts as leaving.
|
||||
return (
|
||||
<span ref={rootRef} className={clsx(css.root, className)}>
|
||||
<span
|
||||
ref={rootRef}
|
||||
className={clsx(css.root, className)}
|
||||
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
|
||||
onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
|
||||
>
|
||||
{anchor}
|
||||
{portal ? (list !== false && createPortal(list, document.body)) : list}
|
||||
</span>
|
||||
|
||||
53
packages/client/ui-primitives/src/pointer-grace.ts
Normal file
53
packages/client/ui-primitives/src/pointer-grace.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Shared close timing for pointer-dismissed popups (HoverCard, hover-closing
|
||||
// Menu). Both float free of their anchor, so the pointer has to cross ground
|
||||
// that belongs to neither on its way in; closing on the first pointerleave
|
||||
// makes the popup unreachable. The grace turns that transit into a cancelable
|
||||
// pending close.
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap
|
||||
* (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a
|
||||
* popup lingering once the pointer has genuinely moved on.
|
||||
*/
|
||||
export const POINTER_GRACE_MS = 200
|
||||
|
||||
/** Cancelable delayed close for a pointer-dismissed popup. */
|
||||
export interface PointerGrace {
|
||||
/** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */
|
||||
arm: () => void
|
||||
/** Abort a pending close (the pointer came back). */
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay a pointer-dismissed popup's close so the pointer can cross the gap
|
||||
* between anchor and popup. A pending close is dropped on unmount.
|
||||
* @param close - runs when the grace elapses with no re-entry; read at fire
|
||||
* time, so callers may pass a fresh closure each render.
|
||||
* @returns the {@link PointerGrace} handle.
|
||||
*/
|
||||
export function usePointerGrace(close: () => void): PointerGrace {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closeRef = useRef(close)
|
||||
closeRef.current = close
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current === null) return
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}, [])
|
||||
|
||||
const arm = useCallback(() => {
|
||||
cancel()
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
closeRef.current()
|
||||
}, POINTER_GRACE_MS)
|
||||
}, [cancel])
|
||||
|
||||
useEffect(() => cancel, [cancel])
|
||||
|
||||
return { arm, cancel }
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -160,16 +161,77 @@ describe('Menu', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith('del')
|
||||
})
|
||||
|
||||
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
rerender(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// Still open through the grace: the pointer may be crossing the gap.
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
rerender(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('coming back inside the grace keeps the list open (trigger and list are one region)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a close from selection disarms the pending grace close', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// The owner closes for its own reason (selection/Escape) mid-grace; the
|
||||
// armed timer must not survive to shut a list reopened right after.
|
||||
rerender(
|
||||
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaving a closed list arms nothing', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user