diff options
| author | Fuwn <[email protected]> | 2026-05-15 09:51:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-05-15 09:51:50 +0000 |
| commit | d892961db82f34add347240ab244b5e8a2715a53 (patch) | |
| tree | 037883ee0a4f81ef8c7711c90cabb58eee90b041 /src/lib | |
| parent | style(a11y): drop redundant border-radius from Dropdown focus rule (diff) | |
| download | due.moe-d892961db82f34add347240ab244b5e8a2715a53.tar.xz due.moe-d892961db82f34add347240ab244b5e8a2715a53.zip | |
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.
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/CommandPalette/CommandPalette.svelte | 50 |
1 files 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<typeof setTimeout> | null = null; let itemIDs = new Map<string, number>(); +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) => { <div class="command-palette-overlay {open ? 'fade-in' : 'fade-out'}" onclick={() => (open = false)} + aria-hidden="true" ></div> - <div class="dropdown {open ? 'fade-in' : 'fade-out'}"> + <div + class="dropdown {open ? 'fade-in' : 'fade-out'}" + role="dialog" + aria-modal="true" + aria-label="Command palette" + > <div class="dropdown-content card card-small"> <input bind:this={inputRef} bind:value={search} class="command-input" + type="text" placeholder="Search" + aria-label="Search commands" + autocomplete="off" + spellcheck="false" + role="combobox" + aria-expanded={open} + aria-controls={listboxId} + aria-autocomplete="list" + aria-activedescendant={activeOptionId} onkeydown={handleKey} /> - <div class="results-container"> + <div class="results-container" role="listbox" id={listboxId} aria-label="Commands"> {#each filtered as item, i (item.id || item.url)} <a + id={optionDomId(i)} href={item.url} class="header-item {selectedIndex === i ? 'selected' : ''}" + role="option" + aria-selected={selectedIndex === i} in:fly={{ y: 20, duration: 150, delay: i * 30 }} out:fly={{ y: -20, duration: 150 }} animate:flip={{ duration: 200 }} @@ -177,7 +219,9 @@ const handleGlobalKey = (e: KeyboardEvent) => { {/each} {#if filtered.length === 0 && search !== ''} - <div class="no-results opaque" in:fade={{ duration: 150 }}>No results found</div> + <div class="no-results opaque" role="status" in:fade={{ duration: 150 }}> + No results found + </div> {/if} </div> </div> |