/**
* 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 `
`/`- ` 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 `
` 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 `- ` whose
* label is a `.treeview__label` child; a parent item additionally holds a
* `
` of child items. No ARIA is authored by hand.
*
* ```html
*
* -
* README.md
*
* -
* src
*
*
*
* ```
*
* Notes on the markup:
* - A node is a PARENT iff its `- ` contains a direct `.treeview__group`.
* - Mark a parent as initially open with `data-expanded` on its `
- `.
* - Mark a node as initially selected with `data-selected` on its `
- `
* (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 `
- ` element carrying `role="treeitem"`. */
readonly li: HTMLElement;
/** The label element whose text drives type-ahead. */
readonly label: HTMLElement;
/** The child 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;
private readonly nodes: readonly TreeNode[];
/** Fast lookup from a treeitem element to its index in {@link nodes}. */
private readonly indexByLi: Map;
/** 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(SELECTORS.item));
const nodes: TreeNode[] = lis.map((li, i) => {
const label = li.querySelector(`:scope > ${SELECTORS.label}`);
if (!label) {
throw new Error(`TreeView: treeitem #${i} is missing a "${SELECTORS.label}" child.`);
}
const group = li.querySelector(`: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(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();
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(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('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('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('treeview:select', { index, selected, item: node.li });
}
}
/** Dispatch a bubbling CustomEvent from the root. */
private dispatch(type: string, detail: T): void {
this.root.dispatchEvent(new CustomEvent(type, { detail, bubbles: true }));
}
}