Build a searchable multi-select
Build a multi-select with a text input and a list of selectable options. Typing filters the options (case-insensitive substring match on the option label).
Clicking an option toggles it in the current selection. Selected options appear as chips above the input; clicking a chip's × removes that selection.
The dropdown stays open while interacting. Clicking an option does not clear the search box.
Examples
// Initial: no chips, all 7 options with aria-selected="false".
// Type "m" → only Marketing + Operations shown.
// Click opt-marketing
// → chip-marketing appears above the input;
// opt-marketing aria-selected="true"; query stays "m".
// Click chip-remove-marketing
// → chip gone; opt-marketing aria-selected="false".Options stay visible while picking; clicking them toggles rather than closes.
function toggle(label) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}
// On <li aria-selected={selected.has(label) ? "true" : "false"} />A `Set` makes membership and toggle O(1); always copy before mutating so React sees a new reference.
What it has to do
- The input is `<input data-testid="ms-input" />`.
- The dropdown is `<ul data-testid="ms-options">`, present when there is at least one filtered option.
- Each option is `<li data-testid="opt-{slug}" aria-selected="true|false">` — slug is a lowercase hyphenated version of the label.
- Each selected chip is `<li data-testid="chip-{slug}">` with a `<button data-testid="chip-remove-{slug}">×</button>`.
- Clicking an unselected option adds it to selection; clicking a selected option removes it. The dropdown stays open across clicks.
- Removing a chip via its × restores the option to `aria-selected="false"` in the dropdown.
Notes
- Keep the dropdown open on option click — users expect to pick multiple without reopening. Close only on explicit outside click or Escape (not graded here, but production-expected).
- Preserve the query across selections so a user can refine ("m" → pick Marketing → pick Operations) without retyping.
- `role="listbox"` + `aria-multiselectable="true"` on the `<ul>`, with each `<li role="option">`, is the correct ARIA pattern — the test rig only checks `aria-selected`, but production needs the roles too.
- For a very large option list, debounce the filter or use a `Trie` / fuzzy-ranker — a straight `includes` on each keystroke is fine for 100 options and painful for 10,000.
Stuck? The react course covers the ideas this problem is built on.
import { useState } from "react"; const LABELS = ["Design", "Engineering", "Marketing", "Operations", "Product", "Sales", "Support"]; function toSlug(label) { return label.toLowerCase().replace(/\s+/g, "-"); } export default function App() { const [query, setQuery] = useState(""); const [selected, setSelected] = useState(new Set()); // TODO: compute filtered options; implement toggle on option click; // implement remove on chip × click. const filtered = LABELS; return ( <div style={{ fontFamily: "sans-serif", padding: 16, maxWidth: 320 }}> <ul style={{ display: "flex", flexWrap: "wrap", gap: 4, listStyle: "none", padding: 0, marginBottom: 8 }}> {[...selected].map((label) => ( <li key={label} data-testid={`chip-${toSlug(label)}`} style={{ border: "1px solid #ddd", borderRadius: 999, padding: "2px 8px" }}> {label}{" "} <button data-testid={`chip-remove-${toSlug(label)}`}>×</button> </li> ))} </ul> <input data-testid="ms-input" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search teams…" style={{ width: "100%", padding: 8 }} /> {filtered.length > 0 ? ( <ul data-testid="ms-options" style={{ listStyle: "none", padding: 0, border: "1px solid #ddd", marginTop: 4 }}> {filtered.map((label) => ( <li key={label} data-testid={`opt-${toSlug(label)}`} aria-selected="false" style={{ padding: 8 }} > {label} </li> ))} </ul> ) : null} </div> ); }
- 1.The input is `<input data-testid="ms-input" />`.
- 2.The dropdown is `<ul data-testid="ms-options">`, present when there is at least one filtered option.
- 3.Each option is `<li data-testid="opt-{slug}" aria-selected="true|false">` — slug is a lowercase hyphenated version of the label.
- 4.Each selected chip is `<li data-testid="chip-{slug}">` with a `<button data-testid="chip-remove-{slug}">×</button>`.
- 5.Clicking an unselected option adds it to selection; clicking a selected option removes it. The dropdown stays open across clicks.
- 6.Removing a chip via its × restores the option to `aria-selected="false"` in the dropdown.
5 automated assertions run against your rendered component. The exact assertion names are hidden so you build to the behaviour, not to the test.
Discussion
Sign in to postNo comments yet. Be the first to say something.