Tree
The tree control of a desktop app on the web: nodes in levels, one selected, branches expanded or collapsed, the keyboard of a desktop tree. A nested list of <details> without JavaScript; the script makes it a WAI-ARIA tree.
Demo
Window "Stock"
Menu bar
- File
- Edit
- HelpA link: Enter follows it.
Toolbar
- NewButton, Ctrl+N.
- SaveButton, Ctrl+S.
- SearchText field.
Content pane
Form
- Name
- Amount
- Notes
Button row
- OKPrimary button.
- Cancel
- Status bar
Property editor
Change a value and apply it; the demo renders with the new values. A refused value is reported at its field and the demo keeps the starting value.
Properties
| Property | Type | Default | Allowed values | Description |
|---|---|---|---|---|
id | string | required | The tree's id; the nodes' element ids are <id>-<n>, their labels <id>-<n>-label. | |
nodes | array | required | The nodes: id, text, children per node; optional href (a link), lang, description (one short sentence), lazy (the children come later through dv-tree:load), expanded (renders open). An empty or duplicate id is refused. | |
label | string | none | The accessible name of the tree, a text; or labelledby. One of the two is required. | |
labelledby | string | none | The id of a heading that names the tree, instead of label. | |
selected | string | none | window, menubar, menu-file, menu-edit, menu-help, toolbar, new, save, search, content, form, field-name, field-amount, field-notes, actions, ok, cancel, statusbar | The id of the selected node; its ancestors render open. In this demo one of the demo's node ids. |
expanded | array | none | Ids of nodes that render open besides the selected node's ancestors. | |
selectionFollowsFocus | bool | true | True: the arrows select, as a desktop tree. False: the arrows move focus only; Space, Enter or a click selects (for an inspector whose selection is costly). | |
src | string | none | A URL template with {id}; the script fetches a lazy node's children there as JSON when no listener answers dv-tree:load. | |
density | string | comfortable | comfortable, compact | comfortable: rows of 44 CSS pixels; compact: lower rows and a smaller indent. |
lang | string | none | The language of all the nodes' text when it differs from the page's. | |
dir | string | none | ltr, rtl | rtl or ltr when the tree's direction differs from the page's; Right and Left swap in a right-to-left tree. |
strings | array | required | The interface strings of the page's language: tree_expand, tree_collapse, tree_loading, tree_loaded, tree_failed. |
Events
| Event | Detail | Description |
|---|---|---|
dv-tree:select | { id, text, level, path, trigger } | The selection moved to this node. trigger is keyboard, pointer or api. |
dv-tree:expand | { id, text, level, path, trigger } | This branch opened, also when an ancestor opened for a selection. |
dv-tree:collapse | { id, text, level, path, trigger } | This branch closed. |
dv-tree:activate | { id, text, level, path, trigger } | Enter, a double click or a click on its link. Cancelable: preventDefault() keeps the link from being followed. |
dv-tree:load | { id, text, level, path, trigger, load(nodes) } | A lazy node opens for the first time; call load() with an array of nodes or a promise of one. Without an answer the children come from src, else the node collapses again and the status line says so. |
Keyboard
| Keys | Does |
|---|---|
| Arrow Down, Arrow Up | The next, the previous visible node. |
| Arrow Right | A collapsed branch: expand; an expanded branch: its first child; a leaf: nothing. |
| Arrow Left | An expanded branch: collapse; else the parent, without collapsing it. |
| Home, End (also with Ctrl) | The first, the last visible node. |
| * | Expands every sibling of the focused node. |
| Enter | Activates the node (dv-tree:activate), then follows its link when it has one. |
| Space | Selects the focused node. |
| Letters | Type-ahead: letters typed within a second form a prefix; one letter repeated moves to the next visible node starting with it, wrapping round. A branch never opens on a keystroke. |
| Tab | Enters and leaves the tree once: one node holds the tabindex, the selected one, else the first. |
Accessibility
- Without script a nested list with a <details> per branch and the selected label aria-current; no tree role, no state and no tabindex are claimed.
- With script: roles tree, treeitem and group; aria-expanded on branches, aria-selected on the selected node, aria-level, aria-setsize and aria-posinset; a roving tabindex.
- A node is named by its own label (aria-labelledby), never by its whole branch; a description is an sr-only sibling through aria-describedby.
- On a coarse pointer the chevron is a named button (tree_expand, tree_collapse) for touch screen readers; elsewhere it is hidden from assistive technology.
- The only live region is the status line of lazy loading, rendered by the server when the tree can load. Forced colours use the system highlight; motion follows --motion-duration; logical properties mirror a right-to-left tree.
Rationale
The web counterpart of MiniProv's MpTreeView, with the same words (node, id, text, children; select, expand, collapse, focus) so a desktop tool's inspector reads on the web as it does on the desktop. The server markup claims no tree role and no state it cannot honour: a collapsed branch is a <details> that opens without the script, a link stays a link, the selected node's label carries aria-current. The script upgrades to the tree pattern with a roving tabindex and the desktop keys; the tree role never switches off for touch, because a device with touch and a keyboard keeps the keyboard model. A node is named by its own text, never by its descendants.
References
- MpTreeView
- UiTreeNode
- UiTreeNodes.Validate
- IUiLiveTreeWindow
- REG-P1-023
- WAI-ARIA APG tree view
Uses
This component uses no other component.
Used by
No usages recorded yet.
README
tree
The kit's tree control, the web counterpart of MiniProv's MpTreeView (docs/architecture/concepts-and-terms.md):
nodes in levels, one selected, branches expanded or collapsed, the keyboard of a desktop tree. The HTML is a nested
list that works without JavaScript: a branch is a <details> whose <summary> is the node's row, so a collapsed
branch opens without the script; a leaf is a plain row; a node with an href is a link. tree.js turns the list
into the WAI-ARIA tree pattern (single select) and upgrades, never enables: the server markup claims no tree role,
no state and no tabindex. Made for the coming web inspector of a desktop app, whose navigation it will be.
Helper
dvlop_tree(array $props): string, pure, everything escaped, registers tree with core/assets.php.
| Prop | Meaning |
|---|---|
id |
the tree's id; the nodes' element ids are <id>-<n>, their labels <id>-<n>-label (required) |
nodes |
the nodes, see below |
label or labelledby |
the accessible name of the tree: a text, or the id of a heading (one is required) |
selected |
the id of the selected node; its ancestors render open |
expanded |
ids of nodes that render open besides those |
selectionFollowsFocus |
true (default): the arrows select, as a desktop tree; false: the arrows move focus only and Space, Enter or a click selects (for an inspector whose selection is costly) |
src |
a URL template with {id}; the script fetches the children of a lazy node there as JSON when no listener answers dv-tree:load |
density |
comfortable (default) or compact |
lang |
the language of all the nodes' text when it differs from the page's |
dir |
rtl or ltr when the tree's direction differs from the page's |
strings |
the interface strings of the page's language: tree_expand, tree_collapse, tree_loading, tree_loaded, tree_failed (required) |
The helper throws an InvalidArgumentException for a node without an id, an id two nodes share (as
UiTreeNodes.Validate does), a node without text, a selected id that is not in the tree, a tree without a name or
without the strings, and a density that is not one.
The node
['id' => ..., 'text' => ..., 'children' => [...]], MiniProv's UiTreeNode(Id, Text, Children). id is the
node's identity: unique in the whole tree and stable across refreshes (a later live tree reconciles by it); text is
what the node says and is its accessible name, never its descendants'. Optional: href (the node is a link; Enter and
a click follow it), lang (the text is in another language than the page; written on the label), description (one
short sentence, aria-describedby), lazy (the children come later through dv-tree:load; a node with children
given is never lazy), expanded (renders open).
Initial expansion, as MpTreeView: everything collapsed except the ancestors of the selected node and the nodes
given expanded. Selecting a node in code expands its ancestors.
Markup
div.dv-tree[#id][lang][dir][data-selection=manual][data-src][data-expand ...]
ul.dv-tree-group.dv-tree-root[aria-label | aria-labelledby] (role=tree with the script)
li.dv-tree-item[#id-n][data-id][data-lazy] (role=treeitem, aria-expanded/selected/level/setsize/posinset, tabindex)
details.dv-tree-branch[open] > summary.dv-tree-row (a branch; the script makes it div.dv-tree-row + ul.dv-tree-group[role=group])
div.dv-tree-row (a leaf or a lazy node)
span.dv-tree-toggle (the chevron's column; a button on a branch with the script)
span|a.dv-tree-label[#id-n-label][href][lang] (the text; aria-labelledby of the item points here)
span.sr-only[#id-n-description] (the description, a sibling)
div.dv-tree-status[role=status][aria-live=polite] (only with a lazy node or a src)
The chevrons and the guide lines are drawn by the stylesheet and never announced. The server marks the selected
node's label with aria-current="true" (a meaning the list has without the script); the script replaces it by
aria-selected on the item. The name of a treeitem is its own label through aria-labelledby, which holds in
Chrome, Firefox and Safari; a plain name from content would include the whole branch.
Keys (tree.js, one home for every tree)
| Key | Does |
|---|---|
| Down, Up | the next, the previous visible node |
| Right | a collapsed branch: expand; an expanded branch: its first child; a leaf: nothing |
| Left | an expanded branch: collapse; else: the parent, without collapsing it (the inspiration page collapses it, a fault to avoid) |
| Home, End, also with Ctrl | the first, the last visible node |
* |
expands every sibling of the focused node |
| Enter | activates the node (dv-tree:activate, then its link when it has one) |
| Space | selects the focused node |
| letters | type-ahead: characters typed within a second of each other are a prefix; one letter, or the same letter repeated, moves to the next visible node whose text starts with it (wrapping round); a longer prefix keeps the node when it still matches, else moves to the next match; case-insensitive on text; only visible nodes, a branch never opens on a keystroke; no match: stay, say nothing |
Right and Left swap in a right-to-left tree (dir="rtl" on the tree or an ancestor). Selection follows focus by
default; with selectionFollowsFocus => false the arrows, Home, End and type-ahead move focus only. Exactly one node
has tabindex="0": the selected one, else the first, else the one the keyboard was last on; collapsing a branch that
holds it moves it to the branch (and the selection too when it follows focus, as a desktop tree does).
Pointer: the chevron toggles (its column is one indent wide and a row tall, at least 24 by 24 CSS pixels; comfortable
rows are 44 pixels); a click on the row selects and focuses; a double click activates; a click on a link follows it
(after dv-tree:activate, which can cancel it).
Touch screen readers (VoiceOver on iOS, TalkBack) cannot send arrow keys. On a coarse pointer ((pointer: coarse),
re-evaluated when it changes) the chevron is a named button (tree_expand, tree_collapse) they can double-tap, and
a double tap on the row selects. Elsewhere the chevron is hidden from assistive technology and the treeitem's own
state says expanded or collapsed. The tree role never switches off, unlike the inspiration page: a device with touch
and a keyboard keeps the keyboard model, and a mode guessed from ontouchstart is wrong on such a device.
Announcements come from the native tree semantics (name, level, position, expanded, selected). The only live region
is the status line of lazy loading: tree_loading, tree_loaded with the count, tree_failed.
Events
CustomEvents dispatched on the .dv-tree element, bubbling. Detail, the same shape for every event:
{ id, text, level, path, trigger }
id the node's id
text its text
level 1 for a top node
path the ids from the top down to this node, the node's own last
trigger 'keyboard' | 'pointer' | 'api'
| Event | When |
|---|---|
dv-tree:select |
the selection moved to this node |
dv-tree:expand |
this branch opened (also when an ancestor opened for a selection) |
dv-tree:collapse |
this branch closed |
dv-tree:activate |
Enter, a double click, or a click on its link; cancelable: preventDefault() keeps the link from being followed |
dv-tree:load |
a lazy node opens for the first time; the detail also holds load(nodes): call it during the event with an array of nodes or a promise of one. When nobody calls it and the tree has src, the children are fetched from src with {id} replaced (JSON: an array of nodes). The item is aria-busy meanwhile; a failure collapses it again and the status line says so |
Instance API
element.dvTree on the .dv-tree element, the words of IUiLiveTreeWindow:
| Member | Does |
|---|---|
selectedId |
the id of the selected node, or null |
expandedIds |
the ids of the expanded nodes |
select(id) |
selects the node, expands its ancestors, moves the tabindex to it (and the focus, when the keyboard is on the tree); true when the id exists |
expand(id), collapse(id) |
opens or closes a branch (a lazy one loads); true when the id exists |
focus() |
puts the keyboard on the tree, on the node that holds the tabindex |
Events from the API carry trigger: 'api'. A later setNodes(nodes) (the live tree of UiLiveTreeWindow) fits
beside these; docs/architecture/STATUS-treeview.md describes it.
Style
tree.css, tokens only: --tree-row-height, --tree-indent, --tree-gap (each with a -compact value, read by
the compact variant), --tree-guide, --tree-hover-background and --tree-selected-background, which derive from
the page's colour tokens, so a site colours the tree through its own token overrides and dark token values give a
dark tree. A derived token is computed where it is declared (:root): a site that sets its colours in :root needs
nothing more; a wrapper that changes the colours for one part of a page states the three derived tree tokens again
(the demo's dark wrapper does). The focused row uses the focus tokens; selected and focused is distinct from selected without focus (the
tint and the accent bar). forced-colors: active uses system colours (the selected row Highlight on
HighlightText, the guides GrayText), --motion-duration is 0s under prefers-reduced-motion, and every
property is logical so dir="rtl" mirrors the tree.
Manual check list
The repository has no JavaScript test road, so the script's behaviour is checked by hand on the catalogue page
(examples/site, "Componenten" / "Components"), with the keyboard and a screen reader. Each line is one check.
Keyboard:
- [ ] Tab reaches the tree once, on the selected node ("Opslaan"); Shift+Tab leaves it; Tab again continues after it
- [ ] Down and Up walk the visible nodes only, stopping at the ends; the selection follows
- [ ] Right on a collapsed branch opens it; Right again goes to its first child; Right on a leaf does nothing
- [ ] Left on an expanded branch closes it; Left on a child goes to its parent and leaves the parent open
- [ ] Home and End, also with Ctrl, go to the first and the last visible node
- [ ]
*on "Menubalk" opens every sibling branch at that level - [ ] Enter on "Help" follows the link; Enter on "Opslaan" shows
dv-tree:activatein the panel - [ ] Space selects (in the dark tree, where the arrows only move focus)
- [ ] type "n": the next node starting with n; "n" again: the one after it (wrapping); "na" quickly: the node starting with "na"; a second later, "o": the next node starting with o
- [ ] type-ahead never opens a collapsed branch; a letter with no match leaves the focus where it is
- [ ] in the Hebrew tree Left opens and Right closes
- [ ] collapsing the branch that holds the selected node (Left on "Werkbalk") moves selection and focus to the branch
Screen reader (NVDA with Firefox and Chrome, VoiceOver on macOS):
- [ ] on focus a node says its own text, level, position in its set, expanded or collapsed, selected; never the texts of its children, never "button" for the chevron
- [ ] "Opslaan" also reads its description, once
- [ ] "Help" is a link inside the item: Enter follows it
- [ ] opening "Artikelen" says "Bezig met laden" then "3 onderdelen geladen" / the English equivalents; "Leveranciers" says "Laden is mislukt" and stays collapsed
- [ ] the tree's name ("Opbouw van het venster") is announced on entering; the Hebrew tree reads with a Hebrew voice and is named by its heading in the page's language
Touch screen reader (VoiceOver on iOS, TalkBack):
- [ ] swiping meets, per branch, a button "Uitklappen" / "Inklappen" and then the node; double-tapping the button toggles the branch
- [ ] double-tapping a node selects it; the panel shows
dv-tree:selectwithtrigger: "pointer"
Look:
- [ ] hover tints a row; the selected row is tinted with the accent bar at its start; the focused row shows the focus tokens; both at once are visibly different from selected alone
- [ ] forced colours (Windows high contrast): the selected row in the system highlight colours, the guides and the focus ring visible, the chevrons drawn
- [ ]
prefers-reduced-motion: the chevrons turn without a transition - [ ] 200% zoom and a phone width: rows wrap, nothing overflows, the panel moves under the tree
- [ ] the dark wrapper shows a dark tree with readable text, selection and focus
- [ ] the Hebrew tree is mirrored: chevrons at the right, indent to the left
- [ ] without JavaScript: the branches open and close as
<details>, the links work, the selected node is marked
Change log
- 2026-10-14: new, for the web inspector of a desktop app (order dvlopcore-treeview). Behaviour measured against Tim's ultimatetree page (keyboard versus touch, a separate expand button, activate versus expand, a status region) and improved: the tree role never switches off, Left never collapses the parent, children carry their position, type-ahead holds a prefix, the name of a node is its own text.