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

OnAttributeValues
.corn-combobox-inputdata-sizesm|md|lg
.corn-combobox-optiondata-activepresent or absent
.corn-combobox-optiondata-selectedpresent or absent
.corn-combobox-optiondata-createpresent 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

OptionTypeDefaultDescription
optionsArray<ComboboxOption|ComboboxGroup|string|number>requiredSelectable 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.
placeholderstringundefined
size'sm'|'md'|'lg''md'
disabledbooleanfalse
filter(option: ComboboxOption, query: string) => booleanundefinedCustom filter (default: case-insensitive substring on label).
onChange(value: *, option: ComboboxOption) => voidundefinedCalled when an option is selected.
creatablebooleanfalseOffer 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>)undefinedCalled 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)undefinedThe create entry’s content. Defaults to Create "text".
renderOption(option: ComboboxOption, state: { selected: boolean }) => (string|Node)undefinedAn 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

PropTypeDefaultDescription
optionsArray<ComboboxOption|ComboboxGroup|string|number>requiredSelectable 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*undefinedSelected value (controlled).
defaultValue*undefinedInitial selected value (uncontrolled).
onChange(value: *, option: ComboboxOption) => voidundefinedCalled when an option is selected.
placeholderstringundefined
size'sm'|'md'|'lg''md'
disabledbooleanfalse
filter(option: ComboboxOption, query: string) => booleanundefinedCustom filter (default: case-insensitive substring on label).
clearablebooleanfalseShow 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.
clearLabelstring'Clear selection'Accessible name for it.
creatablebooleanfalseOffer 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>)undefinedCalled 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.ReactNodeundefinedThe create entry’s content. Defaults to Create "text".
renderOption(option: ComboboxOption, state: { selected: boolean }) => React.ReactNodeundefinedAn 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.
classNamestringundefined

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, so onChange gets 42, 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. group may 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>
  )}
/>