# 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.
```html
```
```ts
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 `
` 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](/docs/examples/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](/docs/examples/listbox#the-pieces).
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](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).
It uses [options](/docs/examples/javascript-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.
```html
src
components
Menu.tsx
Tree.tsx
utils
dom.ts
keys.ts
main.ts
package.json
README.md
tsconfig.json
```
```ts
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, and `role="none"` on the `
`
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](/docs/introduction#what-it-leaves-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](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/).
- **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](/docs/examples/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](/docs/examples/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](/docs/examples/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](/docs/examples/listbox#the-pieces). 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.