# Tree select

A field that opens a tree of nested options to choose one, several, or whole checked branches.

- **Import:** `import { TreeSelect } from "@booleanpress/ui/tree-select"`
- **Radix Popover:** <https://www.radix-ui.com/primitives/docs/components/popover>
- **APG Combobox:** <https://www.w3.org/WAI/ARIA/apg/patterns/combobox/>
- **Page:** <https://ui.booleanpress.com/components/tree-select> · @booleanpress/ui 0.2.0

## Usage

Pass the options as tree nodes (`{ id, label, children? }`, the shape `Tree` takes) and name the field with a `Label htmlFor` and an `id`.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"

const categories = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  { id: "account", label: "Account" },
]

export function Category() {
  return (
    <>
      <Label htmlFor="category">Category</Label>
      <TreeSelect id="category" nodes={categories} placeholder="Choose a category" />
    </>
  )
}
```

The value is an array of node ids: uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`. `selectionMode` is `single` (the default: choosing a node closes the list), `multiple` or `checkbox` (checking a parent checks its branch; the field shows the branch by its top node). With several chosen, the field lists their labels, or the provider's `selectedCount` ("3 selected") past `maxSelectedLabels`; `display="chip"` shows chips instead, with "+2 more" past the limit.

The list opens with the branches of the chosen nodes open. `filter` adds a filter field at its top, `loadChildren` loads children the first time a node opens, `loading` and `empty` set those states, and `renderLabel` draws the options, all as `Tree` does. The field takes `size` (`sm`, `default`, `lg`), `variant="filled"`, `fluid`, `clearable`, `disabled` and `aria-invalid`; `name` submits each chosen id in a hidden input; a disabled field is not submitted, and a form reset puts an uncontrolled field back to its `defaultValue`, as with a native select.

## Examples

### Basic

One category from a tree; choosing a node, parent or child, closes the list.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  {
    id: "delivery",
    label: "Email delivery",
    children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }, { id: "spam", label: "Spam folder" }],
  },
  { id: "account", label: "Account", children: [{ id: "login", label: "Signing in" }, { id: "team", label: "Team members" }] },
]

export default function TreeSelectBasic() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="ticket-category">Category</Label>
      <TreeSelect id="ticket-category" nodes={CATEGORIES} placeholder="Choose a category" fluid />
    </div>
  )
}
```

### Multiple

`selectionMode="multiple"`: the list stays open while nodes are added and taken away; the field lists their labels.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  {
    id: "delivery",
    label: "Email delivery",
    children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }, { id: "spam", label: "Spam folder" }],
  },
  { id: "account", label: "Account", children: [{ id: "login", label: "Signing in" }, { id: "team", label: "Team members" }] },
]

export default function TreeSelectMultiple() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="agent-categories">Categories this agent answers</Label>
      <TreeSelect
        id="agent-categories"
        nodes={CATEGORIES}
        selectionMode="multiple"
        defaultValue={["refunds", "bounces"]}
        placeholder="Choose categories"
        fluid
      />
    </div>
  )
}
```

### Checkbox

`selectionMode="checkbox"`: checking a parent checks its branch, which the field shows by its top node.

```tsx
import { useState } from "react"
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const EVENTS: TreeNode[] = [
  {
    id: "delivery",
    label: "Delivery",
    children: [{ id: "sent", label: "Sent" }, { id: "delivered", label: "Delivered" }, { id: "bounced", label: "Bounced" }],
  },
  { id: "engagement", label: "Engagement", children: [{ id: "opened", label: "Opened" }, { id: "clicked", label: "Clicked" }] },
  { id: "complaints", label: "Spam complaints" },
]

export default function TreeSelectCheckbox() {
  const [events, setEvents] = useState(["delivery", "sent", "delivered", "bounced"])

  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="webhook-events">Webhook events</Label>
      <TreeSelect id="webhook-events" nodes={EVENTS} selectionMode="checkbox" value={events} onValueChange={setEvents} fluid />
      <p className="text-sm text-muted-foreground">{events.length} events checked.</p>
    </div>
  )
}
```

### Chips display

`display="chip"` shows each choice as a chip; past `maxSelectedLabels` a last chip counts the rest.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const REGIONS: TreeNode[] = [
  { id: "europe", label: "Europe", children: [{ id: "de", label: "Germany" }, { id: "fr", label: "France" }, { id: "nl", label: "Netherlands" }] },
  { id: "americas", label: "Americas", children: [{ id: "us", label: "United States" }, { id: "br", label: "Brazil" }] },
  { id: "asia", label: "Asia", children: [{ id: "jp", label: "Japan" }, { id: "in", label: "India" }] },
]

export default function TreeSelectChips() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-80">
      <Label htmlFor="send-regions">Send from servers in</Label>
      <TreeSelect
        id="send-regions"
        nodes={REGIONS}
        selectionMode="multiple"
        display="chip"
        maxSelectedLabels={2}
        defaultValue={["de", "fr", "us"]}
        placeholder="Choose countries"
        fluid
      />
    </div>
  )
}
```

### Filter

`filter` puts a field at the top of the list that keeps the matching nodes and their ancestors.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const PAGES: TreeNode[] = [
  {
    id: "shop",
    label: "Shop",
    children: [
      { id: "cart", label: "Cart" },
      { id: "checkout", label: "Checkout", children: [{ id: "payment", label: "Payment" }, { id: "thank-you", label: "Thank you" }] },
    ],
  },
  { id: "account", label: "My account", children: [{ id: "orders", label: "Orders" }, { id: "addresses", label: "Addresses" }] },
  { id: "contact", label: "Contact us" },
]

export default function TreeSelectFilter() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="form-page">Show the form on</Label>
      <TreeSelect id="form-page" nodes={PAGES} filter filterPlaceholder="Search pages" placeholder="Choose a page" fluid />
    </div>
  )
}
```

### Clear

`clearable` shows a button that empties the field while something is chosen.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  { id: "delivery", label: "Email delivery", children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }] },
]

export default function TreeSelectClear() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="filter-category">Filter by category</Label>
      <TreeSelect id="filter-category" nodes={CATEGORIES} defaultValue={["bounces"]} placeholder="Any category" clearable fluid />
    </div>
  )
}
```

### Sizes

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

```tsx
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  { id: "delivery", label: "Email delivery", children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }] },
]

export default function TreeSelectSizes() {
  return (
    <div className="flex w-full flex-col items-center gap-3 md:w-64">
      <TreeSelect aria-label="Category, small" size="sm" nodes={CATEGORIES} placeholder="Small" fluid />
      <TreeSelect aria-label="Category, default" nodes={CATEGORIES} placeholder="Default" fluid />
      <TreeSelect aria-label="Category, large" size="lg" nodes={CATEGORIES} placeholder="Large" fluid />
    </div>
  )
}
```

### Filled

`variant="filled"` fills the field with `--field-filled`.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  { id: "delivery", label: "Email delivery", children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }] },
]

export default function TreeSelectFilled() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="filled-category">Category</Label>
      <TreeSelect id="filled-category" variant="filled" nodes={CATEGORIES} placeholder="Choose a category" fluid />
    </div>
  )
}
```

### Fluid

`fluid` fills the width of the container.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  { id: "delivery", label: "Email delivery", children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }] },
]

export default function TreeSelectFluid() {
  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <Label htmlFor="fluid-category">Category</Label>
      <TreeSelect id="fluid-category" nodes={CATEGORIES} placeholder="Choose a category" fluid />
    </div>
  )
}
```

### Disabled

A disabled field keeps its value, cannot open and leaves the tab order.

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  { id: "delivery", label: "Email delivery", children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }] },
]

export default function TreeSelectDisabled() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="locked-category">Category</Label>
      <TreeSelect id="locked-category" nodes={CATEGORIES} defaultValue={["refunds"]} disabled fluid />
    </div>
  )
}
```

### Invalid

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

```tsx
import { Label } from "@booleanpress/ui/label"
import { TreeSelect } from "@booleanpress/ui/tree-select"
import type { TreeNode } from "@booleanpress/ui/tree"

const CATEGORIES: TreeNode[] = [
  { id: "billing", label: "Billing", children: [{ id: "invoices", label: "Invoices" }, { id: "refunds", label: "Refunds" }] },
  { id: "delivery", label: "Email delivery", children: [{ id: "smtp", label: "SMTP errors" }, { id: "bounces", label: "Bounces" }] },
]

export default function TreeSelectInvalid() {
  return (
    <div className="flex w-full flex-col gap-2 md:w-64">
      <Label htmlFor="required-category">Category</Label>
      <TreeSelect
        id="required-category"
        nodes={CATEGORIES}
        placeholder="Choose a category"
        aria-invalid
        aria-describedby="required-category-error"
        fluid
      />
      <p id="required-category-error" className="text-sm text-destructive-strong">
        Choose a category so the ticket reaches the right team.
      </p>
    </div>
  )
}
```

## Accessibility

**Semantics.** The field is a `button` with `role="combobox"`, `aria-expanded` and, while open, `aria-controls`. The list is a `Tree` (`role="tree"`) in a popover, named by the field's label, and `aria-haspopup="tree"`; with `filter`, the popover is a `role="dialog"` named the same way, holding the filter and the tree, and `aria-haspopup="dialog"`.

**Labels.** Name the field with a `Label htmlFor` (give it an `id`), `aria-label` or `aria-labelledby`; the open list takes the same name. The clear button is named by the provider's `clear` string, the filter by `filterTree`.

**Focus.** Opening moves the focus into the list: the filter when there is one, else the chosen node or the first. Escape, a single choice, or Tab past the list closes it and the focus returns to the field.

**Known limits.**

- Chips are a summary, not controls: take a choice away in the list, or empty the field with `clearable`.
- The list's keys are the tree's: see the Tree page for the full set.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter | On the field, opens the list. In the list, chooses the focused node (checks it with checkboxes). |
| Space | As Enter. |
| ↓ | On the closed field, opens the list. In the list, moves to the next node; from the filter, moves into the tree. |
| ↑ | On the closed field, opens the list. In the list, moves to the previous node. |
| → | In the list, opens a closed node or moves to its first child. Left Arrow in a right-to-left page. |
| ← | In the list, closes an open node or moves to its parent. Right Arrow in a right-to-left page. |
| Escape | Closes the list without changing the value; the focus returns to the field. |
| Tab | From the last stop in the list, closes it and returns the focus to the field. |

## API

### TreeSelect

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nodes` (required) | `TreeNode<TData>[]` |  | The nodes of the list. |
| `clearable` | `boolean` | `false` | Shows a button that empties the field while something is chosen. |
| `defaultExpanded` | `string[]` | `[]` | The nodes open at first in the list; the branches of the chosen nodes open too whenever the list opens. |
| `defaultValue` | `string[]` | `[]` | The nodes chosen at first, when the field controls them. |
| `display` | `"comma" \| "chip"` | `comma` | With several nodes chosen: their labels separated by commas (default), or chips. |
| `empty` | `ReactNode` |  | What the list shows when there are no nodes. Defaults to the provider's `noResults` string. |
| `filter` | `boolean` | `false` | Shows a filter field at the top of the list. |
| `filterPlaceholder` | `string` |  | The filter's placeholder. Defaults to the provider's `filterTree` string. |
| `fluid` | `boolean` | `false` | Fills the width of its container. |
| `loadChildren` | `((node: TreeNode<TData>) => Promise<TreeNode<TData>[]>)` |  | Loads the children of a node the first time it opens, as `Tree` does. |
| `loading` | `boolean` | `false` | Dims the list under a spinner, or draws placeholder rows while there are no nodes. |
| `maxSelectedLabels` | `number` | `3` | The most labels or chips shown; past it the field reads "3 selected" (`comma`) or adds "+2 more" (`chip`). |
| `name` | `string` |  | Submits each chosen id under this name, in a hidden input. |
| `onValueChange` | `((value: string[]) => void)` |  | Called with the chosen nodes' ids. With checkboxes it lists every checked node. |
| `placeholder` | `ReactNode` |  | The text shown while nothing is chosen. |
| `renderLabel` | `((node: TreeNode<TData>, state: TreeNodeState) => ReactNode)` |  | Draws a node's label in the list, as `Tree` does. |
| `selectionMode` | `"checkbox" \| "single" \| "multiple"` | `single` | `single` (default) chooses one node and closes; `multiple` and `checkbox` keep the list open. |
| `size` | `"default" \| "sm" \| "lg"` |  | 28, 35 or 42 px tall. Defaults to the provider's `controlSize`. |
| `value` | `string[]` |  | The chosen nodes' ids, when you control them. |
| `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="tree-select-trigger"` (TreeSelect), and `data-size`, `data-variant`, `data-placeholder`.

**Provider strings:** `selectedCount`, `moreSelected`, `clear` (`BooleanUIProvider`'s `strings`).

## Theming

The field is the select field: `--field` (`--field-filled` filled), a `--control` edge that turns `--control-hover` under the pointer and `--ring` while focused or open, `--invalid` when invalid, `--field-disabled` disabled. Chips are `--secondary` with `--accent-foreground` text. The list is the tree on the `--popover` surface.

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