css-is-awesome 1.14.2 → 1.15.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/AGENTS.md +3 -3
- package/CHANGELOG.md +20 -0
- package/README.md +4 -4
- package/dist/tokens.d.ts +1 -1
- package/llm.txt +2 -2
- package/mcp/server.cjs +1 -1
- package/package.json +1 -1
- package/scss/recipes/README.md +1 -1
- package/scss/recipes/breadcrumb.md +265 -0
- package/scss/recipes/color-picker.md +605 -0
- package/scss/recipes/combobox-multiselect.md +695 -0
- package/scss/recipes/combobox.md +3 -2
- package/scss/recipes/file-upload.md +653 -0
- package/scss/recipes/pagination.md +444 -0
- package/scss/recipes/sortable-list.md +701 -0
- package/scss/recipes/toast.md +571 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: combobox-multiselect
|
|
3
|
+
description: A tag-input combobox — the ARIA combobox pattern extended to multiple selections, each rendered as a removable chip in front of the text field.
|
|
4
|
+
category: input
|
|
5
|
+
complexity: complex
|
|
6
|
+
cia-version: ">=1.0.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Use this when
|
|
10
|
+
|
|
11
|
+
You need a "pick several from a list" field where the chosen items stay visible as chips — tags on a post, assignees on a ticket, skills on a profile. This recipe **extends the custom variant of [`combobox`](./combobox.md)**: one text input, one filtered listbox, but the listbox is `aria-multiselectable` and every committed option becomes a chip with its own remove button. If the user only ever picks **one** value, stop here and use the plain `combobox` recipe. If the option list is short and fixed and typing-to-filter adds nothing, a group of native `<input type="checkbox">` styled with `cia.check-base` is simpler and needs zero JS.
|
|
12
|
+
|
|
13
|
+
## Structure (raw HTML)
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<div class="my-multiselect" data-cia-recipe="combobox-multiselect">
|
|
17
|
+
<label id="my-ms-label" for="my-ms-input" data-slot="label">Toppings</label>
|
|
18
|
+
|
|
19
|
+
<!-- The chips and the input share one visual "field" box -->
|
|
20
|
+
<div data-slot="control">
|
|
21
|
+
<div data-slot="field">
|
|
22
|
+
<ul data-slot="chips" aria-labelledby="my-ms-label" role="list">
|
|
23
|
+
<li data-slot="chip">
|
|
24
|
+
Cheese
|
|
25
|
+
<button type="button" data-slot="remove" aria-label="Remove Cheese">×</button>
|
|
26
|
+
</li>
|
|
27
|
+
<li data-slot="chip">
|
|
28
|
+
Olives
|
|
29
|
+
<button type="button" data-slot="remove" aria-label="Remove Olives">×</button>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
|
|
33
|
+
<input
|
|
34
|
+
id="my-ms-input"
|
|
35
|
+
data-slot="input"
|
|
36
|
+
type="text"
|
|
37
|
+
role="combobox"
|
|
38
|
+
aria-expanded="false"
|
|
39
|
+
aria-controls="my-ms-listbox"
|
|
40
|
+
aria-autocomplete="list"
|
|
41
|
+
aria-describedby="my-ms-hint"
|
|
42
|
+
autocomplete="off"
|
|
43
|
+
spellcheck="false"
|
|
44
|
+
placeholder="Add a topping…"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<ul id="my-ms-listbox" data-slot="listbox" role="listbox" aria-label="Toppings" aria-multiselectable="true" hidden>
|
|
49
|
+
<li id="my-ms-opt-0" role="option" aria-selected="true">Cheese</li>
|
|
50
|
+
<li id="my-ms-opt-1" role="option" aria-selected="false" data-active>Mushrooms</li>
|
|
51
|
+
<li id="my-ms-opt-2" role="option" aria-selected="true">Olives</li>
|
|
52
|
+
<li id="my-ms-opt-3" role="option" aria-selected="false">Peppers</li>
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<span id="my-ms-hint" data-slot="hint">Type to filter. Enter adds, Backspace removes the last one.</span>
|
|
57
|
+
<span data-slot="announce" aria-live="polite" class="my-sr-only"></span>
|
|
58
|
+
</div>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Notes on the markup:
|
|
62
|
+
|
|
63
|
+
- The input keeps the exact combobox contract from the base recipe — `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete="list"`, `aria-activedescendant` (added by JS when an option is active). The **only** ARIA additions are `aria-multiselectable="true"` on the listbox and `aria-selected` reflecting membership rather than "the one committed value".
|
|
64
|
+
- Chips are a real `<ul role="list">` named by the field's label, so a screen-reader user can review the current selection as a list ("Toppings, list, 2 items") without opening the popup. Each chip's remove `<button>` carries its own `aria-label` naming the item — "×" alone is not a name.
|
|
65
|
+
- `[data-slot="field"]` is a purely visual wrapper that makes chips and input read as one control. Clicking anywhere on it should focus the input (JS, one line).
|
|
66
|
+
- The `[data-slot="announce"]` region is where selection changes are spoken once ("Olives added", "Cheese removed") — the listbox's `aria-selected` flips are not reliably announced when DOM focus stays on the input.
|
|
67
|
+
- SSR snapshot: listbox `hidden`, `aria-expanded="false"`, chips already rendered for any preselected values.
|
|
68
|
+
|
|
69
|
+
## Styling (cia mixins)
|
|
70
|
+
|
|
71
|
+
```scss
|
|
72
|
+
// MyMultiselect.module.scss — component stylesheet, so import the zero-emit barrel.
|
|
73
|
+
@use 'css-is-awesome/api' as cia;
|
|
74
|
+
|
|
75
|
+
.my-multiselect {
|
|
76
|
+
[data-slot="label"] {
|
|
77
|
+
@include cia.label-base;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Positioning context for the popup — wraps the field and the listbox only,
|
|
81
|
+
// so the popup opens directly under the field, not under the hint.
|
|
82
|
+
[data-slot="control"] {
|
|
83
|
+
position: relative;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The shared "field" box gets the input chrome; the real <input> is bare.
|
|
87
|
+
[data-slot="field"] {
|
|
88
|
+
@include cia.input-base($py: 2xs, $px: 1);
|
|
89
|
+
@include cia.cluster($gap: 2xs);
|
|
90
|
+
cursor: text;
|
|
91
|
+
|
|
92
|
+
// Delegate the focus ring to the wrapper — the visible box is what the user reads as "the field".
|
|
93
|
+
&:focus-within {
|
|
94
|
+
border-color: cia.color(border-focus);
|
|
95
|
+
box-shadow: 0 0 0 3px rgba(cia.color-static(border-focus), 0.2);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
[data-slot="chips"] {
|
|
100
|
+
@include cia.list-reset;
|
|
101
|
+
@include cia.cluster($gap: 2xs);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
[data-slot="chip"] {
|
|
105
|
+
@include cia.tag($removable: true);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
[data-slot="remove"] {
|
|
109
|
+
@include cia.btn-icon($size: 1.25rem, $r: sm);
|
|
110
|
+
@include cia.font(medium, 2);
|
|
111
|
+
line-height: 1;
|
|
112
|
+
color: cia.color(text-muted);
|
|
113
|
+
|
|
114
|
+
&:hover {
|
|
115
|
+
color: cia.color(text-primary);
|
|
116
|
+
background: cia.color(interactive-hover);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
[data-slot="input"] {
|
|
121
|
+
@include cia.form-reset;
|
|
122
|
+
flex: 1 1 8ch;
|
|
123
|
+
min-inline-size: 8ch;
|
|
124
|
+
padding: cia.space(2xs) cia.space(1);
|
|
125
|
+
background: transparent;
|
|
126
|
+
color: cia.color(text-primary);
|
|
127
|
+
@include cia.font(reg, 2);
|
|
128
|
+
|
|
129
|
+
&::placeholder {
|
|
130
|
+
color: cia.color(text-muted);
|
|
131
|
+
}
|
|
132
|
+
&:focus {
|
|
133
|
+
outline: none; // the wrapper's :focus-within draws the ring
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
[data-slot="listbox"] {
|
|
138
|
+
@include cia.popover-base($p: 1, $max-width: none);
|
|
139
|
+
position: absolute;
|
|
140
|
+
inset-block-start: calc(100% + #{cia.space(1)});
|
|
141
|
+
inset-inline: 0;
|
|
142
|
+
margin: 0;
|
|
143
|
+
list-style: none;
|
|
144
|
+
max-block-size: 16rem;
|
|
145
|
+
overflow-y: auto;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
[role="option"] {
|
|
149
|
+
@include cia.dropdown-item;
|
|
150
|
+
border-radius: cia.radius(sm);
|
|
151
|
+
justify-content: space-between;
|
|
152
|
+
|
|
153
|
+
// A visible "selected" mark so membership isn't conveyed by colour alone
|
|
154
|
+
&[aria-selected="true"]::after {
|
|
155
|
+
content: "✓";
|
|
156
|
+
color: cia.color(action-primary-default);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Keyboard highlight — where aria-activedescendant points
|
|
161
|
+
[role="option"][data-active] {
|
|
162
|
+
background: cia.color(interactive-hover);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
[role="option"][aria-selected="true"] {
|
|
166
|
+
font-weight: cia.font-weight(medium);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
[data-slot="hint"] {
|
|
170
|
+
@include cia.form-help;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.my-sr-only {
|
|
175
|
+
@include cia.sr-only;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`cia.input-base` goes on the **wrapper**, not the `<input>`, because the chips live inside the same border. The input itself gets `cia.form-reset` so it inherits the wrapper's font and background with no chrome of its own. `[data-slot="control"]` is the positioning context so the popup hangs off the field, not off the hint below it.
|
|
180
|
+
|
|
181
|
+
## Interactivity
|
|
182
|
+
|
|
183
|
+
Everything from the base [`combobox`](./combobox.md) recipe still applies — filter on `input`, open/close mirrored to `aria-expanded`, `aria-activedescendant` tracking, commit on Enter/`mousedown`, dismiss on Esc/blur. Three things change:
|
|
184
|
+
|
|
185
|
+
1. **Commit toggles instead of replaces.** Enter (or clicking an option) adds the option to the selection if absent, removes it if present. The input is cleared after a commit and the listbox **stays open** so the user can keep picking — closing it after every pick is the most common multiselect annoyance.
|
|
186
|
+
2. **Backspace in an empty input removes the last chip.** Only when the caret is at position 0 with no text; otherwise Backspace edits text as normal.
|
|
187
|
+
3. **Each chip's remove button is a real button.** Click or Enter/Space on it removes that item and moves focus back to the input, so keyboard users don't land on a chip that just disappeared.
|
|
188
|
+
|
|
189
|
+
Keyboard map (per the [WAI-ARIA APG combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/), with the multiselect additions marked):
|
|
190
|
+
|
|
191
|
+
| Key | Behavior |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `ArrowDown` | Open the listbox if closed; move active option down (wraps to top) |
|
|
194
|
+
| `ArrowUp` | Move active option up (wraps to bottom) |
|
|
195
|
+
| `Enter` | **Toggle** the active option; clear the input; keep the listbox open |
|
|
196
|
+
| `Backspace` (input empty) | **Remove the last chip** |
|
|
197
|
+
| `Escape` | Close the listbox; if already closed, clear the input text |
|
|
198
|
+
| `Tab` | Native — leaves the field; the listbox closes on blur |
|
|
199
|
+
| Printable keys | Type into the input; list re-filters |
|
|
200
|
+
|
|
201
|
+
Edge cases:
|
|
202
|
+
|
|
203
|
+
- **Announcements.** After every add/remove, write a short sentence into the polite live region ("Olives added", "Cheese removed, 1 selected"). Clear it on the next change so repeated identical messages are still spoken.
|
|
204
|
+
- **Chip focus order.** Chips come *before* the input in DOM order, so Shift+Tab from the input walks the remove buttons in reverse — that's correct and expected; don't `tabindex="-1"` them away, or keyboard users lose the only way to remove a specific chip.
|
|
205
|
+
- **Max selection.** If you cap the count, keep the listbox open but render options non-selectable (`aria-disabled="true"`, skip in Arrow navigation) and say why in the hint. Don't silently ignore Enter.
|
|
206
|
+
- **Form submission.** The chips are display; the *value* is your state. For a plain `<form>` post, render one `<input type="hidden" name="toppings[]">` per selection, or a single hidden input with a delimited value.
|
|
207
|
+
|
|
208
|
+
Pairs well with (but requires none of): [Downshift `useMultipleSelection`](https://www.downshift-js.com/use-multiple-selection), [Headless UI Combobox (`multiple`)](https://headlessui.com/react/combobox#selecting-multiple-values), [Zag.js tags-input](https://zagjs.com/components/react/tags-input) — they own the state machine, you keep this recipe's markup and styling.
|
|
209
|
+
|
|
210
|
+
## A11y checklist
|
|
211
|
+
|
|
212
|
+
- [ ] Listbox has `aria-multiselectable="true"` and every option reflects membership via `aria-selected="true|false"` — not by chip presence alone ([APG Listbox pattern, multi-select](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/))
|
|
213
|
+
- [ ] Input keeps the full combobox contract: `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete="list"`, `aria-activedescendant`; DOM focus never leaves the input while navigating options ([APG Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/))
|
|
214
|
+
- [ ] Chips are a `role="list"` named by the field's label, so the current selection is reviewable without opening the popup ([WCAG 2.2 SC 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html))
|
|
215
|
+
- [ ] Every remove control is a `<button>` with an `aria-label` naming its item ("Remove Olives"), and is reachable by Tab ([WCAG 2.2 SC 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
|
|
216
|
+
- [ ] Removing a chip moves focus back to the input, never leaves it on a removed node ([WCAG 2.2 SC 2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html))
|
|
217
|
+
- [ ] Add/remove events are announced once via a polite live region ([WCAG 2.2 SC 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html))
|
|
218
|
+
- [ ] Selected options carry a visible mark (the ✓) in addition to weight/colour ([WCAG 2.2 SC 1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html))
|
|
219
|
+
- [ ] Remove buttons are at least 24×24 CSS px, or spaced so targets don't overlap ([WCAG 2.2 SC 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html))
|
|
220
|
+
- [ ] Keyboard highlight (`[data-active]`) meets non-text contrast against the listbox surface ([WCAG 2.2 SC 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html))
|
|
221
|
+
|
|
222
|
+
## Framework examples
|
|
223
|
+
|
|
224
|
+
All four examples implement the same spec: filter-as-you-type over a static list, chips with remove buttons, Enter toggles, Backspace-on-empty removes the last chip, polite announcements.
|
|
225
|
+
|
|
226
|
+
### React
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
"use client";
|
|
230
|
+
import { useId, useRef, useState } from "react";
|
|
231
|
+
import styles from "./MyMultiselect.module.scss";
|
|
232
|
+
|
|
233
|
+
const OPTIONS = ["Cheese", "Mushrooms", "Olives", "Onions", "Peppers", "Pineapple", "Spinach"];
|
|
234
|
+
|
|
235
|
+
export default function MyMultiselect() {
|
|
236
|
+
const id = useId();
|
|
237
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
238
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
239
|
+
const [text, setText] = useState("");
|
|
240
|
+
const [open, setOpen] = useState(false);
|
|
241
|
+
const [active, setActive] = useState(-1);
|
|
242
|
+
const [announce, setAnnounce] = useState("");
|
|
243
|
+
|
|
244
|
+
const matches = OPTIONS.filter((o) => o.toLowerCase().includes(text.toLowerCase()));
|
|
245
|
+
const expanded = open && matches.length > 0;
|
|
246
|
+
|
|
247
|
+
const toggle = (option: string) => {
|
|
248
|
+
const has = selected.includes(option);
|
|
249
|
+
const next = has ? selected.filter((s) => s !== option) : [...selected, option];
|
|
250
|
+
setSelected(next);
|
|
251
|
+
setAnnounce(`${option} ${has ? "removed" : "added"}, ${next.length} selected`);
|
|
252
|
+
setText("");
|
|
253
|
+
setActive(-1);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const remove = (option: string) => {
|
|
257
|
+
setSelected((s) => s.filter((x) => x !== option));
|
|
258
|
+
setAnnounce(`${option} removed`);
|
|
259
|
+
inputRef.current?.focus();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
263
|
+
if (e.key === "ArrowDown") {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
setOpen(true);
|
|
266
|
+
setActive((a) => (a + 1) % Math.max(matches.length, 1));
|
|
267
|
+
} else if (e.key === "ArrowUp") {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
setActive((a) => (a <= 0 ? matches.length - 1 : a - 1));
|
|
270
|
+
} else if (e.key === "Enter" && expanded && active >= 0) {
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
toggle(matches[active]);
|
|
273
|
+
} else if (e.key === "Backspace" && text === "" && selected.length) {
|
|
274
|
+
remove(selected[selected.length - 1]);
|
|
275
|
+
} else if (e.key === "Escape") {
|
|
276
|
+
if (open) { setOpen(false); setActive(-1); } else setText("");
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div className={styles.myMultiselect}>
|
|
282
|
+
<label id={`${id}-label`} htmlFor={`${id}-input`} data-slot="label">Toppings</label>
|
|
283
|
+
|
|
284
|
+
<div data-slot="control">
|
|
285
|
+
<div data-slot="field" onClick={() => inputRef.current?.focus()}>
|
|
286
|
+
<ul data-slot="chips" role="list" aria-labelledby={`${id}-label`}>
|
|
287
|
+
{selected.map((s) => (
|
|
288
|
+
<li key={s} data-slot="chip">
|
|
289
|
+
{s}
|
|
290
|
+
<button type="button" data-slot="remove" aria-label={`Remove ${s}`} onClick={() => remove(s)}>×</button>
|
|
291
|
+
</li>
|
|
292
|
+
))}
|
|
293
|
+
</ul>
|
|
294
|
+
<input
|
|
295
|
+
ref={inputRef}
|
|
296
|
+
id={`${id}-input`}
|
|
297
|
+
data-slot="input"
|
|
298
|
+
type="text"
|
|
299
|
+
role="combobox"
|
|
300
|
+
aria-expanded={expanded}
|
|
301
|
+
aria-controls={`${id}-listbox`}
|
|
302
|
+
aria-autocomplete="list"
|
|
303
|
+
aria-activedescendant={active >= 0 ? `${id}-opt-${active}` : undefined}
|
|
304
|
+
aria-describedby={`${id}-hint`}
|
|
305
|
+
autoComplete="off"
|
|
306
|
+
spellCheck={false}
|
|
307
|
+
placeholder={selected.length ? "" : "Add a topping…"}
|
|
308
|
+
value={text}
|
|
309
|
+
onChange={(e) => { setText(e.target.value); setOpen(true); setActive(-1); }}
|
|
310
|
+
onFocus={() => setOpen(true)}
|
|
311
|
+
onKeyDown={onKeyDown}
|
|
312
|
+
onBlur={() => setOpen(false)}
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<ul id={`${id}-listbox`} data-slot="listbox" role="listbox" aria-label="Toppings" aria-multiselectable="true" hidden={!expanded}>
|
|
317
|
+
{matches.map((o, i) => (
|
|
318
|
+
<li
|
|
319
|
+
key={o}
|
|
320
|
+
id={`${id}-opt-${i}`}
|
|
321
|
+
role="option"
|
|
322
|
+
aria-selected={selected.includes(o)}
|
|
323
|
+
data-active={i === active || undefined}
|
|
324
|
+
onMouseDown={(e) => { e.preventDefault(); toggle(o); }}
|
|
325
|
+
>
|
|
326
|
+
{o}
|
|
327
|
+
</li>
|
|
328
|
+
))}
|
|
329
|
+
</ul>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<span id={`${id}-hint`} data-slot="hint">Type to filter. Enter adds, Backspace removes the last one.</span>
|
|
333
|
+
<span data-slot="announce" aria-live="polite" className={styles.mySrOnly}>{announce}</span>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Vue
|
|
340
|
+
|
|
341
|
+
```vue
|
|
342
|
+
<script setup>
|
|
343
|
+
import { computed, ref } from "vue";
|
|
344
|
+
|
|
345
|
+
const OPTIONS = ["Cheese", "Mushrooms", "Olives", "Onions", "Peppers", "Pineapple", "Spinach"];
|
|
346
|
+
const id = "ms-" + Math.random().toString(36).slice(2, 8);
|
|
347
|
+
|
|
348
|
+
const input = ref(null);
|
|
349
|
+
const selected = ref([]);
|
|
350
|
+
const text = ref("");
|
|
351
|
+
const open = ref(false);
|
|
352
|
+
const active = ref(-1);
|
|
353
|
+
const announce = ref("");
|
|
354
|
+
|
|
355
|
+
const matches = computed(() => OPTIONS.filter((o) => o.toLowerCase().includes(text.value.toLowerCase())));
|
|
356
|
+
const expanded = computed(() => open.value && matches.value.length > 0);
|
|
357
|
+
|
|
358
|
+
function toggle(option) {
|
|
359
|
+
const has = selected.value.includes(option);
|
|
360
|
+
selected.value = has ? selected.value.filter((s) => s !== option) : [...selected.value, option];
|
|
361
|
+
announce.value = `${option} ${has ? "removed" : "added"}, ${selected.value.length} selected`;
|
|
362
|
+
text.value = "";
|
|
363
|
+
active.value = -1;
|
|
364
|
+
}
|
|
365
|
+
function remove(option) {
|
|
366
|
+
selected.value = selected.value.filter((s) => s !== option);
|
|
367
|
+
announce.value = `${option} removed`;
|
|
368
|
+
input.value?.focus();
|
|
369
|
+
}
|
|
370
|
+
function onKeydown(e) {
|
|
371
|
+
if (e.key === "ArrowDown") {
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
open.value = true;
|
|
374
|
+
active.value = (active.value + 1) % Math.max(matches.value.length, 1);
|
|
375
|
+
} else if (e.key === "ArrowUp") {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
active.value = active.value <= 0 ? matches.value.length - 1 : active.value - 1;
|
|
378
|
+
} else if (e.key === "Enter" && expanded.value && active.value >= 0) {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
toggle(matches.value[active.value]);
|
|
381
|
+
} else if (e.key === "Backspace" && text.value === "" && selected.value.length) {
|
|
382
|
+
remove(selected.value[selected.value.length - 1]);
|
|
383
|
+
} else if (e.key === "Escape") {
|
|
384
|
+
if (open.value) { open.value = false; active.value = -1; } else text.value = "";
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
</script>
|
|
388
|
+
|
|
389
|
+
<template>
|
|
390
|
+
<div class="my-multiselect">
|
|
391
|
+
<label :id="`${id}-label`" :for="`${id}-input`" data-slot="label">Toppings</label>
|
|
392
|
+
|
|
393
|
+
<div data-slot="control">
|
|
394
|
+
<div data-slot="field" @click="input?.focus()">
|
|
395
|
+
<ul data-slot="chips" role="list" :aria-labelledby="`${id}-label`">
|
|
396
|
+
<li v-for="s in selected" :key="s" data-slot="chip">
|
|
397
|
+
{{ s }}
|
|
398
|
+
<button type="button" data-slot="remove" :aria-label="`Remove ${s}`" @click="remove(s)">×</button>
|
|
399
|
+
</li>
|
|
400
|
+
</ul>
|
|
401
|
+
<input
|
|
402
|
+
ref="input"
|
|
403
|
+
:id="`${id}-input`"
|
|
404
|
+
data-slot="input"
|
|
405
|
+
type="text"
|
|
406
|
+
role="combobox"
|
|
407
|
+
:aria-expanded="expanded"
|
|
408
|
+
:aria-controls="`${id}-listbox`"
|
|
409
|
+
aria-autocomplete="list"
|
|
410
|
+
:aria-activedescendant="active >= 0 ? `${id}-opt-${active}` : undefined"
|
|
411
|
+
:aria-describedby="`${id}-hint`"
|
|
412
|
+
autocomplete="off"
|
|
413
|
+
spellcheck="false"
|
|
414
|
+
:placeholder="selected.length ? '' : 'Add a topping…'"
|
|
415
|
+
v-model="text"
|
|
416
|
+
@input="open = true; active = -1"
|
|
417
|
+
@focus="open = true"
|
|
418
|
+
@keydown="onKeydown"
|
|
419
|
+
@blur="open = false"
|
|
420
|
+
/>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
<ul :id="`${id}-listbox`" data-slot="listbox" role="listbox" aria-label="Toppings" aria-multiselectable="true" :hidden="!expanded">
|
|
424
|
+
<li
|
|
425
|
+
v-for="(o, i) in matches"
|
|
426
|
+
:key="o"
|
|
427
|
+
:id="`${id}-opt-${i}`"
|
|
428
|
+
role="option"
|
|
429
|
+
:aria-selected="selected.includes(o)"
|
|
430
|
+
:data-active="i === active ? '' : undefined"
|
|
431
|
+
@mousedown.prevent="toggle(o)"
|
|
432
|
+
>
|
|
433
|
+
{{ o }}
|
|
434
|
+
</li>
|
|
435
|
+
</ul>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
<span :id="`${id}-hint`" data-slot="hint">Type to filter. Enter adds, Backspace removes the last one.</span>
|
|
439
|
+
<span data-slot="announce" aria-live="polite" class="my-sr-only">{{ announce }}</span>
|
|
440
|
+
</div>
|
|
441
|
+
</template>
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Svelte
|
|
445
|
+
|
|
446
|
+
```svelte
|
|
447
|
+
<script>
|
|
448
|
+
const OPTIONS = ["Cheese", "Mushrooms", "Olives", "Onions", "Peppers", "Pineapple", "Spinach"];
|
|
449
|
+
const id = "ms-" + Math.random().toString(36).slice(2, 8);
|
|
450
|
+
|
|
451
|
+
let input;
|
|
452
|
+
let selected = [];
|
|
453
|
+
let text = "";
|
|
454
|
+
let open = false;
|
|
455
|
+
let active = -1;
|
|
456
|
+
let announce = "";
|
|
457
|
+
|
|
458
|
+
$: matches = OPTIONS.filter((o) => o.toLowerCase().includes(text.toLowerCase()));
|
|
459
|
+
$: expanded = open && matches.length > 0;
|
|
460
|
+
|
|
461
|
+
function toggle(option) {
|
|
462
|
+
const has = selected.includes(option);
|
|
463
|
+
selected = has ? selected.filter((s) => s !== option) : [...selected, option];
|
|
464
|
+
announce = `${option} ${has ? "removed" : "added"}, ${selected.length} selected`;
|
|
465
|
+
text = "";
|
|
466
|
+
active = -1;
|
|
467
|
+
}
|
|
468
|
+
function remove(option) {
|
|
469
|
+
selected = selected.filter((s) => s !== option);
|
|
470
|
+
announce = `${option} removed`;
|
|
471
|
+
input?.focus();
|
|
472
|
+
}
|
|
473
|
+
function onKeydown(e) {
|
|
474
|
+
if (e.key === "ArrowDown") {
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
open = true;
|
|
477
|
+
active = (active + 1) % Math.max(matches.length, 1);
|
|
478
|
+
} else if (e.key === "ArrowUp") {
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
active = active <= 0 ? matches.length - 1 : active - 1;
|
|
481
|
+
} else if (e.key === "Enter" && expanded && active >= 0) {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
toggle(matches[active]);
|
|
484
|
+
} else if (e.key === "Backspace" && text === "" && selected.length) {
|
|
485
|
+
remove(selected[selected.length - 1]);
|
|
486
|
+
} else if (e.key === "Escape") {
|
|
487
|
+
if (open) { open = false; active = -1; } else text = "";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
</script>
|
|
491
|
+
|
|
492
|
+
<div class="my-multiselect">
|
|
493
|
+
<label id="{id}-label" for="{id}-input" data-slot="label">Toppings</label>
|
|
494
|
+
|
|
495
|
+
<div data-slot="control">
|
|
496
|
+
<div data-slot="field" on:click={() => input?.focus()}>
|
|
497
|
+
<ul data-slot="chips" role="list" aria-labelledby="{id}-label">
|
|
498
|
+
{#each selected as s (s)}
|
|
499
|
+
<li data-slot="chip">
|
|
500
|
+
{s}
|
|
501
|
+
<button type="button" data-slot="remove" aria-label="Remove {s}" on:click={() => remove(s)}>×</button>
|
|
502
|
+
</li>
|
|
503
|
+
{/each}
|
|
504
|
+
</ul>
|
|
505
|
+
<input
|
|
506
|
+
bind:this={input}
|
|
507
|
+
id="{id}-input"
|
|
508
|
+
data-slot="input"
|
|
509
|
+
type="text"
|
|
510
|
+
role="combobox"
|
|
511
|
+
aria-expanded={expanded}
|
|
512
|
+
aria-controls="{id}-listbox"
|
|
513
|
+
aria-autocomplete="list"
|
|
514
|
+
aria-activedescendant={active >= 0 ? `${id}-opt-${active}` : undefined}
|
|
515
|
+
aria-describedby="{id}-hint"
|
|
516
|
+
autocomplete="off"
|
|
517
|
+
spellcheck="false"
|
|
518
|
+
placeholder={selected.length ? "" : "Add a topping…"}
|
|
519
|
+
bind:value={text}
|
|
520
|
+
on:input={() => { open = true; active = -1; }}
|
|
521
|
+
on:focus={() => (open = true)}
|
|
522
|
+
on:keydown={onKeydown}
|
|
523
|
+
on:blur={() => (open = false)}
|
|
524
|
+
/>
|
|
525
|
+
</div>
|
|
526
|
+
|
|
527
|
+
<ul id="{id}-listbox" data-slot="listbox" role="listbox" aria-label="Toppings" aria-multiselectable="true" hidden={!expanded}>
|
|
528
|
+
{#each matches as o, i (o)}
|
|
529
|
+
<li
|
|
530
|
+
id="{id}-opt-{i}"
|
|
531
|
+
role="option"
|
|
532
|
+
aria-selected={selected.includes(o)}
|
|
533
|
+
data-active={i === active ? "" : undefined}
|
|
534
|
+
on:mousedown|preventDefault={() => toggle(o)}
|
|
535
|
+
>
|
|
536
|
+
{o}
|
|
537
|
+
</li>
|
|
538
|
+
{/each}
|
|
539
|
+
</ul>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
<span id="{id}-hint" data-slot="hint">Type to filter. Enter adds, Backspace removes the last one.</span>
|
|
543
|
+
<span data-slot="announce" aria-live="polite" class="my-sr-only">{announce}</span>
|
|
544
|
+
</div>
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### Vanilla (Web Component)
|
|
548
|
+
|
|
549
|
+
```js
|
|
550
|
+
class MyMultiselect extends HTMLElement {
|
|
551
|
+
static options = ["Cheese", "Mushrooms", "Olives", "Onions", "Peppers", "Pineapple", "Spinach"];
|
|
552
|
+
|
|
553
|
+
connectedCallback() {
|
|
554
|
+
const id = "ms-" + Math.random().toString(36).slice(2, 8);
|
|
555
|
+
this.selected = [];
|
|
556
|
+
this.active = -1;
|
|
557
|
+
this.open = false;
|
|
558
|
+
|
|
559
|
+
this.innerHTML = `
|
|
560
|
+
<div class="my-multiselect">
|
|
561
|
+
<label id="${id}-label" for="${id}-input" data-slot="label">Toppings</label>
|
|
562
|
+
<div data-slot="control">
|
|
563
|
+
<div data-slot="field">
|
|
564
|
+
<ul data-slot="chips" role="list" aria-labelledby="${id}-label"></ul>
|
|
565
|
+
<input id="${id}-input" data-slot="input" type="text" role="combobox" aria-expanded="false"
|
|
566
|
+
aria-controls="${id}-listbox" aria-autocomplete="list" aria-describedby="${id}-hint"
|
|
567
|
+
autocomplete="off" spellcheck="false" placeholder="Add a topping…" />
|
|
568
|
+
</div>
|
|
569
|
+
<ul id="${id}-listbox" data-slot="listbox" role="listbox" aria-label="Toppings" aria-multiselectable="true" hidden></ul>
|
|
570
|
+
</div>
|
|
571
|
+
<span id="${id}-hint" data-slot="hint">Type to filter. Enter adds, Backspace removes the last one.</span>
|
|
572
|
+
<span data-slot="announce" aria-live="polite" class="my-sr-only"></span>
|
|
573
|
+
</div>`;
|
|
574
|
+
|
|
575
|
+
this.id_ = id;
|
|
576
|
+
this.input = this.querySelector('[data-slot="input"]');
|
|
577
|
+
this.chips = this.querySelector('[data-slot="chips"]');
|
|
578
|
+
this.listbox = this.querySelector('[data-slot="listbox"]');
|
|
579
|
+
this.announce = this.querySelector('[data-slot="announce"]');
|
|
580
|
+
|
|
581
|
+
this.querySelector('[data-slot="field"]').addEventListener("click", () => this.input.focus());
|
|
582
|
+
this.input.addEventListener("input", () => { this.open = true; this.active = -1; this.render(); });
|
|
583
|
+
this.input.addEventListener("focus", () => { this.open = true; this.render(); });
|
|
584
|
+
this.input.addEventListener("blur", () => { this.open = false; this.render(); });
|
|
585
|
+
this.input.addEventListener("keydown", (e) => this.onKeydown(e));
|
|
586
|
+
this.render();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
get matches() {
|
|
590
|
+
const q = this.input.value.toLowerCase();
|
|
591
|
+
return MyMultiselect.options.filter((o) => o.toLowerCase().includes(q));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
toggle(option) {
|
|
595
|
+
const has = this.selected.includes(option);
|
|
596
|
+
this.selected = has ? this.selected.filter((s) => s !== option) : [...this.selected, option];
|
|
597
|
+
this.announce.textContent = `${option} ${has ? "removed" : "added"}, ${this.selected.length} selected`;
|
|
598
|
+
this.input.value = "";
|
|
599
|
+
this.active = -1;
|
|
600
|
+
this.dispatchEvent(new CustomEvent("change", { detail: this.selected }));
|
|
601
|
+
this.render();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
remove(option) {
|
|
605
|
+
this.selected = this.selected.filter((s) => s !== option);
|
|
606
|
+
this.announce.textContent = `${option} removed`;
|
|
607
|
+
this.dispatchEvent(new CustomEvent("change", { detail: this.selected }));
|
|
608
|
+
this.render();
|
|
609
|
+
this.input.focus();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
onKeydown(e) {
|
|
613
|
+
const matches = this.matches;
|
|
614
|
+
const expanded = this.open && matches.length > 0;
|
|
615
|
+
if (e.key === "ArrowDown") {
|
|
616
|
+
e.preventDefault();
|
|
617
|
+
this.open = true;
|
|
618
|
+
this.active = (this.active + 1) % Math.max(matches.length, 1);
|
|
619
|
+
} else if (e.key === "ArrowUp") {
|
|
620
|
+
e.preventDefault();
|
|
621
|
+
this.active = this.active <= 0 ? matches.length - 1 : this.active - 1;
|
|
622
|
+
} else if (e.key === "Enter" && expanded && this.active >= 0) {
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
return this.toggle(matches[this.active]);
|
|
625
|
+
} else if (e.key === "Backspace" && this.input.value === "" && this.selected.length) {
|
|
626
|
+
return this.remove(this.selected[this.selected.length - 1]);
|
|
627
|
+
} else if (e.key === "Escape") {
|
|
628
|
+
if (this.open) { this.open = false; this.active = -1; } else this.input.value = "";
|
|
629
|
+
}
|
|
630
|
+
this.render();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
render() {
|
|
634
|
+
const id = this.id_;
|
|
635
|
+
const matches = this.matches;
|
|
636
|
+
const expanded = this.open && matches.length > 0;
|
|
637
|
+
|
|
638
|
+
this.chips.innerHTML = this.selected
|
|
639
|
+
.map((s) => `<li data-slot="chip">${s}<button type="button" data-slot="remove" aria-label="Remove ${s}">×</button></li>`)
|
|
640
|
+
.join("");
|
|
641
|
+
this.chips.querySelectorAll('[data-slot="remove"]').forEach((btn, i) =>
|
|
642
|
+
btn.addEventListener("click", () => this.remove(this.selected[i])),
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
this.input.placeholder = this.selected.length ? "" : "Add a topping…";
|
|
646
|
+
this.input.setAttribute("aria-expanded", String(expanded));
|
|
647
|
+
if (this.active >= 0) this.input.setAttribute("aria-activedescendant", `${id}-opt-${this.active}`);
|
|
648
|
+
else this.input.removeAttribute("aria-activedescendant");
|
|
649
|
+
|
|
650
|
+
this.listbox.hidden = !expanded;
|
|
651
|
+
this.listbox.innerHTML = matches
|
|
652
|
+
.map(
|
|
653
|
+
(o, i) =>
|
|
654
|
+
`<li id="${id}-opt-${i}" role="option" aria-selected="${this.selected.includes(o)}"${i === this.active ? " data-active" : ""}>${o}</li>`,
|
|
655
|
+
)
|
|
656
|
+
.join("");
|
|
657
|
+
this.listbox.querySelectorAll('[role="option"]').forEach((li, i) =>
|
|
658
|
+
li.addEventListener("mousedown", (e) => { e.preventDefault(); this.toggle(matches[i]); }),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
customElements.define("my-multiselect", MyMultiselect);
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
## Variants
|
|
666
|
+
|
|
667
|
+
### Selected options hidden from the list
|
|
668
|
+
|
|
669
|
+
Some products prefer the popup to show only what's *left* to pick. Filter `matches` to exclude `selected` and drop the ✓ styling — the chips are then the only selection surface, so the live-region announcements become mandatory rather than nice-to-have.
|
|
670
|
+
|
|
671
|
+
### Free-text tags (create on Enter)
|
|
672
|
+
|
|
673
|
+
For a tag input that accepts values not in the list, add one branch: `Enter` with no active option and non-empty text commits the typed text as a new chip. Announce it as "Added new tag X" so the user knows it wasn't matched.
|
|
674
|
+
|
|
675
|
+
### Compact / inline
|
|
676
|
+
|
|
677
|
+
```scss
|
|
678
|
+
.my-multiselect [data-slot="chip"] {
|
|
679
|
+
@include cia.tag($py: 2xs, $px: 1, $font-size: 1, $removable: true);
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
## Pitfalls
|
|
684
|
+
|
|
685
|
+
- **Don't close the listbox after every commit.** Users picking five toppings will hate reopening it five times. Clear the text, keep it open, and let Esc / blur / Tab close it.
|
|
686
|
+
- **Don't make Backspace destructive when there's text in the field.** Only remove the last chip when the input is empty — otherwise a user editing a half-typed word loses a selection they didn't touch.
|
|
687
|
+
- **Don't strip remove buttons out of the tab order** to "simplify" Tab. That leaves keyboard users with no way to remove a specific middle chip; Backspace only reaches the last one.
|
|
688
|
+
- **Don't rely on `aria-selected` flips being announced.** Focus stays on the input, so most screen readers stay quiet — the live region is the announcement channel.
|
|
689
|
+
- **`hidden` + `position: absolute`:** the listbox is absolutely positioned under the field, so if a parent has `overflow: hidden` it gets clipped. Either lift the overflow or render the listbox through a top-layer `[popover]` (see `/docs/recipes/anchor-positioning` for the dropdown engine).
|
|
690
|
+
|
|
691
|
+
## Related recipes
|
|
692
|
+
|
|
693
|
+
- [`combobox`](./combobox.md) — the single-select base this recipe extends; read it first for the full APG keyboard contract
|
|
694
|
+
- [`otp-input`](./otp-input.md) — another "orchestrate focus between native inputs" pattern
|
|
695
|
+
|