Combobox
A text field over a list of your own rows: APG's editable combobox, object values, a filter you can compose, chips, and a create row.
Import
JSX
import Combobox from '@box-kite/react/components/combobox';Usage
{ id: 1, name: 'Ada Lovelace' }JSX
<Combobox
data={people}
def={{ label: 'name', key: 'id' }}
label="Assignee"
onValueChange={(person) => assign(person)}
/>A row in is a row out
data is the list you already have, and the value is one of those rows — not a string dug out of one. onValueChange hands the object straight back, typed, so there is no lookup table on the other side of the handler. That is the complaint every string-only select collects, and it is the reason this component exists.def is how a row is read: label is its text — searched, displayed and read out — and key is what makes two rows the same row, so a list refetched from the server still shows the selection as chosen. Both take a key of the row or a function. disabled and display are the other two.JSX
<Combobox
data={people}
def={{ label: (p) => `${p.name} · ${p.team}`, key: 'id', disabled: 'away' }}
label="Reviewer"
/>Several rows are chips
multiple puts the selection in front of the field as chips and turns the value into an array — the one shape decision that cannot be read off the value itself, because a combobox usually starts with nothing in it. Choosing a row that is already chosen takes it off again.A chip's remove button is deliberately not a tab stop: twenty selections would otherwise cost twenty presses to Tab past.
Backspace on an empty field removes the last one, and the listbox toggles any row back off, so nothing here needs a mouse.Grace Hopper
[2]JSX
<Combobox data={people} def={{ label: 'name', key: 'id' }} label="Reviewers" multiple />The filter is yours to compose
The built-in one folds case and strips accents, so
jose finds José, and matches anywhere in the label rather than only at the front. filter replaces it, and it takes the whole list so it can rank as well as reject — it is handed the label reader, so starting from the built-in one costs nothing.JSX
<Combobox
data={people}
def={{ label: 'name', key: 'id' }}
label="Assignee"
filter={(rows, query, labelOf) => ComboboxUtils.filterRows(rows, query, labelOf).slice(0, 3)}
/>A server that already searched
filter={false} says the data arrived filtered, so nothing is thrown away a second time. onQueryChange is the hook the request hangs off, and loading makes the popup say the rows are coming rather than that there are none — which is the difference between "still looking" and "nothing here".JSX
<Combobox
data={rows}
def={{ label: 'name', key: 'id' }}
label="Search people"
filter={false}
loading={loading}
onQueryChange={(query) => search(query)}
/>A row that is not there yet
createRow turns what was typed into the row it would make, or returns null to refuse it. Its presence is what offers the create row at all, and one is never offered for a query a row already answers by name — offering "Create Design" beside Design is how a list grows twins. The change arrives with reason create.JSX
<Combobox
data={tags}
def={{ label: 'name', key: 'id' }}
label="Tags"
multiple
createRow={(query) => ({ id: query, name: query })}
/>Ten thousand options
A list past a hundred rows is windowed: the popup renders the dozen on screen and a few either side, so it opens in one frame whether it holds a hundred rows or ten thousand. Nothing about the rest of the component changes — the filter still runs over the whole list, the arrows still walk all of it, and the value is still the row you passed in.
10,000 rows. Open it and scroll: the DOM holds about fifteen options at a time.JSX
<Combobox
data={cities}
def={{ label: 'name', key: 'id' }}
label="City"
/>The row height is measured rather than declared, so a restyled option windows correctly on its own. What windowing assumes is that rows are all the same height — a
display that varies one wants virtualize={false}, which renders every row however many there are. virtualize also takes true to window a short list and an object to tune it: threshold is the row count it starts at, overscan how many rows are kept either side, and itemHeight the pitch, when it should be stated rather than measured.The half of this that is not performance is the ARIA. A windowed listbox holds a slice of its rows, so every option carries
aria-setsize and aria-posinset — without them a screen reader would announce “Person 1 of 15” on a list of ten thousand. And the row the keyboard is on is always rendered, whatever the scroll position says: aria-activedescendant naming a row that was never put in the DOM names nothing at all, so the window goes where the keyboard is and lets the scroll catch up.What the keyboard does
It is APG's editable combobox. Typing filters and never highlights a suggestion — that is list autocomplete, not inline, and a highlight nobody asked for is one
Tab away from being committed. The arrows move through what the filter left; Home, End and the sideways arrows move the caret and hand the highlight back to the field; Enter chooses the highlighted row and does nothing when there is none.Escape closes the listbox keeping what was typed, and a second one clears the field. The whole control is one tab stop however many options are open, because DOM focus never leaves the input — the listbox is named by aria-activedescendant instead.Styling
Every part is a node of the
combobox style tree: combobox.label, combobox.field, combobox.chip and its combobox.remove, combobox.icon, combobox.items, combobox.item and combobox.message. The one that is not yours is combobox.window, what a windowed listbox renders its slice into. A row's chosen state is its own aria-selected, and where the keyboard is is the highlighted variant — a listbox driven by aria-activedescendant holds DOM focus nowhere, so :focus-within never fires and the highlight has to be drawn from state.JSX
<Combobox data={people} def={{ label: 'name' }} label="Assignee" borderRadius={4} itemsProps={{ p: 2 }} />The whole pattern, in one component
A combobox is usually assembled: a behaviour package for the state machine, then the markup, the styling and the ARIA written around it. This is 8.65 KB on top of Box with all of it inside — the listbox, the chips, the create row and the windowing — so what you write is the data and what it means.
JSX
<Combobox
data={cities}
def={{ label: 'name', key: 'id' }}
label="City"
multiple
createRow={(query) => ({ id: query, name: query })}
/>Ten thousand rows, several selected as chips, a query that can become a row, and the value that comes back is one of the objects that went in — not its label. Those three are what a string-only API cannot do.
Combobox props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
datarequired | TRow[] | — | Every row the combobox can offer. Filtered by the query unless filter is false. |
defrequired | ComboboxDef<TRow> | — | How to read a row: its label (the text, which is searched and read out), its key (what makes two rows the same row), and optionally disabled and display. A key of the row, or a function. |
label | React.ReactNode | — | The combobox's name, rendered above it in a real <label>. A combobox is not named by what it contains, so without this (or an aria-label of your own in props) it has no accessible name. |
labelProps | BoxProps<'label'> | — | Styles for the <label> itself — it sits above the control rather than wrapping it. |
placeholder | string | — | The field's placeholder. Not a name — it is gone on the first keystroke. See label. |
name | string | — | What the selection submits under: one hidden input per selected row, carrying its key. |
disabled | boolean | — | Not editable and not openable, but still read out. The real attribute, on the field itself. |
props | Record<string, unknown> | — | Attributes for the <input> that *is* the combobox — an aria-label, an inputMode, an id. |
itemsProps | BoxStyleProps | — | Styles for the popup (combobox.items). |
hideIcon | boolean | — | Drop the chevron. It is decoration either way: aria-hidden and never focusable. |
filter | ComboboxFilter<TRow> | false | ComboboxUtils.filterRows | Which rows a query leaves, and in what order — it takes the whole list so it can rank as well as reject, and is handed the label reader so composing it with the built-in one costs nothing. false means the data is already filtered, which is what a server that searched needs. |
loading | boolean | — | Rows are on their way. The popup says so rather than claiming there are none. |
loadingText | React.ReactNode | 'Loading...' | What the popup shows while loading and there is nothing to show yet. |
emptyText | React.ReactNode | 'No results' | What the popup shows when the query matched nothing. |
createRow | (query: string) => TRow | null | — | Turns the query into the row it would create, or null to refuse it. Its presence is what offers a create row at all, and one is never offered for a query a row already answers by name. |
createLabel | (query: string) => React.ReactNode | (query: string) => `Create "${query}"` | What the create row reads. Given the query, since the row it would make is not the one shown. |
virtualize | boolean | ComboboxVirtualize | — | Render only the rows on screen, which is what a list of thousands needs to open in one frame. On by default above a hundred rows; an object tunes it (threshold is what turns it on earlier), false never windows and true always does. Rows have to be a uniform height for it, which is measured rather than declared — a display that varies one wants false. |
removeLabel | (label: string) => string | (text: string) => `Remove ${text}` | A chip's remove button's accessible name, given the row's label. |
query | string | — | The typed text. Controlled — pair it with onQueryChange, which is also the hook an async search uses. |
defaultQuery | string | — | The text the field starts with when the combobox owns its own query. |
onQueryChange | ChangeHandler<string, ComboboxQueryReason> | — | Fires with the text and why it changed. Only input came from a keystroke. |
open | boolean | — | Whether the listbox is showing. Controlled — pair it with onOpenChange. |
defaultOpen | boolean | — | Whether it starts open, when the combobox owns that itself. |
onOpenChange | ChangeHandler<boolean, ComboboxOpenReason> | — | Fires with the new state and why: a press, a keystroke, a choice, Escape, or a press outside. |
Combobox (single-select)
One row at a time, which is the default: the value is a row or
null, and the field shows the selected row's label whenever nothing is being typed.| Prop | Type | Default | What it does |
|---|---|---|---|
multiple | false | — | Leave it off, or false. See the multiple half for the other shape. |
value | TRow | null | — | The selected row, or null. Controlled — pair it with onValueChange. |
defaultValue | TRow | null | — | What is selected to begin with, when the combobox owns its own value. |
onValueChange | ChangeHandler<TRow | null, ComboboxValueReason> | — | Fires with the row itself — the object out of data, not a key dug out of it — and why. |
Combobox (multiple)
Several rows, shown as chips in front of the field, and the value is an array. Choosing a row that is already chosen takes it off again, which is the keyboard's way to a chip its own button cannot reach.
| Prop | Type | Default | What it does |
|---|---|---|---|
multiplerequired | true | — | true is what turns the value into an array — the one shape a combobox cannot read off its value. |
value | TRow[] | — | The selected rows. Controlled — pair it with onValueChange. |
defaultValue | TRow[] | — | What is selected to begin with, when the combobox owns its own value. |
onValueChange | ChangeHandler<TRow[], ComboboxValueReason> | — | Fires with the whole selection, as rows, and why it changed. |
Combobox keyboard
| Key | Result |
|---|---|
A printable character | Types, which opens the listbox and filters it. Filtering never moves the highlight: that is list autocomplete, not inline, and a highlight nobody asked for gets committed. |
Down / Up | Opens, or moves the highlight through what the filter left, wrapping and skipping disabled rows. |
Alt + Down | Opens without moving the highlight. Alt + Up — chooses and closes. |
Home / End, Left / Right | Move the caret, and hand the highlight back to the field. |
Enter | Chooses the highlighted row. With nothing highlighted it does nothing, so a typed query is never committed by accident. |
Escape | Closes the listbox, keeping what was typed. Pressed again on a closed one, clears it. |
Tab | Commits the highlighted row, then moves on. Nothing is highlighted until an arrow key put it there, so this only ever commits a deliberate choice. |
Backspace (multiple, empty field) | Removes the last chip. |
Combobox accessibility
- The
<input>carriesrole="combobox",aria-autocomplete="list"andaria-expanded; the popup isrole="listbox"withrole="option"rows, named byaria-activedescendantrather than by focus, so one Tab enters and one leaves however many options there are. - Nothing names a combobox for you: pass
label, or anaria-labelinprops. - A chip's remove button is deliberately not a tab stop — twenty selections would otherwise cost twenty presses to Tab past. Backspace on an empty field removes the last, and the listbox toggles a row back off, so removal is reachable from the keyboard without them.
- The popup is a sibling of the field in the browser's top layer, so it needs no portal and keeps the theme, the custom properties and the direction around it.
Swept with axe on every release, in 3 states:
Combobox (open), Combobox (multiple, open), Combobox (virtualized, open). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.Combobox style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.comboboxvariants: compactcombobox.labelcombobox.fieldcombobox.chipcombobox.removecombobox.iconcombobox.itemsvariants: closed, closedUpcombobox.windowcombobox.itemvariants: highlighted, createcombobox.message