Combobox
A single-select text field that filters a list as you type — for picking one value
out of a longer list than a plain select reads
well, without the overhead of asking "which of these N do I want" up front.
Not for a short fixed list — five or fewer known options is exactly
what a plain select is for: no filtering to
write, no JS to ship, and the platform's own picker is faster to operate. For
picking several values, use the
multi-select dropdown instead — different job.
Without the JS: this one does not degrade — it stops. The
listbox is a [popover], so Esc and light-dismiss are native,
but opening, filtering and selection are all
initCombobox()'s, and without it you have a text input beside
a list that never opens. If you cannot run the behavior, ship a
<select>.
Basic
- CC-1180 — Warehouse
- CC-2205 — Logistics
- CC-4021 — Procurement
- CC-3390 — Facilities
View code
<div class="bo-form-field" style="margin-block-end: 0">
<label class="bo-form-field__label" for="demo-cb-input">Cost center</label>
<div class="bo-combobox">
<input class="bo-input" type="text" role="combobox" id="demo-cb-input"
aria-expanded="false" aria-controls="demo-cb-list" aria-autocomplete="list"
autocomplete="off" placeholder="Search cost centers…" />
<ul class="bo-combobox__listbox" id="demo-cb-list" role="listbox" popover>
<li class="bo-combobox__option" role="option" id="demo-cb-opt-1" data-value="CC-1180">CC-1180 — Warehouse</li>
<li class="bo-combobox__option" role="option" id="demo-cb-opt-2" data-value="CC-2205">CC-2205 — Logistics</li>
<li class="bo-combobox__option" role="option" id="demo-cb-opt-3" data-value="CC-4021">CC-4021 — Procurement</li>
<li class="bo-combobox__option" role="option" id="demo-cb-opt-4" data-value="CC-3390">CC-3390 — Facilities</li>
</ul>
</div>
</div> Type to filter (case-insensitive substring match against
each option's full text — try "log" or "cc-33"), ↓/↑ to move
through the filtered results, Enter to commit. The listbox is a real
[popover] — Esc and clicking outside close it without
changing the field's value, for free. Two honest caveats (owner test
report, 2026-08-16): the popover is never clipped by a scroll
container, but it is positioned once at open time and is not
re-anchored — so the list closes on scroll rather than
drifting away from its field; and aria-expanded is
resynced on the listbox's toggle event, so a test must
assert after a task, not synchronously in the keypress.
What the open list looks like — active option
A static snapshot (no popover) so the
active option styling is visible without interacting:
↓/↑ move aria-selected +
aria-activedescendant down the filtered list — that's the
highlighted row.
No matches — the list closes, the field tells the story
When the filter matches nothing, the behavior
closes the listbox (aria-expanded="false") rather
than showing an empty popup — the typed text stays in the field. If "must match
a known value" matters, validate on commit/submit: pair the field with the
standard error message ("Unknown cost center")
or the validation summary — don't
invent an in-popup empty state the pattern doesn't have.
In a filter bar — labeled, composed
View code
<form class="bo-filter-bar" role="search" aria-label="PO filters">
<div class="bo-form-field">
<label class="bo-form-field__label" for="fb-cb">Cost center</label>
<div class="bo-combobox">
<input class="bo-input" type="text" role="combobox" id="fb-cb"
aria-expanded="false" aria-controls="fb-cb-list" aria-autocomplete="list"
autocomplete="off" placeholder="Type to search…" />
<ul class="bo-combobox__listbox" id="fb-cb-list" role="listbox" popover>
<li class="bo-combobox__option" role="option" id="fb-opt-1" data-value="CC-1180">CC-1180 — Warehouse</li>
<li class="bo-combobox__option" role="option" id="fb-opt-2" data-value="CC-2205">CC-2205 — Logistics</li>
</ul>
</div>
</div>
<button class="bo-btn bo-btn--secondary" type="submit">Apply</button>
</form> The real placement: inside a
filter bar, wrapped in a
.bo-form-field with a proper <label for> —
the combobox is a form control first.
WAI-ARIA APG combobox pattern, not a custom widget
The input carries role="combobox", aria-expanded,
aria-controls, and aria-autocomplete="list"; the popup is
a real role="listbox" with role="option" children.
initCombobox() keeps aria-activedescendant in sync as the
active option changes — nothing is simulated with generic <div>s.
On commit, it fires bo:combobox-select
(detail: { value, text }) — your code reads
e.detail.value for the machine value (data-value on the
chosen option) and persists it (a hidden input, a form submit, an hx-patch).
Form integration — the machine value
- CC-1180 — Warehouse
- CC-2205 — Logistics
- CC-4021 — Procurement
View code
<div class="bo-form-field">
<label class="bo-form-field__label" for="fi-cb">Cost center</label>
<!-- data-name: the behavior mirrors the committed data-value into a
hidden input, so a plain form POST carries CC-1180, not the label -->
<div class="bo-combobox" data-name="cost_center">
<input class="bo-input" type="text" role="combobox" id="fi-cb"
aria-expanded="false" aria-controls="fi-list" aria-autocomplete="list"
autocomplete="off" placeholder="Search cost centers…" />
<ul class="bo-combobox__listbox" id="fi-list" role="listbox" popover>
<li class="bo-combobox__option" role="option" id="fi-o1" data-value="CC-1180">CC-1180 — Warehouse</li>
<li class="bo-combobox__option" role="option" id="fi-o2" data-value="CC-2205">CC-2205 — Logistics</li>
<li class="bo-combobox__option" role="option" id="fi-o3" data-value="CC-4021">CC-4021 — Procurement</li>
</ul>
</div>
</div> Pick one, then inspect the DOM: the widget
carries data-name="cost_center", so committing writes
<input type="hidden" name="cost_center" value="CC-1180">
inside it. A plain form POST now sends the machine value,
not the display label — no JavaScript of your own, no framework.
<script type="module">
// "must match a known value" — the list has just closed on focusout,
// so this is the natural moment to validate.
document.addEventListener("focusout", (e) => {
const input = e.target.closest?.('[role="combobox"]');
if (!input) return;
const hidden = input.closest(".bo-combobox").querySelector('input[type="hidden"]');
const ok = input.value === "" || (hidden && hidden.value);
input.setAttribute("aria-invalid", String(!ok));
});
</script> Validation for "must match a known value":
the field can hold free text the user typed, so check it on
focusout — the list has just closed, which is the
natural moment. Server-side, treat the hidden value as authoritative
and re-render with aria-invalid if it is missing.
Field states
View code
<div class="bo-stack" style="max-inline-size: 26rem">
<div class="bo-form-field">
<label class="bo-form-field__label" for="st-1">Disabled</label>
<input class="bo-input" id="st-1" role="combobox" aria-expanded="false" value="CC-1180 — Warehouse" disabled />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="st-2">Read-only</label>
<input class="bo-input" id="st-2" role="combobox" aria-expanded="false" value="CC-2205 — Logistics" readonly />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="st-3">Required & invalid</label>
<input class="bo-input" id="st-3" role="combobox" aria-expanded="false" required
aria-invalid="true" aria-describedby="st-3-err" value="CC-9999" />
<span class="bo-form-field__message" id="st-3-err">No such cost center — pick one from the list</span>
</div>
</div> Nothing combobox-specific: the field is a
.bo-input, so every form
field state applies unchanged. Disabled and read-only differ —
read-only text stays selectable and still submits.
Filtering semantics
Exactly what the built-in filter does, so you can predict it:
case-insensitive, single substring,
matched against the option's full text content, with
the query trimmed. It is not token-based
(cc warehouse matches nothing), not
accent-folded (zurich does not match Zürich),
and not fuzzy. When you need more, own the filter: hide and show
options yourself on input — the behavior only ever
considers non-hidden options, so your matcher composes
with its keyboard and ARIA handling.
// Own the matcher — the behavior only navigates options that are not hidden
const fold = (t) => t.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
document.addEventListener("input", (e) => {
const input = e.target.closest?.("[role=combobox][data-custom-filter]");
if (!input) return;
const words = fold(input.value).split(/\s+/).filter(Boolean); // token-based
for (const li of input.closest(".bo-combobox").querySelectorAll("[role=option]")) {
li.hidden = !words.every((w) => fold(li.textContent).includes(w));
}
}); Async / server-driven options
The common ERP case: too many options to ship up front. Fetch on
input, replace the <li>s, and let the behavior keep
doing keyboard and ARIA. Ids are minted automatically if you omit
them, so a template render is safe.
<script type="module">
import { initCombobox } from "@busy-office/ui/js";
initCombobox();
const input = document.querySelector("#vendor");
const list = document.querySelector("#vendor-list");
let seq = 0;
input.addEventListener("input", debounce(async () => {
const mine = ++seq; // ignore out-of-order responses
list.innerHTML = option("Searching…", { disabled: true });
try {
const res = await fetch("/vendors?q=" + encodeURIComponent(input.value));
if (mine !== seq) return; // a newer keystroke already won
const vendors = await res.json();
list.innerHTML = vendors.length
? vendors.map((v) => option(v.name, { value: v.id })).join("")
: option("No matches", { disabled: true });
} catch {
if (mine !== seq) return;
list.innerHTML = option("Search unavailable — retry", { disabled: true });
}
}, 200));
// ids are minted by the behavior if you omit them, so a template
// render is safe; aria-disabled rows are skipped by the arrows.
function option(text, { value, disabled } = {}) {
return "<li class='bo-combobox__option' role='option'"
+ (value ? " data-value='" + value + "'" : "")
+ (disabled ? " aria-disabled='true'" : "")
+ ">" + text + "</li>";
}
</script> Keyboard reference
| Key | Behaviour |
|---|---|
| Printable characters | Filter; open the list if anything matches |
| ↓ / ↑ | Move the active option; clamps at the ends — deliberately no wrap-around |
| Home / End | Move the text caret, not the active option (correct for an editable combobox) |
| PageUp / PageDown | Not handled |
| Enter (option active) | Commit, close, dispatch bo:combobox-select |
| Enter (open, nothing active) | Swallowed — never submits the surrounding form; commits the sole match if exactly one remains |
| Enter (list closed) | Submits the form natively, as any text input would |
| Esc | Close, keep the typed text |
| Tab | Close, clear the active option, move on |
Value help — recents first, rich rows, browse as a link
- Recent
- CC-1180 Warehouse Hamburg
- CC-4021 Assembly Line 2
- All cost centres
- CC-2205 Inbound Rotterdam
View code
<!-- ERP selection is Zipfian: users pick the same ~20 values, so the
useful list is the one shown BEFORE any keystroke. data-open-on-focus
opens on focus while the field is empty; the recents are ordinary
server-rendered markup — nothing is stored client-side. -->
<label class="bo-form-field__label" for="vh-input">Cost centre</label>
<div class="bo-combobox" data-name="cost_center" data-open-on-focus>
<input class="bo-input" type="text" role="combobox" id="vh-input"
aria-expanded="false" aria-controls="vh-list" aria-autocomplete="list"
autocomplete="off" placeholder="Cost centre — code, name or city" />
<ul class="bo-combobox__listbox" id="vh-list" role="listbox" popover>
<!-- role="presentation": a heading, never an arrow-key stop, never
counted in the "n results" announcement -->
<li class="bo-combobox__group" role="presentation">Recent</li>
<li class="bo-combobox__option" role="option" id="vh-1" data-value="CC-1180">
<span class="bo-combobox__option-code">CC-1180</span>
<span class="bo-combobox__option-label">Warehouse Hamburg</span>
<span class="bo-combobox__option-meta">Logistics</span>
</li>
<li class="bo-combobox__option" role="option" id="vh-2" data-value="CC-4021">
<span class="bo-combobox__option-code">CC-4021</span>
<span class="bo-combobox__option-label">Assembly Line 2</span>
<span class="bo-combobox__option-meta">Production</span>
</li>
<li class="bo-combobox__group" role="presentation">All cost centres</li>
<li class="bo-combobox__option" role="option" id="vh-3" data-value="CC-2205">
<span class="bo-combobox__option-code">CC-2205</span>
<span class="bo-combobox__option-label">Inbound Rotterdam</span>
<span class="bo-combobox__option-meta">Logistics</span>
</li>
</ul>
</div>
<!-- The escape hatch is a plain link BELOW the field, not a modal and not
inside the listbox: the list closes on focusout, so a link inside it
could never be tabbed to. -->
<p class="bo-u-text-muted"><a href="#">Browse all cost centres</a></p> <!-- your route --> Three parts make a row recognisable:
__option-code (the ID, monospace and tabular),
__option-label (the name) and __option-meta
(one context column). All three sit inside the option, so the substring
filter matches on any of them — typing 4021,
Assembly or Production all find the same row.
The label is what gets committed to the field;
data-value still carries the machine value to the hidden
input. A plain-text option behaves exactly as before.
Group headings describe the unfiltered order, so they disappear the moment you type — once results are ranked by match, a "Recent" heading above them would be a lie.
Markup
<div class="bo-combobox">
<input class="bo-input" type="text" role="combobox" id="cb-input"
aria-expanded="false" aria-controls="cb-list" aria-autocomplete="list"
autocomplete="off" placeholder="Search cost centers…" />
<ul class="bo-combobox__listbox" id="cb-list" role="listbox" popover>
<li class="bo-combobox__option" role="option" id="cb-opt-1" data-value="CC-1180">CC-1180 — Warehouse</li>
<li class="bo-combobox__option" role="option" id="cb-opt-2" data-value="CC-2205">CC-2205 — Logistics</li>
<li class="bo-combobox__option" role="option" id="cb-opt-3" data-value="CC-4021">CC-4021 — Procurement</li>
</ul>
</div>
<script type="module">
import { initCombobox } from "@busy-office/ui/js";
initCombobox(); // once — filtering, arrow-key nav, Enter-to-commit, Esc/light-dismiss are already native
</script> Quick reference generated
| Class | Role |
|---|---|
| .bo-combobox | block (root) |
| .bo-combobox__group | part: group |
| .bo-combobox__listbox | part: listbox |
| .bo-combobox__option | part: option |
| .bo-combobox__option-code | part: option-code |
| .bo-combobox__option-label | part: option-label |
| .bo-combobox__option-meta | part: option-meta |
| .bo-input | block (root) |
State attributes: aria-selected
API generated from CSS
| Blocks | bo-comboboxbo-input |
|---|---|
| Parts | bo-combobox__groupbo-combobox__listboxbo-combobox__optionbo-combobox__option-codebo-combobox__option-labelbo-combobox__option-meta |
| ARIA styled | aria-selected |
| JS required | Required: initCombobox() — without it the field is a plain text input; typing does not filter or open the list. |
- Single-select only — the committed value lives in the input's text, not on any option (options never carry a persisted "chosen" marker across reopens). For choosing several values, use the multi-select dropdown instead.
- The listbox is a native
[popover]— never clipped by scrolling containers, and Esc/light-dismiss close it without touching the field value at no extra cost. It is NOT re-anchored on scroll (CSS anchor positioning is below the browser floor), so it closes instead. - Focus leaving the widget closes the list and clears the active option — that is also the natural moment to run "must match a known value" validation.
- Options carrying
aria-disabled="true"are shown but skipped by the arrows and rejected by Enter and click. - Options rendered without an
idget one minted at activation time, soaria-activedescendantis never an empty string. - Filtering is a case-insensitive substring match against each option's full text, computed client-side against the options already in the markup — for a server-driven / async result list, swap the options'
hiddenstate yourself in response to your own fetch instead of relying on the built-in filter. - Commit dispatches
bo:combobox-selectwith{ value, text }—valuecomes from the chosen option'sdata-value(falls back to its text if absent).
Contrast — 15 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| bg-surface | text-primary | 17.74 | 16.15 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| accent-text | bg-selected | 7.27 | 8.38 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| accent | bg-selected | 5.25 | 6.66 |
| text-primary | bg-muted | 16.12 | 13.75 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | no raw font-size; the 0.05em letter-spacing is intrinsic |
| Colour | 3 / 3 | zero raw hex |
| Spacing | 3 / 3 | 12rem min / 16rem max listbox sizing states what each bound protects (94.2) |
| Interaction | 3 / 3 | the opener says this one does not degrade, it stops: Esc and light-dismiss are native to the [popover], but opening/filtering/selection are all initCombobox(), and it names the fallback — ship a <select> (94.14) |
| Content | 3 / 3 | the opener says a short fixed list (five or fewer) is wrong for this — use a plain select instead, no filtering/JS needed (102.10) |
| Fit | 3 / 3 | single-select by design; the page names the multi-select dropdown as the different job |