Navigation
TreeView
A hierarchy with roving focus, expansion state and selection.
Examples
Selection, and what it reports
One tree, one selection. The callback carries the node as well as the id, so a caller never has to search its own data to find out what was clicked.
Selected id
rates
Click a row, or tab into the tree once and walk it with the arrow keys — the selection follows the focus by default.
const [selected, setSelected] = useState<string | null>("rates");
<TreeView
nodes={tree}
label="Data catalogue"
defaultExpandedIds={["markets", "crypto"]}
selectedId={selected}
onSelect={setSelected}
/>Selection does not follow focus
With `selectionFollowsFocus={false}` the arrows browse and only Enter or Space commits — the shape a picker inside a panel wants, where arrowing past a row must not change the pane beside it.
<TreeView
nodes={tree}
label="Datasets"
defaultExpandedIds={["research"]}
selectionFollowsFocus={false}
indent={20}
/>Expansion the caller owns
`expandedIds` makes the open set the caller's, which is what a saved view or a remembered position needs. `treeBranchIds` is the helper for an expand-all control.
1 branches open
const [expanded, setExpanded] = useState(["markets"]);
<Button onClick={() => setExpanded(treeBranchIds(tree))}>Expand all</Button>
<Button onClick={() => setExpanded([])}>Collapse all</Button>
<TreeView
nodes={tree}
label="Data catalogue"
expandedIds={expanded}
onExpandedChange={setExpanded}
/>Long labels, and nodes that are not available
A long label truncates rather than pushing its metadata out of the row. A `disabled` node stays visible and announced — the arrow keys step over it — because a branch the reader is told about and cannot see is worse than one they can see and cannot open.
<TreeView
nodes={[
{
id: "portfolios",
label: "Portfolios",
meta: "4",
children: [
{ id: "momentum", label: "Momentum and trend following across global futures", meta: "live" },
{
id: "carry",
label: "Carry",
meta: "12",
children: [
{ id: "fx", label: "FX carry", meta: "8" },
{ id: "rates", label: "Rates carry", meta: "4", disabled: true },
],
},
],
},
{ id: "archive-2019", label: "Archive 2019", disabled: true },
]}
label="Portfolios"
defaultExpandedIds={["portfolios", "carry"]}
/>Usage
import { TreeView } from "@luc/ui";<TreeView
nodes={tree}
label="Workspace"
defaultExpandedIds={["markets"]}
defaultSelectedId="rates"
/>`nodes` is the hierarchy, `expandedIds`/`selectedId` make either side controlled; `label` is the tree's accessible name and the pattern requires one.
API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
Three symbols: the component and the two helpers a caller needs to drive it from outside — `findTreeNode`, to turn a selected id back into its node, and `treeBranchIds`, for an expand-all control of your own.
| Export | Kind | Signature |
|---|---|---|
findTreeNode | export | (nodes: readonly TreeNode[], id: string) => TreeNode | undefined |
treeBranchIds | export | (nodes: readonly TreeNode[]) => string[] |
TreeView | 10 documented props | ({ nodes, expandedIds, defaultExpandedIds, onExpandedChange, selectedId, defaultSelectedId, onSelect, selectionFollowsFocus, label, indent, className, ...props }: TreeViewProps) => import("@types/rea… |
TreeView props
Type: TreeViewProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
defaultExpandedIds? | readonly string[] | undefined | — | |
defaultSelectedId? | string | null | undefined | — | |
expandedIds? | readonly string[] | undefined | — | Controlled set of open ids. |
indent? | number | undefined | — | Indentation per level, in pixels. Default 16. |
label? | string | undefined | — | Accessible name for the tree. Required — a tree with no name is a puzzle. |
nodes | readonly TreeNode[] | — | |
onExpandedChange? | ((ids: string[]) => void) | undefined | — | |
onSelect? | ((id: string, node: TreeNode) => void) | undefined | — | |
selectedId? | string | null | undefined | — | Controlled selection. `null` means "nothing is selected". |
selectionFollowsFocus? | boolean | undefined | — | Move the selection with the arrow keys, not only the focus. Default `true`. |
Guidelines
Do
- Name the tree with `label`: it is the only thing that tells one tree from another, and an unnamed `role="tree"` is a puzzle to a screen reader.
- Keep `selectionFollowsFocus` on unless selecting *is* an edit — it is what makes the arrow keys feel like a list rather than a corridor.
- Use `disabled` for a branch that exists but is unavailable: the row stays visible and announced, and the keyboard steps over it.
Don't
- Do not build a menu out of a tree: two levels of navigation is a sidebar, and a chooser over 200 flat options is a `Combobox`.
- Do not expand the whole hierarchy by default — `treeBranchIds` is there for an explicit expand-all control, and a tree that opens itself loses the reader's place on every visit.
- Do not nest more than a few levels: depth without search is a filing cabinet, and a reader cannot see where they are in one.
Accessibility
The tree follows the ARIA `tree`/`treeitem` pattern rather than approximating it, and the consequence a reader has to know is that the whole control is **one Tab stop**: `tabindex` roves with the focused row, so Tab leaves the tree rather than walking out of it row by row. `aria-level`, `aria-setsize` and `aria-posinset` state the shape, so a screen reader announces "3 of 8, level 2" instead of an indentation nobody can see; a collapsed branch leaves the keyboard order entirely; and a `disabled` node is still rendered and announced, but the arrow keys step over it.
Move along the rows that are visible.
Open a closed branch, then step into its first child.
Close an open branch, then step out to its parent.
Jump to the first or the last visible row.
Select the focused row.
Open every closed sibling of the focused row.
Related