TreeMultiSelect
Pick several values from hierarchical data — an always-expanded checkbox tree in a Popover, with cascading parent selection.
TreeMultiSelect lets the user pick several values from hierarchical data. It pairs a pills trigger (like MultiSelect) with an always-expanded checkbox tree in a Popover: top-level rows sit flush, their descendants are connected by indent guide lines, and checking a folder cascades to everything inside it. A search field at the top of the popover filters the tree.
import { TreeMultiSelect } from '@42/ui-react/tree-multi-select';
<TreeMultiSelect
data={[
{ value: 'srcs', label: 'srcs', children: [
{ value: 'main.c', label: 'main.c' },
]},
{ value: 'Makefile', label: 'Makefile' },
]}
placeholder="Pick files"
/>For a single-value tree picker, reach for TreeSelect.
Playground
Component
Dir
Presets
Props
Code
<Component placeholder="Pick files" clearSectionMode="replace" size="md"/>Data
data is a tree of nodes — each { value, label, disabled?, children? }. A node with children is a branch: checking it toggles every leaf beneath it, and it reads as indeterminate when only some descendants are selected. A node without children is a leaf. value must be unique across the tree.
const data = [
{ value: 'builtins', label: 'builtins', children: [
{ value: 'ft_cd', label: 'ft_cd' },
{ value: 'ft_echo', label: 'ft_echo' },
]},
{ value: 'parsing', label: 'parsing', children: [
{ value: 'lexer', label: 'lexer' },
]},
];Controlled
value is the array of selected leaf values; onChange fires with the full selection on every toggle or pill removal. Branch state is derived, so the value never contains branch nodes.
'use client';
import { useState } from 'react';
import { TreeMultiSelect } from '@42/ui-react/tree-multi-select';
export function FilePicker({ tree }) {
const [value, setValue] = useState<string[]>([]);
return <TreeMultiSelect data={tree} value={value} onChange={setValue} />;
}Selection
Checking a folder selects everything inside it; partially-filled folders show an indeterminate state. Remove a value from its pill, or clear them all with clearable.
Code
<TreeMultiSelectclearabledefaultValue={['lexer.c', 'ft_cd.c']}data={tree}/>Max lines
maxLines caps the trigger's pills at N rows; any beyond collapse into a static +N counter. The hidden values stay selected — open the tree to deselect them.
Start & end slots
startSlot and endSlot place content at the trigger's inline-start / inline-end with the same slot layout as Input. endSlot renders before the built-in clear button and chevron.
Custom rendering
Pass renderItem to fill each node's content yourself. It receives the node ({ value, label, disabled?, children? }) and its live { checked, indeterminate, leaf, disabled, depth } state. TreeMultiSelect keeps the row — treeitem role, indent rails, roving focus, click, and the cascade — and the callback fills everything after the rails, including the checkbox (the default one is part of the content it replaces). Read state.checked / state.indeterminate to draw your own tri-state box. Define the function in a "use client" module so it survives the server → client boundary.
'use client';
import { Check, Minus } from 'lucide-react';
const renderNode = (node, { checked, indeterminate }) => (
<span className="flex flex-1 items-center gap-2">
<span className="flex size-4 items-center justify-center rounded-xs border border-(--c-solid) bg-(--c-solid) text-(--c-on-solid)">
{checked && <Check className="size-3" />}
{indeterminate && <Minus className="size-3" />}
</span>
<span className="flex-1 truncate text-start">{node.label}</span>
</span>
);
<TreeMultiSelect data={tree} renderItem={renderNode} />Typed payload
Nodes can carry an arbitrary, typed payload ({ value, label, payload?, children? }), read back as node.payload in renderItem — inferred from data, and undefined on nodes that omit it.
'use client';
import type { TreeNodeData } from '@42/ui-react/tree-multi-select';
type FileMeta = { loc: number };
const tree: TreeNodeData<FileMeta>[] = [
{ value: 'srcs', label: 'srcs', children: [
{ value: 'main.c', label: 'main.c', payload: { loc: 12 } },
]},
];
// node.payload is inferred as FileMeta | undefined
const renderNode = (node) => (
<span className="flex flex-1 items-center justify-between gap-2">
{node.label}
{node.payload && <span className="text-xs">{node.payload.loc} LOC</span>}
</span>
);
<TreeMultiSelect data={tree} renderItem={renderNode} />Label, description & error
Built-in via Field, like the rest of the family:
Code
<TreeMultiSelectlabel="Files"description="Choose files from the tree"data={tree}/>API
Prop
Type
value holds leaf values only — a branch's checked / indeterminate state is derived from its descendants, so each selected leaf shows as its own pill in the trigger.
Accessibility
- The tree is
role="tree"witharia-multiselectable; each row is arole="treeitem"carryingaria-checked(true/false/mixed) andaria-level. The checkbox is presentational — selection state lives on the row. ↑/↓move between rows,Home/Endjump to the first / last, andSpace/Entertoggle the focused node (cascading to its children). There is no expand / collapse — the tree is always fully shown.- The trigger is a
role="combobox"that opens the Popover; it traps focus and dismisses on outside-click /Escape. Label / description / error are associated via the built-inField. - When
searchable, the popover opens with focus in the search field, and↓moves focus from the field into the first tree row.