|Kit

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

Loading preview

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:

Choose files from the tree

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" with aria-multiselectable; each row is a role="treeitem" carrying aria-checked (true / false / mixed) and aria-level. The checkbox is presentational — selection state lives on the row.
  • / move between rows, Home / End jump to the first / last, and Space / Enter toggle 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-in Field.
  • When searchable, the popover opens with focus in the search field, and moves focus from the field into the first tree row.

On this page