Tree View
A hierarchical list where items with children can be expanded or collapsed. Common uses include file browsers, navigation menus, and organizational charts.
🤖 AI Implementation GuideDemo
Single-Select with Activation
- Documents
- report.pdf
- notes.txt
- Projects
- Images
- readme.md
Activated: Select a node with Enter, Space, or Click
Multi-Select
- Documents
- report.pdf
- notes.txt
- Projects
- Images
- vacation.jpg
- profile.png
- readme.md
With Disabled Nodes
- Accessible Folder
- file1.txt
- file2.txt
- Restricted Folder
- 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
TreeView.tsx
import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
export interface TreeNode {
/** Unique identifier for the node */
id: string;
/** Display label for the node */
label: string;
/** Child nodes (makes this a parent node) */
children?: TreeNode[];
/** When true, the node cannot be selected, activated, or expanded */
disabled?: boolean;
}
export interface TreeViewProps {
/** Array of tree nodes */
nodes: TreeNode[];
/** Enable multi-select mode */
multiselectable?: boolean;
/** Initially selected node ID(s) - uncontrolled */
defaultSelectedIds?: string[];
/** Currently selected node ID(s) - controlled */
selectedIds?: string[];
/** Callback when selection changes */
onSelectionChange?: (selectedIds: string[]) => void;
/** Initially expanded node IDs - uncontrolled */
defaultExpandedIds?: string[];
/** Currently expanded node IDs - controlled */
expandedIds?: string[];
/** Callback when expansion changes */
onExpandedChange?: (expandedIds: string[]) => void;
/** Callback when node is activated (Enter key) */
onActivate?: (nodeId: string) => void;
/** Type-ahead search timeout in ms */
typeAheadTimeout?: number;
/** Accessible label */
'aria-label'?: string;
/** ID of labeling element */
'aria-labelledby'?: string;
/** Additional CSS class */
className?: string;
}
interface FlatNode {
node: TreeNode;
depth: number;
parentId: string | null;
hasChildren: boolean;
}
export function TreeView({
nodes,
multiselectable = false,
defaultSelectedIds = [],
selectedIds: controlledSelectedIds,
onSelectionChange,
defaultExpandedIds = [],
expandedIds: controlledExpandedIds,
onExpandedChange,
onActivate,
typeAheadTimeout = 500,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
className = '',
}: TreeViewProps): React.ReactElement {
const instanceId = useId();
// Flatten tree for easier navigation
const flattenTree = useCallback(
(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 = useMemo(() => flattenTree(nodes), [nodes, flattenTree]);
const nodeMap = useMemo(() => {
const map = new Map<string, FlatNode>();
for (const flatNode of allNodes) {
map.set(flatNode.node.id, flatNode);
}
return map;
}, [allNodes]);
// Expansion state
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(
() => new Set(defaultExpandedIds)
);
const expandedIds = controlledExpandedIds ? new Set(controlledExpandedIds) : internalExpandedIds;
const updateExpandedIds = useCallback(
(newExpandedIds: Set<string>) => {
if (!controlledExpandedIds) {
setInternalExpandedIds(newExpandedIds);
}
onExpandedChange?.([...newExpandedIds]);
},
[controlledExpandedIds, onExpandedChange]
);
// Selection state
const getInitialSelectedIds = useCallback(() => {
// Filter out disabled nodes from default selection
if (defaultSelectedIds.length > 0) {
const validIds = defaultSelectedIds.filter((id) => {
const flatNode = nodeMap.get(id);
return flatNode && !flatNode.node.disabled;
});
if (validIds.length > 0) {
return new Set(validIds);
}
}
// No auto-selection - user must explicitly select via Enter/Space/Click
return new Set<string>();
}, [defaultSelectedIds, nodeMap]);
const [internalSelectedIds, setInternalSelectedIds] =
useState<Set<string>>(getInitialSelectedIds);
const selectedIds = controlledSelectedIds ? new Set(controlledSelectedIds) : internalSelectedIds;
const updateSelectedIds = useCallback(
(newSelectedIds: Set<string>) => {
if (!controlledSelectedIds) {
setInternalSelectedIds(newSelectedIds);
}
onSelectionChange?.([...newSelectedIds]);
},
[controlledSelectedIds, onSelectionChange]
);
// Focus state - find first valid node (prefer selected, then first non-disabled)
const [focusedId, setFocusedId] = useState<string>(() => {
const firstSelected = [...selectedIds][0];
if (firstSelected) {
const flatNode = nodeMap.get(firstSelected);
if (flatNode && !flatNode.node.disabled) {
return firstSelected;
}
}
// Fall back to first non-disabled node
const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
return firstEnabled?.node.id ?? '';
});
const nodeRefs = useRef<Map<string, HTMLLIElement>>(new Map());
const typeAheadBuffer = useRef<string>('');
const typeAheadTimeoutId = useRef<number | null>(null);
const selectionAnchor = useRef<string>(focusedId);
// Ref to track focused node synchronously (avoids stale closure issues)
const focusedIdRef = useRef<string>(focusedId);
// Get visible nodes (respecting expansion state)
const visibleNodes = useMemo(() => {
const result: FlatNode[] = [];
const collapsedParents = new Set<string>();
for (const flatNode of allNodes) {
// Check if any ancestor is collapsed
let isHidden = false;
let currentParentId = flatNode.parentId;
while (currentParentId) {
if (collapsedParents.has(currentParentId) || !expandedIds.has(currentParentId)) {
isHidden = true;
break;
}
const parent = nodeMap.get(currentParentId);
currentParentId = parent?.parentId ?? null;
}
if (!isHidden) {
result.push(flatNode);
if (flatNode.hasChildren && !expandedIds.has(flatNode.node.id)) {
collapsedParents.add(flatNode.node.id);
}
}
}
return result;
}, [allNodes, expandedIds, nodeMap]);
const visibleIndexMap = useMemo(() => {
const map = new Map<string, number>();
visibleNodes.forEach((flatNode, index) => map.set(flatNode.node.id, index));
return map;
}, [visibleNodes]);
// Ref to track the target node to focus (set before state update)
const pendingFocusRef = useRef<string | null>(null);
// Focus helpers
const focusNode = useCallback((nodeId: string) => {
focusedIdRef.current = nodeId;
pendingFocusRef.current = nodeId;
setFocusedId(nodeId);
}, []);
// Apply focus after render
useLayoutEffect(() => {
if (pendingFocusRef.current !== null) {
const targetId = pendingFocusRef.current;
pendingFocusRef.current = null;
nodeRefs.current.get(targetId)?.focus();
}
});
const focusByIndex = useCallback(
(index: number) => {
const flatNode = visibleNodes[index];
if (flatNode) {
focusNode(flatNode.node.id);
}
},
[visibleNodes, focusNode]
);
// Expansion helpers
const expandNode = useCallback(
(nodeId: string) => {
const flatNode = nodeMap.get(nodeId);
if (!flatNode?.hasChildren || flatNode.node.disabled) return;
if (expandedIds.has(nodeId)) return;
const newExpanded = new Set(expandedIds);
newExpanded.add(nodeId);
updateExpandedIds(newExpanded);
},
[nodeMap, expandedIds, updateExpandedIds]
);
const collapseNode = useCallback(
(nodeId: string) => {
const flatNode = nodeMap.get(nodeId);
if (!flatNode?.hasChildren || flatNode.node.disabled) return;
if (!expandedIds.has(nodeId)) return;
const newExpanded = new Set(expandedIds);
newExpanded.delete(nodeId);
updateExpandedIds(newExpanded);
// If a child of this node was focused, move focus to the collapsed parent
const currentFocused = nodeMap.get(focusedId);
if (currentFocused) {
let parentId = currentFocused.parentId;
while (parentId) {
if (parentId === nodeId) {
focusNode(nodeId);
break;
}
const parent = nodeMap.get(parentId);
parentId = parent?.parentId ?? null;
}
}
},
[nodeMap, expandedIds, updateExpandedIds, focusedId, focusNode]
);
const expandAllSiblings = useCallback(
(nodeId: string) => {
const flatNode = nodeMap.get(nodeId);
if (!flatNode) return;
const newExpanded = new Set(expandedIds);
for (const fn of allNodes) {
if (fn.parentId === flatNode.parentId && fn.hasChildren && !fn.node.disabled) {
newExpanded.add(fn.node.id);
}
}
updateExpandedIds(newExpanded);
},
[nodeMap, allNodes, expandedIds, updateExpandedIds]
);
// Selection helpers
const selectNode = useCallback(
(nodeId: string) => {
const flatNode = nodeMap.get(nodeId);
if (flatNode?.node.disabled) return;
if (multiselectable) {
const newSelected = new Set(selectedIds);
if (newSelected.has(nodeId)) {
newSelected.delete(nodeId);
} else {
newSelected.add(nodeId);
}
updateSelectedIds(newSelected);
} else {
updateSelectedIds(new Set([nodeId]));
}
},
[nodeMap, multiselectable, selectedIds, updateSelectedIds]
);
const selectRange = useCallback(
(fromId: string, toId: string) => {
const fromIndex = visibleIndexMap.get(fromId) ?? 0;
const toIndex = visibleIndexMap.get(toId) ?? 0;
const start = Math.min(fromIndex, toIndex);
const end = Math.max(fromIndex, toIndex);
const newSelected = new Set(selectedIds);
for (let i = start; i <= end; i++) {
const flatNode = visibleNodes[i];
if (flatNode && !flatNode.node.disabled) {
newSelected.add(flatNode.node.id);
}
}
updateSelectedIds(newSelected);
},
[visibleIndexMap, visibleNodes, selectedIds, updateSelectedIds]
);
const selectAllVisible = useCallback(() => {
const newSelected = new Set<string>();
for (const flatNode of visibleNodes) {
if (!flatNode.node.disabled) {
newSelected.add(flatNode.node.id);
}
}
updateSelectedIds(newSelected);
}, [visibleNodes, updateSelectedIds]);
// Type-ahead
const handleTypeAhead = useCallback(
(char: string) => {
if (visibleNodes.length === 0) return;
if (typeAheadTimeoutId.current !== null) {
clearTimeout(typeAheadTimeoutId.current);
}
typeAheadBuffer.current += char.toLowerCase();
const buffer = typeAheadBuffer.current;
// Check if same character repeated
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
const currentIndex = visibleIndexMap.get(focusedId) ?? 0;
let startIndex: number;
let searchStr: string;
if (isSameChar) {
// Same character repeated: cycle through matches starting from next
typeAheadBuffer.current = buffer[0];
startIndex = (currentIndex + 1) % visibleNodes.length;
searchStr = buffer[0];
} else if (buffer.length === 1) {
// Single character: start from next to cycle through matches
startIndex = (currentIndex + 1) % visibleNodes.length;
searchStr = buffer;
} else {
// Multiple different characters: start from current to allow prefix matching
startIndex = currentIndex;
searchStr = buffer;
}
for (let i = 0; i < visibleNodes.length; i++) {
const index = (startIndex + i) % visibleNodes.length;
const flatNode = visibleNodes[index];
// Skip disabled nodes in type-ahead
if (flatNode.node.disabled) continue;
if (flatNode.node.label.toLowerCase().startsWith(searchStr)) {
focusNode(flatNode.node.id);
// Update anchor in multiselect mode
if (multiselectable) {
selectionAnchor.current = flatNode.node.id;
}
// Type-ahead only moves focus, does not change selection
break;
}
}
typeAheadTimeoutId.current = window.setTimeout(() => {
typeAheadBuffer.current = '';
typeAheadTimeoutId.current = null;
}, typeAheadTimeout);
},
[visibleNodes, visibleIndexMap, focusedId, focusNode, multiselectable, typeAheadTimeout]
);
// Keyboard handler
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (visibleNodes.length === 0) return;
const { key, shiftKey, ctrlKey, metaKey } = event;
// Use ref for current focused node (updated synchronously, avoids stale closure)
const actualFocusedId = focusedIdRef.current;
const currentIndex = visibleIndexMap.get(actualFocusedId) ?? 0;
const currentFlatNode = visibleNodes[currentIndex];
let shouldPreventDefault = false;
switch (key) {
case 'ArrowDown': {
shouldPreventDefault = true;
if (currentIndex < visibleNodes.length - 1) {
const nextIndex = currentIndex + 1;
focusByIndex(nextIndex);
const nextNode = visibleNodes[nextIndex];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor.current, nextNode.node.id);
} else if (multiselectable) {
selectionAnchor.current = nextNode.node.id;
}
// Single-select: focus moves but selection does not change
}
break;
}
case 'ArrowUp': {
shouldPreventDefault = true;
if (currentIndex > 0) {
const prevIndex = currentIndex - 1;
focusByIndex(prevIndex);
const prevNode = visibleNodes[prevIndex];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor.current, prevNode.node.id);
} else if (multiselectable) {
selectionAnchor.current = prevNode.node.id;
}
// Single-select: focus moves but selection does not change
}
break;
}
case 'ArrowRight': {
shouldPreventDefault = true;
if (!currentFlatNode) break;
if (currentFlatNode.hasChildren && !currentFlatNode.node.disabled) {
if (!expandedIds.has(actualFocusedId)) {
// Expand closed parent
expandNode(actualFocusedId);
} else {
// Move to first child
const nextIndex = currentIndex + 1;
if (nextIndex < visibleNodes.length) {
const nextNode = visibleNodes[nextIndex];
if (nextNode.parentId === actualFocusedId) {
focusByIndex(nextIndex);
// Update anchor on lateral navigation in multiselect
if (multiselectable) {
selectionAnchor.current = nextNode.node.id;
}
// Single-select: focus moves but selection does not change
}
}
}
}
// Leaf node: do nothing
break;
}
case 'ArrowLeft': {
shouldPreventDefault = true;
if (!currentFlatNode) break;
if (
currentFlatNode.hasChildren &&
expandedIds.has(actualFocusedId) &&
!currentFlatNode.node.disabled
) {
// Collapse open parent
collapseNode(actualFocusedId);
} else if (currentFlatNode.parentId) {
// Move to parent
focusNode(currentFlatNode.parentId);
// Update anchor on lateral navigation in multiselect
if (multiselectable) {
selectionAnchor.current = currentFlatNode.parentId;
}
// Single-select: focus moves but selection does not change
}
// Root with no expansion: do nothing
break;
}
case 'Home': {
shouldPreventDefault = true;
focusByIndex(0);
const firstNode = visibleNodes[0];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor.current, firstNode.node.id);
} else if (multiselectable) {
selectionAnchor.current = firstNode.node.id;
}
// Single-select: focus moves but selection does not change
break;
}
case 'End': {
shouldPreventDefault = true;
const lastIndex = visibleNodes.length - 1;
focusByIndex(lastIndex);
const lastNode = visibleNodes[lastIndex];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor.current, lastNode.node.id);
} else if (multiselectable) {
selectionAnchor.current = lastNode.node.id;
}
// Single-select: focus moves but selection does not change
break;
}
case 'Enter': {
shouldPreventDefault = true;
if (currentFlatNode && !currentFlatNode.node.disabled) {
// Select the node (single-select replaces, multi-select behavior via selectNode)
if (multiselectable) {
selectNode(actualFocusedId);
selectionAnchor.current = actualFocusedId;
} else {
updateSelectedIds(new Set([actualFocusedId]));
}
// Fire activation callback
onActivate?.(actualFocusedId);
}
break;
}
case ' ': {
shouldPreventDefault = true;
if (currentFlatNode && !currentFlatNode.node.disabled) {
if (multiselectable) {
selectNode(actualFocusedId);
// Ctrl+Space: toggle without updating anchor
// Space alone: update anchor for subsequent Shift+Arrow operations
if (!ctrlKey) {
selectionAnchor.current = actualFocusedId;
}
} else {
// Single-select: Space selects and activates (same as Enter)
updateSelectedIds(new Set([actualFocusedId]));
onActivate?.(actualFocusedId);
}
}
break;
}
case '*': {
shouldPreventDefault = true;
expandAllSiblings(actualFocusedId);
break;
}
case 'a':
case 'A': {
if ((ctrlKey || metaKey) && multiselectable) {
shouldPreventDefault = true;
selectAllVisible();
} else {
handleTypeAhead(key);
}
break;
}
default: {
// Type-ahead for printable characters
if (key.length === 1 && !ctrlKey && !metaKey) {
shouldPreventDefault = true;
handleTypeAhead(key);
}
}
}
if (shouldPreventDefault) {
event.preventDefault();
}
},
[
visibleNodes,
visibleIndexMap,
focusedId,
focusByIndex,
focusNode,
expandedIds,
expandNode,
collapseNode,
expandAllSiblings,
multiselectable,
selectNode,
selectRange,
selectAllVisible,
updateSelectedIds,
nodeMap,
onActivate,
handleTypeAhead,
]
);
// Click handler
const handleNodeClick = useCallback(
(nodeId: string) => {
const flatNode = nodeMap.get(nodeId);
if (!flatNode || flatNode.node.disabled) return;
focusNode(nodeId);
// Toggle expansion for parent nodes
if (flatNode.hasChildren) {
if (expandedIds.has(nodeId)) {
collapseNode(nodeId);
} else {
expandNode(nodeId);
}
}
// Select and activate
if (multiselectable) {
selectNode(nodeId);
selectionAnchor.current = nodeId;
} else {
updateSelectedIds(new Set([nodeId]));
}
onActivate?.(nodeId);
},
[
nodeMap,
focusNode,
expandedIds,
collapseNode,
expandNode,
multiselectable,
selectNode,
updateSelectedIds,
onActivate,
]
);
// Render a node and its children recursively
const renderNode = useCallback(
(node: TreeNode, depth: number = 0): React.ReactNode => {
const hasChildren = Boolean(node.children && node.children.length > 0);
const isExpanded = expandedIds.has(node.id);
const isSelected = selectedIds.has(node.id);
const isFocused = focusedId === node.id;
const visibleIndex = visibleIndexMap.get(node.id);
const isVisible = visibleIndex !== undefined;
if (!isVisible && depth > 0) {
return null;
}
const nodeClass = `apg-treeview-item ${
isSelected ? 'apg-treeview-item--selected' : ''
} ${node.disabled ? 'apg-treeview-item--disabled' : ''} ${
hasChildren ? 'apg-treeview-item--parent' : 'apg-treeview-item--leaf'
}`.trim();
const labelId = `${instanceId}-label-${node.id}`;
return (
<li
key={node.id}
ref={(el) => {
if (el) {
nodeRefs.current.set(node.id, el);
} else {
nodeRefs.current.delete(node.id);
}
}}
role="treeitem"
aria-labelledby={labelId}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-disabled={node.disabled || undefined}
tabIndex={isFocused ? 0 : -1}
className={nodeClass}
style={{ '--depth': depth }}
onClick={(e) => {
e.stopPropagation();
handleNodeClick(node.id);
}}
onFocus={(e) => {
// Only handle focus if this element is the actual target (not bubbled from child)
if (e.target === e.currentTarget) {
focusedIdRef.current = node.id;
setFocusedId(node.id);
}
}}
>
<span className="apg-treeview-item-content">
{hasChildren && (
<span className="apg-treeview-item-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'}
</span>
)}
<span id={labelId} className="apg-treeview-item-label">
{node.label}
</span>
</span>
{hasChildren && isExpanded && node.children && (
<ul role="group" className="apg-treeview-group">
{node.children.map((child) => renderNode(child, depth + 1))}
</ul>
)}
</li>
);
},
[expandedIds, selectedIds, focusedId, visibleIndexMap, handleNodeClick, instanceId]
);
const containerClass = `apg-treeview ${className}`.trim();
return (
<ul
role="tree"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable || undefined}
className={containerClass}
onKeyDown={handleKeyDown}
>
{nodes.map((node) => renderNode(node, 0))}
</ul>
);
}
export default TreeView; Usage
Example
import { TreeView } from './TreeView';
const nodes = [
{
id: 'documents',
label: 'Documents',
children: [
{ id: 'report', label: 'report.pdf' },
{ id: 'notes', label: 'notes.txt' },
],
},
{ id: 'readme', label: 'readme.md' },
];
function App() {
return (
<div>
{/* 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 */}
<TreeView
nodes={nodes}
aria-label="Files"
onSelectionChange={(ids) => console.log('Selected:', ids)}
onExpandedChange={(ids) => console.log('Expanded:', ids)}
onActivate={(id) => console.log('Activated:', id)}
/>
{/* Controlled mode */}
<TreeView
nodes={nodes}
aria-label="Files"
selectedIds={selectedIds}
expandedIds={expandedIds}
onSelectionChange={setSelectedIds}
onExpandedChange={setExpandedIds}
/>
</div>
);
} 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 |
TreeView 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 (uncontrolled) |
selectedIds | string[] | - | Currently selected node IDs (controlled) |
onSelectionChange | (ids: string[]) => void | - | Callback when selection changes |
defaultExpandedIds | string[] | [] | Initially expanded node IDs (uncontrolled) |
expandedIds | string[] | - | Currently expanded node IDs (controlled) |
onExpandedChange | (ids: string[]) => void | - | Callback when expansion changes |
onActivate | (id: string) => void | - | Callback when node is activated (Enter key) |
typeAheadTimeout | number | 500 | Type-ahead buffer reset timeout (ms) |
aria-label | string | - | Accessible label for the tree |
aria-labelledby | string | - | ID of labeling element |
className | string | - | Additional CSS class |
Testing
Tests verify APG compliance for ARIA attributes, keyboard interactions, expansion/collapse behavior, selection models, and accessibility requirements.
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 |
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)
TreeView.test.tsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { TreeView, type TreeNode } from './TreeView';
// Test tree data
const simpleNodes: TreeNode[] = [
{
id: 'docs',
label: 'Documents',
children: [
{ id: 'report', label: 'report.pdf' },
{ id: 'notes', label: 'notes.txt' },
],
},
{
id: 'images',
label: 'Images',
children: [
{ id: 'photo1', label: 'photo1.jpg' },
{ id: 'photo2', label: 'photo2.jpg' },
],
},
{ id: 'readme', label: 'readme.md' },
];
const nestedNodes: TreeNode[] = [
{
id: 'root',
label: 'Root',
children: [
{
id: 'level1',
label: 'Level 1',
children: [
{
id: 'level2',
label: 'Level 2',
children: [{ id: 'level3', label: 'Level 3' }],
},
],
},
],
},
];
const nodesWithDisabled: TreeNode[] = [
{ id: 'item1', label: 'Item 1' },
{ id: 'item2', label: 'Item 2', disabled: true },
{ id: 'item3', label: 'Item 3' },
];
const nodesWithDisabledParent: TreeNode[] = [
{
id: 'parent',
label: 'Disabled Parent',
disabled: true,
children: [{ id: 'child', label: 'Child' }],
},
{ id: 'item', label: 'Item' },
];
describe('TreeView', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('has role="tree" on container', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
expect(screen.getByRole('tree')).toBeInTheDocument();
});
it('has role="treeitem" on all nodes', () => {
render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs', 'images']} />
);
// 3 top-level + 2 docs children + 2 images children = 7
expect(screen.getAllByRole('treeitem')).toHaveLength(7);
});
it('has role="group" on child containers', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
expect(screen.getByRole('group')).toBeInTheDocument();
});
it('has aria-expanded on parent nodes only', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
expect(docs).toHaveAttribute('aria-expanded');
expect(images).toHaveAttribute('aria-expanded');
expect(readme).not.toHaveAttribute('aria-expanded');
});
it('leaf nodes do NOT have aria-expanded', () => {
render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs', 'images']} />
);
const report = screen.getByRole('treeitem', { name: 'report.pdf' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
expect(report).not.toHaveAttribute('aria-expanded');
expect(readme).not.toHaveAttribute('aria-expanded');
});
it('updates aria-expanded on expand/collapse', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
expect(docs).toHaveAttribute('aria-expanded', 'false');
docs.focus();
await user.keyboard('{ArrowRight}');
expect(docs).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{ArrowLeft}');
expect(docs).toHaveAttribute('aria-expanded', 'false');
});
it('all treeitems have aria-selected when selection enabled', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const treeitems = screen.getAllByRole('treeitem');
treeitems.forEach((item) => {
expect(item).toHaveAttribute('aria-selected');
});
});
it('selected node has aria-selected="true", others have "false"', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultSelectedIds={['readme']} />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
const docs = screen.getByRole('treeitem', { name: 'Documents' });
expect(readme).toHaveAttribute('aria-selected', 'true');
expect(docs).toHaveAttribute('aria-selected', 'false');
});
it('has aria-multiselectable="true" on multi-select tree', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
expect(screen.getByRole('tree')).toHaveAttribute('aria-multiselectable', 'true');
});
it('does not have aria-multiselectable on single-select tree', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
expect(screen.getByRole('tree')).not.toHaveAttribute('aria-multiselectable');
});
it('has accessible name via aria-label', () => {
render(<TreeView nodes={simpleNodes} aria-label="File Explorer" />);
expect(screen.getByRole('tree', { name: 'File Explorer' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<>
<h2 id="tree-label">My Files</h2>
<TreeView nodes={simpleNodes} aria-labelledby="tree-label" />
</>
);
expect(screen.getByRole('tree', { name: 'My Files' })).toBeInTheDocument();
});
it('disabled nodes have aria-disabled="true"', () => {
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
expect(disabled).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: Keyboard Navigation
describe('APG: Keyboard Navigation', () => {
it('ArrowDown moves to next visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveFocus();
});
it('ArrowDown moves into expanded children', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
});
it('ArrowUp moves to previous visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const images = screen.getByRole('treeitem', { name: 'Images' });
images.focus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('ArrowUp moves to parent from first child', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Start from Documents (which is expanded) and navigate to first child
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to first child (report.pdf)
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('ArrowRight expands closed parent', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowRight}');
expect(docs).toHaveAttribute('aria-expanded', 'true');
});
it('ArrowRight moves to first child when parent is expanded', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
});
it('ArrowRight does nothing on leaf node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Navigate to leaf node via keyboard
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to report.pdf
const report = screen.getByRole('treeitem', { name: 'report.pdf' });
expect(report).toHaveFocus();
await user.keyboard('{ArrowRight}');
expect(report).toHaveFocus();
});
it('ArrowLeft collapses open parent', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{ArrowLeft}');
expect(docs).toHaveAttribute('aria-expanded', 'false');
});
it('ArrowLeft moves to parent from child', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Navigate to child via keyboard
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to report.pdf
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('ArrowLeft moves to parent from closed parent', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nestedNodes} aria-label="Nested" defaultExpandedIds={['root']} />);
// Navigate to Level 1 via keyboard
const root = screen.getByRole('treeitem', { name: 'Root' });
root.focus();
await user.keyboard('{ArrowRight}'); // Move to Level 1
const level1 = screen.getByRole('treeitem', { name: 'Level 1' });
expect(level1).toHaveFocus();
expect(level1).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('treeitem', { name: 'Root' })).toHaveFocus();
});
it('ArrowLeft does nothing on root node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// First collapse, then try ArrowLeft again
expect(docs).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowLeft}');
expect(docs).toHaveFocus();
});
it('Home moves focus to first node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('{Home}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('End moves focus to last visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{End}');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('End moves to last visible node when children are expanded', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['images']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{End}');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('Enter activates node but does not toggle expansion', async () => {
const user = userEvent.setup();
const onActivate = vi.fn();
render(<TreeView nodes={simpleNodes} aria-label="Files" onActivate={onActivate} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{Enter}');
expect(docs).toHaveAttribute('aria-expanded', 'false');
expect(onActivate).toHaveBeenCalledWith('docs');
});
it('* expands all siblings at current level', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('*');
expect(docs).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it('collapsed children are skipped during navigation', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// docs is collapsed, so ArrowDown should skip its children
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveFocus();
});
});
// 🔴 High Priority: Type-ahead
describe('APG: Type-ahead', () => {
it('focuses matching visible node on character input', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('cycles through matches on repeated character', async () => {
const user = userEvent.setup();
const nodesWithSamePrefix: TreeNode[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'apricot', label: 'Apricot' },
{ id: 'avocado', label: 'Avocado' },
];
render(<TreeView nodes={nodesWithSamePrefix} aria-label="Fruits" />);
const apple = screen.getByRole('treeitem', { name: 'Apple' });
apple.focus();
await user.keyboard('a');
expect(screen.getByRole('treeitem', { name: 'Apricot' })).toHaveFocus();
await user.keyboard('a');
expect(screen.getByRole('treeitem', { name: 'Avocado' })).toHaveFocus();
await user.keyboard('a');
expect(screen.getByRole('treeitem', { name: 'Apple' })).toHaveFocus();
});
it('only searches visible nodes', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// "report.pdf" is collapsed, so 'r' should match 'readme.md' instead
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('matches multiple characters typed quickly', async () => {
const user = userEvent.setup();
const nodesForTypeAhead: TreeNode[] = [
{ id: 'readme', label: 'readme.md' },
{ id: 'report', label: 'report.pdf' },
{ id: 'resources', label: 'resources' },
];
render(<TreeView nodes={nodesForTypeAhead} aria-label="Files" />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
// Type "rep" quickly to match "report.pdf"
await user.keyboard('rep');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
});
it('resets buffer after timeout', async () => {
const user = userEvent.setup();
const nodesForTypeAhead: TreeNode[] = [
{ id: 'readme', label: 'readme.md' },
{ id: 'report', label: 'report.pdf' },
{ id: 'resources', label: 'resources' },
];
render(<TreeView nodes={nodesForTypeAhead} aria-label="Files" typeAheadTimeout={100} />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
// Wait for timeout to reset buffer
await new Promise((resolve) => setTimeout(resolve, 150));
// New 'r' should cycle again
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'resources' })).toHaveFocus();
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('tree is single Tab stop', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<TreeView nodes={simpleNodes} aria-label="Files" />
<button>After</button>
</>
);
const before = screen.getByRole('button', { name: 'Before' });
before.focus();
await user.keyboard('{Tab}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
await user.keyboard('{Tab}');
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('focused node has tabindex="0"', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
expect(docs).toHaveAttribute('tabindex', '0');
});
it('other nodes have tabindex="-1"', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const images = screen.getByRole('treeitem', { name: 'Images' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
expect(images).toHaveAttribute('tabindex', '-1');
expect(readme).toHaveAttribute('tabindex', '-1');
});
it('focus moves to parent when child is focused and parent collapses', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Navigate to report.pdf via keyboard
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to report.pdf
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
// ArrowLeft from child goes to parent, then collapse
await user.keyboard('{ArrowLeft}'); // Move to parent (Documents)
expect(docs).toHaveFocus();
await user.keyboard('{ArrowLeft}'); // Collapse parent
expect(docs).toHaveFocus();
expect(docs).toHaveAttribute('aria-expanded', 'false');
});
it('focus moves to parent when parent is collapsed programmatically while child has focus', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const report = screen.getByRole('treeitem', { name: 'report.pdf' });
const docs = screen.getByRole('treeitem', { name: 'Documents' });
report.focus();
expect(report).toHaveFocus();
// Simulate clicking the parent to collapse while child has focus
await user.click(docs);
// Focus should move to parent when collapsed
expect(docs).toHaveFocus();
});
it('disabled nodes are focusable', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const item1 = screen.getByRole('treeitem', { name: 'Item 1' });
item1.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'Item 2' })).toHaveFocus();
});
it('disabled nodes cannot be selected', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
disabled.focus();
// In single-select, selection follows focus but disabled nodes stay unselected
expect(disabled).toHaveAttribute('aria-selected', 'false');
});
it('disabled parent nodes cannot be expanded', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nodesWithDisabledParent} aria-label="Items" />);
const parent = screen.getByRole('treeitem', { name: 'Disabled Parent' });
parent.focus();
expect(parent).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowRight}');
expect(parent).toHaveAttribute('aria-expanded', 'false');
});
it('disabled nodes do not trigger onActivate on Enter', async () => {
const user = userEvent.setup();
const onActivate = vi.fn();
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" onActivate={onActivate} />);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
disabled.focus();
await user.keyboard('{Enter}');
expect(onActivate).not.toHaveBeenCalled();
});
it('disabled nodes do not toggle selection on Space in multi-select', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
<TreeView
nodes={nodesWithDisabled}
aria-label="Items"
multiselectable
onSelectionChange={onSelectionChange}
/>
);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
disabled.focus();
onSelectionChange.mockClear();
await user.keyboard(' ');
expect(onSelectionChange).not.toHaveBeenCalled();
expect(disabled).toHaveAttribute('aria-selected', 'false');
});
});
// 🔴 High Priority: Boundary Navigation
describe('APG: Boundary Navigation', () => {
it('ArrowUp at first node does nothing', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowUp}');
expect(docs).toHaveFocus();
});
it('ArrowDown at last visible node does nothing', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('{ArrowDown}');
expect(readme).toHaveFocus();
});
it('ArrowDown at last visible node with expanded children does nothing', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['images']} />);
// Last visible is photo2.jpg when images is expanded, then readme.md
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('{ArrowDown}');
expect(readme).toHaveFocus();
});
});
// 🔴 High Priority: Selection (Single-Select) - Explicit Selection Model
// Arrow keys move focus only, Enter/Space/Click selects
describe('APG: Selection (Single-Select)', () => {
it('arrow keys move focus only (selection does NOT follow focus)', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultSelectedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
docs.focus();
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(images).toHaveAttribute('aria-selected', 'false');
await user.keyboard('{ArrowDown}');
// Focus moved but selection did NOT follow
expect(images).toHaveFocus();
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(images).toHaveAttribute('aria-selected', 'false');
});
it('only one node is selected at a time', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
docs.focus();
// Select first node with Enter
await user.keyboard('{Enter}');
expect(docs).toHaveAttribute('aria-selected', 'true');
// Move focus and select another node
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
// Only the new node should be selected
expect(docs).toHaveAttribute('aria-selected', 'false');
expect(images).toHaveAttribute('aria-selected', 'true');
const selected = screen
.getAllByRole('treeitem')
.filter((item) => item.getAttribute('aria-selected') === 'true');
expect(selected).toHaveLength(1);
});
it('Space selects focused node in single-select mode', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
<TreeView nodes={simpleNodes} aria-label="Files" onSelectionChange={onSelectionChange} />
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard(' ');
expect(onSelectionChange).toHaveBeenCalledWith(['docs']);
expect(docs).toHaveAttribute('aria-selected', 'true');
});
it('calls onSelectionChange when Enter selects a node', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
<TreeView nodes={simpleNodes} aria-label="Files" onSelectionChange={onSelectionChange} />
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{Enter}');
expect(onSelectionChange).toHaveBeenCalledWith(['docs']);
});
});
// 🔴 High Priority: Selection (Multi-Select)
describe('APG: Selection (Multi-Select)', () => {
it('Space toggles selection', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-selected', 'false');
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'true');
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'false');
});
it('arrow keys move focus without changing selection', async () => {
const user = userEvent.setup();
render(
<TreeView
nodes={simpleNodes}
aria-label="Files"
multiselectable
defaultSelectedIds={['docs']}
/>
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowDown}');
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'false'
);
});
it('Shift+Arrow extends selection range', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard(' '); // Select docs
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
it('Ctrl+A selects all visible nodes', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{Control>}a{/Control}');
const allItems = screen.getAllByRole('treeitem');
allItems.forEach((item) => {
expect(item).toHaveAttribute('aria-selected', 'true');
});
});
it('Ctrl+A selects only visible nodes (not collapsed children)', async () => {
const user = userEvent.setup();
render(
<TreeView
nodes={simpleNodes}
aria-label="Files"
multiselectable
defaultExpandedIds={['docs']}
/>
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{Control>}a{/Control}');
// docs and its children are visible
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'notes.txt' })).toHaveAttribute(
'aria-selected',
'true'
);
// images is visible but collapsed, so its children should not be selected
// (they're not in the DOM when collapsed)
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
it('Ctrl+Space toggles selection without updating anchor', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
docs.focus();
// Select docs with Space (sets anchor to docs)
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'true');
// Move to Images and toggle with Ctrl+Space (should NOT update anchor)
await user.keyboard('{ArrowDown}');
expect(images).toHaveFocus();
await user.keyboard('{Control>} {/Control}');
expect(images).toHaveAttribute('aria-selected', 'true');
// Now Shift+ArrowDown should extend from original anchor (docs), not from Images
// This will select from docs to readme (anchor=docs, current=images, target=readme)
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
// All three should be selected because we extend from anchor (docs) to readme
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(images).toHaveAttribute('aria-selected', 'true');
expect(readme).toHaveAttribute('aria-selected', 'true');
});
it('Shift+Home extends selection from anchor to first node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
// Set anchor by selecting with Space
await user.keyboard(' ');
expect(readme).toHaveAttribute('aria-selected', 'true');
// Shift+Home should select from readme.md to Documents
await user.keyboard('{Shift>}{Home}{/Shift}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(readme).toHaveAttribute('aria-selected', 'true');
});
it('Shift+End extends selection from anchor to last visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// Set anchor by selecting with Space
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'true');
// Shift+End should select from Documents to readme.md
await user.keyboard('{Shift>}{End}{/Shift}');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
});
// 🔴 High Priority: Expansion Callbacks
describe('Expansion', () => {
it('calls onExpandedChange when nodes are expanded', async () => {
const user = userEvent.setup();
const onExpandedChange = vi.fn();
render(
<TreeView nodes={simpleNodes} aria-label="Files" onExpandedChange={onExpandedChange} />
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}');
expect(onExpandedChange).toHaveBeenCalledWith(['docs']);
});
it('calls onExpandedChange when nodes are collapsed', async () => {
const user = userEvent.setup();
const onExpandedChange = vi.fn();
render(
<TreeView
nodes={simpleNodes}
aria-label="Files"
defaultExpandedIds={['docs']}
onExpandedChange={onExpandedChange}
/>
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowLeft}');
expect(onExpandedChange).toHaveBeenCalledWith([]);
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with multi-select', async () => {
const { container } = render(
<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with disabled nodes', async () => {
const { container } = render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('respects defaultExpandedIds', () => {
render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs', 'images']} />
);
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveAttribute(
'aria-expanded',
'true'
);
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it('respects defaultSelectedIds', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultSelectedIds={['readme']} />);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
it('applies className to container', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" className="custom-tree" />);
expect(screen.getByRole('tree')).toHaveClass('custom-tree');
});
});
}); Resources
- WAI-ARIA APG: Tree View Pattern (opens in new tab)
- APG Example: Navigation Treeview (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist