APG Patterns
English
English

Tree View

子を持つアイテムを展開または折りたたむことができる階層的なリスト。ファイルブラウザ、ナビゲーションメニュー、組織図などでよく使用されます。

デモ

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

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール対象要素説明
treeコンテナ(<ul>ツリーウィジェットのコンテナ
treeitem各ノード(<li>個々のツリーノード(親ノードとリーフノードの両方)
group子コンテナ(<ul>展開された親の子ノードのコンテナ

WAI-ARIA プロパティ

role="tree"

コンテナをツリーウィジェットとして識別

-
必須
はい

aria-label

ツリーのアクセシブルな名前

String
必須
はい*

aria-labelledby

aria-labelの代替(優先される)

ID参照
必須
はい*

aria-multiselectable

複数選択モードでのみ存在

true
必須
いいえ

WAI-ARIA ステート

aria-expanded

対象要素
親treeitem
true | false
必須
はい
変更トリガー
Click、ArrowRight、ArrowLeft、Enter

aria-selected

対象要素
すべてのtreeitem
true | false
必須
はい
変更トリガー
Click、Enter、Space、矢印キー

aria-disabled

対象要素
無効化されたtreeitem
true
必須
いいえ

キーボードサポート

ナビゲーション

キーアクション
ArrowDown次の表示ノードにフォーカスを移動
ArrowUp前の表示ノードにフォーカスを移動
ArrowRight閉じた親: 展開 / 開いた親: 最初の子へ移動 / リーフ: 操作なし
ArrowLeft開いた親: 折りたたみ / 子または閉じた親: 親へ移動 / ルート: 操作なし
Home最初のノードにフォーカスを移動
End最後の表示ノードにフォーカスを移動
Enterノードを選択してアクティブ化(下記選択セクション参照)
*現在のレベルのすべての兄弟を展開
文字入力その文字で始まる次の表示ノードにフォーカスを移動

選択(単一選択モード)

キーアクション
ArrowDown / ArrowUpフォーカスのみ移動(選択はフォーカスに追従しない)
Enterフォーカスされたノードを選択してアクティブ化(onActivateコールバックを発火)
Spaceフォーカスされたノードを選択してアクティブ化(onActivateコールバックを発火)
クリッククリックしたノードを選択してアクティブ化(onActivateコールバックを発火)

選択(複数選択モード)

キーアクション
Spaceフォーカスされたノードの選択を切り替え
Ctrl + Spaceフォーカスを移動せずに選択を切り替え
Shift + ArrowDown / ArrowUpアンカーから選択範囲を拡張
Shift + Home最初のノードまで選択を拡張
Shift + End最後の表示ノードまで選択を拡張
Ctrl + Aすべての表示ノードを選択
  • aria-labelまたはaria-labelledbyのいずれかが必須です。
  • 親ノードはaria-expandedを持つ必要があります。リーフノードはaria-expandedを持ってはいけません。
  • 選択がサポートされる場合、すべてのtreeitemはaria-selectedを持つ必要があります。

フォーカス管理

イベント振る舞い
Roving tabindex1つのノードのみがtabindex="0"を持つ(フォーカスされたノード)
他のノード他のすべてのノードはtabindex="-1"を持つ
単一Tabストップツリーは単一のTabストップ(Tabで入り、Shift+Tabで出る)
表示ノードのみフォーカスは表示ノード間のみを移動(折りたたまれた子はスキップ)
折りたたみ動作子にフォーカスがある状態で親を折りたたむと、フォーカスは親に移動

参考資料

ソースコード

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
  /* eslint-disable react-hooks/immutability -- Recursive function requires self-reference */
  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;
    },
    []
  );
  /* eslint-enable react-hooks/immutability */

  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
  /* eslint-disable react-hooks/immutability -- Recursive function requires self-reference */
  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 (
        // treegrid keyboard events managed at tree level
        // eslint-disable-next-line jsx-a11y/click-events-have-key-events
        <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]
  );
  /* eslint-enable react-hooks/immutability */

  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;

使い方

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

プロパティ デフォルト 説明
nodes TreeNode[] Required ツリーノードの配列
multiselectable boolean false 複数選択モードを有効化
defaultSelectedIds string[] [] 初期選択されるノード ID(非制御)
selectedIds string[] - 現在選択されているノード ID(制御)
onSelectionChange (ids: string[]) => void - 選択変更時のコールバック
defaultExpandedIds string[] [] 初期展開されるノード ID(非制御)
expandedIds string[] - 現在展開されているノード ID(制御)
onExpandedChange (ids: string[]) => void - 展開変更時のコールバック
onActivate (id: string) => void - ノードがアクティブ化された時のコールバック(Enter キー)
typeAheadTimeout number 500 先行入力バッファのリセットタイムアウト(ミリ秒)
aria-label string - ツリーのアクセシブル名
aria-labelledby string - ラベル要素の ID
className string - 追加のCSSクラス

TreeNode Props

プロパティ デフォルト 説明
id string required ノードの一意な識別子
label string required ノードの表示テキスト
children TreeNode[] - 子ノード(親ノードになる)
disabled boolean false ノードが無効化されているかどうか

テスト

テストは、ARIA属性、キーボード操作、展開/折りたたみ動作、選択モデル、アクセシビリティ要件におけるAPG準拠を検証します。Tree View コンポーネントは2層のテスト戦略を使用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用して、コンポーネントの出力を検証します。正しいHTML構造とARIA属性を確認します。

  • ARIA属性(role="tree"、role="treeitem"、role="group")
  • 展開状態(aria-expanded)
  • 選択状態(aria-selected、aria-multiselectable)
  • 無効化状態(aria-disabled)
  • jest-axeによるアクセシビリティ

E2Eテスト(Playwright)

実際のブラウザ環境で全フレームワークのコンポーネント動作を検証します。インタラクションとクロスフレームワークの一貫性をカバーします。

  • 矢印キーナビゲーション(ArrowUp、ArrowDown、Home、End)
  • ArrowRight/ArrowLeftでの展開/折りたたみ
  • Enter、Space、クリックでの選択
  • Shift+矢印での複数選択
  • タイプアヘッド文字ナビゲーション
  • axe-coreアクセシビリティスキャン
  • クロスフレームワーク一貫性チェック

テストカテゴリ

高優先度 : ARIA構造(Unit + E2E)

テスト 説明
role="tree" コンテナ要素がtreeロールを持つ
role="treeitem" 各ノードがtreeitemロールを持つ
role="group" 子コンテナがgroupロールを持つ
aria-expanded (parent) 親ノードがaria-expanded(trueまたはfalse)を持つ
aria-expanded (leaf) リーフノードはaria-expandedを持たない
aria-selected すべてのtreeitemがaria-selected(trueまたはfalse)を持つ
aria-multiselectable 複数選択モードでツリーがaria-multiselectable="true"を持つ
aria-disabled 無効化ノードがaria-disabled="true"を持つ

高優先度 : アクセシブルな名前(Unit + E2E)

テスト 説明
aria-label ツリーがaria-labelでアクセシブルな名前を持つ
aria-labelledby ツリーがaria-labelledby(優先される)を使用できる

高優先度 : ナビゲーション(Unit + E2E)

テスト 説明
ArrowDown 次の表示ノードに移動
ArrowUp 前の表示ノードに移動
Home 最初のノードに移動
End 最後の表示ノードに移動
Type-ahead 文字入力で一致する表示ノードにフォーカス

高優先度 : 展開/折りたたみ(Unit + E2E)

テスト 説明
ArrowRight (closed parent) 親ノードを展開
ArrowRight (open parent) 最初の子に移動
ArrowRight (leaf) 何もしない
ArrowLeft (open parent) 親ノードを折りたたむ
ArrowLeft (child/closed) 親ノードに移動
ArrowLeft (root) 何もしない
* (asterisk) 現在のレベルのすべての兄弟を展開
Enter ノードをアクティブ化(展開の切り替えはしない)

高優先度 : 選択(単一選択)(Unit + E2E)

テスト 説明
Arrow navigation 選択がフォーカスに追従(矢印で選択が変わる)
Space 単一選択モードでは効果なし
Only one selected 一度に1つのノードのみ選択可能

高優先度 : 選択(複数選択)(Unit + E2E)

テスト 説明
Space フォーカスされたノードの選択を切り替え
Ctrl+Space フォーカスを移動せずに選択を切り替え
Shift+Arrow アンカーから選択範囲を拡張
Shift+Home 最初のノードまで選択を拡張
Shift+End 最後の表示ノードまで選択を拡張
Ctrl+A すべての表示ノードを選択
Multiple selection 複数のノードを同時に選択可能

高優先度 : フォーカス管理(Unit + E2E)

テスト 説明
Single Tab stop ツリーが単一のTabストップ(Tab/Shift+Tab)
tabindex="0" フォーカスされたノードがtabindex="0"を持つ
tabindex="-1" 他のノードがtabindex="-1"を持つ
Skip collapsed ナビゲーション中に折りたたまれた子をスキップ
Focus to parent 子の親が折りたたまれると、フォーカスが親に移動

中優先度 : 無効化ノード(Unit + E2E)

テスト 説明
Focusable 無効化ノードはフォーカスを受け取れる
Not selectable 無効化ノードは選択できない
Not expandable 無効化された親ノードは展開/折りたたみできない
Not activatable Enterキーで無効化ノードをアクティブ化できない

中優先度 : タイプアヘッド(Unit + E2E)

テスト 説明
Visible nodes only タイプアヘッドは表示ノードのみを検索
Cycle on repeat 繰り返し文字でマッチをサイクル
Multi-character prefix 複数文字で検索プレフィックスを形成
Timeout reset タイムアウト後にバッファがリセット(デフォルト500ms)
Skip disabled タイプアヘッドは無効化ノードをスキップ

中優先度 : マウス操作(E2E)

テスト 説明
Click parent 展開を切り替えてノードを選択
Click leaf リーフノードを選択
Click disabled 無効化ノードは選択・展開できない

低優先度 : コールバック(Unit + E2E)

テスト 説明
onSelectionChange 選択が変わったときに選択されたIDで呼び出される
onExpandedChange 展開が変わったときに展開されたIDで呼び出される
onActivate EnterキーでノードIDと共に呼び出される

テストコード例

以下は実際の E2E テストファイルです (e2e/tree-view.spec.ts).

e2e/tree-view.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Tree View Pattern
 *
 * A hierarchical list where items with children can be expanded or collapsed.
 * Common uses include file browsers, navigation menus, and organizational charts.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getTree = (page: import('@playwright/test').Page) => {
  return page.getByRole('tree');
};

const getTreeItems = (page: import('@playwright/test').Page) => {
  return page.getByRole('treeitem');
};

const getGroups = (page: import('@playwright/test').Page) => {
  return page.getByRole('group');
};

/**
 * Click on a treeitem element and wait for it to receive focus.
 * This is needed because:
 * 1. Frameworks like React apply focus asynchronously via useLayoutEffect
 * 2. Expanded parent treeitems contain child elements, so we must click
 *    on the label area (top of the element) to avoid hitting children
 *    which have stopPropagation() on their click handlers.
 *
 * NOTE: Clicking on a parent node TOGGLES its expansion state.
 * Use focusWithoutClick() for tests where you need to focus without side effects.
 */
const clickAndWaitForFocus = async (
  element: import('@playwright/test').Locator,
  _page: import('@playwright/test').Page
) => {
  // Click at the top-left area of the element to hit the label, not children
  // Use position { x: 10, y: 10 } to click near the top-left corner
  await element.click({ position: { x: 10, y: 10 } });

  // Wait for the element to become the active element
  await expect
    .poll(
      async () => {
        return await element.evaluate((el) => document.activeElement === el);
      },
      { timeout: 5000 }
    )
    .toBe(true);
};

/**
 * Focus a treeitem element without triggering click handlers.
 * This is useful for testing keyboard navigation where we don't want
 * to trigger expansion toggle that happens on click.
 */
const focusWithoutClick = async (
  element: import('@playwright/test').Locator,
  _page: import('@playwright/test').Page
) => {
  await element.focus();

  // Wait for the element to become the active element
  await expect
    .poll(
      async () => {
        return await element.evaluate((el) => document.activeElement === el);
      },
      { timeout: 5000 }
    )
    .toBe(true);
};

/**
 * Press a key on a focused element and wait for focus to settle on a treeitem.
 * Uses element.press() for stability instead of page.keyboard.press().
 */
const pressKeyOnElement = async (element: import('@playwright/test').Locator, key: string) => {
  await expect(element).toBeFocused();
  await element.press(key);
  // Wait for focus to settle on a treeitem
  await expect(element.page().locator('[role="treeitem"]:focus')).toBeVisible();
};

/**
 * Navigate to a target node by pressing ArrowDown multiple times.
 * Waits for focus to settle after each key press.
 */
const navigateToIndex = async (page: import('@playwright/test').Page, targetIndex: number) => {
  for (let i = 0; i < targetIndex; i++) {
    const currentFocused = page.locator('[role="treeitem"]:focus');
    await pressKeyOnElement(currentFocused, 'ArrowDown');
  }
};

/**
 * Wait for page hydration to complete.
 * This ensures:
 * 1. Tree element is rendered
 * 2. Treeitems have proper aria-labelledby (not Svelte's pre-hydration IDs)
 * 3. Interactive handlers are attached (one item has tabindex="0")
 */
async function waitForHydration(page: import('@playwright/test').Page) {
  // Wait for basic rendering
  await getTree(page).first().waitFor();

  // Wait for hydration - ensure treeitems have proper aria-labelledby
  const firstItem = getTreeItems(page).first();
  await expect
    .poll(async () => {
      const labelledby = await firstItem.getAttribute('aria-labelledby');
      return labelledby && labelledby.length > 1 && !labelledby.startsWith('-');
    })
    .toBe(true);

  // Wait for full hydration - ensure interactive handlers are attached
  await expect
    .poll(async () => {
      const tree = getTree(page).first();
      const items = tree.getByRole('treeitem');
      const count = await items.count();
      let hasFocusable = false;
      for (let i = 0; i < count; i++) {
        const tabindex = await items.nth(i).getAttribute('tabindex');
        if (tabindex === '0') {
          hasFocusable = true;
          break;
        }
      }
      return hasFocusable;
    })
    .toBe(true);
}

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Tree View (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('container has role="tree"', async ({ page }) => {
        const tree = getTree(page).first();
        await expect(tree).toBeVisible();
      });

      test('nodes have role="treeitem"', async ({ page }) => {
        const items = getTreeItems(page);
        const count = await items.count();
        expect(count).toBeGreaterThan(0);
      });

      test('child containers have role="group"', async ({ page }) => {
        // First expand a parent node to make group visible
        const tree = getTree(page).first();
        const parentItem = tree.getByRole('treeitem', { expanded: true }).first();

        if ((await parentItem.count()) > 0) {
          const groups = getGroups(page);
          const count = await groups.count();
          expect(count).toBeGreaterThan(0);
        }
      });

      test('tree has accessible name via aria-label', async ({ page }) => {
        const tree = getTree(page).first();
        const label = await tree.getAttribute('aria-label');
        expect(label).toBeTruthy();
      });

      test('parent nodes have aria-expanded', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node (one with children)
        const expandedItems = tree.locator('[role="treeitem"][aria-expanded]');
        const count = await expandedItems.count();
        expect(count).toBeGreaterThan(0);

        const firstExpanded = expandedItems.first();
        const expanded = await firstExpanded.getAttribute('aria-expanded');
        expect(['true', 'false']).toContain(expanded);
      });

      test('leaf nodes do NOT have aria-expanded', async ({ page }) => {
        const tree = getTree(page).first();
        // Expand all to see leaf nodes
        const items = tree.getByRole('treeitem');
        const count = await items.count();

        for (let i = 0; i < count; i++) {
          const item = items.nth(i);
          const expanded = await item.getAttribute('aria-expanded');
          const hasGroup = (await item.locator('[role="group"]').count()) > 0;

          // If item has no children (no group), it shouldn't have aria-expanded
          if (!hasGroup && expanded === null) {
            // This is correct - leaf node without aria-expanded
            continue;
          }
          // If it has aria-expanded, it should also have children
          if (expanded !== null) {
            // Parent nodes with aria-expanded should have potential children
            continue;
          }
        }
      });

      test('all treeitems have aria-selected', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const count = await items.count();

        for (let i = 0; i < count; i++) {
          const item = items.nth(i);
          const selected = await item.getAttribute('aria-selected');
          expect(['true', 'false']).toContain(selected);
        }
      });

      test('multi-select tree has aria-multiselectable', async ({ page }) => {
        // Find multi-select tree (second tree on demo page)
        const trees = getTree(page);
        const count = await trees.count();

        // Look for a tree with aria-multiselectable
        let foundMultiselect = false;
        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const multiselectable = await tree.getAttribute('aria-multiselectable');
          if (multiselectable === 'true') {
            foundMultiselect = true;
            break;
          }
        }

        // Demo page should have at least one multi-select tree
        expect(foundMultiselect).toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Navigation
    // ------------------------------------------
    test.describe('APG: Keyboard Navigation', () => {
      test('ArrowDown moves to next visible node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toBeFocused();
        await firstItem.press('ArrowDown');

        // Focus should have moved to a different item
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();
      });

      test('ArrowUp moves to previous visible node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Click second item first
        const secondItem = items.nth(1);
        await clickAndWaitForFocus(secondItem, page);
        await expect(secondItem).toBeFocused();
        await secondItem.press('ArrowUp');

        const firstItem = items.first();
        await expect(firstItem).toBeFocused();
      });

      test('Home moves to first node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Start from a later item
        const laterItem = items.nth(2);
        await clickAndWaitForFocus(laterItem, page);
        await expect(laterItem).toBeFocused();
        await laterItem.press('Home');

        const firstItem = items.first();
        await expect(firstItem).toBeFocused();
      });

      test('End moves to last visible node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Click on a non-parent item to avoid triggering expansion toggle
        // Use the second item (first child of expanded parent)
        const secondItem = items.nth(1);
        await clickAndWaitForFocus(secondItem, page);
        await expect(secondItem).toBeFocused();
        await secondItem.press('End');

        // Get current count after any DOM changes
        const currentCount = await items.count();
        const lastItem = items.nth(currentCount - 1);
        await expect(lastItem).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Expand/Collapse
    // ------------------------------------------
    test.describe('APG: Expand/Collapse', () => {
      test('ArrowRight on closed parent expands it', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node (any with aria-expanded attribute)
        const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();

        if ((await anyParent.count()) > 0) {
          // Get label ID for stable reference before any interaction
          const labelledby = await anyParent.getAttribute('aria-labelledby');
          const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);

          // Focus the first treeitem in tree using click
          const firstItem = tree.getByRole('treeitem').first();
          await clickAndWaitForFocus(firstItem, page);

          // Navigate to find a collapsed parent using keyboard
          // Press * to collapse all siblings first (so we have collapsed parents)
          // Then we can test ArrowRight on a collapsed one

          // Navigate to the parent node using keyboard
          // Use Home to go to first item, then navigate down
          await expect(firstItem).toBeFocused();
          await firstItem.press('Home');

          // Now navigate to the target node
          const items = tree.getByRole('treeitem');
          const count = await items.count();
          let targetIndex = -1;
          for (let i = 0; i < count; i++) {
            const lb = await items.nth(i).getAttribute('aria-labelledby');
            if (lb === labelledby) {
              targetIndex = i;
              break;
            }
          }

          // Press ArrowDown to reach the target (with focus wait after each press)
          await navigateToIndex(page, targetIndex);

          // Now we're on the parent node - check if it's expanded and collapse if needed
          const currentExpanded = await stableLocator.getAttribute('aria-expanded');
          if (currentExpanded === 'true') {
            // Collapse it first
            const focusedItem = page.locator('[role="treeitem"]:focus');
            await expect(focusedItem).toBeFocused();
            await focusedItem.press('ArrowLeft');
            await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
          }

          // Now test ArrowRight to expand
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowRight');
          await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
        }
      });

      test('ArrowRight on open parent moves to first child', async ({ page }) => {
        const tree = getTree(page).first();
        // Find an expanded parent
        const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();

        if ((await expandedParent.count()) > 0) {
          // Get parent's labelledby for stable reference
          const parentLabelledby = await expandedParent.getAttribute('aria-labelledby');

          // Focus without click to avoid toggling expansion
          await focusWithoutClick(expandedParent, page);
          await expect(expandedParent).toBeFocused();
          await expandedParent.press('ArrowRight');

          // Focus should move to first child - verify by checking:
          // 1. Focus moved to a different element
          // 2. The focused element is a treeitem
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeVisible();

          // The focused item should not be the parent
          const focusedLabelledby = await focusedItem.getAttribute('aria-labelledby');
          expect(focusedLabelledby).not.toBe(parentLabelledby);
        }
      });

      test('ArrowLeft on open parent collapses it', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node (any with aria-expanded attribute)
        const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();

        if ((await anyParent.count()) > 0) {
          // Get label ID for stable reference before any interaction
          const labelledby = await anyParent.getAttribute('aria-labelledby');
          const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);

          // Focus the first treeitem in tree using click
          const firstItem = tree.getByRole('treeitem').first();
          await clickAndWaitForFocus(firstItem, page);

          // Navigate to the parent node using keyboard
          await expect(firstItem).toBeFocused();
          await firstItem.press('Home');

          // Find the index of target node
          const items = tree.getByRole('treeitem');
          const count = await items.count();
          let targetIndex = -1;
          for (let i = 0; i < count; i++) {
            const lb = await items.nth(i).getAttribute('aria-labelledby');
            if (lb === labelledby) {
              targetIndex = i;
              break;
            }
          }

          // Press ArrowDown to reach the target (with focus wait after each press)
          await navigateToIndex(page, targetIndex);

          // Now we're on the parent node - check if it's collapsed and expand if needed
          const currentExpanded = await stableLocator.getAttribute('aria-expanded');
          if (currentExpanded === 'false') {
            // Expand it first
            const focusedItem = page.locator('[role="treeitem"]:focus');
            await expect(focusedItem).toBeFocused();
            await focusedItem.press('ArrowRight');
            await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
          }

          // Now test ArrowLeft to collapse
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowLeft');
          await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
        }
      });

      test('ArrowLeft on child moves to parent', async ({ page }) => {
        const tree = getTree(page).first();
        // Find an expanded parent to access its children
        const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();

        if ((await expandedParent.count()) > 0) {
          // Focus parent without click to avoid toggling expansion
          await focusWithoutClick(expandedParent, page);
          // Move to first child
          await expect(expandedParent).toBeFocused();
          await expandedParent.press('ArrowRight');

          // Now press ArrowLeft to go back to parent
          const focusedChild = page.locator('[role="treeitem"]:focus');
          await expect(focusedChild).toBeFocused();
          await focusedChild.press('ArrowLeft');

          await expect(expandedParent).toBeFocused();
        }
      });

      test('click on parent toggles expansion', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node
        const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();

        if ((await parentItem.count()) > 0) {
          const initialExpanded = await parentItem.getAttribute('aria-expanded');

          // Click on label area (top of element) to avoid hitting children
          await parentItem.click({ position: { x: 10, y: 10 } });

          const newExpanded = await parentItem.getAttribute('aria-expanded');
          expect(newExpanded).not.toBe(initialExpanded);
        }
      });

      test('* key expands all siblings', async ({ page }) => {
        const tree = getTree(page).first();

        // Focus the first visible item using click
        const firstItem = tree.getByRole('treeitem').first();
        await clickAndWaitForFocus(firstItem, page);

        // The * key expands all expandable siblings at the SAME LEVEL as the focused node
        // First, let's ensure we're at the root level
        await expect(firstItem).toBeFocused();
        await firstItem.press('Home');

        // Get all top-level treeitems (direct children of tree, not nested in groups)
        // Top-level items are those that are not inside a group element
        const topLevelParents = tree.locator(':scope > [role="treeitem"][aria-expanded]');
        const topLevelCount = await topLevelParents.count();

        if (topLevelCount > 0) {
          // Collapse all top-level parents first so we can test * expanding them
          for (let i = 0; i < topLevelCount; i++) {
            const parent = topLevelParents.nth(i);
            const expanded = await parent.getAttribute('aria-expanded');
            if (expanded === 'true') {
              // Navigate to this item and collapse it
              const labelledby = await parent.getAttribute('aria-labelledby');
              // Go home and navigate to find it
              const currentFocused = page.locator('[role="treeitem"]:focus');
              await pressKeyOnElement(currentFocused, 'Home');
              const items = tree.getByRole('treeitem');
              const count = await items.count();
              for (let j = 0; j < count; j++) {
                const lb = await items.nth(j).getAttribute('aria-labelledby');
                if (lb === labelledby) {
                  // Navigate to this item (with focus wait after each press)
                  await navigateToIndex(page, j);
                  // Collapse it
                  const focusedItem = page.locator('[role="treeitem"]:focus');
                  await expect(focusedItem).toBeFocused();
                  await focusedItem.press('ArrowLeft');
                  await expect(parent).toHaveAttribute('aria-expanded', 'false');
                  break;
                }
              }
            }
          }

          // Go back home
          const currentFocused = page.locator('[role="treeitem"]:focus');
          await pressKeyOnElement(currentFocused, 'Home');

          // Press * to expand all siblings at the current level
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('*');

          // Wait for all top-level parents to be expanded (state-based wait)
          for (let i = 0; i < topLevelCount; i++) {
            await expect(topLevelParents.nth(i)).toHaveAttribute('aria-expanded', 'true');
          }
        }
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Selection
    // ------------------------------------------
    test.describe('APG: Selection (Single-Select)', () => {
      test('Enter selects focused node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Use first item for reliability, navigate to second using keyboard
        const firstItem = items.first();
        await clickAndWaitForFocus(firstItem, page);

        // Navigate to second item
        await pressKeyOnElement(firstItem, 'ArrowDown');
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();

        // Press Enter to select
        await secondItem.press('Enter');
        await expect(secondItem).toHaveAttribute('aria-selected', 'true');
      });

      test('Space selects focused node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('aria-selected', 'true');

        // Navigate to another item without selecting (with focus wait)
        await pressKeyOnElement(firstItem, 'ArrowDown');
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();

        // Press Space to select
        await secondItem.press('Space');
        await expect(secondItem).toHaveAttribute('aria-selected', 'true');
        // First item should no longer be selected in single-select
        await expect(firstItem).toHaveAttribute('aria-selected', 'false');
      });

      test('arrow keys move focus without changing selection', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        // Select first item
        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('aria-selected', 'true');

        // Press Enter to explicitly select it
        await expect(firstItem).toBeFocused();
        await firstItem.press('Enter');

        // Navigate away (with focus wait)
        await pressKeyOnElement(firstItem, 'ArrowDown');
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();

        // Selection should stay on first item (focus moved but not selection)
        await expect(firstItem).toHaveAttribute('aria-selected', 'true');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Multi-Select
    // ------------------------------------------
    test.describe('APG: Selection (Multi-Select)', () => {
      test('Space toggles selection in multi-select', async ({ page }) => {
        // Find multi-select tree
        const trees = getTree(page);
        const count = await trees.count();

        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const multiselectable = await tree.getAttribute('aria-multiselectable');

          if (multiselectable === 'true') {
            const items = tree.getByRole('treeitem');
            const firstItem = items.first();

            await clickAndWaitForFocus(firstItem, page);
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');

            // Toggle off
            await expect(firstItem).toBeFocused();
            await firstItem.press('Space');
            await expect(firstItem).toHaveAttribute('aria-selected', 'false');

            // Toggle on
            await expect(firstItem).toBeFocused();
            await firstItem.press('Space');
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');
            break;
          }
        }
      });

      test('Shift+Arrow extends selection range', async ({ page }) => {
        // Find multi-select tree
        const trees = getTree(page);
        const count = await trees.count();

        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const multiselectable = await tree.getAttribute('aria-multiselectable');

          if (multiselectable === 'true') {
            const items = tree.getByRole('treeitem');
            const firstItem = items.first();

            await clickAndWaitForFocus(firstItem, page);
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');

            // Shift+ArrowDown to extend selection
            await expect(firstItem).toBeFocused();
            await firstItem.press('Shift+ArrowDown');

            const secondItem = items.nth(1);
            await expect(secondItem).toHaveAttribute('aria-selected', 'true');
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');
            break;
          }
        }
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Disabled State
    // ------------------------------------------
    test.describe('Disabled State', () => {
      test('disabled nodes have aria-disabled', async ({ page }) => {
        const tree = getTree(page);
        const disabledItems = tree.locator('[role="treeitem"][aria-disabled="true"]');

        if ((await disabledItems.count()) > 0) {
          const firstDisabled = disabledItems.first();
          await expect(firstDisabled).toHaveAttribute('aria-disabled', 'true');
        }
      });

      test('disabled nodes are focusable but not selectable', async ({ page }) => {
        const trees = getTree(page);
        const count = await trees.count();

        // Look for tree with disabled items (third tree on demo page)
        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const disabledItem = tree.locator('[role="treeitem"][aria-disabled="true"]').first();

          if ((await disabledItem.count()) > 0) {
            // Get the labelledby of the disabled item for navigation
            const disabledLabelledby = await disabledItem.getAttribute('aria-labelledby');

            // Focus the first item in this tree using keyboard navigation
            const firstItem = tree.getByRole('treeitem').first();
            await focusWithoutClick(firstItem, page);

            // Navigate using keyboard to find the disabled item
            const items = tree.getByRole('treeitem');
            const itemCount = await items.count();
            let foundDisabled = false;

            for (let j = 0; j < itemCount; j++) {
              const currentFocused = page.locator('[role="treeitem"]:focus');
              const currentLabelledby = await currentFocused.getAttribute('aria-labelledby');

              if (currentLabelledby === disabledLabelledby) {
                foundDisabled = true;
                break;
              }
              await pressKeyOnElement(currentFocused, 'ArrowDown');
            }

            if (foundDisabled) {
              // Now we're on the disabled item via keyboard navigation
              // Try to select it with Space (keep page.keyboard.press for disabled element tests)
              await page.keyboard.press('Space');

              // Should not be selected
              await expect(disabledItem).toHaveAttribute('aria-selected', 'false');
            }
            break;
          }
        }
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Type-Ahead
    // ------------------------------------------
    test.describe('Type-Ahead', () => {
      test('typing character focuses matching node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toBeFocused();

        // Type 'r' to find nodes starting with 'r' (like 'readme.md')
        await firstItem.press('r');

        // Wait for type-ahead to process (state-based wait)
        // Focus should move to a node starting with 'r'
        await expect
          .poll(
            async () => {
              const focused = page.locator('[role="treeitem"]:focus');
              const labelledby = await focused.getAttribute('aria-labelledby');
              if (!labelledby) return false;
              const label = page.locator(`[id="${labelledby}"]`);
              const text = await label.textContent();
              return text?.toLowerCase().startsWith('r') ?? false;
            },
            { timeout: 5000 }
          )
          .toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management
    // ------------------------------------------
    test.describe('Focus Management', () => {
      test('focused node has tabindex="0"', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('tabindex', '0');
      });

      test('other nodes have tabindex="-1"', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('tabindex', '0');

        // Check that at least one other item has tabindex="-1"
        const secondItem = items.nth(1);
        await expect(secondItem).toHaveAttribute('tabindex', '-1');
      });

      test('focus moves to parent when parent is collapsed', async ({ page }) => {
        const tree = getTree(page).first();
        // Find an expanded parent and save its aria-labelledby
        const expandedParentLocator = tree
          .locator('[role="treeitem"][aria-expanded="true"]')
          .first();

        if ((await expandedParentLocator.count()) > 0) {
          // Get the parent's labelledby ID to use as a stable selector
          const labelledby = await expandedParentLocator.getAttribute('aria-labelledby');

          // Focus parent without click to avoid toggling expansion
          await focusWithoutClick(expandedParentLocator, page);
          // Navigate to a child
          await expect(expandedParentLocator).toBeFocused();
          await expandedParentLocator.press('ArrowRight');

          // Collapse the parent by pressing ArrowLeft twice
          // (first ArrowLeft goes back to parent, second collapses it)
          let focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowLeft');

          focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowLeft');

          // Use the stable selector to find the parent (now collapsed)
          const parent = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
          // Focus should be on the parent
          await expect(parent).toBeFocused();
        }
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const tree = getTree(page);
        await tree.first().waitFor();

        const results = await new AxeBuilder({ page })
          .include('[role="tree"]')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Tree View - Cross-framework Consistency', () => {
  test('all frameworks have trees', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);

      const trees = getTree(page);
      const count = await trees.count();
      expect(count).toBeGreaterThan(0);
    }
  });

  test('all frameworks support click to expand/collapse', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);

      const tree = getTree(page).first();
      const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();

      if ((await parentItem.count()) > 0) {
        const initialExpanded = await parentItem.getAttribute('aria-expanded');

        // Click on label area (top of element) to avoid hitting children
        await parentItem.click({ position: { x: 10, y: 10 } });
        // Wait for the expansion state to change
        await expect(parentItem).not.toHaveAttribute('aria-expanded', initialExpanded!);

        const newExpanded = await parentItem.getAttribute('aria-expanded');
        expect(newExpanded).not.toBe(initialExpanded);

        // Click again to toggle back
        await parentItem.click({ position: { x: 10, y: 10 } });
        await expect(parentItem).toHaveAttribute('aria-expanded', initialExpanded!);
      }
    }
  });

  test('all frameworks have consistent ARIA structure', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);

      const tree = getTree(page).first();

      // Check tree has accessible name
      const label = await tree.getAttribute('aria-label');
      expect(label).toBeTruthy();

      // Check treeitems exist
      const items = tree.getByRole('treeitem');
      const count = await items.count();
      expect(count).toBeGreaterThan(0);

      // Check first item has aria-selected
      const firstItem = items.first();
      const selected = await firstItem.getAttribute('aria-selected');
      expect(['true', 'false']).toContain(selected);
    }
  });
});

テストの実行

# Tree Viewのユニットテストを実行
npm run test -- treeview

# Tree ViewのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=tree-view

# 特定のフレームワークでE2Eテストを実行
npm run test:e2e:react:pattern --pattern=tree-view

テストツール

詳細なドキュメントについては、 testing-strategy.md (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');
    });
  });
});

リソース