|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/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"  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' },
  ]},
];

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

<TreeSelectlabel="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.

On this page