77 lines
3.4 KiB
TypeScript
77 lines
3.4 KiB
TypeScript
/**
|
|
* 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<HTMLDivElement | null>,
|
|
): (e: KeyboardEvent<HTMLDivElement>) => void {
|
|
return (e: KeyboardEvent<HTMLDivElement>) => {
|
|
if (e.key !== 'Tab' || !dialogRef.current) return;
|
|
|
|
const focusable = Array.from(
|
|
dialogRef.current.querySelectorAll<HTMLElement>(
|
|
'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();
|
|
}
|
|
}
|
|
};
|
|
}
|