Tree View
子を持つアイテムを展開または折りたたむことができる階層的なリスト。 ファイルブラウザ、ナビゲーションメニュー、組織図などでよく使用されます。
デモ
単一選択 + アクティベーション
- Documents
- Images
- readme.md
複数選択
- Documents
- Images
- readme.md
無効化されたノード
- Accessible Folder
- Restricted Folder
- public.txt
アクセシビリティ
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
tree | Container (<ul>) | The tree widget container |
treeitem | Each node (<li>) | Individual tree nodes (both parent and leaf) |
group | Child container (<ul>) | Container for child nodes of an expanded parent |
WAI-ARIA tree role (opens in new tab)
WAI-ARIA Properties (Tree Container)
| Attribute | Values | Required | Description |
|---|---|---|---|
role="tree" | - | Yes | Identifies the container as a tree widget |
aria-label | String | Yes* | Accessible name for the tree |
aria-labelledby | ID reference | Yes* | Alternative to aria-label (takes precedence) |
aria-multiselectable | true | No | Only present for multi-select mode |
* Either aria-label or aria-labelledby is required.
WAI-ARIA States (Tree Items)
| Attribute | Values | Required | Description |
|---|---|---|---|
aria-expanded | true | false | Yes* | Present on parent nodes only. Indicates expansion state. |
aria-selected | true | false | Yes** | All treeitems must have this (selected=true, others=false) |
aria-disabled | true | No | Indicates the node is disabled |
tabindex | 0 | -1 | Yes | Roving tabindex for focus management |
* Required on parent nodes. Leaf nodes must NOT have aria-expanded.
** When selection is supported, ALL treeitems must have aria-selected.
Keyboard Support
Navigation
| Key | Action |
|---|---|
| ↓ | Move focus to next visible node |
| ↑ | Move focus to previous visible node |
| → | Closed parent: expand / Open parent: move to first child / Leaf: no action |
| ← | Open parent: collapse / Child or closed parent: move to parent / Root: no action |
| Home | Move focus to first node |
| End | Move focus to last visible node |
| Enter | Select and activate node (see Selection section below) |
| * | Expand all siblings at current level |
| Type characters | Move focus to next visible node starting with that character |
Selection (Single-Select Mode)
| Key | Action |
|---|---|
| ↓ / ↑ | Move focus only (selection does NOT follow focus) |
| Enter | Select focused node and activate (fire onActivate callback) |
| Space | Select focused node and activate (fire onActivate callback) |
| Click | Select clicked node and activate (fire onActivate callback) |
Selection (Multi-Select Mode)
| Key | Action |
|---|---|
| Space | Toggle selection of focused node |
| Ctrl + Space | Toggle selection without moving focus |
| Shift + ↓ / ↑ | Extend selection range from anchor |
| Shift + Home | Extend selection to first node |
| Shift + End | Extend selection to last visible node |
| Ctrl + A | Select all visible nodes |
Focus Management
This component uses roving tabindex for focus management:
- Only one node has
tabindex="0"(the focused node) - All other nodes have
tabindex="-1" - Tree is a single Tab stop (Tab enters tree, Shift+Tab exits)
- Focus moves only among visible nodes (collapsed children are skipped)
- When a parent is collapsed while a child has focus, focus moves to the parent
Disabled Nodes
- Have
aria-disabled="true" - Are focusable (included in keyboard navigation)
- Cannot be selected or activated
- Cannot be expanded or collapsed if a parent node
- Visually distinct (e.g., grayed out)
ソースコード
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
export interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
disabled?: boolean;
}
interface TreeViewProps {
nodes: TreeNode[];
multiselectable?: boolean;
defaultSelectedIds?: string[];
selectedIds?: string[];
defaultExpandedIds?: string[];
expandedIds?: string[];
ariaLabel?: string;
ariaLabelledby?: string;
typeAheadTimeout?: number;
onSelectionChange?: (selectedIds: string[]) => void;
onExpandedChange?: (expandedIds: string[]) => void;
onActivate?: (nodeId: string) => void;
class?: string;
}
interface FlatNode {
node: TreeNode;
depth: number;
parentId: string | null;
hasChildren: boolean;
}
let {
nodes = [],
multiselectable = false,
defaultSelectedIds = [],
selectedIds: controlledSelectedIds = undefined,
defaultExpandedIds = [],
expandedIds: controlledExpandedIds = undefined,
ariaLabel = undefined,
ariaLabelledby = undefined,
typeAheadTimeout = 500,
onSelectionChange = () => {},
onExpandedChange = () => {},
onActivate = () => {},
class: className = '',
}: TreeViewProps = $props();
let instanceId = $state('');
let nodeRefs = new SvelteMap<string, HTMLLIElement>();
let typeAheadBuffer = $state('');
let typeAheadTimeoutId: number | null = null;
let selectionAnchor = $state('');
let focusedIdRef = $state('');
// Internal state - using SvelteSet for fine-grained reactivity via mutations
let internalExpandedIds = new SvelteSet<string>();
let internalSelectedIds = new SvelteSet<string>();
// Helper function to sync SvelteSet with new values (using mutation for reactivity)
function syncSvelteSet<T>(target: SvelteSet<T>, source: Iterable<T>) {
target.clear();
for (const item of source) {
target.add(item);
}
}
let focusedId = $state('');
onMount(() => {
instanceId = `treeview-${Math.random().toString(36).slice(2, 11)}`;
});
// Cleanup type-ahead timeout on destroy
onDestroy(() => {
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
}
});
// 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;
}
let allNodes = $derived(flattenTree(nodes));
let nodeMap = $derived.by(() => {
const map = new SvelteMap<string, FlatNode>();
for (const flatNode of allNodes) {
map.set(flatNode.node.id, flatNode);
}
return map;
});
// Expansion state (controlled or uncontrolled)
let expandedIds = $derived(
controlledExpandedIds ? new SvelteSet(controlledExpandedIds) : internalExpandedIds
);
// Selection state (controlled or uncontrolled)
let selectedIds = $derived(
controlledSelectedIds ? new SvelteSet(controlledSelectedIds) : internalSelectedIds
);
// Visible nodes (respecting expansion state)
let visibleNodes = $derived.by(() => {
const result: FlatNode[] = [];
const collapsedParents = new SvelteSet<string>();
for (const flatNode of allNodes) {
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;
});
let visibleIndexMap = $derived.by(() => {
const map = new SvelteMap<string, number>();
visibleNodes.forEach((flatNode, index) => map.set(flatNode.node.id, index));
return map;
});
let containerClass = $derived(`apg-treeview ${className}`.trim());
// Initialize state
$effect(() => {
if (allNodes.length > 0 && internalSelectedIds.size === 0 && internalExpandedIds.size === 0) {
// Initialize expansion
syncSvelteSet(internalExpandedIds, defaultExpandedIds);
// Initialize selection (filter out disabled nodes)
if (defaultSelectedIds.length > 0) {
const validIds = defaultSelectedIds.filter((id) => {
const flatNode = nodeMap.get(id);
return flatNode && !flatNode.node.disabled;
});
if (validIds.length > 0) {
syncSvelteSet(internalSelectedIds, validIds);
}
}
// No auto-selection - user must explicitly select via Enter/Space/Click
// Initialize focus
const firstSelected = [...selectedIds][0];
if (firstSelected) {
const flatNode = nodeMap.get(firstSelected);
if (flatNode && !flatNode.node.disabled) {
focusedId = firstSelected;
focusedIdRef = firstSelected;
selectionAnchor = firstSelected;
return;
}
}
const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
if (firstEnabled) {
focusedId = firstEnabled.node.id;
focusedIdRef = firstEnabled.node.id;
selectionAnchor = firstEnabled.node.id;
}
}
});
// Reactive guard: ensure focusedId and selectionAnchor remain valid when visibility changes
$effect(() => {
// Skip during initialization
if (!focusedId) return;
// Check if focusedId is still visible
if (!visibleIndexMap.has(focusedId)) {
// Find first visible non-disabled node
const firstEnabled = visibleNodes.find((fn) => !fn.node.disabled);
if (firstEnabled) {
focusedId = firstEnabled.node.id;
focusedIdRef = firstEnabled.node.id;
selectionAnchor = firstEnabled.node.id;
// Focus moves but selection does not change automatically
}
}
// Check if selectionAnchor is still valid
if (selectionAnchor && !visibleIndexMap.has(selectionAnchor)) {
selectionAnchor = focusedId;
}
});
// Action to track node element references
function trackNodeRef(node: HTMLLIElement, nodeId: string) {
nodeRefs.set(nodeId, node);
return {
destroy() {
nodeRefs.delete(nodeId);
},
};
}
function updateExpandedIds(newExpandedIds: Set<string>) {
if (!controlledExpandedIds) {
syncSvelteSet(internalExpandedIds, newExpandedIds);
}
onExpandedChange([...newExpandedIds]);
}
function updateSelectedIds(newSelectedIds: Set<string>) {
if (!controlledSelectedIds) {
syncSvelteSet(internalSelectedIds, newSelectedIds);
}
onSelectionChange([...newSelectedIds]);
}
function setFocusedId(nodeId: string) {
focusedIdRef = nodeId;
focusedId = nodeId;
}
async function applyDomFocus(nodeId: string) {
await tick();
nodeRefs.get(nodeId)?.focus();
}
function focusNode(nodeId: string) {
setFocusedId(nodeId);
applyDomFocus(nodeId);
}
function focusByIndex(index: number) {
const flatNode = visibleNodes[index];
if (flatNode) {
focusNode(flatNode.node.id);
}
}
// Expansion helpers
function expandNode(nodeId: string) {
const flatNode = nodeMap.get(nodeId);
if (!flatNode?.hasChildren || flatNode.node.disabled) return;
if (expandedIds.has(nodeId)) return;
const newExpanded = new SvelteSet(expandedIds);
newExpanded.add(nodeId);
updateExpandedIds(newExpanded);
}
function collapseNode(nodeId: string) {
const flatNode = nodeMap.get(nodeId);
if (!flatNode?.hasChildren || flatNode.node.disabled) return;
if (!expandedIds.has(nodeId)) return;
const newExpanded = new SvelteSet(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;
}
}
}
function expandAllSiblings(nodeId: string) {
const flatNode = nodeMap.get(nodeId);
if (!flatNode) return;
const newExpanded = new SvelteSet(expandedIds);
for (const fn of allNodes) {
if (fn.parentId === flatNode.parentId && fn.hasChildren && !fn.node.disabled) {
newExpanded.add(fn.node.id);
}
}
updateExpandedIds(newExpanded);
}
// Selection helpers
function selectNode(nodeId: string) {
const flatNode = nodeMap.get(nodeId);
if (flatNode?.node.disabled) return;
if (multiselectable) {
const newSelected = new SvelteSet(selectedIds);
if (newSelected.has(nodeId)) {
newSelected.delete(nodeId);
} else {
newSelected.add(nodeId);
}
updateSelectedIds(newSelected);
} else {
updateSelectedIds(new SvelteSet([nodeId]));
}
}
function selectRange(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 SvelteSet(selectedIds);
for (let i = start; i <= end; i++) {
const flatNode = visibleNodes[i];
if (flatNode && !flatNode.node.disabled) {
newSelected.add(flatNode.node.id);
}
}
updateSelectedIds(newSelected);
}
function selectAllVisible() {
const newSelected = new SvelteSet<string>();
for (const flatNode of visibleNodes) {
if (!flatNode.node.disabled) {
newSelected.add(flatNode.node.id);
}
}
updateSelectedIds(newSelected);
}
// Type-ahead
function handleTypeAhead(char: string) {
if (visibleNodes.length === 0) return;
if (typeAheadTimeoutId !== null) {
clearTimeout(typeAheadTimeoutId);
}
typeAheadBuffer += char.toLowerCase();
const buffer = typeAheadBuffer;
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) {
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 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 = flatNode.node.id;
}
// Type-ahead only moves focus, does not change selection
break;
}
}
typeAheadTimeoutId = window.setTimeout(() => {
typeAheadBuffer = '';
typeAheadTimeoutId = null;
}, typeAheadTimeout);
}
function handleNodeClick(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 = nodeId;
} else {
updateSelectedIds(new SvelteSet([nodeId]));
}
onActivate(nodeId);
}
function handleNodeFocus(nodeId: string) {
focusedIdRef = nodeId;
focusedId = nodeId;
}
// Determine if key should be handled
function shouldHandleKey(key: string, ctrlKey: boolean, metaKey: boolean): boolean {
const handledKeys = [
'ArrowDown',
'ArrowUp',
'ArrowRight',
'ArrowLeft',
'Home',
'End',
'Enter',
' ',
'*',
];
if (handledKeys.includes(key)) return true;
if (key.length === 1 && !ctrlKey && !metaKey) return true;
if ((key === 'a' || key === 'A') && (ctrlKey || metaKey) && multiselectable) return true;
return false;
}
function handleKeyDown(event: KeyboardEvent) {
if (visibleNodes.length === 0) return;
const { key, shiftKey, ctrlKey, metaKey } = event;
// Call preventDefault synchronously for all handled keys
if (shouldHandleKey(key, ctrlKey, metaKey)) {
event.preventDefault();
}
const actualFocusedId = focusedIdRef;
const currentIndex = visibleIndexMap.get(actualFocusedId) ?? 0;
const currentFlatNode = visibleNodes[currentIndex];
switch (key) {
case 'ArrowDown': {
if (currentIndex < visibleNodes.length - 1) {
const nextIndex = currentIndex + 1;
focusByIndex(nextIndex);
const nextNode = visibleNodes[nextIndex];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor, nextNode.node.id);
} else if (multiselectable) {
selectionAnchor = nextNode.node.id;
}
// Single-select: focus moves but selection does not change
}
break;
}
case 'ArrowUp': {
if (currentIndex > 0) {
const prevIndex = currentIndex - 1;
focusByIndex(prevIndex);
const prevNode = visibleNodes[prevIndex];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor, prevNode.node.id);
} else if (multiselectable) {
selectionAnchor = prevNode.node.id;
}
// Single-select: focus moves but selection does not change
}
break;
}
case 'ArrowRight': {
if (!currentFlatNode) break;
if (currentFlatNode.hasChildren && !currentFlatNode.node.disabled) {
if (!expandedIds.has(actualFocusedId)) {
expandNode(actualFocusedId);
} else {
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 = nextNode.node.id;
}
// Single-select: focus moves but selection does not change
}
}
}
}
break;
}
case 'ArrowLeft': {
if (!currentFlatNode) break;
if (
currentFlatNode.hasChildren &&
expandedIds.has(actualFocusedId) &&
!currentFlatNode.node.disabled
) {
collapseNode(actualFocusedId);
} else if (currentFlatNode.parentId) {
focusNode(currentFlatNode.parentId);
// Update anchor on lateral navigation in multiselect
if (multiselectable) {
selectionAnchor = currentFlatNode.parentId;
}
// Single-select: focus moves but selection does not change
}
break;
}
case 'Home': {
focusByIndex(0);
const firstNode = visibleNodes[0];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor, firstNode.node.id);
} else if (multiselectable) {
selectionAnchor = firstNode.node.id;
}
// Single-select: focus moves but selection does not change
break;
}
case 'End': {
const lastIndex = visibleNodes.length - 1;
focusByIndex(lastIndex);
const lastNode = visibleNodes[lastIndex];
if (multiselectable && shiftKey) {
selectRange(selectionAnchor, lastNode.node.id);
} else if (multiselectable) {
selectionAnchor = lastNode.node.id;
}
// Single-select: focus moves but selection does not change
break;
}
case 'Enter': {
if (currentFlatNode && !currentFlatNode.node.disabled) {
// Select the node (single-select replaces, multi-select behavior via selectNode)
if (multiselectable) {
selectNode(actualFocusedId);
selectionAnchor = actualFocusedId;
} else {
updateSelectedIds(new SvelteSet([actualFocusedId]));
}
// Fire activation callback
onActivate(actualFocusedId);
}
break;
}
case ' ': {
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 = actualFocusedId;
}
} else {
// Single-select: Space selects and activates (same as Enter)
updateSelectedIds(new SvelteSet([actualFocusedId]));
onActivate(actualFocusedId);
}
}
break;
}
case '*': {
expandAllSiblings(actualFocusedId);
break;
}
case 'a':
case 'A': {
if ((ctrlKey || metaKey) && multiselectable) {
selectAllVisible();
} else if (!ctrlKey && !metaKey) {
handleTypeAhead(key);
}
break;
}
default: {
if (key.length === 1 && !ctrlKey && !metaKey) {
handleTypeAhead(key);
}
}
}
}
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(' ');
}
</script>
{#snippet renderNode(node: TreeNode, depth: number)}
{@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 labelId = `${instanceId}-label-${node.id}`}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
use:trackNodeRef={node.id}
role="treeitem"
aria-labelledby={labelId}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-disabled={node.disabled || undefined}
tabindex={isFocused ? 0 : -1}
class={getNodeClass(node, hasChildren)}
style="--depth: {depth}"
onclick={(e) => {
e.stopPropagation();
handleNodeClick(node.id);
}}
onfocus={(e) => {
if (e.target === e.currentTarget) {
handleNodeFocus(node.id);
}
}}
>
<span class="apg-treeview-item-content">
{#if hasChildren}
<span class="apg-treeview-item-icon" aria-hidden="true">
{isExpanded ? '\u25BC' : '\u25B6'}
</span>
{/if}
<span id={labelId} class="apg-treeview-item-label">
{node.label}
</span>
</span>
{#if hasChildren && isExpanded && node.children}
<ul role="group" class="apg-treeview-group">
{#each node.children as child (child.id)}
{@render renderNode(child, depth + 1)}
{/each}
</ul>
{/if}
</li>
{/snippet}
<ul
role="tree"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable || undefined}
class={containerClass}
onkeydown={handleKeyDown}
>
{#each nodes as node (node.id)}
{@render renderNode(node, 0)}
{/each}
</ul> 使い方
<script>
import TreeView from './TreeView.svelte';
const nodes = [
{
id: 'documents',
label: 'Documents',
children: [
{ id: 'report', label: 'report.pdf' },
{ id: 'notes', label: 'notes.txt' },
],
},
{ id: 'readme', label: 'readme.md' },
];
function handleSelectionChange(event) {
console.log('Selected:', event.detail);
}
function handleExpandedChange(event) {
console.log('Expanded:', event.detail);
}
function handleActivate(event) {
console.log('Activated:', event.detail);
}
</script>
<!-- 基本的な単一選択 -->
<TreeView
{nodes}
aria-label="ファイルエクスプローラー"
/>
<!-- デフォルトで展開 -->
<TreeView
{nodes}
aria-label="ファイル"
defaultExpandedIds={['documents']}
/>
<!-- 複数選択モード -->
<TreeView
{nodes}
aria-label="ファイル"
multiselectable
/>
<!-- コールバック付き -->
<TreeView
{nodes}
aria-label="ファイル"
onselectionchange={handleSelectionChange}
onexpandedchange={handleExpandedChange}
onactivate={handleActivate}
/> API
TreeNode インターフェース
| プロパティ | 型 | 必須 | 説明 |
|---|---|---|---|
id | string | はい | ノードの一意な識別子 |
label | string | はい | ノードの表示ラベル |
children | TreeNode[] | いいえ | 子ノード(これを親ノードにする) |
disabled | boolean | いいえ | 選択、アクティブ化、展開を無効化 |
Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
nodes | TreeNode[] | 必須 | ツリーノードの配列 |
multiselectable | boolean | false | 複数選択モードを有効化 |
defaultSelectedIds | string[] | [] | 初期選択されるノード ID(非制御) |
defaultExpandedIds | string[] | [] | 初期展開されるノード ID(非制御) |
typeAheadTimeout | number | 500 | 先行入力バッファのリセットタイムアウト(ミリ秒) |
aria-label | string | - | ツリーのアクセシブル名 |
イベント
| イベント | 詳細 | 説明 |
|---|---|---|
onselectionchange | string[] | 選択が変更された時に発火 |
onexpandedchange | string[] | 展開状態が変更された時に発火 |
onactivate | string | ノードがアクティブ化された時に発火(Enter キー) |
テスト
Tests verify APG compliance for ARIA attributes, keyboard interactions, expansion/collapse behavior, selection models, and accessibility requirements. The Tree View component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.
- ARIA attributes (role="tree", role="treeitem", role="group")
- Expansion state (aria-expanded)
- Selection state (aria-selected, aria-multiselectable)
- Disabled state (aria-disabled)
- Accessibility via jest-axe
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all frameworks. These tests cover interactions and cross-framework consistency.
- Arrow key navigation (ArrowUp, ArrowDown, Home, End)
- Expand/collapse with ArrowRight/ArrowLeft
- Selection with Enter, Space, and click
- Multi-select with Shift+Arrow
- Type-ahead character navigation
- axe-core accessibility scanning
- Cross-framework consistency checks
Test Categories
High Priority: ARIA Structure
| Test | Description |
|---|---|
role="tree" | Container element has the tree role |
role="treeitem" | Each node has the treeitem role |
role="group" | Child containers have the group role |
aria-expanded (parent) | Parent nodes have aria-expanded (true or false) |
aria-expanded (leaf) | Leaf nodes do NOT have aria-expanded |
aria-selected | All treeitems have aria-selected (true or false) |
aria-multiselectable | Tree has aria-multiselectable="true" in multi-select mode |
aria-disabled | Disabled nodes have aria-disabled="true" |
High Priority: Accessible Name
| Test | Description |
|---|---|
aria-label | Tree has accessible name via aria-label |
aria-labelledby | Tree can use aria-labelledby (takes precedence) |
High Priority: Navigation
| Test | Description |
|---|---|
ArrowDown | Moves to next visible node |
ArrowUp | Moves to previous visible node |
Home | Moves to first node |
End | Moves to last visible node |
Type-ahead | Typing characters focuses matching visible node |
High Priority: Expand/Collapse
| Test | Description |
|---|---|
ArrowRight (closed parent) | Expands the parent node |
ArrowRight (open parent) | Moves to first child |
ArrowRight (leaf) | Does nothing |
ArrowLeft (open parent) | Collapses the parent node |
ArrowLeft (child/closed) | Moves to parent node |
ArrowLeft (root) | Does nothing |
* (asterisk) | Expands all siblings at current level |
Enter | Activates node (does NOT toggle expansion) |
High Priority: Selection (Single-Select)
| Test | Description |
|---|---|
Arrow navigation | Selection follows focus (arrows change selection) |
Space | Has no effect in single-select mode |
Only one selected | Only one node can be selected at a time |
High Priority: Selection (Multi-Select)
| Test | Description |
|---|---|
Space | Toggles selection of focused node |
Ctrl+Space | Toggles selection without moving focus |
Shift+Arrow | Extends selection range from anchor |
Shift+Home | Extends selection to first node |
Shift+End | Extends selection to last visible node |
Ctrl+A | Selects all visible nodes |
Multiple selection | Multiple nodes can be selected simultaneously |
High Priority: Focus Management
| Test | Description |
|---|---|
Single Tab stop | Tree is a single Tab stop (Tab/Shift+Tab) |
tabindex="0" | Focused node has tabindex="0" |
tabindex="-1" | Other nodes have tabindex="-1" |
Skip collapsed | Collapsed children are skipped during navigation |
Focus to parent | Focus moves to parent when child's parent is collapsed |
Medium Priority: Disabled Nodes
| Test | Description |
|---|---|
Focusable | Disabled nodes can receive focus |
Not selectable | Disabled nodes cannot be selected |
Not expandable | Disabled parent nodes cannot be expanded/collapsed |
Not activatable | Enter key does not activate disabled nodes |
Medium Priority: Type-Ahead
| Test | Description |
|---|---|
Visible nodes only | Type-ahead only searches visible nodes |
Cycle on repeat | Repeated character cycles through matches |
Multi-character prefix | Multiple characters form a search prefix |
Timeout reset | Buffer resets after timeout (default 500ms) |
Skip disabled | Type-ahead skips disabled nodes |
Medium Priority: Mouse Interaction
| Test | Description |
|---|---|
Click parent | Toggles expansion and selects node |
Click leaf | Selects the leaf node |
Click disabled | Disabled nodes cannot be selected or expanded |
Low Priority: Callbacks
| Test | Description |
|---|---|
onSelectionChange | Called with selected IDs when selection changes |
onExpandedChange | Called with expanded IDs when expansion changes |
onActivate | Called with node ID on Enter key |
Example Test Code
The following is the actual E2E test file (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);
}
});
}); Running Tests
# Run unit tests for Tree View
npm run test -- treeview
# Run E2E tests for Tree View (all frameworks)
npm run test:e2e:pattern --pattern=tree-view
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=tree-view
npm run test:e2e:vue:pattern --pattern=tree-view
npm run test:e2e:svelte:pattern --pattern=tree-view
npm run test:e2e:astro:pattern --pattern=tree-view Testing Tools
- 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: Vitest with JSDOM for Web Component unit tests
- Accessibility: axe-core (opens in new tab)
- E2E: Playwright (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