# Cascade select

Lets people choose one option from a tree, one level at a time, in menus that open beside each other.

- **Import:** `import { CascadeSelect } from "@booleanpress/ui/cascade-select"`
- **Radix Dropdown Menu:** <https://www.radix-ui.com/primitives/docs/components/dropdown-menu>
- **APG Menu Button:** <https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/>
- **Page:** <https://ui.booleanpress.com/components/cascade-select> · @booleanpress/ui 0.2.0

## Usage

Give the field a visible name, a `Label` whose `htmlFor` is its `id`, and the tree as `options`. An option with `children` is a group that opens the next level; an option without is a leaf, and choosing a leaf sets the value and closes the menu.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const OFFICES: CascadeSelectOption[] = [
  {
    value: "us",
    label: "United States",
    children: [
      { value: "us-ca", label: "California", children: [{ value: "los-angeles", label: "Los Angeles" }] },
    ],
  },
]

export function OfficeField() {
  return (
    <>
      <Label htmlFor="office">Office</Label>
      <CascadeSelect id="office" options={OFFICES} placeholder="Select a city" />
    </>
  )
}
```

It is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`, which also receives the options from the top level down to the leaf. The value is a leaf's `value`, so every `value` in the tree must be unique; `""` means nothing is chosen. The field shows the leaf's label, or the whole path with `showPath` (levels joined by `separator`, " / " by default). Opening the menu again opens every level on the chosen path and puts focus on the chosen option.

For a tree too large to send at once, mark a group `hasChildren` instead of giving it `children`, and pass `loadOptions`: it runs the first time that group opens, the level says "Loading" until it resolves, and its result is kept. If it rejects, the level says so (the provider's `loadFailed`, "Could not load the options") and opening the group again tries once more. `loading` does the same for the top level. `size` is `sm`, `default` or `lg` and `variant="filled"` fills the field grey, both defaulting to the provider's `controlSize` and `fieldVariant`; `fluid` fills the container and `clearable` adds a clear button while a value is chosen. With `name`, a hidden input carries the value in a form; a disabled field is not submitted, and a form reset puts an uncontrolled field back to its `defaultValue`, as with a native select. For a flat list use a [select](/components/select).

## Examples

### Basic

Country, then state, then city: each group opens the next level beside it.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const OFFICES: CascadeSelectOption[] = [
  {
    value: "au",
    label: "Australia",
    children: [
      { value: "au-nsw", label: "New South Wales", children: [{ value: "sydney", label: "Sydney" }, { value: "newcastle", label: "Newcastle" }] },
      { value: "au-qld", label: "Queensland", children: [{ value: "brisbane", label: "Brisbane" }, { value: "townsville", label: "Townsville" }] },
    ],
  },
  {
    value: "us",
    label: "United States",
    children: [
      { value: "us-ca", label: "California", children: [{ value: "los-angeles", label: "Los Angeles" }, { value: "san-francisco", label: "San Francisco" }] },
      { value: "us-ny", label: "New York", children: [{ value: "new-york-city", label: "New York City" }, { value: "buffalo", label: "Buffalo" }] },
    ],
  },
  {
    value: "ca",
    label: "Canada",
    children: [
      { value: "ca-on", label: "Ontario", children: [{ value: "toronto", label: "Toronto" }, { value: "ottawa", label: "Ottawa" }] },
      { value: "ca-qc", label: "Quebec", children: [{ value: "montreal", label: "Montreal" }, { value: "quebec-city", label: "Quebec City" }] },
    ],
  },
]

export default function CascadeSelectBasic() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="office-city">Office</Label>
      <CascadeSelect id="office-city" options={OFFICES} placeholder="Select a city" className="w-full" />
    </div>
  )
}
```

### Show the path

`showPath` writes every level of the chosen option in the field, not only the city.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const REGIONS: CascadeSelectOption[] = [
  {
    value: "eu",
    label: "Europe",
    children: [
      { value: "eu-de", label: "Germany", children: [{ value: "frankfurt", label: "Frankfurt" }, { value: "berlin", label: "Berlin" }] },
      { value: "eu-ie", label: "Ireland", children: [{ value: "dublin", label: "Dublin" }] },
    ],
  },
  {
    value: "na",
    label: "North America",
    children: [
      { value: "na-us", label: "United States", children: [{ value: "virginia", label: "Virginia" }, { value: "oregon", label: "Oregon" }] },
      { value: "na-ca", label: "Canada", children: [{ value: "montreal", label: "Montreal" }] },
    ],
  },
]

export default function CascadeSelectShowPath() {
  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="data-region">Data region</Label>
      <CascadeSelect id="data-region" options={REGIONS} defaultValue="frankfurt" showPath className="w-full" />
    </div>
  )
}
```

### Groups with icons

An option's `icon` shows before its label, in groups and leaves alike.

```tsx
import {
  KeyRoundIcon,
  LogInIcon,
  MailCheckIcon,
  MailIcon,
  MailOpenIcon,
  MailXIcon,
  SendIcon,
  TriangleAlertIcon,
  UserIcon,
  WebhookIcon,
} from "lucide-react"
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const EVENTS: CascadeSelectOption[] = [
  {
    value: "email",
    label: "Email",
    icon: <MailIcon />,
    children: [
      { value: "email-delivered", label: "Delivered", icon: <MailCheckIcon /> },
      { value: "email-opened", label: "Opened", icon: <MailOpenIcon /> },
      { value: "email-bounced", label: "Bounced", icon: <MailXIcon /> },
    ],
  },
  {
    value: "webhook",
    label: "Webhook",
    icon: <WebhookIcon />,
    children: [
      { value: "webhook-sent", label: "Sent", icon: <SendIcon /> },
      { value: "webhook-failed", label: "Failed", icon: <TriangleAlertIcon /> },
    ],
  },
  {
    value: "account",
    label: "Account",
    icon: <UserIcon />,
    children: [
      { value: "account-sign-in", label: "Signed in", icon: <LogInIcon /> },
      { value: "account-password", label: "Password changed", icon: <KeyRoundIcon /> },
    ],
  },
]

export default function CascadeSelectGroupsWithIcons() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="alert-event">Alert on</Label>
      <CascadeSelect id="alert-event" options={EVENTS} placeholder="Choose an event" className="w-full" />
    </div>
  )
}
```

### Clear

`clearable` shows a clear button while a value is chosen; it empties the field and keeps focus on it.

```tsx
import { useState } from "react"
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const TOPICS: CascadeSelectOption[] = [
  {
    value: "billing",
    label: "Billing",
    children: [
      { value: "refund", label: "Refund" },
      { value: "invoice", label: "Invoice" },
    ],
  },
  {
    value: "delivery",
    label: "Delivery",
    children: [
      { value: "bounce", label: "Bounces" },
      { value: "spam", label: "Marked as spam" },
    ],
  },
]

export default function CascadeSelectClear() {
  const [topic, setTopic] = useState("bounce")
  const [topicLabel, setTopicLabel] = useState("Bounces")

  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="ticket-topic">Ticket topic</Label>
      <CascadeSelect
        id="ticket-topic"
        options={TOPICS}
        value={topic}
        onValueChange={(value, path) => {
          setTopic(value)
          setTopicLabel(path.at(-1)?.label ?? "")
        }}
        placeholder="Any topic"
        clearable
        className="w-full"
      />
      <p className="text-xs text-muted-foreground">{topic ? `Showing ${topicLabel.toLowerCase()} tickets.` : "Showing every ticket."}</p>
    </div>
  )
}
```

### Sizes

`sm` is 28 px high, `default` 35 px and `lg` 42 px.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"

const TEAMS: CascadeSelectOption[] = [
  {
    value: "support",
    label: "Support",
    children: [
      { value: "support-tier-1", label: "Tier 1" },
      { value: "support-tier-2", label: "Tier 2" },
    ],
  },
  {
    value: "billing",
    label: "Billing",
    children: [{ value: "billing-refunds", label: "Refunds" }],
  },
]

const SIZES = [
  { size: "sm", label: "Small" },
  { size: "default", label: "Default" },
  { size: "lg", label: "Large" },
] as const

export default function CascadeSelectSizes() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-4">
      {SIZES.map(({ size, label }) => (
        <CascadeSelect key={size} size={size} options={TEAMS} placeholder={label} aria-label={`Team, ${label.toLowerCase()} size`} className="w-full" />
      ))}
    </div>
  )
}
```

### Filled

`variant="filled"` fills the field with the grey `--field-filled`, on hover and focus too.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const TEAMS: CascadeSelectOption[] = [
  {
    value: "support",
    label: "Support",
    children: [
      { value: "support-tier-1", label: "Tier 1" },
      { value: "support-tier-2", label: "Tier 2" },
    ],
  },
  {
    value: "billing",
    label: "Billing",
    children: [{ value: "billing-refunds", label: "Refunds" }],
  },
]

export default function CascadeSelectFilled() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="assignee-team">Assign to</Label>
      <CascadeSelect id="assignee-team" variant="filled" options={TEAMS} placeholder="Choose a team" className="w-full" />
    </div>
  )
}
```

### Fluid

`fluid` makes the field as wide as its container.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const TEAMS: CascadeSelectOption[] = [
  {
    value: "support",
    label: "Support",
    children: [
      { value: "support-tier-1", label: "Tier 1" },
      { value: "support-tier-2", label: "Tier 2" },
    ],
  },
  {
    value: "billing",
    label: "Billing",
    children: [{ value: "billing-refunds", label: "Refunds" }],
  },
]

export default function CascadeSelectFluid() {
  return (
    <div className="flex w-full flex-col gap-2">
      <Label htmlFor="escalation-team">Escalation team</Label>
      <CascadeSelect id="escalation-team" options={TEAMS} placeholder="Choose a team" fluid />
    </div>
  )
}
```

### Disabled

A disabled field keeps its value and does not open; a disabled group or option stays in the menu and cannot be chosen.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const TEAMS: CascadeSelectOption[] = [
  {
    value: "support",
    label: "Support",
    children: [
      { value: "support-tier-1", label: "Tier 1" },
      { value: "support-tier-2", label: "Tier 2", disabled: true },
    ],
  },
  {
    value: "billing",
    label: "Billing",
    disabled: true,
    children: [{ value: "billing-refunds", label: "Refunds" }],
  },
]

export default function CascadeSelectDisabled() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="locked-team">Owner team</Label>
        <CascadeSelect id="locked-team" options={TEAMS} defaultValue="support-tier-1" disabled className="w-full" />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="partly-disabled-team">Backup team</Label>
        <CascadeSelect id="partly-disabled-team" options={TEAMS} placeholder="Choose a team" className="w-full" />
      </div>
    </div>
  )
}
```

### Invalid

`aria-invalid` draws the error edge and a red placeholder; `aria-describedby` reads the message with the name.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const TEAMS: CascadeSelectOption[] = [
  {
    value: "support",
    label: "Support",
    children: [
      { value: "support-tier-1", label: "Tier 1" },
      { value: "support-tier-2", label: "Tier 2" },
    ],
  },
  {
    value: "billing",
    label: "Billing",
    children: [{ value: "billing-refunds", label: "Refunds" }],
  },
]

export default function CascadeSelectInvalid() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="routing-team">Route to</Label>
      <CascadeSelect
        id="routing-team"
        options={TEAMS}
        placeholder="Choose a team"
        aria-invalid
        aria-describedby="routing-team-error"
        className="w-full"
      />
      <p id="routing-team-error" className="text-xs text-destructive-strong">
        Choose the team that receives new tickets.
      </p>
    </div>
  )
}
```

### Loading options

Groups marked `hasChildren` load their level through `loadOptions` the first time they open, after a fixed delay here.

```tsx
import { CascadeSelect, type CascadeSelectOption } from "@booleanpress/ui/cascade-select"
import { Label } from "@booleanpress/ui/label"

const ORGANISATIONS: CascadeSelectOption[] = [
  { value: "acme", label: "Acme Mail", hasChildren: true },
  { value: "northwind", label: "Northwind Traders", hasChildren: true },
]

const PROJECTS: Record<string, CascadeSelectOption[]> = {
  acme: [
    { value: "acme-newsletter", label: "Newsletter" },
    { value: "acme-receipts", label: "Receipts" },
  ],
  northwind: [{ value: "northwind-alerts", label: "Stock alerts" }],
}

// A level that loads after a fixed delay, as a request to the app's API would.
function loadProjects(option: CascadeSelectOption) {
  return new Promise<CascadeSelectOption[]>((resolve) => setTimeout(() => resolve(PROJECTS[option.value] ?? []), 800))
}

export default function CascadeSelectLoading() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="sending-project">Project</Label>
      <CascadeSelect
        id="sending-project"
        options={ORGANISATIONS}
        loadOptions={loadProjects}
        placeholder="Choose a project"
        className="w-full"
      />
    </div>
  )
}
```

## Accessibility

**Semantics.** The field is a `button` with `aria-haspopup="menu"` and `aria-expanded`, as a menu button is. Each level is a `role="menu"`; a group is a `menuitem` with `aria-haspopup="menu"` and `aria-expanded`, and a leaf is a `menuitemradio`, the chosen one with `aria-checked="true"`. A level that is loading carries `aria-busy` and a disabled "Loading" item; a level that failed to load has a disabled item that says so.

**Labels.** Name the field with a `Label htmlFor`, `aria-labelledby` or `aria-label`. A menu button would otherwise be named by its label alone; the field adds its own value to the name, so it is read as "Office, Los Angeles". The top menu is named by the same label. Put an error message in `aria-describedby`.

**Focus.** The field is in the tab order. Opening moves focus into the menu (to the first option with the keyboard), or to the chosen option when there is one, with its levels open; closing returns focus to the field. While the menu is open the page behind it cannot be reached.

**Known limits.**

- Every level is a portal, outside the field's container, so CSS that targets the container does not reach it.
- There is no search box. Typing moves to the next option of the open level whose label starts with the letters typed.
- A group cannot be chosen itself; only leaves set the value.
- A value inside a level that has not been loaded yet shows as the raw value until that level loads.
- A long label is cut short with an ellipsis, in the field and in its level; the whole label stays the option's accessible name.
- Levels open side by side and flip to the other side at the window's edge; on a narrow phone screen a deep tree may need horizontal room it does not have.
- A clearable field sits in a wrapper (`data-slot="cascade-select-control"`) with its clear button beside it, since a button cannot hold another.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter or Space or ↓ | On the field, opens the menu and moves focus to the first option, or to the chosen one. |
| ↓ or ↑ | Moves to the next or previous option of the level, wrapping at its ends. |
| → | On a group, opens its level and moves focus to its first option (← in right-to-left pages). |
| ← | Closes the current level and returns focus to its group (→ in right-to-left pages). |
| Enter or Space | On a group, opens its level. On an option, chooses it, closes the menu and returns focus to the field. |
| Home or End | Moves to the first or last option of the level. |
| A–Z | Type-ahead: moves to the next option of the level whose label starts with the letters typed. |
| Escape | Closes every level without changing the value, and returns focus to the field. |
| Enter or Space | On the clear button, empties the field and returns focus to it. |

## API

### CascadeSelect

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` (required) | `CascadeSelectOption[]` |  | The top level of the tree. |
| `clearable` | `boolean` | `false` | Shows a clear button inside the field while a value is chosen. |
| `defaultOpen` | `boolean` | `false` | Whether the menu starts open. |
| `defaultValue` | `string` |  | The value it starts with, when it controls itself. |
| `fluid` | `boolean` | `false` | Fills the width of its container. |
| `loading` | `boolean` | `false` | The top level is still loading: a spinner takes the chevron's place and the menu says so. |
| `loadOptions` | `((option: CascadeSelectOption) => Promise<CascadeSelectOption[]>)` |  | Fetches the children of a group marked `hasChildren`, the first time it opens. |
| `onOpenChange` | `((open: boolean) => void)` |  | Called when the menu opens or closes. |
| `onValueChange` | `((value: string, path: CascadeSelectOption[]) => void)` |  | Called with the new value and the options from the top level down to it; with `""` and `[]` when cleared. |
| `open` | `boolean` |  | Whether the menu is open, when you control it. Pair it with `onOpenChange`. |
| `placeholder` | `ReactNode` |  | Shown while nothing is chosen, in the muted colour. |
| `separator` | `string` | `/` | What `showPath` puts between the levels. |
| `showPath` | `boolean` | `false` | Shows every level of the chosen option ("United States / California / Los Angeles"), not only the leaf. |
| `size` | `"default" \| "sm" \| "lg"` |  | 28, 35 or 42 px tall. Defaults to the provider's `controlSize`. |
| `value` | `string` |  | The chosen leaf's value, when you control it; `""` means nothing is chosen. Pair it with `onValueChange`. |
| `variant` | `"default" \| "filled"` |  | `filled` fills the field grey. Defaults to the provider's `fieldVariant`. |

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

**Data attributes:** `data-slot="cascade-select-trigger"` (CascadeSelect), and `data-size`, `data-variant`, `data-placeholder`.

**Provider strings:** `noResults`, `loadFailed`, `loading`, `clear` (`BooleanUIProvider`'s `strings`).

## Theming

The field is the select field: `--field` (`--field-filled` with `variant="filled"`), the `--control` edge (`--control-hover` under the pointer), `--ring` on focus and while open, `--invalid` when invalid. Each level uses `--popover` and `--popover-foreground`, `--accent` for the focused option and for a group whose level is open, and `--highlight` for the chosen option (`--highlight-focus` while it has focus). Group chevrons are `--control-hover`. The levels' enter and exit motion is set once in `theme.css`.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--accent-foreground` | text |
| `--control` | border |
| `--control-hover` | text, border |
| `--destructive-strong` | text |
| `--field` | background |
| `--field-disabled` | background |
| `--field-disabled-foreground` | text |
| `--field-filled` | background |
| `--foreground` | text |
| `--highlight` | background |
| `--highlight-focus` | background |
| `--highlight-foreground` | text |
| `--invalid` | border |
| `--muted-foreground` | text |
| `--popover` | background |
| `--popover-foreground` | text |
| `--ring` | border, outline |
