mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
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:
parent
8bc52f4264
commit
c27f6f88f0
4 changed files with 75 additions and 0 deletions
69
packages/shared-ui/src/actions/focusTrap.ts
Normal file
69
packages/shared-ui/src/actions/focusTrap.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
1
packages/shared-ui/src/actions/index.ts
Normal file
1
packages/shared-ui/src/actions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { focusTrap } from './focusTrap';
|
||||
|
|
@ -218,3 +218,6 @@ export type {
|
|||
GlobalErrorHandlerOptions,
|
||||
GlobalErrorHandlerTranslations,
|
||||
} from './toast';
|
||||
|
||||
// Actions
|
||||
export { focusTrap } from './actions';
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue