Tree View
子を持つアイテムを展開または折りたたむことができる階層的なリスト。 ファイルブラウザ、ナビゲーションメニュー、組織図などでよく使用されます。
🤖 AI Implementation Guideデモ
単一選択 + アクティベーション
- Documents
- report.pdf
- notes.txt
- Projects
- Images
- readme.md
Activated: Select a node with Enter, Space, or Click
複数選択
- Documents
- report.pdf
- notes.txt
- Projects
- Images
- vacation.jpg
- profile.png
- readme.md
無効化されたノード
- Accessible Folder
- file1.txt
- file2.txt
- Restricted Folder
- 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 と共に呼び出される |
テストツール
- React: React Testing Library (opens in new tab)
- Vue: Vue Testing Library (opens in new tab)
- Svelte: Svelte Testing Library (opens in new tab)
- Astro: Web Component ユニットテスト用の Vitest と JSDOM
- アクセシビリティ: axe-core (opens in new tab)
TreeView.test.tsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { TreeView, type TreeNode } from './TreeView';
// Test tree data
const simpleNodes: TreeNode[] = [
{
id: 'docs',
label: 'Documents',
children: [
{ id: 'report', label: 'report.pdf' },
{ id: 'notes', label: 'notes.txt' },
],
},
{
id: 'images',
label: 'Images',
children: [
{ id: 'photo1', label: 'photo1.jpg' },
{ id: 'photo2', label: 'photo2.jpg' },
],
},
{ id: 'readme', label: 'readme.md' },
];
const nestedNodes: TreeNode[] = [
{
id: 'root',
label: 'Root',
children: [
{
id: 'level1',
label: 'Level 1',
children: [
{
id: 'level2',
label: 'Level 2',
children: [{ id: 'level3', label: 'Level 3' }],
},
],
},
],
},
];
const nodesWithDisabled: TreeNode[] = [
{ id: 'item1', label: 'Item 1' },
{ id: 'item2', label: 'Item 2', disabled: true },
{ id: 'item3', label: 'Item 3' },
];
const nodesWithDisabledParent: TreeNode[] = [
{
id: 'parent',
label: 'Disabled Parent',
disabled: true,
children: [{ id: 'child', label: 'Child' }],
},
{ id: 'item', label: 'Item' },
];
describe('TreeView', () => {
// 🔴 High Priority: APG ARIA Attributes
describe('APG: ARIA Attributes', () => {
it('has role="tree" on container', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
expect(screen.getByRole('tree')).toBeInTheDocument();
});
it('has role="treeitem" on all nodes', () => {
render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs', 'images']} />
);
// 3 top-level + 2 docs children + 2 images children = 7
expect(screen.getAllByRole('treeitem')).toHaveLength(7);
});
it('has role="group" on child containers', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
expect(screen.getByRole('group')).toBeInTheDocument();
});
it('has aria-expanded on parent nodes only', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
expect(docs).toHaveAttribute('aria-expanded');
expect(images).toHaveAttribute('aria-expanded');
expect(readme).not.toHaveAttribute('aria-expanded');
});
it('leaf nodes do NOT have aria-expanded', () => {
render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs', 'images']} />
);
const report = screen.getByRole('treeitem', { name: 'report.pdf' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
expect(report).not.toHaveAttribute('aria-expanded');
expect(readme).not.toHaveAttribute('aria-expanded');
});
it('updates aria-expanded on expand/collapse', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
expect(docs).toHaveAttribute('aria-expanded', 'false');
docs.focus();
await user.keyboard('{ArrowRight}');
expect(docs).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{ArrowLeft}');
expect(docs).toHaveAttribute('aria-expanded', 'false');
});
it('all treeitems have aria-selected when selection enabled', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const treeitems = screen.getAllByRole('treeitem');
treeitems.forEach((item) => {
expect(item).toHaveAttribute('aria-selected');
});
});
it('selected node has aria-selected="true", others have "false"', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultSelectedIds={['readme']} />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
const docs = screen.getByRole('treeitem', { name: 'Documents' });
expect(readme).toHaveAttribute('aria-selected', 'true');
expect(docs).toHaveAttribute('aria-selected', 'false');
});
it('has aria-multiselectable="true" on multi-select tree', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
expect(screen.getByRole('tree')).toHaveAttribute('aria-multiselectable', 'true');
});
it('does not have aria-multiselectable on single-select tree', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
expect(screen.getByRole('tree')).not.toHaveAttribute('aria-multiselectable');
});
it('has accessible name via aria-label', () => {
render(<TreeView nodes={simpleNodes} aria-label="File Explorer" />);
expect(screen.getByRole('tree', { name: 'File Explorer' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<>
<h2 id="tree-label">My Files</h2>
<TreeView nodes={simpleNodes} aria-labelledby="tree-label" />
</>
);
expect(screen.getByRole('tree', { name: 'My Files' })).toBeInTheDocument();
});
it('disabled nodes have aria-disabled="true"', () => {
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
expect(disabled).toHaveAttribute('aria-disabled', 'true');
});
});
// 🔴 High Priority: Keyboard Navigation
describe('APG: Keyboard Navigation', () => {
it('ArrowDown moves to next visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveFocus();
});
it('ArrowDown moves into expanded children', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
});
it('ArrowUp moves to previous visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const images = screen.getByRole('treeitem', { name: 'Images' });
images.focus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('ArrowUp moves to parent from first child', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Start from Documents (which is expanded) and navigate to first child
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to first child (report.pdf)
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
await user.keyboard('{ArrowUp}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('ArrowRight expands closed parent', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowRight}');
expect(docs).toHaveAttribute('aria-expanded', 'true');
});
it('ArrowRight moves to first child when parent is expanded', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
});
it('ArrowRight does nothing on leaf node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Navigate to leaf node via keyboard
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to report.pdf
const report = screen.getByRole('treeitem', { name: 'report.pdf' });
expect(report).toHaveFocus();
await user.keyboard('{ArrowRight}');
expect(report).toHaveFocus();
});
it('ArrowLeft collapses open parent', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-expanded', 'true');
await user.keyboard('{ArrowLeft}');
expect(docs).toHaveAttribute('aria-expanded', 'false');
});
it('ArrowLeft moves to parent from child', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Navigate to child via keyboard
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to report.pdf
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('ArrowLeft moves to parent from closed parent', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nestedNodes} aria-label="Nested" defaultExpandedIds={['root']} />);
// Navigate to Level 1 via keyboard
const root = screen.getByRole('treeitem', { name: 'Root' });
root.focus();
await user.keyboard('{ArrowRight}'); // Move to Level 1
const level1 = screen.getByRole('treeitem', { name: 'Level 1' });
expect(level1).toHaveFocus();
expect(level1).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('treeitem', { name: 'Root' })).toHaveFocus();
});
it('ArrowLeft does nothing on root node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// First collapse, then try ArrowLeft again
expect(docs).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowLeft}');
expect(docs).toHaveFocus();
});
it('Home moves focus to first node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('{Home}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
});
it('End moves focus to last visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{End}');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('End moves to last visible node when children are expanded', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['images']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{End}');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('Enter activates node but does not toggle expansion', async () => {
const user = userEvent.setup();
const onActivate = vi.fn();
render(<TreeView nodes={simpleNodes} aria-label="Files" onActivate={onActivate} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{Enter}');
expect(docs).toHaveAttribute('aria-expanded', 'false');
expect(onActivate).toHaveBeenCalledWith('docs');
});
it('* expands all siblings at current level', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('*');
expect(docs).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it('collapsed children are skipped during navigation', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// docs is collapsed, so ArrowDown should skip its children
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveFocus();
});
});
// 🔴 High Priority: Type-ahead
describe('APG: Type-ahead', () => {
it('focuses matching visible node on character input', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('cycles through matches on repeated character', async () => {
const user = userEvent.setup();
const nodesWithSamePrefix: TreeNode[] = [
{ id: 'apple', label: 'Apple' },
{ id: 'apricot', label: 'Apricot' },
{ id: 'avocado', label: 'Avocado' },
];
render(<TreeView nodes={nodesWithSamePrefix} aria-label="Fruits" />);
const apple = screen.getByRole('treeitem', { name: 'Apple' });
apple.focus();
await user.keyboard('a');
expect(screen.getByRole('treeitem', { name: 'Apricot' })).toHaveFocus();
await user.keyboard('a');
expect(screen.getByRole('treeitem', { name: 'Avocado' })).toHaveFocus();
await user.keyboard('a');
expect(screen.getByRole('treeitem', { name: 'Apple' })).toHaveFocus();
});
it('only searches visible nodes', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// "report.pdf" is collapsed, so 'r' should match 'readme.md' instead
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
});
it('matches multiple characters typed quickly', async () => {
const user = userEvent.setup();
const nodesForTypeAhead: TreeNode[] = [
{ id: 'readme', label: 'readme.md' },
{ id: 'report', label: 'report.pdf' },
{ id: 'resources', label: 'resources' },
];
render(<TreeView nodes={nodesForTypeAhead} aria-label="Files" />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
// Type "rep" quickly to match "report.pdf"
await user.keyboard('rep');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
});
it('resets buffer after timeout', async () => {
const user = userEvent.setup();
const nodesForTypeAhead: TreeNode[] = [
{ id: 'readme', label: 'readme.md' },
{ id: 'report', label: 'report.pdf' },
{ id: 'resources', label: 'resources' },
];
render(<TreeView nodes={nodesForTypeAhead} aria-label="Files" typeAheadTimeout={100} />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
// Wait for timeout to reset buffer
await new Promise((resolve) => setTimeout(resolve, 150));
// New 'r' should cycle again
await user.keyboard('r');
expect(screen.getByRole('treeitem', { name: 'resources' })).toHaveFocus();
});
});
// 🔴 High Priority: Focus Management
describe('APG: Focus Management', () => {
it('tree is single Tab stop', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<TreeView nodes={simpleNodes} aria-label="Files" />
<button>After</button>
</>
);
const before = screen.getByRole('button', { name: 'Before' });
before.focus();
await user.keyboard('{Tab}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
await user.keyboard('{Tab}');
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('focused node has tabindex="0"', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
expect(docs).toHaveAttribute('tabindex', '0');
});
it('other nodes have tabindex="-1"', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const images = screen.getByRole('treeitem', { name: 'Images' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
expect(images).toHaveAttribute('tabindex', '-1');
expect(readme).toHaveAttribute('tabindex', '-1');
});
it('focus moves to parent when child is focused and parent collapses', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
// Navigate to report.pdf via keyboard
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}'); // Move to report.pdf
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveFocus();
// ArrowLeft from child goes to parent, then collapse
await user.keyboard('{ArrowLeft}'); // Move to parent (Documents)
expect(docs).toHaveFocus();
await user.keyboard('{ArrowLeft}'); // Collapse parent
expect(docs).toHaveFocus();
expect(docs).toHaveAttribute('aria-expanded', 'false');
});
it('focus moves to parent when parent is collapsed programmatically while child has focus', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />);
const report = screen.getByRole('treeitem', { name: 'report.pdf' });
const docs = screen.getByRole('treeitem', { name: 'Documents' });
report.focus();
expect(report).toHaveFocus();
// Simulate clicking the parent to collapse while child has focus
await user.click(docs);
// Focus should move to parent when collapsed
expect(docs).toHaveFocus();
});
it('disabled nodes are focusable', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const item1 = screen.getByRole('treeitem', { name: 'Item 1' });
item1.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('treeitem', { name: 'Item 2' })).toHaveFocus();
});
it('disabled nodes cannot be selected', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
disabled.focus();
// In single-select, selection follows focus but disabled nodes stay unselected
expect(disabled).toHaveAttribute('aria-selected', 'false');
});
it('disabled parent nodes cannot be expanded', async () => {
const user = userEvent.setup();
render(<TreeView nodes={nodesWithDisabledParent} aria-label="Items" />);
const parent = screen.getByRole('treeitem', { name: 'Disabled Parent' });
parent.focus();
expect(parent).toHaveAttribute('aria-expanded', 'false');
await user.keyboard('{ArrowRight}');
expect(parent).toHaveAttribute('aria-expanded', 'false');
});
it('disabled nodes do not trigger onActivate on Enter', async () => {
const user = userEvent.setup();
const onActivate = vi.fn();
render(<TreeView nodes={nodesWithDisabled} aria-label="Items" onActivate={onActivate} />);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
disabled.focus();
await user.keyboard('{Enter}');
expect(onActivate).not.toHaveBeenCalled();
});
it('disabled nodes do not toggle selection on Space in multi-select', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
<TreeView
nodes={nodesWithDisabled}
aria-label="Items"
multiselectable
onSelectionChange={onSelectionChange}
/>
);
const disabled = screen.getByRole('treeitem', { name: 'Item 2' });
disabled.focus();
onSelectionChange.mockClear();
await user.keyboard(' ');
expect(onSelectionChange).not.toHaveBeenCalled();
expect(disabled).toHaveAttribute('aria-selected', 'false');
});
});
// 🔴 High Priority: Boundary Navigation
describe('APG: Boundary Navigation', () => {
it('ArrowUp at first node does nothing', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowUp}');
expect(docs).toHaveFocus();
});
it('ArrowDown at last visible node does nothing', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('{ArrowDown}');
expect(readme).toHaveFocus();
});
it('ArrowDown at last visible node with expanded children does nothing', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['images']} />);
// Last visible is photo2.jpg when images is expanded, then readme.md
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
await user.keyboard('{ArrowDown}');
expect(readme).toHaveFocus();
});
});
// 🔴 High Priority: Selection (Single-Select) - Explicit Selection Model
// Arrow keys move focus only, Enter/Space/Click selects
describe('APG: Selection (Single-Select)', () => {
it('arrow keys move focus only (selection does NOT follow focus)', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultSelectedIds={['docs']} />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
docs.focus();
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(images).toHaveAttribute('aria-selected', 'false');
await user.keyboard('{ArrowDown}');
// Focus moved but selection did NOT follow
expect(images).toHaveFocus();
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(images).toHaveAttribute('aria-selected', 'false');
});
it('only one node is selected at a time', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
docs.focus();
// Select first node with Enter
await user.keyboard('{Enter}');
expect(docs).toHaveAttribute('aria-selected', 'true');
// Move focus and select another node
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
// Only the new node should be selected
expect(docs).toHaveAttribute('aria-selected', 'false');
expect(images).toHaveAttribute('aria-selected', 'true');
const selected = screen
.getAllByRole('treeitem')
.filter((item) => item.getAttribute('aria-selected') === 'true');
expect(selected).toHaveLength(1);
});
it('Space selects focused node in single-select mode', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
<TreeView nodes={simpleNodes} aria-label="Files" onSelectionChange={onSelectionChange} />
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard(' ');
expect(onSelectionChange).toHaveBeenCalledWith(['docs']);
expect(docs).toHaveAttribute('aria-selected', 'true');
});
it('calls onSelectionChange when Enter selects a node', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
<TreeView nodes={simpleNodes} aria-label="Files" onSelectionChange={onSelectionChange} />
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{Enter}');
expect(onSelectionChange).toHaveBeenCalledWith(['docs']);
});
});
// 🔴 High Priority: Selection (Multi-Select)
describe('APG: Selection (Multi-Select)', () => {
it('Space toggles selection', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
expect(docs).toHaveAttribute('aria-selected', 'false');
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'true');
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'false');
});
it('arrow keys move focus without changing selection', async () => {
const user = userEvent.setup();
render(
<TreeView
nodes={simpleNodes}
aria-label="Files"
multiselectable
defaultSelectedIds={['docs']}
/>
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowDown}');
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'false'
);
});
it('Shift+Arrow extends selection range', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard(' '); // Select docs
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
it('Ctrl+A selects all visible nodes', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{Control>}a{/Control}');
const allItems = screen.getAllByRole('treeitem');
allItems.forEach((item) => {
expect(item).toHaveAttribute('aria-selected', 'true');
});
});
it('Ctrl+A selects only visible nodes (not collapsed children)', async () => {
const user = userEvent.setup();
render(
<TreeView
nodes={simpleNodes}
aria-label="Files"
multiselectable
defaultExpandedIds={['docs']}
/>
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{Control>}a{/Control}');
// docs and its children are visible
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'report.pdf' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'notes.txt' })).toHaveAttribute(
'aria-selected',
'true'
);
// images is visible but collapsed, so its children should not be selected
// (they're not in the DOM when collapsed)
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
it('Ctrl+Space toggles selection without updating anchor', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
const images = screen.getByRole('treeitem', { name: 'Images' });
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
docs.focus();
// Select docs with Space (sets anchor to docs)
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'true');
// Move to Images and toggle with Ctrl+Space (should NOT update anchor)
await user.keyboard('{ArrowDown}');
expect(images).toHaveFocus();
await user.keyboard('{Control>} {/Control}');
expect(images).toHaveAttribute('aria-selected', 'true');
// Now Shift+ArrowDown should extend from original anchor (docs), not from Images
// This will select from docs to readme (anchor=docs, current=images, target=readme)
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
// All three should be selected because we extend from anchor (docs) to readme
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(images).toHaveAttribute('aria-selected', 'true');
expect(readme).toHaveAttribute('aria-selected', 'true');
});
it('Shift+Home extends selection from anchor to first node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const readme = screen.getByRole('treeitem', { name: 'readme.md' });
readme.focus();
// Set anchor by selecting with Space
await user.keyboard(' ');
expect(readme).toHaveAttribute('aria-selected', 'true');
// Shift+Home should select from readme.md to Documents
await user.keyboard('{Shift>}{Home}{/Shift}');
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveFocus();
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(readme).toHaveAttribute('aria-selected', 'true');
});
it('Shift+End extends selection from anchor to last visible node', async () => {
const user = userEvent.setup();
render(<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
// Set anchor by selecting with Space
await user.keyboard(' ');
expect(docs).toHaveAttribute('aria-selected', 'true');
// Shift+End should select from Documents to readme.md
await user.keyboard('{Shift>}{End}{/Shift}');
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveFocus();
expect(docs).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-selected',
'true'
);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
});
// 🔴 High Priority: Expansion Callbacks
describe('Expansion', () => {
it('calls onExpandedChange when nodes are expanded', async () => {
const user = userEvent.setup();
const onExpandedChange = vi.fn();
render(
<TreeView nodes={simpleNodes} aria-label="Files" onExpandedChange={onExpandedChange} />
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowRight}');
expect(onExpandedChange).toHaveBeenCalledWith(['docs']);
});
it('calls onExpandedChange when nodes are collapsed', async () => {
const user = userEvent.setup();
const onExpandedChange = vi.fn();
render(
<TreeView
nodes={simpleNodes}
aria-label="Files"
defaultExpandedIds={['docs']}
onExpandedChange={onExpandedChange}
/>
);
const docs = screen.getByRole('treeitem', { name: 'Documents' });
docs.focus();
await user.keyboard('{ArrowLeft}');
expect(onExpandedChange).toHaveBeenCalledWith([]);
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs']} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with multi-select', async () => {
const { container } = render(
<TreeView nodes={simpleNodes} aria-label="Files" multiselectable />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with disabled nodes', async () => {
const { container } = render(<TreeView nodes={nodesWithDisabled} aria-label="Items" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟢 Low Priority: Props & Behavior
describe('Props & Behavior', () => {
it('respects defaultExpandedIds', () => {
render(
<TreeView nodes={simpleNodes} aria-label="Files" defaultExpandedIds={['docs', 'images']} />
);
expect(screen.getByRole('treeitem', { name: 'Documents' })).toHaveAttribute(
'aria-expanded',
'true'
);
expect(screen.getByRole('treeitem', { name: 'Images' })).toHaveAttribute(
'aria-expanded',
'true'
);
});
it('respects defaultSelectedIds', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" defaultSelectedIds={['readme']} />);
expect(screen.getByRole('treeitem', { name: 'readme.md' })).toHaveAttribute(
'aria-selected',
'true'
);
});
it('applies className to container', () => {
render(<TreeView nodes={simpleNodes} aria-label="Files" className="custom-tree" />);
expect(screen.getByRole('tree')).toHaveClass('custom-tree');
});
});
}); リソース
- WAI-ARIA APG: Tree View パターン (opens in new tab)
- APG サンプル: ナビゲーション Treeview (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist