feat(shared-ui): add focusTrap action and apply to shared Modal

Create reusable Svelte action for focus trapping in modals/dialogs.
Traps Tab/Shift+Tab within element and restores focus on destroy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 10:04:04 +01:00
parent 8bc52f4264
commit c27f6f88f0
4 changed files with 75 additions and 0 deletions

View file

@ -0,0 +1,69 @@
/**
* Svelte action that traps focus within an element.
* Useful for modals and dialogs to prevent tabbing outside.
*/
export function focusTrap(node: HTMLElement) {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
let previouslyFocused: HTMLElement | null = null;
function getFocusableElements(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectors)).filter(
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// Save currently focused element and focus first focusable in trap
previouslyFocused = document.activeElement as HTMLElement;
// Use requestAnimationFrame to ensure the DOM is ready
requestAnimationFrame(() => {
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
} else {
node.focus();
}
});
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
// Restore focus to previously focused element
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
},
};
}

View file

@ -0,0 +1 @@
export { focusTrap } from './focusTrap';

View file

@ -218,3 +218,6 @@ export type {
GlobalErrorHandlerOptions,
GlobalErrorHandlerTranslations,
} from './toast';
// Actions
export { focusTrap } from './actions';

View file

@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import { X } from '@manacore/shared-icons';
import Text from '../atoms/Text.svelte';
import { focusTrap } from '../actions/focusTrap';
interface Props {
visible: boolean;
@ -60,6 +61,7 @@
role="dialog"
aria-modal="true"
tabindex="-1"
use:focusTrap
>
<!-- Modal Content -->
<!-- svelte-ignore a11y_no_static_element_interactions -->