/** * useFocusTrap — Tab / Shift+Tab focus cycling within a dialog (IN-05). * * Extracted from the verbatim-duplicated handleDialogKeyDown in EventForm.tsx and * SeriesEditPrompt.tsx so a future fix (handling disabled/hidden elements, radio-group * focus, etc.) lands in one place. * * Returns a keydown handler to spread onto the dialog container's `onKeyDown`. The * handler queries the dialog's focusable descendants on each keydown: * - Tab on the last focusable element wraps to the first. * - Shift+Tab on the first focusable element wraps to the last. * * No external library — keeps the zero-dependency posture of the original inline impl. * * @param dialogRef - ref to the dialog container element */ import type { KeyboardEvent, RefObject } from 'react'; export function useFocusTrap( dialogRef: RefObject, ): (e: KeyboardEvent) => void { return (e: KeyboardEvent) => { if (e.key !== 'Tab' || !dialogRef.current) return; const focusable = Array.from( dialogRef.current.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ), ).filter((el) => { // Exclude disabled / explicitly-untabbable nodes … if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false; // … and the `hidden` attribute, which is unambiguous regardless of layout. if (el.hasAttribute('hidden')) return false; // … and nodes that are not actually rendered/visible (WR-01). A focusable // inside a hidden/collapsed block would otherwise become the computed // first/last and `last.focus()` would no-op, leaking Tab to background // content that `aria-modal="true"` promises is unreachable. // // Guard against a non-layout environment (jsdom): there, every node reports // all-zero geometry and a null offsetParent, so applying the visibility // heuristic unconditionally would reject *every* focusable and silently // disable the trap. Only filter on visibility when there is positive // evidence a layout engine is present; otherwise treat the node as visible. const r = el.getBoundingClientRect(); const hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null; if (!hasLayout) return true; // no layout engine → don't filter on visibility if (el.offsetParent === null) return false; return r.width > 0 && r.height > 0; }); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; // Boundary-only trap (IN-01): this handler is wired to the dialog's own // onKeyDown, so it only runs while focus is already inside the dialog // subtree — a `document`-level containment guard would be required to pull // back focus that originates outside, and is unnecessary for the current // always-focus-the-heading-on-open flows. We intentionally do not claim a // containment guarantee the wiring cannot provide. if (e.shiftKey) { // Shift+Tab: if on first element, wrap to last if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { // Tab: if on last element, wrap to first if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }; }