import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as ComboboxStories from "./Combobox.stories";

<Meta of={ComboboxStories} />

# Combobox

The searchable, filtering, virtualized picker — the other half of the picker family, and
the one that consolidates the fleet's hand-rolled dropdowns
([decision](../../../docs/decisions/2026-08-03-listbox-and-combobox-are-siblings-of-select.md)).
Use it when the list is long enough to search or filter; reach for
[`Listbox`](?path=/docs/components-listbox--docs) for a short rich-option list, and the
native [`Select`](?path=/docs/components-select--docs) for a plain one.

<Canvas of={ComboboxStories.Playground} />

## Props

<Controls />

## The ARIA is forced, not stylistic

Unlike `Listbox`, **focus stays in the text input**, so the active option is tracked with
`aria-activedescendant`, not roving tabindex. The input is the `role="combobox"`
(`aria-autocomplete="list"`, `aria-expanded`, `aria-controls`); the popup is a
`role="listbox"`. Arrows move the active descendant while the caret stays put; Enter
selects; Escape clears the query before it closes. The options are `<div>`-shaped
`role="option"`s with **zero** tab stops — a combobox popup legitimately has none.

## Filtering is the consumer's, or the component's

Omit `onQueryChange` and the list filters internally by `textValue`. Supply it — a
react-query search, say — and you own the query: the `options` then arrive pre-filtered,
and the debounce, the request token, and the per-parent cache stay yours, exactly the way
`PathPicker` already splits async work.

```tsx
<Combobox
  isVisible={isVisible}
  onDismiss={hide}
  onQueryChange={setQuery}   // omit to filter internally
  onSelect={choose}
  options={results}          // pre-filtered when you own the query
  trigger={<Button onClick={toggle}>Search</Button>}
/>
```

## Loading, error, empty, footer, creatable, multiple

`isLoading` and `error` are panel states (only `PathPicker` had all of them); an empty
result shows `emptyLabel` and stays open. `footer` is a sticky hint (`LinkPicker`'s "type a
path directly"). `isCreatable` lets Enter commit the raw query (`AssFieldPicker`), and Tab
accepts the active suggestion (`PathPicker`). `isMultiple` renders the chosen values as
removable chips rather than filling the input, and keeps the popup open.

<Canvas of={ComboboxStories.AllVariants} />

<Canvas of={ComboboxStories.AllStates} />

## Attached input & drill-down

Supply `inputRef` and Combobox stops rendering its own input: it anchors a **list-only**
popup to the consumer's `<input>`, mirrors the combobox ARIA (`role`, `aria-controls`,
`aria-activedescendant`) onto it, and drives arrows/Enter/Tab/Escape off it while focus
stays put. Use it when the field is **both the value and the query** — a path field whose
text you type *and* filter by, the shape `PathPicker` needs.

The consumer owns the input's `value`/`onChange` (wire them to the controlled `query`,
which is then required) **and** `isVisible`. Because of that, **a select does not
auto-dismiss** in this mode: `onSelect` fires and the consumer decides what happens next —
append the folder and leave `isVisible` on to drill deeper (the popup re-queries from the
new path and stays open), or call its own `close()` to finish. Enter and Tab both accept
the active option; Escape closes outright. Single-select only (`isMultiple` is classic-mode
only, and it stays the only mode that keeps the popup open on a normal select).

```tsx
<input ref={inputRef} value={path} onChange={(e) => setPath(e.target.value)} />
<Combobox
  inputRef={inputRef}
  isVisible={isVisible}
  query={path}
  options={folders}                 // pre-filtered by the trailing segment
  onSelect={(name) => setPath(`${parentPath}${name}/`)}  // drills; stays open
  onDismiss={() => setIsVisible(false)}
/>
```

<Canvas of={ComboboxStories.AttachedInputDrillDown} />

## Virtualization, and why `aria-setsize` is not optional

Above ~100 options (or with `isVirtualized`), the list is windowed with
`@tanstack/react-virtual` — the second runtime dependency this package has ever taken (MIT,
US-origin, ~4 KB gz). Because most of the list is not in the DOM, every windowed option
**must** carry `aria-setsize` and `aria-posinset`, or a screen reader announces "2 of 12"
for a 4,000-item list. Below the threshold the list is plain DOM.

<Canvas of={ComboboxStories.Virtualized} />

<Canvas of={ComboboxStories.Interactive} />

## One panel, one row size

`itemSize` drives the search field **and** the options together, from the same
density-aware `--control-height-*` tokens a `Button` reads. It defaults to `md`, so at the
default a trigger, the search field and every option are all 2.25rem.

Covering the search row is the point rather than a bonus. The report that started this was
that a `Combobox`'s trigger was bigger than the options under it — but the panel's own
field was bigger than them too, and **two sizes inside one panel** is the defect rather
than either number being wrong.

It also retires a hand-picked number. The search row used to carry `px-3` to line its caret
up with the option labels below it, which worked only while the option padding was a
hardcoded `px-2`; it is now wrapped in the list's own `p-1` and given the same row-size
class, so the two cannot drift apart again.

`lg` is the fat one — 2.75rem, the 44px WCAG 2.5.5 target. A window shorter than 40rem
steps each size down one step on its own.

The virtualizer's `estimateSize` tracks `itemSize` too. Every row is re-measured
immediately, so the estimate only decides how far the scrollbar lies before the first
measurement lands — but a `lg` list opening with a scrollbar a third too short and visibly
settling is exactly the kind of thing nobody files a bug about.

<Canvas of={ComboboxStories.ItemSizes} />
