css-is-awesome 1.14.3 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,737 @@
1
+ ---
2
+ name: command-palette
3
+ description: A Cmd+K / Ctrl+K command palette — native <dialog> for the top layer, focus trap and Esc, plus the combobox recipe's input layer over a grouped, filterable command list.
4
+ category: overlay
5
+ complexity: complex
6
+ cia-version: ">=1.0.0"
7
+ ---
8
+
9
+ ## Use this when
10
+
11
+ You want a keyboard-first launcher — "press Cmd+K, type a few letters, hit Enter" — for navigation, actions, or recent items in an app or docs site. Build it from **two things you already have**: the native `<dialog>` element (see [`dialog`](./dialog.md)) for the overlay, and the ARIA combobox input layer from the [`combobox`](./combobox.md) recipe for the search + list. If you only need a single search box on a page with no modal, use the combobox recipe directly. If the list is short and fixed (five menu items), a `cia.dropdown` is enough.
12
+
13
+ The palette is also where the "focus trap" question is settled for good: **`dialog.showModal()` is the focus trap.** It puts the dialog in the browser's top layer, makes everything behind it `inert`, keeps Tab inside, closes on Esc, and returns focus to the element that had it when the dialog opened. A JS focus-trap library exists to emulate exactly that for a `<div role="dialog">` — with a real `<dialog>` there is nothing left for it to do.
14
+
15
+ ## Structure (raw HTML)
16
+
17
+ ```html
18
+ <button type="button" data-slot="trigger" aria-haspopup="dialog">
19
+ Search commands… <kbd>Ctrl</kbd> <kbd>K</kbd>
20
+ </button>
21
+
22
+ <dialog data-cia-recipe="command-palette" aria-label="Command palette">
23
+ <div data-slot="search">
24
+ <input
25
+ data-slot="input"
26
+ type="text"
27
+ role="combobox"
28
+ aria-expanded="true"
29
+ aria-controls="my-palette-list"
30
+ aria-autocomplete="list"
31
+ aria-activedescendant=""
32
+ aria-describedby="my-palette-count"
33
+ autocomplete="off"
34
+ spellcheck="false"
35
+ placeholder="Type a command or search…"
36
+ autofocus
37
+ />
38
+ <span id="my-palette-count" data-slot="count" aria-live="polite" class="sr-only">8 commands</span>
39
+ </div>
40
+
41
+ <ul id="my-palette-list" data-slot="list" role="listbox" aria-label="Commands">
42
+ <li role="group" aria-labelledby="my-palette-nav">
43
+ <div id="my-palette-nav" data-slot="heading">Navigation</div>
44
+ <ul role="presentation">
45
+ <li id="cmd-docs" role="option" data-slot="option">Go to Docs <kbd data-slot="hint">G D</kbd></li>
46
+ <li id="cmd-themes" role="option" data-slot="option" data-active>Go to Themes <kbd data-slot="hint">G T</kbd></li>
47
+ </ul>
48
+ </li>
49
+ <li role="group" aria-labelledby="my-palette-actions">
50
+ <div id="my-palette-actions" data-slot="heading">Actions</div>
51
+ <ul role="presentation">
52
+ <li id="cmd-dark" role="option" data-slot="option">Toggle dark mode</li>
53
+ <li id="cmd-copy" role="option" data-slot="option">Copy install command</li>
54
+ </ul>
55
+ </li>
56
+ </ul>
57
+
58
+ <p data-slot="empty" hidden>No commands match.</p>
59
+ </dialog>
60
+ ```
61
+
62
+ Notes on the markup:
63
+
64
+ - The `<dialog>` gets an `aria-label` rather than `aria-labelledby` — a palette has no visible title, the search box *is* the UI. Screen readers still announce "Command palette, dialog" on open.
65
+ - The input is the combobox recipe's custom variant verbatim: `role="combobox"`, `aria-controls` pointing at the listbox, `aria-activedescendant` tracking the highlighted option while DOM focus never leaves the input. `aria-expanded` is always `"true"` here — the list is permanently visible inside the dialog; there is no closed state to represent.
66
+ - `autofocus` on the input is what makes the palette keyboard-first: `showModal()` moves focus to the first `autofocus` element inside the dialog, so the user is typing the instant it opens.
67
+ - Groups are `<li role="group" aria-labelledby>` wrapping a `role="presentation"` list. Screen readers announce "Navigation, group" when the active option moves into a new group; sighted users get the heading row. Flat lists can drop the group layer entirely.
68
+ - The `[data-slot="count"]` live region is visually hidden and reports the filtered count ("3 commands", "No commands match") so a screen-reader user knows what typing did — a sighted user sees the list shrink, an AT user hears nothing otherwise.
69
+ - `<kbd>` hints are decoration: `aria-hidden` is deliberately *not* set, because "Go to Docs, G D" is useful information; keep them short.
70
+
71
+ ## Styling (cia mixins)
72
+
73
+ ```scss
74
+ // CommandPalette.module.scss — component stylesheet, so import the zero-emit barrel.
75
+ @use 'css-is-awesome/api' as cia;
76
+
77
+ .my-palette-trigger {
78
+ @include cia.btn(outline);
79
+ gap: cia.space(2);
80
+
81
+ kbd {
82
+ @include cia.font(reg, 1);
83
+ padding: 0 cia.space(1);
84
+ border: 1px solid cia.color(border-default);
85
+ border-radius: cia.radius(sm);
86
+ background: cia.color(surface-muted);
87
+ }
88
+ }
89
+
90
+ .my-palette {
91
+ // modal-base, not modal: the palette hangs from the top of the viewport and
92
+ // has no padding of its own — the search row and the list pad themselves.
93
+ @include cia.modal-base($p: 0, $r: lg, $shadow: 5, $max-width: 40rem);
94
+ border: 1px solid cia.color(border-default);
95
+ margin-block-start: 10vh;
96
+ overflow: hidden;
97
+
98
+ &::backdrop {
99
+ background: rgb(0 0 0 / 50%);
100
+ }
101
+
102
+ [data-slot="search"] {
103
+ padding: cia.space(3) cia.space(4);
104
+ border-block-end: 1px solid cia.color(border-default);
105
+ }
106
+
107
+ // The bare input: no box of its own, the search row is the box.
108
+ [data-slot="input"] {
109
+ @include cia.form-reset;
110
+ inline-size: 100%;
111
+ border: 0;
112
+ background: transparent;
113
+ color: cia.color(text-primary);
114
+ @include cia.font(reg, 3);
115
+
116
+ &:focus {
117
+ outline: none;
118
+ }
119
+ &::placeholder {
120
+ color: cia.color(text-muted);
121
+ }
122
+ }
123
+
124
+ [data-slot="list"] {
125
+ @include cia.list-reset;
126
+ max-block-size: 22rem;
127
+ overflow-y: auto;
128
+ padding-block: cia.space(2);
129
+
130
+ ul {
131
+ @include cia.list-reset;
132
+ }
133
+ }
134
+
135
+ [data-slot="heading"] {
136
+ padding: cia.space(2) cia.space(4) cia.space(1);
137
+ @include cia.font(semibold, 1);
138
+ color: cia.color(text-muted);
139
+ text-transform: uppercase;
140
+ letter-spacing: 0.04em;
141
+ }
142
+
143
+ [role="option"] {
144
+ @include cia.dropdown-item($py: 2, $px: 4);
145
+ justify-content: space-between;
146
+ cursor: default;
147
+ }
148
+
149
+ // Keyboard highlight — the option aria-activedescendant points at.
150
+ // dropdown-item already handles :hover, so mouse and keyboard stay independent.
151
+ [role="option"][data-active] {
152
+ background: cia.color(interactive-hover);
153
+ }
154
+
155
+ [data-slot="hint"] {
156
+ @include cia.font(reg, 1);
157
+ color: cia.color(text-muted);
158
+ }
159
+
160
+ [data-slot="empty"] {
161
+ margin: 0;
162
+ padding: cia.space(5) cia.space(4);
163
+ text-align: center;
164
+ color: cia.color(text-muted);
165
+ }
166
+ }
167
+
168
+ .sr-only {
169
+ @include cia.sr-only;
170
+ }
171
+ ```
172
+
173
+ `cia.modal-base` gives the surface, radius, shadow, width clamp and `z(modal)`; the recipe overrides only what a palette needs differently from a centred dialog (top-anchored via `margin-block-start`, zero padding, a visible border so it reads on dark backdrops). Everything else is the combobox recipe's styling with the listbox inlined instead of absolutely positioned.
174
+
175
+ ## Interactivity
176
+
177
+ **Native (zero JS):** `showModal()` handles the top layer, `::backdrop`, the focus trap, `inert` on the page behind, Esc-to-close, and focus return to the trigger. `autofocus` on the input handles initial focus.
178
+
179
+ **The consumer script owns five jobs** — the same five as the combobox recipe, minus open/close of the list, plus the global shortcut:
180
+
181
+ 1. **Shortcut** — one `keydown` listener on `document`: `(e.metaKey || e.ctrlKey) && e.key === "k"` → `preventDefault()` + `dialog.showModal()`. Register it once (in `useEffect` / `onMounted` / `connectedCallback`), never at module load — there is no `document` during SSR.
182
+ 2. **Filter** — on every `input` event, filter the command list by substring (or a small fuzzy scorer), re-render the groups, drop groups that became empty, reset the active index to the first visible option, and write the count to the live region ("3 commands" / "No commands match").
183
+ 3. **Track the active option** — ArrowDown / ArrowUp move an index over the *visible* options, wrapping at both ends; set `aria-activedescendant` to that option's `id`, toggle `data-active`, and `scrollIntoView({ block: "nearest" })` so a long list follows the keyboard.
184
+ 4. **Run** — Enter runs the active command's handler and calls `dialog.close()`; pointer click on an option does the same. Run *after* `close()` if the command navigates, so focus restoration completes before the route changes.
185
+ 5. **Reset** — on the dialog's `close` event, clear the input, restore the full list, and move "Recent" bookkeeping if you keep one (most palettes push the executed command to the top of a Recent group).
186
+
187
+ Keyboard map:
188
+
189
+ | Key | Behavior |
190
+ |---|---|
191
+ | `Ctrl+K` / `Cmd+K` (anywhere) | Open the palette, focus the input |
192
+ | `ArrowDown` / `ArrowUp` | Move the active option (wraps) |
193
+ | `Enter` | Run the active command, close |
194
+ | `Escape` | Close (native to `showModal()`) |
195
+ | `Home` / `End` | Native text-caret movement in the input — not hijacked |
196
+ | Printable keys | Filter the list |
197
+
198
+ Edge cases:
199
+
200
+ - **Nothing matches:** show `[data-slot="empty"]`, clear `aria-activedescendant` (an id pointing at a hidden element is an ARIA error), and let Enter do nothing.
201
+ - **Pointer + keyboard:** commit on `mousedown` + `preventDefault()` as in the combobox recipe so the input never blurs before the click lands; hover does *not* move `aria-activedescendant` — only keys do — so a mouse resting on the list doesn't fight the arrow keys.
202
+ - **Scroll lock:** the top layer stops interaction with the page but not wheel-scrolling of `<body>` in every browser. If that bothers you, `html:has(dialog[open]) { overflow: hidden; }` in the global stylesheet is the whole fix.
203
+
204
+ Pairs well with (but requires none of): [cmdk](https://cmdk.paco.me/), [kbar](https://kbar.vercel.app/), [Headless UI Combobox](https://headlessui.com/react/combobox) — keep this recipe's markup and styling and let them own jobs 2-4.
205
+
206
+ ## A11y checklist
207
+
208
+ - [ ] Opened with `showModal()` so the dialog is in the top layer with `aria-modal` semantics, background content is `inert`, Tab is trapped, Esc closes, and focus returns to the trigger on close ([APG Dialog (Modal) pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/))
209
+ - [ ] The dialog has an accessible name — `aria-label="Command palette"` — since it has no visible heading ([WCAG 2.2 SC 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
210
+ - [ ] Initial focus lands on the search input (`autofocus`), so the palette is usable from the keyboard the moment it opens ([WCAG 2.2 SC 2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html))
211
+ - [ ] The input follows the combobox contract: `role="combobox"`, `aria-controls` → listbox `id`, `aria-autocomplete="list"`, `aria-activedescendant` tracks the highlighted option while DOM focus stays on the input ([APG Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/))
212
+ - [ ] Every command is `role="option"` with a stable `id`; groups are `role="group"` with `aria-labelledby` pointing at their visible heading ([WAI-ARIA 1.2: listbox role](https://www.w3.org/TR/wai-aria-1.2/#listbox))
213
+ - [ ] Filtering results are announced through a polite live region ("3 commands", "No commands match") so the effect of typing is not visual-only ([WCAG 2.2 SC 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html))
214
+ - [ ] The trigger carries `aria-haspopup="dialog"` and the documented shortcut is also reachable by pointer — the keyboard shortcut is an accelerator, never the only way in ([WCAG 2.2 SC 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html))
215
+ - [ ] The keyboard highlight (`[data-active]`) meets non-text contrast against the list surface ([WCAG 2.2 SC 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html))
216
+ - [ ] Options inherit ≥24px height from `cia.dropdown-item` padding ([WCAG 2.2 SC 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html))
217
+
218
+ ## Framework examples
219
+
220
+ All four implement the same spec: Ctrl/Cmd+K opens, substring filter over grouped commands, wrapping arrow navigation with `aria-activedescendant`, Enter runs + closes, live-region count. The command list is data, not markup — swap in your own.
221
+
222
+ ### React
223
+
224
+ ```tsx
225
+ "use client";
226
+ import { useEffect, useMemo, useRef, useState } from "react";
227
+ import styles from "./CommandPalette.module.scss";
228
+
229
+ type Command = { id: string; label: string; group: string; hint?: string; run: () => void };
230
+
231
+ export default function CommandPalette({ commands }: { commands: Command[] }) {
232
+ const dialogRef = useRef<HTMLDialogElement>(null);
233
+ const [query, setQuery] = useState("");
234
+ const [active, setActive] = useState(0);
235
+
236
+ const visible = useMemo(() => {
237
+ const q = query.trim().toLowerCase();
238
+ return q ? commands.filter((c) => c.label.toLowerCase().includes(q)) : commands;
239
+ }, [commands, query]);
240
+
241
+ const groups = useMemo(() => {
242
+ const map = new Map<string, Command[]>();
243
+ for (const c of visible) map.set(c.group, [...(map.get(c.group) ?? []), c]);
244
+ return [...map.entries()];
245
+ }, [visible]);
246
+
247
+ useEffect(() => {
248
+ function onKey(e: KeyboardEvent) {
249
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
250
+ e.preventDefault();
251
+ dialogRef.current?.showModal();
252
+ }
253
+ }
254
+ document.addEventListener("keydown", onKey);
255
+ return () => document.removeEventListener("keydown", onKey);
256
+ }, []);
257
+
258
+ function run(cmd: Command) {
259
+ dialogRef.current?.close();
260
+ cmd.run();
261
+ }
262
+
263
+ function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
264
+ if (!visible.length) return;
265
+ if (e.key === "ArrowDown") {
266
+ e.preventDefault();
267
+ setActive((i) => (i + 1) % visible.length);
268
+ } else if (e.key === "ArrowUp") {
269
+ e.preventDefault();
270
+ setActive((i) => (i - 1 + visible.length) % visible.length);
271
+ } else if (e.key === "Enter") {
272
+ e.preventDefault();
273
+ run(visible[active]);
274
+ }
275
+ }
276
+
277
+ const activeId = visible[active] ? `cmd-${visible[active].id}` : undefined;
278
+ const count = visible.length ? `${visible.length} command${visible.length === 1 ? "" : "s"}` : "No commands match";
279
+
280
+ return (
281
+ <>
282
+ <button
283
+ type="button"
284
+ className={styles.myPaletteTrigger}
285
+ aria-haspopup="dialog"
286
+ onClick={() => dialogRef.current?.showModal()}
287
+ >
288
+ Search commands… <kbd>Ctrl</kbd> <kbd>K</kbd>
289
+ </button>
290
+
291
+ <dialog
292
+ ref={dialogRef}
293
+ className={styles.myPalette}
294
+ aria-label="Command palette"
295
+ onClose={() => {
296
+ setQuery("");
297
+ setActive(0);
298
+ }}
299
+ >
300
+ <div data-slot="search">
301
+ <input
302
+ data-slot="input"
303
+ type="text"
304
+ role="combobox"
305
+ aria-expanded="true"
306
+ aria-controls="my-palette-list"
307
+ aria-autocomplete="list"
308
+ aria-activedescendant={activeId}
309
+ aria-describedby="my-palette-count"
310
+ autoComplete="off"
311
+ spellCheck={false}
312
+ placeholder="Type a command or search…"
313
+ autoFocus
314
+ value={query}
315
+ onChange={(e) => {
316
+ setQuery(e.target.value);
317
+ setActive(0);
318
+ }}
319
+ onKeyDown={onKeyDown}
320
+ />
321
+ <span id="my-palette-count" className={styles.srOnly} aria-live="polite">
322
+ {count}
323
+ </span>
324
+ </div>
325
+
326
+ <ul id="my-palette-list" data-slot="list" role="listbox" aria-label="Commands">
327
+ {groups.map(([group, items]) => (
328
+ <li key={group} role="group" aria-labelledby={`my-palette-${group}`}>
329
+ <div id={`my-palette-${group}`} data-slot="heading">{group}</div>
330
+ <ul role="presentation">
331
+ {items.map((c) => {
332
+ const isActive = visible[active]?.id === c.id;
333
+ return (
334
+ <li
335
+ key={c.id}
336
+ id={`cmd-${c.id}`}
337
+ role="option"
338
+ aria-selected={isActive}
339
+ data-active={isActive ? "" : undefined}
340
+ onMouseDown={(e) => {
341
+ e.preventDefault();
342
+ run(c);
343
+ }}
344
+ >
345
+ {c.label}
346
+ {c.hint && <kbd data-slot="hint">{c.hint}</kbd>}
347
+ </li>
348
+ );
349
+ })}
350
+ </ul>
351
+ </li>
352
+ ))}
353
+ </ul>
354
+
355
+ <p data-slot="empty" hidden={visible.length > 0}>No commands match.</p>
356
+ </dialog>
357
+ </>
358
+ );
359
+ }
360
+ ```
361
+
362
+ ### Vue
363
+
364
+ ```vue
365
+ <script setup>
366
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
367
+
368
+ const props = defineProps({ commands: { type: Array, required: true } });
369
+
370
+ const dialog = ref(null);
371
+ const query = ref("");
372
+ const active = ref(0);
373
+
374
+ const visible = computed(() => {
375
+ const q = query.value.trim().toLowerCase();
376
+ return q ? props.commands.filter((c) => c.label.toLowerCase().includes(q)) : props.commands;
377
+ });
378
+ const groups = computed(() => {
379
+ const map = new Map();
380
+ for (const c of visible.value) map.set(c.group, [...(map.get(c.group) ?? []), c]);
381
+ return [...map.entries()];
382
+ });
383
+ const activeId = computed(() => (visible.value[active.value] ? `cmd-${visible.value[active.value].id}` : undefined));
384
+ const count = computed(() =>
385
+ visible.value.length ? `${visible.value.length} command${visible.value.length === 1 ? "" : "s"}` : "No commands match",
386
+ );
387
+
388
+ function open() {
389
+ dialog.value?.showModal();
390
+ }
391
+ function run(cmd) {
392
+ dialog.value?.close();
393
+ cmd.run();
394
+ }
395
+ function onKeydown(e) {
396
+ if (!visible.value.length) return;
397
+ if (e.key === "ArrowDown") {
398
+ e.preventDefault();
399
+ active.value = (active.value + 1) % visible.value.length;
400
+ } else if (e.key === "ArrowUp") {
401
+ e.preventDefault();
402
+ active.value = (active.value - 1 + visible.value.length) % visible.value.length;
403
+ } else if (e.key === "Enter") {
404
+ e.preventDefault();
405
+ run(visible.value[active.value]);
406
+ }
407
+ }
408
+ function onShortcut(e) {
409
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
410
+ e.preventDefault();
411
+ open();
412
+ }
413
+ }
414
+ onMounted(() => document.addEventListener("keydown", onShortcut));
415
+ onBeforeUnmount(() => document.removeEventListener("keydown", onShortcut));
416
+ </script>
417
+
418
+ <template>
419
+ <button type="button" class="my-palette-trigger" aria-haspopup="dialog" @click="open">
420
+ Search commands… <kbd>Ctrl</kbd> <kbd>K</kbd>
421
+ </button>
422
+
423
+ <dialog ref="dialog" class="my-palette" aria-label="Command palette" @close="query = ''; active = 0">
424
+ <div data-slot="search">
425
+ <input
426
+ data-slot="input"
427
+ type="text"
428
+ role="combobox"
429
+ aria-expanded="true"
430
+ aria-controls="my-palette-list"
431
+ aria-autocomplete="list"
432
+ :aria-activedescendant="activeId"
433
+ aria-describedby="my-palette-count"
434
+ autocomplete="off"
435
+ spellcheck="false"
436
+ placeholder="Type a command or search…"
437
+ autofocus
438
+ :value="query"
439
+ @input="query = $event.target.value; active = 0"
440
+ @keydown="onKeydown"
441
+ />
442
+ <span id="my-palette-count" class="sr-only" aria-live="polite">{{ count }}</span>
443
+ </div>
444
+
445
+ <ul id="my-palette-list" data-slot="list" role="listbox" aria-label="Commands">
446
+ <li v-for="[group, items] in groups" :key="group" role="group" :aria-labelledby="`my-palette-${group}`">
447
+ <div :id="`my-palette-${group}`" data-slot="heading">{{ group }}</div>
448
+ <ul role="presentation">
449
+ <li
450
+ v-for="c in items"
451
+ :key="c.id"
452
+ :id="`cmd-${c.id}`"
453
+ role="option"
454
+ :aria-selected="visible[active]?.id === c.id"
455
+ :data-active="visible[active]?.id === c.id ? '' : undefined"
456
+ @mousedown.prevent="run(c)"
457
+ >
458
+ {{ c.label }}
459
+ <kbd v-if="c.hint" data-slot="hint">{{ c.hint }}</kbd>
460
+ </li>
461
+ </ul>
462
+ </li>
463
+ </ul>
464
+
465
+ <p data-slot="empty" :hidden="visible.length > 0">No commands match.</p>
466
+ </dialog>
467
+ </template>
468
+ ```
469
+
470
+ ### Svelte
471
+
472
+ ```svelte
473
+ <script>
474
+ import { onMount } from "svelte";
475
+
476
+ export let commands = [];
477
+
478
+ let dialog;
479
+ let query = "";
480
+ let active = 0;
481
+
482
+ $: q = query.trim().toLowerCase();
483
+ $: visible = q ? commands.filter((c) => c.label.toLowerCase().includes(q)) : commands;
484
+ $: groups = [...visible.reduce((m, c) => m.set(c.group, [...(m.get(c.group) ?? []), c]), new Map()).entries()];
485
+ $: activeId = visible[active] ? `cmd-${visible[active].id}` : undefined;
486
+ $: count = visible.length ? `${visible.length} command${visible.length === 1 ? "" : "s"}` : "No commands match";
487
+
488
+ function open() {
489
+ dialog?.showModal();
490
+ }
491
+ function run(cmd) {
492
+ dialog?.close();
493
+ cmd.run();
494
+ }
495
+ function onKeydown(e) {
496
+ if (!visible.length) return;
497
+ if (e.key === "ArrowDown") {
498
+ e.preventDefault();
499
+ active = (active + 1) % visible.length;
500
+ } else if (e.key === "ArrowUp") {
501
+ e.preventDefault();
502
+ active = (active - 1 + visible.length) % visible.length;
503
+ } else if (e.key === "Enter") {
504
+ e.preventDefault();
505
+ run(visible[active]);
506
+ }
507
+ }
508
+
509
+ onMount(() => {
510
+ function onShortcut(e) {
511
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
512
+ e.preventDefault();
513
+ open();
514
+ }
515
+ }
516
+ document.addEventListener("keydown", onShortcut);
517
+ return () => document.removeEventListener("keydown", onShortcut);
518
+ });
519
+ </script>
520
+
521
+ <button type="button" class="my-palette-trigger" aria-haspopup="dialog" on:click={open}>
522
+ Search commands… <kbd>Ctrl</kbd> <kbd>K</kbd>
523
+ </button>
524
+
525
+ <dialog bind:this={dialog} class="my-palette" aria-label="Command palette" on:close={() => { query = ""; active = 0; }}>
526
+ <div data-slot="search">
527
+ <input
528
+ data-slot="input"
529
+ type="text"
530
+ role="combobox"
531
+ aria-expanded="true"
532
+ aria-controls="my-palette-list"
533
+ aria-autocomplete="list"
534
+ aria-activedescendant={activeId}
535
+ aria-describedby="my-palette-count"
536
+ autocomplete="off"
537
+ spellcheck="false"
538
+ placeholder="Type a command or search…"
539
+ autofocus
540
+ bind:value={query}
541
+ on:input={() => (active = 0)}
542
+ on:keydown={onKeydown}
543
+ />
544
+ <span id="my-palette-count" class="sr-only" aria-live="polite">{count}</span>
545
+ </div>
546
+
547
+ <ul id="my-palette-list" data-slot="list" role="listbox" aria-label="Commands">
548
+ {#each groups as [group, items] (group)}
549
+ <li role="group" aria-labelledby={`my-palette-${group}`}>
550
+ <div id={`my-palette-${group}`} data-slot="heading">{group}</div>
551
+ <ul role="presentation">
552
+ {#each items as c (c.id)}
553
+ <li
554
+ id={`cmd-${c.id}`}
555
+ role="option"
556
+ aria-selected={visible[active]?.id === c.id}
557
+ data-active={visible[active]?.id === c.id ? "" : undefined}
558
+ on:mousedown|preventDefault={() => run(c)}
559
+ >
560
+ {c.label}
561
+ {#if c.hint}<kbd data-slot="hint">{c.hint}</kbd>{/if}
562
+ </li>
563
+ {/each}
564
+ </ul>
565
+ </li>
566
+ {/each}
567
+ </ul>
568
+
569
+ <p data-slot="empty" hidden={visible.length > 0}>No commands match.</p>
570
+ </dialog>
571
+ ```
572
+
573
+ ### Vanilla (Web Component)
574
+
575
+ ```js
576
+ class CommandPalette extends HTMLElement {
577
+ // Pass commands in via a property: el.commands = [{ id, label, group, hint, run }]
578
+ commands = [];
579
+ #query = "";
580
+ #active = 0;
581
+
582
+ connectedCallback() {
583
+ this.innerHTML = `
584
+ <button type="button" class="my-palette-trigger" aria-haspopup="dialog">
585
+ Search commands… <kbd>Ctrl</kbd> <kbd>K</kbd>
586
+ </button>
587
+ <dialog class="my-palette" aria-label="Command palette">
588
+ <div data-slot="search">
589
+ <input data-slot="input" type="text" role="combobox" aria-expanded="true"
590
+ aria-controls="my-palette-list" aria-autocomplete="list" aria-describedby="my-palette-count"
591
+ autocomplete="off" spellcheck="false" placeholder="Type a command or search…" autofocus />
592
+ <span id="my-palette-count" class="sr-only" aria-live="polite"></span>
593
+ </div>
594
+ <ul id="my-palette-list" data-slot="list" role="listbox" aria-label="Commands"></ul>
595
+ <p data-slot="empty" hidden>No commands match.</p>
596
+ </dialog>`;
597
+
598
+ this.dialog = this.querySelector("dialog");
599
+ this.input = this.querySelector('[data-slot="input"]');
600
+ this.list = this.querySelector('[data-slot="list"]');
601
+
602
+ this.querySelector(".my-palette-trigger").addEventListener("click", () => this.dialog.showModal());
603
+ this.onShortcut = (e) => {
604
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
605
+ e.preventDefault();
606
+ this.dialog.showModal();
607
+ }
608
+ };
609
+ document.addEventListener("keydown", this.onShortcut);
610
+
611
+ this.input.addEventListener("input", () => {
612
+ this.#query = this.input.value;
613
+ this.#active = 0;
614
+ this.render();
615
+ });
616
+ this.input.addEventListener("keydown", (e) => {
617
+ const visible = this.visible();
618
+ if (!visible.length) return;
619
+ if (e.key === "ArrowDown") {
620
+ e.preventDefault();
621
+ this.#active = (this.#active + 1) % visible.length;
622
+ this.render();
623
+ } else if (e.key === "ArrowUp") {
624
+ e.preventDefault();
625
+ this.#active = (this.#active - 1 + visible.length) % visible.length;
626
+ this.render();
627
+ } else if (e.key === "Enter") {
628
+ e.preventDefault();
629
+ this.run(visible[this.#active]);
630
+ }
631
+ });
632
+ this.dialog.addEventListener("close", () => {
633
+ this.#query = "";
634
+ this.#active = 0;
635
+ this.input.value = "";
636
+ this.render();
637
+ });
638
+ this.render();
639
+ }
640
+
641
+ disconnectedCallback() {
642
+ document.removeEventListener("keydown", this.onShortcut);
643
+ }
644
+
645
+ visible() {
646
+ const q = this.#query.trim().toLowerCase();
647
+ return q ? this.commands.filter((c) => c.label.toLowerCase().includes(q)) : this.commands;
648
+ }
649
+
650
+ run(cmd) {
651
+ this.dialog.close();
652
+ cmd.run();
653
+ }
654
+
655
+ render() {
656
+ const visible = this.visible();
657
+ const activeCmd = visible[this.#active];
658
+ const groups = new Map();
659
+ for (const c of visible) groups.set(c.group, [...(groups.get(c.group) ?? []), c]);
660
+
661
+ this.list.innerHTML = [...groups.entries()]
662
+ .map(
663
+ ([group, items]) => `
664
+ <li role="group" aria-labelledby="my-palette-${group}">
665
+ <div id="my-palette-${group}" data-slot="heading">${group}</div>
666
+ <ul role="presentation">
667
+ ${items
668
+ .map(
669
+ (c) => `<li id="cmd-${c.id}" role="option" aria-selected="${c === activeCmd}"${c === activeCmd ? " data-active" : ""}>
670
+ ${c.label}${c.hint ? `<kbd data-slot="hint">${c.hint}</kbd>` : ""}
671
+ </li>`,
672
+ )
673
+ .join("")}
674
+ </ul>
675
+ </li>`,
676
+ )
677
+ .join("");
678
+
679
+ this.list.querySelectorAll('[role="option"]').forEach((el, i) =>
680
+ el.addEventListener("mousedown", (e) => {
681
+ e.preventDefault();
682
+ this.run(visible[i]);
683
+ }),
684
+ );
685
+
686
+ if (activeCmd) this.input.setAttribute("aria-activedescendant", `cmd-${activeCmd.id}`);
687
+ else this.input.removeAttribute("aria-activedescendant");
688
+ this.querySelector('[data-slot="empty"]').hidden = visible.length > 0;
689
+ this.querySelector("#my-palette-count").textContent = visible.length
690
+ ? `${visible.length} command${visible.length === 1 ? "" : "s"}`
691
+ : "No commands match";
692
+ }
693
+ }
694
+ customElements.define("command-palette", CommandPalette);
695
+ ```
696
+
697
+ ## Variants
698
+
699
+ ### WCAG-strict
700
+
701
+ The base recipe already satisfies WCAG 2.2 AA for the palette itself. Teams shipping to strict audits usually want these four extras — all small, none needing a library:
702
+
703
+ - **Announce result counts.** Keep the polite live region (`[data-slot="count"]`) and write to it on *every* filter change, including "No commands match". Without it, a screen-reader user who types "xyz" hears silence and cannot tell whether the list is empty or the palette is broken. Debounce the write by ~150 ms if your filter runs per keystroke, so a fast typist hears one announcement, not eight.
704
+ - **Keep focus on the input.** Never move DOM focus into the list. `aria-activedescendant` is the whole mechanism — the screen reader reads the referenced option while the caret stays in the search box. Moving real focus to `<li>` elements breaks typing-to-filter and is the most common palette a11y bug in the wild.
705
+ - **Return focus to the trigger.** `showModal()` does this automatically when the dialog was opened by the trigger's click. When the palette was opened by the keyboard shortcut instead, the browser still returns focus to whatever was focused before — usually right. If a command navigates, run it *after* `close()` so the restoration completes first, then let the new route set focus as it normally would.
706
+ - **`inert` and scroll lock.** The top layer already makes the page behind the dialog inert to AT and pointer. If your app also has a sticky header with its own tabbable controls rendered *outside* the normal flow (a portal), verify Tab cannot reach them; if it can, they are not behind the dialog in the DOM and need `inert` set manually while the palette is open. Scroll lock is a one-liner in the global stylesheet: `html:has(dialog[open]) { overflow: hidden; }`.
707
+
708
+ ```scss
709
+ // app/globals.scss — global stylesheet, so the emitting bundle is correct here.
710
+ @use 'css-is-awesome';
711
+
712
+ html:has(dialog[open]) {
713
+ overflow: hidden;
714
+ }
715
+ ```
716
+
717
+ ### Recent commands
718
+
719
+ Push the executed command's `id` to the front of a `recent` array (cap at 3-5), persist it in `localStorage` if you like, and render a "Recent" group first whenever the query is empty. Filtering ignores the Recent group so results don't appear twice.
720
+
721
+ ### Nested pages
722
+
723
+ For "Go to Theme → pick a theme" flows, replace the command list with a second list when a parent command runs, prefix the input with a breadcrumb chip, and make Backspace-on-empty-input pop back one level. The dialog, input, and listbox contracts stay identical; only the data changes.
724
+
725
+ ## Pitfalls
726
+
727
+ - **Don't build the overlay from a `<div role="dialog">` plus a focus-trap library.** Native `<dialog>` + `showModal()` gives you the top layer, `inert`, Tab trapping, Esc and focus return with zero code. The library route exists for browsers older than 2022; if you can't target `<dialog>`, use the [`dialog`](./dialog.md) recipe's fallback note rather than reinventing it here.
728
+ - **Don't set `aria-expanded="false"` on the input when the list is empty.** The list is still present and controlled; use the empty-state paragraph and clear `aria-activedescendant` instead.
729
+ - **Don't register the Cmd+K listener at module scope.** It throws during SSR (`document` is undefined) and, in React strict mode, double-registers. Register in a mount hook and remove on unmount.
730
+ - **Don't use `Cmd+K` on macOS without also handling `Ctrl+K`.** Some users remap; some Linux/Windows keyboards report `metaKey` for the Windows key. Check both modifiers.
731
+ - **Don't let hover move the active option.** Mouse hover highlights via `:hover` (from `cia.dropdown-item`); only arrow keys move `aria-activedescendant`. Otherwise a stray pointer resting on the list hijacks Enter.
732
+
733
+ ## Related recipes
734
+
735
+ - [`dialog`](./dialog.md) — the overlay half: `<dialog>`, `showModal()`, the native focus trap and focus return this recipe relies on
736
+ - [`combobox`](./combobox.md) — the input half: `role="combobox"` + `aria-activedescendant` over a `role="listbox"`; the keyboard contract is reused verbatim
737
+ - [`app-shell`](./app-shell.md) — the layout this palette usually mounts into (trigger in the navbar, shortcut app-wide)