Tree view

Build a tree view with keyboard navigation. The arrow keys move through visible rows, your code opens and closes branches, and typeahead finds items by name.

Use keyrove to navigate a tree's visible rows. Your widget handles opening and closing branches and excludes hidden rows from navigation.

This sidebar uses attributes. ↑/↓ move between visible rows, → opens a folder, and ← closes it. Folders are buttons, so Enter, Space and clicks also toggle them.

Live · docs sidebar
<ul id="docs-nav">
  <li>
    <button data-keyrove-item aria-expanded="true">Guide</button>
    <ul>
      <li><button data-keyrove-item>Introduction</button></li>
      <li><button data-keyrove-item>Installation</button></li>
    </ul>
  </li>
  <li>
    <button data-keyrove-item aria-expanded="false">Examples</button>
    <ul hidden>
      <li><button data-keyrove-item data-keyrove-skip>Basic list</button></li>
      <li><button data-keyrove-item data-keyrove-skip>Grid</button></li>
    </ul>
  </li>
  <li><button data-keyrove-item>API reference</button></li>
</ul>

Mark each row's button with data-keyrove-item. Avoid marking a <li> that contains child items: when items nest, keyrove treats the first item in DOM order that contains focus as the current item.

Hidden rows remain in the DOM. setOpen updates data-keyrove-skip after each toggle so navigation passes over rows inside hidden lists. See skipped items.

fold handles ←/→ only when they change a folder's state. On a page, or when the folder is already in the requested state, it returns null and leaves the key to the browser.

Why ← and → are not keyrove's

Expanding a branch and moving to a parent require knowledge of the tree's structure. Add those actions in your widget's handler, as the listbox adds selection.

A vertical list leaves ←/→ unbound. keyRove returns null for them, so keyRove(e) || fold(e) passes them to your handler.

A full tree view

The file explorer adds role="tree", roving tabindex, typeahead and branch navigation from the APG tree pattern. It uses options to select rows and skip hidden descendants directly from the DOM.

  • ↑/↓ move between visible rows; Home/End move to the first/last.
  • → opens a closed folder or enters an open folder's first row.
  • ← closes an open folder or moves to the parent folder.
  • Typing finds a row by name. Clicking a folder toggles it.
Live · file tree
  • src
    • main.ts
    • styles.css
  • package.json
  • README.md
  • tsconfig.json
<ul id="files" role="tree" aria-label="Project files">
  <li role="none">
    <div
      role="treeitem"
      aria-expanded="true"
      aria-owns="files-src"
      tabindex="0"
    >
      src
    </div>
    <ul id="files-src" role="group">
      <li role="none">
        <div
          role="treeitem"
          aria-expanded="false"
          aria-owns="files-components"
          tabindex="-1"
        >
          components
        </div>
        <ul id="files-components" role="group" hidden>
          <li role="none"><div role="treeitem" tabindex="-1">Menu.tsx</div></li>
          <!-- … -->
          <li role="none"><div role="treeitem" tabindex="-1">Tree.tsx</div></li>
        </ul>
      </li>
      <li role="none">
        <div
          role="treeitem"
          aria-expanded="false"
          aria-owns="files-utils"
          tabindex="-1"
        >
          utils
        </div>
        <ul id="files-utils" role="group" hidden>
          <li role="none"><div role="treeitem" tabindex="-1">dom.ts</div></li>
          <li role="none"><div role="treeitem" tabindex="-1">keys.ts</div></li>
        </ul>
      </li>
      <li role="none"><div role="treeitem" tabindex="-1">main.ts</div></li>
      <!-- … -->
    </ul>
  </li>
  <!-- … -->
  <li role="none"><div role="treeitem" tabindex="-1">package.json</div></li>
  <li role="none"><div role="treeitem" tabindex="-1">README.md</div></li>
  <li role="none"><div role="treeitem" tabindex="-1">tsconfig.json</div></li>
</ul>

The readout shows keyrove's moves and the tree's own actions in the same indigo; the verb tells them apart. From components, ↓ reads next → utils, and → then reads expanded → utils.

The pieces

  • The markup. role="tree" on the list, role="treeitem" on every row, role="group" on each folder's rows, and role="none" on the <li> elements between them, so their list semantics do not compete with the tree's. A folder carries aria-expanded, and a closed folder's group is hidden. The roles and states are left to you; keyrove reads them here only because the config names the rows by their role.
  • Rows, not list items, are the treeitems. For the reason the sidebar's buttons are its items, each row is a treeitem of its own, with its folder's group beside it rather than inside it. aria-owns ties the two together for assistive technology. That is the shape of the APG's navigation treeview.
  • Navigation. items names the rows, and skip passes over any row inside a hidden group. A closed folder's rows are still in the DOM, so they are still items; skip keeps ↓, End and the page keys on the rows that are on screen. The selector is asked on every keypress, so opening a folder puts its rows in the order with nothing to call. See skipped items.
  • One tab stop. rovingTabindex: true, with tabindex="0" on the first row and -1 on the rest, so Tab treats the tree as one control and comes back to the row you left. See roving tabindex.
  • Typeahead. createTypeahead gets the same config, so it skips the hidden rows too. With components closed, T lands on tsconfig.json; open it, and T finds Tabs.tsx first. See typeahead.
  • Opening and closing. branch, third in the chain, is the widget's own. It covers the four cases the APG gives the two arrows, and returns null for a key it leaves alone: → on a file, or ← on a closed folder at the top. The browser keeps those keys, and a fourth handler could chain on.
  • The mouse. A click focuses a row natively, but it leaves the tab stop where the keyboard last put it. The click handler moves the stop with moveTo, then opens or closes the folder it landed on.

Variations

  • Enter. The APG has Enter perform a row's default action: open the file, or open or close the folder. In the full tree that is one more case in branch, on matchesCombo(e, 'Enter'); the sidebar's buttons have it already.
  • Selection. Add aria-selected to selectable rows and a selection handler like the listbox's. For multiple selection, add aria-multiselectable="true" and implement toggling each row's selection.
  • Rows loaded on open. Fill a folder's group before un-hiding it. The rows are read on every keypress, so rows that arrive join the order with nothing to call.
  • * to open siblings. The APG's optional * opens every folder beside the focused row: call toggle(folder, true) on each folder in the same group.

Search documentation

↑ ↓ to selectEnter to openEsc to close