diff --git a/web/src/lib/actions/__test__/focus-trap.spec.ts b/web/src/lib/actions/__test__/focus-trap.spec.ts index b03064a91d..c4d43dbc71 100644 --- a/web/src/lib/actions/__test__/focus-trap.spec.ts +++ b/web/src/lib/actions/__test__/focus-trap.spec.ts @@ -24,7 +24,7 @@ describe('focusTrap action', () => { it('supports backward focus wrapping', async () => { render(FocusTrapTest, { show: true }); await tick(); - await user.keyboard('{Shift>}{Tab}{/Shift}'); + await user.keyboard('{Shift}{Tab}{/Shift}'); expect(document.activeElement).toEqual(screen.getByTestId('three')); }); diff --git a/web/src/lib/actions/focus-trap.ts b/web/src/lib/actions/focus-trap.ts index 2b03282c2d..9c7b7b3bd2 100644 --- a/web/src/lib/actions/focus-trap.ts +++ b/web/src/lib/actions/focus-trap.ts @@ -1,4 +1,3 @@ -import { shortcuts } from '$lib/actions/shortcut'; import { getTabbable } from '$lib/utils/focus-util'; import { tick } from 'svelte'; @@ -12,6 +11,24 @@ interface Options { export function focusTrap(container: HTMLElement, options?: Options) { const triggerElement = document.activeElement; + // Create sentinel nodes + const startSentinel = document.createElement('div'); + startSentinel.setAttribute('tabindex', '0'); + startSentinel.dataset.focusTrap = 'start'; + + const backupSentinel = document.createElement('div'); + backupSentinel.setAttribute('tabindex', '-1'); + backupSentinel.dataset.focusTrap = 'backup'; + + const endSentinel = document.createElement('div'); + endSentinel.setAttribute('tabindex', '0'); + endSentinel.dataset.focusTrap = 'end'; + + // Insert sentinel nodes into the container + container.insertBefore(startSentinel, container.firstChild); + container.insertBefore(backupSentinel, startSentinel.nextSibling); + container.append(endSentinel); + const withDefaults = (options?: Options) => { return { active: options?.active ?? true, @@ -19,11 +36,19 @@ export function focusTrap(container: HTMLElement, options?: Options) { }; const setInitialFocus = async () => { - const focusableElement = getTabbable(container, false)[0]; + // Use tick() to ensure focus trap works correctly inside + await tick(); + + // Get focusable elements, excluding our sentinel nodes + const allTabbable = getTabbable(container, false); + const focusableElement = allTabbable.find((el) => !Object.hasOwn(el.dataset, 'focusTrap')); + if (focusableElement) { - // Use tick() to ensure focus trap works correctly inside - await tick(); - focusableElement?.focus(); + focusableElement.focus(); + } else { + backupSentinel.setAttribute('tabindex', '-1'); + // No focusable elements found, use backup sentinel as fallback + backupSentinel.focus(); } }; @@ -32,39 +57,56 @@ export function focusTrap(container: HTMLElement, options?: Options) { } const getFocusableElements = () => { - const focusableElements = getTabbable(container); + // Get all tabbable elements except our sentinel nodes + const allTabbable = getTabbable(container); + const focusableElements = allTabbable.filter((el) => !Object.hasOwn(el.dataset, 'focusTrap')); + return [ focusableElements.at(0), // focusableElements.at(-1), ]; }; - const { destroy: destroyShortcuts } = shortcuts(container, [ - { - ignoreInputFields: false, - preventDefault: false, - shortcut: { key: 'Tab' }, - onShortcut: (event) => { - const [firstElement, lastElement] = getFocusableElements(); - if (document.activeElement === lastElement && withDefaults(options).active) { - event.preventDefault(); - firstElement?.focus(); - } - }, - }, - { - ignoreInputFields: false, - preventDefault: false, - shortcut: { key: 'Tab', shift: true }, - onShortcut: (event) => { - const [firstElement, lastElement] = getFocusableElements(); - if (document.activeElement === firstElement && withDefaults(options).active) { - event.preventDefault(); - lastElement?.focus(); - } - }, - }, - ]); + // Add focus event listeners to sentinel nodes + const handleStartFocus = () => { + if (withDefaults(options).active) { + const [, lastElement] = getFocusableElements(); + // If no elements, stay on backup sentinel + if (lastElement) { + lastElement.focus(); + } else { + backupSentinel.focus(); + } + } + }; + + const handleBackupFocus = () => { + // Backup sentinel keeps focus when there are no other focusable elements + if (withDefaults(options).active) { + const [firstElement] = getFocusableElements(); + // Only move focus if there are actual focusable elements + if (firstElement) { + firstElement.focus(); + } + // Otherwise, focus stays on backup sentinel + } + }; + + const handleEndFocus = () => { + if (withDefaults(options).active) { + const [firstElement] = getFocusableElements(); + // If no elements, move to backup sentinel + if (firstElement) { + firstElement.focus(); + } else { + backupSentinel.focus(); + } + } + }; + + startSentinel.addEventListener('focus', handleStartFocus); + backupSentinel.addEventListener('focus', handleBackupFocus); + endSentinel.addEventListener('focus', handleEndFocus); return { update(newOptions?: Options) { @@ -74,7 +116,16 @@ export function focusTrap(container: HTMLElement, options?: Options) { } }, destroy() { - destroyShortcuts?.(); + // Remove event listeners + startSentinel.removeEventListener('focus', handleStartFocus); + backupSentinel.removeEventListener('focus', handleBackupFocus); + endSentinel.removeEventListener('focus', handleEndFocus); + + // Remove sentinel nodes from DOM + startSentinel.remove(); + backupSentinel.remove(); + endSentinel.remove(); + if (triggerElement instanceof HTMLElement) { triggerElement.focus(); } diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index b3c44da61a..c5f9080a13 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -51,7 +51,9 @@ {/if} +