Skip to the content

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 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

Keyboard
KeyBehaviour
EnterOn the field, opens the list. In the list, chooses the focused node (checks it with checkboxes).
SpaceAs 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.
EscapeCloses the list without changing the value; the focus returns to the field.
TabFrom the last stop in the list, closes it and returns the focus to the field.

API

TreeSelect

TreeSelect props
PropTypeDefaultDescription
nodesrequiredTreeNode<TData>[]The nodes of the list.
clearablebooleanfalseShows a button that empties the field while something is chosen.
defaultExpandedstring[][]The nodes open at first in the list; the branches of the chosen nodes open too whenever the list opens.
defaultValuestring[][]The nodes chosen at first, when the field controls them.
display"comma" | "chip"commaWith several nodes chosen: their labels separated by commas (default), or chips.
emptyReactNodeWhat the list shows when there are no nodes. Defaults to the provider's noResults string.
filterbooleanfalseShows a filter field at the top of the list.
filterPlaceholderstringThe filter's placeholder. Defaults to the provider's filterTree string.
fluidbooleanfalseFills 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.
loadingbooleanfalseDims the list under a spinner, or draws placeholder rows while there are no nodes.
maxSelectedLabelsnumber3The most labels or chips shown; past it the field reads "3 selected" (comma) or adds "+2 more" (chip).
namestringSubmits 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.
placeholderReactNodeThe 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"singlesingle (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.
valuestring[]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.

Theme tokens
TokenUsed for
--accent-foregroundtext
--controlborder
--control-hoverborder, text
--destructive-strongtext
--fieldbackground
--field-disabledbackground
--field-disabled-foregroundtext
--field-filledbackground
--foregroundtext
--invalidborder
--muted-foregroundtext
--ringborder, outline
--secondarybackground