From d892961db82f34add347240ab244b5e8a2715a53 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 15 May 2026 09:51:50 +0000 Subject: fix(a11y): give CommandPalette real dialog and combobox semantics Wrap the palette in role=dialog with aria-modal, mark the overlay aria-hidden, and turn the search input into a labeled combobox driving a listbox of role=option results via aria-activedescendant. Trap Tab on the input, preventDefault on Escape, and restore focus to the previously-focused element when the palette closes. --- src/lib/CommandPalette/CommandPalette.svelte | 50 ++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/src/lib/CommandPalette/CommandPalette.svelte b/src/lib/CommandPalette/CommandPalette.svelte index 8ace0222..5eb06ef7 100644 --- a/src/lib/CommandPalette/CommandPalette.svelte +++ b/src/lib/CommandPalette/CommandPalette.svelte @@ -14,6 +14,10 @@ let inputRef: HTMLInputElement; let isVisible = false; let timeoutID: ReturnType | null = null; let itemIDs = new Map(); +let previouslyFocused: HTMLElement | null = null; +const instanceId = `cmd-palette-${Math.random().toString(36).slice(2, 10)}`; +const listboxId = `${instanceId}-listbox`; +const optionDomId = (index: number) => `${instanceId}-option-${index}`; $: { items.forEach((item, index) => { @@ -63,6 +67,23 @@ $: { $: if (selectedIndex >= filtered.length) selectedIndex = filtered.length - 1; $: if (selectedIndex < 0 && filtered.length > 0) selectedIndex = 0; +$: activeOptionId = + selectedIndex >= 0 && selectedIndex < filtered.length + ? optionDomId(selectedIndex) + : undefined; + +$: if (open && !previouslyFocused) { + previouslyFocused = document.activeElement as HTMLElement | null; +} + +$: if (!open && previouslyFocused) { + const toRestore = previouslyFocused; + + previouslyFocused = null; + + requestAnimationFrame(() => toRestore?.focus()); +} + $: if (open && !isVisible) { isVisible = true; @@ -103,7 +124,10 @@ const handleKey = (e: KeyboardEvent) => { executeItem(filtered[selectedIndex]); } } else if (e.key === "Escape") { + e.preventDefault(); open = false; + } else if (e.key === "Tab") { + e.preventDefault(); } }; @@ -145,23 +169,41 @@ const handleGlobalKey = (e: KeyboardEvent) => {
(open = false)} + aria-hidden="true" >
-