From c27f6f88f05e7842205cb288bf2bf0dc36165cb1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 24 Mar 2026 10:04:04 +0100 Subject: [PATCH] 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) --- packages/shared-ui/src/actions/focusTrap.ts | 69 +++++++++++++++++++ packages/shared-ui/src/actions/index.ts | 1 + packages/shared-ui/src/index.ts | 3 + packages/shared-ui/src/organisms/Modal.svelte | 2 + 4 files changed, 75 insertions(+) create mode 100644 packages/shared-ui/src/actions/focusTrap.ts create mode 100644 packages/shared-ui/src/actions/index.ts diff --git a/packages/shared-ui/src/actions/focusTrap.ts b/packages/shared-ui/src/actions/focusTrap.ts new file mode 100644 index 000000000..4e86c96e4 --- /dev/null +++ b/packages/shared-ui/src/actions/focusTrap.ts @@ -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(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(); + } + }, + }; +} diff --git a/packages/shared-ui/src/actions/index.ts b/packages/shared-ui/src/actions/index.ts new file mode 100644 index 000000000..bd71e8799 --- /dev/null +++ b/packages/shared-ui/src/actions/index.ts @@ -0,0 +1 @@ +export { focusTrap } from './focusTrap'; diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 23a588fea..4bd6a5778 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -218,3 +218,6 @@ export type { GlobalErrorHandlerOptions, GlobalErrorHandlerTranslations, } from './toast'; + +// Actions +export { focusTrap } from './actions'; diff --git a/packages/shared-ui/src/organisms/Modal.svelte b/packages/shared-ui/src/organisms/Modal.svelte index efeeec703..a4b334c12 100644 --- a/packages/shared-ui/src/organisms/Modal.svelte +++ b/packages/shared-ui/src/organisms/Modal.svelte @@ -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 >