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

import * as ListboxStories from "./Listbox.stories";

<Meta of={ListboxStories} />

# Listbox

The single-select **fancy** dropdown — the rich-option sibling of the native
[`Select`](?path=/docs/components-select--docs), not a replacement for it. `Select` keeps
type-ahead, Home/End, the mobile wheel picker, form submission, `:invalid` and autofill;
`Listbox` is for the cases `<option>` cannot render — a leading icon, two lines, a trailing
badge ([decision](../../../docs/decisions/2026-08-03-listbox-and-combobox-are-siblings-of-select.md)).

<Canvas of={ListboxStories.Playground} />

## Props

<Controls />

## A listbox is not a menu

An `option` **is** something you are choosing; a `menuitem` **does** something. So a
`Listbox` carries `aria-selected`, it type-aheads (the APG requires it of a listbox and
`Menu` deliberately omits it), and a screen reader announces "listbox, selected, 2 of 4".
That is the distinction `Menu` documents from the other side.

```tsx
const { hide, isVisible, toggle } = useVisibility()
const [value, setValue] = useState<string>()

<Listbox
  isVisible={isVisible}
  onDismiss={hide}
  onSelect={setValue}
  options={LANGUAGES}
  selectedValue={value}
  trigger={<Button onClick={toggle}>{label}</Button>}
/>
```

## Two kinds of state, composed

`useRovingFocus` owns which option is tabbable — exactly one, so Tab enters and leaves the
group once and the arrow keys move within it — and `useSinglePicker` owns which option is
chosen. They are the same two kinds `Tabs` composes, and they never disturb each other:
arrowing moves focus without selecting, Enter selects the focused option. A disabled option
joins the choice but not the focus group, so the arrow keys and type-ahead skip it.

<Canvas of={ListboxStories.AllStates} />

## Rich options, and `textValue`

The options take a `ReactNode` label — two lines, a flag, an intent-coloured confidence
badge. When the label is rich, `textValue` is required: it is the type-ahead target and the
name a screen reader reads, so a picture of a value is still a value you can find by typing.

<Canvas of={ListboxStories.AllVariants} />

## Portalled, and a plain button opens it

The panel portals to `document.body`. The trigger stays a native **button** that gains
`aria-haspopup="listbox"`, `aria-expanded`, and `aria-controls` — the button-opens-listbox
pattern, not a `combobox` (that is `Combobox`, where focus stays in a text input). So there
is no `heading` prop; the button names the widget. The panel height is clamped to the
viewport by floating-ui's `size` middleware and scrolls within it.

<Canvas of={ListboxStories.Interactive} />

## An option is the size of the control that opened it

`itemSize` takes the same `sm` / `md` / `lg` a `Button` takes, from the same
density-aware `--control-height-*` tokens. It **defaults to `md`** — 2.25rem, the height a
default `Button` and a `Select` already take — so a picker's options measure the same as
its trigger.

That default is a fix, not a preference. The rows were a hardcoded `px-2 py-1.5 text-sm`
that no control in the library shares, so an option was visibly the smaller thing inside
its own panel; the owner's report was *"the button is huge, but the options don't match
it"*.

`lg` is the fat one: 2.75rem, the 44px WCAG 2.5.5 target, for a list that is aimed at
rather than scanned. `sm` is what this component rendered before, kept for a genuinely
dense list.

The floor is a **minimum**, not a fixed height (`min-h-`, where a `Button` uses `h-`) — the
rich option below, with a flag and a trailing `Badge`, still grows past it.

A window shorter than 40rem steps each size down one step on its own, and one shorter than
30rem takes them to `sm`.

<Canvas of={ListboxStories.ItemSizes} />
