|Kit

TreeSelect

Pick a single value from hierarchical data — an always-expanded tree in a Popover where only leaves are selectable.

TreeSelect lets the user pick one value from hierarchical data. It pairs a text trigger (like 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.

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.

Playground

Component

Loading preview

Props

Code

<Component
  placeholder="Pick a file"
  clearSectionMode="replace"
  searchPlaceholder="Search…"
  size="md"
/>

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.

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 guide. It defaults to `item-${value}` when omitted.

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.

'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

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

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.

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 { 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.

'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

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-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

Built-in via Field, like the rest of the family:

Choose a file from the tree

Code

<TreeSelect
  label="File"
  description="Choose a file from the tree"
  data={tree}
/>

API

Prop

Type

Branches are organizational only — they render as non-interactive rows. To pick a category itself, model it as a leaf (give it no children) or use TreeMultiSelect, which selects whole branches.

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.

Changelog

v0.19.0
  • A translations prop for the built-in trigger, clear, remove, search, increment/decrement and upload labels

    #331
  • The trigger is named by the Field label, announces the description or error, and its aria-controls points at the tree

    #325
  • Tree rows and Notification's action, close and collapse buttons show the kit's opaque 2px focus ring instead of a faint translucent one

    #326
v0.5.0
  • value and onChange narrow to the literal values of as const data, like Select

    #71
v0.3.1
  • Borders and dividers on floating panels match the panel's own surface

    #37
v0.3.0
  • nothingFound → empty across the pickers and combobox lists

    #34
  • Show the focus ring on shells that are themselves focusable: TreeSelect, TreeMultiSelect and PinInput

    #35
v0.0.1
  • The picker family: Select, MultiSelect, Autocomplete, Combobox, TreeSelect and TreeMultiSelect

  • renderItem across the picker family, with a typed item payload

  • clearSectionMode picks whether the clear button replaces the chevron or sits beside it

  • Pickers open on a tap anywhere in the field, and their clear button is keyboard-reachable

On this page