# Select

A choice from a list, styled the whole way down.

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

## When to reach for it

Up to roughly a dozen options. Past that a Combobox wins, because a list nobody can filter is slower to scan than one you can type into.

## Anatomy

- **Trigger** (required) — A <button role="combobox"> on CONTROL_BASE, so it matches the Input beside it exactly. It is named by the label and by its own value together, and carries the chevron that turns over while the panel is open.
- **Value** — The chosen item’s text, or placeholder in --ink-3-aa when nothing is chosen. It truncates, and it is the half of the accessible name that says what was picked.
- **Panel** — Portalled, at least as wide as the trigger and at most 18rem tall, with a scroll chevron appearing at each end once the list is longer than that.
- **Item** — One option. The 3.5 tick box is always drawn and only the tick inside it appears, so choosing does not shunt every label sideways; data-highlighted is the fill, the tick is the chosen one.
- **Group heading** — SelectLabel — a mono eyebrow inside a SelectGroup. It is a heading, not an option, which is the difference between it and a disabled item used as a divider.

## Best practices

### Do

- Pass name when the value has to survive a submit. The trigger is a <button>; Radix renders the hidden native <select> that carries the value only when the control is inside a <form>, and only a named one sends anything.
- Pick one spelling of invalid. The trigger reads the invalid prop and aria-invalid alike, including the one a Field sets from error, so setting both is two sources of truth for one border.
- Divide long lists with SelectGroup and SelectLabel rather than a disabled item used as a heading: a disabled item is still an option, so a screen reader counts it and announces the list as one longer than it is.

### Don’t

- Inside a Field with a label, the label prop here is not announced — the field’s words name the trigger — so a label that disagrees with the one above it is dead text nobody will ever hear.
- Do not lean on the closed trigger to show a long option: it truncates to keep the field’s height, so the end of the value is only readable with the panel open.
- Do not point contentClassName at the trigger. className is the trigger; contentClassName is the panel, and confusing them is how a select gets a 18rem-wide dropdown over the thing it is choosing for.

## Accessibility

- Inside a bounded frame — a device preview, an embedded console — wrap the subtree in `<OverlayContainer container={el}>`. The panel then renders into that element and collides with its edges instead of the viewport’s, and inherits the `dir` and `data-density` set there.
- The option list is ours, so it does not change typeface, spacing and selection colour the moment it opens — which is what a native select does.
- The keyboard contract is the platform's: typeahead, arrows, Home and End, Escape to close without choosing.
- label is required, and it is announced WITH the value: the trigger reads "Region, Australia", because a value is not a name and a name without the value is not the answer. Inside a Field the FIELD’s label supplies the name half — the trigger’s aria-labelledby points at that label and at the value — and the label prop here is neither rendered nor announced, so one that disagrees with the words above it is text nobody will hear.

## Keyboard

- Enter / Space / ↓ — Opens the list.
- ↑ / ↓ — Moves between options.
- a–z — Typeahead — jumps to the next option starting with that letter.
- Home / End — Jumps to the first or last option.
- Escape — Closes without choosing.

## SelectRoot

Re-export of `SelectPrimitive.Root`.

Radix Select root, group and label, as typed passthroughs.

## SelectGroup

Re-export of `SelectPrimitive.Group`.

## Select

A choice from a list, styled the whole way down. The option list is ours — drawn from the same tokens as everything else, so it does not change typeface, spacing and selection colour the moment it opens. That is the whole reason this replaced the native control as the default: a design system whose most common form control stops being part of the system on click is not a design system, it is a stylesheet for closed states. The keyboard contract is Radix's, which means it is the platform's: typeahead works, the arrows move, Home and End reach the ends, and Escape closes without choosing. That was the one genuine argument for staying native, and it is answered. Past roughly a dozen options, reach for `Combobox` — a list nobody can filter is worse than one they can type into. Where the platform picker is genuinely better — a phone, or a form that must survive without JavaScript — reach for `NativeSelect`.

### Props

- `contentClassName` — `string`. Classes for the option PANEL, not the trigger. `className` styles the trigger, which is the common case. This exists for the uncommon one: a select inside a bounded surface, where the default 18rem of list would cover the thing the reader is choosing for — a year picker over its own calendar, say.
- `label` (required) — `string`. Names the control. Required — the trigger shows a value, and a value is not a name. It is announced together with the value, not instead of it: the trigger is named by the label and by its own text, so a reader hears "Region, Australia". Inside a `Field` with a label, that label is used and this one is not repeated.
- `placeholder` — `string` default `'Select…'`.
- `invalid` — `boolean`. Paints the resting border with `--danger` and reflects `aria-invalid`.
- `disabled` — `boolean`.
- `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'`. The spelling a form library sets; read together with `invalid`.
- `aria-required` — `boolean`. Announced on the trigger. A `Field` sets it from `required`.
- `children` (required) — `ReactNode`. `SelectItem`s, optionally wrapped in `SelectGroup` with a `SelectLabel`.

Also accepts: `ComponentProps<typeof SelectPrimitive.Root>`.

## SelectItem

One option. The tick marks the chosen one; the fill marks the highlighted one.

Also accepts: `ComponentProps<typeof SelectPrimitive.Item>`.

## SelectLabel

Mono eyebrow heading for a group of options. Must sit inside a `SelectGroup` — Radix throws otherwise, because a heading with no group is a heading for nothing, and assistive tech would announce it as an option.

## SelectSeparator

Hairline divider between groups.

## Example — default

```tsx
import { Field, Select, SelectGroup, SelectItem, SelectLabel, SelectSeparator } from '@misoto22/folio'

<Field label="Region" hint="The list is ours, not the platform’s — open it." className="w-full max-w-xs">
  <Select label="Region" defaultValue="au">
    <SelectGroup>
      <SelectLabel>Oceania</SelectLabel>
      <SelectItem value="au">Australia</SelectItem>
      <SelectItem value="nz">New Zealand</SelectItem>
    </SelectGroup>
    <SelectSeparator />
    <SelectGroup>
      <SelectLabel>Asia</SelectLabel>
      <SelectItem value="jp">Japan</SelectItem>
      <SelectItem value="sg">Singapore</SelectItem>
      <SelectItem value="cn">China</SelectItem>
    </SelectGroup>
  </Select>
</Field>
```

## Example — invalid

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

<Field label="Plan" required error="Choose a plan before inviting your team." className="w-full max-w-xs">
  <Select label="Plan" invalid placeholder="Select a plan">
    <SelectItem value="solo">Solo — one seat</SelectItem>
    <SelectItem value="studio">Studio — ten seats</SelectItem>
    <SelectItem value="agency">Agency — unlimited seats</SelectItem>
  </Select>
</Field>
```

## Example — inside a form

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

<form
  className="flex w-full max-w-xs flex-col gap-4"
  onSubmit={(event) => {
    event.preventDefault()
    setSent(String(new FormData(event.currentTarget).get('currency') ?? 'nothing'))
  }}
>
  <Field label="Currency" className="w-full">
    <Select label="Currency" name="currency" defaultValue="aud">
      <SelectItem value="aud">Australian dollar</SelectItem>
      <SelectItem value="jpy">Japanese yen</SelectItem>
      <SelectItem value="usd">United States dollar</SelectItem>
    </Select>
  </Field>
  <Button type="submit" variant="secondary" className="self-start">
    Save
  </Button>
  {sent !== undefined && (
    <Text size="sm" tone="muted">
      The form sent currency = {sent}
    </Text>
  )}
</form>
```
