/** * Focus management for modal dialogs. * * `trapFocus` is a Svelte action for an element with `role="dialog"` and * `aria-modal="true"`. On mount it remembers the currently-focused * element, moves focus into the dialog, and keeps Tab / Shift+Tab cycling * within it; on destroy it restores focus to the original element. ESC * handling stays with the component (it owns the open/close state). * * Initial focus goes to the element marked `data-autofocus`, else the * first focusable element, else the dialog node itself. */ const FOCUSABLE_SELECTOR = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", '[tabindex]:not([tabindex="-1"])', ].join(","); function focusableWithin(node: HTMLElement): HTMLElement[] { return Array.from( node.querySelectorAll(FOCUSABLE_SELECTOR), ).filter((el) => el.offsetParent !== null || el === document.activeElement); } /** Svelte action: trap and restore focus for a modal dialog node. */ export function trapFocus(node: HTMLElement): { destroy(): void } { const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; function onKeydown(event: KeyboardEvent): void { if (event.key !== "Tab") return; const items = focusableWithin(node); if (items.length === 0) { event.preventDefault(); node.focus(); 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(); } } const initial = node.querySelector("[data-autofocus]") ?? focusableWithin(node)[0] ?? node; initial.focus(); node.addEventListener("keydown", onKeydown); return { destroy(): void { node.removeEventListener("keydown", onKeydown); previouslyFocused?.focus(); }, }; }