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.
<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>
import { keyRove, matchesCombo } from '@mixedrays/keyrove';
const nav = document.querySelector('#docs-nav');
const setOpen = (folder, open) => {
folder.setAttribute('aria-expanded', String(open));
folder.nextElementSibling.hidden = !open;
for (const item of nav.querySelectorAll('[data-keyrove-item]')) {
item.toggleAttribute('data-keyrove-skip', !!item.closest('[hidden]'));
}
};
const fold = (e) => {
const open = matchesCombo(e, 'ArrowRight');
if (!open && !matchesCombo(e, 'ArrowLeft')) return null;
if (e.target.getAttribute('aria-expanded') !== String(!open)) return null;
e.preventDefault();
setOpen(e.target, open);
return e.target;
};
nav.addEventListener('keydown', (e) => keyRove(e) || fold(e));
nav.addEventListener('click', (e) => {
const folder = e.target.closest('[aria-expanded]');
if (folder) setOpen(folder, folder.getAttribute('aria-expanded') === 'false');
});
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.
-
src
-
components
- Menu.tsx
- Tabs.tsx
- Tree.tsx
-
utils
- dom.ts
- keys.ts
- main.ts
- styles.css
-
-
node_modules
- left-pad
-
public
- favicon.svg
- robots.txt
- 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>
import {
createTypeahead,
keyRove,
matchesCombo,
toggleTabIndex,
} from '@mixedrays/keyrove';
const tree = document.querySelector('#files');
const config = {
items: '[role="treeitem"]',
skip: '[hidden] [role="treeitem"]',
rovingTabindex: true,
};
const typeahead = createTypeahead(config);
const groupOf = (item) =>
document.getElementById(item.getAttribute('aria-owns'));
const parentOf = (item) =>
item.closest('[role="group"]')?.previousElementSibling;
const toggle = (item, open) => {
item.setAttribute('aria-expanded', String(open));
groupOf(item).hidden = !open;
};
const moveTo = (from, to) => {
toggleTabIndex({ root: from, isActive: false });
toggleTabIndex({ root: to, isActive: true });
to.focus();
};
const branch = (e) => {
const item = e.target.closest('[role="treeitem"]');
if (!item) return null;
const expanded = item.getAttribute('aria-expanded'); // null on a file
const parent = parentOf(item);
if (matchesCombo(e, 'ArrowRight') && expanded === 'false') {
toggle(item, true);
} else if (matchesCombo(e, 'ArrowRight') && expanded === 'true') {
moveTo(item, groupOf(item).querySelector('[role="treeitem"]'));
} else if (matchesCombo(e, 'ArrowLeft') && expanded === 'true') {
toggle(item, false);
} else if (matchesCombo(e, 'ArrowLeft') && parent) {
moveTo(item, parent);
} else {
return null;
}
e.preventDefault();
return item;
};
tree.addEventListener('keydown', (e) => {
keyRove(e, config) || typeahead(e) || branch(e);
});
tree.addEventListener('click', (e) => {
const item = e.target.closest('[role="treeitem"]');
if (!item) return;
moveTo(tree.querySelector('[tabindex="0"]'), item);
const expanded = item.getAttribute('aria-expanded');
if (expanded) toggle(item, expanded === 'false');
});
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, androle="none"on the<li>elements between them, so their list semantics do not compete with the tree's. A folder carriesaria-expanded, and a closed folder's group ishidden. 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-ownsties the two together for assistive technology. That is the shape of the APG's navigation treeview. - Navigation.
itemsnames the rows, andskippasses over any row inside ahiddengroup. A closed folder's rows are still in the DOM, so they are still items;skipkeeps ↓, 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, withtabindex="0"on the first row and-1on the rest, so Tab treats the tree as one control and comes back to the row you left. See roving tabindex. - Typeahead.
createTypeaheadgets the sameconfig, 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 returnsnullfor 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, onmatchesCombo(e, 'Enter'); the sidebar's buttons have it already. - Selection. Add
aria-selectedto selectable rows and a selection handler like the listbox's. For multiple selection, addaria-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.