APG Patterns
日本語 GitHub
日本語 GitHub

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 Guide

Demo

Single-Select with Activation

  • Documents
    • report.pdf
    • notes.txt
  • readme.md
Activated: Select a node with Enter, Space, or Click

Multi-Select

  • Documents
    • report.pdf
    • notes.txt
  • Images
    • vacation.jpg
    • profile.png
  • readme.md

With Disabled Nodes

  • Accessible Folder
    • file1.txt
    • file2.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

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

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