APG Patterns
English GitHub
English GitHub

Tree View

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

🤖 AI Implementation Guide

デモ

単一選択 + アクティベーション

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

複数選択

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

無効化されたノード

  • Accessible Folder
    • file1.txt
    • file2.txt
  • public.txt

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA tree ロール (opens in new tab)

WAI-ARIA プロパティ(ツリーコンテナ)

属性 必須 説明
role="tree" - はい コンテナをツリーウィジェットとして識別
aria-label 文字列 はい* ツリーのアクセシブル名
aria-labelledby ID参照 はい* aria-label の代替(優先される)
aria-multiselectable true いいえ 複数選択モード時のみ設定

* aria-label または aria-labelledby のいずれかが必須

WAI-ARIA ステート(ツリーアイテム)

属性 必須 説明
aria-expanded true | false はい* 親ノードのみに設定。展開状態を示す
aria-selected true | false はい** すべてのtreeitemに必須(選択=true、その他=false)
aria-disabled true いいえ ノードが無効であることを示す
tabindex 0 | -1 はい フォーカス管理用のローヴィングタブインデックス

* 親ノードに必須。リーフノードには aria-expanded を設定しない
** 選択がサポートされている場合、すべてのtreeitemに aria-selected が必須

キーボードサポート

ナビゲーション

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

選択(単一選択モード)

キー アクション
/ フォーカスのみ移動(選択はフォーカスに追従しない)
Enter フォーカス中のノードを選択してアクティベート(onActivate発火)
Space フォーカス中のノードを選択してアクティベート(onActivate発火)
クリック クリックしたノードを選択してアクティベート(onActivate発火)

選択(複数選択モード)

キー アクション
Space フォーカス中のノードの選択をトグル
Ctrl + Space フォーカスを移動せずに選択をトグル
Shift + / アンカーから選択範囲を拡張
Shift + Home 最初のノードまで選択を拡張
Shift + End 最後の表示されているノードまで選択を拡張
Ctrl + A 表示されているすべてのノードを選択

フォーカス管理

このコンポーネントは、フォーカス管理にローヴィングタブインデックスを使用します:

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

無効化されたノード

  • aria-disabled="true" を持つ
  • フォーカス可能(キーボードナビゲーションに含まれる)
  • 選択またはアクティブ化できない
  • 親ノードの場合、展開/折りたたみができない
  • 視覚的に区別される(例: グレーアウト)

ソースコード

TreeView.tsx
import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';

export interface TreeNode {
  /** Unique identifier for the node */
  id: string;
  /** Display label for the node */
  label: string;
  /** Child nodes (makes this a parent node) */
  children?: TreeNode[];
  /** When true, the node cannot be selected, activated, or expanded */
  disabled?: boolean;
}

export interface TreeViewProps {
  /** Array of tree nodes */
  nodes: TreeNode[];
  /** Enable multi-select mode */
  multiselectable?: boolean;
  /** Initially selected node ID(s) - uncontrolled */
  defaultSelectedIds?: string[];
  /** Currently selected node ID(s) - controlled */
  selectedIds?: string[];
  /** Callback when selection changes */
  onSelectionChange?: (selectedIds: string[]) => void;
  /** Initially expanded node IDs - uncontrolled */
  defaultExpandedIds?: string[];
  /** Currently expanded node IDs - controlled */
  expandedIds?: string[];
  /** Callback when expansion changes */
  onExpandedChange?: (expandedIds: string[]) => void;
  /** Callback when node is activated (Enter key) */
  onActivate?: (nodeId: string) => void;
  /** Type-ahead search timeout in ms */
  typeAheadTimeout?: number;
  /** Accessible label */
  'aria-label'?: string;
  /** ID of labeling element */
  'aria-labelledby'?: string;
  /** Additional CSS class */
  className?: string;
}

interface FlatNode {
  node: TreeNode;
  depth: number;
  parentId: string | null;
  hasChildren: boolean;
}

export function TreeView({
  nodes,
  multiselectable = false,
  defaultSelectedIds = [],
  selectedIds: controlledSelectedIds,
  onSelectionChange,
  defaultExpandedIds = [],
  expandedIds: controlledExpandedIds,
  onExpandedChange,
  onActivate,
  typeAheadTimeout = 500,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  className = '',
}: TreeViewProps): React.ReactElement {
  const instanceId = useId();

  // Flatten tree for easier navigation
  const flattenTree = useCallback(
    (treeNodes: TreeNode[], depth: number = 0, parentId: string | null = null): FlatNode[] => {
      const result: FlatNode[] = [];
      for (const node of treeNodes) {
        const hasChildren = Boolean(node.children && node.children.length > 0);
        result.push({ node, depth, parentId, hasChildren });
        if (node.children) {
          result.push(...flattenTree(node.children, depth + 1, node.id));
        }
      }
      return result;
    },
    []
  );

  const allNodes = useMemo(() => flattenTree(nodes), [nodes, flattenTree]);
  const nodeMap = useMemo(() => {
    const map = new Map<string, FlatNode>();
    for (const flatNode of allNodes) {
      map.set(flatNode.node.id, flatNode);
    }
    return map;
  }, [allNodes]);

  // Expansion state
  const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(
    () => new Set(defaultExpandedIds)
  );
  const expandedIds = controlledExpandedIds ? new Set(controlledExpandedIds) : internalExpandedIds;

  const updateExpandedIds = useCallback(
    (newExpandedIds: Set<string>) => {
      if (!controlledExpandedIds) {
        setInternalExpandedIds(newExpandedIds);
      }
      onExpandedChange?.([...newExpandedIds]);
    },
    [controlledExpandedIds, onExpandedChange]
  );

  // Selection state
  const getInitialSelectedIds = useCallback(() => {
    // Filter out disabled nodes from default selection
    if (defaultSelectedIds.length > 0) {
      const validIds = defaultSelectedIds.filter((id) => {
        const flatNode = nodeMap.get(id);
        return flatNode && !flatNode.node.disabled;
      });
      if (validIds.length > 0) {
        return new Set(validIds);
      }
    }
    // No auto-selection - user must explicitly select via Enter/Space/Click
    return new Set<string>();
  }, [defaultSelectedIds, nodeMap]);

  const [internalSelectedIds, setInternalSelectedIds] =
    useState<Set<string>>(getInitialSelectedIds);
  const selectedIds = controlledSelectedIds ? new Set(controlledSelectedIds) : internalSelectedIds;

  const updateSelectedIds = useCallback(
    (newSelectedIds: Set<string>) => {
      if (!controlledSelectedIds) {
        setInternalSelectedIds(newSelectedIds);
      }
      onSelectionChange?.([...newSelectedIds]);
    },
    [controlledSelectedIds, onSelectionChange]
  );

  // Focus state - find first valid node (prefer selected, then first non-disabled)
  const [focusedId, setFocusedId] = useState<string>(() => {
    const firstSelected = [...selectedIds][0];
    if (firstSelected) {
      const flatNode = nodeMap.get(firstSelected);
      if (flatNode && !flatNode.node.disabled) {
        return firstSelected;
      }
    }
    // Fall back to first non-disabled node
    const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
    return firstEnabled?.node.id ?? '';
  });

  const nodeRefs = useRef<Map<string, HTMLLIElement>>(new Map());
  const typeAheadBuffer = useRef<string>('');
  const typeAheadTimeoutId = useRef<number | null>(null);
  const selectionAnchor = useRef<string>(focusedId);
  // Ref to track focused node synchronously (avoids stale closure issues)
  const focusedIdRef = useRef<string>(focusedId);

  // Get visible nodes (respecting expansion state)
  const visibleNodes = useMemo(() => {
    const result: FlatNode[] = [];
    const collapsedParents = new Set<string>();

    for (const flatNode of allNodes) {
      // Check if any ancestor is collapsed
      let isHidden = false;
      let currentParentId = flatNode.parentId;
      while (currentParentId) {
        if (collapsedParents.has(currentParentId) || !expandedIds.has(currentParentId)) {
          isHidden = true;
          break;
        }
        const parent = nodeMap.get(currentParentId);
        currentParentId = parent?.parentId ?? null;
      }

      if (!isHidden) {
        result.push(flatNode);
        if (flatNode.hasChildren && !expandedIds.has(flatNode.node.id)) {
          collapsedParents.add(flatNode.node.id);
        }
      }
    }
    return result;
  }, [allNodes, expandedIds, nodeMap]);

  const visibleIndexMap = useMemo(() => {
    const map = new Map<string, number>();
    visibleNodes.forEach((flatNode, index) => map.set(flatNode.node.id, index));
    return map;
  }, [visibleNodes]);

  // Ref to track the target node to focus (set before state update)
  const pendingFocusRef = useRef<string | null>(null);

  // Focus helpers
  const focusNode = useCallback((nodeId: string) => {
    focusedIdRef.current = nodeId;
    pendingFocusRef.current = nodeId;
    setFocusedId(nodeId);
  }, []);

  // Apply focus after render
  useLayoutEffect(() => {
    if (pendingFocusRef.current !== null) {
      const targetId = pendingFocusRef.current;
      pendingFocusRef.current = null;
      nodeRefs.current.get(targetId)?.focus();
    }
  });

  const focusByIndex = useCallback(
    (index: number) => {
      const flatNode = visibleNodes[index];
      if (flatNode) {
        focusNode(flatNode.node.id);
      }
    },
    [visibleNodes, focusNode]
  );

  // Expansion helpers
  const expandNode = useCallback(
    (nodeId: string) => {
      const flatNode = nodeMap.get(nodeId);
      if (!flatNode?.hasChildren || flatNode.node.disabled) return;
      if (expandedIds.has(nodeId)) return;

      const newExpanded = new Set(expandedIds);
      newExpanded.add(nodeId);
      updateExpandedIds(newExpanded);
    },
    [nodeMap, expandedIds, updateExpandedIds]
  );

  const collapseNode = useCallback(
    (nodeId: string) => {
      const flatNode = nodeMap.get(nodeId);
      if (!flatNode?.hasChildren || flatNode.node.disabled) return;
      if (!expandedIds.has(nodeId)) return;

      const newExpanded = new Set(expandedIds);
      newExpanded.delete(nodeId);
      updateExpandedIds(newExpanded);

      // If a child of this node was focused, move focus to the collapsed parent
      const currentFocused = nodeMap.get(focusedId);
      if (currentFocused) {
        let parentId = currentFocused.parentId;
        while (parentId) {
          if (parentId === nodeId) {
            focusNode(nodeId);
            break;
          }
          const parent = nodeMap.get(parentId);
          parentId = parent?.parentId ?? null;
        }
      }
    },
    [nodeMap, expandedIds, updateExpandedIds, focusedId, focusNode]
  );

  const expandAllSiblings = useCallback(
    (nodeId: string) => {
      const flatNode = nodeMap.get(nodeId);
      if (!flatNode) return;

      const newExpanded = new Set(expandedIds);
      for (const fn of allNodes) {
        if (fn.parentId === flatNode.parentId && fn.hasChildren && !fn.node.disabled) {
          newExpanded.add(fn.node.id);
        }
      }
      updateExpandedIds(newExpanded);
    },
    [nodeMap, allNodes, expandedIds, updateExpandedIds]
  );

  // Selection helpers
  const selectNode = useCallback(
    (nodeId: string) => {
      const flatNode = nodeMap.get(nodeId);
      if (flatNode?.node.disabled) return;

      if (multiselectable) {
        const newSelected = new Set(selectedIds);
        if (newSelected.has(nodeId)) {
          newSelected.delete(nodeId);
        } else {
          newSelected.add(nodeId);
        }
        updateSelectedIds(newSelected);
      } else {
        updateSelectedIds(new Set([nodeId]));
      }
    },
    [nodeMap, multiselectable, selectedIds, updateSelectedIds]
  );

  const selectRange = useCallback(
    (fromId: string, toId: string) => {
      const fromIndex = visibleIndexMap.get(fromId) ?? 0;
      const toIndex = visibleIndexMap.get(toId) ?? 0;
      const start = Math.min(fromIndex, toIndex);
      const end = Math.max(fromIndex, toIndex);

      const newSelected = new Set(selectedIds);
      for (let i = start; i <= end; i++) {
        const flatNode = visibleNodes[i];
        if (flatNode && !flatNode.node.disabled) {
          newSelected.add(flatNode.node.id);
        }
      }
      updateSelectedIds(newSelected);
    },
    [visibleIndexMap, visibleNodes, selectedIds, updateSelectedIds]
  );

  const selectAllVisible = useCallback(() => {
    const newSelected = new Set<string>();
    for (const flatNode of visibleNodes) {
      if (!flatNode.node.disabled) {
        newSelected.add(flatNode.node.id);
      }
    }
    updateSelectedIds(newSelected);
  }, [visibleNodes, updateSelectedIds]);

  // Type-ahead
  const handleTypeAhead = useCallback(
    (char: string) => {
      if (visibleNodes.length === 0) return;

      if (typeAheadTimeoutId.current !== null) {
        clearTimeout(typeAheadTimeoutId.current);
      }

      typeAheadBuffer.current += char.toLowerCase();
      const buffer = typeAheadBuffer.current;

      // Check if same character repeated
      const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

      const currentIndex = visibleIndexMap.get(focusedId) ?? 0;
      let startIndex: number;
      let searchStr: string;

      if (isSameChar) {
        // Same character repeated: cycle through matches starting from next
        typeAheadBuffer.current = buffer[0];
        startIndex = (currentIndex + 1) % visibleNodes.length;
        searchStr = buffer[0];
      } else if (buffer.length === 1) {
        // Single character: start from next to cycle through matches
        startIndex = (currentIndex + 1) % visibleNodes.length;
        searchStr = buffer;
      } else {
        // Multiple different characters: start from current to allow prefix matching
        startIndex = currentIndex;
        searchStr = buffer;
      }

      for (let i = 0; i < visibleNodes.length; i++) {
        const index = (startIndex + i) % visibleNodes.length;
        const flatNode = visibleNodes[index];
        // Skip disabled nodes in type-ahead
        if (flatNode.node.disabled) continue;
        if (flatNode.node.label.toLowerCase().startsWith(searchStr)) {
          focusNode(flatNode.node.id);
          // Update anchor in multiselect mode
          if (multiselectable) {
            selectionAnchor.current = flatNode.node.id;
          }
          // Type-ahead only moves focus, does not change selection
          break;
        }
      }

      typeAheadTimeoutId.current = window.setTimeout(() => {
        typeAheadBuffer.current = '';
        typeAheadTimeoutId.current = null;
      }, typeAheadTimeout);
    },
    [visibleNodes, visibleIndexMap, focusedId, focusNode, multiselectable, typeAheadTimeout]
  );

  // Keyboard handler
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (visibleNodes.length === 0) return;

      const { key, shiftKey, ctrlKey, metaKey } = event;

      // Use ref for current focused node (updated synchronously, avoids stale closure)
      const actualFocusedId = focusedIdRef.current;
      const currentIndex = visibleIndexMap.get(actualFocusedId) ?? 0;
      const currentFlatNode = visibleNodes[currentIndex];

      let shouldPreventDefault = false;

      switch (key) {
        case 'ArrowDown': {
          shouldPreventDefault = true;
          if (currentIndex < visibleNodes.length - 1) {
            const nextIndex = currentIndex + 1;
            focusByIndex(nextIndex);
            const nextNode = visibleNodes[nextIndex];
            if (multiselectable && shiftKey) {
              selectRange(selectionAnchor.current, nextNode.node.id);
            } else if (multiselectable) {
              selectionAnchor.current = nextNode.node.id;
            }
            // Single-select: focus moves but selection does not change
          }
          break;
        }

        case 'ArrowUp': {
          shouldPreventDefault = true;
          if (currentIndex > 0) {
            const prevIndex = currentIndex - 1;
            focusByIndex(prevIndex);
            const prevNode = visibleNodes[prevIndex];
            if (multiselectable && shiftKey) {
              selectRange(selectionAnchor.current, prevNode.node.id);
            } else if (multiselectable) {
              selectionAnchor.current = prevNode.node.id;
            }
            // Single-select: focus moves but selection does not change
          }
          break;
        }

        case 'ArrowRight': {
          shouldPreventDefault = true;
          if (!currentFlatNode) break;

          if (currentFlatNode.hasChildren && !currentFlatNode.node.disabled) {
            if (!expandedIds.has(actualFocusedId)) {
              // Expand closed parent
              expandNode(actualFocusedId);
            } else {
              // Move to first child
              const nextIndex = currentIndex + 1;
              if (nextIndex < visibleNodes.length) {
                const nextNode = visibleNodes[nextIndex];
                if (nextNode.parentId === actualFocusedId) {
                  focusByIndex(nextIndex);
                  // Update anchor on lateral navigation in multiselect
                  if (multiselectable) {
                    selectionAnchor.current = nextNode.node.id;
                  }
                  // Single-select: focus moves but selection does not change
                }
              }
            }
          }
          // Leaf node: do nothing
          break;
        }

        case 'ArrowLeft': {
          shouldPreventDefault = true;
          if (!currentFlatNode) break;

          if (
            currentFlatNode.hasChildren &&
            expandedIds.has(actualFocusedId) &&
            !currentFlatNode.node.disabled
          ) {
            // Collapse open parent
            collapseNode(actualFocusedId);
          } else if (currentFlatNode.parentId) {
            // Move to parent
            focusNode(currentFlatNode.parentId);
            // Update anchor on lateral navigation in multiselect
            if (multiselectable) {
              selectionAnchor.current = currentFlatNode.parentId;
            }
            // Single-select: focus moves but selection does not change
          }
          // Root with no expansion: do nothing
          break;
        }

        case 'Home': {
          shouldPreventDefault = true;
          focusByIndex(0);
          const firstNode = visibleNodes[0];
          if (multiselectable && shiftKey) {
            selectRange(selectionAnchor.current, firstNode.node.id);
          } else if (multiselectable) {
            selectionAnchor.current = firstNode.node.id;
          }
          // Single-select: focus moves but selection does not change
          break;
        }

        case 'End': {
          shouldPreventDefault = true;
          const lastIndex = visibleNodes.length - 1;
          focusByIndex(lastIndex);
          const lastNode = visibleNodes[lastIndex];
          if (multiselectable && shiftKey) {
            selectRange(selectionAnchor.current, lastNode.node.id);
          } else if (multiselectable) {
            selectionAnchor.current = lastNode.node.id;
          }
          // Single-select: focus moves but selection does not change
          break;
        }

        case 'Enter': {
          shouldPreventDefault = true;
          if (currentFlatNode && !currentFlatNode.node.disabled) {
            // Select the node (single-select replaces, multi-select behavior via selectNode)
            if (multiselectable) {
              selectNode(actualFocusedId);
              selectionAnchor.current = actualFocusedId;
            } else {
              updateSelectedIds(new Set([actualFocusedId]));
            }
            // Fire activation callback
            onActivate?.(actualFocusedId);
          }
          break;
        }

        case ' ': {
          shouldPreventDefault = true;
          if (currentFlatNode && !currentFlatNode.node.disabled) {
            if (multiselectable) {
              selectNode(actualFocusedId);
              // Ctrl+Space: toggle without updating anchor
              // Space alone: update anchor for subsequent Shift+Arrow operations
              if (!ctrlKey) {
                selectionAnchor.current = actualFocusedId;
              }
            } else {
              // Single-select: Space selects and activates (same as Enter)
              updateSelectedIds(new Set([actualFocusedId]));
              onActivate?.(actualFocusedId);
            }
          }
          break;
        }

        case '*': {
          shouldPreventDefault = true;
          expandAllSiblings(actualFocusedId);
          break;
        }

        case 'a':
        case 'A': {
          if ((ctrlKey || metaKey) && multiselectable) {
            shouldPreventDefault = true;
            selectAllVisible();
          } else {
            handleTypeAhead(key);
          }
          break;
        }

        default: {
          // Type-ahead for printable characters
          if (key.length === 1 && !ctrlKey && !metaKey) {
            shouldPreventDefault = true;
            handleTypeAhead(key);
          }
        }
      }

      if (shouldPreventDefault) {
        event.preventDefault();
      }
    },
    [
      visibleNodes,
      visibleIndexMap,
      focusedId,
      focusByIndex,
      focusNode,
      expandedIds,
      expandNode,
      collapseNode,
      expandAllSiblings,
      multiselectable,
      selectNode,
      selectRange,
      selectAllVisible,
      updateSelectedIds,
      nodeMap,
      onActivate,
      handleTypeAhead,
    ]
  );

  // Click handler
  const handleNodeClick = useCallback(
    (nodeId: string) => {
      const flatNode = nodeMap.get(nodeId);
      if (!flatNode || flatNode.node.disabled) return;

      focusNode(nodeId);

      // Toggle expansion for parent nodes
      if (flatNode.hasChildren) {
        if (expandedIds.has(nodeId)) {
          collapseNode(nodeId);
        } else {
          expandNode(nodeId);
        }
      }

      // Select and activate
      if (multiselectable) {
        selectNode(nodeId);
        selectionAnchor.current = nodeId;
      } else {
        updateSelectedIds(new Set([nodeId]));
      }
      onActivate?.(nodeId);
    },
    [
      nodeMap,
      focusNode,
      expandedIds,
      collapseNode,
      expandNode,
      multiselectable,
      selectNode,
      updateSelectedIds,
      onActivate,
    ]
  );

  // Render a node and its children recursively
  const renderNode = useCallback(
    (node: TreeNode, depth: number = 0): React.ReactNode => {
      const hasChildren = Boolean(node.children && node.children.length > 0);
      const isExpanded = expandedIds.has(node.id);
      const isSelected = selectedIds.has(node.id);
      const isFocused = focusedId === node.id;
      const visibleIndex = visibleIndexMap.get(node.id);
      const isVisible = visibleIndex !== undefined;

      if (!isVisible && depth > 0) {
        return null;
      }

      const nodeClass = `apg-treeview-item ${
        isSelected ? 'apg-treeview-item--selected' : ''
      } ${node.disabled ? 'apg-treeview-item--disabled' : ''} ${
        hasChildren ? 'apg-treeview-item--parent' : 'apg-treeview-item--leaf'
      }`.trim();

      const labelId = `${instanceId}-label-${node.id}`;

      return (
        <li
          key={node.id}
          ref={(el) => {
            if (el) {
              nodeRefs.current.set(node.id, el);
            } else {
              nodeRefs.current.delete(node.id);
            }
          }}
          role="treeitem"
          aria-labelledby={labelId}
          aria-expanded={hasChildren ? isExpanded : undefined}
          aria-selected={isSelected}
          aria-disabled={node.disabled || undefined}
          tabIndex={isFocused ? 0 : -1}
          className={nodeClass}
          style={{ '--depth': depth }}
          onClick={(e) => {
            e.stopPropagation();
            handleNodeClick(node.id);
          }}
          onFocus={(e) => {
            // Only handle focus if this element is the actual target (not bubbled from child)
            if (e.target === e.currentTarget) {
              focusedIdRef.current = node.id;
              setFocusedId(node.id);
            }
          }}
        >
          <span className="apg-treeview-item-content">
            {hasChildren && (
              <span className="apg-treeview-item-icon" aria-hidden="true">
                {isExpanded ? '▼' : '▶'}
              </span>
            )}
            <span id={labelId} className="apg-treeview-item-label">
              {node.label}
            </span>
          </span>
          {hasChildren && isExpanded && node.children && (
            <ul role="group" className="apg-treeview-group">
              {node.children.map((child) => renderNode(child, depth + 1))}
            </ul>
          )}
        </li>
      );
    },
    [expandedIds, selectedIds, focusedId, visibleIndexMap, handleNodeClick, instanceId]
  );

  const containerClass = `apg-treeview ${className}`.trim();

  return (
    <ul
      role="tree"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-multiselectable={multiselectable || undefined}
      className={containerClass}
      onKeyDown={handleKeyDown}
    >
      {nodes.map((node) => renderNode(node, 0))}
    </ul>
  );
}

export default TreeView;

使い方

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>
      {/* 基本的な単一選択 */}
      <TreeView
        nodes={nodes}
        aria-label="ファイルエクスプローラー"
      />

      {/* デフォルトで展開 */}
      <TreeView
        nodes={nodes}
        aria-label="ファイル"
        defaultExpandedIds={['documents']}
      />

      {/* 複数選択モード */}
      <TreeView
        nodes={nodes}
        aria-label="ファイル"
        multiselectable
      />

      {/* コールバック付き */}
      <TreeView
        nodes={nodes}
        aria-label="ファイル"
        onSelectionChange={(ids) => console.log('Selected:', ids)}
        onExpandedChange={(ids) => console.log('Expanded:', ids)}
        onActivate={(id) => console.log('Activated:', id)}
      />
    </div>
  );
}

API

TreeNode インターフェース

プロパティ 必須 説明
id string はい ノードの一意な識別子
label string はい ノードの表示ラベル
children TreeNode[] いいえ 子ノード(これを親ノードにする)
disabled boolean いいえ 選択、アクティブ化、展開を無効化

TreeView Props

プロパティ デフォルト 説明
nodes TreeNode[] 必須 ツリーノードの配列
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

テスト

テストでは、ARIA 属性、キーボード操作、展開/折りたたみ動作、選択モデル、およびアクセシビリティ要件の APG 準拠を検証します。

テストカテゴリ

高優先度: ARIA 構造

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

高優先度: アクセシブル名

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

高優先度: ナビゲーション

テスト 説明
ArrowDown 次の表示されているノードに移動
ArrowUp 前の表示されているノードに移動
Home 最初のノードに移動
End 最後の表示されているノードに移動
先行入力 文字入力で一致する表示されているノードにフォーカス

高優先度: 展開/折りたたみ

テスト 説明
ArrowRight(閉じた親) 親ノードを展開
ArrowRight(開いた親) 最初の子に移動
ArrowRight(リーフ) 何もしない
ArrowLeft(開いた親) 親ノードを折りたたむ
ArrowLeft(子/閉じた親) 親ノードに移動
ArrowLeft(ルート) 何もしない
*(アスタリスク) 現在のレベルのすべての兄弟を展開
Enter ノードをアクティブ化(展開/折りたたみはしない)

高優先度: 選択(単一選択)

テスト 説明
矢印ナビゲーション 選択がフォーカスに追従(矢印キーで選択が変更)
Space 単一選択モードでは効果なし
選択は1つのみ 一度に1つのノードのみ選択可能

高優先度: 選択(複数選択)

テスト 説明
Space フォーカス中のノードの選択をトグル
Ctrl+Space フォーカスを移動せずに選択をトグル
Shift+矢印 アンカーから選択範囲を拡張
Shift+Home 最初のノードまで選択を拡張
Shift+End 最後の表示されているノードまで選択を拡張
Ctrl+A 表示されているすべてのノードを選択
複数選択 複数のノードを同時に選択可能

高優先度: フォーカス管理

テスト 説明
単一タブストップ ツリーは単一のタブストップ(Tab/Shift+Tab)
tabindex="0" フォーカス中のノードが tabindex="0" を持つ
tabindex="-1" その他のノードが tabindex="-1" を持つ
折りたたまれた子をスキップ ナビゲーション中に折りたたまれた子はスキップ
親へフォーカス移動 子の親が折りたたまれるとフォーカスが親に移動

中優先度: 無効化されたノード

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

中優先度: 先行入力

テスト 説明
表示ノードのみ 先行入力は表示されているノードのみを検索
繰り返しでサイクル 同じ文字の繰り返しでマッチを順に巡回
複数文字プレフィックス 複数の文字が検索プレフィックスを形成
タイムアウトリセット タイムアウト後にバッファがリセット(デフォルト500ms)
無効ノードをスキップ 先行入力は無効化されたノードをスキップ

中優先度: マウス操作

テスト 説明
親をクリック 展開をトグルしてノードを選択
リーフをクリック リーフノードを選択
無効ノードをクリック 無効化されたノードは選択または展開できない

低優先度: コールバック

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

テストツール

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');
    });
  });
});

リソース