# Listbox

A list of options always in view, from which people choose one or several, with an optional filter field.

- **Import:** `import { Listbox, ListboxEmpty, ListboxGroup, ListboxItem, ListboxLabel, ListboxSeparator } from "@booleanpress/ui/listbox"`
- **APG Listbox:** <https://www.w3.org/WAI/ARIA/apg/patterns/listbox/>
- **Page:** <https://ui.booleanpress.com/components/listbox> · @booleanpress/ui 0.2.0

## Usage

`Listbox` follows the [APG listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) on plain elements: the list is one tab stop, the arrow keys move a highlight that is the list's `aria-activedescendant`, and Space or Enter chooses. It needs no peer package.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

export function RegionList() {
  return (
    <>
      <Label id="region-label">Sending region</Label>
      <Listbox aria-labelledby="region-label" defaultValue="eu-west-1">
        <ListboxItem value="eu-west-1">Europe (Ireland)</ListboxItem>
        <ListboxItem value="us-east-1">US East (N. Virginia)</ListboxItem>
      </Listbox>
    </>
  )
}
```

It is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`: a string (or `null`) for one choice, an array of strings with `multiple`. Name it with `aria-labelledby` pointing at a visible label, or `aria-label`. Chosen options fill with `--highlight`; `indicator="check"` adds a check at their start and `indicator="checkbox"` draws a checkbox on every option. `filter` adds a field above the list that narrows the options as people type; `ListboxEmpty`, a direct child of `Listbox`, then says "No results" in a polite live region after the list. An option's `textValue` is what the filter and type-ahead match when its children are not plain text. Group options with `ListboxGroup` and `ListboxLabel`. With `name`, each chosen value is submitted as a hidden input of that name, none while the list is disabled; a form reset brings back `defaultValue`. The list is as wide as its container and as tall as its options; `listClassName="max-h-64"` makes it scroll. For a choice that opens from a field, use `Select`, `MultiSelect` or `Combobox`.

## Examples

### Basic

One choice from a list always in view; the chosen option fills with `--highlight`.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const REGIONS = ["Europe (Ireland)", "Europe (Frankfurt)", "US East (N. Virginia)", "US West (Oregon)", "Asia Pacific (Mumbai)"]

export default function ListboxBasic() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-region-label">Sending region</Label>
      <Listbox aria-labelledby="listbox-region-label" defaultValue="Europe (Ireland)">
        {REGIONS.map((region) => (
          <ListboxItem key={region} value={region}>
            {region}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}
```

### Multiple

`multiple`: Space, Enter or a click toggles an option; the value is an array.

```tsx
import { useState } from "react"
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const EVENTS = ["Delivered", "Bounced", "Complained", "Opened", "Clicked"]

export default function ListboxMultiple() {
  const [events, setEvents] = useState(["Bounced", "Complained"])

  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-events-label">Webhook events</Label>
      <Listbox aria-labelledby="listbox-events-label" multiple value={events} onValueChange={setEvents}>
        {EVENTS.map((event) => (
          <ListboxItem key={event} value={event}>
            {event}
          </ListboxItem>
        ))}
      </Listbox>
      <p className="text-xs text-muted-foreground">{events.length ? `Sending ${events.join(", ").toLowerCase()}.` : "Sending nothing."}</p>
    </div>
  )
}
```

### Checkbox selection

`indicator="checkbox"` draws a checkbox on every option, ticked once chosen.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const SITES = ["shop.example.com", "blog.example.com", "docs.example.com", "status.example.com"]

export default function ListboxCheckbox() {
  return (
    <div className="flex w-full max-w-60 flex-col gap-2">
      <Label id="listbox-sites-label">Send from these sites</Label>
      <Listbox aria-labelledby="listbox-sites-label" multiple indicator="checkbox" defaultValue={["shop.example.com", "docs.example.com"]}>
        {SITES.map((site) => (
          <ListboxItem key={site} value={site}>
            {site}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}
```

### Groups

`ListboxGroup` and `ListboxLabel` name sets of options; `listClassName="max-h-64"` makes a long list scroll.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxGroup, ListboxItem, ListboxLabel } from "@booleanpress/ui/listbox"

const GROUPS = [
  { label: "API mailers", items: ["Amazon SES", "Mailgun", "Postmark"] },
  { label: "SMTP", items: ["Gmail SMTP", "Outlook SMTP", "Custom SMTP"] },
]

export default function ListboxGroups() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-groups-label">Mailer</Label>
      <Listbox aria-labelledby="listbox-groups-label" defaultValue="Postmark" listClassName="max-h-64">
        {GROUPS.map((group) => (
          <ListboxGroup key={group.label}>
            <ListboxLabel>{group.label}</ListboxLabel>
            {group.items.map((mailer) => (
              <ListboxItem key={mailer} value={mailer}>
                {mailer}
              </ListboxItem>
            ))}
          </ListboxGroup>
        ))}
      </Listbox>
    </div>
  )
}
```

### Filter

`filter` adds a field that narrows the options as you type; the arrows and Enter work from it.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxEmpty, ListboxItem } from "@booleanpress/ui/listbox"

const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "Europe/Madrid", "America/New_York", "America/Chicago", "Asia/Dhaka", "Asia/Tokyo"]

export default function ListboxFilter() {
  return (
    <div className="flex w-full max-w-60 flex-col gap-2">
      <Label id="listbox-zone-label">Report time zone</Label>
      <Listbox aria-labelledby="listbox-zone-label" filter filterPlaceholder="Search time zones" defaultValue="UTC" listClassName="max-h-48">
        {ZONES.map((zone) => (
          <ListboxItem key={zone} value={zone}>
            {zone}
          </ListboxItem>
        ))}
        <ListboxEmpty />
      </Listbox>
    </div>
  )
}
```

### Custom option

`icon` and `description` give an option an avatar and a second line; `textValue` is what type-ahead matches.

```tsx
import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const AGENTS = [
  { id: "sc", name: "Sara Chowdhury", role: "Support lead" },
  { id: "ar", name: "Arif Rahman", role: "Billing specialist" },
  { id: "jk", name: "Jonas Keller", role: "Deliverability engineer" },
]

export default function ListboxCustomOption() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label id="listbox-agent-label">Assign the ticket to</Label>
      <Listbox aria-labelledby="listbox-agent-label" defaultValue="ar">
        {AGENTS.map((agent) => (
          <ListboxItem
            key={agent.id}
            value={agent.id}
            textValue={agent.name}
            description={agent.role}
            icon={
              <Avatar>
                <AvatarFallback className="text-xs">{agent.id.toUpperCase()}</AvatarFallback>
              </Avatar>
            }
          >
            <span className="font-medium">{agent.name}</span>
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}
```

### Disabled options

A disabled option stays in view at 60%, and the highlight skips it.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const PLANS = [
  { value: "free", label: "Free", disabled: false },
  { value: "starter", label: "Starter", disabled: false },
  { value: "agency", label: "Agency (sold out)", disabled: true },
  { value: "enterprise", label: "Enterprise (contact sales)", disabled: true },
]

export default function ListboxDisabledOptions() {
  return (
    <div className="flex w-full max-w-60 flex-col gap-2">
      <Label id="listbox-plan-label">Plan</Label>
      <Listbox aria-labelledby="listbox-plan-label" defaultValue="starter">
        {PLANS.map((plan) => (
          <ListboxItem key={plan.value} value={plan.value} disabled={plan.disabled}>
            {plan.label}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}
```

### Disabled

`disabled` greys the list, keeps its value and takes it out of the tab order.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const REGIONS = ["Europe (Ireland)", "Europe (Frankfurt)", "US East (N. Virginia)"]

export default function ListboxDisabled() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-disabled-label">Sending region</Label>
      <Listbox aria-labelledby="listbox-disabled-label" disabled defaultValue="Europe (Frankfurt)">
        {REGIONS.map((region) => (
          <ListboxItem key={region} value={region}>
            {region}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}
```

### Invalid

`aria-invalid` draws the error edge; `aria-describedby` reads the message with the name.

```tsx
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const REGIONS = ["Europe (Ireland)", "Europe (Frankfurt)", "US East (N. Virginia)"]

export default function ListboxInvalid() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-invalid-label">Sending region</Label>
      <Listbox aria-labelledby="listbox-invalid-label" aria-invalid aria-describedby="listbox-invalid-error">
        {REGIONS.map((region) => (
          <ListboxItem key={region} value={region}>
            {region}
          </ListboxItem>
        ))}
      </Listbox>
      <p id="listbox-invalid-error" className="text-xs text-destructive-strong">
        Choose the region your mailer sends from.
      </p>
    </div>
  )
}
```

## Accessibility

**Semantics.** The list has `role="listbox"` (with `aria-multiselectable="true"` for `multiple`) and holds `role="option"` items, each with `aria-selected` and, when disabled, `aria-disabled`; groups are `role="group"` named by their label. With `filter`, the field is a `role="combobox"` with `aria-expanded="true"` that controls the list and carries its `aria-activedescendant`. The empty message is a `role="status"` after the list, so the listbox holds only options and groups and the message is announced.

**Labels.** Name the list with `aria-labelledby` or `aria-label`. The filter field is named "Filter options" from the provider.

**Focus.** The list (or, with `filter`, its field) is one tab stop. Focus highlights the chosen option, else the first; the highlight is shown only while the list has focus. A click chooses and leaves focus on the list.

**Known limits.**

- There is no range selection with Shift and no Ctrl+A; each option is toggled on its own.
- Options are always in the page: thousands of them belong in a virtualised `Combobox`.

### Keyboard

| Key | Behaviour |
| --- | --- |
| ↓ or ↑ | Move the highlight to the next or the previous option, stopping at the ends. |
| Home or End | On the list, move the highlight to the first or the last option. |
| Space | On the list, chooses the highlighted option; with `multiple`, toggles it. |
| Enter | Chooses the highlighted option; with `multiple`, toggles it. Works from the filter field too. |
| A–Z | On the list, type-ahead: moves to the next option whose text starts with the letters typed. In the filter field, narrows the options. |

## API

### Listbox

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `string \| string[] \| null` |  | The value it starts with, when it controls itself. |
| `disabled` | `boolean` | `false` | Makes the list read-only and greys it; it leaves the tab order. |
| `filter` | `boolean` | `false` | Shows a field above the list that filters the options as people type. |
| `filterPlaceholder` | `string` |  | The filter field's placeholder. |
| `form` | `string` |  | The id of the form the values belong to, for a list placed outside it. |
| `indicator` | `"none" \| "checkbox" \| "check"` | `none` | `none` marks chosen options with the fill only; `check` adds a check at the start; `checkbox` draws a checkbox. |
| `listClassName` | `string` |  | Classes for the scrolling list inside the frame, such as a maximum height. |
| `multiple` | `boolean` | `false` | Lets people choose several options; Space and Enter toggle the highlighted one. |
| `name` | `string` |  | Submits each chosen value as a hidden input of this name, as a native select does. |
| `onValueChange` | `((value: string \| null) => void) \| ((value: string[]) => void)` |  | Called with the new value when an option is chosen. |
| `value` | `string \| string[] \| null` |  | The chosen value, when you control it; `null` for none. Pair it with `onValueChange`. |

### ListboxEmpty

Renders a `div` and passes it every other prop.

### ListboxGroup

Renders a `div` and passes it every other prop.

### ListboxItem

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `string` |  | The value this option chooses. |
| `description` | `ReactNode` |  | A second, muted line under the label. |
| `disabled` | `boolean` | `false` | The option cannot be highlighted or chosen. |
| `icon` | `ReactNode` |  | Leading media beside both lines, such as an avatar or a flag. |
| `textValue` | `string` |  | The text matched by the filter and type-ahead, when the children are not plain text. |

### ListboxLabel

Renders a `div` and passes it every other prop.

### ListboxSeparator

Renders a `div` and passes it every other prop.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="listbox"` (Listbox), `data-slot="listbox-empty"` (ListboxEmpty), `data-slot="listbox-group"` (ListboxGroup), `data-slot="listbox-item"` (ListboxItem), `data-slot="listbox-label"` (ListboxLabel), `data-slot="listbox-separator"` (ListboxSeparator), and `data-disabled`, `data-value`, `data-text`, `data-selected`, `data-highlighted`, `data-indicator`.

**Provider strings:** `filterOptions`, `noResults` (`BooleanUIProvider`'s `strings`).

## Theming

The frame takes the field tokens: `--field`, `--control` for the edge, `--ring` while it has focus, `--invalid` when invalid and `--field-disabled` when disabled. Options use `--accent` while highlighted and `--highlight` once chosen (`--highlight-focus` both); a drawn checkbox is `--primary` once chosen.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--accent-foreground` | text |
| `--border` | background |
| `--control` | border, background |
| `--field` | background |
| `--field-disabled` | background |
| `--field-disabled-foreground` | text |
| `--foreground` | text |
| `--highlight` | background |
| `--highlight-focus` | background |
| `--highlight-foreground` | text, border |
| `--invalid` | border |
| `--muted-foreground` | text |
| `--primary` | border, background |
| `--primary-foreground` | text |
| `--ring` | border |
