# Combobox

A select you can type into.

- Group: Forms
- Import: `import { Combobox } from '@misoto22/folio'`
- Page: https://ui.misoto22.com/components/combobox/
- Related: select, command

## When to reach for it

Past roughly a dozen options. Below that a native Select is better: the platform picker on a phone, typeahead for free, no JavaScript.

## Anatomy

- **Trigger** (required) — A <button role="combobox"> named by the label and its own summary together, carrying aria-expanded. Its text truncates rather than wrapping, so the field keeps its height whatever is chosen.
- **Summary** — The trigger’s text: the placeholder, or up to two chosen labels joined by commas, then “n selected”. Counting past two is what stops a multiple picker reflowing the form on every choice.
- **Clear** — A <span role="button"> beside the chevron, on multiple with something chosen. A span rather than a nested <button>, which is invalid inside the trigger and which browsers reparent out of the field.
- **Filter field** — cmdk’s input inside the panel. It is named through the Command wrapper as “label: searchPlaceholder”, because aria-labelledby beats aria-label and naming the input directly did nothing.
- **Option row** — A tick for single, a fillable box for multiple, then the label. emptyMessage takes the list’s place when the filter matches nothing.

## Best practices

### Do

- Spell “nothing chosen” as an empty string when the value is controlled. value={undefined} is precisely how this component decides it is UNCONTROLLED, so clearing that way hands it back its own state and it stops following the parent.
- Put readable text in label and anything else worth matching in keywords: cmdk scores against the option’s value too, so a list keyed by UUID is being ranked on a string no reader will ever type.
- Say what WOULD match in emptyMessage. The default tells a reader the filter ran and nothing about which of the four hundred options they should have typed instead.

### Don’t

- Past two choices the trigger stops naming them — it announces “Tags, 3 selected”, and WHICH three is only in the panel. Print them beside the field when the choice has to be checkable without opening it.
- Do not hand it thousands of options. Nothing here virtualises: every option in the array renders into the panel on open and stays there behind the filter, so the list length is a DOM cost, not a search cost.
- A disabled option is not a hidden one — it still renders and still matches the filter, so a reader can type its exact name, watch it come up, and be unable to pick it with no reason offered.

## Accessibility

- The highlight moves through aria-activedescendant while focus stays in the input — the ARIA combobox pattern. Hand-rolled comboboxes move focus into the list, and the typed text stops being editable.
- label is required, and it is announced with the summary rather than instead of it: the trigger reads “Tags, 3 selected”. Inside a Field the FIELD’s label supplies the name half and the label prop is neither rendered nor announced on the trigger — it still names the clear control, as “Clear Tags”, so it has to stay truthful even where the trigger no longer says it.

## Keyboard

- Enter / Space / ↓ — Opens the list.
- ↑ / ↓ — Moves the highlight while focus stays in the filter.
- Enter — Chooses the highlighted option; choosing the current one clears it.
- Escape — Closes without choosing.

## Combobox

A select you can type into, choosing one or several. The line against `Select` is length, and it is not a matter of taste: a styled select is better up to roughly a dozen options, because a list nobody can filter is faster to scan than one they have to think about. Past that, this is the right answer. Filtering, the highlighted row and the arrow keys come from cmdk, which implements the ARIA combobox pattern properly: the highlight moves through `aria-activedescendant` while focus stays in the input. Hand-rolled comboboxes move focus into the list instead, and the typed text stops being editable. Controlled or uncontrolled, like every other form control here.

### Props

- `multiple` — `false`.
- `value` — `string`.
- `defaultValue` — `string`.
- `onValueChange` — `(value: string) => void`.
- `options` (required) — `ComboboxOption[]`.
- `label` (required) — `string`. Names the control. Required — the trigger's text is a value, not a label. Announced together with the summary rather than instead of it, so a reader hears "Tags, 3 selected". Inside a `Field` with a label, that label names the trigger and this one is not repeated.
- `placeholder` — `string` default `'Select…'`. Shown on the trigger when nothing is chosen.
- `searchPlaceholder` — `string` default `'Search…'`. Placeholder inside the filter field.
- `emptyMessage` — `string` default `'Nothing matches.'`. Shown when the filter matches nothing. Say what WOULD match.
- `disabled` — `boolean` default `false`.
- `className` — `string`.
- `id` — `string`. The TRIGGER's id — the element a label points at. A `Field` sets it.
- `aria-describedby` — `string`. Ids of the copy describing the control. A `Field` sets it from hint, error and description.
- `aria-invalid` — `boolean | 'true' | 'false'`. Paints the resting border with `--danger` and is announced. A `Field` sets it from `error`.
- `aria-required` — `boolean`. Announced on the trigger. A `Field` sets it from `required`.

## Example — default

```tsx
import { Combobox, Field } from '@misoto22/folio'

<Field label="Camera" hint="Type to filter — “compact” matches two of them." className="w-full max-w-xs">
  <Combobox label="Camera" options={CAMERAS} placeholder="Pick a body" />
</Field>
```

## Example — multiple

```tsx
import { Combobox, Field } from '@misoto22/folio'

<Field
  label="Tags"
  hint="The panel stays open while you pick; past two it counts instead of listing."
  className="w-full max-w-xs"
>
  <Combobox multiple label="Tags" options={TAGS} defaultValue={['film']} placeholder="Add tags" />
</Field>
```

## Example — controlled and cleared

```tsx
import { Button, Combobox, Field, Text } from '@misoto22/folio'

<div className="flex w-full max-w-xs flex-col gap-3">
  <Field label="Reviewer" className="w-full">
    <Combobox
      label="Reviewer"
      options={REVIEWERS}
      value={reviewer}
      onValueChange={setReviewer}
      placeholder="Unassigned"
      searchPlaceholder="Search by name or team"
      emptyMessage="Nobody by that name — try a team, like platform."
    />
  </Field>
  <div className="flex items-center gap-3">
    <Button
      variant="secondary"
      size="sm"
      disabled={reviewer === ''}
      onClick={() => setReviewer('')}
    >
      Unassign
    </Button>
    <Text size="xs" tone="muted">
      value is {reviewer === '' ? 'an empty string' : reviewer}
    </Text>
  </div>
</div>
```
