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>
48 lines
1.5 KiB
TypeScript
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?.();
|
|
}
|
|
};
|
|
}
|