pointer-grace.ts 1.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
  1. // Shared close timing for pointer-dismissed popups (HoverCard, hover-closing
  2. // Menu). Both float free of their anchor, so the pointer has to cross ground
  3. // that belongs to neither on its way in; closing on the first pointerleave
  4. // makes the popup unreachable. The grace turns that transit into a cancelable
  5. // pending close.
  6. import { useCallback, useEffect, useRef } from 'react'
  7. /**
  8. * Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap
  9. * (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a
  10. * popup lingering once the pointer has genuinely moved on.
  11. */
  12. export const POINTER_GRACE_MS = 200
  13. /** Cancelable delayed close for a pointer-dismissed popup. */
  14. export interface PointerGrace {
  15. /** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */
  16. arm: () => void
  17. /** Abort a pending close (the pointer came back). */
  18. cancel: () => void
  19. }
  20. /**
  21. * Delay a pointer-dismissed popup's close so the pointer can cross the gap
  22. * between anchor and popup. A pending close is dropped on unmount.
  23. * @param close - runs when the grace elapses with no re-entry; read at fire
  24. * time, so callers may pass a fresh closure each render.
  25. * @returns the {@link PointerGrace} handle.
  26. */
  27. export function usePointerGrace(close: () => void): PointerGrace {
  28. const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  29. const closeRef = useRef(close)
  30. closeRef.current = close
  31. const cancel = useCallback(() => {
  32. if (timerRef.current === null) return
  33. clearTimeout(timerRef.current)
  34. timerRef.current = null
  35. }, [])
  36. const arm = useCallback(() => {
  37. cancel()
  38. timerRef.current = setTimeout(() => {
  39. timerRef.current = null
  40. closeRef.current()
  41. }, POINTER_GRACE_MS)
  42. }, [cancel])
  43. useEffect(() => cancel, [cancel])
  44. return { arm, cancel }
  45. }