From d892961db82f34add347240ab244b5e8a2715a53 Mon Sep 17 00:00:00 2001
From: Fuwn <contact@fuwn.net>
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(-)

(limited to 'src/lib/CommandPalette/CommandPalette.svelte')

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>
-- 
cgit v1.2.3

