css-is-awesome 1.15.0 → 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.
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/ROADMAP.md +2 -2
- package/dist/tokens.d.ts +1 -1
- package/llm.txt +1 -1
- package/package.json +1 -1
- package/scss/recipes/combobox.md +1 -1
- package/scss/recipes/command-palette.md +737 -0
- package/scss/recipes/dialog.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.16.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.15.0...v1.16.0) (2026-09-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **recipes:** command-palette recipe + live demo ([6cc16b1](https://github.com/Jerry2d3d/css-is-awesome/commit/6cc16b104b625c8f73fda7631f72289b50e892ed))
|
|
7
|
+
|
|
1
8
|
# [1.15.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.14.3...v1.15.0) (2026-09-16)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
**Docs:** [cssisawesome.com](https://cssisawesome.com/) · **Install:** `npm install css-is-awesome`
|
|
10
10
|
|
|
11
|
-
> **The recipes book:** build any component in any framework using cia mixins —
|
|
11
|
+
> **The recipes book:** build any component in any framework using cia mixins — 32 recipes today, including `dialog`, `command-palette`, `combobox`, `combobox-multiselect`, `datepicker`, `data-table`, `pagination`, `breadcrumb`, `toast`, `file-upload`, `sortable-list`, `color-picker`, `app-shell`, `admin-dashboard-layout`, `auth-flow`, `otp-input`, `multi-step-wizard`, `confirm-dialog`, five form-validation patterns (HTML5, react-hook-form, Zod, async, success-states), three i18n patterns, `rtl-layout`, `print-to-pdf`, `print-spec`, `letterhead`, `mobile-nav` and `bottom-nav`. AI agents read recipes via MCP and generate components in your stack; humans read them at [`/docs/recipes`](https://cssisawesome.com/docs/recipes/).
|
|
12
12
|
|
|
13
13
|
## For AI agents — start here
|
|
14
14
|
|
package/ROADMAP.md
CHANGED
|
@@ -390,7 +390,7 @@ Full backlog: [`roadmap/epics/v1-0/README.md`](./roadmap/epics/v1-0/README.md).
|
|
|
390
390
|
|
|
391
391
|
- ❌ `@cia/react` as a separate npm component library Jerry maintains forever
|
|
392
392
|
- ❌ shadcn-style component ejection CLI for cia
|
|
393
|
-
- ❌ `@cia/a11y` as cia-original JS shims (deferred → `@cia/a11y-recipes` post-v1.0)
|
|
393
|
+
- ❌ `@cia/a11y` as cia-original JS shims (deferred → `@cia/a11y-recipes` post-v1.0 → **retired 2026-09-17**: folded into the core recipe book, WCAG-strict content is a Variants subsection per recipe)
|
|
394
394
|
- ❌ Component library as the v1.0 selling point — recipes ARE the deliverable
|
|
395
395
|
- ❌ VS Code extension at v1.0 (deferred to v1.5; playground covers the demo need)
|
|
396
396
|
|
|
@@ -465,7 +465,7 @@ Open list of ideas that could make cia better, captured in [`WISHLIST.md`](./WIS
|
|
|
465
465
|
|
|
466
466
|
| Release | Theme | Epic folder | Stories | Effort |
|
|
467
467
|
|---|---|---|---|---|
|
|
468
|
-
| **v1.1** | Recipes momentum (
|
|
468
|
+
| **v1.1** | Recipes momentum (14 more recipes, install wizard, ~~@cia/a11y-recipes add-on~~ (retired), @cia/react codegen POC, playground) | [v1-1](./roadmap/epics/v1-1/README.md) | 43 | ~25-35 days |
|
|
469
469
|
| **v1.2** | Coverage (RTL audit, form-validation recipes, i18n recipes, print recipe, MUI + Chakra migration) | [v1-2](./roadmap/epics/v1-2/README.md) | 32 | ~16-22 days |
|
|
470
470
|
| **v1.3** | Ecosystem (Figma plugin, theme marketplace, DTCG migration CLI, @cia/angular) | [v1-3](./roadmap/epics/v1-3/README.md) | 34 | ~28-35 days |
|
|
471
471
|
| v1.4 | *Reserved — scoped based on v1.1-v1.3 community feedback* | — | — | — |
|
package/dist/tokens.d.ts
CHANGED
package/llm.txt
CHANGED
|
@@ -183,7 +183,7 @@ cia ships a **recipes book**: portable patterns for building accessible componen
|
|
|
183
183
|
|
|
184
184
|
AI agents read recipes via MCP `list_recipes` / `get_recipe(name)` and generate consumer components in any framework. Humans read them at `/docs/recipes` and copy patterns directly.
|
|
185
185
|
|
|
186
|
-
**Shipped today —
|
|
186
|
+
**Shipped today — 32 recipes** (as of 2026-09-17): admin-dashboard-layout, app-shell, auth-flow, bottom-nav, breadcrumb, color-picker, combobox, combobox-multiselect, command-palette, confirm-dialog, data-table, datepicker, dialog, file-upload, form-validation-async, form-validation-html5, form-validation-react-hook-form, form-validation-success-states, form-validation-zod, i18n-date-formatting, i18n-number-currency, i18n-pluralization, letterhead, mobile-nav, multi-step-wizard, otp-input, pagination, print-spec, print-to-pdf, rtl-layout, sortable-list, toast. The authoritative list is `list_recipes` (MCP) or `npx cia add --list` — both read `scss/recipes/` directly. WCAG-strict variants live inside the recipes themselves (a `### WCAG-strict` subsection under Variants) — there is no separate a11y package. **No component library** — recipes are the deliverable. See `scss/recipes/README.md` for the schema.
|
|
187
187
|
|
|
188
188
|
## Priority ladder (the v1.0 pitch order)
|
|
189
189
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "css-is-awesome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "A token-driven SCSS design system with light/dark theming, semantic color tokens, and a 800+ LOC mixin API.",
|
|
5
5
|
"homepage": "https://github.com/Jerry2d3d/css-is-awesome#readme",
|
|
6
6
|
"bugs": {
|
package/scss/recipes/combobox.md
CHANGED
|
@@ -516,4 +516,4 @@ Committed values render as removable chips before the input; the input clears af
|
|
|
516
516
|
- [`dialog`](./dialog.md) — the other half of the command-palette pattern
|
|
517
517
|
- [`combobox-multiselect`](./combobox-multiselect.md) — this recipe extended to multiple selections with removable chips
|
|
518
518
|
- [`datepicker`](./datepicker.md) — another "native first, custom when needed" input recipe
|
|
519
|
-
-
|
|
519
|
+
- [`command-palette`](./command-palette.md) — Cmd+K palette = `<dialog>` + this combobox's input layer; it links here for the input, doesn't redefine it
|
|
@@ -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)
|
package/scss/recipes/dialog.md
CHANGED
|
@@ -282,5 +282,5 @@ If the slide-up entrance matters to you, use a `[popover]` element with the `bot
|
|
|
282
282
|
|
|
283
283
|
- [`bare-tags`](./_bare-tags.scss) — base bare `<dialog>` styling that applies if you skip a custom class name
|
|
284
284
|
- `bottom-nav` — slide-up sheets on `[popover]` with the full animation; the alternative named in the bottom-sheet variant above
|
|
285
|
-
-
|
|
286
|
-
-
|
|
285
|
+
- [`command-palette`](./command-palette.md) — Cmd+K palette built on `<dialog>` + the combobox pattern; `showModal()` is its focus trap
|
|
286
|
+
- [`toast`](./toast.md) — non-modal transient notifications (`[popover]` variant included)
|