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 <noreply@anthropic.com>
This commit is contained in:
60
frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
Normal file
60
frontend/src/lib/shared/actions/trapFocus.svelte.spec.ts
Normal file
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
frontend/src/lib/shared/actions/trapFocus.ts
Normal file
43
frontend/src/lib/shared/actions/trapFocus.ts
Normal file
@@ -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<HTMLElement>(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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user