Files
deepseek-harness/packages/client/ui-primitives/src/pointer-grace.ts
T
creatixchu 2cac565383 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.
2026-07-30 20:25:02 +08:00

54 lines
1.8 KiB
TypeScript

// 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 }
}