# Combobox

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/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**

```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.

```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.

```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](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `data`required | `TRow[]` | — | Every row the combobox can offer. Filtered by the query unless `filter` is `false`. |
| `def`required | `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 |
| --- | --- | --- | --- |
| `multiple`required | `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>` carries `role="combobox"`, `aria-autocomplete="list"` and `aria-expanded`; the popup is `role="listbox"` with `role="option"` rows, named by `aria-activedescendant` rather than by focus, so one Tab enters and one leaves however many options there are.
- Nothing names a combobox for you: pass `label`, or an `aria-label` in `props`.
- 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.

`combobox`variants: compact

`combobox.label`

`combobox.field`

`combobox.chip`

`combobox.remove`

`combobox.icon`

`combobox.items`variants: closed, closedUp

`combobox.window`

`combobox.item`variants: highlighted, create

`combobox.message`

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

