TreeView.e2e
Open in Storybook (Docs) · Live story
Everything needed to reproduce this component is inlined below (README and full source). Individual raw files: README.md TreeView.e2e.ts TreeView.stories.ts TreeView.test.ts TreeView.ts treeview.css
README.md (raw)
# TreeView
A framework-agnostic, WCAG-conformant **tree view** in vanilla TypeScript. It is a
faithful, heavily-documented implementation of the [WAI-ARIA APG *Tree View*
pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/), written so it can
double as a reference you hand to an LLM (or a person) to reproduce in React,
Vue, Svelte, etc. without losing accessibility semantics.
The class **enhances** semantic nested-list markup rather than generating it, so
the tree degrades gracefully to a plain nested list without JavaScript.
## Markup contract
Provide nested `<ul>`/`<li>` lists. Each item is an `<li class="treeview__item">`
whose visible text lives in a `.treeview__label` child; a **parent** item also
contains a `<ul class="treeview__group">` of child items.
```html
<ul class="treeview" data-treeview aria-label="File system">
<li class="treeview__item">
<span class="treeview__label">README.md</span>
</li>
<li class="treeview__item" data-expanded>
<span class="treeview__label">src</span>
<ul class="treeview__group">
<li class="treeview__item">
<span class="treeview__label">index.ts</span>
</li>
</ul>
</li>
</ul>
```
- A node is a **parent** iff its `<li>` has a direct `.treeview__group` child.
- Mark a parent open initially with `data-expanded` on its `<li>`.
- Mark a node selected initially with `data-selected` on its `<li>` (only
meaningful when `selectionMode` is not `"none"`).
- You do **not** author any ARIA or ids — the class wires up `role`,
`aria-expanded`, `aria-selected`, `aria-level`, `aria-setsize`,
`aria-posinset`, `hidden`, roving `tabindex`, and generates missing ids.
## Usage
```ts
import { TreeView } from './TreeView';
import './treeview.css';
const root = document.querySelector<HTMLElement>('[data-treeview]')!;
const tree = new TreeView(root, { selectionMode: 'single', label: 'File system' });
root.addEventListener('treeview:activate', (e) => {
const { index, item } = (e as CustomEvent).detail;
// e.g. navigate to the resource represented by `item`
});
```
Passing a non-`HTMLElement` root throws a `TypeError`; a treeitem without a
`.treeview__label` child throws an `Error`.
## Options
All options are optional. Defaults match the APG's baseline example.
| Option | Type | Default | Description |
| --------------- | ------------------------------------- | -------------- | ----------- |
| `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'single'` | `none`: navigation only, no `aria-selected`. `single`: one node selected. `multiple`: any number selected; root gets `aria-multiselectable="true"`. |
| `wrapFocus` | `boolean` | `false` | Wrap Up/Down focus around the first/last visible node. The APG example does not wrap. |
| `typeAhead` | `boolean` | `true` | Focus the next visible node whose label starts with the typed characters. |
| `idPrefix` | `string` | `'treeview'` | Prefix for generated element ids. |
| `label` | `string` | `''` | Applied as `aria-label` on the root **only** if the markup has no `aria-label`/`aria-labelledby`. |
## Events
All events are `CustomEvent`s dispatched on the root element and **bubble**.
Events fired during initial setup are suppressed.
| Event | `detail` | Fired when |
| -------------------- | ---------------------------------------------------------- | ---------- |
| `treeview:toggle` | `{ index, expanded, item, group }` | A parent expands or collapses. |
| `treeview:select` | `{ index, selected, item }` | A node's selection state changes. |
| `treeview:activate` | `{ index, item }` | A node's primary action runs (click / Enter / Space). |
`index` is the zero-based document-order position of the node; `item` is its
`<li>`; `group` is the child `<ul>`.
## Public API
| Member | Description |
| ----------------------- | ----------- |
| `size` | Number of treeitems. |
| `isExpanded(index)` | Whether the node is an expanded parent. |
| `isSelected(index)` | Whether the node is selected. |
| `expand(index)` | Expand a parent (no-op for leaves / already open). |
| `collapse(index)` | Collapse a parent (no-op for leaves / already closed). |
| `toggle(index)` | Toggle a parent's expansion. |
| `select(index)` | Select a node (clears others in single mode). |
| `deselect(index)` | Deselect a node. |
| `focusItem(index)` | Move keyboard focus to a node, if currently visible. |
| `destroy()` | Remove all listeners and clear pending timers. |
## Accessibility contract
### Roles
| Element | Role |
| ------------------------------ | -------- |
| Root list (`[data-treeview]`) | `tree` |
| Each `.treeview__item` | `treeitem` |
| Each nested `.treeview__group` | `group` |
### States & properties (per treeitem)
| Attribute | Applied to | Meaning |
| -------------------------------- | ---------- | ------- |
| `aria-expanded` | parents only | `true`/`false` — leaf nodes never get it. |
| `aria-selected` | when selection enabled | `true`/`false`. |
| `aria-level` | all | 1-based depth. |
| `aria-setsize` / `aria-posinset` | all | Position within the group of siblings. |
| roving `tabindex` | all | Exactly one node is `0`; the rest are `-1`. Tab is a single stop into/out of the tree. |
Collapsed groups are hidden with the native `hidden` attribute, removing their
items from both the tab order and the accessibility tree.
### Keyboard interaction
All of the following are **required** by the APG for this pattern (tree
navigation is not optional the way the Accordion's arrow keys are).
| Key | Action |
| ------------------ | ------ |
| Down / Up Arrow | Move focus to the next / previous visible node. |
| Right Arrow | Collapsed parent → expand; expanded parent → focus first child; leaf → nothing. |
| Left Arrow | Expanded parent → collapse; otherwise → focus parent. |
| Home / End | Move focus to the first / last visible node. |
| Enter / Space | Primary action: toggle a parent, apply selection, emit `treeview:activate`. |
| Type a character | Focus the next visible node whose label starts with the typed string. |
| `*` (asterisk) | Expand every sibling parent at the current level. |
## Styling notes
The bundled `treeview.css` is deliberately minimal — indentation, a focus ring,
and ARIA-driven expand/collapse and selection affordances only. Override freely.
- **Never set `display` on `.treeview__group`.** Collapsed groups rely on the
`hidden` attribute (`[hidden] { display: none }` from the UA stylesheet); a
`display` rule would override it and break hiding.
- The **treeitem `<li>`**, not the label, is the focusable element, so the
focus indicator (`:focus-visible`) belongs on `.treeview__item`. Never remove it.
- Expand/collapse (`▸`/`▾`) and selection styling are driven purely by the ARIA
attributes, so the visuals always match what assistive tech announces.
TreeView.e2e.ts (raw)
/**
* TreeView — end-to-end tests (Playwright, real browser).
*
* These drive the rendered "Default" Storybook story and verify the REQUIRED
* APG keyboard model that only works with real focus / `activeElement`: Up/Down
* navigation over visible nodes, Right/Left expand-collapse-and-move, Home/End,
* type-ahead, and `*` to expand siblings. The ARIA wiring, selection modes and
* JS API are covered by the Vitest unit suite (`TreeView.test.ts`).
*
* Requires `bunx playwright install chromium` first. The Storybook web server is
* started automatically (see `playwright.config.ts`).
*
* The Default story tree (document order, with `src` and `src/components`
* initially expanded):
* README.md
* src [expanded]
* index.ts
* components [expanded]
* Accordion.ts
* TreeView.ts
* docs [collapsed]
* getting-started.md
* accessibility.md
*/
import { test, expect } from '@playwright/test';
const STORY = '/iframe.html?id=components-treeview--default&viewMode=story';
/**
* Locate a treeitem by the exact text of its OWN label. An XPath `./span`
* step matches only the item's direct-child label, so a parent node is never
* matched by one of its descendants' labels (which live in nested `<li>`s).
*/
function item(page: import('@playwright/test').Page, label: string) {
return page.locator(
`xpath=//li[contains(concat(" ", normalize-space(@class), " "), " treeview__item ")]` +
`[./span[contains(concat(" ", normalize-space(@class), " "), " treeview__label ")` +
` and normalize-space(.)=${JSON.stringify(label)}]]`,
);
}
test.beforeEach(async ({ page }) => {
await page.goto(STORY);
await page.waitForSelector('.treeview__item');
});
test('Down and Up move focus over visible nodes only', async ({ page }) => {
await item(page, 'README.md').focus();
await page.keyboard.press('ArrowDown');
await expect(item(page, 'src')).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(item(page, 'index.ts')).toBeFocused();
await page.keyboard.press('ArrowUp');
await expect(item(page, 'src')).toBeFocused();
});
test('Right expands a collapsed parent then moves to its first child', async ({ page }) => {
const docs = item(page, 'docs');
await docs.focus();
await expect(docs).toHaveAttribute('aria-expanded', 'false');
await page.keyboard.press('ArrowRight');
await expect(docs).toHaveAttribute('aria-expanded', 'true');
await page.keyboard.press('ArrowRight');
await expect(item(page, 'getting-started.md')).toBeFocused();
});
test('Left collapses an expanded parent then moves to the parent', async ({ page }) => {
const src = item(page, 'src');
await src.focus();
await expect(src).toHaveAttribute('aria-expanded', 'true');
await page.keyboard.press('ArrowLeft');
await expect(src).toHaveAttribute('aria-expanded', 'false');
// index.ts is now hidden; move onto a child then Left returns to the parent.
await page.keyboard.press('ArrowRight'); // re-expand
await page.keyboard.press('ArrowRight'); // focus index.ts
await expect(item(page, 'index.ts')).toBeFocused();
await page.keyboard.press('ArrowLeft'); // leaf → focus parent
await expect(src).toBeFocused();
});
test('Home and End move to the first and last visible node', async ({ page }) => {
await item(page, 'index.ts').focus();
await page.keyboard.press('End');
await expect(item(page, 'TreeView.ts')).toBeFocused();
await page.keyboard.press('Home');
await expect(item(page, 'README.md')).toBeFocused();
});
test('type-ahead focuses the next node whose label matches', async ({ page }) => {
await item(page, 'README.md').focus();
await page.keyboard.press('d'); // "docs"
await expect(item(page, 'docs')).toBeFocused();
});
test('asterisk expands all sibling parents at the level', async ({ page }) => {
// Collapse src first so both top-level parents (src, docs) are collapsed.
await item(page, 'src').focus();
await page.keyboard.press('ArrowLeft');
await expect(item(page, 'src')).toHaveAttribute('aria-expanded', 'false');
await page.keyboard.press('*');
await expect(item(page, 'src')).toHaveAttribute('aria-expanded', 'true');
await expect(item(page, 'docs')).toHaveAttribute('aria-expanded', 'true');
});
test('Enter selects the focused node (aria-selected)', async ({ page }) => {
const readme = item(page, 'README.md');
await readme.focus();
await page.keyboard.press('Enter');
await expect(readme).toHaveAttribute('aria-selected', 'true');
});
TreeView.stories.ts (raw)
import type { Meta, StoryObj } from '@storybook/html';
import { TreeView, type TreeViewOptions } from './TreeView';
import './treeview.css';
// `storybook-readme` (installed for this repo) targets Storybook 5 and is
// incompatible with this Storybook 8 setup, so the README is surfaced through
// SB8's native docs mechanism: imported as raw Markdown and shown on the Docs
// page via `parameters.docs.description.component`. See the Accordion stories.
import readme from './README.md?raw';
/**
* Storybook stories for the TreeView component.
*
* The library is framework-agnostic vanilla TS, so each story's `render` builds
* real nested-list DOM, then enhances it with `new TreeView(...)` — exactly how
* a consumer would use it. The `@storybook/addon-a11y` panel runs axe-core
* against the rendered output so regressions in the ARIA wiring surface here.
*/
interface Node {
label: string;
/** Start this parent expanded (maps to `data-expanded` on the item). */
open?: boolean;
/** Start this node selected (maps to `data-selected` on the item). */
selected?: boolean;
children?: readonly Node[];
}
const SAMPLE_TREE: readonly Node[] = [
{ label: 'README.md' },
{
label: 'src',
open: true,
children: [
{ label: 'index.ts', selected: true },
{
label: 'components',
open: true,
children: [{ label: 'Accordion.ts' }, { label: 'TreeView.ts' }],
},
],
},
{
label: 'docs',
children: [{ label: 'getting-started.md' }, { label: 'accessibility.md' }],
},
];
/** Recursively build the `<li>`/`<ul>` markup the component expects. */
function buildItems(nodes: readonly Node[]): HTMLLIElement[] {
return nodes.map((node) => {
const li = document.createElement('li');
li.className = 'treeview__item';
if (node.open) li.setAttribute('data-expanded', '');
if (node.selected) li.setAttribute('data-selected', '');
const label = document.createElement('span');
label.className = 'treeview__label';
label.textContent = node.label;
li.append(label);
if (node.children?.length) {
const group = document.createElement('ul');
group.className = 'treeview__group';
group.append(...buildItems(node.children));
li.append(group);
}
return li;
});
}
/** Build the semantic tree markup and activate the component. */
function renderTree(nodes: readonly Node[], options: TreeViewOptions = {}): HTMLElement {
const root = document.createElement('ul');
root.className = 'treeview';
root.setAttribute('data-treeview', '');
root.append(...buildItems(nodes));
new TreeView(root, { label: 'Project files', ...options });
return root;
}
const meta: Meta<TreeViewOptions> = {
title: 'Components/TreeView',
// `autodocs` (see `.storybook/main.ts` → `docs.autodocs: "tag"`) generates a
// "Docs" page for this component; the README is rendered at the top of it.
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: readme,
},
},
},
argTypes: {
// `table.defaultValue.summary` fills the Controls/Docs "Default" column.
// `@storybook/html` cannot read these from the TS interface, so they are
// kept in sync with the defaults in `TreeViewOptions` by hand.
selectionMode: {
control: 'select',
options: ['none', 'single', 'multiple'],
description:
'Selection behaviour: "none" (navigation only), "single" (one node), or "multiple" (any number; adds aria-multiselectable).',
table: { defaultValue: { summary: 'single' } },
},
wrapFocus: {
control: 'boolean',
description: 'Wrap Up/Down arrow focus around the first/last visible node.',
table: { defaultValue: { summary: 'false' } },
},
typeAhead: {
control: 'boolean',
description: 'Focus the next visible node whose label matches typed characters.',
table: { defaultValue: { summary: 'true' } },
},
},
render: (args) => renderTree(SAMPLE_TREE, args),
};
export default meta;
type Story = StoryObj<TreeViewOptions>;
/** Default configuration: single-selection navigation tree. */
export const Default: Story = {
args: {
selectionMode: 'single',
wrapFocus: false,
typeAhead: true,
},
};
/** Any number of nodes may be selected; the tree is `aria-multiselectable`. */
export const MultiSelect: Story = {
args: {
...Default.args,
selectionMode: 'multiple',
},
};
/** Pure navigation: no `aria-selected` is applied to nodes. */
export const NavigationOnly: Story = {
args: {
...Default.args,
selectionMode: 'none',
},
};
TreeView.test.ts (raw)
/**
* TreeView — unit / DOM tests (Vitest + happy-dom).
*
* These exercise the public API and the ARIA contract documented in
* `TreeView.ts` against a real (happy-dom) document. Focus-driven keyboard
* navigation (arrow keys, Home/End, type-ahead, `*`) depends on real
* `element.focus()` / `activeElement`, which happy-dom does not model reliably,
* so those are covered by the Playwright `TreeView.e2e.ts` suite. Here we assert
* through the public API and resulting attributes.
*/
import { describe, it, expect } from 'vitest';
import { TreeView, type TreeViewOptions } from './TreeView';
interface NodeSpec {
label: string;
open?: boolean;
selected?: boolean;
children?: readonly NodeSpec[];
}
const TREE: readonly NodeSpec[] = [
{ label: 'README.md' },
{
label: 'src',
open: true,
children: [
{ label: 'index.ts', selected: true },
{
label: 'components',
open: true,
children: [{ label: 'Accordion.ts' }, { label: 'TreeView.ts' }],
},
],
},
{
label: 'docs',
children: [{ label: 'getting-started.md' }, { label: 'accessibility.md' }],
},
];
function buildItems(nodes: readonly NodeSpec[]): HTMLLIElement[] {
return nodes.map((node) => {
const li = document.createElement('li');
li.className = 'treeview__item';
if (node.open) li.setAttribute('data-expanded', '');
if (node.selected) li.setAttribute('data-selected', '');
const label = document.createElement('span');
label.className = 'treeview__label';
label.textContent = node.label;
li.append(label);
if (node.children?.length) {
const group = document.createElement('ul');
group.className = 'treeview__group';
group.append(...buildItems(node.children));
li.append(group);
}
return li;
});
}
/** Build the semantic nested-list markup and enhance it. */
function build(
nodes: readonly NodeSpec[] = TREE,
options: TreeViewOptions = {},
): { root: HTMLElement; instance: TreeView } {
const root = document.createElement('ul');
root.className = 'treeview';
root.append(...buildItems(nodes));
document.body.append(root);
const instance = new TreeView(root, { label: 'Project files', ...options });
return { root, instance };
}
/** All treeitem `<li>` elements in document order. */
function items(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>('.treeview__item'));
}
/** Find the index (document order) of the item whose own label is `text`. */
function indexOfLabel(root: HTMLElement, text: string): number {
return items(root).findIndex(
(li) => li.querySelector(':scope > .treeview__label')?.textContent === text,
);
}
describe('TreeView — construction', () => {
it('throws when root is not an element', () => {
// @ts-expect-error deliberately wrong type
expect(() => new TreeView(undefined)).toThrow(TypeError);
});
it('reports node count via `size`', () => {
const { instance } = build();
expect(instance.size).toBe(9);
});
});
describe('TreeView — roles and structure', () => {
it('applies tree/treeitem/group roles', () => {
const { root } = build();
expect(root.getAttribute('role')).toBe('tree');
for (const li of items(root)) {
expect(li.getAttribute('role')).toBe('treeitem');
}
for (const group of root.querySelectorAll('.treeview__group')) {
expect(group.getAttribute('role')).toBe('group');
}
});
it('sets aria-level, aria-setsize and aria-posinset', () => {
const { root } = build();
const readme = items(root)[indexOfLabel(root, 'README.md')]!;
expect(readme.getAttribute('aria-level')).toBe('1');
expect(readme.getAttribute('aria-setsize')).toBe('3'); // README.md, src, docs
expect(readme.getAttribute('aria-posinset')).toBe('1');
const accordion = items(root)[indexOfLabel(root, 'Accordion.ts')]!;
expect(accordion.getAttribute('aria-level')).toBe('3');
expect(accordion.getAttribute('aria-setsize')).toBe('2');
expect(accordion.getAttribute('aria-posinset')).toBe('1');
});
it('puts aria-expanded only on parents, reflecting data-expanded', () => {
const { root } = build();
const src = items(root)[indexOfLabel(root, 'src')]!;
const docs = items(root)[indexOfLabel(root, 'docs')]!;
const readme = items(root)[indexOfLabel(root, 'README.md')]!;
expect(src.getAttribute('aria-expanded')).toBe('true'); // data-expanded
expect(docs.getAttribute('aria-expanded')).toBe('false');
expect(readme.hasAttribute('aria-expanded')).toBe(false); // leaf
});
it('hides groups of collapsed parents and shows groups of expanded ones', () => {
const { root } = build();
const src = items(root)[indexOfLabel(root, 'src')]!;
const docs = items(root)[indexOfLabel(root, 'docs')]!;
expect(src.querySelector<HTMLElement>(':scope > .treeview__group')!.hidden).toBe(false);
expect(docs.querySelector<HTMLElement>(':scope > .treeview__group')!.hidden).toBe(true);
});
it('removes data-expanded / data-selected authoring hints', () => {
const { root } = build();
for (const li of items(root)) {
expect(li.hasAttribute('data-expanded')).toBe(false);
expect(li.hasAttribute('data-selected')).toBe(false);
}
});
});
describe('TreeView — roving tabindex', () => {
it('leaves exactly one item with tabindex 0 and the rest -1', () => {
const { root } = build();
const all = items(root);
const zeros = all.filter((li) => li.tabIndex === 0);
expect(zeros.length).toBe(1);
expect(zeros[0]).toBe(all[0]); // first node is the initial tab stop
for (const li of all.slice(1)) expect(li.tabIndex).toBe(-1);
});
});
describe('TreeView — expand / collapse / toggle API', () => {
it('expands and collapses a parent, syncing group.hidden', () => {
const { root, instance } = build();
const i = indexOfLabel(root, 'docs');
const group = items(root)[i]!.querySelector<HTMLElement>(':scope > .treeview__group')!;
expect(instance.isExpanded(i)).toBe(false);
instance.expand(i);
expect(instance.isExpanded(i)).toBe(true);
expect(group.hidden).toBe(false);
instance.collapse(i);
expect(instance.isExpanded(i)).toBe(false);
expect(group.hidden).toBe(true);
});
it('toggle flips expansion', () => {
const { root, instance } = build();
const i = indexOfLabel(root, 'docs');
instance.toggle(i);
expect(instance.isExpanded(i)).toBe(true);
instance.toggle(i);
expect(instance.isExpanded(i)).toBe(false);
});
it('expand is a no-op on leaf nodes', () => {
const { root, instance } = build();
const i = indexOfLabel(root, 'README.md');
instance.expand(i);
expect(instance.isExpanded(i)).toBe(false);
expect(items(root)[i]!.hasAttribute('aria-expanded')).toBe(false);
});
});
describe('TreeView — selection modes', () => {
it('single mode reflects initial data-selected and dedups selection', () => {
const { root, instance } = build(TREE, { selectionMode: 'single' });
const index = indexOfLabel(root, 'index.ts');
const treeview = indexOfLabel(root, 'TreeView.ts');
expect(instance.isSelected(index)).toBe(true); // data-selected
instance.select(treeview);
expect(instance.isSelected(treeview)).toBe(true);
expect(instance.isSelected(index)).toBe(false); // previous cleared
});
it('multiple mode toggles independently and sets aria-multiselectable', () => {
const { root, instance } = build(TREE, { selectionMode: 'multiple' });
expect(root.getAttribute('aria-multiselectable')).toBe('true');
const readme = indexOfLabel(root, 'README.md');
const index = indexOfLabel(root, 'index.ts');
instance.select(readme);
expect(instance.isSelected(readme)).toBe(true);
expect(instance.isSelected(index)).toBe(true); // initial selection kept
instance.deselect(index);
expect(instance.isSelected(index)).toBe(false);
expect(instance.isSelected(readme)).toBe(true);
});
it('none mode applies no aria-selected and ignores select()', () => {
const { root, instance } = build(TREE, { selectionMode: 'none' });
expect(root.hasAttribute('aria-multiselectable')).toBe(false);
for (const li of items(root)) {
expect(li.hasAttribute('aria-selected')).toBe(false);
}
instance.select(0);
expect(items(root)[0]!.hasAttribute('aria-selected')).toBe(false);
});
});
describe('TreeView — events', () => {
it('fires treeview:toggle with detail on expand', () => {
const { root, instance } = build();
let detail: { index: number; expanded: boolean } | null = null;
root.addEventListener('treeview:toggle', (e) => {
const d = (e as CustomEvent<{ index: number; expanded: boolean }>).detail;
detail = { index: d.index, expanded: d.expanded };
});
const i = indexOfLabel(root, 'docs');
instance.expand(i);
expect(detail).toEqual({ index: i, expanded: true });
});
it('fires treeview:select with detail', () => {
const { root, instance } = build();
let detail: { index: number; selected: boolean } | null = null;
root.addEventListener('treeview:select', (e) => {
const d = (e as CustomEvent<{ index: number; selected: boolean }>).detail;
detail = { index: d.index, selected: d.selected };
});
const i = indexOfLabel(root, 'README.md');
instance.select(i);
expect(detail).toEqual({ index: i, selected: true });
});
it('fires treeview:activate on click and toggles a clicked parent', () => {
const { root, instance } = build();
const activated: number[] = [];
root.addEventListener('treeview:activate', (e) => {
activated.push((e as CustomEvent<{ index: number }>).detail.index);
});
const i = indexOfLabel(root, 'docs');
const label = items(root)[i]!.querySelector<HTMLElement>(':scope > .treeview__label')!;
label.click();
expect(activated).toEqual([i]);
expect(instance.isExpanded(i)).toBe(true);
});
});
describe('TreeView — destroy', () => {
it('removes listeners so later clicks do nothing', () => {
const { root, instance } = build();
instance.destroy();
const i = indexOfLabel(root, 'docs');
items(root)[i]!.querySelector<HTMLElement>(':scope > .treeview__label')!.click();
expect(instance.isExpanded(i)).toBe(false);
});
});
TreeView.ts (raw)
/**
* TreeView
* ========
*
* A framework-agnostic, class-based implementation of the WAI-ARIA Authoring
* Practices Guide (APG) "Tree View" pattern:
*
* https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
*
* Like the {@link ../accordion/Accordion Accordion}, this file is written as a
* **reference / translation template**: plain, strictly-typed vanilla TypeScript
* with heavy documentation so it can be hand- or AI-translated into React, Vue,
* Svelte, etc. without losing any of the accessibility semantics. Prefer clarity
* over cleverness here.
*
* ---------------------------------------------------------------------------
* Accessibility contract (what the APG requires and what this class guarantees)
* ---------------------------------------------------------------------------
*
* Roles & structure
* - The root list becomes `role="tree"`. Because there is no native "tree"
* element, roles are applied to semantic `<ul>`/`<li>` markup — the class
* ENHANCES that markup rather than generating it, so the tree degrades to a
* plain nested list without JavaScript.
* - Every list item becomes `role="treeitem"`.
* - Every nested list (a `<ul>` that holds an item's children) becomes
* `role="group"`. Groups belonging to a collapsed parent are hidden with the
* native `hidden` attribute, which removes them from the tab order and the
* accessibility tree at once (no extra CSS required).
*
* States & properties on each treeitem
* - `aria-expanded` : "true"/"false" — ONLY on items that own a child group
* (parent nodes). Leaf nodes never get it, exactly as the
* APG requires.
* - `aria-selected` : "true"/"false" — only when `selectionMode` is not
* "none". The root gets `aria-multiselectable="true"` in
* multiple-selection mode.
* - `aria-level` : 1-based depth of the node.
* - `aria-setsize` / `aria-posinset` : position within its group of siblings.
* - Roving `tabindex` : exactly ONE treeitem is in the tab order at a time
* (`tabindex="0"`); all others are `tabindex="-1"`. Tab
* moves into/out of the whole tree as a single stop; the
* arrow keys move between nodes.
*
* Keyboard interaction (all REQUIRED by the APG for this pattern — unlike the
* Accordion's arrow keys, tree navigation is not optional)
* - Down / Up Arrow : move focus to the next / previous VISIBLE node.
* - Right Arrow : on a collapsed parent → expand it; on an expanded parent
* → move to its first child; on a leaf → do nothing.
* - Left Arrow : on an expanded parent → collapse it; otherwise → move
* focus to the node's parent.
* - Home / End : move focus to the first / last visible node.
* - Enter / Space : perform the node's primary action (toggle a parent,
* select per `selectionMode`, and emit `treeview:activate`).
* - Type-ahead : typing printable characters moves focus to the next
* visible node whose label starts with the typed string.
* - `*` (asterisk) : expand every sibling parent at the current node's level.
*
* ---------------------------------------------------------------------------
* Expected markup (progressive enhancement)
* ---------------------------------------------------------------------------
*
* Provide nested lists. Each item is an `<li class="treeview__item">` whose
* label is a `.treeview__label` child; a parent item additionally holds a
* `<ul class="treeview__group">` of child items. No ARIA is authored by hand.
*
* ```html
* <ul class="treeview" data-treeview aria-label="File system">
* <li class="treeview__item">
* <span class="treeview__label">README.md</span>
* </li>
* <li class="treeview__item" data-expanded>
* <span class="treeview__label">src</span>
* <ul class="treeview__group">
* <li class="treeview__item">
* <span class="treeview__label">index.ts</span>
* </li>
* </ul>
* </li>
* </ul>
* ```
*
* Notes on the markup:
* - A node is a PARENT iff its `<li>` contains a direct `.treeview__group`.
* - Mark a parent as initially open with `data-expanded` on its `<li>`.
* - Mark a node as initially selected with `data-selected` on its `<li>`
* (only meaningful when `selectionMode` is not "none").
* - You do NOT write any ARIA or ids yourself — the class wires up `role`,
* `aria-expanded`, `aria-selected`, `aria-level`, `aria-setsize`,
* `aria-posinset`, `hidden`, roving `tabindex`, and generates missing ids.
*/
/** How the tree handles selection. */
export type TreeViewSelectionMode = 'none' | 'single' | 'multiple';
/** Options controlling tree-view behaviour. All fields are optional. */
export interface TreeViewOptions {
/**
* Selection behaviour:
* - `"none"` — pure navigation tree; no `aria-selected` is applied.
* - `"single"` — at most one node selected at a time.
* - `"multiple"` — any number of nodes may be selected; the root gains
* `aria-multiselectable="true"`.
* @default "single"
*/
selectionMode?: TreeViewSelectionMode;
/**
* When moving with Up/Down at the ends of the visible list, wrap around
* (Down on the last node focuses the first, and vice versa). The APG example
* does not wrap, so this defaults off.
* @default false
*/
wrapFocus?: boolean;
/**
* Enable printable-character type-ahead: focus moves to the next visible node
* whose label starts with the recently typed string.
* @default true
*/
typeAhead?: boolean;
/**
* Prefix used when generating element ids. Useful to keep ids stable/readable.
* @default "treeview"
*/
idPrefix?: string;
/**
* Accessible name applied as `aria-label` on the root ONLY when the markup
* doesn't already provide `aria-label`/`aria-labelledby`. A tree should always
* have a name.
* @default ""
*/
label?: string;
}
/** Detail payload for the `treeview:toggle` custom event. */
export interface TreeViewToggleDetail {
/** Zero-based index (document order) of the affected node. */
index: number;
/** Whether the node is now expanded. */
expanded: boolean;
/** The affected treeitem element. */
item: HTMLElement;
/** The child group element that was shown/hidden. */
group: HTMLElement;
}
/** Detail payload for the `treeview:select` custom event. */
export interface TreeViewSelectDetail {
/** Zero-based index (document order) of the affected node. */
index: number;
/** Whether the node is now selected. */
selected: boolean;
/** The affected treeitem element. */
item: HTMLElement;
}
/** Detail payload for the `treeview:activate` custom event (Enter/Space/click). */
export interface TreeViewActivateDetail {
/** Zero-based index (document order) of the activated node. */
index: number;
/** The activated treeitem element. */
item: HTMLElement;
}
/** Internal representation of one tree node. */
interface TreeNode {
/** The `<li>` element carrying `role="treeitem"`. */
readonly li: HTMLElement;
/** The label element whose text drives type-ahead. */
readonly label: HTMLElement;
/** The child group (`<ul role="group">`), or `null` for a leaf node. */
readonly group: HTMLElement | null;
/** The parent node, or `null` for a root-level node. */
parent: TreeNode | null;
/** 1-based depth (root nodes are level 1). */
level: number;
}
/** Module-level counter guaranteeing unique generated ids across instances. */
let uidCounter = 0;
/** How long a type-ahead buffer lives before resetting, in milliseconds. */
const TYPEAHEAD_TIMEOUT = 500;
/** CSS selectors for the required markup hooks. Single source of truth. */
const SELECTORS = {
item: '.treeview__item',
label: '.treeview__label',
group: '.treeview__group',
} as const;
export class TreeView {
private readonly root: HTMLElement;
private readonly options: Required<TreeViewOptions>;
private readonly nodes: readonly TreeNode[];
/** Fast lookup from a treeitem element to its index in {@link nodes}. */
private readonly indexByLi: Map<HTMLElement, number>;
/** Accumulated type-ahead keystrokes and the timer that clears them. */
private typeaheadBuffer = '';
private typeaheadTimer: number | null = null;
/** Delegated click handler; bound as a field so `destroy()` can remove it. */
private readonly onClick = (event: MouseEvent): void => {
const node = this.findNode(event.target as Element | null);
if (!node) return;
node.li.focus();
this.primaryAction(this.indexOf(node));
};
/**
* Keep the roving tabstop in sync with real focus so that tabbing away and
* back returns to the last-focused node.
*/
private readonly onFocusIn = (event: FocusEvent): void => {
const node = this.findNode(event.target as Element | null);
if (node) this.setTabStop(this.indexOf(node));
};
/** Delegated keydown handler implementing the full tree keyboard model. */
private readonly onKeydown = (event: KeyboardEvent): void => {
const node = this.findNode(event.target as Element | null);
// Only act when a treeitem itself is focused, never when focus is on some
// interactive element the author nested inside a label.
if (!node || event.target !== node.li) return;
const index = this.indexOf(node);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.focusRelative(node, +1);
return;
case 'ArrowUp':
event.preventDefault();
this.focusRelative(node, -1);
return;
case 'ArrowRight':
event.preventDefault();
if (node.group && !this.isExpanded(index)) this.expand(index);
else if (node.group) this.firstChildOf(node)?.li.focus();
return;
case 'ArrowLeft':
event.preventDefault();
if (node.group && this.isExpanded(index)) this.collapse(index);
else node.parent?.li.focus();
return;
case 'Home': {
event.preventDefault();
this.visibleNodes()[0]?.li.focus();
return;
}
case 'End': {
event.preventDefault();
const visible = this.visibleNodes();
visible[visible.length - 1]?.li.focus();
return;
}
case 'Enter':
case ' ':
case 'Spacebar': // legacy value
event.preventDefault();
this.primaryAction(index);
return;
case '*':
event.preventDefault();
this.expandSiblings(node);
return;
default:
// Printable single characters (excluding modifiers) drive type-ahead.
if (
this.options.typeAhead &&
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
/\S/.test(event.key)
) {
this.typeahead(event.key, node);
}
}
};
/**
* @param root The list element that should become the tree (`role="tree"`).
* @param options Behavioural options; see {@link TreeViewOptions}.
*/
constructor(root: HTMLElement, options: TreeViewOptions = {}) {
if (!(root instanceof HTMLElement)) {
throw new TypeError('TreeView: `root` must be an HTMLElement.');
}
this.root = root;
this.options = {
selectionMode: options.selectionMode ?? 'single',
wrapFocus: options.wrapFocus ?? false,
typeAhead: options.typeAhead ?? true,
idPrefix: options.idPrefix ?? 'treeview',
label: options.label ?? '',
};
this.nodes = this.collectNodes();
this.indexByLi = new Map(this.nodes.map((node, i) => [node.li, i]));
this.setupAria();
this.bindEvents();
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
/**
* Discover every treeitem, then link each to its parent and depth. Because
* `querySelectorAll` returns nodes in document order, parents always appear
* before their children, so a single forward pass can resolve levels.
*/
private collectNodes(): TreeNode[] {
const lis = Array.from(this.root.querySelectorAll<HTMLElement>(SELECTORS.item));
const nodes: TreeNode[] = lis.map((li, i) => {
const label = li.querySelector<HTMLElement>(`:scope > ${SELECTORS.label}`);
if (!label) {
throw new Error(`TreeView: treeitem #${i} is missing a "${SELECTORS.label}" child.`);
}
const group = li.querySelector<HTMLElement>(`:scope > ${SELECTORS.group}`);
return { li, label, group, parent: null, level: 1 };
});
const nodeByLi = new Map(nodes.map((node) => [node.li, node]));
for (const node of nodes) {
const parentLi = node.li.parentElement?.closest<HTMLElement>(SELECTORS.item) ?? null;
node.parent = parentLi ? nodeByLi.get(parentLi) ?? null : null;
node.level = node.parent ? node.parent.level + 1 : 1;
}
return nodes;
}
/** Apply all static ARIA wiring and reconcile the initial state from markup. */
private setupAria(): void {
this.root.setAttribute('role', 'tree');
if (this.options.selectionMode === 'multiple') {
this.root.setAttribute('aria-multiselectable', 'true');
}
const named =
this.root.hasAttribute('aria-label') || this.root.hasAttribute('aria-labelledby');
if (!named && this.options.label) {
this.root.setAttribute('aria-label', this.options.label);
}
// Group siblings by parent once so set-size / position are cheap to derive.
const siblingsByParent = new Map<TreeNode | null, TreeNode[]>();
for (const node of this.nodes) {
const list = siblingsByParent.get(node.parent) ?? [];
list.push(node);
siblingsByParent.set(node.parent, list);
}
this.nodes.forEach((node) => {
const uid = `${this.options.idPrefix}-${++uidCounter}`;
if (!node.li.id) node.li.id = uid;
node.li.setAttribute('role', 'treeitem');
node.li.setAttribute('aria-level', String(node.level));
const siblings = siblingsByParent.get(node.parent) ?? [node];
node.li.setAttribute('aria-setsize', String(siblings.length));
node.li.setAttribute('aria-posinset', String(siblings.indexOf(node) + 1));
if (node.group) {
node.group.setAttribute('role', 'group');
const open = node.li.hasAttribute('data-expanded');
node.li.setAttribute('aria-expanded', String(open));
node.group.hidden = !open;
}
if (this.options.selectionMode !== 'none') {
node.li.setAttribute('aria-selected', String(node.li.hasAttribute('data-selected')));
}
// Clean up authoring hints so they don't linger in the DOM.
node.li.removeAttribute('data-expanded');
node.li.removeAttribute('data-selected');
// Roving tabindex: everything starts out of the tab order...
node.li.tabIndex = -1;
});
// ...then the first node becomes the single tab stop.
if (this.nodes[0]) this.nodes[0].li.tabIndex = 0;
}
/** Attach delegated event listeners on the root. */
private bindEvents(): void {
this.root.addEventListener('click', this.onClick);
this.root.addEventListener('keydown', this.onKeydown);
this.root.addEventListener('focusin', this.onFocusIn);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Number of treeitems managed by this instance. */
get size(): number {
return this.nodes.length;
}
/** Whether the node at `index` is an expanded parent. */
isExpanded(index: number): boolean {
return this.isExpandedNode(this.nodes[index]);
}
/** Whether the node at `index` is selected. */
isSelected(index: number): boolean {
return this.nodes[index]?.li.getAttribute('aria-selected') === 'true';
}
/** Expand the parent node at `index` (no-op for leaves or if already open). */
expand(index: number): void {
const node = this.nodes[index];
if (node?.group && !this.isExpanded(index)) this.setExpanded(index, true);
}
/** Collapse the parent node at `index` (no-op for leaves or if already shut). */
collapse(index: number): void {
const node = this.nodes[index];
if (node?.group && this.isExpanded(index)) this.setExpanded(index, false);
}
/** Toggle the parent node at `index`. */
toggle(index: number): void {
if (this.isExpanded(index)) this.collapse(index);
else this.expand(index);
}
/**
* Select the node at `index`. In single-selection mode any other selected
* node is cleared first; in multiple mode the node is simply added.
*/
select(index: number): void {
if (this.options.selectionMode === 'none' || !this.nodes[index]) return;
if (this.options.selectionMode === 'single') {
this.nodes.forEach((_, i) => {
if (i !== index && this.isSelected(i)) this.setSelected(i, false);
});
}
this.setSelected(index, true);
}
/** Deselect the node at `index`. */
deselect(index: number): void {
if (this.options.selectionMode === 'none') return;
this.setSelected(index, false);
}
/** Move keyboard focus to the node at `index` (only if it is currently visible). */
focusItem(index: number): void {
const node = this.nodes[index];
if (node && this.isVisible(node)) node.li.focus();
}
/**
* Remove all listeners and clear any pending type-ahead timer. ARIA attributes
* are left in place. Call before discarding the subtree or re-initialising.
*/
destroy(): void {
this.root.removeEventListener('click', this.onClick);
this.root.removeEventListener('keydown', this.onKeydown);
this.root.removeEventListener('focusin', this.onFocusIn);
if (this.typeaheadTimer !== null) window.clearTimeout(this.typeaheadTimer);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Resolve the {@link TreeNode} owning `target`, or `null` if outside the tree. */
private findNode(target: Element | null): TreeNode | null {
const li = target?.closest<HTMLElement>(SELECTORS.item) ?? null;
if (!li || !this.root.contains(li)) return null;
const index = this.indexByLi.get(li);
return index === undefined ? null : this.nodes[index] ?? null;
}
/** Index of a node in {@link nodes}. */
private indexOf(node: TreeNode): number {
return this.indexByLi.get(node.li) ?? -1;
}
private isExpandedNode(node: TreeNode | undefined): boolean {
return !!node?.group && node.li.getAttribute('aria-expanded') === 'true';
}
/** A node is visible iff every ancestor is an expanded parent. */
private isVisible(node: TreeNode): boolean {
for (let p = node.parent; p; p = p.parent) {
if (!this.isExpandedNode(p)) return false;
}
return true;
}
/** All nodes currently visible, in document order. */
private visibleNodes(): TreeNode[] {
return this.nodes.filter((node) => this.isVisible(node));
}
/** The first child of `node` in document order, or `null` for a leaf. */
private firstChildOf(node: TreeNode): TreeNode | null {
return this.nodes.find((candidate) => candidate.parent === node) ?? null;
}
/** Move focus to the visible node `delta` steps from `node` (honours wrap). */
private focusRelative(node: TreeNode, delta: 1 | -1): void {
const visible = this.visibleNodes();
const here = visible.indexOf(node);
if (here === -1) return;
let next = here + delta;
if (next < 0) next = this.options.wrapFocus ? visible.length - 1 : -1;
else if (next >= visible.length) next = this.options.wrapFocus ? 0 : -1;
if (next !== -1) visible[next]?.li.focus();
}
/** Expand every sibling of `node` (same parent) that is itself a parent. */
private expandSiblings(node: TreeNode): void {
this.nodes.forEach((candidate, i) => {
if (candidate.parent === node.parent && candidate.group) this.expand(i);
});
}
/** Focus the next visible node whose label matches the typed buffer. */
private typeahead(char: string, current: TreeNode): void {
this.typeaheadBuffer += char.toLowerCase();
if (this.typeaheadTimer !== null) window.clearTimeout(this.typeaheadTimer);
this.typeaheadTimer = window.setTimeout(() => {
this.typeaheadBuffer = '';
this.typeaheadTimer = null;
}, TYPEAHEAD_TIMEOUT);
const visible = this.visibleNodes();
const start = visible.indexOf(current);
if (start === -1 || visible.length === 0) return;
// Search forward from the node after the current one, wrapping around.
for (let step = 1; step <= visible.length; step++) {
const candidate = visible[(start + step) % visible.length];
const text = (candidate?.label.textContent ?? '').trim().toLowerCase();
if (candidate && text.startsWith(this.typeaheadBuffer)) {
candidate.li.focus();
return;
}
}
}
/** Make the node at `index` the single tab stop. */
private setTabStop(index: number): void {
this.nodes.forEach((node, i) => {
node.li.tabIndex = i === index ? 0 : -1;
});
}
/**
* The primary action for a node, shared by click and Enter/Space: toggle a
* parent's expansion, apply selection per mode, and always emit
* `treeview:activate` so consumers can react (e.g. follow a link).
*/
private primaryAction(index: number): void {
const node = this.nodes[index];
if (!node) return;
if (node.group) this.toggle(index);
if (this.options.selectionMode === 'single') this.select(index);
else if (this.options.selectionMode === 'multiple') {
this.setSelected(index, !this.isSelected(index));
}
this.dispatch<TreeViewActivateDetail>('treeview:activate', { index, item: node.li });
}
// ---------------------------------------------------------------------------
// Internal state mutation — every change funnels through these two methods so
// the DOM can never drift out of a valid ARIA state.
// ---------------------------------------------------------------------------
private setExpanded(index: number, expanded: boolean, opts: { silent?: boolean } = {}): void {
const node = this.nodes[index];
if (!node?.group) return;
node.li.setAttribute('aria-expanded', String(expanded));
node.group.hidden = !expanded;
// If focus was inside the subtree we just collapsed, pull it up to the
// parent so keyboard focus never lands on a hidden node.
if (!expanded) {
const active = document.activeElement;
if (active instanceof HTMLElement && node.group.contains(active)) node.li.focus();
}
if (!opts.silent) {
this.dispatch<TreeViewToggleDetail>('treeview:toggle', {
index,
expanded,
item: node.li,
group: node.group,
});
}
}
private setSelected(index: number, selected: boolean, opts: { silent?: boolean } = {}): void {
const node = this.nodes[index];
if (!node || this.options.selectionMode === 'none') return;
if (this.isSelected(index) === selected) return; // already in the target state
node.li.setAttribute('aria-selected', String(selected));
if (!opts.silent) {
this.dispatch<TreeViewSelectDetail>('treeview:select', { index, selected, item: node.li });
}
}
/** Dispatch a bubbling CustomEvent from the root. */
private dispatch<T>(type: string, detail: T): void {
this.root.dispatchEvent(new CustomEvent<T>(type, { detail, bubbles: true }));
}
}
treeview.css (raw)
/*
* TreeView — functional-only skeleton of styling hooks
* ====================================================
*
* This file is deliberately EMPTY of cosmetics. It exists as a documented
* skeleton: every class the component produces and every state/ARIA selector it
* toggles is listed below as a (mostly empty) rule block, so a design system can
* see exactly which hooks exist and drop its own values in. There is no
* indentation, no list-marker handling, no expand/collapse glyphs, no selection
* highlight, no colours or fonts here — the component falls back entirely to the
* browser's default rendering of nested lists.
*
* Why empty is enough:
* - Collapsed groups are hidden by JS setting the native `hidden` attribute;
* the UA stylesheet's `[hidden] { display: none }` does the hiding.
* - The keyboard focus indicator is the browser's default focus ring on the
* focusable treeitem.
* - Expansion and selection are ARIA state on the treeitem, applied by JS;
* none of it depends on CSS.
* So removing the cosmetic CSS cannot break functionality.
*
* IMPORTANT — do not reintroduce these:
* - Never set `display` on `.treeview__group`. Collapsed groups are hidden via
* the native `hidden` attribute; a `display` rule here would override
* `[hidden]` and break hiding. `[hidden] { display: none }` from the UA
* stylesheet must always win.
* - Never remove the focus indicator (e.g. `outline: 0` / `outline: none` on
* the treeitem or its `:focus-visible`). The focusable element is the
* `.treeview__item`, not the label; the browser default provides its focus
* ring and suppressing it would make keyboard use inaccessible.
*/
/* ---------------------------------------------------------------------------
* Element hooks — one placeholder per class the component emits.
* ------------------------------------------------------------------------- */
.treeview {
}
.treeview__group {
}
.treeview__item {
}
.treeview__label {
}
/* ---------------------------------------------------------------------------
* State / ARIA hooks — these mirror the attributes the JS toggles, so styling
* always matches what assistive tech announces.
* ------------------------------------------------------------------------- */
/* Expanded parent node. */
.treeview__item[aria-expanded='true'] {
}
/* Collapsed parent node. */
.treeview__item[aria-expanded='false'] {
}
/* Selected node. */
.treeview__item[aria-selected='true'] {
}
/* Unselected node. */
.treeview__item[aria-selected='false'] {
}
/* Keyboard focus lands on the treeitem, not the label. Leave empty so the
browser default focus ring stands; never suppress it. */
.treeview__item:focus-visible {
}
/* Collapsed child group. Hidden by the native `hidden` attribute — never add a
`display` here (see header note). */
.treeview__group[hidden] {
}