css-is-awesome 1.1.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.
Files changed (198) hide show
  1. package/AGENTS.md +343 -0
  2. package/CHANGELOG.md +1036 -0
  3. package/CLAUDE.md +9 -0
  4. package/CONTRACT.md +580 -0
  5. package/GEMINI.md +9 -0
  6. package/LICENSE +21 -0
  7. package/LICENSE-third-party +88 -0
  8. package/MIGRATION.md +312 -0
  9. package/README.md +380 -0
  10. package/ROADMAP.md +714 -0
  11. package/THEMING.md +290 -0
  12. package/THREE-TIERS.md +158 -0
  13. package/VERSIONING.md +200 -0
  14. package/_index.scss +34 -0
  15. package/api.scss +16 -0
  16. package/bin/README.md +57 -0
  17. package/bin/cia.cjs +114 -0
  18. package/bin/migrate-bootstrap.cjs +427 -0
  19. package/bin/migrate-tailwind.cjs +832 -0
  20. package/css-is-awesome.instructions.md +533 -0
  21. package/dist/css-is-awesome.core.css +598 -0
  22. package/dist/css-is-awesome.core.min.css +1 -0
  23. package/dist/css-is-awesome.css +3733 -0
  24. package/dist/css-is-awesome.min.css +1 -0
  25. package/dist/css-is-awesome.utilities.css +2445 -0
  26. package/dist/css-is-awesome.utilities.min.css +1 -0
  27. package/dist/tokens.css +508 -0
  28. package/dist/tokens.d.ts +266 -0
  29. package/dist/tokens.min.css +1 -0
  30. package/figma-tokens/README.md +57 -0
  31. package/figma-tokens/primitives-brand.scss +121 -0
  32. package/figma-tokens/semantic-dark.scss +61 -0
  33. package/figma-tokens/semantic-light.scss +61 -0
  34. package/figma-tokens/tokens.json +144 -0
  35. package/llm.txt +183 -0
  36. package/mcp/server.cjs +1495 -0
  37. package/package.json +199 -0
  38. package/public/icons/README.md +278 -0
  39. package/public/icons/arrow-right.svg +4 -0
  40. package/public/icons/check.svg +4 -0
  41. package/public/icons/chevron-down.svg +4 -0
  42. package/public/icons/close.svg +4 -0
  43. package/public/icons/core/arrow-down.svg +13 -0
  44. package/public/icons/core/arrow-left.svg +13 -0
  45. package/public/icons/core/arrow-right.svg +13 -0
  46. package/public/icons/core/arrow-up.svg +13 -0
  47. package/public/icons/core/bell.svg +13 -0
  48. package/public/icons/core/calendar.svg +15 -0
  49. package/public/icons/core/check.svg +12 -0
  50. package/public/icons/core/chevron-down.svg +12 -0
  51. package/public/icons/core/chevron-left.svg +12 -0
  52. package/public/icons/core/chevron-right.svg +12 -0
  53. package/public/icons/core/chevron-up.svg +12 -0
  54. package/public/icons/core/clock.svg +13 -0
  55. package/public/icons/core/close.svg +13 -0
  56. package/public/icons/core/copy.svg +13 -0
  57. package/public/icons/core/download.svg +14 -0
  58. package/public/icons/core/edit.svg +13 -0
  59. package/public/icons/core/error.svg +14 -0
  60. package/public/icons/core/external-link.svg +14 -0
  61. package/public/icons/core/eye-off.svg +15 -0
  62. package/public/icons/core/eye.svg +13 -0
  63. package/public/icons/core/filter.svg +12 -0
  64. package/public/icons/core/heart.svg +12 -0
  65. package/public/icons/core/help.svg +14 -0
  66. package/public/icons/core/home.svg +13 -0
  67. package/public/icons/core/info.svg +14 -0
  68. package/public/icons/core/loading.svg +12 -0
  69. package/public/icons/core/lock.svg +13 -0
  70. package/public/icons/core/mail.svg +13 -0
  71. package/public/icons/core/menu.svg +14 -0
  72. package/public/icons/core/message.svg +12 -0
  73. package/public/icons/core/minus.svg +12 -0
  74. package/public/icons/core/more-horizontal.svg +14 -0
  75. package/public/icons/core/pause.svg +13 -0
  76. package/public/icons/core/play.svg +12 -0
  77. package/public/icons/core/plus.svg +13 -0
  78. package/public/icons/core/refresh.svg +15 -0
  79. package/public/icons/core/save.svg +14 -0
  80. package/public/icons/core/search.svg +13 -0
  81. package/public/icons/core/settings.svg +13 -0
  82. package/public/icons/core/share.svg +14 -0
  83. package/public/icons/core/sort.svg +15 -0
  84. package/public/icons/core/star.svg +12 -0
  85. package/public/icons/core/success.svg +13 -0
  86. package/public/icons/core/trash.svg +16 -0
  87. package/public/icons/core/unlock.svg +13 -0
  88. package/public/icons/core/upload.svg +14 -0
  89. package/public/icons/core/user.svg +13 -0
  90. package/public/icons/core/users.svg +15 -0
  91. package/public/icons/core/warning.svg +14 -0
  92. package/public/icons/download.svg +4 -0
  93. package/public/icons/edit.svg +4 -0
  94. package/public/icons/menu.svg +4 -0
  95. package/public/icons/search.svg +4 -0
  96. package/public/theme.css +4179 -0
  97. package/public/themes/README.md +102 -0
  98. package/public/themes/boilerplate/theme.css +154 -0
  99. package/public/themes/boilerplate-dark/theme.css +171 -0
  100. package/public/themes/boilerplate-light/theme.css +171 -0
  101. package/public/themes/cupertino/theme.css +165 -0
  102. package/public/themes/cupertino-dark/theme.css +200 -0
  103. package/public/themes/cupertino-light/theme.css +227 -0
  104. package/public/themes/glass/theme.css +190 -0
  105. package/public/themes/glass-dark/theme.css +201 -0
  106. package/public/themes/glass-light/theme.css +229 -0
  107. package/public/themes/graphite/theme.css +155 -0
  108. package/public/themes/graphite-dark/theme.css +223 -0
  109. package/public/themes/graphite-light/theme.css +191 -0
  110. package/public/themes/press/theme.css +189 -0
  111. package/public/themes/press-dark/theme.css +199 -0
  112. package/public/themes/press-light/theme.css +235 -0
  113. package/public/themes/prism/theme.css +159 -0
  114. package/public/themes/prism-dark/theme.css +185 -0
  115. package/public/themes/prism-light/theme.css +188 -0
  116. package/public/themes/sketchbook/theme.css +152 -0
  117. package/public/themes/sketchbook-dark/theme.css +194 -0
  118. package/public/themes/sketchbook-light/theme.css +188 -0
  119. package/public/themes/terminal/theme.css +159 -0
  120. package/public/themes/terminal-dark/theme.css +229 -0
  121. package/public/themes/terminal-light/theme.css +152 -0
  122. package/scripts/README.md +70 -0
  123. package/scripts/icon-contract.json +67 -0
  124. package/scripts/icon-validator.js +205 -0
  125. package/scripts/prepare-dist.mjs +65 -0
  126. package/scripts/theme-a11y.js +415 -0
  127. package/scripts/theme-contract.json +171 -0
  128. package/scripts/theme-validator.js +674 -0
  129. package/scss/README.md +55 -0
  130. package/scss/_animations-utilities.scss +107 -0
  131. package/scss/_animations.scss +248 -0
  132. package/scss/_app-styles.scss +15 -0
  133. package/scss/_generator.scss +174 -0
  134. package/scss/_icons.scss +475 -0
  135. package/scss/_index.scss +17 -0
  136. package/scss/_layout.scss +597 -0
  137. package/scss/_mixins.scss +1036 -0
  138. package/scss/_system.scss +291 -0
  139. package/scss/_utilities.scss +377 -0
  140. package/scss/api.scss +61 -0
  141. package/scss/components/_accordion.scss +72 -0
  142. package/scss/components/_buttons.scss +171 -0
  143. package/scss/components/_copy-button.scss +69 -0
  144. package/scss/components/_data.scss +190 -0
  145. package/scss/components/_feedback.scss +197 -0
  146. package/scss/components/_forms.scss +272 -0
  147. package/scss/components/_index.scss +17 -0
  148. package/scss/components/_navigation.scss +186 -0
  149. package/scss/components/_overlay.scss +259 -0
  150. package/scss/components/_stepper.scss +159 -0
  151. package/scss/components/_tabs.scss +72 -0
  152. package/scss/core.scss +54 -0
  153. package/scss/examples/_theming.scss +107 -0
  154. package/scss/examples/_usage.scss +317 -0
  155. package/scss/main.scss +64 -0
  156. package/scss/recipes/README.md +110 -0
  157. package/scss/recipes/_bare-tags.scss +232 -0
  158. package/scss/recipes/_recipe-template.md +119 -0
  159. package/scss/recipes/combobox.md +509 -0
  160. package/scss/recipes/dialog.md +265 -0
  161. package/scss/recipes/print-to-pdf.md +263 -0
  162. package/scss/theme/README.md +67 -0
  163. package/scss/theme/_brand.scss +18 -0
  164. package/scss/theme/_colors-dark.scss +68 -0
  165. package/scss/theme/_colors-light.scss +70 -0
  166. package/scss/theme/_components.scss +94 -0
  167. package/scss/theme/_icons.scss +104 -0
  168. package/scss/theme/_index.scss +62 -0
  169. package/scss/theme/_shadows.scss +65 -0
  170. package/scss/theme/_shape.scss +13 -0
  171. package/scss/theme/_typography.scss +9 -0
  172. package/scss/theme/registry.json +447 -0
  173. package/scss/themes/boilerplate-dark.scss +212 -0
  174. package/scss/themes/boilerplate-light.scss +209 -0
  175. package/scss/themes/boilerplate.scss +181 -0
  176. package/scss/themes/cupertino-dark.scss +238 -0
  177. package/scss/themes/cupertino-light.scss +266 -0
  178. package/scss/themes/cupertino.scss +194 -0
  179. package/scss/themes/glass-dark.scss +238 -0
  180. package/scss/themes/glass-light.scss +266 -0
  181. package/scss/themes/glass.scss +220 -0
  182. package/scss/themes/graphite-dark.scss +260 -0
  183. package/scss/themes/graphite-light.scss +228 -0
  184. package/scss/themes/graphite.scss +182 -0
  185. package/scss/themes/press-dark.scss +237 -0
  186. package/scss/themes/press-light.scss +273 -0
  187. package/scss/themes/press.scss +241 -0
  188. package/scss/themes/prism-dark.scss +217 -0
  189. package/scss/themes/prism-light.scss +220 -0
  190. package/scss/themes/prism.scss +190 -0
  191. package/scss/themes/sketchbook-dark.scss +231 -0
  192. package/scss/themes/sketchbook-light.scss +231 -0
  193. package/scss/themes/sketchbook.scss +178 -0
  194. package/scss/themes/terminal-dark.scss +267 -0
  195. package/scss/themes/terminal-light.scss +175 -0
  196. package/scss/themes/terminal.scss +185 -0
  197. package/scss/tokens.scss +23 -0
  198. package/scss/utilities-only.scss +4 -0
@@ -0,0 +1,509 @@
1
+ ---
2
+ name: combobox
3
+ description: Autocomplete text input with a filtered suggestion list — native <datalist> for the simple case, ARIA combobox pattern for full control.
4
+ category: input
5
+ complexity: complex
6
+ cia-version: ">=1.0.0"
7
+ ---
8
+
9
+ ## Use this when
10
+
11
+ You need a text input that suggests and filters options as the user types — country pickers, tag inputs, search-with-suggestions. Start with the **native variant** (`<input list>` + `<datalist>`): zero JS, screen-reader support for free. Upgrade to the **custom variant** only when you need styled options, async option loading, or multi-select. If users pick from a short fixed list and never type, bail and use a plain `<select>` with `cia.select-base` instead.
12
+
13
+ ## Structure (raw HTML)
14
+
15
+ ### Native variant (`<datalist>`)
16
+
17
+ ```html
18
+ <div data-cia-recipe="combobox">
19
+ <label for="my-fruit" data-slot="label">Favorite fruit</label>
20
+ <input id="my-fruit" data-slot="input" type="text" list="my-fruit-options" autocomplete="off" />
21
+ <datalist id="my-fruit-options">
22
+ <option value="Apple"></option>
23
+ <option value="Banana"></option>
24
+ <option value="Cherry"></option>
25
+ </datalist>
26
+ </div>
27
+ ```
28
+
29
+ Notes on the markup:
30
+
31
+ - The browser owns the suggestion popup: filtering, keyboard nav, and screen-reader announcements all work natively
32
+ - `autocomplete="off"` stops the browser's form-autofill popup from fighting the datalist popup
33
+ - The popup itself is **not styleable** — if the design requires styled options, use the custom variant below
34
+
35
+ ### Custom variant (ARIA combobox pattern)
36
+
37
+ ```html
38
+ <div class="my-combobox" data-cia-recipe="combobox">
39
+ <label for="my-combo-input" data-slot="label">Favorite fruit</label>
40
+ <input
41
+ id="my-combo-input"
42
+ data-slot="input"
43
+ type="text"
44
+ role="combobox"
45
+ aria-expanded="false"
46
+ aria-controls="my-combo-listbox"
47
+ aria-autocomplete="list"
48
+ autocomplete="off"
49
+ spellcheck="false"
50
+ />
51
+ <ul id="my-combo-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" hidden>
52
+ <li id="my-combo-opt-0" role="option">Apple</li>
53
+ <li id="my-combo-opt-1" role="option" data-active>Banana</li>
54
+ <li id="my-combo-opt-2" role="option" aria-selected="true">Cherry</li>
55
+ </ul>
56
+ </div>
57
+ ```
58
+
59
+ Notes on the markup:
60
+
61
+ - The `<input>` keeps DOM focus the whole time — the listbox is driven by `aria-activedescendant` on the input pointing at the active option's `id` (no roving `tabindex`)
62
+ - `aria-expanded` mirrors whether the listbox is visible; `aria-controls` ties input to listbox
63
+ - `aria-autocomplete="list"` tells screen readers a filtered list appears as the user types
64
+ - `data-active` marks the option `aria-activedescendant` currently points at (keyboard highlight); `aria-selected="true"` marks the committed value — they are different states and style differently
65
+ - Option `id`s must be stable per render so `aria-activedescendant` can reference them
66
+
67
+ ## Styling (cia mixins)
68
+
69
+ ```scss
70
+ // MyCombobox.module.scss — component stylesheet, so import the zero-emit barrel.
71
+ @use 'css-is-awesome/api' as cia;
72
+
73
+ .my-combobox {
74
+ position: relative;
75
+
76
+ [data-slot="label"] { @include cia.label-base; }
77
+
78
+ [data-slot="input"] {
79
+ @include cia.input-base;
80
+ inline-size: 100%;
81
+ }
82
+
83
+ [data-slot="listbox"] {
84
+ @include cia.popover-base($p: 1, $max-width: none);
85
+ position: absolute;
86
+ inset-block-start: calc(100% + #{cia.space(1)});
87
+ inset-inline: 0;
88
+ margin: 0;
89
+ list-style: none;
90
+ max-block-size: 16rem;
91
+ overflow-y: auto;
92
+ }
93
+
94
+ [role="option"] {
95
+ @include cia.dropdown-item;
96
+ border-radius: cia.radius(sm);
97
+ }
98
+
99
+ /* Keyboard highlight — where aria-activedescendant points */
100
+ [role="option"][data-active] {
101
+ background: cia.color(interactive-hover);
102
+ }
103
+
104
+ /* Committed value */
105
+ [role="option"][aria-selected="true"] {
106
+ font-weight: cia.font-weight(medium);
107
+ background: cia.color(interactive-active);
108
+ }
109
+ }
110
+ ```
111
+
112
+ The native variant only needs `[data-slot="label"]` + `[data-slot="input"]` — the browser popup ignores author CSS. `cia.dropdown-item` already provides `:hover` (pointer highlight); `[data-active]` layers the keyboard highlight on top so mouse and keyboard states stay independent.
113
+
114
+ ## Interactivity
115
+
116
+ Native variant: **zero JS.** The browser filters, navigates, commits, and announces.
117
+
118
+ Custom variant: this is the rare recipe where real JS is required — ARIA state doesn't update itself. The consumer script owns five jobs, each a few lines:
119
+
120
+ 1. **Filter** options against the input value on every `input` event (re-open the listbox, reset the active index)
121
+ 2. **Open/close** — set `hidden` on the listbox and mirror it to `aria-expanded` on the input
122
+ 3. **Track the active option** — move an index with Arrow keys, set `aria-activedescendant` to that option's `id`, toggle `data-active`
123
+ 4. **Commit** — on Enter or option click, write the option's text into the input, set `aria-selected`, close
124
+ 5. **Dismiss** — Esc closes the listbox (a second Esc clears the input); blur closes
125
+
126
+ Keyboard map (per the [WAI-ARIA APG combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)):
127
+
128
+ | Key | Behavior |
129
+ |---|---|
130
+ | `ArrowDown` | Open the listbox if closed; move active option down |
131
+ | `ArrowUp` | Move active option up |
132
+ | `Enter` | Commit the active option, close the listbox |
133
+ | `Escape` | Close the listbox; if already closed, clear the input |
134
+ | `Home` / `End` | Move the **text caret** to start/end of the input (native — DOM focus never leaves the input) |
135
+ | Printable keys | Type into the input; list re-filters |
136
+
137
+ Edge cases:
138
+
139
+ - **Option `mousedown` vs input `blur`:** clicking an option fires the input's `blur` first, which closes the listbox before `click` lands. Commit on `mousedown` + `preventDefault()` (the framework examples below all do this).
140
+ - **SSR:** render with `hidden` on the listbox and `aria-expanded="false"` — the closed state is the correct server snapshot.
141
+ - **Async options:** while loading, keep `aria-expanded="true"` and render a single non-interactive `<li>` *without* `role="option"` ("Loading…") so it isn't keyboard-reachable.
142
+
143
+ Pairs well with (but requires none of): [Downshift](https://www.downshift-js.com/), [Headless UI Combobox](https://headlessui.com/react/combobox), [Zag.js combobox](https://zagjs.com/components/react/combobox) — they own jobs 1-5 and you keep this recipe's styling section verbatim.
144
+
145
+ ## A11y checklist
146
+
147
+ - [ ] Input has `role="combobox"`, `aria-expanded`, `aria-controls` pointing at the listbox `id` ([APG Combobox Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/))
148
+ - [ ] `aria-activedescendant` on the input tracks the active option; DOM focus stays on the input
149
+ - [ ] Every suggestion has `role="option"` and a stable `id`; the listbox has `role="listbox"` with an accessible name
150
+ - [ ] `ArrowDown` / `ArrowUp` move the active option; `Enter` commits; `Esc` closes (APG keyboard interaction)
151
+ - [ ] `Home` / `End` keep their native text-editing behavior (caret to start/end) — do not hijack them for option nav in an editable combobox
152
+ - [ ] Committed option carries `aria-selected="true"`
153
+ - [ ] Visible label is associated via `for`/`id` ([WCAG 2.2 SC 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html))
154
+ - [ ] Keyboard highlight (`[data-active]`) meets non-text contrast against the listbox surface ([WCAG 2.2 SC 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html))
155
+ - [ ] Touch targets: options inherit ≥24px height from `cia.dropdown-item` padding ([WCAG 2.2 SC 2.5.8 Target Size](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html))
156
+
157
+ ## Framework examples
158
+
159
+ All four examples implement the same spec: filter-as-you-type over a static list, full APG keyboard map, commit on Enter/click.
160
+
161
+ ### React
162
+
163
+ ```tsx
164
+ "use client";
165
+ import { useId, useState } from "react";
166
+ import styles from "./MyCombobox.module.scss";
167
+
168
+ const FRUITS = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Mango", "Peach", "Pear"];
169
+
170
+ export default function MyCombobox() {
171
+ const id = useId();
172
+ const [value, setValue] = useState("");
173
+ const [open, setOpen] = useState(false);
174
+ const [active, setActive] = useState(-1);
175
+
176
+ const matches = FRUITS.filter((f) => f.toLowerCase().includes(value.toLowerCase()));
177
+ const expanded = open && matches.length > 0;
178
+
179
+ const commit = (option: string) => {
180
+ setValue(option);
181
+ setOpen(false);
182
+ setActive(-1);
183
+ };
184
+
185
+ const onKeyDown = (e: React.KeyboardEvent) => {
186
+ if (e.key === "ArrowDown") {
187
+ e.preventDefault();
188
+ setOpen(true);
189
+ setActive((a) => Math.min(a + 1, matches.length - 1));
190
+ } else if (e.key === "ArrowUp") {
191
+ e.preventDefault();
192
+ setActive((a) => Math.max(a - 1, 0));
193
+ } else if (e.key === "Enter" && expanded && active >= 0) {
194
+ e.preventDefault();
195
+ commit(matches[active]);
196
+ } else if (e.key === "Escape") {
197
+ if (open) { setOpen(false); setActive(-1); } else setValue("");
198
+ }
199
+ // Home / End fall through: native caret movement
200
+ };
201
+
202
+ return (
203
+ <div className={styles.myCombobox}>
204
+ <label htmlFor={`${id}-input`} data-slot="label">Favorite fruit</label>
205
+ <input
206
+ id={`${id}-input`}
207
+ data-slot="input"
208
+ type="text"
209
+ role="combobox"
210
+ aria-expanded={expanded}
211
+ aria-controls={`${id}-listbox`}
212
+ aria-autocomplete="list"
213
+ aria-activedescendant={active >= 0 ? `${id}-opt-${active}` : undefined}
214
+ autoComplete="off"
215
+ spellCheck={false}
216
+ value={value}
217
+ onChange={(e) => { setValue(e.target.value); setOpen(true); setActive(-1); }}
218
+ onKeyDown={onKeyDown}
219
+ onBlur={() => setOpen(false)}
220
+ />
221
+ <ul id={`${id}-listbox`} data-slot="listbox" role="listbox" aria-label="Suggestions" hidden={!expanded}>
222
+ {matches.map((fruit, i) => (
223
+ <li
224
+ key={fruit}
225
+ id={`${id}-opt-${i}`}
226
+ role="option"
227
+ aria-selected={fruit === value}
228
+ data-active={i === active || undefined}
229
+ onMouseDown={(e) => { e.preventDefault(); commit(fruit); }}
230
+ >
231
+ {fruit}
232
+ </li>
233
+ ))}
234
+ </ul>
235
+ </div>
236
+ );
237
+ }
238
+ ```
239
+
240
+ ### Vue
241
+
242
+ ```vue
243
+ <script setup>
244
+ import { computed, ref } from "vue";
245
+
246
+ const FRUITS = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Mango", "Peach", "Pear"];
247
+
248
+ const value = ref("");
249
+ const open = ref(false);
250
+ const active = ref(-1);
251
+
252
+ const matches = computed(() =>
253
+ FRUITS.filter((f) => f.toLowerCase().includes(value.value.toLowerCase()))
254
+ );
255
+ const expanded = computed(() => open.value && matches.value.length > 0);
256
+
257
+ function commit(option) {
258
+ value.value = option;
259
+ open.value = false;
260
+ active.value = -1;
261
+ }
262
+
263
+ function onInput() {
264
+ open.value = true;
265
+ active.value = -1;
266
+ }
267
+
268
+ function onKeyDown(e) {
269
+ if (e.key === "ArrowDown") {
270
+ e.preventDefault();
271
+ open.value = true;
272
+ active.value = Math.min(active.value + 1, matches.value.length - 1);
273
+ } else if (e.key === "ArrowUp") {
274
+ e.preventDefault();
275
+ active.value = Math.max(active.value - 1, 0);
276
+ } else if (e.key === "Enter" && expanded.value && active.value >= 0) {
277
+ e.preventDefault();
278
+ commit(matches.value[active.value]);
279
+ } else if (e.key === "Escape") {
280
+ if (open.value) { open.value = false; active.value = -1; } else value.value = "";
281
+ }
282
+ }
283
+ </script>
284
+
285
+ <template>
286
+ <div class="my-combobox">
287
+ <label for="combo-input" data-slot="label">Favorite fruit</label>
288
+ <input
289
+ id="combo-input"
290
+ data-slot="input"
291
+ type="text"
292
+ role="combobox"
293
+ :aria-expanded="expanded"
294
+ aria-controls="combo-listbox"
295
+ aria-autocomplete="list"
296
+ :aria-activedescendant="active >= 0 ? `combo-opt-${active}` : undefined"
297
+ autocomplete="off"
298
+ spellcheck="false"
299
+ v-model="value"
300
+ @input="onInput"
301
+ @keydown="onKeyDown"
302
+ @blur="open = false"
303
+ />
304
+ <ul id="combo-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" :hidden="!expanded">
305
+ <li
306
+ v-for="(fruit, i) in matches"
307
+ :key="fruit"
308
+ :id="`combo-opt-${i}`"
309
+ role="option"
310
+ :aria-selected="fruit === value"
311
+ :data-active="i === active ? '' : undefined"
312
+ @mousedown.prevent="commit(fruit)"
313
+ >
314
+ {{ fruit }}
315
+ </li>
316
+ </ul>
317
+ </div>
318
+ </template>
319
+ ```
320
+
321
+ ### Svelte
322
+
323
+ ```svelte
324
+ <script>
325
+ const FRUITS = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Mango", "Peach", "Pear"];
326
+
327
+ let value = "";
328
+ let open = false;
329
+ let active = -1;
330
+
331
+ $: matches = FRUITS.filter((f) => f.toLowerCase().includes(value.toLowerCase()));
332
+ $: expanded = open && matches.length > 0;
333
+
334
+ function commit(option) {
335
+ value = option;
336
+ open = false;
337
+ active = -1;
338
+ }
339
+
340
+ function onKeyDown(e) {
341
+ if (e.key === "ArrowDown") {
342
+ e.preventDefault();
343
+ open = true;
344
+ active = Math.min(active + 1, matches.length - 1);
345
+ } else if (e.key === "ArrowUp") {
346
+ e.preventDefault();
347
+ active = Math.max(active - 1, 0);
348
+ } else if (e.key === "Enter" && expanded && active >= 0) {
349
+ e.preventDefault();
350
+ commit(matches[active]);
351
+ } else if (e.key === "Escape") {
352
+ if (open) { open = false; active = -1; } else value = "";
353
+ }
354
+ }
355
+ </script>
356
+
357
+ <div class="my-combobox">
358
+ <label for="combo-input" data-slot="label">Favorite fruit</label>
359
+ <input
360
+ id="combo-input"
361
+ data-slot="input"
362
+ type="text"
363
+ role="combobox"
364
+ aria-expanded={expanded}
365
+ aria-controls="combo-listbox"
366
+ aria-autocomplete="list"
367
+ aria-activedescendant={active >= 0 ? `combo-opt-${active}` : undefined}
368
+ autocomplete="off"
369
+ spellcheck="false"
370
+ bind:value
371
+ on:input={() => { open = true; active = -1; }}
372
+ on:keydown={onKeyDown}
373
+ on:blur={() => (open = false)}
374
+ />
375
+ <ul id="combo-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" hidden={!expanded}>
376
+ {#each matches as fruit, i (fruit)}
377
+ <li
378
+ id={`combo-opt-${i}`}
379
+ role="option"
380
+ aria-selected={fruit === value}
381
+ data-active={i === active ? "" : undefined}
382
+ on:mousedown|preventDefault={() => commit(fruit)}
383
+ >
384
+ {fruit}
385
+ </li>
386
+ {/each}
387
+ </ul>
388
+ </div>
389
+ ```
390
+
391
+ ### Vanilla (Web Component)
392
+
393
+ ```js
394
+ class MyCombobox extends HTMLElement {
395
+ static idSeq = 0;
396
+
397
+ connectedCallback() {
398
+ this._options = (this.getAttribute("options") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
399
+ this._uid = `my-combo-${MyCombobox.idSeq++}`;
400
+ this._active = -1;
401
+ this._open = false;
402
+
403
+ this.classList.add("my-combobox");
404
+ this.innerHTML = `
405
+ <label for="${this._uid}-input" data-slot="label">${this.getAttribute("label") ?? "Choose"}</label>
406
+ <input id="${this._uid}-input" data-slot="input" type="text" role="combobox"
407
+ aria-expanded="false" aria-controls="${this._uid}-listbox"
408
+ aria-autocomplete="list" autocomplete="off" spellcheck="false" />
409
+ <ul id="${this._uid}-listbox" data-slot="listbox" role="listbox" aria-label="Suggestions" hidden></ul>
410
+ `;
411
+ this._input = this.querySelector("input");
412
+ this._listbox = this.querySelector("ul");
413
+
414
+ this._input.addEventListener("input", () => { this._open = true; this._active = -1; this._render(); });
415
+ this._input.addEventListener("keydown", (e) => this._onKeyDown(e));
416
+ this._input.addEventListener("blur", () => { this._open = false; this._render(); });
417
+ }
418
+
419
+ get _matches() {
420
+ const q = this._input.value.toLowerCase();
421
+ return this._options.filter((o) => o.toLowerCase().includes(q));
422
+ }
423
+
424
+ _commit(option) {
425
+ this._input.value = option;
426
+ this._open = false;
427
+ this._active = -1;
428
+ this._render();
429
+ this.dispatchEvent(new CustomEvent("change", { detail: option }));
430
+ }
431
+
432
+ _onKeyDown(e) {
433
+ const matches = this._matches;
434
+ if (e.key === "ArrowDown") {
435
+ e.preventDefault();
436
+ this._open = true;
437
+ this._active = Math.min(this._active + 1, matches.length - 1);
438
+ } else if (e.key === "ArrowUp") {
439
+ e.preventDefault();
440
+ this._active = Math.max(this._active - 1, 0);
441
+ } else if (e.key === "Enter" && this._open && this._active >= 0) {
442
+ e.preventDefault();
443
+ this._commit(matches[this._active]);
444
+ return;
445
+ } else if (e.key === "Escape") {
446
+ if (this._open) { this._open = false; this._active = -1; } else this._input.value = "";
447
+ } else {
448
+ return; // Home/End + printable keys: native behavior
449
+ }
450
+ this._render();
451
+ }
452
+
453
+ _render() {
454
+ const matches = this._matches;
455
+ const expanded = this._open && matches.length > 0;
456
+ this._input.setAttribute("aria-expanded", String(expanded));
457
+ this._listbox.hidden = !expanded;
458
+
459
+ this._listbox.innerHTML = "";
460
+ matches.forEach((option, i) => {
461
+ const li = document.createElement("li");
462
+ li.id = `${this._uid}-opt-${i}`;
463
+ li.setAttribute("role", "option");
464
+ li.setAttribute("aria-selected", String(option === this._input.value));
465
+ if (i === this._active) li.setAttribute("data-active", "");
466
+ li.textContent = option;
467
+ li.addEventListener("mousedown", (e) => { e.preventDefault(); this._commit(option); });
468
+ this._listbox.appendChild(li);
469
+ });
470
+
471
+ this._input.setAttribute("aria-activedescendant",
472
+ this._active >= 0 ? `${this._uid}-opt-${this._active}` : "");
473
+ }
474
+ }
475
+ customElements.define("my-combobox", MyCombobox);
476
+
477
+ // usage in HTML:
478
+ // <my-combobox label="Favorite fruit" options="Apple, Banana, Cherry, Mango"></my-combobox>
479
+ // <script>document.querySelector('my-combobox').addEventListener('change', (e) => console.log(e.detail));</script>
480
+ ```
481
+
482
+ ## Variants
483
+
484
+ ### Select-only (no free text)
485
+
486
+ Add `readonly` semantics: ignore printable keys, open on click/`ArrowDown`, and skip the filter (always show all options). Keep `role="combobox"` — this is the APG "select-only combobox" sub-pattern. Consider whether a styled `<select>` with `cia.select-base` does the job first.
487
+
488
+ ### Async / remote options
489
+
490
+ Replace the static array with a fetch debounced ~200ms. While loading, keep the listbox open with one non-interactive `<li>` ("Searching…") that has **no** `role="option"`. Announce result counts with a visually-hidden `aria-live="polite"` region ("8 results available").
491
+
492
+ ### Multi-select (tag input)
493
+
494
+ Committed values render as removable chips before the input; the input clears after each commit instead of taking the value. Set `aria-selected="true"` on every committed option still in the list. This is a large step up in complexity — this is the point where a headless engine (Downshift, Zag.js) earns its weight.
495
+
496
+ ## Pitfalls
497
+
498
+ - **Don't hijack Home/End.** In an editable combobox they must move the text caret. Hijacking them for first/last option breaks text editing and contradicts the APG.
499
+ - **`aria-activedescendant` requires same-document `id` references.** It cannot point across shadow DOM boundaries — the Web Component above deliberately uses light DOM.
500
+ - **Blur-close race:** closing the listbox on input `blur` kills option `click` handlers. Commit on `mousedown` + `preventDefault()` (all examples above) or use `relatedTarget` checks.
501
+ - **`<datalist>` styling is a dead end.** No browser lets you style the native popup. Don't burn time on it — if the design needs styled options, that's the signal to move to the custom variant.
502
+ - **Filtering resets the active index.** After re-filtering, old indices point at different options. Reset `active` to `-1` on every `input` event (all examples above do).
503
+ - **Scroll the active option into view** when the list overflows: `option.scrollIntoView({ block: "nearest" })` after moving the active index — omitted from the examples for brevity, needed in production with long lists.
504
+
505
+ ## Related recipes
506
+
507
+ - [`dialog`](./dialog.md) — the other half of the command-palette pattern
508
+ - (planned, Week 2) `command-palette.md` — Cmd+K palette = `<dialog>` + this combobox's input layer; it links here for the input, doesn't redefine it
509
+ - (planned, Week 2) `datepicker.md` — another "native first, custom when needed" input recipe