Combobox
<cinq-combobox> adds keyboard support, popup visibility, and ARIA state to combobox markup you provide.
Inspired by @19h47/combobox and the
WAI-ARIA combobox pattern.
Install
Section titled “Install”pnpm add @agencecinq/comboboxImport once:
import "@agencecinq/combobox";Write the combobox markup, then assign search on the host. The component mounts
once the element is connected and search is set.
<label for="monster">Monster</label><cinq-combobox id="monster-combobox"> <input id="monster" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="monster-listbox" autocomplete="off" /> <button type="button" tabindex="-1" aria-label="Monsters" aria-controls="monster-listbox" aria-expanded="false" > Open </button> <ul id="monster-listbox" role="listbox" aria-label="Monsters" hidden></ul></cinq-combobox>const host = document.querySelector("#monster-combobox");
host.search = (value, { signal }) => { if (value.length < 1) return monsters; return monsters.filter((name) => name.toLowerCase().startsWith(value.toLowerCase()), );};An open button is optional. Wire the toggle and sync aria-expanded / disabled
yourself:
const button = host.querySelector("button[aria-controls]");
const syncButton = () => { button.setAttribute("aria-expanded", host.expanded ? "true" : "false"); button.disabled = host.disabled;};
button.addEventListener("mousedown", (event) => event.preventDefault());button.addEventListener("click", (event) => { event.stopPropagation(); if (host.disabled) return; if (host.expanded) host.hide({ force: true }); else void host.ensureOpen().then(() => host.$input?.focus());});
new MutationObserver(syncButton).observe(host, { attributes: true, attributeFilter: ["expanded", "disabled"],});syncButton();search may return an array or a Promise. Stale responses are ignored. The
previous AbortSignal is aborted when a newer search starts.
| Mode | Who owns the listbox DOM | Typical use |
|---|---|---|
managed (default) |
Library rebuilds options from string[] via render |
Classic autocomplete |
external |
Consumer injects HTML / mutates the listbox | Rich results, grouped options |
Required markup
Section titled “Required markup”| Selector / attribute | Required | Role |
|---|---|---|
<cinq-combobox> |
Yes | Host wrapper. |
input or [role="combobox"] |
Yes | Focusable textbox. |
[role="listbox"] with stable id |
Yes | Popup list, referenced by aria-controls. |
role="combobox" + aria-autocomplete="list" |
Yes | On the input. |
aria-controls / aria-expanded |
Yes | On the input (and optional button). |
Optional button[aria-controls] inside the host |
No | Optional pointer/touch toggle. Wire it in your app. |
[role="option"] with stable id |
In external mode |
Generated in managed mode from {listboxId}-option-{n}. |
Options
Section titled “Options”Configure via data attributes on the host (observed at runtime), or properties for callbacks:
| Attribute / property | Type | Default | Description |
|---|---|---|---|
value |
string |
"" |
Mirrored textbox value. Setting it updates the input without opening the list or firing combobox:submit. |
disabled |
boolean | false |
Disables the input and closes the list if open. |
expanded |
boolean | false |
Reflected open state. Setting it opens / closes the list from outside. |
busy |
boolean | false |
Reflected while a search is in flight (mirrors aria-busy on the listbox). |
search (property) |
SearchFn |
- | Required. (value, { signal }) => SearchResult | Promise<...> |
data-mode |
managed | external |
managed |
Who owns listbox markup |
data-select-mode |
value | custom |
value |
Fill the textbox, or only fire onSelect / combobox:submit |
data-debounce |
number |
0 |
Debounce (ms) for input-driven searches |
data-min-length |
number |
0 |
Minimum trimmed length before searching |
data-open-on-empty |
boolean | false |
Keep popup open on empty results |
data-autoselect |
boolean | false |
Highlight the first suggestion |
render (property) |
(label, props) => string |
<li> |
HTML for each managed option |
write (property) |
(input, value) => void |
sets input.value |
How a chosen option is written |
onSelect (property) |
(detail) => void |
- | Called when an option is accepted |
Custom render
Section titled “Custom render”In managed mode, override render to control option markup. Use
serializeOptionAttrs so id, role, and ARIA stay correct:
import { serializeOptionAttrs } from "@agencecinq/combobox";
host.render = (label, props) => `<li${serializeOptionAttrs(props)} class="my-option">${label}</li>`;optionRenderProps(index, selectedIndex, listboxId, size) is also exported if
you build props yourself.
Custom write
Section titled “Custom write”When selectMode is value (default), write runs on accept:
host.write = (input, value) => { input.value = value.toUpperCase();};selectMode: 'custom'
Section titled “selectMode: 'custom'”Default (value): accepting an option writes its label into the input (classic autocomplete).
Custom: the input is only a filter. Accepting an option fires combobox:submit /
onSelect but does not change the textbox. You store or display the choice elsewhere
(link, chip, hidden field, router navigation, etc.).
<cinq-combobox id="monster-picker" data-select-mode="custom"> <input type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="monster-listbox" placeholder="Filter monsters" /> <ul id="monster-listbox" role="listbox" hidden></ul></cinq-combobox><p>Picked: <span id="monster-picked">-</span></p>import { EVENTS } from "@agencecinq/utils";
const host = document.querySelector("#monster-picker");const picked = document.querySelector("#monster-picked");
host.search = (query) => monsters.filter((name) => name.toLowerCase().startsWith(query.toLowerCase()), );
host.addEventListener(EVENTS.COMBOBOX_SUBMIT, ({ detail }) => { // detail.value = chosen monster. Input still holds the filter ("be", etc.) picked.textContent = detail.value;});Try it in the Custom select playground section: type be, pick Beholder. Query
stays be. Picked shows Beholder.
For a fuller UI (rich external options, detail panel, empty state), see Compendium (advanced) in the playground.
Advanced UI
Section titled “Advanced UI”Combine external mode, custom select, and your own markup to build command-palette or compendium UIs:
searchreturns grouped HTML ({ html }) with avatars, badges, metadata on each[role="option"]data-select-mode="custom"keeps the filter in the input.combobox:submitdrives a detail paneldata-*on options (data-cr,data-type, etc.) are read in the submit handler- pair
combobox:emptywith a visible empty state anddata-debouncefor async feel
The Compendium (advanced) playground demo wires all of this end-to-end.
search return shapes
Section titled “search return shapes”| Return | Mode | Effect |
|---|---|---|
string[] |
managed | Rebuild options via render |
HTMLElement[] |
either | Replace listbox children |
{ html } |
external | Set listbox innerHTML |
{ options: string[] | HTMLElement[] } |
either | Same as the array forms above |
Control from outside
Section titled “Control from outside”host.setValue("Mind Flayer"); // or host.value = "..."host.setAttribute("expanded", ""); // openhost.removeAttribute("expanded"); // closehost.setAttribute("disabled", "");While a search is in flight, the host reflects [busy] and the listbox gets
aria-busy="true".
Styling hooks
Section titled “Styling hooks”| Hook | Element | When |
|---|---|---|
[hidden] |
listbox | Popup closed |
[aria-expanded="true"] |
input / button | Popup open |
[aria-busy="true"] |
listbox | Search in flight |
[expanded] / [busy] / [disabled] |
<cinq-combobox> |
Mirrored host state |
[aria-selected="true"] |
option | Visual focus |
ul[role="listbox"][hidden] { display: none;}
ul[role="listbox"]:not([hidden]) { display: flex; flex-direction: column; max-height: 16rem; overflow: auto;}
[role="option"][aria-selected="true"] { background: #e9ecef;}Methods
Section titled “Methods”| Method | Description |
|---|---|
setValue(value) |
Set the textbox value from outside (also available as the value property / attribute). |
show() / hide(options?) |
Open or close the listbox (hide: { force?, clear? }) |
select() |
Accept the focused option |
destroy() |
Remove listeners, clear debounce, abort in-flight searches |
Readable state: index, options, value, loading, expanded, focused, disabled.
Events
Section titled “Events”Dispatched on the host <cinq-combobox> (bubble). Constants live on @agencecinq/utils EVENTS:
| Event | Constant | Detail | When |
|---|---|---|---|
combobox:loading |
COMBOBOX_LOADING |
- | Before search resolves |
combobox:loaded |
COMBOBOX_LOADED |
- | After a non-stale search resolves |
combobox:update |
COMBOBOX_UPDATE |
{ options, index, value } |
After options / ARIA are synced |
combobox:submit |
COMBOBOX_SUBMIT |
{ option, index, value } |
When an option is chosen |
combobox:empty |
COMBOBOX_EMPTY |
{ value } |
No options (or below minLength) |
import { EVENTS } from "@agencecinq/utils";
$host.addEventListener(EVENTS.COMBOBOX_SUBMIT, ({ detail }) => { console.log(detail.option, detail.value);});Keyboard
Section titled “Keyboard”| Key | Function |
|---|---|
ArrowDown |
Open list and move to first / next option |
Alt + ArrowDown |
Open list without moving visual focus |
ArrowUp |
Open list and move to last / previous option |
Alt + ArrowUp |
Open list without moving visual focus |
Enter |
Accept focused option. Without focus, close and allow form submit |
Escape |
Close list, or clear field if already closed |
Tab |
Accept focused option, otherwise close |
Home / End / ArrowLeft / ArrowRight |
Return editing to the textbox when an option has visual focus |
DOM focus stays on the textbox. Visual focus uses aria-activedescendant.
Styling the listbox
Section titled “Styling the listbox”The component does not position the popup. Style the listbox yourself. For placement that flips near the viewport edge, use CSS Anchor Positioning:
cinq-combobox { anchor-name: --combobox-anchor; anchor-scope: --combobox-anchor; /* one name per host on the page */}
cinq-combobox [role="listbox"] { position: absolute; top: calc(100% + 0.25rem); left: 0; right: 0;}
@supports (anchor-name: --x) and (position-anchor: --x) { cinq-combobox [role="listbox"] { position: fixed; position-anchor: --combobox-anchor; top: anchor(bottom); left: anchor(left); width: anchor-size(width); right: auto; margin-top: 0.25rem; position-try-fallbacks: flip-block; }}position: fixed + anchor keeps the list aligned to the input. flip-block opens upward when there is not enough space below.
Playground (live)
Section titled “Playground (live)”Local list
Managed mode: sync search, optional open button, anddata-autoselect (first match highlighted on open).
Status: -
Async search
Managed mode: async search, AbortSignal, event log, and[busy] while loading.
Status: Idle
External mode
Grouped HTML, debounce, minLength, and combobox:empty.
Hint: Idle. Try "be", "mi", or "xx".
Custom select
By default, choosing an option fills the input (see Local list above). With data-select-mode="custom", the input stays your filter. Handle the choice in combobox:submit.
- Type
bein the field below. - Open the list (Down arrow or click the input).
- Pick an option (click or Enter).
- Query stays
be. Only Picked updates.
- Query (unchanged on pick)
- -
- Picked
- -
Compendium (advanced)
External mode and selectMode: custom: rich grouped options, debounced async search, empty state, and a detail panel fed from combobox:submit.
Down arrow to navigate, Enter to inspect. The filter stays in the field.