/** * Trap keyboard focus within a node and move focus to its first focusable * element on mount. Used by modal-style overlays such as the Stammbaum mobile * bottom sheet (#692, NFR-A11Y-004). Tab from the last focusable wraps to the * first and Shift+Tab from the first wraps to the last. */ const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ].join(','); export function trapFocus(node: HTMLElement) { // Remember what had focus so it can be restored when the overlay closes // (WCAG 2.4.3 — don't strand keyboard/AT users at the top of the page). const previouslyFocused = document.activeElement as HTMLElement | null; const focusable = () => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)); function onKeydown(event: KeyboardEvent) { if (event.key !== 'Tab') return; const items = focusable(); if (items.length === 0) return; const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement; if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } } node.addEventListener('keydown', onKeydown); focusable()[0]?.focus(); return { destroy() { node.removeEventListener('keydown', onKeydown); previouslyFocused?.focus?.(); } }; }