Tree View
A hierarchical list where items with children can be expanded or collapsed. Common uses include file browsers, navigation menus, and organizational charts.
Demo
Single-Select with Activation
- Documents
- report.pdf
- notes.txt
- Projects
- Project A
- Project B
- Images
- vacation.jpg
- profile.png
- readme.md
Multi-Select
- Documents
- report.pdf
- notes.txt
- Projects
- Project A
- Project B
- Images
- vacation.jpg
- profile.png
- readme.md
With Disabled Nodes
- Accessible Folder
- file1.txt
- file2.txt
- Restricted Folder
- secret.txt
- public.txt
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
tree | Container (<ul>) | The tree widget container |
treeitem | Each node (<li>) | Individual tree nodes (both parent and leaf) |
group | Child container (<ul>) | Container for child nodes of an expanded parent |
WAI-ARIA tree role (opens in new tab)
WAI-ARIA Properties (Tree Container)
| Attribute | Values | Required | Description |
|---|---|---|---|
role="tree" | - | Yes | Identifies the container as a tree widget |
aria-label | String | Yes* | Accessible name for the tree |
aria-labelledby | ID reference | Yes* | Alternative to aria-label (takes precedence) |
aria-multiselectable | true | No | Only present for multi-select mode |
* Either aria-label or aria-labelledby is required.
WAI-ARIA States (Tree Items)
| Attribute | Values | Required | Description |
|---|---|---|---|
aria-expanded | true | false | Yes* | Present on parent nodes only. Indicates expansion state. |
aria-selected | true | false | Yes** | All treeitems must have this (selected=true, others=false) |
aria-disabled | true | No | Indicates the node is disabled |
tabindex | 0 | -1 | Yes | Roving tabindex for focus management |
* Required on parent nodes. Leaf nodes must NOT have aria-expanded.
** When selection is supported, ALL treeitems must have aria-selected.
Keyboard Support
Navigation
| Key | Action |
|---|---|
| ↓ | Move focus to next visible node |
| ↑ | Move focus to previous visible node |
| → | Closed parent: expand / Open parent: move to first child / Leaf: no action |
| ← | Open parent: collapse / Child or closed parent: move to parent / Root: no action |
| Home | Move focus to first node |
| End | Move focus to last visible node |
| Enter | Select and activate node (see Selection section below) |
| * | Expand all siblings at current level |
| Type characters | Move focus to next visible node starting with that character |
Selection (Single-Select Mode)
| Key | Action |
|---|---|
| ↓ / ↑ | Move focus only (selection does NOT follow focus) |
| Enter | Select focused node and activate (fire onActivate callback) |
| Space | Select focused node and activate (fire onActivate callback) |
| Click | Select clicked node and activate (fire onActivate callback) |
Selection (Multi-Select Mode)
| Key | Action |
|---|---|
| Space | Toggle selection of focused node |
| Ctrl + Space | Toggle selection without moving focus |
| Shift + ↓ / ↑ | Extend selection range from anchor |
| Shift + Home | Extend selection to first node |
| Shift + End | Extend selection to last visible node |
| Ctrl + A | Select all visible nodes |
Focus Management
This component uses roving tabindex for focus management:
- Only one node has
tabindex="0"(the focused node) - All other nodes have
tabindex="-1" - Tree is a single Tab stop (Tab enters tree, Shift+Tab exits)
- Focus moves only among visible nodes (collapsed children are skipped)
- When a parent is collapsed while a child has focus, focus moves to the parent
Disabled Nodes
- Have
aria-disabled="true" - Are focusable (included in keyboard navigation)
- Cannot be selected or activated
- Cannot be expanded or collapsed if a parent node
- Visually distinct (e.g., grayed out)
Source Code
---
/**
* APG Tree View Pattern - Astro Implementation
*
* A widget that presents a hierarchical list where items with children can be
* expanded or collapsed.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
*/
export interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
disabled?: boolean;
}
export interface Props {
/** Tree node data */
nodes: TreeNode[];
/** Enable multi-select mode */
multiselectable?: boolean;
/** Initially selected node IDs */
defaultSelectedIds?: string[];
/** Initially expanded node IDs */
defaultExpandedIds?: string[];
/** Accessible label for the tree */
'aria-label'?: string;
/** ID of element that labels the tree */
'aria-labelledby'?: string;
/** Type-ahead timeout in ms */
typeAheadTimeout?: number;
/** Additional CSS class */
class?: string;
}
interface FlatNode {
node: TreeNode;
depth: number;
parentId: string | null;
hasChildren: boolean;
}
const {
nodes = [],
multiselectable = false,
defaultSelectedIds = [],
defaultExpandedIds = [],
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
typeAheadTimeout = 500,
class: className = '',
} = Astro.props;
const instanceId = `treeview-${Math.random().toString(36).slice(2, 11)}`;
// Flatten tree for navigation
function flattenTree(
treeNodes: TreeNode[],
depth: number = 0,
parentId: string | null = null
): FlatNode[] {
const result: FlatNode[] = [];
for (const node of treeNodes) {
const hasChildren = Boolean(node.children && node.children.length > 0);
result.push({ node, depth, parentId, hasChildren });
if (node.children) {
result.push(...flattenTree(node.children, depth + 1, node.id));
}
}
return result;
}
const allNodes = flattenTree(nodes);
const nodeMap = new Map<string, FlatNode>(allNodes.map((fn) => [fn.node.id, fn]));
// Initialize expanded IDs
const expandedIds = new Set(defaultExpandedIds);
// Initialize selected IDs (filter out disabled nodes)
const selectedIds = new Set<string>();
if (defaultSelectedIds.length > 0) {
for (const id of defaultSelectedIds) {
const flatNode = nodeMap.get(id);
if (flatNode && !flatNode.node.disabled) {
selectedIds.add(id);
}
}
}
// No auto-selection - user must explicitly select via Enter/Space/Click
// Find initial focus
let initialFocusId = [...selectedIds][0];
if (!initialFocusId) {
const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
if (firstEnabled) {
initialFocusId = firstEnabled.node.id;
}
}
const containerClass = `apg-treeview ${className}`.trim();
function getNodeClass(node: TreeNode, hasChildren: boolean): string {
const classes = ['apg-treeview-item'];
if (selectedIds.has(node.id)) {
classes.push('apg-treeview-item--selected');
}
if (node.disabled) {
classes.push('apg-treeview-item--disabled');
}
if (hasChildren) {
classes.push('apg-treeview-item--parent');
} else {
classes.push('apg-treeview-item--leaf');
}
return classes.join(' ');
}
// Generate HTML string for recursive rendering
function renderNodeHTML(node: TreeNode, depth: number): string {
const hasChildren = Boolean(node.children && node.children.length > 0);
const flatNode = nodeMap.get(node.id);
const isExpanded = expandedIds.has(node.id);
const isSelected = selectedIds.has(node.id);
const isFocused = node.id === initialFocusId;
const labelId = `${instanceId}-label-${node.id}`;
const parentId = flatNode?.parentId ?? '';
const nodeClass = getNodeClass(node, hasChildren);
const ariaExpandedAttr = hasChildren ? ` aria-expanded="${isExpanded}"` : '';
const ariaDisabledAttr = node.disabled ? ' aria-disabled="true"' : '';
const parentIdAttr = parentId ? ` data-parent-id="${parentId}"` : '';
const hasChildrenAttr = hasChildren ? ' data-has-children="true"' : '';
const hiddenAttr = !isExpanded ? ' hidden' : '';
const icon = hasChildren
? `<span class="apg-treeview-item-icon" aria-hidden="true">${isExpanded ? '▼' : '▶'}</span>`
: '';
const childrenHTML =
hasChildren && node.children
? `<ul role="group" class="apg-treeview-group"${hiddenAttr}>${node.children.map((child) => renderNodeHTML(child, depth + 1)).join('')}</ul>`
: '';
return `<li role="treeitem" data-node-id="${node.id}"${parentIdAttr}${hasChildrenAttr} data-depth="${depth}" aria-labelledby="${labelId}"${ariaExpandedAttr} aria-selected="${isSelected}"${ariaDisabledAttr} tabindex="${isFocused ? 0 : -1}" class="${nodeClass}" style="--depth: ${depth}"><span class="apg-treeview-item-content">${icon}<span id="${labelId}" class="apg-treeview-item-label">${node.label}</span></span>${childrenHTML}</li>`;
}
const treeHTML = nodes.map((node) => renderNodeHTML(node, 0)).join('');
---
<apg-treeview
data-multiselectable={multiselectable ? 'true' : undefined}
data-type-ahead-timeout={typeAheadTimeout}
data-initial-selected={JSON.stringify([...selectedIds])}
data-initial-expanded={JSON.stringify([...expandedIds])}
data-initial-focus-id={initialFocusId}
>
<ul
role="tree"
aria-multiselectable={multiselectable || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class={containerClass}
set:html={treeHTML}
/>
</apg-treeview>
<script>
interface FlatNode {
id: string;
label: string;
depth: number;
parentId: string | null;
hasChildren: boolean;
disabled: boolean;
}
class ApgTreeView extends HTMLElement {
private tree: HTMLElement | null = null;
private rafId: number | null = null;
private focusedId = '';
private selectionAnchor = '';
private selectedIds: Set<string> = new Set();
private expandedIds: Set<string> = new Set();
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private allNodes: FlatNode[] = [];
private nodeMap: Map<string, FlatNode> = new Map();
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.tree = this.querySelector('[role="tree"]');
if (!this.tree) {
console.warn('apg-treeview: tree element not found');
return;
}
// Build node map from DOM
this.buildNodeMap();
// Initialize from data attributes
try {
this.selectedIds = new Set(JSON.parse(this.dataset.initialSelected || '[]'));
this.expandedIds = new Set(JSON.parse(this.dataset.initialExpanded || '[]'));
} catch {
this.selectedIds = new Set();
this.expandedIds = new Set();
}
this.focusedId = this.dataset.initialFocusId || '';
this.selectionAnchor = this.focusedId;
this.tree.addEventListener('keydown', this.handleKeyDown);
this.tree.addEventListener('click', this.handleClick);
this.tree.addEventListener('focusin', this.handleFocus);
this.updateVisualState();
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
this.tree?.removeEventListener('keydown', this.handleKeyDown);
this.tree?.removeEventListener('click', this.handleClick);
this.tree?.removeEventListener('focusin', this.handleFocus);
this.tree = null;
}
private get isMultiselectable(): boolean {
return this.dataset.multiselectable === 'true';
}
private get typeAheadTimeout(): number {
return parseInt(this.dataset.typeAheadTimeout || '500', 10);
}
private buildNodeMap() {
this.allNodes = [];
this.nodeMap.clear();
const items = this.querySelectorAll<HTMLLIElement>('[role="treeitem"]');
items.forEach((item) => {
const id = item.dataset.nodeId || '';
const label = item.querySelector('.apg-treeview-item-label')?.textContent || '';
const depth = parseInt(item.dataset.depth || '0', 10);
const parentId = item.dataset.parentId || null;
const hasChildren = item.dataset.hasChildren === 'true';
const disabled = item.getAttribute('aria-disabled') === 'true';
const node: FlatNode = { id, label, depth, parentId, hasChildren, disabled };
this.allNodes.push(node);
this.nodeMap.set(id, node);
});
}
private getVisibleNodes(): FlatNode[] {
const result: FlatNode[] = [];
const collapsedParents = new Set<string>();
for (const node of this.allNodes) {
let isHidden = false;
let currentParentId = node.parentId;
while (currentParentId) {
if (collapsedParents.has(currentParentId) || !this.expandedIds.has(currentParentId)) {
isHidden = true;
break;
}
const parent = this.nodeMap.get(currentParentId);
currentParentId = parent?.parentId ?? null;
}
if (!isHidden) {
result.push(node);
if (node.hasChildren && !this.expandedIds.has(node.id)) {
collapsedParents.add(node.id);
}
}
}
return result;
}
private getVisibleIndexMap(): Map<string, number> {
const map = new Map<string, number>();
this.getVisibleNodes().forEach((node, index) => map.set(node.id, index));
return map;
}
private getNodeElement(nodeId: string): HTMLLIElement | null {
return this.querySelector<HTMLLIElement>(`[data-node-id="${CSS.escape(nodeId)}"]`);
}
private updateVisualState() {
// Update tabindex for roving tabindex
this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
const nodeId = item.dataset.nodeId || '';
item.tabIndex = nodeId === this.focusedId ? 0 : -1;
});
}
private focusNode(nodeId: string) {
this.focusedId = nodeId;
this.updateVisualState();
this.getNodeElement(nodeId)?.focus();
}
private expandNode(nodeId: string) {
const node = this.nodeMap.get(nodeId);
if (!node?.hasChildren || node.disabled) return;
if (this.expandedIds.has(nodeId)) return;
this.expandedIds.add(nodeId);
this.updateExpansionDOM(nodeId, true);
this.dispatchEvent(
new CustomEvent('expandedchange', {
detail: { expandedIds: [...this.expandedIds] },
bubbles: true,
})
);
}
private collapseNode(nodeId: string) {
const node = this.nodeMap.get(nodeId);
if (!node?.hasChildren || node.disabled) return;
if (!this.expandedIds.has(nodeId)) return;
this.expandedIds.delete(nodeId);
this.updateExpansionDOM(nodeId, false);
// If a child was focused, move focus to the collapsed parent
const currentFocused = this.nodeMap.get(this.focusedId);
if (currentFocused) {
let parentId = currentFocused.parentId;
while (parentId) {
if (parentId === nodeId) {
this.focusNode(nodeId);
break;
}
const parent = this.nodeMap.get(parentId);
parentId = parent?.parentId ?? null;
}
}
this.dispatchEvent(
new CustomEvent('expandedchange', {
detail: { expandedIds: [...this.expandedIds] },
bubbles: true,
})
);
}
private updateExpansionDOM(nodeId: string, expanded: boolean) {
const item = this.getNodeElement(nodeId);
if (!item) return;
item.setAttribute('aria-expanded', String(expanded));
const icon = item.querySelector('.apg-treeview-item-icon');
if (icon) {
icon.textContent = expanded ? '▼' : '▶';
}
const group = item.querySelector(':scope > [role="group"]');
if (group) {
if (expanded) {
group.removeAttribute('hidden');
} else {
group.setAttribute('hidden', '');
}
}
}
private expandAllSiblings(nodeId: string) {
const node = this.nodeMap.get(nodeId);
if (!node) return;
for (const n of this.allNodes) {
if (n.parentId === node.parentId && n.hasChildren && !n.disabled) {
if (!this.expandedIds.has(n.id)) {
this.expandedIds.add(n.id);
this.updateExpansionDOM(n.id, true);
}
}
}
this.dispatchEvent(
new CustomEvent('expandedchange', {
detail: { expandedIds: [...this.expandedIds] },
bubbles: true,
})
);
}
private updateSelection(nodeId: string | null, action: 'toggle' | 'set' | 'range' | 'all') {
const visibleNodes = this.getVisibleNodes();
const visibleIndexMap = this.getVisibleIndexMap();
if (action === 'all') {
this.selectedIds = new Set(visibleNodes.filter((n) => !n.disabled).map((n) => n.id));
} else if (action === 'range' && nodeId) {
const anchorIndex = visibleIndexMap.get(this.selectionAnchor) ?? 0;
const targetIndex = visibleIndexMap.get(nodeId) ?? 0;
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
for (let i = start; i <= end; i++) {
const node = visibleNodes[i];
if (node && !node.disabled) {
this.selectedIds.add(node.id);
}
}
} else if (nodeId) {
const node = this.nodeMap.get(nodeId);
if (node?.disabled) return;
if (this.isMultiselectable && action === 'toggle') {
if (this.selectedIds.has(nodeId)) {
this.selectedIds.delete(nodeId);
} else {
this.selectedIds.add(nodeId);
}
} else {
this.selectedIds = new Set([nodeId]);
}
}
// Update aria-selected
this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
const id = item.dataset.nodeId || '';
const isSelected = this.selectedIds.has(id);
item.setAttribute('aria-selected', String(isSelected));
item.classList.toggle('apg-treeview-item--selected', isSelected);
});
this.dispatchEvent(
new CustomEvent('selectionchange', {
detail: { selectedIds: [...this.selectedIds] },
bubbles: true,
})
);
}
private handleTypeAhead(char: string) {
const visibleNodes = this.getVisibleNodes();
if (visibleNodes.length === 0) return;
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
}
this.typeAheadBuffer += char.toLowerCase();
const buffer = this.typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
const visibleIndexMap = this.getVisibleIndexMap();
const currentIndex = visibleIndexMap.get(this.focusedId) ?? 0;
let startIndex: number;
let searchStr: string;
if (isSameChar) {
this.typeAheadBuffer = buffer[0];
startIndex = (currentIndex + 1) % visibleNodes.length;
searchStr = buffer[0];
} else if (buffer.length === 1) {
startIndex = (currentIndex + 1) % visibleNodes.length;
searchStr = buffer;
} else {
startIndex = currentIndex;
searchStr = buffer;
}
for (let i = 0; i < visibleNodes.length; i++) {
const index = (startIndex + i) % visibleNodes.length;
const node = visibleNodes[index];
if (node.disabled) continue;
if (node.label.toLowerCase().startsWith(searchStr)) {
this.focusNode(node.id);
this.selectionAnchor = node.id;
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const item = target.closest('[role="treeitem"]') as HTMLLIElement | null;
if (!item) return;
const nodeId = item.dataset.nodeId || '';
const node = this.nodeMap.get(nodeId);
if (!node) return;
event.stopPropagation();
this.focusNode(nodeId);
if (node.hasChildren) {
if (node.disabled) return;
if (this.expandedIds.has(nodeId)) {
this.collapseNode(nodeId);
} else {
this.expandNode(nodeId);
}
}
if (!node.disabled) {
if (this.isMultiselectable) {
this.updateSelection(nodeId, 'toggle');
} else {
this.updateSelection(nodeId, 'set');
}
this.selectionAnchor = nodeId;
// Fire activate event on click
this.dispatchEvent(
new CustomEvent('activate', {
detail: { nodeId },
bubbles: true,
})
);
}
};
private handleFocus = (event: FocusEvent) => {
const target = event.target as HTMLElement;
if (target.getAttribute('role') !== 'treeitem') return;
const nodeId = (target as HTMLLIElement).dataset.nodeId || '';
if (nodeId && nodeId !== this.focusedId) {
this.focusedId = nodeId;
this.updateVisualState();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const visibleNodes = this.getVisibleNodes();
if (visibleNodes.length === 0) return;
const { key, shiftKey, ctrlKey, metaKey } = event;
const visibleIndexMap = this.getVisibleIndexMap();
const currentIndex = visibleIndexMap.get(this.focusedId) ?? 0;
const currentNode = visibleNodes[currentIndex];
const handledKeys = [
'ArrowDown',
'ArrowUp',
'ArrowRight',
'ArrowLeft',
'Home',
'End',
'Enter',
' ',
'*',
];
if (
handledKeys.includes(key) ||
(key.length === 1 && !ctrlKey && !metaKey) ||
((key === 'a' || key === 'A') && (ctrlKey || metaKey) && this.isMultiselectable)
) {
event.preventDefault();
}
switch (key) {
case 'ArrowDown': {
if (currentIndex < visibleNodes.length - 1) {
const nextIndex = currentIndex + 1;
const nextNode = visibleNodes[nextIndex];
this.focusNode(nextNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(nextNode.id, 'range');
} else {
this.selectionAnchor = nextNode.id;
}
}
break;
}
case 'ArrowUp': {
if (currentIndex > 0) {
const prevIndex = currentIndex - 1;
const prevNode = visibleNodes[prevIndex];
this.focusNode(prevNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(prevNode.id, 'range');
} else {
this.selectionAnchor = prevNode.id;
}
}
break;
}
case 'ArrowRight': {
if (!currentNode) break;
if (currentNode.hasChildren && !currentNode.disabled) {
if (!this.expandedIds.has(this.focusedId)) {
this.expandNode(this.focusedId);
} else {
const nextIndex = currentIndex + 1;
if (nextIndex < visibleNodes.length) {
const nextNode = visibleNodes[nextIndex];
if (nextNode.parentId === this.focusedId) {
this.focusNode(nextNode.id);
this.selectionAnchor = nextNode.id;
}
}
}
}
break;
}
case 'ArrowLeft': {
if (!currentNode) break;
if (
currentNode.hasChildren &&
this.expandedIds.has(this.focusedId) &&
!currentNode.disabled
) {
this.collapseNode(this.focusedId);
} else if (currentNode.parentId) {
this.focusNode(currentNode.parentId);
this.selectionAnchor = currentNode.parentId;
}
break;
}
case 'Home': {
const firstNode = visibleNodes[0];
this.focusNode(firstNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(firstNode.id, 'range');
} else {
this.selectionAnchor = firstNode.id;
}
break;
}
case 'End': {
const lastNode = visibleNodes[visibleNodes.length - 1];
this.focusNode(lastNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(lastNode.id, 'range');
} else {
this.selectionAnchor = lastNode.id;
}
break;
}
case 'Enter': {
if (currentNode && !currentNode.disabled) {
if (this.isMultiselectable) {
this.updateSelection(this.focusedId, 'toggle');
this.selectionAnchor = this.focusedId;
} else {
this.updateSelection(this.focusedId, 'set');
}
this.dispatchEvent(
new CustomEvent('activate', {
detail: { nodeId: this.focusedId },
bubbles: true,
})
);
}
break;
}
case ' ': {
if (currentNode && !currentNode.disabled) {
if (this.isMultiselectable) {
this.updateSelection(this.focusedId, 'toggle');
// Ctrl+Space: toggle without updating anchor
// Space alone: update anchor for subsequent Shift+Arrow operations
if (!ctrlKey) {
this.selectionAnchor = this.focusedId;
}
} else {
this.updateSelection(this.focusedId, 'set');
this.dispatchEvent(
new CustomEvent('activate', {
detail: { nodeId: this.focusedId },
bubbles: true,
})
);
}
}
break;
}
case '*': {
this.expandAllSiblings(this.focusedId);
break;
}
case 'a':
case 'A': {
if ((ctrlKey || metaKey) && this.isMultiselectable) {
this.updateSelection(null, 'all');
} else if (!ctrlKey && !metaKey) {
this.handleTypeAhead(key);
}
break;
}
default: {
if (key.length === 1 && !ctrlKey && !metaKey) {
this.handleTypeAhead(key);
}
}
}
};
}
if (!customElements.get('apg-treeview')) {
customElements.define('apg-treeview', ApgTreeView);
}
</script> Usage
---
import TreeView from './TreeView.astro';
const nodes = [
{
id: 'documents',
label: 'Documents',
children: [
{ id: 'report', label: 'report.pdf' },
{ id: 'notes', label: 'notes.txt' },
],
},
{ id: 'readme', label: 'readme.md' },
];
---
<!-- Basic single-select -->
<TreeView
nodes={nodes}
aria-label="File Explorer"
/>
<!-- With default expanded -->
<TreeView
nodes={nodes}
aria-label="Files"
defaultExpandedIds={['documents']}
/>
<!-- Multi-select mode -->
<TreeView
nodes={nodes}
aria-label="Files"
multiselectable
/>
<!-- With callbacks via custom events -->
<TreeView
nodes={nodes}
aria-label="Files"
/>
<script>
document.querySelector('apg-treeview')?.addEventListener('selection-change', (e) => {
console.log('Selected:', e.detail);
});
document.querySelector('apg-treeview')?.addEventListener('expanded-change', (e) => {
console.log('Expanded:', e.detail);
});
document.querySelector('apg-treeview')?.addEventListener('activate', (e) => {
console.log('Activated:', e.detail);
});
</script> API
TreeNode Interface
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier for the node |
label | string | Yes | Display label for the node |
children | TreeNode[] | No | Child nodes (makes this a parent node) |
disabled | boolean | No | Prevents selection, activation, and expansion |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | TreeNode[] | Required | Array of tree nodes |
multiselectable | boolean | false | Enable multi-select mode |
defaultSelectedIds | string[] | [] | Initially selected node IDs |
defaultExpandedIds | string[] | [] | Initially expanded node IDs |
typeAheadTimeout | number | 500 | Type-ahead buffer reset timeout (ms) |
aria-label | string | - | Accessible label for the tree |
aria-labelledby | string | - | ID of labeling element |
Custom Events
| Event | Detail | Description |
|---|---|---|
selection-change | string[] | Dispatched when selection changes |
expanded-change | string[] | Dispatched when expansion state changes |
activate | string | Dispatched when node is activated (Enter key) |
Testing
Tests verify APG compliance for ARIA attributes, keyboard interactions, expansion/collapse behavior, selection models, and accessibility requirements. The Tree View component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (role="tree", role="treeitem", role="group")
- Expansion state (aria-expanded)
- Selection state (aria-selected, aria-multiselectable)
- Disabled state (aria-disabled)
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- Arrow key navigation (ArrowUp, ArrowDown, Home, End)
- Expand/collapse with ArrowRight/ArrowLeft
- Selection with Enter, Space, and click
- Multi-select with Shift+Arrow
- Type-ahead character navigation
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority: ARIA Structure
| Test | Description |
|---|---|
role="tree" | Container element has the tree role |
role="treeitem" | Each node has the treeitem role |
role="group" | Child containers have the group role |
aria-expanded (parent) | Parent nodes have aria-expanded (true or false) |
aria-expanded (leaf) | Leaf nodes do NOT have aria-expanded |
aria-selected | All treeitems have aria-selected (true or false) |
aria-multiselectable | Tree has aria-multiselectable="true" in multi-select mode |
aria-disabled | Disabled nodes have aria-disabled="true" |
High Priority: Accessible Name
| Test | Description |
|---|---|
aria-label | Tree has accessible name via aria-label |
aria-labelledby | Tree can use aria-labelledby (takes precedence) |
High Priority: Navigation
| Test | Description |
|---|---|
ArrowDown | Moves to next visible node |
ArrowUp | Moves to previous visible node |
Home | Moves to first node |
End | Moves to last visible node |
Type-ahead | Typing characters focuses matching visible node |
High Priority: Expand/Collapse
| Test | Description |
|---|---|
ArrowRight (closed parent) | Expands the parent node |
ArrowRight (open parent) | Moves to first child |
ArrowRight (leaf) | Does nothing |
ArrowLeft (open parent) | Collapses the parent node |
ArrowLeft (child/closed) | Moves to parent node |
ArrowLeft (root) | Does nothing |
* (asterisk) | Expands all siblings at current level |
Enter | Activates node (does NOT toggle expansion) |
High Priority: Selection (Single-Select)
| Test | Description |
|---|---|
Arrow navigation | Selection follows focus (arrows change selection) |
Space | Has no effect in single-select mode |
Only one selected | Only one node can be selected at a time |
High Priority: Selection (Multi-Select)
| Test | Description |
|---|---|
Space | Toggles selection of focused node |
Ctrl+Space | Toggles selection without moving focus |
Shift+Arrow | Extends selection range from anchor |
Shift+Home | Extends selection to first node |
Shift+End | Extends selection to last visible node |
Ctrl+A | Selects all visible nodes |
Multiple selection | Multiple nodes can be selected simultaneously |
High Priority: Focus Management
| Test | Description |
|---|---|
Single Tab stop | Tree is a single Tab stop (Tab/Shift+Tab) |
tabindex="0" | Focused node has tabindex="0" |
tabindex="-1" | Other nodes have tabindex="-1" |
Skip collapsed | Collapsed children are skipped during navigation |
Focus to parent | Focus moves to parent when child's parent is collapsed |
Medium Priority: Disabled Nodes
| Test | Description |
|---|---|
Focusable | Disabled nodes can receive focus |
Not selectable | Disabled nodes cannot be selected |
Not expandable | Disabled parent nodes cannot be expanded/collapsed |
Not activatable | Enter key does not activate disabled nodes |
Medium Priority: Type-Ahead
| Test | Description |
|---|---|
Visible nodes only | Type-ahead only searches visible nodes |
Cycle on repeat | Repeated character cycles through matches |
Multi-character prefix | Multiple characters form a search prefix |
Timeout reset | Buffer resets after timeout (default 500ms) |
Skip disabled | Type-ahead skips disabled nodes |
Medium Priority: Mouse Interaction
| Test | Description |
|---|---|
Click parent | Toggles expansion and selects node |
Click leaf | Selects the leaf node |
Click disabled | Disabled nodes cannot be selected or expanded |
Low Priority: Callbacks
| Test | Description |
|---|---|
onSelectionChange | Called with selected IDs when selection changes |
onExpandedChange | Called with expanded IDs when expansion changes |
onActivate | Called with node ID on Enter key |
Example Test Code
The following is the actual E2E test file (e2e/tree-view.spec.ts).
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Tree View Pattern
*
* A hierarchical list where items with children can be expanded or collapsed.
* Common uses include file browsers, navigation menus, and organizational charts.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getTree = (page: import('@playwright/test').Page) => {
return page.getByRole('tree');
};
const getTreeItems = (page: import('@playwright/test').Page) => {
return page.getByRole('treeitem');
};
const getGroups = (page: import('@playwright/test').Page) => {
return page.getByRole('group');
};
/**
* Click on a treeitem element and wait for it to receive focus.
* This is needed because:
* 1. Frameworks like React apply focus asynchronously via useLayoutEffect
* 2. Expanded parent treeitems contain child elements, so we must click
* on the label area (top of the element) to avoid hitting children
* which have stopPropagation() on their click handlers.
*
* NOTE: Clicking on a parent node TOGGLES its expansion state.
* Use focusWithoutClick() for tests where you need to focus without side effects.
*/
const clickAndWaitForFocus = async (
element: import('@playwright/test').Locator,
_page: import('@playwright/test').Page
) => {
// Click at the top-left area of the element to hit the label, not children
// Use position { x: 10, y: 10 } to click near the top-left corner
await element.click({ position: { x: 10, y: 10 } });
// Wait for the element to become the active element
await expect
.poll(
async () => {
return await element.evaluate((el) => document.activeElement === el);
},
{ timeout: 5000 }
)
.toBe(true);
};
/**
* Focus a treeitem element without triggering click handlers.
* This is useful for testing keyboard navigation where we don't want
* to trigger expansion toggle that happens on click.
*/
const focusWithoutClick = async (
element: import('@playwright/test').Locator,
_page: import('@playwright/test').Page
) => {
await element.focus();
// Wait for the element to become the active element
await expect
.poll(
async () => {
return await element.evaluate((el) => document.activeElement === el);
},
{ timeout: 5000 }
)
.toBe(true);
};
/**
* Press a key on a focused element and wait for focus to settle on a treeitem.
* Uses element.press() for stability instead of page.keyboard.press().
*/
const pressKeyOnElement = async (element: import('@playwright/test').Locator, key: string) => {
await expect(element).toBeFocused();
await element.press(key);
// Wait for focus to settle on a treeitem
await expect(element.page().locator('[role="treeitem"]:focus')).toBeVisible();
};
/**
* Navigate to a target node by pressing ArrowDown multiple times.
* Waits for focus to settle after each key press.
*/
const navigateToIndex = async (page: import('@playwright/test').Page, targetIndex: number) => {
for (let i = 0; i < targetIndex; i++) {
const currentFocused = page.locator('[role="treeitem"]:focus');
await pressKeyOnElement(currentFocused, 'ArrowDown');
}
};
/**
* Wait for page hydration to complete.
* This ensures:
* 1. Tree element is rendered
* 2. Treeitems have proper aria-labelledby (not Svelte's pre-hydration IDs)
* 3. Interactive handlers are attached (one item has tabindex="0")
*/
async function waitForHydration(page: import('@playwright/test').Page) {
// Wait for basic rendering
await getTree(page).first().waitFor();
// Wait for hydration - ensure treeitems have proper aria-labelledby
const firstItem = getTreeItems(page).first();
await expect
.poll(async () => {
const labelledby = await firstItem.getAttribute('aria-labelledby');
return labelledby && labelledby.length > 1 && !labelledby.startsWith('-');
})
.toBe(true);
// Wait for full hydration - ensure interactive handlers are attached
await expect
.poll(async () => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const count = await items.count();
let hasFocusable = false;
for (let i = 0; i < count; i++) {
const tabindex = await items.nth(i).getAttribute('tabindex');
if (tabindex === '0') {
hasFocusable = true;
break;
}
}
return hasFocusable;
})
.toBe(true);
}
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Tree View (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('container has role="tree"', async ({ page }) => {
const tree = getTree(page).first();
await expect(tree).toBeVisible();
});
test('nodes have role="treeitem"', async ({ page }) => {
const items = getTreeItems(page);
const count = await items.count();
expect(count).toBeGreaterThan(0);
});
test('child containers have role="group"', async ({ page }) => {
// First expand a parent node to make group visible
const tree = getTree(page).first();
const parentItem = tree.getByRole('treeitem', { expanded: true }).first();
if ((await parentItem.count()) > 0) {
const groups = getGroups(page);
const count = await groups.count();
expect(count).toBeGreaterThan(0);
}
});
test('tree has accessible name via aria-label', async ({ page }) => {
const tree = getTree(page).first();
const label = await tree.getAttribute('aria-label');
expect(label).toBeTruthy();
});
test('parent nodes have aria-expanded', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node (one with children)
const expandedItems = tree.locator('[role="treeitem"][aria-expanded]');
const count = await expandedItems.count();
expect(count).toBeGreaterThan(0);
const firstExpanded = expandedItems.first();
const expanded = await firstExpanded.getAttribute('aria-expanded');
expect(['true', 'false']).toContain(expanded);
});
test('leaf nodes do NOT have aria-expanded', async ({ page }) => {
const tree = getTree(page).first();
// Expand all to see leaf nodes
const items = tree.getByRole('treeitem');
const count = await items.count();
for (let i = 0; i < count; i++) {
const item = items.nth(i);
const expanded = await item.getAttribute('aria-expanded');
const hasGroup = (await item.locator('[role="group"]').count()) > 0;
// If item has no children (no group), it shouldn't have aria-expanded
if (!hasGroup && expanded === null) {
// This is correct - leaf node without aria-expanded
continue;
}
// If it has aria-expanded, it should also have children
if (expanded !== null) {
// Parent nodes with aria-expanded should have potential children
continue;
}
}
});
test('all treeitems have aria-selected', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const count = await items.count();
for (let i = 0; i < count; i++) {
const item = items.nth(i);
const selected = await item.getAttribute('aria-selected');
expect(['true', 'false']).toContain(selected);
}
});
test('multi-select tree has aria-multiselectable', async ({ page }) => {
// Find multi-select tree (second tree on demo page)
const trees = getTree(page);
const count = await trees.count();
// Look for a tree with aria-multiselectable
let foundMultiselect = false;
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const multiselectable = await tree.getAttribute('aria-multiselectable');
if (multiselectable === 'true') {
foundMultiselect = true;
break;
}
}
// Demo page should have at least one multi-select tree
expect(foundMultiselect).toBe(true);
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Navigation
// ------------------------------------------
test.describe('APG: Keyboard Navigation', () => {
test('ArrowDown moves to next visible node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
// Focus should have moved to a different item
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
});
test('ArrowUp moves to previous visible node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Click second item first
const secondItem = items.nth(1);
await clickAndWaitForFocus(secondItem, page);
await expect(secondItem).toBeFocused();
await secondItem.press('ArrowUp');
const firstItem = items.first();
await expect(firstItem).toBeFocused();
});
test('Home moves to first node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Start from a later item
const laterItem = items.nth(2);
await clickAndWaitForFocus(laterItem, page);
await expect(laterItem).toBeFocused();
await laterItem.press('Home');
const firstItem = items.first();
await expect(firstItem).toBeFocused();
});
test('End moves to last visible node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Click on a non-parent item to avoid triggering expansion toggle
// Use the second item (first child of expanded parent)
const secondItem = items.nth(1);
await clickAndWaitForFocus(secondItem, page);
await expect(secondItem).toBeFocused();
await secondItem.press('End');
// Get current count after any DOM changes
const currentCount = await items.count();
const lastItem = items.nth(currentCount - 1);
await expect(lastItem).toBeFocused();
});
});
// ------------------------------------------
// 🔴 High Priority: Expand/Collapse
// ------------------------------------------
test.describe('APG: Expand/Collapse', () => {
test('ArrowRight on closed parent expands it', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node (any with aria-expanded attribute)
const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await anyParent.count()) > 0) {
// Get label ID for stable reference before any interaction
const labelledby = await anyParent.getAttribute('aria-labelledby');
const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
// Focus the first treeitem in tree using click
const firstItem = tree.getByRole('treeitem').first();
await clickAndWaitForFocus(firstItem, page);
// Navigate to find a collapsed parent using keyboard
// Press * to collapse all siblings first (so we have collapsed parents)
// Then we can test ArrowRight on a collapsed one
// Navigate to the parent node using keyboard
// Use Home to go to first item, then navigate down
await expect(firstItem).toBeFocused();
await firstItem.press('Home');
// Now navigate to the target node
const items = tree.getByRole('treeitem');
const count = await items.count();
let targetIndex = -1;
for (let i = 0; i < count; i++) {
const lb = await items.nth(i).getAttribute('aria-labelledby');
if (lb === labelledby) {
targetIndex = i;
break;
}
}
// Press ArrowDown to reach the target (with focus wait after each press)
await navigateToIndex(page, targetIndex);
// Now we're on the parent node - check if it's expanded and collapse if needed
const currentExpanded = await stableLocator.getAttribute('aria-expanded');
if (currentExpanded === 'true') {
// Collapse it first
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
}
// Now test ArrowRight to expand
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowRight');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
}
});
test('ArrowRight on open parent moves to first child', async ({ page }) => {
const tree = getTree(page).first();
// Find an expanded parent
const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();
if ((await expandedParent.count()) > 0) {
// Get parent's labelledby for stable reference
const parentLabelledby = await expandedParent.getAttribute('aria-labelledby');
// Focus without click to avoid toggling expansion
await focusWithoutClick(expandedParent, page);
await expect(expandedParent).toBeFocused();
await expandedParent.press('ArrowRight');
// Focus should move to first child - verify by checking:
// 1. Focus moved to a different element
// 2. The focused element is a treeitem
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeVisible();
// The focused item should not be the parent
const focusedLabelledby = await focusedItem.getAttribute('aria-labelledby');
expect(focusedLabelledby).not.toBe(parentLabelledby);
}
});
test('ArrowLeft on open parent collapses it', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node (any with aria-expanded attribute)
const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await anyParent.count()) > 0) {
// Get label ID for stable reference before any interaction
const labelledby = await anyParent.getAttribute('aria-labelledby');
const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
// Focus the first treeitem in tree using click
const firstItem = tree.getByRole('treeitem').first();
await clickAndWaitForFocus(firstItem, page);
// Navigate to the parent node using keyboard
await expect(firstItem).toBeFocused();
await firstItem.press('Home');
// Find the index of target node
const items = tree.getByRole('treeitem');
const count = await items.count();
let targetIndex = -1;
for (let i = 0; i < count; i++) {
const lb = await items.nth(i).getAttribute('aria-labelledby');
if (lb === labelledby) {
targetIndex = i;
break;
}
}
// Press ArrowDown to reach the target (with focus wait after each press)
await navigateToIndex(page, targetIndex);
// Now we're on the parent node - check if it's collapsed and expand if needed
const currentExpanded = await stableLocator.getAttribute('aria-expanded');
if (currentExpanded === 'false') {
// Expand it first
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowRight');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
}
// Now test ArrowLeft to collapse
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
}
});
test('ArrowLeft on child moves to parent', async ({ page }) => {
const tree = getTree(page).first();
// Find an expanded parent to access its children
const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();
if ((await expandedParent.count()) > 0) {
// Focus parent without click to avoid toggling expansion
await focusWithoutClick(expandedParent, page);
// Move to first child
await expect(expandedParent).toBeFocused();
await expandedParent.press('ArrowRight');
// Now press ArrowLeft to go back to parent
const focusedChild = page.locator('[role="treeitem"]:focus');
await expect(focusedChild).toBeFocused();
await focusedChild.press('ArrowLeft');
await expect(expandedParent).toBeFocused();
}
});
test('click on parent toggles expansion', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node
const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await parentItem.count()) > 0) {
const initialExpanded = await parentItem.getAttribute('aria-expanded');
// Click on label area (top of element) to avoid hitting children
await parentItem.click({ position: { x: 10, y: 10 } });
const newExpanded = await parentItem.getAttribute('aria-expanded');
expect(newExpanded).not.toBe(initialExpanded);
}
});
test('* key expands all siblings', async ({ page }) => {
const tree = getTree(page).first();
// Focus the first visible item using click
const firstItem = tree.getByRole('treeitem').first();
await clickAndWaitForFocus(firstItem, page);
// The * key expands all expandable siblings at the SAME LEVEL as the focused node
// First, let's ensure we're at the root level
await expect(firstItem).toBeFocused();
await firstItem.press('Home');
// Get all top-level treeitems (direct children of tree, not nested in groups)
// Top-level items are those that are not inside a group element
const topLevelParents = tree.locator(':scope > [role="treeitem"][aria-expanded]');
const topLevelCount = await topLevelParents.count();
if (topLevelCount > 0) {
// Collapse all top-level parents first so we can test * expanding them
for (let i = 0; i < topLevelCount; i++) {
const parent = topLevelParents.nth(i);
const expanded = await parent.getAttribute('aria-expanded');
if (expanded === 'true') {
// Navigate to this item and collapse it
const labelledby = await parent.getAttribute('aria-labelledby');
// Go home and navigate to find it
const currentFocused = page.locator('[role="treeitem"]:focus');
await pressKeyOnElement(currentFocused, 'Home');
const items = tree.getByRole('treeitem');
const count = await items.count();
for (let j = 0; j < count; j++) {
const lb = await items.nth(j).getAttribute('aria-labelledby');
if (lb === labelledby) {
// Navigate to this item (with focus wait after each press)
await navigateToIndex(page, j);
// Collapse it
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
await expect(parent).toHaveAttribute('aria-expanded', 'false');
break;
}
}
}
}
// Go back home
const currentFocused = page.locator('[role="treeitem"]:focus');
await pressKeyOnElement(currentFocused, 'Home');
// Press * to expand all siblings at the current level
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('*');
// Wait for all top-level parents to be expanded (state-based wait)
for (let i = 0; i < topLevelCount; i++) {
await expect(topLevelParents.nth(i)).toHaveAttribute('aria-expanded', 'true');
}
}
});
});
// ------------------------------------------
// 🔴 High Priority: Selection
// ------------------------------------------
test.describe('APG: Selection (Single-Select)', () => {
test('Enter selects focused node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Use first item for reliability, navigate to second using keyboard
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
// Navigate to second item
await pressKeyOnElement(firstItem, 'ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
// Press Enter to select
await secondItem.press('Enter');
await expect(secondItem).toHaveAttribute('aria-selected', 'true');
});
test('Space selects focused node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Navigate to another item without selecting (with focus wait)
await pressKeyOnElement(firstItem, 'ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
// Press Space to select
await secondItem.press('Space');
await expect(secondItem).toHaveAttribute('aria-selected', 'true');
// First item should no longer be selected in single-select
await expect(firstItem).toHaveAttribute('aria-selected', 'false');
});
test('arrow keys move focus without changing selection', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
// Select first item
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Press Enter to explicitly select it
await expect(firstItem).toBeFocused();
await firstItem.press('Enter');
// Navigate away (with focus wait)
await pressKeyOnElement(firstItem, 'ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
// Selection should stay on first item (focus moved but not selection)
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
});
});
// ------------------------------------------
// 🟡 Medium Priority: Multi-Select
// ------------------------------------------
test.describe('APG: Selection (Multi-Select)', () => {
test('Space toggles selection in multi-select', async ({ page }) => {
// Find multi-select tree
const trees = getTree(page);
const count = await trees.count();
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const multiselectable = await tree.getAttribute('aria-multiselectable');
if (multiselectable === 'true') {
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Toggle off
await expect(firstItem).toBeFocused();
await firstItem.press('Space');
await expect(firstItem).toHaveAttribute('aria-selected', 'false');
// Toggle on
await expect(firstItem).toBeFocused();
await firstItem.press('Space');
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
break;
}
}
});
test('Shift+Arrow extends selection range', async ({ page }) => {
// Find multi-select tree
const trees = getTree(page);
const count = await trees.count();
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const multiselectable = await tree.getAttribute('aria-multiselectable');
if (multiselectable === 'true') {
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Shift+ArrowDown to extend selection
await expect(firstItem).toBeFocused();
await firstItem.press('Shift+ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toHaveAttribute('aria-selected', 'true');
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
break;
}
}
});
});
// ------------------------------------------
// 🟡 Medium Priority: Disabled State
// ------------------------------------------
test.describe('Disabled State', () => {
test('disabled nodes have aria-disabled', async ({ page }) => {
const tree = getTree(page);
const disabledItems = tree.locator('[role="treeitem"][aria-disabled="true"]');
if ((await disabledItems.count()) > 0) {
const firstDisabled = disabledItems.first();
await expect(firstDisabled).toHaveAttribute('aria-disabled', 'true');
}
});
test('disabled nodes are focusable but not selectable', async ({ page }) => {
const trees = getTree(page);
const count = await trees.count();
// Look for tree with disabled items (third tree on demo page)
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const disabledItem = tree.locator('[role="treeitem"][aria-disabled="true"]').first();
if ((await disabledItem.count()) > 0) {
// Get the labelledby of the disabled item for navigation
const disabledLabelledby = await disabledItem.getAttribute('aria-labelledby');
// Focus the first item in this tree using keyboard navigation
const firstItem = tree.getByRole('treeitem').first();
await focusWithoutClick(firstItem, page);
// Navigate using keyboard to find the disabled item
const items = tree.getByRole('treeitem');
const itemCount = await items.count();
let foundDisabled = false;
for (let j = 0; j < itemCount; j++) {
const currentFocused = page.locator('[role="treeitem"]:focus');
const currentLabelledby = await currentFocused.getAttribute('aria-labelledby');
if (currentLabelledby === disabledLabelledby) {
foundDisabled = true;
break;
}
await pressKeyOnElement(currentFocused, 'ArrowDown');
}
if (foundDisabled) {
// Now we're on the disabled item via keyboard navigation
// Try to select it with Space (keep page.keyboard.press for disabled element tests)
await page.keyboard.press('Space');
// Should not be selected
await expect(disabledItem).toHaveAttribute('aria-selected', 'false');
}
break;
}
}
});
});
// ------------------------------------------
// 🟡 Medium Priority: Type-Ahead
// ------------------------------------------
test.describe('Type-Ahead', () => {
test('typing character focuses matching node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toBeFocused();
// Type 'r' to find nodes starting with 'r' (like 'readme.md')
await firstItem.press('r');
// Wait for type-ahead to process (state-based wait)
// Focus should move to a node starting with 'r'
await expect
.poll(
async () => {
const focused = page.locator('[role="treeitem"]:focus');
const labelledby = await focused.getAttribute('aria-labelledby');
if (!labelledby) return false;
const label = page.locator(`[id="${labelledby}"]`);
const text = await label.textContent();
return text?.toLowerCase().startsWith('r') ?? false;
},
{ timeout: 5000 }
)
.toBe(true);
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management
// ------------------------------------------
test.describe('Focus Management', () => {
test('focused node has tabindex="0"', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('tabindex', '0');
});
test('other nodes have tabindex="-1"', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('tabindex', '0');
// Check that at least one other item has tabindex="-1"
const secondItem = items.nth(1);
await expect(secondItem).toHaveAttribute('tabindex', '-1');
});
test('focus moves to parent when parent is collapsed', async ({ page }) => {
const tree = getTree(page).first();
// Find an expanded parent and save its aria-labelledby
const expandedParentLocator = tree
.locator('[role="treeitem"][aria-expanded="true"]')
.first();
if ((await expandedParentLocator.count()) > 0) {
// Get the parent's labelledby ID to use as a stable selector
const labelledby = await expandedParentLocator.getAttribute('aria-labelledby');
// Focus parent without click to avoid toggling expansion
await focusWithoutClick(expandedParentLocator, page);
// Navigate to a child
await expect(expandedParentLocator).toBeFocused();
await expandedParentLocator.press('ArrowRight');
// Collapse the parent by pressing ArrowLeft twice
// (first ArrowLeft goes back to parent, second collapses it)
let focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
// Use the stable selector to find the parent (now collapsed)
const parent = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
// Focus should be on the parent
await expect(parent).toBeFocused();
}
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const tree = getTree(page);
await tree.first().waitFor();
const results = await new AxeBuilder({ page })
.include('[role="tree"]')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Tree View - Cross-framework Consistency', () => {
test('all frameworks have trees', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
const trees = getTree(page);
const count = await trees.count();
expect(count).toBeGreaterThan(0);
}
});
test('all frameworks support click to expand/collapse', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
const tree = getTree(page).first();
const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await parentItem.count()) > 0) {
const initialExpanded = await parentItem.getAttribute('aria-expanded');
// Click on label area (top of element) to avoid hitting children
await parentItem.click({ position: { x: 10, y: 10 } });
// Wait for the expansion state to change
await expect(parentItem).not.toHaveAttribute('aria-expanded', initialExpanded!);
const newExpanded = await parentItem.getAttribute('aria-expanded');
expect(newExpanded).not.toBe(initialExpanded);
// Click again to toggle back
await parentItem.click({ position: { x: 10, y: 10 } });
await expect(parentItem).toHaveAttribute('aria-expanded', initialExpanded!);
}
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
const tree = getTree(page).first();
// Check tree has accessible name
const label = await tree.getAttribute('aria-label');
expect(label).toBeTruthy();
// Check treeitems exist
const items = tree.getByRole('treeitem');
const count = await items.count();
expect(count).toBeGreaterThan(0);
// Check first item has aria-selected
const firstItem = items.first();
const selected = await firstItem.getAttribute('aria-selected');
expect(['true', 'false']).toContain(selected);
}
});
}); Running Tests
# Run unit tests for Tree View
npm run test -- treeview
# Run E2E tests for Tree View (all frameworks)
npm run test:e2e:pattern --pattern=tree-view
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=tree-view
npm run test:e2e:vue:pattern --pattern=tree-view
npm run test:e2e:svelte:pattern --pattern=tree-view
npm run test:e2e:astro:pattern --pattern=tree-view Testing Tools
- React: React Testing Library (opens in new tab)
- Vue: Vue Testing Library (opens in new tab)
- Svelte: Svelte Testing Library (opens in new tab)
- Astro: Vitest with JSDOM for Web Component unit tests
- Accessibility: axe-core (opens in new tab)
- E2E: Playwright (opens in new tab)
Resources
- WAI-ARIA APG: Tree View Pattern (opens in new tab)
- APG Example: Navigation Treeview (opens in new tab)
- MDN: Web Components (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist