Tree View
子を持つアイテムを展開または折りたたむことができる階層的なリスト。ファイルブラウザ、ナビゲーションメニュー、組織図などでよく使用されます。
デモ
Single-Select with Activation
- Documents
- report.pdf
- notes.txt
- Projects
- Project A
- Project B
- Images
- vacation.jpg
- profile.png
- readme.md
Activated: Select a node with Enter, Space, or Click
Multi-Select
- Documents
- report.pdf
- notes.txt
- Projects
- Project A
- Project B
- Images
- vacation.jpg
- profile.png
- readme.md
With Disabled Nodes
- Accessible Folder
- file1.txt
- file2.txt
- Restricted Folder
- secret.txt
- public.txt
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
tree | コンテナ(<ul>) | ツリーウィジェットのコンテナ |
treeitem | 各ノード(<li>) | 個々のツリーノード(親ノードとリーフノードの両方) |
group | 子コンテナ(<ul>) | 展開された親の子ノードのコンテナ |
WAI-ARIA プロパティ
role="tree"
コンテナをツリーウィジェットとして識別
- 値
- -
- 必須
- はい
aria-label
ツリーのアクセシブルな名前
- 値
- String
- 必須
- はい*
aria-labelledby
aria-labelの代替(優先される)
- 値
- ID参照
- 必須
- はい*
aria-multiselectable
複数選択モードでのみ存在
- 値
- true
- 必須
- いいえ
WAI-ARIA ステート
aria-expanded
- 対象要素
- 親treeitem
- 値
- true | false
- 必須
- はい
- 変更トリガー
- Click、ArrowRight、ArrowLeft、Enter
aria-selected
- 対象要素
- すべてのtreeitem
- 値
- true | false
- 必須
- はい
- 変更トリガー
- Click、Enter、Space、矢印キー
aria-disabled
- 対象要素
- 無効化されたtreeitem
- 値
- true
- 必須
- いいえ
キーボードサポート
ナビゲーション
| キー | アクション |
|---|---|
| ArrowDown | 次の表示ノードにフォーカスを移動 |
| ArrowUp | 前の表示ノードにフォーカスを移動 |
| ArrowRight | 閉じた親: 展開 / 開いた親: 最初の子へ移動 / リーフ: 操作なし |
| ArrowLeft | 開いた親: 折りたたみ / 子または閉じた親: 親へ移動 / ルート: 操作なし |
| Home | 最初のノードにフォーカスを移動 |
| End | 最後の表示ノードにフォーカスを移動 |
| Enter | ノードを選択してアクティブ化(下記選択セクション参照) |
| * | 現在のレベルのすべての兄弟を展開 |
| 文字入力 | その文字で始まる次の表示ノードにフォーカスを移動 |
選択(単一選択モード)
| キー | アクション |
|---|---|
| ArrowDown / ArrowUp | フォーカスのみ移動(選択はフォーカスに追従しない) |
| Enter | フォーカスされたノードを選択してアクティブ化(onActivateコールバックを発火) |
| Space | フォーカスされたノードを選択してアクティブ化(onActivateコールバックを発火) |
| クリック | クリックしたノードを選択してアクティブ化(onActivateコールバックを発火) |
選択(複数選択モード)
| キー | アクション |
|---|---|
| Space | フォーカスされたノードの選択を切り替え |
| Ctrl + Space | フォーカスを移動せずに選択を切り替え |
| Shift + ArrowDown / ArrowUp | アンカーから選択範囲を拡張 |
| Shift + Home | 最初のノードまで選択を拡張 |
| Shift + End | 最後の表示ノードまで選択を拡張 |
| Ctrl + A | すべての表示ノードを選択 |
- aria-labelまたはaria-labelledbyのいずれかが必須です。
- 親ノードはaria-expandedを持つ必要があります。リーフノードはaria-expandedを持ってはいけません。
- 選択がサポートされる場合、すべてのtreeitemはaria-selectedを持つ必要があります。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| Roving tabindex | 1つのノードのみがtabindex="0"を持つ(フォーカスされたノード) |
| 他のノード | 他のすべてのノードはtabindex="-1"を持つ |
| 単一Tabストップ | ツリーは単一のTabストップ(Tabで入り、Shift+Tabで出る) |
| 表示ノードのみ | フォーカスは表示ノード間のみを移動(折りたたまれた子はスキップ) |
| 折りたたみ動作 | 子にフォーカスがある状態で親を折りたたむと、フォーカスは親に移動 |
参考資料
ソースコード
TreeView.astro
---
/**
* APG Tree View Pattern - Astro Implementation
*
* A widget that presents a hierarchical list where items with children can be
* expanded or collapsed.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
*/
export interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
disabled?: boolean;
}
export interface Props {
/** Tree node data */
nodes: TreeNode[];
/** Enable multi-select mode */
multiselectable?: boolean;
/** Initially selected node IDs */
defaultSelectedIds?: string[];
/** Initially expanded node IDs */
defaultExpandedIds?: string[];
/** Accessible label for the tree */
'aria-label'?: string;
/** ID of element that labels the tree */
'aria-labelledby'?: string;
/** Type-ahead timeout in ms */
typeAheadTimeout?: number;
/** Additional CSS class */
class?: string;
}
interface FlatNode {
node: TreeNode;
depth: number;
parentId: string | null;
hasChildren: boolean;
}
const {
nodes = [],
multiselectable = false,
defaultSelectedIds = [],
defaultExpandedIds = [],
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
typeAheadTimeout = 500,
class: className = '',
} = Astro.props;
const instanceId = `treeview-${Math.random().toString(36).slice(2, 11)}`;
// Flatten tree for navigation
function flattenTree(
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 = flattenTree(nodes);
const nodeMap = new Map<string, FlatNode>(allNodes.map((fn) => [fn.node.id, fn]));
// Initialize expanded IDs
const expandedIds = new Set(defaultExpandedIds);
// Initialize selected IDs (filter out disabled nodes)
const selectedIds = new Set<string>();
if (defaultSelectedIds.length > 0) {
for (const id of defaultSelectedIds) {
const flatNode = nodeMap.get(id);
if (flatNode && !flatNode.node.disabled) {
selectedIds.add(id);
}
}
}
// No auto-selection - user must explicitly select via Enter/Space/Click
// Find initial focus
let initialFocusId = [...selectedIds][0];
if (!initialFocusId) {
const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
if (firstEnabled) {
initialFocusId = firstEnabled.node.id;
}
}
const containerClass = `apg-treeview ${className}`.trim();
function getNodeClass(node: TreeNode, hasChildren: boolean): string {
const classes = ['apg-treeview-item'];
if (selectedIds.has(node.id)) {
classes.push('apg-treeview-item--selected');
}
if (node.disabled) {
classes.push('apg-treeview-item--disabled');
}
if (hasChildren) {
classes.push('apg-treeview-item--parent');
} else {
classes.push('apg-treeview-item--leaf');
}
return classes.join(' ');
}
// Generate HTML string for recursive rendering
function renderNodeHTML(node: TreeNode, depth: number): string {
const hasChildren = Boolean(node.children && node.children.length > 0);
const flatNode = nodeMap.get(node.id);
const isExpanded = expandedIds.has(node.id);
const isSelected = selectedIds.has(node.id);
const isFocused = node.id === initialFocusId;
const labelId = `${instanceId}-label-${node.id}`;
const parentId = flatNode?.parentId ?? '';
const nodeClass = getNodeClass(node, hasChildren);
const ariaExpandedAttr = hasChildren ? ` aria-expanded="${isExpanded}"` : '';
const ariaDisabledAttr = node.disabled ? ' aria-disabled="true"' : '';
const parentIdAttr = parentId ? ` data-parent-id="${parentId}"` : '';
const hasChildrenAttr = hasChildren ? ' data-has-children="true"' : '';
const hiddenAttr = !isExpanded ? ' hidden' : '';
const icon = hasChildren
? `<span class="apg-treeview-item-icon" aria-hidden="true">${isExpanded ? '▼' : '▶'}</span>`
: '';
const childrenHTML =
hasChildren && node.children
? `<ul role="group" class="apg-treeview-group"${hiddenAttr}>${node.children.map((child) => renderNodeHTML(child, depth + 1)).join('')}</ul>`
: '';
return `<li role="treeitem" data-node-id="${node.id}"${parentIdAttr}${hasChildrenAttr} data-depth="${depth}" aria-labelledby="${labelId}"${ariaExpandedAttr} aria-selected="${isSelected}"${ariaDisabledAttr} tabindex="${isFocused ? 0 : -1}" class="${nodeClass}" style="--depth: ${depth}"><span class="apg-treeview-item-content">${icon}<span id="${labelId}" class="apg-treeview-item-label">${node.label}</span></span>${childrenHTML}</li>`;
}
const treeHTML = nodes.map((node) => renderNodeHTML(node, 0)).join('');
---
<apg-treeview
data-multiselectable={multiselectable ? 'true' : undefined}
data-type-ahead-timeout={typeAheadTimeout}
data-initial-selected={JSON.stringify([...selectedIds])}
data-initial-expanded={JSON.stringify([...expandedIds])}
data-initial-focus-id={initialFocusId}
>
<ul
role="tree"
aria-multiselectable={multiselectable || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
class={containerClass}
>
<Fragment set:html={treeHTML} />
</ul>
</apg-treeview>
<script>
interface FlatNode {
id: string;
label: string;
depth: number;
parentId: string | null;
hasChildren: boolean;
disabled: boolean;
}
class ApgTreeView extends HTMLElement {
private tree: HTMLElement | null = null;
private rafId: number | null = null;
private focusedId = '';
private selectionAnchor = '';
private selectedIds: Set<string> = new Set();
private expandedIds: Set<string> = new Set();
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private allNodes: FlatNode[] = [];
private nodeMap: Map<string, FlatNode> = new Map();
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.tree = this.querySelector('[role="tree"]');
if (!this.tree) {
console.warn('apg-treeview: tree element not found');
return;
}
// Build node map from DOM
this.buildNodeMap();
// Initialize from data attributes
try {
this.selectedIds = new Set(JSON.parse(this.dataset.initialSelected || '[]'));
this.expandedIds = new Set(JSON.parse(this.dataset.initialExpanded || '[]'));
} catch {
this.selectedIds = new Set();
this.expandedIds = new Set();
}
this.focusedId = this.dataset.initialFocusId || '';
this.selectionAnchor = this.focusedId;
this.tree.addEventListener('keydown', this.handleKeyDown);
this.tree.addEventListener('click', this.handleClick);
this.tree.addEventListener('focusin', this.handleFocus);
this.updateVisualState();
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
this.tree?.removeEventListener('keydown', this.handleKeyDown);
this.tree?.removeEventListener('click', this.handleClick);
this.tree?.removeEventListener('focusin', this.handleFocus);
this.tree = null;
}
private get isMultiselectable(): boolean {
return this.dataset.multiselectable === 'true';
}
private get typeAheadTimeout(): number {
return parseInt(this.dataset.typeAheadTimeout || '500', 10);
}
private buildNodeMap() {
this.allNodes = [];
this.nodeMap.clear();
const items = this.querySelectorAll<HTMLLIElement>('[role="treeitem"]');
items.forEach((item) => {
const id = item.dataset.nodeId || '';
const label = item.querySelector('.apg-treeview-item-label')?.textContent || '';
const depth = parseInt(item.dataset.depth || '0', 10);
const parentId = item.dataset.parentId || null;
const hasChildren = item.dataset.hasChildren === 'true';
const disabled = item.getAttribute('aria-disabled') === 'true';
const node: FlatNode = { id, label, depth, parentId, hasChildren, disabled };
this.allNodes.push(node);
this.nodeMap.set(id, node);
});
}
private getVisibleNodes(): FlatNode[] {
const result: FlatNode[] = [];
const collapsedParents = new Set<string>();
for (const node of this.allNodes) {
let isHidden = false;
let currentParentId = node.parentId;
while (currentParentId) {
if (collapsedParents.has(currentParentId) || !this.expandedIds.has(currentParentId)) {
isHidden = true;
break;
}
const parent = this.nodeMap.get(currentParentId);
currentParentId = parent?.parentId ?? null;
}
if (!isHidden) {
result.push(node);
if (node.hasChildren && !this.expandedIds.has(node.id)) {
collapsedParents.add(node.id);
}
}
}
return result;
}
private getVisibleIndexMap(): Map<string, number> {
const map = new Map<string, number>();
this.getVisibleNodes().forEach((node, index) => map.set(node.id, index));
return map;
}
private getNodeElement(nodeId: string): HTMLLIElement | null {
return this.querySelector<HTMLLIElement>(`[data-node-id="${CSS.escape(nodeId)}"]`);
}
private updateVisualState() {
// Update tabindex for roving tabindex
this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
const nodeId = item.dataset.nodeId || '';
item.tabIndex = nodeId === this.focusedId ? 0 : -1;
});
}
private focusNode(nodeId: string) {
this.focusedId = nodeId;
this.updateVisualState();
this.getNodeElement(nodeId)?.focus();
}
private expandNode(nodeId: string) {
const node = this.nodeMap.get(nodeId);
if (!node?.hasChildren || node.disabled) return;
if (this.expandedIds.has(nodeId)) return;
this.expandedIds.add(nodeId);
this.updateExpansionDOM(nodeId, true);
this.dispatchEvent(
new CustomEvent('expandedchange', {
detail: { expandedIds: [...this.expandedIds] },
bubbles: true,
})
);
}
private collapseNode(nodeId: string) {
const node = this.nodeMap.get(nodeId);
if (!node?.hasChildren || node.disabled) return;
if (!this.expandedIds.has(nodeId)) return;
this.expandedIds.delete(nodeId);
this.updateExpansionDOM(nodeId, false);
// If a child was focused, move focus to the collapsed parent
const currentFocused = this.nodeMap.get(this.focusedId);
if (currentFocused) {
let parentId = currentFocused.parentId;
while (parentId) {
if (parentId === nodeId) {
this.focusNode(nodeId);
break;
}
const parent = this.nodeMap.get(parentId);
parentId = parent?.parentId ?? null;
}
}
this.dispatchEvent(
new CustomEvent('expandedchange', {
detail: { expandedIds: [...this.expandedIds] },
bubbles: true,
})
);
}
private updateExpansionDOM(nodeId: string, expanded: boolean) {
const item = this.getNodeElement(nodeId);
if (!item) return;
item.setAttribute('aria-expanded', String(expanded));
const icon = item.querySelector('.apg-treeview-item-icon');
if (icon) {
icon.textContent = expanded ? '▼' : '▶';
}
const group = item.querySelector(':scope > [role="group"]');
if (group) {
if (expanded) {
group.removeAttribute('hidden');
} else {
group.setAttribute('hidden', '');
}
}
}
private expandAllSiblings(nodeId: string) {
const node = this.nodeMap.get(nodeId);
if (!node) return;
for (const n of this.allNodes) {
if (n.parentId === node.parentId && n.hasChildren && !n.disabled) {
if (!this.expandedIds.has(n.id)) {
this.expandedIds.add(n.id);
this.updateExpansionDOM(n.id, true);
}
}
}
this.dispatchEvent(
new CustomEvent('expandedchange', {
detail: { expandedIds: [...this.expandedIds] },
bubbles: true,
})
);
}
private updateSelection(nodeId: string | null, action: 'toggle' | 'set' | 'range' | 'all') {
const visibleNodes = this.getVisibleNodes();
const visibleIndexMap = this.getVisibleIndexMap();
if (action === 'all') {
this.selectedIds = new Set(visibleNodes.filter((n) => !n.disabled).map((n) => n.id));
} else if (action === 'range' && nodeId) {
const anchorIndex = visibleIndexMap.get(this.selectionAnchor) ?? 0;
const targetIndex = visibleIndexMap.get(nodeId) ?? 0;
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
for (let i = start; i <= end; i++) {
const node = visibleNodes[i];
if (node && !node.disabled) {
this.selectedIds.add(node.id);
}
}
} else if (nodeId) {
const node = this.nodeMap.get(nodeId);
if (node?.disabled) return;
if (this.isMultiselectable && action === 'toggle') {
if (this.selectedIds.has(nodeId)) {
this.selectedIds.delete(nodeId);
} else {
this.selectedIds.add(nodeId);
}
} else {
this.selectedIds = new Set([nodeId]);
}
}
// Update aria-selected
this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
const id = item.dataset.nodeId || '';
const isSelected = this.selectedIds.has(id);
item.setAttribute('aria-selected', String(isSelected));
item.classList.toggle('apg-treeview-item--selected', isSelected);
});
this.dispatchEvent(
new CustomEvent('selectionchange', {
detail: { selectedIds: [...this.selectedIds] },
bubbles: true,
})
);
}
private handleTypeAhead(char: string) {
const visibleNodes = this.getVisibleNodes();
if (visibleNodes.length === 0) return;
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
}
this.typeAheadBuffer += char.toLowerCase();
const buffer = this.typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
const visibleIndexMap = this.getVisibleIndexMap();
const currentIndex = visibleIndexMap.get(this.focusedId) ?? 0;
let startIndex: number;
let searchStr: string;
if (isSameChar) {
this.typeAheadBuffer = buffer[0];
startIndex = (currentIndex + 1) % visibleNodes.length;
searchStr = buffer[0];
} else if (buffer.length === 1) {
startIndex = (currentIndex + 1) % visibleNodes.length;
searchStr = buffer;
} else {
startIndex = currentIndex;
searchStr = buffer;
}
for (let i = 0; i < visibleNodes.length; i++) {
const index = (startIndex + i) % visibleNodes.length;
const node = visibleNodes[index];
if (node.disabled) continue;
if (node.label.toLowerCase().startsWith(searchStr)) {
this.focusNode(node.id);
this.selectionAnchor = node.id;
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const item = target.closest('[role="treeitem"]') as HTMLLIElement | null;
if (!item) return;
const nodeId = item.dataset.nodeId || '';
const node = this.nodeMap.get(nodeId);
if (!node) return;
event.stopPropagation();
this.focusNode(nodeId);
if (node.hasChildren) {
if (node.disabled) return;
if (this.expandedIds.has(nodeId)) {
this.collapseNode(nodeId);
} else {
this.expandNode(nodeId);
}
}
if (!node.disabled) {
if (this.isMultiselectable) {
this.updateSelection(nodeId, 'toggle');
} else {
this.updateSelection(nodeId, 'set');
}
this.selectionAnchor = nodeId;
// Fire activate event on click
this.dispatchEvent(
new CustomEvent('activate', {
detail: { nodeId },
bubbles: true,
})
);
}
};
private handleFocus = (event: FocusEvent) => {
const target = event.target as HTMLElement;
if (target.getAttribute('role') !== 'treeitem') return;
const nodeId = (target as HTMLLIElement).dataset.nodeId || '';
if (nodeId && nodeId !== this.focusedId) {
this.focusedId = nodeId;
this.updateVisualState();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const visibleNodes = this.getVisibleNodes();
if (visibleNodes.length === 0) return;
const { key, shiftKey, ctrlKey, metaKey } = event;
const visibleIndexMap = this.getVisibleIndexMap();
const currentIndex = visibleIndexMap.get(this.focusedId) ?? 0;
const currentNode = visibleNodes[currentIndex];
const handledKeys = [
'ArrowDown',
'ArrowUp',
'ArrowRight',
'ArrowLeft',
'Home',
'End',
'Enter',
' ',
'*',
];
if (
handledKeys.includes(key) ||
(key.length === 1 && !ctrlKey && !metaKey) ||
((key === 'a' || key === 'A') && (ctrlKey || metaKey) && this.isMultiselectable)
) {
event.preventDefault();
}
switch (key) {
case 'ArrowDown': {
if (currentIndex < visibleNodes.length - 1) {
const nextIndex = currentIndex + 1;
const nextNode = visibleNodes[nextIndex];
this.focusNode(nextNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(nextNode.id, 'range');
} else {
this.selectionAnchor = nextNode.id;
}
}
break;
}
case 'ArrowUp': {
if (currentIndex > 0) {
const prevIndex = currentIndex - 1;
const prevNode = visibleNodes[prevIndex];
this.focusNode(prevNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(prevNode.id, 'range');
} else {
this.selectionAnchor = prevNode.id;
}
}
break;
}
case 'ArrowRight': {
if (!currentNode) break;
if (currentNode.hasChildren && !currentNode.disabled) {
if (!this.expandedIds.has(this.focusedId)) {
this.expandNode(this.focusedId);
} else {
const nextIndex = currentIndex + 1;
if (nextIndex < visibleNodes.length) {
const nextNode = visibleNodes[nextIndex];
if (nextNode.parentId === this.focusedId) {
this.focusNode(nextNode.id);
this.selectionAnchor = nextNode.id;
}
}
}
}
break;
}
case 'ArrowLeft': {
if (!currentNode) break;
if (
currentNode.hasChildren &&
this.expandedIds.has(this.focusedId) &&
!currentNode.disabled
) {
this.collapseNode(this.focusedId);
} else if (currentNode.parentId) {
this.focusNode(currentNode.parentId);
this.selectionAnchor = currentNode.parentId;
}
break;
}
case 'Home': {
const firstNode = visibleNodes[0];
this.focusNode(firstNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(firstNode.id, 'range');
} else {
this.selectionAnchor = firstNode.id;
}
break;
}
case 'End': {
const lastNode = visibleNodes[visibleNodes.length - 1];
this.focusNode(lastNode.id);
if (this.isMultiselectable && shiftKey) {
this.updateSelection(lastNode.id, 'range');
} else {
this.selectionAnchor = lastNode.id;
}
break;
}
case 'Enter': {
if (currentNode && !currentNode.disabled) {
if (this.isMultiselectable) {
this.updateSelection(this.focusedId, 'toggle');
this.selectionAnchor = this.focusedId;
} else {
this.updateSelection(this.focusedId, 'set');
}
this.dispatchEvent(
new CustomEvent('activate', {
detail: { nodeId: this.focusedId },
bubbles: true,
})
);
}
break;
}
case ' ': {
if (currentNode && !currentNode.disabled) {
if (this.isMultiselectable) {
this.updateSelection(this.focusedId, 'toggle');
// Ctrl+Space: toggle without updating anchor
// Space alone: update anchor for subsequent Shift+Arrow operations
if (!ctrlKey) {
this.selectionAnchor = this.focusedId;
}
} else {
this.updateSelection(this.focusedId, 'set');
this.dispatchEvent(
new CustomEvent('activate', {
detail: { nodeId: this.focusedId },
bubbles: true,
})
);
}
}
break;
}
case '*': {
this.expandAllSiblings(this.focusedId);
break;
}
case 'a':
case 'A': {
if ((ctrlKey || metaKey) && this.isMultiselectable) {
this.updateSelection(null, 'all');
} else if (!ctrlKey && !metaKey) {
this.handleTypeAhead(key);
}
break;
}
default: {
if (key.length === 1 && !ctrlKey && !metaKey) {
this.handleTypeAhead(key);
}
}
}
};
}
if (!customElements.get('apg-treeview')) {
customElements.define('apg-treeview', ApgTreeView);
}
</script> 使い方
Example
---
import TreeView from './TreeView.astro';
const nodes = [
{
id: 'documents',
label: 'Documents',
children: [
{ id: 'report', label: 'report.pdf' },
{ id: 'notes', label: 'notes.txt' },
],
},
{ id: 'readme', label: 'readme.md' },
];
---
<!-- Basic single-select -->
<TreeView
nodes={nodes}
aria-label="File Explorer"
/>
<!-- With default expanded -->
<TreeView
nodes={nodes}
aria-label="Files"
defaultExpandedIds={['documents']}
/>
<!-- Multi-select mode -->
<TreeView
nodes={nodes}
aria-label="Files"
multiselectable
/>
<!-- With callbacks via custom events -->
<TreeView
nodes={nodes}
aria-label="Files"
/>
<script>
document.querySelector('apg-treeview')?.addEventListener('selection-change', (e) => {
console.log('Selected:', e.detail);
});
document.querySelector('apg-treeview')?.addEventListener('expanded-change', (e) => {
console.log('Expanded:', e.detail);
});
document.querySelector('apg-treeview')?.addEventListener('activate', (e) => {
console.log('Activated:', e.detail);
});
</script> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
nodes | TreeNode[] | Required | ツリーノードの配列 |
multiselectable | boolean | false | 複数選択モードを有効化 |
defaultSelectedIds | string[] | [] | 初期選択されるノード ID |
defaultExpandedIds | string[] | [] | 初期展開されるノード ID |
typeAheadTimeout | number | 500 | 先行入力バッファのリセットタイムアウト(ミリ秒) |
aria-label | string | - | ツリーのアクセシブル名 |
aria-labelledby | string | - | ラベル要素の ID |
TreeNode Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
id | string | required | ノードの一意な識別子 |
label | string | required | ノードの表示テキスト |
children | TreeNode[] | - | 子ノード(親ノードになる) |
disabled | boolean | false | ノードが無効化されているかどうか |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
selection-change | string[] | 選択が変更された時に発火 |
expanded-change | string[] | 展開状態が変更された時に発火 |
activate | string | ノードがアクティブ化された時に発火(Enter キー) |
テスト
テストは、ARIA属性、キーボード操作、展開/折りたたみ動作、選択モデル、アクセシビリティ要件におけるAPG準拠を検証します。Tree View コンポーネントは2層のテスト戦略を使用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のテストライブラリを使用して、コンポーネントの出力を検証します。正しいHTML構造とARIA属性を確認します。
- ARIA属性(role="tree"、role="treeitem"、role="group")
- 展開状態(aria-expanded)
- 選択状態(aria-selected、aria-multiselectable)
- 無効化状態(aria-disabled)
- jest-axeによるアクセシビリティ
E2Eテスト(Playwright)
実際のブラウザ環境で全フレームワークのコンポーネント動作を検証します。インタラクションとクロスフレームワークの一貫性をカバーします。
- 矢印キーナビゲーション(ArrowUp、ArrowDown、Home、End)
- ArrowRight/ArrowLeftでの展開/折りたたみ
- Enter、Space、クリックでの選択
- Shift+矢印での複数選択
- タイプアヘッド文字ナビゲーション
- axe-coreアクセシビリティスキャン
- クロスフレームワーク一貫性チェック
テストカテゴリ
高優先度 : ARIA構造(Unit + E2E)
| テスト | 説明 |
|---|---|
role="tree" | コンテナ要素がtreeロールを持つ |
role="treeitem" | 各ノードがtreeitemロールを持つ |
role="group" | 子コンテナがgroupロールを持つ |
aria-expanded (parent) | 親ノードがaria-expanded(trueまたはfalse)を持つ |
aria-expanded (leaf) | リーフノードはaria-expandedを持たない |
aria-selected | すべてのtreeitemがaria-selected(trueまたはfalse)を持つ |
aria-multiselectable | 複数選択モードでツリーがaria-multiselectable="true"を持つ |
aria-disabled | 無効化ノードがaria-disabled="true"を持つ |
高優先度 : アクセシブルな名前(Unit + E2E)
| テスト | 説明 |
|---|---|
aria-label | ツリーがaria-labelでアクセシブルな名前を持つ |
aria-labelledby | ツリーがaria-labelledby(優先される)を使用できる |
高優先度 : ナビゲーション(Unit + E2E)
| テスト | 説明 |
|---|---|
ArrowDown | 次の表示ノードに移動 |
ArrowUp | 前の表示ノードに移動 |
Home | 最初のノードに移動 |
End | 最後の表示ノードに移動 |
Type-ahead | 文字入力で一致する表示ノードにフォーカス |
高優先度 : 展開/折りたたみ(Unit + E2E)
| テスト | 説明 |
|---|---|
ArrowRight (closed parent) | 親ノードを展開 |
ArrowRight (open parent) | 最初の子に移動 |
ArrowRight (leaf) | 何もしない |
ArrowLeft (open parent) | 親ノードを折りたたむ |
ArrowLeft (child/closed) | 親ノードに移動 |
ArrowLeft (root) | 何もしない |
* (asterisk) | 現在のレベルのすべての兄弟を展開 |
Enter | ノードをアクティブ化(展開の切り替えはしない) |
高優先度 : 選択(単一選択)(Unit + E2E)
| テスト | 説明 |
|---|---|
Arrow navigation | 選択がフォーカスに追従(矢印で選択が変わる) |
Space | 単一選択モードでは効果なし |
Only one selected | 一度に1つのノードのみ選択可能 |
高優先度 : 選択(複数選択)(Unit + E2E)
| テスト | 説明 |
|---|---|
Space | フォーカスされたノードの選択を切り替え |
Ctrl+Space | フォーカスを移動せずに選択を切り替え |
Shift+Arrow | アンカーから選択範囲を拡張 |
Shift+Home | 最初のノードまで選択を拡張 |
Shift+End | 最後の表示ノードまで選択を拡張 |
Ctrl+A | すべての表示ノードを選択 |
Multiple selection | 複数のノードを同時に選択可能 |
高優先度 : フォーカス管理(Unit + E2E)
| テスト | 説明 |
|---|---|
Single Tab stop | ツリーが単一のTabストップ(Tab/Shift+Tab) |
tabindex="0" | フォーカスされたノードがtabindex="0"を持つ |
tabindex="-1" | 他のノードがtabindex="-1"を持つ |
Skip collapsed | ナビゲーション中に折りたたまれた子をスキップ |
Focus to parent | 子の親が折りたたまれると、フォーカスが親に移動 |
中優先度 : 無効化ノード(Unit + E2E)
| テスト | 説明 |
|---|---|
Focusable | 無効化ノードはフォーカスを受け取れる |
Not selectable | 無効化ノードは選択できない |
Not expandable | 無効化された親ノードは展開/折りたたみできない |
Not activatable | Enterキーで無効化ノードをアクティブ化できない |
中優先度 : タイプアヘッド(Unit + E2E)
| テスト | 説明 |
|---|---|
Visible nodes only | タイプアヘッドは表示ノードのみを検索 |
Cycle on repeat | 繰り返し文字でマッチをサイクル |
Multi-character prefix | 複数文字で検索プレフィックスを形成 |
Timeout reset | タイムアウト後にバッファがリセット(デフォルト500ms) |
Skip disabled | タイプアヘッドは無効化ノードをスキップ |
中優先度 : マウス操作(E2E)
| テスト | 説明 |
|---|---|
Click parent | 展開を切り替えてノードを選択 |
Click leaf | リーフノードを選択 |
Click disabled | 無効化ノードは選択・展開できない |
低優先度 : コールバック(Unit + E2E)
| テスト | 説明 |
|---|---|
onSelectionChange | 選択が変わったときに選択されたIDで呼び出される |
onExpandedChange | 展開が変わったときに展開されたIDで呼び出される |
onActivate | EnterキーでノードIDと共に呼び出される |
テストコード例
以下は実際の E2E テストファイルです (e2e/tree-view.spec.ts).
e2e/tree-view.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Tree View Pattern
*
* A hierarchical list where items with children can be expanded or collapsed.
* Common uses include file browsers, navigation menus, and organizational charts.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getTree = (page: import('@playwright/test').Page) => {
return page.getByRole('tree');
};
const getTreeItems = (page: import('@playwright/test').Page) => {
return page.getByRole('treeitem');
};
const getGroups = (page: import('@playwright/test').Page) => {
return page.getByRole('group');
};
/**
* Click on a treeitem element and wait for it to receive focus.
* This is needed because:
* 1. Frameworks like React apply focus asynchronously via useLayoutEffect
* 2. Expanded parent treeitems contain child elements, so we must click
* on the label area (top of the element) to avoid hitting children
* which have stopPropagation() on their click handlers.
*
* NOTE: Clicking on a parent node TOGGLES its expansion state.
* Use focusWithoutClick() for tests where you need to focus without side effects.
*/
const clickAndWaitForFocus = async (
element: import('@playwright/test').Locator,
_page: import('@playwright/test').Page
) => {
// Click at the top-left area of the element to hit the label, not children
// Use position { x: 10, y: 10 } to click near the top-left corner
await element.click({ position: { x: 10, y: 10 } });
// Wait for the element to become the active element
await expect
.poll(
async () => {
return await element.evaluate((el) => document.activeElement === el);
},
{ timeout: 5000 }
)
.toBe(true);
};
/**
* Focus a treeitem element without triggering click handlers.
* This is useful for testing keyboard navigation where we don't want
* to trigger expansion toggle that happens on click.
*/
const focusWithoutClick = async (
element: import('@playwright/test').Locator,
_page: import('@playwright/test').Page
) => {
await element.focus();
// Wait for the element to become the active element
await expect
.poll(
async () => {
return await element.evaluate((el) => document.activeElement === el);
},
{ timeout: 5000 }
)
.toBe(true);
};
/**
* Press a key on a focused element and wait for focus to settle on a treeitem.
* Uses element.press() for stability instead of page.keyboard.press().
*/
const pressKeyOnElement = async (element: import('@playwright/test').Locator, key: string) => {
await expect(element).toBeFocused();
await element.press(key);
// Wait for focus to settle on a treeitem
await expect(element.page().locator('[role="treeitem"]:focus')).toBeVisible();
};
/**
* Navigate to a target node by pressing ArrowDown multiple times.
* Waits for focus to settle after each key press.
*/
const navigateToIndex = async (page: import('@playwright/test').Page, targetIndex: number) => {
for (let i = 0; i < targetIndex; i++) {
const currentFocused = page.locator('[role="treeitem"]:focus');
await pressKeyOnElement(currentFocused, 'ArrowDown');
}
};
/**
* Wait for page hydration to complete.
* This ensures:
* 1. Tree element is rendered
* 2. Treeitems have proper aria-labelledby (not Svelte's pre-hydration IDs)
* 3. Interactive handlers are attached (one item has tabindex="0")
*/
async function waitForHydration(page: import('@playwright/test').Page) {
// Wait for basic rendering
await getTree(page).first().waitFor();
// Wait for hydration - ensure treeitems have proper aria-labelledby
const firstItem = getTreeItems(page).first();
await expect
.poll(async () => {
const labelledby = await firstItem.getAttribute('aria-labelledby');
return labelledby && labelledby.length > 1 && !labelledby.startsWith('-');
})
.toBe(true);
// Wait for full hydration - ensure interactive handlers are attached
await expect
.poll(async () => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const count = await items.count();
let hasFocusable = false;
for (let i = 0; i < count; i++) {
const tabindex = await items.nth(i).getAttribute('tabindex');
if (tabindex === '0') {
hasFocusable = true;
break;
}
}
return hasFocusable;
})
.toBe(true);
}
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Tree View (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
});
// ------------------------------------------
// 🔴 High Priority: APG ARIA Structure
// ------------------------------------------
test.describe('APG: ARIA Structure', () => {
test('container has role="tree"', async ({ page }) => {
const tree = getTree(page).first();
await expect(tree).toBeVisible();
});
test('nodes have role="treeitem"', async ({ page }) => {
const items = getTreeItems(page);
const count = await items.count();
expect(count).toBeGreaterThan(0);
});
test('child containers have role="group"', async ({ page }) => {
// First expand a parent node to make group visible
const tree = getTree(page).first();
const parentItem = tree.getByRole('treeitem', { expanded: true }).first();
if ((await parentItem.count()) > 0) {
const groups = getGroups(page);
const count = await groups.count();
expect(count).toBeGreaterThan(0);
}
});
test('tree has accessible name via aria-label', async ({ page }) => {
const tree = getTree(page).first();
const label = await tree.getAttribute('aria-label');
expect(label).toBeTruthy();
});
test('parent nodes have aria-expanded', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node (one with children)
const expandedItems = tree.locator('[role="treeitem"][aria-expanded]');
const count = await expandedItems.count();
expect(count).toBeGreaterThan(0);
const firstExpanded = expandedItems.first();
const expanded = await firstExpanded.getAttribute('aria-expanded');
expect(['true', 'false']).toContain(expanded);
});
test('leaf nodes do NOT have aria-expanded', async ({ page }) => {
const tree = getTree(page).first();
// Expand all to see leaf nodes
const items = tree.getByRole('treeitem');
const count = await items.count();
for (let i = 0; i < count; i++) {
const item = items.nth(i);
const expanded = await item.getAttribute('aria-expanded');
const hasGroup = (await item.locator('[role="group"]').count()) > 0;
// If item has no children (no group), it shouldn't have aria-expanded
if (!hasGroup && expanded === null) {
// This is correct - leaf node without aria-expanded
continue;
}
// If it has aria-expanded, it should also have children
if (expanded !== null) {
// Parent nodes with aria-expanded should have potential children
continue;
}
}
});
test('all treeitems have aria-selected', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const count = await items.count();
for (let i = 0; i < count; i++) {
const item = items.nth(i);
const selected = await item.getAttribute('aria-selected');
expect(['true', 'false']).toContain(selected);
}
});
test('multi-select tree has aria-multiselectable', async ({ page }) => {
// Find multi-select tree (second tree on demo page)
const trees = getTree(page);
const count = await trees.count();
// Look for a tree with aria-multiselectable
let foundMultiselect = false;
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const multiselectable = await tree.getAttribute('aria-multiselectable');
if (multiselectable === 'true') {
foundMultiselect = true;
break;
}
}
// Demo page should have at least one multi-select tree
expect(foundMultiselect).toBe(true);
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Navigation
// ------------------------------------------
test.describe('APG: Keyboard Navigation', () => {
test('ArrowDown moves to next visible node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toBeFocused();
await firstItem.press('ArrowDown');
// Focus should have moved to a different item
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
});
test('ArrowUp moves to previous visible node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Click second item first
const secondItem = items.nth(1);
await clickAndWaitForFocus(secondItem, page);
await expect(secondItem).toBeFocused();
await secondItem.press('ArrowUp');
const firstItem = items.first();
await expect(firstItem).toBeFocused();
});
test('Home moves to first node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Start from a later item
const laterItem = items.nth(2);
await clickAndWaitForFocus(laterItem, page);
await expect(laterItem).toBeFocused();
await laterItem.press('Home');
const firstItem = items.first();
await expect(firstItem).toBeFocused();
});
test('End moves to last visible node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Click on a non-parent item to avoid triggering expansion toggle
// Use the second item (first child of expanded parent)
const secondItem = items.nth(1);
await clickAndWaitForFocus(secondItem, page);
await expect(secondItem).toBeFocused();
await secondItem.press('End');
// Get current count after any DOM changes
const currentCount = await items.count();
const lastItem = items.nth(currentCount - 1);
await expect(lastItem).toBeFocused();
});
});
// ------------------------------------------
// 🔴 High Priority: Expand/Collapse
// ------------------------------------------
test.describe('APG: Expand/Collapse', () => {
test('ArrowRight on closed parent expands it', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node (any with aria-expanded attribute)
const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await anyParent.count()) > 0) {
// Get label ID for stable reference before any interaction
const labelledby = await anyParent.getAttribute('aria-labelledby');
const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
// Focus the first treeitem in tree using click
const firstItem = tree.getByRole('treeitem').first();
await clickAndWaitForFocus(firstItem, page);
// Navigate to find a collapsed parent using keyboard
// Press * to collapse all siblings first (so we have collapsed parents)
// Then we can test ArrowRight on a collapsed one
// Navigate to the parent node using keyboard
// Use Home to go to first item, then navigate down
await expect(firstItem).toBeFocused();
await firstItem.press('Home');
// Now navigate to the target node
const items = tree.getByRole('treeitem');
const count = await items.count();
let targetIndex = -1;
for (let i = 0; i < count; i++) {
const lb = await items.nth(i).getAttribute('aria-labelledby');
if (lb === labelledby) {
targetIndex = i;
break;
}
}
// Press ArrowDown to reach the target (with focus wait after each press)
await navigateToIndex(page, targetIndex);
// Now we're on the parent node - check if it's expanded and collapse if needed
const currentExpanded = await stableLocator.getAttribute('aria-expanded');
if (currentExpanded === 'true') {
// Collapse it first
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
}
// Now test ArrowRight to expand
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowRight');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
}
});
test('ArrowRight on open parent moves to first child', async ({ page }) => {
const tree = getTree(page).first();
// Find an expanded parent
const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();
if ((await expandedParent.count()) > 0) {
// Get parent's labelledby for stable reference
const parentLabelledby = await expandedParent.getAttribute('aria-labelledby');
// Focus without click to avoid toggling expansion
await focusWithoutClick(expandedParent, page);
await expect(expandedParent).toBeFocused();
await expandedParent.press('ArrowRight');
// Focus should move to first child - verify by checking:
// 1. Focus moved to a different element
// 2. The focused element is a treeitem
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeVisible();
// The focused item should not be the parent
const focusedLabelledby = await focusedItem.getAttribute('aria-labelledby');
expect(focusedLabelledby).not.toBe(parentLabelledby);
}
});
test('ArrowLeft on open parent collapses it', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node (any with aria-expanded attribute)
const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await anyParent.count()) > 0) {
// Get label ID for stable reference before any interaction
const labelledby = await anyParent.getAttribute('aria-labelledby');
const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
// Focus the first treeitem in tree using click
const firstItem = tree.getByRole('treeitem').first();
await clickAndWaitForFocus(firstItem, page);
// Navigate to the parent node using keyboard
await expect(firstItem).toBeFocused();
await firstItem.press('Home');
// Find the index of target node
const items = tree.getByRole('treeitem');
const count = await items.count();
let targetIndex = -1;
for (let i = 0; i < count; i++) {
const lb = await items.nth(i).getAttribute('aria-labelledby');
if (lb === labelledby) {
targetIndex = i;
break;
}
}
// Press ArrowDown to reach the target (with focus wait after each press)
await navigateToIndex(page, targetIndex);
// Now we're on the parent node - check if it's collapsed and expand if needed
const currentExpanded = await stableLocator.getAttribute('aria-expanded');
if (currentExpanded === 'false') {
// Expand it first
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowRight');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
}
// Now test ArrowLeft to collapse
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
}
});
test('ArrowLeft on child moves to parent', async ({ page }) => {
const tree = getTree(page).first();
// Find an expanded parent to access its children
const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();
if ((await expandedParent.count()) > 0) {
// Focus parent without click to avoid toggling expansion
await focusWithoutClick(expandedParent, page);
// Move to first child
await expect(expandedParent).toBeFocused();
await expandedParent.press('ArrowRight');
// Now press ArrowLeft to go back to parent
const focusedChild = page.locator('[role="treeitem"]:focus');
await expect(focusedChild).toBeFocused();
await focusedChild.press('ArrowLeft');
await expect(expandedParent).toBeFocused();
}
});
test('click on parent toggles expansion', async ({ page }) => {
const tree = getTree(page).first();
// Find a parent node
const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await parentItem.count()) > 0) {
const initialExpanded = await parentItem.getAttribute('aria-expanded');
// Click on label area (top of element) to avoid hitting children
await parentItem.click({ position: { x: 10, y: 10 } });
const newExpanded = await parentItem.getAttribute('aria-expanded');
expect(newExpanded).not.toBe(initialExpanded);
}
});
test('* key expands all siblings', async ({ page }) => {
const tree = getTree(page).first();
// Focus the first visible item using click
const firstItem = tree.getByRole('treeitem').first();
await clickAndWaitForFocus(firstItem, page);
// The * key expands all expandable siblings at the SAME LEVEL as the focused node
// First, let's ensure we're at the root level
await expect(firstItem).toBeFocused();
await firstItem.press('Home');
// Get all top-level treeitems (direct children of tree, not nested in groups)
// Top-level items are those that are not inside a group element
const topLevelParents = tree.locator(':scope > [role="treeitem"][aria-expanded]');
const topLevelCount = await topLevelParents.count();
if (topLevelCount > 0) {
// Collapse all top-level parents first so we can test * expanding them
for (let i = 0; i < topLevelCount; i++) {
const parent = topLevelParents.nth(i);
const expanded = await parent.getAttribute('aria-expanded');
if (expanded === 'true') {
// Navigate to this item and collapse it
const labelledby = await parent.getAttribute('aria-labelledby');
// Go home and navigate to find it
const currentFocused = page.locator('[role="treeitem"]:focus');
await pressKeyOnElement(currentFocused, 'Home');
const items = tree.getByRole('treeitem');
const count = await items.count();
for (let j = 0; j < count; j++) {
const lb = await items.nth(j).getAttribute('aria-labelledby');
if (lb === labelledby) {
// Navigate to this item (with focus wait after each press)
await navigateToIndex(page, j);
// Collapse it
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
await expect(parent).toHaveAttribute('aria-expanded', 'false');
break;
}
}
}
}
// Go back home
const currentFocused = page.locator('[role="treeitem"]:focus');
await pressKeyOnElement(currentFocused, 'Home');
// Press * to expand all siblings at the current level
const focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('*');
// Wait for all top-level parents to be expanded (state-based wait)
for (let i = 0; i < topLevelCount; i++) {
await expect(topLevelParents.nth(i)).toHaveAttribute('aria-expanded', 'true');
}
}
});
});
// ------------------------------------------
// 🔴 High Priority: Selection
// ------------------------------------------
test.describe('APG: Selection (Single-Select)', () => {
test('Enter selects focused node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
// Use first item for reliability, navigate to second using keyboard
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
// Navigate to second item
await pressKeyOnElement(firstItem, 'ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
// Press Enter to select
await secondItem.press('Enter');
await expect(secondItem).toHaveAttribute('aria-selected', 'true');
});
test('Space selects focused node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Navigate to another item without selecting (with focus wait)
await pressKeyOnElement(firstItem, 'ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
// Press Space to select
await secondItem.press('Space');
await expect(secondItem).toHaveAttribute('aria-selected', 'true');
// First item should no longer be selected in single-select
await expect(firstItem).toHaveAttribute('aria-selected', 'false');
});
test('arrow keys move focus without changing selection', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
// Select first item
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Press Enter to explicitly select it
await expect(firstItem).toBeFocused();
await firstItem.press('Enter');
// Navigate away (with focus wait)
await pressKeyOnElement(firstItem, 'ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toBeFocused();
// Selection should stay on first item (focus moved but not selection)
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
});
});
// ------------------------------------------
// 🟡 Medium Priority: Multi-Select
// ------------------------------------------
test.describe('APG: Selection (Multi-Select)', () => {
test('Space toggles selection in multi-select', async ({ page }) => {
// Find multi-select tree
const trees = getTree(page);
const count = await trees.count();
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const multiselectable = await tree.getAttribute('aria-multiselectable');
if (multiselectable === 'true') {
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Toggle off
await expect(firstItem).toBeFocused();
await firstItem.press('Space');
await expect(firstItem).toHaveAttribute('aria-selected', 'false');
// Toggle on
await expect(firstItem).toBeFocused();
await firstItem.press('Space');
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
break;
}
}
});
test('Shift+Arrow extends selection range', async ({ page }) => {
// Find multi-select tree
const trees = getTree(page);
const count = await trees.count();
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const multiselectable = await tree.getAttribute('aria-multiselectable');
if (multiselectable === 'true') {
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
// Shift+ArrowDown to extend selection
await expect(firstItem).toBeFocused();
await firstItem.press('Shift+ArrowDown');
const secondItem = items.nth(1);
await expect(secondItem).toHaveAttribute('aria-selected', 'true');
await expect(firstItem).toHaveAttribute('aria-selected', 'true');
break;
}
}
});
});
// ------------------------------------------
// 🟡 Medium Priority: Disabled State
// ------------------------------------------
test.describe('Disabled State', () => {
test('disabled nodes have aria-disabled', async ({ page }) => {
const tree = getTree(page);
const disabledItems = tree.locator('[role="treeitem"][aria-disabled="true"]');
if ((await disabledItems.count()) > 0) {
const firstDisabled = disabledItems.first();
await expect(firstDisabled).toHaveAttribute('aria-disabled', 'true');
}
});
test('disabled nodes are focusable but not selectable', async ({ page }) => {
const trees = getTree(page);
const count = await trees.count();
// Look for tree with disabled items (third tree on demo page)
for (let i = 0; i < count; i++) {
const tree = trees.nth(i);
const disabledItem = tree.locator('[role="treeitem"][aria-disabled="true"]').first();
if ((await disabledItem.count()) > 0) {
// Get the labelledby of the disabled item for navigation
const disabledLabelledby = await disabledItem.getAttribute('aria-labelledby');
// Focus the first item in this tree using keyboard navigation
const firstItem = tree.getByRole('treeitem').first();
await focusWithoutClick(firstItem, page);
// Navigate using keyboard to find the disabled item
const items = tree.getByRole('treeitem');
const itemCount = await items.count();
let foundDisabled = false;
for (let j = 0; j < itemCount; j++) {
const currentFocused = page.locator('[role="treeitem"]:focus');
const currentLabelledby = await currentFocused.getAttribute('aria-labelledby');
if (currentLabelledby === disabledLabelledby) {
foundDisabled = true;
break;
}
await pressKeyOnElement(currentFocused, 'ArrowDown');
}
if (foundDisabled) {
// Now we're on the disabled item via keyboard navigation
// Try to select it with Space (keep page.keyboard.press for disabled element tests)
await page.keyboard.press('Space');
// Should not be selected
await expect(disabledItem).toHaveAttribute('aria-selected', 'false');
}
break;
}
}
});
});
// ------------------------------------------
// 🟡 Medium Priority: Type-Ahead
// ------------------------------------------
test.describe('Type-Ahead', () => {
test('typing character focuses matching node', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toBeFocused();
// Type 'r' to find nodes starting with 'r' (like 'readme.md')
await firstItem.press('r');
// Wait for type-ahead to process (state-based wait)
// Focus should move to a node starting with 'r'
await expect
.poll(
async () => {
const focused = page.locator('[role="treeitem"]:focus');
const labelledby = await focused.getAttribute('aria-labelledby');
if (!labelledby) return false;
const label = page.locator(`[id="${labelledby}"]`);
const text = await label.textContent();
return text?.toLowerCase().startsWith('r') ?? false;
},
{ timeout: 5000 }
)
.toBe(true);
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management
// ------------------------------------------
test.describe('Focus Management', () => {
test('focused node has tabindex="0"', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('tabindex', '0');
});
test('other nodes have tabindex="-1"', async ({ page }) => {
const tree = getTree(page).first();
const items = tree.getByRole('treeitem');
const firstItem = items.first();
await clickAndWaitForFocus(firstItem, page);
await expect(firstItem).toHaveAttribute('tabindex', '0');
// Check that at least one other item has tabindex="-1"
const secondItem = items.nth(1);
await expect(secondItem).toHaveAttribute('tabindex', '-1');
});
test('focus moves to parent when parent is collapsed', async ({ page }) => {
const tree = getTree(page).first();
// Find an expanded parent and save its aria-labelledby
const expandedParentLocator = tree
.locator('[role="treeitem"][aria-expanded="true"]')
.first();
if ((await expandedParentLocator.count()) > 0) {
// Get the parent's labelledby ID to use as a stable selector
const labelledby = await expandedParentLocator.getAttribute('aria-labelledby');
// Focus parent without click to avoid toggling expansion
await focusWithoutClick(expandedParentLocator, page);
// Navigate to a child
await expect(expandedParentLocator).toBeFocused();
await expandedParentLocator.press('ArrowRight');
// Collapse the parent by pressing ArrowLeft twice
// (first ArrowLeft goes back to parent, second collapses it)
let focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
focusedItem = page.locator('[role="treeitem"]:focus');
await expect(focusedItem).toBeFocused();
await focusedItem.press('ArrowLeft');
// Use the stable selector to find the parent (now collapsed)
const parent = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
// Focus should be on the parent
await expect(parent).toBeFocused();
}
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const tree = getTree(page);
await tree.first().waitFor();
const results = await new AxeBuilder({ page })
.include('[role="tree"]')
.disableRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Tree View - Cross-framework Consistency', () => {
test('all frameworks have trees', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
const trees = getTree(page);
const count = await trees.count();
expect(count).toBeGreaterThan(0);
}
});
test('all frameworks support click to expand/collapse', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
const tree = getTree(page).first();
const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();
if ((await parentItem.count()) > 0) {
const initialExpanded = await parentItem.getAttribute('aria-expanded');
// Click on label area (top of element) to avoid hitting children
await parentItem.click({ position: { x: 10, y: 10 } });
// Wait for the expansion state to change
await expect(parentItem).not.toHaveAttribute('aria-expanded', initialExpanded!);
const newExpanded = await parentItem.getAttribute('aria-expanded');
expect(newExpanded).not.toBe(initialExpanded);
// Click again to toggle back
await parentItem.click({ position: { x: 10, y: 10 } });
await expect(parentItem).toHaveAttribute('aria-expanded', initialExpanded!);
}
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/tree-view/${framework}/demo/`);
await waitForHydration(page);
const tree = getTree(page).first();
// Check tree has accessible name
const label = await tree.getAttribute('aria-label');
expect(label).toBeTruthy();
// Check treeitems exist
const items = tree.getByRole('treeitem');
const count = await items.count();
expect(count).toBeGreaterThan(0);
// Check first item has aria-selected
const firstItem = items.first();
const selected = await firstItem.getAttribute('aria-selected');
expect(['true', 'false']).toContain(selected);
}
});
}); テストの実行
# Tree Viewのユニットテストを実行
npm run test -- treeview
# Tree ViewのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=tree-view
# 特定のフレームワークでE2Eテストを実行
npm run test:e2e:react:pattern --pattern=tree-view テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core (opens in new tab) - 自動アクセシビリティテスト
詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。
リソース
- 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