39 lines · 1.4 KB
import { useEffect, useRef } from "react";

/**
 * Closes a popover when the user clicks or tabs outside of it, or presses Escape. Attach the returned
 * ref to the element that wraps both the trigger and the popover so clicking the trigger still toggles it.
 */
export function useDismiss<T extends HTMLElement>(open: boolean, onDismiss: () => void) {
  const ref = useRef<T>(null);
  const dismiss = useRef(onDismiss);
  dismiss.current = onDismiss;

  useEffect(() => {
    if (!open) return;
    const outside = (target: EventTarget | null) => !(target instanceof Node && ref.current?.contains(target));
    const onPointer = (event: PointerEvent) => {
      if (outside(event.target)) dismiss.current();
    };
    const onFocus = (event: FocusEvent) => {
      if (outside(event.target)) dismiss.current();
    };
    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      dismiss.current();
      const trigger = ref.current?.querySelector<HTMLElement>("[aria-expanded]");
      trigger?.focus();
    };
    document.addEventListener("pointerdown", onPointer);
    document.addEventListener("focusin", onFocus);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onPointer);
      document.removeEventListener("focusin", onFocus);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  return ref;
}