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}
+