busy-office-ui
Menu

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

CC-1180 — Warehouse
CC-2205 — Logistics
CC-4021 — Procurement

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

No such cost center — pick one from the list
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 &amp; 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

KeyBehaviour
Printable charactersFilter; open the list if anything matches
↓ / ↑Move the active option; clamps at the ends — deliberately no wrap-around
Home / EndMove the text caret, not the active option (correct for an editable combobox)
PageUp / PageDownNot 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
EscClose, keep the typed text
TabClose, clear the active option, move on

Value help — recents first, rich rows, browse as a link

  • CC-1180 Warehouse Hamburg Logistics
  • CC-4021 Assembly Line 2 Production
  • CC-2205 Inbound Rotterdam Logistics

Browse all cost centres

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

ClassRole
.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 id get one minted at activation time, so aria-activedescendant is 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' hidden state yourself in response to your own fetch instead of relying on the built-in filter.
  • Commit dispatches bo:combobox-select with { value, text } — value comes from the chosen option's data-value (falls back to its text if absent).
Contrast — 15 AA pairs this component uses (generated, CI-gated)
TextonLightDark
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
↑↓ move Enter open Esc close