Combobox
An autocomplete text input with a filterable dropdown list.
import { ... } from '@corndev/ui' HTML
<div class="corn-combobox">
<label class="corn-label" for="fruit">Fruit</label>
<input id="fruit" class="corn-combobox-input" data-size="md" role="combobox" aria-expanded="false" />
<ul class="corn-combobox-listbox" role="listbox">
<li class="corn-combobox-option" role="option">Apple</li>
</ul>
</div>
Classes
| Class |
|---|
.corn-combobox |
.corn-combobox-input |
.corn-combobox-listbox |
.corn-combobox-option |
.corn-combobox-empty |
.corn-combobox-adornment |
Attributes
| On | Attribute | Values |
|---|---|---|
.corn-combobox-input | data-size | sm|md|lg |
.corn-combobox-option | data-active | present or absent |
.corn-combobox-option | data-selected | present or absent |
.corn-combobox-option | data-create | present or absent |
Vanilla JS
Required for behaviour — adds filters options as you type and handles keyboard selection.
import { createCombobox } from '@corndev/ui';
import '@corndev/ui/theme';
const inputEl = document.querySelector('#target');
const instance = createCombobox(inputEl, { /* options */ });
Options
| Option | Type | Default | Description |
|---|---|---|---|
options | Array<ComboboxOption|ComboboxGroup|string|number> | required | Selectable options: strings or numbers (a number is handed back as a number), { label, value } objects, or { group, items } groups, which render under a heading. An object with items is a group; any other object is an option. |
placeholder | string | undefined | |
size | 'sm'|'md'|'lg' | 'md' | |
disabled | boolean | false | |
filter | (option: ComboboxOption, query: string) => boolean | undefined | Custom filter (default: case-insensitive substring on label). |
onChange | (value: *, option: ComboboxOption) => void | undefined | Called when an option is selected. |
creatable | boolean | false | Offer to create what was typed when no option is labelled with it (ignoring case). The offer is the last entry in the list; picking it — or pressing Enter when nothing else matches — calls onCreate, then onChange with the new option. A created option stays on the list, with its label, until options carries one with the same value — so adding it to your options is optional, and does not duplicate it. |
onCreate | (text: string) => (ComboboxOption|*|void|Promise<ComboboxOption|*|void>) | undefined | Called with the trimmed text. Return nothing to use the text as the value, a { label, value } option, or just a value (a new record’s id, say), which keeps the text as its label. May return a promise; the field commits when it settles. |
createLabel | (text: string) => (string|Node) | undefined | The create entry’s content. Defaults to Create "text". |
renderOption | (option: ComboboxOption, state: { selected: boolean }) => (string|Node) | undefined | An option’s content in the list — an icon, a description, a count. A string is text, not HTML. The input still shows the plain label, and the filter still reads it, so keep the label meaningful. The highlighted option carries data-active, for styling. |
React
Combobox — from @corndev/ui-react.
Passing value makes the combobox controlled; use defaultValue for uncontrolled. Neither exists in the vanilla version, which reads and writes through getValue() and the input element.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | Array<ComboboxOption|ComboboxGroup|string|number> | required | Selectable options: strings or numbers (a number is handed back as a number), { label, value } objects, or { group, items } groups, which render under a heading. An object with items is a group; any other object is an option. |
value | * | undefined | Selected value (controlled). |
defaultValue | * | undefined | Initial selected value (uncontrolled). |
onChange | (value: *, option: ComboboxOption) => void | undefined | Called when an option is selected. |
placeholder | string | undefined | |
size | 'sm'|'md'|'lg' | 'md' | |
disabled | boolean | false | |
filter | (option: ComboboxOption, query: string) => boolean | undefined | Custom filter (default: case-insensitive substring on label). |
clearable | boolean | false | Show a control that returns the field to no selection, calling onChange(null, null). null, not '' or undefined. Those mean different things to a caller writing into a document: '' is a value, and an absent key is not a JSON null. A combobox especially needs this, because its list is filtered by typing and an empty option is the one entry nobody can type their way to. Escape clears too, while the field has focus and the list is shut. The button is tabIndex={-1}, so it does not sit between the input and the list in the tab order. |
clearLabel | string | 'Clear selection' | Accessible name for it. |
creatable | boolean | false | Offer to create what was typed when no option is labelled with it (ignoring case). The offer is the last entry in the list; picking it — or pressing Enter when nothing else matches — calls onCreate, then onChange with the new option. A created option stays on the list, with its label, until options carries one with the same value — so adding it to your options is optional, and does not duplicate it. |
onCreate | (text: string) => (ComboboxOption|*|void|Promise<ComboboxOption|*|void>) | undefined | Called with the trimmed text. Return nothing to use the text as the value, a { label, value } option, or just a value (a new record’s id, say), which keeps the text as its label. May return a promise; the field commits when it settles. |
createLabel | (text: string) => React.ReactNode | undefined | The create entry’s content. Defaults to Create "text". |
renderOption | (option: ComboboxOption, state: { selected: boolean }) => React.ReactNode | undefined | An option’s content in the list — an icon, a description, a count. The input still shows the plain label, and the filter still reads it, so keep the label meaningful. The highlighted option carries data-active, for styling. |
className | string | undefined |
Combobox is a text input paired with a filterable listbox — the
“select with search” / autocomplete pattern. It implements the ARIA combobox
pattern (role="combobox", aria-activedescendant, listbox/option roles) with
full keyboard support: type to filter, ↑/↓ to highlight, Enter to choose, Esc
to close. Use it instead of reaching for a separate autocomplete library.
React
import { Combobox, Label } from '@corndev/ui-react';
const fruits = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
];
// Controlled, with a visible label pointing at the input
<Label htmlFor="fruit">Fruit</Label>
<Combobox id="fruit" options={fruits} value={value} onChange={setValue} placeholder="Search..." />
// Uncontrolled
<Combobox aria-label="Fruit" options={fruits} defaultValue="apple" onChange={(v) => console.log(v)} />
// Plain string options also work
<Combobox aria-label="Count" options={['One', 'Two', 'Three']} />
Vanilla JS
Enhances an existing <input> (it gets wrapped automatically):
import { createCombobox } from '@corndev/ui';
const combo = createCombobox(document.querySelector('#fruit'), {
options: [{ label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana' }],
placeholder: 'Search...',
onChange: (value, option) => console.log(value, option),
});
// combo.getValue(), combo.setOptions([...]), combo.destroy()
Options
options takes three shapes, and they can be mixed within a group:
- strings or numbers —
['users', 'orders', 42]. A number is handed back as a number, soonChangegets42, not"42". { label, value }— when what is shown and what is meant differ.{ group, items }— rendered under a heading, as a labelled group a screen reader announces.groupmay be a string or a number.
An object with items is a group; any other object is an option.
<Combobox
options={[
{ group: 'Tables', items: ['users', 'orders', 'invoices'] },
{ group: 'Views', items: ['active_users', 'monthly_revenue'] },
]}
/>The arrow keys move through every option as one list, across the headings, and a group with nothing matching disappears while filtering rather than leaving an empty heading behind.
What the field shows
The input shows the chosen option’s label — but that text is not a filter. Opening the list shows every option, because a field with a value is usually opened to change it. The list narrows only once you type.
Leaving without choosing puts the label back. Type something, then press Escape, Tab or click away, and the field returns to its value — it never shows text that isn’t what it holds. With no value, it empties.
Clearing the field
clearable shows a control that returns the field to no selection. It calls
onChange(null, null).
null, not '' or undefined. Those mean different things to a caller
writing into a document: '' is a value, and an absent key is not a JSON
null. A combobox needs this more than a Select does, because its list is
filtered by typing — an empty option is the one entry nobody can type their way
to.
<Combobox options={['alpha', 'beta', 'gamma']} defaultValue="alpha" clearable />Escape clears too, while the field has focus and the list is shut; when the list is open Escape closes it instead. The control itself is not in the tab order — a field that tabs to its input, then its clear control, then its list is tedious to escape, and the keyboard already has Escape.
On a plain Select
A native <select> does not need this. Give it an <option value=""> and the
reader can choose it like any other value — the list is not filtered, so there
is nothing they cannot reach.
Creating options
creatable offers to create what was typed when no option is labelled with it —
ignoring case and surrounding space, so “apple” never offers a second Apple. The
offer is the last entry in the list; pick it, or press Enter when nothing else
matches.
<Combobox
options={tags}
creatable
onCreate={text => api.createTag(text)} // may return a promise
onChange={setTag}
/>
onCreate gets the trimmed text and decides what the new option is:
- return nothing, and the text is the value
- return
{ label, value }, and that is the option - return just a value — a new record’s id — and the text stays its label
Then onChange fires with it, as for any other pick. Adding the new option to
options is optional: the combobox keeps what it created on the list, labelled,
until your options carries an option with the same value — at which point yours
wins and nothing is duplicated. createLabel={text => …} changes the wording.
Rendering options
renderOption(option, { selected }) renders an option’s content in the list — an
icon, a description, a count. The input still shows the plain label, and the
filter still reads it, so keep the label meaningful. The highlighted option carries
data-active for styling.
<Combobox
options={methods}
renderOption={o => (
<Group gap="xs">
<Badge variant={o.colour}>{o.label}</Badge>
<Text muted>{o.summary}</Text>
</Group>
)}
/>