# TreeMultiSelect (/docs/tree-multi-select)





`TreeMultiSelect` lets the user pick **several** values from **hierarchical** data. It pairs a pills trigger (like [`MultiSelect`](/docs/multi-select)) 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.

```tsx
import { TreeMultiSelect } from '@42-central/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`](/docs/tree-select).

## Playground [#playground]

<story.Playground />

## Data [#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.

```tsx
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' },
  ]},
];
```

Each node also accepts an optional `testId`, for a stable `data-testid` on that node's own row
regardless of its label — see the [Testing](/docs/testing) guide. It defaults to
`` `item-${value}` `` when omitted.

## Controlled [#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.

```tsx
'use client';
import { useState } from 'react';
import { TreeMultiSelect } from '@42-central/ui-react/tree-multi-select';

export function FilePicker({ tree }) {
  const [value, setValue] = useState<string[]>([]);
  return <TreeMultiSelect data={tree} value={value} onChange={setValue} />;
}
```

## Selection [#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`.

<Preview align="block" code="&#x22;<TreeMultiSelect\n  clearable\n  defaultValue={['lexer.c', 'ft_cd.c']}\n  data={tree}\n/>&#x22;">
  <TreeMultiSelect defaultValue="['lexer.c', 'ft_cd.c']" data="TREE" />
</Preview>

## Max lines [#max-lines]

`maxLines&#x60; 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.

<story.Preview variant="Max lines" />

## Start & end slots [#start--end-slots]

`startSlot` and `endSlot` place content at the trigger's inline-start / inline-end with the same slot layout as [`Input`](/docs/input). `endSlot` renders before the built-in clear button and chevron.

## Custom rendering [#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.

<story.Preview variant="Custom rendering" />

```tsx
'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 [#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.

```tsx
'use client';
import type { TreeNodeData } from '@42-central/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 [#label-description--error]

Built-in via [`Field`](/docs/field), like the rest of the family:

<Preview align="block" code="&#x22;<TreeMultiSelect\n  label=\&#x22;Files\&#x22;\n  description=\&#x22;Choose files from the tree\&#x22;\n  data={tree}\n/>&#x22;">
  <TreeMultiSelect label="Files" description="Choose files from the tree" data="TREE" />
</Preview>

## API [#api]

<TypeTable
  type="{
  data: {
    type: 'TreeNodeData[]',
    description: 'Tree of { value, label, disabled?, children? }.',
  },
  value: { type: 'string[]', description: 'Selected leaf values.' },
  defaultValue: { type: 'string[]', description: 'Uncontrolled initial selection.' },
  onChange: { type: '(value: string[]) => void', description: 'Fires with the full leaf selection.' },
  renderItem: {
    type: '(node, state) => ReactNode',
    description: 'Render each node yourself, including the checkbox. node is { value, label, disabled?, children? }; state is { checked, indeterminate, leaf, disabled, depth }. Define it in a &#x22;use client&#x22; module.',
  },
  clearable: { type: 'boolean', default: 'false', description: 'Show a button that clears the whole selection.' },
  searchable: { type: 'boolean', default: 'true', description: 'Show a search field that filters the tree.' },
  searchPlaceholder: { type: 'string', default: '&#x22;Search…&#x22;', description: 'Placeholder for the search field.' },
  empty: { type: 'ReactNode', default: '&#x22;Nothing found&#x22;', description: 'Shown when the search matches nothing.' },
  placeholder: { type: 'string', description: 'Placeholder shown while nothing is selected.' },
  maxLines: { type: 'number', description: 'Cap the pills at N rows; the rest collapse into a static &#x22;+N&#x22; counter.' },
  startSlot: { type: 'ReactNode', description: 'Inline-start adornment (icon, label).' },
  endSlot: { type: 'ReactNode', description: 'Inline-end adornment — before the clear button / chevron.' },
  size: { type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;', default: '&#x22;md&#x22;', description: 'Trigger + node sizing.' },
  variant: { type: '&#x22;default&#x22; | &#x22;unstyled&#x22;', default: '&#x22;default&#x22;', description: 'Trigger shell treatment.' },
  color: { type: 'Color', default: '&#x22;brand&#x22;', description: 'Focus-ring + checkbox accent.' },
  label: { type: 'ReactNode', description: 'Field label.' },
  description: { type: 'ReactNode', description: 'Field helper text.' },
  error: { type: 'ReactNode', description: 'Field error; sets the invalid state.' },
  required: { type: 'boolean', default: 'false', description: 'Required flag + asterisk.' },
  disabled: { type: 'boolean', default: 'false', description: 'Disable the control.' },
  translations: {
    type: '{ clearTriggerLabel?: string; searchLabel?: string; removeValue?: (label: string) => string }',
    description: &#x22;Accessible names of the clear-all button (default \&#x22;Clear all\&#x22;), the popover's search field (default \&#x22;Search\&#x22;) and each value pill's remove button (removeValue, given the value's label; default \&#x22;Remove\&#x22;) — TreeSelect's keys plus MultiSelect's removeValue. A partial override keeps the other defaults.&#x22;,
  },
  classNames: { type: '{ field?, trigger?, popover?, search?, node?, label?, checkbox?, pill? }', description: 'Per-part class overrides.' },
  testId: { type: 'string', description: 'data-testid on the trigger — the styled shell is itself the tabbable/focusable element, unlike MultiSelect. See the Testing guide.' },
}"
/>

<Alert type="info" className="my-4" description="<><code>value</code> holds <strong>leaf</strong> 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 [#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.
