|
|
|
@ -1,4 +1,3 @@
|
|
|
|
import { shortcuts } from '$lib/actions/shortcut';
|
|
|
|
|
|
|
|
import { getTabbable } from '$lib/utils/focus-util';
|
|
|
|
import { getTabbable } from '$lib/utils/focus-util';
|
|
|
|
import { tick } from 'svelte';
|
|
|
|
import { tick } from 'svelte';
|
|
|
|
|
|
|
|
|
|
|
|
@ -12,6 +11,24 @@ interface Options {
|
|
|
|
export function focusTrap(container: HTMLElement, options?: Options) {
|
|
|
|
export function focusTrap(container: HTMLElement, options?: Options) {
|
|
|
|
const triggerElement = document.activeElement;
|
|
|
|
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) => {
|
|
|
|
const withDefaults = (options?: Options) => {
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
active: options?.active ?? true,
|
|
|
|
active: options?.active ?? true,
|
|
|
|
@ -19,11 +36,19 @@ export function focusTrap(container: HTMLElement, options?: Options) {
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setInitialFocus = async () => {
|
|
|
|
const setInitialFocus = async () => {
|
|
|
|
const focusableElement = getTabbable(container, false)[0];
|
|
|
|
|
|
|
|
if (focusableElement) {
|
|
|
|
|
|
|
|
// Use tick() to ensure focus trap works correctly inside <Portal />
|
|
|
|
// Use tick() to ensure focus trap works correctly inside <Portal />
|
|
|
|
await tick();
|
|
|
|
await tick();
|
|
|
|
focusableElement?.focus();
|
|
|
|
|
|
|
|
|
|
|
|
// Get focusable elements, excluding our sentinel nodes
|
|
|
|
|
|
|
|
const allTabbable = getTabbable(container, false);
|
|
|
|
|
|
|
|
const focusableElement = allTabbable.find((el) => !Object.hasOwn(el.dataset, 'focusTrap'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (focusableElement) {
|
|
|
|
|
|
|
|
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 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 [
|
|
|
|
return [
|
|
|
|
focusableElements.at(0), //
|
|
|
|
focusableElements.at(0), //
|
|
|
|
focusableElements.at(-1),
|
|
|
|
focusableElements.at(-1),
|
|
|
|
];
|
|
|
|
];
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const { destroy: destroyShortcuts } = shortcuts(container, [
|
|
|
|
// Add focus event listeners to sentinel nodes
|
|
|
|
{
|
|
|
|
const handleStartFocus = () => {
|
|
|
|
ignoreInputFields: false,
|
|
|
|
if (withDefaults(options).active) {
|
|
|
|
preventDefault: false,
|
|
|
|
const [, lastElement] = getFocusableElements();
|
|
|
|
shortcut: { key: 'Tab' },
|
|
|
|
// If no elements, stay on backup sentinel
|
|
|
|
onShortcut: (event) => {
|
|
|
|
if (lastElement) {
|
|
|
|
const [firstElement, lastElement] = getFocusableElements();
|
|
|
|
lastElement.focus();
|
|
|
|
if (document.activeElement === lastElement && withDefaults(options).active) {
|
|
|
|
} else {
|
|
|
|
event.preventDefault();
|
|
|
|
backupSentinel.focus();
|
|
|
|
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();
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
};
|
|
|
|
},
|
|
|
|
|
|
|
|
]);
|
|
|
|
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 {
|
|
|
|
return {
|
|
|
|
update(newOptions?: Options) {
|
|
|
|
update(newOptions?: Options) {
|
|
|
|
@ -74,7 +116,16 @@ export function focusTrap(container: HTMLElement, options?: Options) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
},
|
|
|
|
destroy() {
|
|
|
|
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) {
|
|
|
|
if (triggerElement instanceof HTMLElement) {
|
|
|
|
triggerElement.focus();
|
|
|
|
triggerElement.focus();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|