/** * useFocusTrap — keyboard-accessible modal helper. * * const ref = useRef(null); * useFocusTrap(ref, isOpen, { onEscape: () => setOpen(false) }); * return
…
; * * On open: * - Saves whatever element was previously focused. * - Moves focus to the first interactive descendant (or the element marked * `data-autofocus`). * - Locks Tab / Shift+Tab to cycle within the container. * - Closes on Escape via `onEscape` (if provided). * * On close (or unmount): restores focus to whatever held it before open. * * No dependencies. Safe to use alongside Radix primitives (Radix's own * Dialog already does this — only call here for custom modals). */ import { useEffect, type RefObject } from "react"; const FOCUSABLE_SELECTOR = [ "a[href]", "button:not([disabled])", "input:not([disabled]):not([type='hidden'])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", "[contenteditable='true']", ].join(","); interface Options { onEscape?: () => void; /** When true, do NOT auto-move focus on open (useful if caller handles it). */ skipInitialFocus?: boolean; } export function useFocusTrap( containerRef: RefObject, isOpen: boolean, options: Options = {} ): void { const { onEscape, skipInitialFocus = false } = options; useEffect(() => { if (!isOpen) return; const container = containerRef.current; if (!container) return; const previouslyFocused = document.activeElement as HTMLElement | null; // Initial focus: prefer [data-autofocus], fall back to first focusable. if (!skipInitialFocus) { const auto = container.querySelector("[data-autofocus]"); const focusables = container.querySelectorAll(FOCUSABLE_SELECTOR); const target = auto || focusables[0] || container; // requestAnimationFrame so it lands after the modal's own mount work. requestAnimationFrame(() => target.focus({ preventScroll: false })); } const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape" && onEscape) { e.preventDefault(); onEscape(); return; } if (e.key !== "Tab") return; const focusables = Array.from( container.querySelectorAll(FOCUSABLE_SELECTOR) ).filter(el => !el.hasAttribute("disabled") && el.offsetParent !== null); if (focusables.length === 0) { e.preventDefault(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; const active = document.activeElement as HTMLElement | null; if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); } }; document.addEventListener("keydown", handleKey, true); return () => { document.removeEventListener("keydown", handleKey, true); // Restore focus to whatever was focused before opening. if (previouslyFocused && document.body.contains(previouslyFocused)) { previouslyFocused.focus({ preventScroll: true }); } }; }, [isOpen, containerRef, onEscape, skipInitialFocus]); }