Files
familienarchiv/frontend/src/lib/shared/actions/trapFocus.ts
Marcel d5a7974f3a fix(shared): trapFocus restores focus to the opener on destroy (#692)
When the bottom sheet closes, focus returns to the element that was focused
before it opened instead of being dropped to document.body (WCAG 2.4.3,
Architect + UX review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:50:54 +02:00

48 lines
1.5 KiB
TypeScript

/**
* 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) {
// Remember what had focus so it can be restored when the overlay closes
// (WCAG 2.4.3 — don't strand keyboard/AT users at the top of the page).
const previouslyFocused = document.activeElement as HTMLElement | null;
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);
previouslyFocused?.focus?.();
}
};
}