Skip to content
Work in progress - do not use in production. This component is still being built: it is not flagged Ready for Dev, so its markup, attributes, events and styles can all still change without notice, it is not covered by the accessibility gate, and it is not published to npm. It is on this site so the work can be seen and reviewed - nothing more.

TreeView

A hierarchical tree view component for navigating nested content.

Usage

  • src
    • index.js
    • app.js
  • package.json
  • src
    • index.js
    • app.js
  • package.json
<section is="root-tree-view" aria-label="File browser">
  <ul role="tree">
    <li role="treeitem" aria-expanded="true">
      <span>src</span>
      <ul role="group">
        <li role="treeitem">index.js</li>
        <li role="treeitem">app.js</li>
      </ul>
    </li>
    <li role="treeitem">package.json</li>
  </ul>
</section>

Which rows can be opened

A treeitem with aria-expanded is a branch and draws a chevron at the end of its row; one without it is a leaf and draws nothing. The value the markup arrives with is the state the tree opens in - so a page can render its own branch open and the rest closed, which is what this site's sidebar does, without a script running after the first paint to do it as a visible jump.

A branch's children live in a <ul role="group">, and a closed branch has hidden on it. Anything the page styles as a list inside a tree has to leave that attribute room to work: a display declaration on the group outranks the display: none the attribute carries, and the branch then collapses in the DOM while staying on screen.

What opens a branch

The branch's own row - its direct > span - and nothing else inside it. That row is the name, whatever sits beside it, and the chevron at the end of it; everything else in the <li> is the group the branch labels. A group is an indented column, so its inline gutter and the gaps between its rows are its own box, and a click landing on any of them used to find the branch through closest and close it: aiming at an entry and missing it by a few pixels collapsed the branch holding it.

So a row's own content stays clickable for its own reasons - a link navigates, a leaf's row does nothing - and only the branch row toggles. From the keyboard it is Enter, Space and the arrows, on whichever row has focus.

A count at the end of a branch row

A branch row is a flex line: the name, whatever else the markup puts in it, and the chevron at the trailing edge. Anything marked [data-part="count"] goes to that trailing edge too, just before the chevron - auto on the inline-start margin, not justify-content: space-between, which would spread all three and leave the count in the middle of the row.

A root-badge is what it is usually made of, and its height is remapped from --root-tree-view--branch--count--block-size so the row decides how tall the row is: the badge's own small step is 25px, drawn for a badge beside a heading, and at 25px it was the tallest thing in the row.

  • src3
    • index.js
    • app.js
    • router.js
  • src3
    • index.js
    • app.js
    • router.js
<section is="root-tree-view" aria-label="Changes">
  <ul role="tree">
    <li role="treeitem" aria-expanded="true">
      <span>src<span is="root-badge" role="note" data-color="neutral" data-size="small" data-part="count">3</span></span>
      <ul role="group">
        <li role="treeitem">index.js</li>
        <li role="treeitem">app.js</li>
        <li role="treeitem">router.js</li>
      </ul>
    </li>
  </ul>
</section>

Keeping the state across pages

persist names where the tree writes which of its branches are open, under root:tree-view:<name> in localStorage. Without it the tree keeps nothing and opens in whatever state the markup arrives in, every time.

It is what makes collapsing worth offering on a multi-page site: every link in a tree of links is a real document load, so a reader who folds a long branch away to get at the one under it had it unfold again on the next page. The state is applied before the first paint, so the tree is painted once - restoring after it would be the visible jump that writing aria-expanded in the markup exists to avoid.

  • src
    • index.js
  • docs
    • readme.md
  • src
    • index.js
  • docs
    • readme.md
<section is="root-tree-view" persist="file-browser" aria-label="File browser">
  <ul role="tree">
    <li role="treeitem" aria-expanded="true" id="tree-src">
      <span>src</span>
      <ul role="group">
        <li role="treeitem">index.js</li>
      </ul>
    </li>
    <li role="treeitem" aria-expanded="true" id="tree-docs">
      <span>docs</span>
      <ul role="group">
        <li role="treeitem">readme.md</li>
      </ul>
    </li>
  </ul>
</section>

A branch is remembered by its id when it has one and by its position in the tree when it does not. Give the branches ids if the tree can grow: a positional key is stable only while the shape is, and a branch inserted above another hands that other one somebody else's state. It is never the row's text - a sidebar heading that carries a count of what is under it is renamed the day something is added to it, and every reader's state goes with it.

Storage is read and written inside try: a sandboxed document throws on the first touch of localStorage, and a tree must not fail to render over a preference it was not allowed to read.

Attributes

Attribute Type Default Description
persist string - Keep which branches are open, under root:tree-view:<name>
data-current (on a treeitem) boolean - Marks the branch holding whatever the reader is on
aria-label string - Accessible label

Parts

Part Description
[data-part="count"] Trailing content of a branch row, pushed to the far end of it

Events

Event Detail Description
root-tree-view.select { value } Fired on item selection
root-tree-view.expand { value } Fired on branch expand
root-tree-view.collapse { value } Fired on branch collapse

Extends

HTMLElement - use with <section is="root-tree-view">. The keyboard model is the ARIA tree pattern: arrows move and open, Home and End jump, Enter and Space toggle. A row whose content is a link keeps that link's own tab stop, so a tree of links is still navigable one link at a time.