tree()
Progressively enhances an author-written, nested <ul>/<li> into an
accessible tree view following the
WAI-ARIA APG Tree View pattern
— roving tabindex, arrow-key navigation, expand/collapse, single-select, and optional
lazy-loaded children. It is a plain decorator like tooltip():
you own the markup and styling, tree() adds the roles and behaviour. Reach for it
for a content/structure sidebar (chapters → sections, file trees, an outline). Source:
tree.mjs.
You write plain nested lists; each <li>'s non-<ul> content is
auto-wrapped in a focusable .ui-tree-row[role="treeitem"] and nested lists become
role="group". A node is a parent (expandable) when its
<li> contains a child <ul> or carries [data-lazy];
otherwise it is a selectable leaf. Use data-id to identify nodes in
the on_select callback and the select() / reveal() handle.
Mark an <li> [data-disabled] and its row is shown but not
selectable — see §4.
The demos below put their source in a <template> and render it live with
<ui-code for="…-tpl" into="…">: because tree() rewrites the live DOM
(adding roles and row wrappers), a <template> keeps the shown markup
pristine — see ui-code §Picking a target.
role="tree" on the list, role="treeitem" on each row,
role="group" on child lists, with aria-level,
aria-setsize and aria-posinset set per node.aria-expanded; leaves do not (so they aren't mistaken for
parents). Selectable leaves carry aria-selected (WCAG SC 4.1.2 — Name, Role, Value).aria-label on the <ul> or
pass { label }.tabindex="0"; the rest -1. Arrow
keys move within (WCAG SC 2.4.3 — Focus Order), so the whole tree is one tab stop.<li> marked [data-disabled] gets
aria-disabled="true" on its row, and keeps its place in the roving
tabindex — so the arrows, Home/End and type-ahead still land on
it and a screen reader announces it as unavailable. The attribute's value is the
reason, published as the row's title, which is where a reader finds out why.
Users cannot select a disabled row: neither a click nor Enter/Space selects it or
fires on_select (WCAG SC 4.1.2 — the announced state matches the behavior).
The APG's other sanctioned form — dropping a disabled item out of the keyboard path
altogether, which <ui-toolbar> offers as
plain disabled — is not supported here. A
.ui-tree-row is a <div> with no native
disabled state. Omit rows that should be excluded from navigation.select(id) is not
blocked. A disabled parent still toggles — by chevron, by click and by
→/← — so users can still reach its children. Disabling a row prevents selection while preserving access to the tree structure. And the handle's select(id) reflects the application's current selection, so it can highlight a disabled row (on_select never fires for it either way).<li>, then call refresh() to apply changes. The row's aria-disabled and the title mirroring the
reason are tree()'s output, rewritten on every decoration pass — so dropping
data-disabled from the <li> and calling refresh()
removes both. A title you wrote yourself on a row that was never disabled is left
alone: cleanup only affects rows previously marked disabled by this helper.| Export | Signature | Description |
|---|---|---|
tree |
tree(ul, { load?, on_select?, label?, should_typeahead?, loading_label?, error_label? }) |
Enhances an in-DOM <ul>. Returns a handle. |
load(li) → children for a [data-lazy] node (HTML string, a <ul>
to unwrap, or <li> nodes; may be async) ·
on_select(id, row) fires on user activation (not for programmatic select()) ·
should_typeahead defaults to true ·
loading_label / error_label override the two strings the lazy path shows and
announces, which otherwise come localized ("Loading…", "Could not load the
content") ·
Handle: select(id), reveal(id), refresh(), destroy() ·
Mark decorative content (icons, badges) aria-hidden="true" to exclude it from the row's accessible name.
Per-<li> attributes you write: data-id (node identity) ·
data-lazy (children fetched on first expand) · data-collapsed
(start closed) · data-selectable (a parent that selects as well as toggles) ·
data-disabled[="reason"] (shown, announced unavailable, not selectable) ·
[data-tree-toggle] on an element inside a row (the chevron that toggles
a data-selectable parent).
A nested list of chapters and sections. Click a chapter to expand/collapse it, click a section to select it; navigate with the arrow keys.
Mark a parent [data-lazy] with no child list; load() is called the first
time it is expanded. While the promise is pending a loading row is shown and announced.
The handle's select(id) highlights a node and expands its ancestors — useful for
keeping the tree in sync with external navigation (without firing on_select).
Chapters 2 and 3 carry data-disabled="You do not have access". They are dimmed and
the arrows still stop on them, so keyboard and screen-reader users can find them and read the explanation in the tooltip. Clicking one, or
pressing Enter on it, logs nothing. Chapter 1 remains selectable; use it to confirm that the log works.
Chapter 3 is a disabled parent, and it still expands — by click, by →, or by Enter. This keeps its selectable child accessible.
<ui-toolbar> — the other roving-tabindex pattern