<!-- Protocore Design System — Combobox -->
# Combobox

- **Category:** Inputs (`inputs`)
- **Slug:** `inputs/combobox`
- **Status:** stable
- **Platforms:** web
- **Import:** `import { Combobox } from "@protocore/pds";`
- **Docs:** https://pds.protocore.io/components/inputs/combobox

> Autocomplete field — a sunken input that filters a list as you type, following the ARIA 1.2 combobox pattern.

## When to use it

Reach for a **Combobox** when the option list is **long or unbounded** and typing to narrow it is the fastest path — a service, a chain, a MIME type, a country. For a short, fixed list where filtering adds nothing, a **Select** is simpler. Use **SearchInput** when the query hits a backend and there's no closed set of options to pick from. Provide a custom `filter` when the default case-insensitive label match isn't what you want (e.g. matching on value or fuzzy scoring).

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `aria-label` | `string` | no | — | Accessible label for the input (required if no visible label wraps it). |
| `className` | `string` | no | — | Merged after the pds class on the root field. |
| `clearable` | `boolean` | no | `false` | Show a clear (×) button once a value is selected. |
| `defaultValue` | `string` | no | — | Initial selected value in uncontrolled mode. |
| `disabled` | `boolean` | no | `false` | Disable the control. |
| `filter` | `((options: ComboboxOption[], query: string) => ComboboxOption[])` | no | `(options: ComboboxOption[], query: string): ComboboxOption[] => { const q = query.trim().toLowerCase(); if (!q) return options; return options.filter((o) => o.label.toLowerCase().includes(q)); }` | Custom filter predicate; defaults to case-insensitive label substring match. |
| `id` | `string` | no | — | Root id — also seeds the listbox/option ids. |
| `onValueChange` | `((value: string) => void)` | no | — | Fires with the newly selected value, or `undefined` when cleared. |
| `options` | `ComboboxOption[]` | yes | — | The full option set. |
| `placeholder` | `string` | no | `Select…` | Placeholder for the empty field. |
| `size` | `enum` | no | `md` | Control height: `sm` (32) · `md` (36, default) · `lg` (40). |
| `value` | `string` | no | — | Controlled selected value. |

## Examples

### Basics

Pass `options` with `value` / `label` and an optional trailing `hint`. Type to filter by label; arrow keys and Enter commit the selection.

```tsx
import { Combobox } from "@protocore/pds";

const SERVICES = [
  { value: "ledger", label: "ledger-api", hint: "grpc" },
  { value: "settlement", label: "settlement-worker", hint: "queue" },
  { value: "gateway", label: "edge-gateway", hint: "http" },
  { value: "indexer", label: "chain-indexer", hint: "batch" },
  { value: "notifier", label: "notifier", hint: "queue" },
];

export default function Demo() {
  return (
    <div style={{ width: 280 }}>
      <Combobox options={SERVICES} placeholder="Find a service…" aria-label="Service" />
    </div>
  );
}
```

### Clearable & controlled

Set `clearable` for an inline × once a value is chosen. Drive `value` from state and read `onValueChange` — it fires with `undefined` when cleared.

```tsx
import { useState } from "react";
import { Combobox, Text } from "@protocore/pds";

const CHAINS = [
  { value: "eth", label: "Ethereum" },
  { value: "base", label: "Base" },
  { value: "arb", label: "Arbitrum" },
  { value: "op", label: "Optimism" },
  { value: "sol", label: "Solana" },
];

export default function Demo() {
  const [value, setValue] = useState<string | undefined>("base");

  return (
    <div style={{ display: "grid", gap: 12, width: 280 }}>
      <Combobox
        options={CHAINS}
        value={value}
        onValueChange={setValue}
        clearable
        aria-label="Settlement chain"
      />
      <Text size="sm" color="muted">
        {value ? `Settling on ${value}` : "No chain selected"}
      </Text>
    </div>
  );
}
```

## Do & don't

**Do**

- Give the input an accessible name via `aria-label` or a wrapping `Field`.
- Keep `options` label text meaningful — the default filter matches on `label`.
- Use `hint` for a compact secondary tag (a category, a shortcut), not a sentence.
- Make it `clearable` when 'no selection' is a valid, reachable state.

**Don't**

- Don't use a Combobox for 2–5 options — a Select or SegmentedControl is clearer.
- Don't pack long descriptions into `label`; the row is a single line.
- Don't reuse it as a freeform text box — it commits to one of the `options`.
- Don't leave it unlabelled; screen readers announce it as a combobox with no name.

## Accessibility

**Keyboard**

| Keys | Action |
| --- | --- |
| `Type` | Filter the list; opens the popup and highlights the first match. |
| `Arrow Down / Up` | Move the active option (opens the popup if closed). |
| `Home / End` | Jump to the first / last option while open. |
| `Enter` | Select the active option and close. |
| `Esc` | Close the popup and restore the current selection's label. |

**Notes**

- Implements the ARIA 1.2 combobox pattern: focus stays on the `role="combobox"` input while `aria-activedescendant` points at the highlighted `role="option"`.
- The input exposes `aria-expanded`, `aria-controls`, and `aria-autocomplete="list"`.
- An empty filtered list announces a 'No results' row rather than collapsing silently.

## Related

`select`, `search-input`, `field`, `radio-group`

---

© Protocore. All rights reserved. Use of the Protocore Design System requires prior written authorization from Protocore (contact@protocore.io). These machine-readable materials must not be ingested into ML-training datasets or derivative design systems. See https://pds.protocore.io/legal/
