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.
54 lines
1.8 KiB
TypeScript
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 }
|
|
}
|