Components / structure
Tree
Its items carry a branch/leaf marker. Treegrid's rows did not until the upstream port found it, which is the kind of asymmetry only a second implementation of the same idea surfaces.
Summary
A hierarchy the reader expands and selects from — a file listing, a section outline.
| APG pattern | Tree View |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
<Tree label="Sections" defaultExpanded={['work']} selectedId={selected} onSelect={setSelected} nodes={…} />
Anatomy
A data-se="tree" list where each treeitem may hold a nested group.
| Part | Element | Required |
|---|---|---|
group | <ul> | no |
icon | <span> | no |
item | <div> | yes |
twisty | <svg> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
nodes | TreeNode[] | required | The hierarchy, nested by children. A node with no children is a leaf, which is what the arrow keys treat differently. |
label | string | required | Names the tree. |
defaultExpanded | string[] | none | Which branches start open, by id. Everything else starts closed. |
selectedId | string | none | The selected node. Selection and focus are separate here — arrowing moves focus without selecting. |
onSelect | (id: string) => void | none | Called with the id of the node the reader selected. |
States
| State | Expressed as | |
|---|---|---|
| Focused | :focus-visible | |
| Hover | :hover | |
| Expanded | aria-expanded | |
| Selected | aria-selected |
Behavior
The keyboard model is the pattern, and it is asymmetric on purpose. Right opens a closed branch but steps into an already-open one; Left closes an open branch but steps out of a leaf. That is what makes a deep tree navigable without a pointer, and it is the part most implementations get wrong.
Type-ahead moves to the next node starting with the letter. * opens every sibling branch at the same level.
Accessibility
One tab stop for the whole tree, with roving focus inside it.
No aria-level, aria-posinset or aria-setsize. The APG requires them only when the DOM does not already carry the set, and this anatomy nests ul[role="group"] inside the li it belongs to — so the structure says it and the attributes would be a second copy to keep in sync. A Treegrid is a flat table, so there they are required, and there they are emitted.
Keyboard
| Key | Result |
|---|---|
Arrow Down / Arrow Up | Move to the next or previous visible node |
Arrow Right | Opens a closed branch; steps into an already-open one |
Arrow Left | Closes an open branch; steps out of a leaf |
Home / End | Jump to the first or last visible node |
Enter | Selects the focused node |
* | Opens every sibling branch at the same level |
| a letter | Type-ahead |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-expanded | set at render |
aria-hidden | always true |
aria-label | set at render |
aria-selected | set at render |
role | always treeitem, always group, always tree |
Focus
One tab stop with roving focus inside. The asymmetry in the arrow keys is the pattern, and it is the part most implementations get wrong.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/batch-b.test.jsxtest/apg/batch-e.test.jsxtest/apg/partials-listbox.test.jsxtest/browser/icon-box.spec.mjstest/browser/keyboard.spec.mjstest/browser/site.spec.mjstest/browser/upstream-composites.spec.mjs
Known gaps
- 7 upstream refs — APG's own page or markup, not the pattern
- 4 upstream refs — a navigation treeview; the rest is their page chrome
- 1 upstream ref — APG requires level/posinset/setsize only when the DOM does not already carry the set. Ours nests ul[role="group"] inside the li it belongs to, so the structure says it and the attributes would be a second copy to keep in sync.
- 2 upstream refs — see treeitem-aria-level
Appearance
| Token | Used for |
|---|---|
--se-control-h-sm | min-block-size |
--se-duration-fast | transition |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-width | outline, outline-offset |
--se-icon-sm | block-size, font-size, inline-size |
--se-radius-sm | border-radius |
--se-space-2 | gap |
--se-space-3 | padding-inline-end, padding-inline-start |
--se-space-4 | padding-inline-start |
--se-surface-selected | background |
--se-surface-sunk | background |
--se-target-min | min-block-size |
--se-text | color |
--se-text-muted | color |
--se-text-sm | font-size |
--se-weight-medium | font-weight |
Motion
None. A branch that animates open delays a reader who is navigating by keyboard at speed.
Content
Node labels are names, not paths. The hierarchy is drawn; repeating it in the text doubles the reading.
Usage
Only if your domain is hierarchical. The keyboard model is expensive to learn and expensive to keep right, and a two-level tree is a list with headings.
When not to use it. If the rows carry columns of data — that is a Treegrid.
Notes
Its items carry a branch/leaf marker. Treegrid's rows did not until the upstream port found it, which is the kind of asymmetry only a second implementation of the same idea surfaces.