Components

Tree Select

A select dropdown backed by a tree. Single mode renders like Select; multi mode renders like Tag Select. Hierarchical search, expand-all and parent-cascade checking come from the embedded Tree View.

Client Only

Installation

pnpm dlx @docyrus/cli add @docyrus/ui-tree-select
UI Primitives(3 components)
npx shadcn@latest add badge button popover

Overview

TreeSelect is a Popover-anchored picker whose dropdown body is the TreeView component. Pass multiple to switch between two trigger shapes that mirror the existing form-field family:

ModeTriggerDropdown
multiple={false} (default)One-line, Select-style rowTreeView without checkboxes — clicking a row picks it and closes the popover
multipleWrap-flowing badges with per-badge × — same shape as Tag SelectTreeView with checkboxes — ticking a parent cascades to its descendants

The dropdown's search input, expand/collapse-all menu and keyboard support come straight from TreeView, so the same TreeViewItem[] you'd pass to a plain tree works here without any reshaping.

Usage

import { TreeSelect } from "@docyrus/ui/components/tree-select";
import { type TreeViewItem } from "@docyrus/ui/components/tree-view";

const data: TreeViewItem[] = [
  {
    id: "engineering",
    name: "Engineering",
    type: "group",
    children: [
      { id: "frontend", name: "Frontend", type: "team" },
      { id: "backend", name: "Backend", type: "team" }
    ]
  },
  { id: "product", name: "Product", type: "group" }
];

export function Example() {
  const [value, setValue] = useState<string | null>(null);

  return (
    <TreeSelect
      data={data}
      value={value}
      onValueChange={(next) => setValue(typeof next === "string" ? next : null)}
      placeholder="Select a department..."
    />
  );
}

Multi-select

Set multiple and treat value as an array of ids. The trigger renders one removable badge per selection.

const [value, setValue] = useState<string[]>([]);

<TreeSelect
  data={data}
  multiple
  value={value}
  onValueChange={(next) => setValue(Array.isArray(next) ? next : [])}
/>

Leaf-only selection

Set leafOnly to restrict the value to leaf nodes only:

  • Single mode — clicking a folder row expands / collapses it instead of committing a value. Only leaf rows commit.
  • Multi mode — checking a folder cascades to its leaf descendants only; the folder id itself never enters value. Clicking a folder row (outside the checkbox) also expands / collapses it.

This relies on TreeView's new expandFolderOnRowClick flag, which TreeSelect enables automatically when leafOnly is on.

The default (leafOnly={false}) treats folders as selectable values and makes a checked folder add the folder id and every descendant id.

<TreeSelect
  data={data}
  multiple
  leafOnly
  value={value}
  onValueChange={(next) => setValue(Array.isArray(next) ? next : [])}
/>

Default expansion

defaultExpanded controls which folders are open each time the dropdown mounts. The default — 'selected' — auto-expands the ancestor chain of every currently-selected item so the value is always visible on reopen.

// Always show the full tree.
<TreeSelect data={data} defaultExpanded="all" />

// Start fully collapsed.
<TreeSelect data={data} defaultExpanded="none" />

// Open exactly these folders.
<TreeSelect data={data} defaultExpanded={["engineering", "engineering.frontend"]} />
ValueBehaviour
'selected' (default)Expand ancestors of items in value. No-op when value is empty.
'all'Expand every folder.
'none'Start fully collapsed.
string[]Expand exactly these ids.

The popover unmounts its content on close, so the strategy is re-applied every time the dropdown opens — 'selected' always reflects the current value, not the initial one.

Trigger breadcrumb

Set showBreadcrumb to render the ancestor path on the trigger for each selected item. Ancestor names render in muted text; the leaf name uses the foreground color. renderSelected still wins when both are supplied.

<TreeSelect
  data={data}
  showBreadcrumb
  value={value}
  onValueChange={(next) => setValue(typeof next === "string" ? next : null)}
/>
// Trigger shows:  Engineering › Frontend › Web

In multi mode the breadcrumb is repeated per badge, so each chip carries its own ancestor chain.

Custom row icons

Forward getIcon (and / or iconMap) directly to the underlying TreeView. The same (item, depth) => ReactNode signature applies.

import { Building2, Folder, Briefcase } from "lucide-react";

const iconMap = {
  group: <Building2 className="size-4 text-amber-500" />,
  team: <Folder className="size-4 text-blue-500" />,
  leaf: <Briefcase className="size-4 text-muted-foreground" />
};

<TreeSelect data={data} iconMap={iconMap} />

Custom trigger badges

renderSelected controls how each selected item appears inside the trigger. Use it to inject icons, color swatches, or any custom shape — the per-badge remove button (×) is added automatically in multi mode.

<TreeSelect
  data={data}
  multiple
  renderSelected={(item) => (
    <span className="flex items-center gap-1">
      <Briefcase className="size-3.5" />
      <span className="truncate">{item.name}</span>
    </span>
  )}
/>

Controlled open state

TreeSelect is uncontrolled by default — pass open + onOpenChange to take over the popover state (useful when driving the picker from a toolbar button or external keyboard shortcut).

const [open, setOpen] = useState(false);

<TreeSelect
  data={data}
  open={open}
  onOpenChange={setOpen}
/>

API Reference

PropTypeDefaultDescription
dataTreeViewItem[]—Tree data shown in the dropdown. Reuses the TreeView data shape.
valuestring | string[] | null—Currently selected id(s). String in single mode, array in multi mode.
onValueChange(value: string | string[] | null) => void—Fired when the selection changes. Receives string | null in single mode, string[] in multi mode.
multiplebooleanfalseEnable multi-select mode (badge trigger + checkbox dropdown).
placeholderstring'Select...' / 'Select items...'Placeholder shown when nothing is selected. Falls back to a localized default.
disabledbooleanfalseDisable the trigger button.
classNamestring—Class applied to the trigger button.
idstring—Trigger button id — pair with a <label htmlFor>.
namestring—Form association name.
onBlur() => void—Blur handler forwarded to the trigger.
aria-invalidboolean—Show invalid styling on the trigger.
maxHeightstring'320px'Max height of the tree view inside the dropdown.
getIcon(item: TreeViewItem, depth: number) => ReactNode—Custom icon renderer — forwarded to TreeView.getIcon.
iconMapTreeViewIconMap—Map of item.type → icon — forwarded to TreeView.iconMap.
hideInfobooleantrueHide the per-row hover-card info button (forwarded as showInfo={!hideInfo} to TreeView).
searchPlaceholderstring—Override the search input placeholder. Defaults to TreeView's localized default.
leafOnlybooleanfalseRestrict selection to leaf nodes. In single mode, folder clicks only expand / collapse. In multi mode, a parent's checkbox cascades to descendant leaves only.
defaultExpanded'none' | 'all' | 'selected' | string[]'selected'Initial expansion strategy applied each time the dropdown opens. 'selected' expands ancestors of items in value.
showBreadcrumbbooleanfalseRender an ancestor breadcrumb (Group › Subgroup › Item) inside the trigger for each selected item.
renderSelected(item: TreeViewItem) => ReactNode—Custom rendering for a selected item inside the trigger. Wins over showBreadcrumb when both are supplied.
openboolean—Controlled open state for the popover.
onOpenChange(open: boolean) => void—Fired when the popover opens or closes.
refRef<HTMLButtonElement>—Ref to the trigger button.

Type Exports

TypeDescription
TreeSelectPropsProps for TreeSelect.
TreeSelectValuestring | string[] | null | undefined — value shape across both modes.
TreeSelectDefaultExpanded'none' | 'all' | 'selected' | ReadonlyArray<string> — initial expansion strategies for the dropdown.

Form-field wrapper

TreeSelectFormField (exported from @docyrus/ui/components/form-fields) bridges TreeSelect to TanStack Form. It converts flat enumOptions with parent references into a tree using field.nestedByProp (default 'parent'), then reads the following keys from field.options to drive behaviour:

field.options keyMaps toNotes
multiplemultipletrue switches to badge trigger + checkboxes.
leafOnlyleafOnlyRestrict the value to leaf nodes.
defaultExpandeddefaultExpandedAccepts 'none' | 'all' | 'selected' or a string[]. Invalid values fall back to the standalone default.
showBreadcrumbshowBreadcrumbRender an ancestor breadcrumb on the trigger.
import { TreeSelectFormField } from "@docyrus/ui/components/form-fields";

<TreeSelectFormField
  field={{
    id: "category",
    slug: "category",
    name: "Category",
    type: "field-tagSelect",
    nested: true,
    nestedByProp: "parent",
    options: {
      multiple: true,
      leafOnly: true,
      defaultExpanded: "all",
      showBreadcrumb: true
    }
  }}
  form={form}
  enumOptions={CATEGORY_OPTIONS}
/>

On this page