APG Patterns
English
English

Tree View

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

デモ

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

  • readme.md
Activated: Select a node with Enter, Space, or Click

複数選択

  • readme.md

無効化されたノード

  • 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)

ソースコード

TreeView.svelte
<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>

使い方

Example
<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).

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

リソース