# TreeSelect (/docs/tree-select)





`TreeSelect` lets the user pick **one** value from **hierarchical** data. It pairs a text trigger (like [`Select`](/docs/select)) with an always-expanded tree in a Popover: top-level rows sit flush, their descendants are connected by indent guide lines. **Only leaves are selectable** — a node with children is an organizational branch, not a value — and picking a leaf closes the popover. A search field can filter the tree.

```tsx
import { TreeSelect } from '@42-central/ui-react/tree-select';

<TreeSelect
  data={[
    { value: 'srcs', label: 'srcs', children: [
      { value: 'main.c', label: 'main.c' },
    ]},
    { value: 'Makefile', label: 'Makefile' },
  ]}
  placeholder="Pick a file"
/>
```

For **multiple** values (with cascading folder selection and pills), reach for [`TreeMultiSelect`](/docs/tree-multi-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**: it organizes the tree but cannot be selected. A node without children is a **leaf** — the only selectable kind. `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 selected **leaf** value, or `null` when nothing is picked; `onChange` fires with the value (or `null` when cleared). Picking a leaf closes the popover.

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

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

## Clearable [#clearable]

With `clearable`, the chevron becomes a clear button once a value is set.

<story.Preview variant="Clearable" />

## Searchable [#searchable]

A search field at the top of the popover filters the tree, keeping the ancestors of any match. The popover opens with focus in the field, and `↓` moves into the first leaf.

<story.Preview variant="Searchable" />

## 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 `{ selected, leaf, disabled, depth }` state. `TreeSelect` keeps the row — treeitem role, indent rails, roving focus, click — and the callback fills the content after the rails, including any selected affordance. Read `state.leaf` to tell organizational branches from selectable leaves. 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, FileCode, Folder } from 'lucide-react';

const renderNode = (node, { leaf, selected }) => (
  <span className="flex flex-1 items-center gap-2">
    {leaf ? <FileCode className="size-4" /> : <Folder className="size-4 text-(--c-solid)" />}
    <span className="flex-1 truncate text-start">{node.label}</span>
    {selected && <Check className="size-4 text-(--c-solid)" />}
  </span>
);

<TreeSelect 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-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>
);

<TreeSelect 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;<TreeSelect\n  label=\&#x22;File\&#x22;\n  description=\&#x22;Choose a file from the tree\&#x22;\n  data={tree}\n/>&#x22;">
  <TreeSelect label="File" description="Choose a file from the tree" data="TREE" />
</Preview>

## API [#api]

<TypeTable
  type="{
  data: {
    type: 'TreeNodeData[]',
    description: 'Tree of { value, label, disabled?, children? }.',
  },
  value: { type: 'string | null', description: 'Selected leaf value.' },
  defaultValue: { type: 'string | null', description: 'Uncontrolled initial value.' },
  onChange: { type: '(value: string | null) => void', description: 'Fires with the selected value, or null when cleared.' },
  renderItem: {
    type: '(node, state) => ReactNode',
    description: 'Render each node yourself. node is { value, label, disabled?, children? }; state is { selected, leaf, disabled, depth }. Define it in a &#x22;use client&#x22; module.',
  },
  clearable: { type: 'boolean', default: 'false', description: 'Swap the chevron for a clear button once a value is set.' },
  searchable: { type: 'boolean', default: 'false', 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 no value is selected.' },
  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 + selected-check 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 }',
    description: &#x22;Accessible names of the clear button (default \&#x22;Clear\&#x22;) and the popover's search field (default \&#x22;Search\&#x22;). clearTriggerLabel is the combobox family's key. A partial override keeps the other default.&#x22;,
  },
  classNames: { type: '{ field?, trigger?, popover?, search?, node?, label? }', description: 'Per-part class overrides.' },
  testId: { type: 'string', description: 'data-testid on the trigger — the styled shell is itself the tabbable/focusable element, unlike Select. See the Testing guide.' },
}"
/>

<Alert type="info" className="my-4" description="<>Branches are organizational only — they render as non-interactive rows. To pick a category itself, model it as a leaf (give it no <code>children</code>) or use <a href=&#x22;/docs/tree-multi-select&#x22;><code>TreeMultiSelect</code></a>, which selects whole branches.</>" />

## Accessibility [#accessibility]

* The tree is `role="tree"` (single-select, so no `aria-multiselectable`); each leaf is a `role="treeitem"` carrying `aria-selected` and `aria-level`, and each branch a `role="treeitem"` with `aria-expanded`. The selected leaf is marked with a trailing check.
* `↑` / `↓` move between selectable leaves (branches are skipped), `Home` / `End` jump to the first / last leaf, and `Space` / `Enter` pick the focused leaf and close. 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 leaf.
