fix: focus-trap on safari (#23246)

This commit is contained in:
Min Idzelis 2025-10-27 22:29:30 -04:00 committed by GitHub
parent 698531d6e0
commit d51b8c1cdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 87 additions and 34 deletions

View File

@ -24,7 +24,7 @@ describe('focusTrap action', () => {
it('supports backward focus wrapping', async () => { it('supports backward focus wrapping', async () => {
render(FocusTrapTest, { show: true }); render(FocusTrapTest, { show: true });
await tick(); await tick();
await user.keyboard('{Shift>}{Tab}{/Shift}'); await user.keyboard('{Shift}{Tab}{/Shift}');
expect(document.activeElement).toEqual(screen.getByTestId('three')); expect(document.activeElement).toEqual(screen.getByTestId('three'));
}); });

View File

@ -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();
} }

View File

@ -51,7 +51,9 @@
</button> </button>
</span> </span>
{/if} {/if}
<!-- safari still needs a tabIndex=0 -->
<a <a
tabindex="0"
{href} {href}
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'} data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
draggable="false" draggable="false"