// Focus management for modals/sheets. On mount: focuses the first focusable // (or the node itself) and stores the previously-focused element. Tab/Shift+Tab // wrap inside the node. Escape calls `onclose` when set. On destroy: restores // focus to the originating element so screen-reader / keyboard users land back // where they were. import type { ActionReturn } from 'svelte/action'; export interface FocusTrapOptions { onclose?: () => void; closeOnEscape?: boolean; /** When true, focus the first focusable on mount. Defaults true. */ autoFocus?: boolean; } const FOCUSABLE = 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'; function focusables(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll(FOCUSABLE)).filter( (el) => !el.hasAttribute('disabled') && el.offsetParent !== null ); } export function focusTrap( node: HTMLElement, options: FocusTrapOptions = {} ): ActionReturn { let opts = options; const previouslyFocused = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLElement | null; function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape' && opts.closeOnEscape !== false && opts.onclose) { e.preventDefault(); opts.onclose(); return; } if (e.key !== 'Tab') return; const list = focusables(node); if (list.length === 0) { e.preventDefault(); node.focus(); return; } const first = list[0]; const last = list[list.length - 1]; const active = document.activeElement as HTMLElement | null; if (e.shiftKey) { if (active === first || !node.contains(active)) { e.preventDefault(); last.focus(); } } else { if (active === last) { e.preventDefault(); first.focus(); } } } node.addEventListener('keydown', onKeyDown); if (opts.autoFocus !== false) { // Defer one frame so the element is fully laid out (sheets animate in). requestAnimationFrame(() => { const list = focusables(node); const target = list[0] ?? node; if (!node.hasAttribute('tabindex')) node.setAttribute('tabindex', '-1'); target.focus({ preventScroll: true }); }); } return { update(newOptions) { opts = newOptions; }, destroy() { node.removeEventListener('keydown', onKeyDown); if (previouslyFocused && typeof previouslyFocused.focus === 'function') { try { previouslyFocused.focus({ preventScroll: true }); } catch { /* element may have unmounted */ } } } }; }