Tree Select
A tree in a popover with real tri-state parents. Checking a branch checks its subtree, the value holds only leaves, and searching keeps the ancestors of every match.
src/components/ui/tree-select.tsxAbout
From the component's own source, where it was written.
A tree in a popover, with checkboxes and real tri-state parents.
**The tri-state is the whole problem.** A parent whose children are partly chosen is neither checked nor unchecked, and rendering it as unchecked is a lie people act on — they tick it, and silently replace a careful selection with everything. The indeterminate state is set through the DOM property, because indeterminate is not an HTML attribute and cannot be expressed in JSX; aria-checked="mixed" carries the same fact to assistive technology.
**Checking a branch checks its subtree, and value holds only the leaves.** Storing branch ids as well means every consumer has to know whether a parent in the list implies its children, and two consumers will answer differently. Leaves are unambiguous, and parent state is derived on render.
Searching filters to matching nodes **and keeps their ancestors**, because a result with its path cut off is unplaceable — you cannot tell which "General" you found.
Install
npx astralyx-ui add tree-select
Usage
import { TreeSelect } from '@/components/ui/tree-select'
<TreeSelect nodes={scopes} value={selected} onChange={setSelected} />Composer
Change the props and the source below follows.
<TreeSelect
nodes={scopes}
value={selected}
onChange={setSelected}
searchable={true}
/>Examples
Token scopes
<TreeSelect nodes={scopes} value={selected} onChange={setSelected} />API
Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.
TreeSelect
Extends Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'>.
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | TreeSelectNode[] | required | The value holds leaves only — a parent in the list would leave every consumer guessing whether it implies its children. |
| value | string[] | — | The value holds leaves only — a parent in the list would leave every consumer guessing whether it implies its children. |
| defaultValue | string[] | [] | — |
| onChange | (value: string[]) => void | — | — |
| placeholder | string | 'Select…' | — |
| searchable | boolean | true | — |
| searchPlaceholder | string | 'Search' | — |
| defaultExpanded | string[] | [] | Values of branches open on first render. |
| size | 'sm' | 'md' | 'lg' | 'md' | — |
| summary | (values: string[]) => ReactNode | — | Summarises the selection on the trigger. |
| emptyLabel | string | 'Nothing matches.' | — |
| disabled | boolean | — | — |
| invalid | boolean | — | — |
| label | string | — | — |
Types
TreeSelectNode
A tree in a popover, with checkboxes and real tri-state parents.
| Field | Type | Description |
|---|---|---|
| value | string | — |
| label | ReactNode | — |
| children? | TreeSelectNode[] | — |
| disabled? | boolean | — |
Behaviour
| Topic | Detail | Description |
|---|---|---|
| tri-state | the whole problem | A partly-chosen parent is neither checked nor unchecked. Rendering it as unchecked is a lie people act on: they tick it and silently replace a careful selection with everything. |
| indeterminate | a property | Not an HTML attribute, so it cannot be expressed in JSX — it is set through a ref callback, with aria-checked="mixed" carrying the same fact. |
| search | keeps ancestors | A result with its path cut off is unplaceable: you cannot tell which "General" you found. Matching branches are force-opened. |