From ccc37fe1bbaa36bfae1b0b87808b31918d2a57af Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 17:04:12 +0200 Subject: [PATCH] feat(shared): add trapFocus action for modal overlays (#692) Focuses the first focusable on mount and wraps Tab/Shift+Tab within the node. Used by the Stammbaum mobile bottom sheet (NFR-A11Y-004). Co-Authored-By: Claude Opus 4.8 --- .../shared/actions/trapFocus.svelte.spec.ts | 60 +++++++++++++++++++ frontend/src/lib/shared/actions/trapFocus.ts | 43 +++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts create mode 100644 frontend/src/lib/shared/actions/trapFocus.ts diff --git a/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts b/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts new file mode 100644 index 00000000..c35cf01d --- /dev/null +++ b/frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, afterEach } from 'vitest'; + +const { trapFocus } = await import('./trapFocus'); + +describe('trapFocus action', () => { + const nodes: HTMLElement[] = []; + + function makeContainer(buttonLabels: string[]): { + node: HTMLElement; + buttons: HTMLButtonElement[]; + } { + const node = document.createElement('div'); + const buttons = buttonLabels.map((label) => { + const b = document.createElement('button'); + b.textContent = label; + node.appendChild(b); + return b; + }); + document.body.appendChild(node); + nodes.push(node); + return { node, buttons }; + } + + afterEach(() => { + nodes.forEach((n) => n.remove()); + nodes.length = 0; + }); + + it('focuses the first focusable element on mount', () => { + const { node, buttons } = makeContainer(['one', 'two']); + trapFocus(node); + expect(document.activeElement).toBe(buttons[0]); + }); + + it('wraps Tab from the last focusable back to the first', () => { + const { node, buttons } = makeContainer(['one', 'two']); + trapFocus(node); + buttons[1].focus(); + node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + expect(document.activeElement).toBe(buttons[0]); + }); + + it('wraps Shift+Tab from the first focusable to the last', () => { + const { node, buttons } = makeContainer(['one', 'two']); + trapFocus(node); + buttons[0].focus(); + node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true })); + expect(document.activeElement).toBe(buttons[1]); + }); + + it('removes its listener on destroy', () => { + const { node, buttons } = makeContainer(['one', 'two']); + const handle = trapFocus(node); + handle.destroy(); + buttons[1].focus(); + node.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + // No trap after destroy → focus stays on the last button. + expect(document.activeElement).toBe(buttons[1]); + }); +}); diff --git a/frontend/src/lib/shared/actions/trapFocus.ts b/frontend/src/lib/shared/actions/trapFocus.ts new file mode 100644 index 00000000..6c941978 --- /dev/null +++ b/frontend/src/lib/shared/actions/trapFocus.ts @@ -0,0 +1,43 @@ +/** + * 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) { + 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); + } + }; +}