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.
Installation
pnpm dlx @docyrus/cli add @docyrus/ui-tree-selectnpx shadcn@latest add badge button popoverOverview
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:
| Mode | Trigger | Dropdown |
|---|---|---|
multiple={false} (default) | One-line, Select-style row | TreeView without checkboxes — clicking a row picks it and closes the popover |
multiple | Wrap-flowing badges with per-badge × — same shape as Tag Select | TreeView 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"]} />| Value | Behaviour |
|---|---|
'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 › WebIn 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
| Prop | Type | Default | Description |
|---|---|---|---|
data | TreeViewItem[] | — | Tree data shown in the dropdown. Reuses the TreeView data shape. |
value | string | 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. |
multiple | boolean | false | Enable multi-select mode (badge trigger + checkbox dropdown). |
placeholder | string | 'Select...' / 'Select items...' | Placeholder shown when nothing is selected. Falls back to a localized default. |
disabled | boolean | false | Disable the trigger button. |
className | string | — | Class applied to the trigger button. |
id | string | — | Trigger button id — pair with a <label htmlFor>. |
name | string | — | Form association name. |
onBlur | () => void | — | Blur handler forwarded to the trigger. |
aria-invalid | boolean | — | Show invalid styling on the trigger. |
maxHeight | string | '320px' | Max height of the tree view inside the dropdown. |
getIcon | (item: TreeViewItem, depth: number) => ReactNode | — | Custom icon renderer — forwarded to TreeView.getIcon. |
iconMap | TreeViewIconMap | — | Map of item.type → icon — forwarded to TreeView.iconMap. |
hideInfo | boolean | true | Hide the per-row hover-card info button (forwarded as showInfo={!hideInfo} to TreeView). |
searchPlaceholder | string | — | Override the search input placeholder. Defaults to TreeView's localized default. |
leafOnly | boolean | false | Restrict 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. |
showBreadcrumb | boolean | false | Render 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. |
open | boolean | — | Controlled open state for the popover. |
onOpenChange | (open: boolean) => void | — | Fired when the popover opens or closes. |
ref | Ref<HTMLButtonElement> | — | Ref to the trigger button. |
Type Exports
| Type | Description |
|---|---|
TreeSelectProps | Props for TreeSelect. |
TreeSelectValue | string | 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 key | Maps to | Notes |
|---|---|---|
multiple | multiple | true switches to badge trigger + checkboxes. |
leafOnly | leafOnly | Restrict the value to leaf nodes. |
defaultExpanded | defaultExpanded | Accepts 'none' | 'all' | 'selected' or a string[]. Invalid values fall back to the standalone default. |
showBreadcrumb | showBreadcrumb | Render 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}
/>