TreeView
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/tree-view.jsonA tree is one control with a cursor in it, not a nest of lists with click handlers. That difference is the whole component, and it shows in two places that are invisible until someone uses a keyboard.
One tab stop, not one per node. A tree of four hundred files with a
tabIndex on each is four hundred stops between the sidebar and the editor.
The container is what the keyboard reaches, and the arrows move a cursor inside
it — the arrangement RadioGroup has, for the
same reason.
Right and Left do different things depending on where you are:
| Key | On a closed folder | On an open folder | On a leaf |
|---|---|---|---|
→ |
opens it | steps into the first child | nothing |
← |
jumps to the parent | closes it | jumps to the parent |
The parent jump is the part hand-rolled trees leave out, and it is the one that matters most: without it the only way back out of a deep folder is through every sibling below it.
<TreeView nodes={nodes} selected={openFile} onSelect={setOpenFile} label="Files"/>The cursor and the selection are different things. The cursor is where the keyboard is; the selection is the file being edited. A reader walks the tree with arrows without opening anything until Enter — so the cursor starts on the selected row rather than at the top, which is halfway up a long sidebar.
An empty folder is a folder. children: [] is not enough to say so —
absence of children is what a leaf looks like — so an empty folder is marked
with empty, opens, and shows nothing.
The nodes are given, not discovered. A tree that loads its children when a folder opens is a different component with a different problem: a spinner per node, a request per open, an error state inside a row.
The sums live next door, in
tree-rows, with no React in them — the same
split table-sort is. visibleRows is also the
answer to “how many rows is this tree”, which is what a product needs to put a
tree inside a VirtualList.
Props
Read from the types in ui/tree-view.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.
| Prop | Type | Default | |
|---|---|---|---|
nodesrequired | readonly TreeNode[] | The tree, as nested nodes. Only the folders in open show their children. | |
open | ReadonlySet<string> | Which folders are open. Uncontrolled if omitted. | |
onOpenChange | (open: Set<string>) => void | Told the new set of open folders whenever one opens or shuts, whether the tree is controlled or not. | |
selected | string | Which row is chosen - the file being edited, not the one the cursor is on. The two move apart: a reader walks the tree with arrows without opening anything until Enter. | |
onSelect | (id: string) => void | Called with the id of a leaf when it is chosen by click, Enter or Space. A folder toggles instead. | |
labelrequired | string | What a screen reader calls the tree. Required: a bare tree announces "tree" and nothing else, and the word belongs to the product. | |
className | string |
Accessibility
The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 20 rules applied and passed, and none failed. The accessibility report has every item.
Left to a person, where axe could not decide: color-contrast, duplicate-id-aria.
History
Each release that changed ui/tree-view.tsx - the file a product copies - newest first.