/** * Focus-trap hook for modal dialogs. * * Requirements (WCAG 2.1.2): when a dialog is open, Tab and Shift+Tab must * cycle focus within the dialog, focus must be moved into the dialog on * open, and focus must be returned to the previously-focused element on * close. We do that with a single keydown listener instead of the heavier * `focus-trap` library to keep the bundle small. * * Usage: * const ref = useFocusTrap(open) *
...
*/ import { useEffect, useRef } from 'react' const FOCUSABLE_SELECTOR = [ 'a[href]', 'area[href]', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', 'iframe', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]', ].join(',') export function useFocusTrap(active: boolean) { const ref = useRef(null) useEffect(() => { if (!active) return const container = ref.current if (!container) return const previouslyFocused = document.activeElement as HTMLElement | null const focusables = (): HTMLElement[] => Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( (el) => el.offsetParent !== null || el === document.activeElement, ) // Move initial focus to the first focusable inside the container so // screen readers announce the dialog content immediately. const initial = focusables()[0] ?? container initial.focus({ preventScroll: true }) const onKey = (e: KeyboardEvent) => { if (e.key !== 'Tab') return const items = focusables() if (items.length === 0) { e.preventDefault() container.focus() return } const first = items[0] const last = items[items.length - 1] if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault() first.focus() } } document.addEventListener('keydown', onKey) return () => { document.removeEventListener('keydown', onKey) // Restore focus only if the previously-focused element is still in // the DOM and connected (the dialog might have unmounted it). if (previouslyFocused && document.contains(previouslyFocused)) { try { previouslyFocused.focus({ preventScroll: true }) } catch { /* element no longer focusable */ } } } }, [active]) return ref }