Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/lib/actions/__test__/focus-trap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});

Expand Down
117 changes: 84 additions & 33 deletions web/src/lib/actions/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { shortcuts } from '$lib/actions/shortcut';
import { getTabbable } from '$lib/utils/focus-util';
import { tick } from 'svelte';

Expand All @@ -12,18 +11,44 @@ interface Options {
export function focusTrap(container: HTMLElement, options?: Options) {
const triggerElement = document.activeElement;

// Create sentinel nodes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give some info on what does sentinel node mean?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shift-tab key binding is not observable in browsers. Browsers do not send the shift-tab keyDown combination to javascript code -- which meant that the current focus-trap implementation was not working properly. This was not detected by the test, because the shift-tab unit test had a typo in it.

The common solution to this problem used by other focus trap libraries is to insert two "marker elements" - one at the beginning of the trap and one at the end - I'm naming these sentinel nodes, because there job is to listen/watch to the 'focus' event (instead of shift-tab).

When the startSentinel element gets focus, it will set focus to the last focusable element. When the lastSentinel element gets focus, it will set focus to the first focusable element, thereby completing the trap.

In the case that there are no focusable elements, a backupSentinel element is inserted - the sole purpose of this element is to accept focus when there are no other elements available to focus. This is useful when, a modal without any input fields is open, but you still want to prevent focus from leaving the modal until it is dismissed.

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,
};
};

const setInitialFocus = async () => {
const focusableElement = getTabbable(container, false)[0];
// Use tick() to ensure focus trap works correctly inside <Portal />
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 <Portal />
await tick();
focusableElement?.focus();
focusableElement.focus();
} else {
backupSentinel.setAttribute('tabindex', '-1');
// No focusable elements found, use backup sentinel as fallback
backupSentinel.focus();
}
};

Expand All @@ -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) {
Expand All @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
</button>
</span>
{/if}
<!-- safari still needs a tabIndex=0 -->
<a
tabindex="0"
{href}
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
draggable="false"
Expand Down
Loading