# Combobox

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/combobox/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

One choice from a long list, found by typing (autocomplete, typeahead, searchable select): a driver, a service, an account. The list filters along while the value stays exactly one.

```ts
import { Combobox } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/combobox/

What is typed is a search term, not a value: the value changes only when a row is chosen or the field is cleared, and it is one option or `null`.

The list keeps its given order while it filters; sort the options before you pass them if you want an order.

## Examples

### Combobox

Typing filters the `options`; `onChange` runs only when a row is chosen or cleared, with one value or `null`.

```tsx
import { useState } from "react";
import { Combobox, FormField, Stack, Text } from "@umriss-ui/core";

const DRIVERS = [
  { value: "d1", label: "Martin Hale" },
  { value: "d2", label: "Nadia Petrova" },
  { value: "d3", label: "Owen Carter" },
  { value: "d4", label: "Lucia Romero" },
  { value: "d5", label: "Ben Adeyemi" },
  { value: "d6", label: "Hanna Berg" },
  { value: "d7", label: "Yusuf Demir" },
  { value: "d8", label: "Clara Wendt" },
];

export default function PickADriver() {
  const [driver, setDriver] = useState<string | null>(null);

  return (
    <Stack gap={3} style={{ maxWidth: 320 }}>
      <FormField label="Driver, tour T-04">
        <Combobox value={driver} onChange={setDriver} clearable options={DRIVERS} placeholder="Type a name" />
      </FormField>
      <Text size="xs" tone="muted" mono>
        value: {driver === null ? "null" : `"${driver}"`}
      </Text>
    </Stack>
  );
}
```

### States

Set `emptyText` for a search that finds nothing; errors come from the `FormField`, and a `disabled` row stays in the list.

```tsx
import { Combobox, FormField, Grid, type ComboboxOption } from "@umriss-ui/core";

const VEHICLES: ComboboxOption[] = [
  { value: "v1", label: "FP 214 K, van, North depot" },
  { value: "v2", label: "FP 377 K, e-van, North depot" },
  { value: "v3", label: "FP 118 R, truck, North depot, in the workshop until Thursday", disabled: true },
  { value: "v4", label: "FP 402 R, van, Riverside depot" },
];

export default function States() {
  return (
    <Grid minItemWidth="240px" gap={4}>
      <FormField label="Vehicle" hint="Type “zz” to see the empty result.">
        <Combobox value={null} onChange={() => {}} options={VEHICLES} emptyText="No vehicle matches. Check the plate." />
      </FormField>
      <FormField label="Vehicle" error="Tour T-04 needs a vehicle before it can be released.">
        <Combobox value={null} onChange={() => {}} options={VEHICLES} placeholder="Choose a vehicle" />
      </FormField>
      <FormField label="Vehicle" hint="The tour is on the road.">
        <Combobox value="v2" onChange={() => {}} options={VEHICLES} disabled />
      </FormField>
      <FormField label="Vehicle" hint="A long label is cut at the field's edge.">
        <Combobox value="v3" onChange={() => {}} options={VEHICLES} />
      </FormField>
    </Grid>
  );
}
```

### With an error

A `FormField` with an `error` marks the field invalid by itself. Without one, set `invalid` and tie your message to the field with `aria-describedby`.

```tsx
import { useId, useState } from "react";
import { Combobox, Stack, Text } from "@umriss-ui/core";

const VEHICLES = [
  { value: "v1", label: "FP 214 K, van, North depot" },
  { value: "v2", label: "FP 377 K, e-van, North depot" },
  { value: "v4", label: "FP 402 R, van, Riverside depot" },
];

export default function WithAnError() {
  const [vehicle, setVehicle] = useState<string | null>(null);
  const messageId = useId();
  const error = vehicle === null ? "Tour T-04 needs a vehicle before it can be released." : undefined;

  return (
    <Stack gap={1} style={{ maxWidth: 360 }}>
      <Combobox
        aria-label="Vehicle"
        value={vehicle}
        onChange={setVehicle}
        options={VEHICLES}
        placeholder="Choose a vehicle"
        invalid={error !== undefined}
        aria-describedby={error ? messageId : undefined}
      />
      {error && (
        <Text id={messageId} size="xs" style={{ color: "var(--u-color-danger-text)" }}>
          {error}
        </Text>
      )}
    </Stack>
  );
}
```

### Assign an incident

Build `options` from your records, with the team in the label so typing finds either; one choice decides the next.

```tsx
import { useState } from "react";
import { Button, Card, CardBody, CardHeader, Combobox, FormField, Stack } from "@umriss-ui/core";

/* Data from the operations world, written out here so the example runs on its own. */
interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface Engineer {
  id: string;
  name: string;
  team: string;
}

const ENGINEERS: readonly Engineer[] = [
  { id: "priya", name: "Priya Raman", team: "Payments" },
  { id: "jonas", name: "Jonas Keller", team: "Payments" },
  { id: "ada", name: "Ada Mwangi", team: "Identity" },
  { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
  { id: "leila", name: "Leila Haddad", team: "Discovery" },
  { id: "sam", name: "Sam Okafor", team: "Messaging" },
  { id: "ines", name: "Ines Duarte", team: "Integrations" },
  { id: "felix", name: "Felix Brandt", team: "Insights" },
];

export default function AssignAnIncident() {
  const [service, setService] = useState<string | null>("checkout");
  const [owner, setOwner] = useState<string | null>(null);
  const team = SERVICES.find((s) => s.id === service)?.team;

  return (
    <Card style={{ maxWidth: 480 }}>
      <CardHeader eyebrow="INC-1049 · SEV2" title="Assign the incident" />
      <CardBody>
        <Stack gap={4}>
          <FormField label="Service">
            <Combobox
              value={service}
              onChange={(next) => {
                setService(next);
                setOwner(null);
              }}
              options={SERVICES.map((s) => ({ value: s.id, label: `${s.name} · ${s.team}` }))}
            />
          </FormField>
          <FormField label="Incident lead" hint={team ? `Engineers of ${team} first.` : undefined}>
            <Combobox
              value={owner}
              onChange={setOwner}
              clearable
              placeholder="Type a name or team"
              options={[...ENGINEERS]
                .sort((a, b) => Number(b.team === team) - Number(a.team === team))
                .map((e) => ({ value: e.id, label: `${e.name} · ${e.team}` }))}
            />
          </FormField>
          <Button variant="primary" size="sm" disabled={owner === null} style={{ alignSelf: "flex-start" }}>
            Assign
          </Button>
        </Stack>
      </CardBody>
    </Card>
  );
}
```

## When to use something else

- A short list that needs no search, with the system's picker under a finger → Select
- Several values at once → MultiSelect

## Keyboard

| Key | Action |
|---|---|
| `Arrow Down / Arrow Up` | Opens the list, then moves through the rows. |
| `Enter` | Chooses the active row. |
| `Escape` | Closes the list. |

## API

### `ComboboxProps<T extends string = string>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `options` *required* | `readonly `[`ComboboxOption`](https://romanhaendler.github.io/umriss-ui/core/combobox/#type-ComboboxOption)`<T>[]` | — | The possibilities, in their natural order. Filtered while typing; never reordered.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox), [Combobox](https://romanhaendler.github.io/umriss-ui/core/combobox/#pick-a-driver), [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states) and 5 more |
| `value` *required* | `T \| null` | — | The chosen value; `null` means none. Always controlled - the field keeps no second state beside the caller's.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox), [Combobox](https://romanhaendler.github.io/umriss-ui/core/combobox/#pick-a-driver), [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states) and 5 more |
| `placeholder` | `string` | — | What stands in the empty field.<br>Shown in: [Combobox](https://romanhaendler.github.io/umriss-ui/core/combobox/#pick-a-driver), [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states), [With an error](https://romanhaendler.github.io/umriss-ui/core/combobox/#with-an-error) and 3 more |
| `size` | `"sm" \| "md"` | the size of a `ControlSizeProvider` around it, else `"md"` | `sm` for a toolbar and dense forms, `md` otherwise.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox) |
| `chars` | `number` | — | The width in characters - room for the typed text and the chosen option; the field adds its own padding, chevron and cross. Given, the field is that wide wherever it stands, and never wider than its place. Without it the field fills its place, and is 20 characters wide where the place asks - in a toolbar or a row. The panel is never narrower than the field.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox) |
| `disabled` | `boolean` | `false` | Locks field and panel.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox), [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states), [Dispatch a tour (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#dispatch-a-tour) |
| `invalid` | `boolean` | — | Marks the field as invalid. `FormField` sets it itself as soon as it carries an `error` - by hand only necessary without `FormField`.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox), [With an error](https://romanhaendler.github.io/umriss-ui/core/combobox/#with-an-error) |
| `emptyText` | `string` | — | Text shown for an empty search result.<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states) |
| `clearable` | `boolean` | `false` | Shows a cross on selection or input that clears field and selection.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox), [Combobox](https://romanhaendler.github.io/umriss-ui/core/combobox/#pick-a-driver), [Assign an incident](https://romanhaendler.github.io/umriss-ui/core/combobox/#assign-an-incident) and 2 more |
| `ref` | `Ref<HTMLDivElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onChange` *required* | `(value: T \| null) => void` | — | Runs on choosing and on clearing (`null`). Not while typing: what is typed is a search term and not yet a value.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/combobox/#configurator-combobox), [Combobox](https://romanhaendler.github.io/umriss-ui/core/combobox/#pick-a-driver), [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states) and 5 more |

Also takes every attribute of `<div>` – without `defaultValue`, `onChange`.

### `ComboboxOption<T extends string = string>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `T` | — | What comes back when this row is chosen.<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states), [Assign an incident](https://romanhaendler.github.io/umriss-ui/core/combobox/#assign-an-incident), [Dispatch a tour (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#dispatch-a-tour) and 1 more |
| `label` *required* | `string` | — | What stands there - and what is filtered on. Text and not a node: what cannot be searched is something nobody finds in a combobox.<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states), [Assign an incident](https://romanhaendler.github.io/umriss-ui/core/combobox/#assign-an-incident), [Dispatch a tour (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#dispatch-a-tour) and 1 more |
| `disabled` | `boolean` | — | Shows the row but does not let it be chosen.<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/combobox/#states) |

## Known limits

- No free text as a value: only an option can be chosen.
- No loading of options from a server; filter your own list and pass it in.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
