ComponentsForm
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"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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
buttonwithrole="combobox",aria-expandedand, while open,aria-controls. The list is aTree(role="tree") in a popover, named by the field's label, andaria-haspopup="tree"; withfilter, the popover is arole="dialog"named the same way, holding the filter and the tree, andaria-haspopup="dialog". - Labels
- Name the field with a
Label htmlFor(give it anid),aria-labeloraria-labelledby; the open list takes the same name. The clear button is named by the provider'sclearstring, the filter byfilterTree. - 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.
- Chips are a summary, not controls: take a choice away in the list, or empty the field with
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 |
|---|---|---|---|
nodesrequired | 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.
The tokens its classes read. Change their values in your theme, and it follows.
| 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 |