@19h47/combobox
v2.0.0
Published
Accessible editable combobox with list autocomplete (W3C APG)
Maintainers
Readme
@19h47/combobox
Editable combobox with list autocomplete (manual selection), aligned with the W3C APG pattern.
HTML is the source of truth for roles and structure. The library does not invent missing attributes or “fix” incomplete markup — the consumer delivers clean HTML. The library only orchestrates interaction state: keyboard behaviour, popup visibility, aria-expanded, aria-selected, aria-activedescendant, and aria-busy.
| 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, server-rendered markup |
Install
pnpm add @19h47/comboboxHTML
The listbox must have a stable id (referenced by aria-controls). In managed mode, option ids are derived from it ({listboxId}-option-{n}).
<label for="monster">Monster</label>
<div class="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"
>
▼
</button>
<ul id="monster-listbox" role="listbox" aria-label="Monsters" hidden></ul>
</div>Accessibility checklist
role="combobox"+aria-autocomplete="list"on the inputaria-controls/aria-expandedon the input (and optional button)role="listbox"with an accessible name (aria-labeloraria-labelledby)- Each
[role="option"]has a stable, uniqueid(required inexternalmode; generated inmanagedmode from the listboxid) - Optional open button:
tabindex="-1", samearia-controls/aria-expandedas the input - Announce empty / loading states with a live region wired to
combobox:empty/combobox:loading(the library setsaria-busyon the listbox while searching)
JavaScript
Managed (default)
import Combobox, { EVENTS } from '@19h47/combobox';
const input = document.querySelector('#monster');
const listbox = document.querySelector('#monster-listbox');
const button = document.querySelector('button[aria-controls="monster-listbox"]');
const combobox = new Combobox(input, listbox, {
button,
search: (value, { signal }) => {
if (value.length < 1) return monsters;
return monsters.filter(name =>
name.toLowerCase().startsWith(value.toLowerCase()),
);
},
// Optional — e.g. Bootstrap list group
render: (result, props) =>
`<li class="list-group-item"${props}>${result}</li>`,
});
combobox.init();search may return an array or a Promise. Stale responses are ignored; the previous AbortSignal is aborted when a newer search starts.
External (rich / server HTML)
Each [role="option"] must already have a stable id. Group headers must not use role="option".
const combobox = new Combobox(input, listbox, {
mode: 'external',
selectMode: 'custom',
debounce: 280,
minLength: 2,
openOnEmpty: true,
search: async (query, { signal }) => {
const html = await fetchSection(query, signal);
return { html };
},
onSelect: ({ option }) => {
option?.element.querySelector('a')?.click();
},
});
combobox.init();
input.addEventListener(EVENTS.empty, ({ detail }) => {
// detail.value is the current query — pair with a polite live region
showNoResults(detail.value);
});Optional data-value on options overrides the string used for selectMode: 'value'.
search return shapes
| Return | Mode | Effect |
| --- | --- | --- |
| string[] | managed | Rebuild options via render |
| HTMLElement[] | either | Replace listbox children, then sync ARIA |
| { html } | external | Set listbox.innerHTML, then sync |
| { options } | either | Same as string[] / HTMLElement[] |
| [] / empty | either | Clear options; see openOnEmpty |
Keyboard
| Key | Function |
| --- | --- |
| ↓ | Open list and move to first / next option |
| Alt + ↓ | Open list without moving visual focus |
| ↑ | Open list and move to last / previous option |
| Alt + ↑ | 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 / ← / → | Return editing to the textbox when an option has visual focus |
DOM focus stays on the textbox; visual focus uses aria-activedescendant.
Options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| search | (value, context) => SearchResult \| Promise<…> | — | Required. context.signal is an AbortSignal. |
| mode | 'managed' \| 'external' | 'managed' | Who owns listbox markup |
| selectMode | 'value' \| 'custom' | 'value' | Fill the textbox, or only fire onSelect / combobox:submit |
| onSelect | (detail) => void | — | Called when an option is accepted |
| debounce | number | 0 | Debounce (ms) for input-driven searches |
| minLength | number | 0 | Minimum trimmed length before searching |
| openOnEmpty | boolean | false | Keep popup open (aria-expanded + visible listbox) on empty results — pair with a live region on combobox:empty |
| autoselect | boolean | false | Highlight the first suggestion |
| button | HTMLButtonElement \| null | null | Optional open button (also auto-discovered via aria-controls) |
| write | (input, value) => void | sets input.value | How a chosen option is written (selectMode: 'value') |
| render | (result, props) => string | <li …>…</li> | HTML for each managed option (props serializes ARIA attributes) |
Methods
| Method | Description |
| --- | --- |
| init() | Bind events and collapse the listbox |
| destroy() | Remove listeners, clear debounce, abort in-flight searches |
| show() / hide(options?) | Open or close the listbox (hide: { force?, clear? }) |
| select() | Accept the focused option |
Readable state: index, options, value, loading, expanded, focused.
Events
Dispatched on the input (EVENTS map exported):
| Event | detail | When |
| --- | --- | --- |
| combobox:loading | — | Before search resolves |
| combobox:loaded | — | After a non-stale search resolves |
| combobox:update | { options, index, value } | After options / ARIA are synced |
| combobox:submit | { option, index, value } | When an option is chosen |
| combobox:empty | { value } | No options (or below minLength); value is the current query |
import { EVENTS } from '@19h47/combobox';
input.addEventListener(EVENTS.submit, ({ detail }) => {
console.log(detail.option, detail.value);
});Styling hooks
Prefer ARIA when possible; classes are optional conveniences.
| Hook | Element | When |
| --- | --- | --- |
| [aria-expanded="true"] | input / button | Popup is open |
| [aria-busy="true"] | listbox | search in flight |
| is-active | listbox | Popup is open (mirrors expanded) |
| is-loading | input + listbox | Same window as aria-busy |
ul[role='listbox']:not(.is-active) {
display: none;
}
ul[role='listbox'].is-active {
display: flex;
flex-direction: column;
max-height: 16rem;
overflow: auto;
}
[role='option'][aria-selected='true'] {
background: #e9ecef;
}Migrating from 1.x
Breaking changes in 2.0:
writeValue→writerenderOption→render- Markup is no longer “fixed up”: provide complete ARIA HTML (listbox
id, optionids inexternalmode) aria-activedescendantis removed when inactive (never set to"")- Managed option ids are
{listboxId}-option-{n}(listboxidrequired for uniqueness) - New:
mode,selectMode,debounce,minLength,openOnEmpty,AbortSignalonsearch,EVENTSmap
Example
Interactive demo: 19h47.github.io/19h47-combobox.
pnpm test
pnpm build