From 90e59ee18612fdd3a02c841cf365ef5ff50cc11a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 07:30:48 -0500 Subject: [PATCH] feat(ui): add trapFocus action for modal dialogs (T7.5, FR-UX-4) Moves focus into the dialog on mount, cycles Tab/Shift+Tab within its focusable elements, and restores focus to whatever had it before the dialog opened once it closes. --- src/lib/actions/trapFocus.ts | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/lib/actions/trapFocus.ts diff --git a/src/lib/actions/trapFocus.ts b/src/lib/actions/trapFocus.ts new file mode 100644 index 0000000..dd6e9f9 --- /dev/null +++ b/src/lib/actions/trapFocus.ts @@ -0,0 +1,40 @@ +// Svelte action for modal dialogs (T7.5, FR-UX-4): moves focus into the node +// on mount, cycles Tab/Shift+Tab within its focusable elements, and restores +// focus to whatever had it before the dialog opened once it closes. + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function trapFocus(node: HTMLElement) { + const previouslyFocused = document.activeElement as HTMLElement | null; + + function focusables(): HTMLElement[] { + return Array.from(node.querySelectorAll(FOCUSABLE)); + } + + (focusables()[0] ?? node).focus(); + + function onKeydown(e: KeyboardEvent) { + if (e.key !== "Tab") return; + const items = focusables(); + if (items.length === 0) 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(); + } + } + + node.addEventListener("keydown", onKeydown); + + return { + destroy() { + node.removeEventListener("keydown", onKeydown); + previouslyFocused?.focus(); + }, + }; +}