APG Patterns
English
English

Tree View

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

デモ

Single-Select with Activation

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

Multi-Select

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

With Disabled Nodes

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

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA プロパティ

role="tree"

コンテナをツリーウィジェットとして識別

-
必須
はい

aria-label

ツリーのアクセシブルな名前

String
必須
はい*

aria-labelledby

aria-labelの代替(優先される)

ID参照
必須
はい*

aria-multiselectable

複数選択モードでのみ存在

true
必須
いいえ

WAI-ARIA ステート

aria-expanded

対象要素
親treeitem
true | false
必須
はい
変更トリガー
Click、ArrowRight、ArrowLeft、Enter

aria-selected

対象要素
すべてのtreeitem
true | false
必須
はい
変更トリガー
Click、Enter、Space、矢印キー

aria-disabled

対象要素
無効化されたtreeitem
true
必須
いいえ

キーボードサポート

ナビゲーション

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

選択(単一選択モード)

キーアクション
ArrowDown / ArrowUpフォーカスのみ移動(選択はフォーカスに追従しない)
Enterフォーカスされたノードを選択してアクティブ化(onActivateコールバックを発火)
Spaceフォーカスされたノードを選択してアクティブ化(onActivateコールバックを発火)
クリッククリックしたノードを選択してアクティブ化(onActivateコールバックを発火)

選択(複数選択モード)

キーアクション
Spaceフォーカスされたノードの選択を切り替え
Ctrl + Spaceフォーカスを移動せずに選択を切り替え
Shift + ArrowDown / ArrowUpアンカーから選択範囲を拡張
Shift + Home最初のノードまで選択を拡張
Shift + End最後の表示ノードまで選択を拡張
Ctrl + Aすべての表示ノードを選択
  • aria-labelまたはaria-labelledbyのいずれかが必須です。
  • 親ノードはaria-expandedを持つ必要があります。リーフノードはaria-expandedを持ってはいけません。
  • 選択がサポートされる場合、すべてのtreeitemはaria-selectedを持つ必要があります。

フォーカス管理

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

参考資料

ソースコード

TreeView.astro
---
/**
 * APG Tree View Pattern - Astro Implementation
 *
 * A widget that presents a hierarchical list where items with children can be
 * expanded or collapsed.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
 */

export interface TreeNode {
  id: string;
  label: string;
  children?: TreeNode[];
  disabled?: boolean;
}

export interface Props {
  /** Tree node data */
  nodes: TreeNode[];
  /** Enable multi-select mode */
  multiselectable?: boolean;
  /** Initially selected node IDs */
  defaultSelectedIds?: string[];
  /** Initially expanded node IDs */
  defaultExpandedIds?: string[];
  /** Accessible label for the tree */
  'aria-label'?: string;
  /** ID of element that labels the tree */
  'aria-labelledby'?: string;
  /** Type-ahead timeout in ms */
  typeAheadTimeout?: number;
  /** Additional CSS class */
  class?: string;
}

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

const {
  nodes = [],
  multiselectable = false,
  defaultSelectedIds = [],
  defaultExpandedIds = [],
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  typeAheadTimeout = 500,
  class: className = '',
} = Astro.props;

const instanceId = `treeview-${Math.random().toString(36).slice(2, 11)}`;

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

const allNodes = flattenTree(nodes);
const nodeMap = new Map<string, FlatNode>(allNodes.map((fn) => [fn.node.id, fn]));

// Initialize expanded IDs
const expandedIds = new Set(defaultExpandedIds);

// Initialize selected IDs (filter out disabled nodes)
const selectedIds = new Set<string>();
if (defaultSelectedIds.length > 0) {
  for (const id of defaultSelectedIds) {
    const flatNode = nodeMap.get(id);
    if (flatNode && !flatNode.node.disabled) {
      selectedIds.add(id);
    }
  }
}
// No auto-selection - user must explicitly select via Enter/Space/Click

// Find initial focus
let initialFocusId = [...selectedIds][0];
if (!initialFocusId) {
  const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
  if (firstEnabled) {
    initialFocusId = firstEnabled.node.id;
  }
}

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

function getNodeClass(node: TreeNode, hasChildren: boolean): string {
  const classes = ['apg-treeview-item'];
  if (selectedIds.has(node.id)) {
    classes.push('apg-treeview-item--selected');
  }
  if (node.disabled) {
    classes.push('apg-treeview-item--disabled');
  }
  if (hasChildren) {
    classes.push('apg-treeview-item--parent');
  } else {
    classes.push('apg-treeview-item--leaf');
  }
  return classes.join(' ');
}

// Generate HTML string for recursive rendering
function renderNodeHTML(node: TreeNode, depth: number): string {
  const hasChildren = Boolean(node.children && node.children.length > 0);
  const flatNode = nodeMap.get(node.id);
  const isExpanded = expandedIds.has(node.id);
  const isSelected = selectedIds.has(node.id);
  const isFocused = node.id === initialFocusId;
  const labelId = `${instanceId}-label-${node.id}`;
  const parentId = flatNode?.parentId ?? '';
  const nodeClass = getNodeClass(node, hasChildren);

  const ariaExpandedAttr = hasChildren ? ` aria-expanded="${isExpanded}"` : '';
  const ariaDisabledAttr = node.disabled ? ' aria-disabled="true"' : '';
  const parentIdAttr = parentId ? ` data-parent-id="${parentId}"` : '';
  const hasChildrenAttr = hasChildren ? ' data-has-children="true"' : '';
  const hiddenAttr = !isExpanded ? ' hidden' : '';

  const icon = hasChildren
    ? `<span class="apg-treeview-item-icon" aria-hidden="true">${isExpanded ? '▼' : '▶'}</span>`
    : '';

  const childrenHTML =
    hasChildren && node.children
      ? `<ul role="group" class="apg-treeview-group"${hiddenAttr}>${node.children.map((child) => renderNodeHTML(child, depth + 1)).join('')}</ul>`
      : '';

  return `<li role="treeitem" data-node-id="${node.id}"${parentIdAttr}${hasChildrenAttr} data-depth="${depth}" aria-labelledby="${labelId}"${ariaExpandedAttr} aria-selected="${isSelected}"${ariaDisabledAttr} tabindex="${isFocused ? 0 : -1}" class="${nodeClass}" style="--depth: ${depth}"><span class="apg-treeview-item-content">${icon}<span id="${labelId}" class="apg-treeview-item-label">${node.label}</span></span>${childrenHTML}</li>`;
}

const treeHTML = nodes.map((node) => renderNodeHTML(node, 0)).join('');
---

<apg-treeview
  data-multiselectable={multiselectable ? 'true' : undefined}
  data-type-ahead-timeout={typeAheadTimeout}
  data-initial-selected={JSON.stringify([...selectedIds])}
  data-initial-expanded={JSON.stringify([...expandedIds])}
  data-initial-focus-id={initialFocusId}
>
  <ul
    role="tree"
    aria-multiselectable={multiselectable || undefined}
    aria-label={ariaLabel}
    aria-labelledby={ariaLabelledby}
    class={containerClass}
  >
    <Fragment set:html={treeHTML} />
  </ul>
</apg-treeview>

<script>
  interface FlatNode {
    id: string;
    label: string;
    depth: number;
    parentId: string | null;
    hasChildren: boolean;
    disabled: boolean;
  }

  class ApgTreeView extends HTMLElement {
    private tree: HTMLElement | null = null;
    private rafId: number | null = null;
    private focusedId = '';
    private selectionAnchor = '';
    private selectedIds: Set<string> = new Set();
    private expandedIds: Set<string> = new Set();
    private typeAheadBuffer = '';
    private typeAheadTimeoutId: number | null = null;
    private allNodes: FlatNode[] = [];
    private nodeMap: Map<string, FlatNode> = new Map();

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.tree = this.querySelector('[role="tree"]');
      if (!this.tree) {
        console.warn('apg-treeview: tree element not found');
        return;
      }

      // Build node map from DOM
      this.buildNodeMap();

      // Initialize from data attributes
      try {
        this.selectedIds = new Set(JSON.parse(this.dataset.initialSelected || '[]'));
        this.expandedIds = new Set(JSON.parse(this.dataset.initialExpanded || '[]'));
      } catch {
        this.selectedIds = new Set();
        this.expandedIds = new Set();
      }

      this.focusedId = this.dataset.initialFocusId || '';
      this.selectionAnchor = this.focusedId;

      this.tree.addEventListener('keydown', this.handleKeyDown);
      this.tree.addEventListener('click', this.handleClick);
      this.tree.addEventListener('focusin', this.handleFocus);

      this.updateVisualState();
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      if (this.typeAheadTimeoutId !== null) {
        clearTimeout(this.typeAheadTimeoutId);
        this.typeAheadTimeoutId = null;
      }
      this.tree?.removeEventListener('keydown', this.handleKeyDown);
      this.tree?.removeEventListener('click', this.handleClick);
      this.tree?.removeEventListener('focusin', this.handleFocus);
      this.tree = null;
    }

    private get isMultiselectable(): boolean {
      return this.dataset.multiselectable === 'true';
    }

    private get typeAheadTimeout(): number {
      return parseInt(this.dataset.typeAheadTimeout || '500', 10);
    }

    private buildNodeMap() {
      this.allNodes = [];
      this.nodeMap.clear();

      const items = this.querySelectorAll<HTMLLIElement>('[role="treeitem"]');
      items.forEach((item) => {
        const id = item.dataset.nodeId || '';
        const label = item.querySelector('.apg-treeview-item-label')?.textContent || '';
        const depth = parseInt(item.dataset.depth || '0', 10);
        const parentId = item.dataset.parentId || null;
        const hasChildren = item.dataset.hasChildren === 'true';
        const disabled = item.getAttribute('aria-disabled') === 'true';

        const node: FlatNode = { id, label, depth, parentId, hasChildren, disabled };
        this.allNodes.push(node);
        this.nodeMap.set(id, node);
      });
    }

    private getVisibleNodes(): FlatNode[] {
      const result: FlatNode[] = [];
      const collapsedParents = new Set<string>();

      for (const node of this.allNodes) {
        let isHidden = false;
        let currentParentId = node.parentId;

        while (currentParentId) {
          if (collapsedParents.has(currentParentId) || !this.expandedIds.has(currentParentId)) {
            isHidden = true;
            break;
          }
          const parent = this.nodeMap.get(currentParentId);
          currentParentId = parent?.parentId ?? null;
        }

        if (!isHidden) {
          result.push(node);
          if (node.hasChildren && !this.expandedIds.has(node.id)) {
            collapsedParents.add(node.id);
          }
        }
      }
      return result;
    }

    private getVisibleIndexMap(): Map<string, number> {
      const map = new Map<string, number>();
      this.getVisibleNodes().forEach((node, index) => map.set(node.id, index));
      return map;
    }

    private getNodeElement(nodeId: string): HTMLLIElement | null {
      return this.querySelector<HTMLLIElement>(`[data-node-id="${CSS.escape(nodeId)}"]`);
    }

    private updateVisualState() {
      // Update tabindex for roving tabindex
      this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
        const nodeId = item.dataset.nodeId || '';
        item.tabIndex = nodeId === this.focusedId ? 0 : -1;
      });
    }

    private focusNode(nodeId: string) {
      this.focusedId = nodeId;
      this.updateVisualState();
      this.getNodeElement(nodeId)?.focus();
    }

    private expandNode(nodeId: string) {
      const node = this.nodeMap.get(nodeId);
      if (!node?.hasChildren || node.disabled) return;
      if (this.expandedIds.has(nodeId)) return;

      this.expandedIds.add(nodeId);
      this.updateExpansionDOM(nodeId, true);

      this.dispatchEvent(
        new CustomEvent('expandedchange', {
          detail: { expandedIds: [...this.expandedIds] },
          bubbles: true,
        })
      );
    }

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

      this.expandedIds.delete(nodeId);
      this.updateExpansionDOM(nodeId, false);

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

      this.dispatchEvent(
        new CustomEvent('expandedchange', {
          detail: { expandedIds: [...this.expandedIds] },
          bubbles: true,
        })
      );
    }

    private updateExpansionDOM(nodeId: string, expanded: boolean) {
      const item = this.getNodeElement(nodeId);
      if (!item) return;

      item.setAttribute('aria-expanded', String(expanded));

      const icon = item.querySelector('.apg-treeview-item-icon');
      if (icon) {
        icon.textContent = expanded ? '▼' : '▶';
      }

      const group = item.querySelector(':scope > [role="group"]');
      if (group) {
        if (expanded) {
          group.removeAttribute('hidden');
        } else {
          group.setAttribute('hidden', '');
        }
      }
    }

    private expandAllSiblings(nodeId: string) {
      const node = this.nodeMap.get(nodeId);
      if (!node) return;

      for (const n of this.allNodes) {
        if (n.parentId === node.parentId && n.hasChildren && !n.disabled) {
          if (!this.expandedIds.has(n.id)) {
            this.expandedIds.add(n.id);
            this.updateExpansionDOM(n.id, true);
          }
        }
      }

      this.dispatchEvent(
        new CustomEvent('expandedchange', {
          detail: { expandedIds: [...this.expandedIds] },
          bubbles: true,
        })
      );
    }

    private updateSelection(nodeId: string | null, action: 'toggle' | 'set' | 'range' | 'all') {
      const visibleNodes = this.getVisibleNodes();
      const visibleIndexMap = this.getVisibleIndexMap();

      if (action === 'all') {
        this.selectedIds = new Set(visibleNodes.filter((n) => !n.disabled).map((n) => n.id));
      } else if (action === 'range' && nodeId) {
        const anchorIndex = visibleIndexMap.get(this.selectionAnchor) ?? 0;
        const targetIndex = visibleIndexMap.get(nodeId) ?? 0;
        const start = Math.min(anchorIndex, targetIndex);
        const end = Math.max(anchorIndex, targetIndex);

        for (let i = start; i <= end; i++) {
          const node = visibleNodes[i];
          if (node && !node.disabled) {
            this.selectedIds.add(node.id);
          }
        }
      } else if (nodeId) {
        const node = this.nodeMap.get(nodeId);
        if (node?.disabled) return;

        if (this.isMultiselectable && action === 'toggle') {
          if (this.selectedIds.has(nodeId)) {
            this.selectedIds.delete(nodeId);
          } else {
            this.selectedIds.add(nodeId);
          }
        } else {
          this.selectedIds = new Set([nodeId]);
        }
      }

      // Update aria-selected
      this.querySelectorAll<HTMLLIElement>('[role="treeitem"]').forEach((item) => {
        const id = item.dataset.nodeId || '';
        const isSelected = this.selectedIds.has(id);
        item.setAttribute('aria-selected', String(isSelected));
        item.classList.toggle('apg-treeview-item--selected', isSelected);
      });

      this.dispatchEvent(
        new CustomEvent('selectionchange', {
          detail: { selectedIds: [...this.selectedIds] },
          bubbles: true,
        })
      );
    }

    private handleTypeAhead(char: string) {
      const visibleNodes = this.getVisibleNodes();
      if (visibleNodes.length === 0) return;

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

      this.typeAheadBuffer += char.toLowerCase();
      const buffer = this.typeAheadBuffer;
      const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

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

      if (isSameChar) {
        this.typeAheadBuffer = buffer[0];
        startIndex = (currentIndex + 1) % visibleNodes.length;
        searchStr = buffer[0];
      } else if (buffer.length === 1) {
        startIndex = (currentIndex + 1) % visibleNodes.length;
        searchStr = buffer;
      } else {
        startIndex = currentIndex;
        searchStr = buffer;
      }

      for (let i = 0; i < visibleNodes.length; i++) {
        const index = (startIndex + i) % visibleNodes.length;
        const node = visibleNodes[index];
        if (node.disabled) continue;
        if (node.label.toLowerCase().startsWith(searchStr)) {
          this.focusNode(node.id);
          this.selectionAnchor = node.id;
          break;
        }
      }

      this.typeAheadTimeoutId = window.setTimeout(() => {
        this.typeAheadBuffer = '';
        this.typeAheadTimeoutId = null;
      }, this.typeAheadTimeout);
    }

    private handleClick = (event: MouseEvent) => {
      const target = event.target as HTMLElement;
      const item = target.closest('[role="treeitem"]') as HTMLLIElement | null;
      if (!item) return;

      const nodeId = item.dataset.nodeId || '';
      const node = this.nodeMap.get(nodeId);
      if (!node) return;

      event.stopPropagation();
      this.focusNode(nodeId);

      if (node.hasChildren) {
        if (node.disabled) return;
        if (this.expandedIds.has(nodeId)) {
          this.collapseNode(nodeId);
        } else {
          this.expandNode(nodeId);
        }
      }

      if (!node.disabled) {
        if (this.isMultiselectable) {
          this.updateSelection(nodeId, 'toggle');
        } else {
          this.updateSelection(nodeId, 'set');
        }
        this.selectionAnchor = nodeId;

        // Fire activate event on click
        this.dispatchEvent(
          new CustomEvent('activate', {
            detail: { nodeId },
            bubbles: true,
          })
        );
      }
    };

    private handleFocus = (event: FocusEvent) => {
      const target = event.target as HTMLElement;
      if (target.getAttribute('role') !== 'treeitem') return;

      const nodeId = (target as HTMLLIElement).dataset.nodeId || '';
      if (nodeId && nodeId !== this.focusedId) {
        this.focusedId = nodeId;
        this.updateVisualState();
      }
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      const visibleNodes = this.getVisibleNodes();
      if (visibleNodes.length === 0) return;

      const { key, shiftKey, ctrlKey, metaKey } = event;
      const visibleIndexMap = this.getVisibleIndexMap();
      const currentIndex = visibleIndexMap.get(this.focusedId) ?? 0;
      const currentNode = visibleNodes[currentIndex];

      const handledKeys = [
        'ArrowDown',
        'ArrowUp',
        'ArrowRight',
        'ArrowLeft',
        'Home',
        'End',
        'Enter',
        ' ',
        '*',
      ];
      if (
        handledKeys.includes(key) ||
        (key.length === 1 && !ctrlKey && !metaKey) ||
        ((key === 'a' || key === 'A') && (ctrlKey || metaKey) && this.isMultiselectable)
      ) {
        event.preventDefault();
      }

      switch (key) {
        case 'ArrowDown': {
          if (currentIndex < visibleNodes.length - 1) {
            const nextIndex = currentIndex + 1;
            const nextNode = visibleNodes[nextIndex];
            this.focusNode(nextNode.id);
            if (this.isMultiselectable && shiftKey) {
              this.updateSelection(nextNode.id, 'range');
            } else {
              this.selectionAnchor = nextNode.id;
            }
          }
          break;
        }

        case 'ArrowUp': {
          if (currentIndex > 0) {
            const prevIndex = currentIndex - 1;
            const prevNode = visibleNodes[prevIndex];
            this.focusNode(prevNode.id);
            if (this.isMultiselectable && shiftKey) {
              this.updateSelection(prevNode.id, 'range');
            } else {
              this.selectionAnchor = prevNode.id;
            }
          }
          break;
        }

        case 'ArrowRight': {
          if (!currentNode) break;
          if (currentNode.hasChildren && !currentNode.disabled) {
            if (!this.expandedIds.has(this.focusedId)) {
              this.expandNode(this.focusedId);
            } else {
              const nextIndex = currentIndex + 1;
              if (nextIndex < visibleNodes.length) {
                const nextNode = visibleNodes[nextIndex];
                if (nextNode.parentId === this.focusedId) {
                  this.focusNode(nextNode.id);
                  this.selectionAnchor = nextNode.id;
                }
              }
            }
          }
          break;
        }

        case 'ArrowLeft': {
          if (!currentNode) break;
          if (
            currentNode.hasChildren &&
            this.expandedIds.has(this.focusedId) &&
            !currentNode.disabled
          ) {
            this.collapseNode(this.focusedId);
          } else if (currentNode.parentId) {
            this.focusNode(currentNode.parentId);
            this.selectionAnchor = currentNode.parentId;
          }
          break;
        }

        case 'Home': {
          const firstNode = visibleNodes[0];
          this.focusNode(firstNode.id);
          if (this.isMultiselectable && shiftKey) {
            this.updateSelection(firstNode.id, 'range');
          } else {
            this.selectionAnchor = firstNode.id;
          }
          break;
        }

        case 'End': {
          const lastNode = visibleNodes[visibleNodes.length - 1];
          this.focusNode(lastNode.id);
          if (this.isMultiselectable && shiftKey) {
            this.updateSelection(lastNode.id, 'range');
          } else {
            this.selectionAnchor = lastNode.id;
          }
          break;
        }

        case 'Enter': {
          if (currentNode && !currentNode.disabled) {
            if (this.isMultiselectable) {
              this.updateSelection(this.focusedId, 'toggle');
              this.selectionAnchor = this.focusedId;
            } else {
              this.updateSelection(this.focusedId, 'set');
            }
            this.dispatchEvent(
              new CustomEvent('activate', {
                detail: { nodeId: this.focusedId },
                bubbles: true,
              })
            );
          }
          break;
        }

        case ' ': {
          if (currentNode && !currentNode.disabled) {
            if (this.isMultiselectable) {
              this.updateSelection(this.focusedId, 'toggle');
              // Ctrl+Space: toggle without updating anchor
              // Space alone: update anchor for subsequent Shift+Arrow operations
              if (!ctrlKey) {
                this.selectionAnchor = this.focusedId;
              }
            } else {
              this.updateSelection(this.focusedId, 'set');
              this.dispatchEvent(
                new CustomEvent('activate', {
                  detail: { nodeId: this.focusedId },
                  bubbles: true,
                })
              );
            }
          }
          break;
        }

        case '*': {
          this.expandAllSiblings(this.focusedId);
          break;
        }

        case 'a':
        case 'A': {
          if ((ctrlKey || metaKey) && this.isMultiselectable) {
            this.updateSelection(null, 'all');
          } else if (!ctrlKey && !metaKey) {
            this.handleTypeAhead(key);
          }
          break;
        }

        default: {
          if (key.length === 1 && !ctrlKey && !metaKey) {
            this.handleTypeAhead(key);
          }
        }
      }
    };
  }

  if (!customElements.get('apg-treeview')) {
    customElements.define('apg-treeview', ApgTreeView);
  }
</script>

使い方

Example
---
import TreeView from './TreeView.astro';

const nodes = [
  {
    id: 'documents',
    label: 'Documents',
    children: [
      { id: 'report', label: 'report.pdf' },
      { id: 'notes', label: 'notes.txt' },
    ],
  },
  { id: 'readme', label: 'readme.md' },
];
---

<!-- Basic single-select -->
<TreeView
  nodes={nodes}
  aria-label="File Explorer"
/>

<!-- With default expanded -->
<TreeView
  nodes={nodes}
  aria-label="Files"
  defaultExpandedIds={['documents']}
/>

<!-- Multi-select mode -->
<TreeView
  nodes={nodes}
  aria-label="Files"
  multiselectable
/>

<!-- With callbacks via custom events -->
<TreeView
  nodes={nodes}
  aria-label="Files"
/>

<script>
  document.querySelector('apg-treeview')?.addEventListener('selection-change', (e) => {
    console.log('Selected:', e.detail);
  });
  document.querySelector('apg-treeview')?.addEventListener('expanded-change', (e) => {
    console.log('Expanded:', e.detail);
  });
  document.querySelector('apg-treeview')?.addEventListener('activate', (e) => {
    console.log('Activated:', e.detail);
  });
</script>

API

プロパティ デフォルト 説明
nodes TreeNode[] Required ツリーノードの配列
multiselectable boolean false 複数選択モードを有効化
defaultSelectedIds string[] [] 初期選択されるノード ID
defaultExpandedIds string[] [] 初期展開されるノード ID
typeAheadTimeout number 500 先行入力バッファのリセットタイムアウト(ミリ秒)
aria-label string - ツリーのアクセシブル名
aria-labelledby string - ラベル要素の ID

TreeNode Props

プロパティ デフォルト 説明
id string required ノードの一意な識別子
label string required ノードの表示テキスト
children TreeNode[] - 子ノード(親ノードになる)
disabled boolean false ノードが無効化されているかどうか

Custom Events

イベント Detail 説明
selection-change string[] 選択が変更された時に発火
expanded-change string[] 展開状態が変更された時に発火
activate string ノードがアクティブ化された時に発火(Enter キー)

テスト

テストは、ARIA属性、キーボード操作、展開/折りたたみ動作、選択モデル、アクセシビリティ要件におけるAPG準拠を検証します。Tree View コンポーネントは2層のテスト戦略を使用しています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のテストライブラリを使用して、コンポーネントの出力を検証します。正しいHTML構造とARIA属性を確認します。

  • ARIA属性(role="tree"、role="treeitem"、role="group")
  • 展開状態(aria-expanded)
  • 選択状態(aria-selected、aria-multiselectable)
  • 無効化状態(aria-disabled)
  • jest-axeによるアクセシビリティ

E2Eテスト(Playwright)

実際のブラウザ環境で全フレームワークのコンポーネント動作を検証します。インタラクションとクロスフレームワークの一貫性をカバーします。

  • 矢印キーナビゲーション(ArrowUp、ArrowDown、Home、End)
  • ArrowRight/ArrowLeftでの展開/折りたたみ
  • Enter、Space、クリックでの選択
  • Shift+矢印での複数選択
  • タイプアヘッド文字ナビゲーション
  • axe-coreアクセシビリティスキャン
  • クロスフレームワーク一貫性チェック

テストカテゴリ

高優先度 : ARIA構造(Unit + E2E)

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

高優先度 : アクセシブルな名前(Unit + E2E)

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

高優先度 : ナビゲーション(Unit + E2E)

テスト 説明
ArrowDown 次の表示ノードに移動
ArrowUp 前の表示ノードに移動
Home 最初のノードに移動
End 最後の表示ノードに移動
Type-ahead 文字入力で一致する表示ノードにフォーカス

高優先度 : 展開/折りたたみ(Unit + E2E)

テスト 説明
ArrowRight (closed parent) 親ノードを展開
ArrowRight (open parent) 最初の子に移動
ArrowRight (leaf) 何もしない
ArrowLeft (open parent) 親ノードを折りたたむ
ArrowLeft (child/closed) 親ノードに移動
ArrowLeft (root) 何もしない
* (asterisk) 現在のレベルのすべての兄弟を展開
Enter ノードをアクティブ化(展開の切り替えはしない)

高優先度 : 選択(単一選択)(Unit + E2E)

テスト 説明
Arrow navigation 選択がフォーカスに追従(矢印で選択が変わる)
Space 単一選択モードでは効果なし
Only one selected 一度に1つのノードのみ選択可能

高優先度 : 選択(複数選択)(Unit + E2E)

テスト 説明
Space フォーカスされたノードの選択を切り替え
Ctrl+Space フォーカスを移動せずに選択を切り替え
Shift+Arrow アンカーから選択範囲を拡張
Shift+Home 最初のノードまで選択を拡張
Shift+End 最後の表示ノードまで選択を拡張
Ctrl+A すべての表示ノードを選択
Multiple selection 複数のノードを同時に選択可能

高優先度 : フォーカス管理(Unit + E2E)

テスト 説明
Single Tab stop ツリーが単一のTabストップ(Tab/Shift+Tab)
tabindex="0" フォーカスされたノードがtabindex="0"を持つ
tabindex="-1" 他のノードがtabindex="-1"を持つ
Skip collapsed ナビゲーション中に折りたたまれた子をスキップ
Focus to parent 子の親が折りたたまれると、フォーカスが親に移動

中優先度 : 無効化ノード(Unit + E2E)

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

中優先度 : タイプアヘッド(Unit + E2E)

テスト 説明
Visible nodes only タイプアヘッドは表示ノードのみを検索
Cycle on repeat 繰り返し文字でマッチをサイクル
Multi-character prefix 複数文字で検索プレフィックスを形成
Timeout reset タイムアウト後にバッファがリセット(デフォルト500ms)
Skip disabled タイプアヘッドは無効化ノードをスキップ

中優先度 : マウス操作(E2E)

テスト 説明
Click parent 展開を切り替えてノードを選択
Click leaf リーフノードを選択
Click disabled 無効化ノードは選択・展開できない

低優先度 : コールバック(Unit + E2E)

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

テストコード例

以下は実際の E2E テストファイルです (e2e/tree-view.spec.ts).

e2e/tree-view.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Tree View Pattern
 *
 * A hierarchical list where items with children can be expanded or collapsed.
 * Common uses include file browsers, navigation menus, and organizational charts.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getTree = (page: import('@playwright/test').Page) => {
  return page.getByRole('tree');
};

const getTreeItems = (page: import('@playwright/test').Page) => {
  return page.getByRole('treeitem');
};

const getGroups = (page: import('@playwright/test').Page) => {
  return page.getByRole('group');
};

/**
 * Click on a treeitem element and wait for it to receive focus.
 * This is needed because:
 * 1. Frameworks like React apply focus asynchronously via useLayoutEffect
 * 2. Expanded parent treeitems contain child elements, so we must click
 *    on the label area (top of the element) to avoid hitting children
 *    which have stopPropagation() on their click handlers.
 *
 * NOTE: Clicking on a parent node TOGGLES its expansion state.
 * Use focusWithoutClick() for tests where you need to focus without side effects.
 */
const clickAndWaitForFocus = async (
  element: import('@playwright/test').Locator,
  _page: import('@playwright/test').Page
) => {
  // Click at the top-left area of the element to hit the label, not children
  // Use position { x: 10, y: 10 } to click near the top-left corner
  await element.click({ position: { x: 10, y: 10 } });

  // Wait for the element to become the active element
  await expect
    .poll(
      async () => {
        return await element.evaluate((el) => document.activeElement === el);
      },
      { timeout: 5000 }
    )
    .toBe(true);
};

/**
 * Focus a treeitem element without triggering click handlers.
 * This is useful for testing keyboard navigation where we don't want
 * to trigger expansion toggle that happens on click.
 */
const focusWithoutClick = async (
  element: import('@playwright/test').Locator,
  _page: import('@playwright/test').Page
) => {
  await element.focus();

  // Wait for the element to become the active element
  await expect
    .poll(
      async () => {
        return await element.evaluate((el) => document.activeElement === el);
      },
      { timeout: 5000 }
    )
    .toBe(true);
};

/**
 * Press a key on a focused element and wait for focus to settle on a treeitem.
 * Uses element.press() for stability instead of page.keyboard.press().
 */
const pressKeyOnElement = async (element: import('@playwright/test').Locator, key: string) => {
  await expect(element).toBeFocused();
  await element.press(key);
  // Wait for focus to settle on a treeitem
  await expect(element.page().locator('[role="treeitem"]:focus')).toBeVisible();
};

/**
 * Navigate to a target node by pressing ArrowDown multiple times.
 * Waits for focus to settle after each key press.
 */
const navigateToIndex = async (page: import('@playwright/test').Page, targetIndex: number) => {
  for (let i = 0; i < targetIndex; i++) {
    const currentFocused = page.locator('[role="treeitem"]:focus');
    await pressKeyOnElement(currentFocused, 'ArrowDown');
  }
};

/**
 * Wait for page hydration to complete.
 * This ensures:
 * 1. Tree element is rendered
 * 2. Treeitems have proper aria-labelledby (not Svelte's pre-hydration IDs)
 * 3. Interactive handlers are attached (one item has tabindex="0")
 */
async function waitForHydration(page: import('@playwright/test').Page) {
  // Wait for basic rendering
  await getTree(page).first().waitFor();

  // Wait for hydration - ensure treeitems have proper aria-labelledby
  const firstItem = getTreeItems(page).first();
  await expect
    .poll(async () => {
      const labelledby = await firstItem.getAttribute('aria-labelledby');
      return labelledby && labelledby.length > 1 && !labelledby.startsWith('-');
    })
    .toBe(true);

  // Wait for full hydration - ensure interactive handlers are attached
  await expect
    .poll(async () => {
      const tree = getTree(page).first();
      const items = tree.getByRole('treeitem');
      const count = await items.count();
      let hasFocusable = false;
      for (let i = 0; i < count; i++) {
        const tabindex = await items.nth(i).getAttribute('tabindex');
        if (tabindex === '0') {
          hasFocusable = true;
          break;
        }
      }
      return hasFocusable;
    })
    .toBe(true);
}

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Tree View (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('container has role="tree"', async ({ page }) => {
        const tree = getTree(page).first();
        await expect(tree).toBeVisible();
      });

      test('nodes have role="treeitem"', async ({ page }) => {
        const items = getTreeItems(page);
        const count = await items.count();
        expect(count).toBeGreaterThan(0);
      });

      test('child containers have role="group"', async ({ page }) => {
        // First expand a parent node to make group visible
        const tree = getTree(page).first();
        const parentItem = tree.getByRole('treeitem', { expanded: true }).first();

        if ((await parentItem.count()) > 0) {
          const groups = getGroups(page);
          const count = await groups.count();
          expect(count).toBeGreaterThan(0);
        }
      });

      test('tree has accessible name via aria-label', async ({ page }) => {
        const tree = getTree(page).first();
        const label = await tree.getAttribute('aria-label');
        expect(label).toBeTruthy();
      });

      test('parent nodes have aria-expanded', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node (one with children)
        const expandedItems = tree.locator('[role="treeitem"][aria-expanded]');
        const count = await expandedItems.count();
        expect(count).toBeGreaterThan(0);

        const firstExpanded = expandedItems.first();
        const expanded = await firstExpanded.getAttribute('aria-expanded');
        expect(['true', 'false']).toContain(expanded);
      });

      test('leaf nodes do NOT have aria-expanded', async ({ page }) => {
        const tree = getTree(page).first();
        // Expand all to see leaf nodes
        const items = tree.getByRole('treeitem');
        const count = await items.count();

        for (let i = 0; i < count; i++) {
          const item = items.nth(i);
          const expanded = await item.getAttribute('aria-expanded');
          const hasGroup = (await item.locator('[role="group"]').count()) > 0;

          // If item has no children (no group), it shouldn't have aria-expanded
          if (!hasGroup && expanded === null) {
            // This is correct - leaf node without aria-expanded
            continue;
          }
          // If it has aria-expanded, it should also have children
          if (expanded !== null) {
            // Parent nodes with aria-expanded should have potential children
            continue;
          }
        }
      });

      test('all treeitems have aria-selected', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const count = await items.count();

        for (let i = 0; i < count; i++) {
          const item = items.nth(i);
          const selected = await item.getAttribute('aria-selected');
          expect(['true', 'false']).toContain(selected);
        }
      });

      test('multi-select tree has aria-multiselectable', async ({ page }) => {
        // Find multi-select tree (second tree on demo page)
        const trees = getTree(page);
        const count = await trees.count();

        // Look for a tree with aria-multiselectable
        let foundMultiselect = false;
        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const multiselectable = await tree.getAttribute('aria-multiselectable');
          if (multiselectable === 'true') {
            foundMultiselect = true;
            break;
          }
        }

        // Demo page should have at least one multi-select tree
        expect(foundMultiselect).toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Navigation
    // ------------------------------------------
    test.describe('APG: Keyboard Navigation', () => {
      test('ArrowDown moves to next visible node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toBeFocused();
        await firstItem.press('ArrowDown');

        // Focus should have moved to a different item
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();
      });

      test('ArrowUp moves to previous visible node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Click second item first
        const secondItem = items.nth(1);
        await clickAndWaitForFocus(secondItem, page);
        await expect(secondItem).toBeFocused();
        await secondItem.press('ArrowUp');

        const firstItem = items.first();
        await expect(firstItem).toBeFocused();
      });

      test('Home moves to first node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Start from a later item
        const laterItem = items.nth(2);
        await clickAndWaitForFocus(laterItem, page);
        await expect(laterItem).toBeFocused();
        await laterItem.press('Home');

        const firstItem = items.first();
        await expect(firstItem).toBeFocused();
      });

      test('End moves to last visible node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Click on a non-parent item to avoid triggering expansion toggle
        // Use the second item (first child of expanded parent)
        const secondItem = items.nth(1);
        await clickAndWaitForFocus(secondItem, page);
        await expect(secondItem).toBeFocused();
        await secondItem.press('End');

        // Get current count after any DOM changes
        const currentCount = await items.count();
        const lastItem = items.nth(currentCount - 1);
        await expect(lastItem).toBeFocused();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Expand/Collapse
    // ------------------------------------------
    test.describe('APG: Expand/Collapse', () => {
      test('ArrowRight on closed parent expands it', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node (any with aria-expanded attribute)
        const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();

        if ((await anyParent.count()) > 0) {
          // Get label ID for stable reference before any interaction
          const labelledby = await anyParent.getAttribute('aria-labelledby');
          const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);

          // Focus the first treeitem in tree using click
          const firstItem = tree.getByRole('treeitem').first();
          await clickAndWaitForFocus(firstItem, page);

          // Navigate to find a collapsed parent using keyboard
          // Press * to collapse all siblings first (so we have collapsed parents)
          // Then we can test ArrowRight on a collapsed one

          // Navigate to the parent node using keyboard
          // Use Home to go to first item, then navigate down
          await expect(firstItem).toBeFocused();
          await firstItem.press('Home');

          // Now navigate to the target node
          const items = tree.getByRole('treeitem');
          const count = await items.count();
          let targetIndex = -1;
          for (let i = 0; i < count; i++) {
            const lb = await items.nth(i).getAttribute('aria-labelledby');
            if (lb === labelledby) {
              targetIndex = i;
              break;
            }
          }

          // Press ArrowDown to reach the target (with focus wait after each press)
          await navigateToIndex(page, targetIndex);

          // Now we're on the parent node - check if it's expanded and collapse if needed
          const currentExpanded = await stableLocator.getAttribute('aria-expanded');
          if (currentExpanded === 'true') {
            // Collapse it first
            const focusedItem = page.locator('[role="treeitem"]:focus');
            await expect(focusedItem).toBeFocused();
            await focusedItem.press('ArrowLeft');
            await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
          }

          // Now test ArrowRight to expand
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowRight');
          await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
        }
      });

      test('ArrowRight on open parent moves to first child', async ({ page }) => {
        const tree = getTree(page).first();
        // Find an expanded parent
        const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();

        if ((await expandedParent.count()) > 0) {
          // Get parent's labelledby for stable reference
          const parentLabelledby = await expandedParent.getAttribute('aria-labelledby');

          // Focus without click to avoid toggling expansion
          await focusWithoutClick(expandedParent, page);
          await expect(expandedParent).toBeFocused();
          await expandedParent.press('ArrowRight');

          // Focus should move to first child - verify by checking:
          // 1. Focus moved to a different element
          // 2. The focused element is a treeitem
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeVisible();

          // The focused item should not be the parent
          const focusedLabelledby = await focusedItem.getAttribute('aria-labelledby');
          expect(focusedLabelledby).not.toBe(parentLabelledby);
        }
      });

      test('ArrowLeft on open parent collapses it', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node (any with aria-expanded attribute)
        const anyParent = tree.locator('[role="treeitem"][aria-expanded]').first();

        if ((await anyParent.count()) > 0) {
          // Get label ID for stable reference before any interaction
          const labelledby = await anyParent.getAttribute('aria-labelledby');
          const stableLocator = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);

          // Focus the first treeitem in tree using click
          const firstItem = tree.getByRole('treeitem').first();
          await clickAndWaitForFocus(firstItem, page);

          // Navigate to the parent node using keyboard
          await expect(firstItem).toBeFocused();
          await firstItem.press('Home');

          // Find the index of target node
          const items = tree.getByRole('treeitem');
          const count = await items.count();
          let targetIndex = -1;
          for (let i = 0; i < count; i++) {
            const lb = await items.nth(i).getAttribute('aria-labelledby');
            if (lb === labelledby) {
              targetIndex = i;
              break;
            }
          }

          // Press ArrowDown to reach the target (with focus wait after each press)
          await navigateToIndex(page, targetIndex);

          // Now we're on the parent node - check if it's collapsed and expand if needed
          const currentExpanded = await stableLocator.getAttribute('aria-expanded');
          if (currentExpanded === 'false') {
            // Expand it first
            const focusedItem = page.locator('[role="treeitem"]:focus');
            await expect(focusedItem).toBeFocused();
            await focusedItem.press('ArrowRight');
            await expect(stableLocator).toHaveAttribute('aria-expanded', 'true');
          }

          // Now test ArrowLeft to collapse
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowLeft');
          await expect(stableLocator).toHaveAttribute('aria-expanded', 'false');
        }
      });

      test('ArrowLeft on child moves to parent', async ({ page }) => {
        const tree = getTree(page).first();
        // Find an expanded parent to access its children
        const expandedParent = tree.locator('[role="treeitem"][aria-expanded="true"]').first();

        if ((await expandedParent.count()) > 0) {
          // Focus parent without click to avoid toggling expansion
          await focusWithoutClick(expandedParent, page);
          // Move to first child
          await expect(expandedParent).toBeFocused();
          await expandedParent.press('ArrowRight');

          // Now press ArrowLeft to go back to parent
          const focusedChild = page.locator('[role="treeitem"]:focus');
          await expect(focusedChild).toBeFocused();
          await focusedChild.press('ArrowLeft');

          await expect(expandedParent).toBeFocused();
        }
      });

      test('click on parent toggles expansion', async ({ page }) => {
        const tree = getTree(page).first();
        // Find a parent node
        const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();

        if ((await parentItem.count()) > 0) {
          const initialExpanded = await parentItem.getAttribute('aria-expanded');

          // Click on label area (top of element) to avoid hitting children
          await parentItem.click({ position: { x: 10, y: 10 } });

          const newExpanded = await parentItem.getAttribute('aria-expanded');
          expect(newExpanded).not.toBe(initialExpanded);
        }
      });

      test('* key expands all siblings', async ({ page }) => {
        const tree = getTree(page).first();

        // Focus the first visible item using click
        const firstItem = tree.getByRole('treeitem').first();
        await clickAndWaitForFocus(firstItem, page);

        // The * key expands all expandable siblings at the SAME LEVEL as the focused node
        // First, let's ensure we're at the root level
        await expect(firstItem).toBeFocused();
        await firstItem.press('Home');

        // Get all top-level treeitems (direct children of tree, not nested in groups)
        // Top-level items are those that are not inside a group element
        const topLevelParents = tree.locator(':scope > [role="treeitem"][aria-expanded]');
        const topLevelCount = await topLevelParents.count();

        if (topLevelCount > 0) {
          // Collapse all top-level parents first so we can test * expanding them
          for (let i = 0; i < topLevelCount; i++) {
            const parent = topLevelParents.nth(i);
            const expanded = await parent.getAttribute('aria-expanded');
            if (expanded === 'true') {
              // Navigate to this item and collapse it
              const labelledby = await parent.getAttribute('aria-labelledby');
              // Go home and navigate to find it
              const currentFocused = page.locator('[role="treeitem"]:focus');
              await pressKeyOnElement(currentFocused, 'Home');
              const items = tree.getByRole('treeitem');
              const count = await items.count();
              for (let j = 0; j < count; j++) {
                const lb = await items.nth(j).getAttribute('aria-labelledby');
                if (lb === labelledby) {
                  // Navigate to this item (with focus wait after each press)
                  await navigateToIndex(page, j);
                  // Collapse it
                  const focusedItem = page.locator('[role="treeitem"]:focus');
                  await expect(focusedItem).toBeFocused();
                  await focusedItem.press('ArrowLeft');
                  await expect(parent).toHaveAttribute('aria-expanded', 'false');
                  break;
                }
              }
            }
          }

          // Go back home
          const currentFocused = page.locator('[role="treeitem"]:focus');
          await pressKeyOnElement(currentFocused, 'Home');

          // Press * to expand all siblings at the current level
          const focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('*');

          // Wait for all top-level parents to be expanded (state-based wait)
          for (let i = 0; i < topLevelCount; i++) {
            await expect(topLevelParents.nth(i)).toHaveAttribute('aria-expanded', 'true');
          }
        }
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Selection
    // ------------------------------------------
    test.describe('APG: Selection (Single-Select)', () => {
      test('Enter selects focused node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');

        // Use first item for reliability, navigate to second using keyboard
        const firstItem = items.first();
        await clickAndWaitForFocus(firstItem, page);

        // Navigate to second item
        await pressKeyOnElement(firstItem, 'ArrowDown');
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();

        // Press Enter to select
        await secondItem.press('Enter');
        await expect(secondItem).toHaveAttribute('aria-selected', 'true');
      });

      test('Space selects focused node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('aria-selected', 'true');

        // Navigate to another item without selecting (with focus wait)
        await pressKeyOnElement(firstItem, 'ArrowDown');
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();

        // Press Space to select
        await secondItem.press('Space');
        await expect(secondItem).toHaveAttribute('aria-selected', 'true');
        // First item should no longer be selected in single-select
        await expect(firstItem).toHaveAttribute('aria-selected', 'false');
      });

      test('arrow keys move focus without changing selection', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        // Select first item
        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('aria-selected', 'true');

        // Press Enter to explicitly select it
        await expect(firstItem).toBeFocused();
        await firstItem.press('Enter');

        // Navigate away (with focus wait)
        await pressKeyOnElement(firstItem, 'ArrowDown');
        const secondItem = items.nth(1);
        await expect(secondItem).toBeFocused();

        // Selection should stay on first item (focus moved but not selection)
        await expect(firstItem).toHaveAttribute('aria-selected', 'true');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Multi-Select
    // ------------------------------------------
    test.describe('APG: Selection (Multi-Select)', () => {
      test('Space toggles selection in multi-select', async ({ page }) => {
        // Find multi-select tree
        const trees = getTree(page);
        const count = await trees.count();

        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const multiselectable = await tree.getAttribute('aria-multiselectable');

          if (multiselectable === 'true') {
            const items = tree.getByRole('treeitem');
            const firstItem = items.first();

            await clickAndWaitForFocus(firstItem, page);
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');

            // Toggle off
            await expect(firstItem).toBeFocused();
            await firstItem.press('Space');
            await expect(firstItem).toHaveAttribute('aria-selected', 'false');

            // Toggle on
            await expect(firstItem).toBeFocused();
            await firstItem.press('Space');
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');
            break;
          }
        }
      });

      test('Shift+Arrow extends selection range', async ({ page }) => {
        // Find multi-select tree
        const trees = getTree(page);
        const count = await trees.count();

        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const multiselectable = await tree.getAttribute('aria-multiselectable');

          if (multiselectable === 'true') {
            const items = tree.getByRole('treeitem');
            const firstItem = items.first();

            await clickAndWaitForFocus(firstItem, page);
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');

            // Shift+ArrowDown to extend selection
            await expect(firstItem).toBeFocused();
            await firstItem.press('Shift+ArrowDown');

            const secondItem = items.nth(1);
            await expect(secondItem).toHaveAttribute('aria-selected', 'true');
            await expect(firstItem).toHaveAttribute('aria-selected', 'true');
            break;
          }
        }
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Disabled State
    // ------------------------------------------
    test.describe('Disabled State', () => {
      test('disabled nodes have aria-disabled', async ({ page }) => {
        const tree = getTree(page);
        const disabledItems = tree.locator('[role="treeitem"][aria-disabled="true"]');

        if ((await disabledItems.count()) > 0) {
          const firstDisabled = disabledItems.first();
          await expect(firstDisabled).toHaveAttribute('aria-disabled', 'true');
        }
      });

      test('disabled nodes are focusable but not selectable', async ({ page }) => {
        const trees = getTree(page);
        const count = await trees.count();

        // Look for tree with disabled items (third tree on demo page)
        for (let i = 0; i < count; i++) {
          const tree = trees.nth(i);
          const disabledItem = tree.locator('[role="treeitem"][aria-disabled="true"]').first();

          if ((await disabledItem.count()) > 0) {
            // Get the labelledby of the disabled item for navigation
            const disabledLabelledby = await disabledItem.getAttribute('aria-labelledby');

            // Focus the first item in this tree using keyboard navigation
            const firstItem = tree.getByRole('treeitem').first();
            await focusWithoutClick(firstItem, page);

            // Navigate using keyboard to find the disabled item
            const items = tree.getByRole('treeitem');
            const itemCount = await items.count();
            let foundDisabled = false;

            for (let j = 0; j < itemCount; j++) {
              const currentFocused = page.locator('[role="treeitem"]:focus');
              const currentLabelledby = await currentFocused.getAttribute('aria-labelledby');

              if (currentLabelledby === disabledLabelledby) {
                foundDisabled = true;
                break;
              }
              await pressKeyOnElement(currentFocused, 'ArrowDown');
            }

            if (foundDisabled) {
              // Now we're on the disabled item via keyboard navigation
              // Try to select it with Space (keep page.keyboard.press for disabled element tests)
              await page.keyboard.press('Space');

              // Should not be selected
              await expect(disabledItem).toHaveAttribute('aria-selected', 'false');
            }
            break;
          }
        }
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Type-Ahead
    // ------------------------------------------
    test.describe('Type-Ahead', () => {
      test('typing character focuses matching node', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toBeFocused();

        // Type 'r' to find nodes starting with 'r' (like 'readme.md')
        await firstItem.press('r');

        // Wait for type-ahead to process (state-based wait)
        // Focus should move to a node starting with 'r'
        await expect
          .poll(
            async () => {
              const focused = page.locator('[role="treeitem"]:focus');
              const labelledby = await focused.getAttribute('aria-labelledby');
              if (!labelledby) return false;
              const label = page.locator(`[id="${labelledby}"]`);
              const text = await label.textContent();
              return text?.toLowerCase().startsWith('r') ?? false;
            },
            { timeout: 5000 }
          )
          .toBe(true);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management
    // ------------------------------------------
    test.describe('Focus Management', () => {
      test('focused node has tabindex="0"', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('tabindex', '0');
      });

      test('other nodes have tabindex="-1"', async ({ page }) => {
        const tree = getTree(page).first();
        const items = tree.getByRole('treeitem');
        const firstItem = items.first();

        await clickAndWaitForFocus(firstItem, page);
        await expect(firstItem).toHaveAttribute('tabindex', '0');

        // Check that at least one other item has tabindex="-1"
        const secondItem = items.nth(1);
        await expect(secondItem).toHaveAttribute('tabindex', '-1');
      });

      test('focus moves to parent when parent is collapsed', async ({ page }) => {
        const tree = getTree(page).first();
        // Find an expanded parent and save its aria-labelledby
        const expandedParentLocator = tree
          .locator('[role="treeitem"][aria-expanded="true"]')
          .first();

        if ((await expandedParentLocator.count()) > 0) {
          // Get the parent's labelledby ID to use as a stable selector
          const labelledby = await expandedParentLocator.getAttribute('aria-labelledby');

          // Focus parent without click to avoid toggling expansion
          await focusWithoutClick(expandedParentLocator, page);
          // Navigate to a child
          await expect(expandedParentLocator).toBeFocused();
          await expandedParentLocator.press('ArrowRight');

          // Collapse the parent by pressing ArrowLeft twice
          // (first ArrowLeft goes back to parent, second collapses it)
          let focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowLeft');

          focusedItem = page.locator('[role="treeitem"]:focus');
          await expect(focusedItem).toBeFocused();
          await focusedItem.press('ArrowLeft');

          // Use the stable selector to find the parent (now collapsed)
          const parent = tree.locator(`[role="treeitem"][aria-labelledby="${labelledby}"]`);
          // Focus should be on the parent
          await expect(parent).toBeFocused();
        }
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const tree = getTree(page);
        await tree.first().waitFor();

        const results = await new AxeBuilder({ page })
          .include('[role="tree"]')
          .disableRules(['color-contrast'])
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Tree View - Cross-framework Consistency', () => {
  test('all frameworks have trees', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);

      const trees = getTree(page);
      const count = await trees.count();
      expect(count).toBeGreaterThan(0);
    }
  });

  test('all frameworks support click to expand/collapse', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);

      const tree = getTree(page).first();
      const parentItem = tree.locator('[role="treeitem"][aria-expanded]').first();

      if ((await parentItem.count()) > 0) {
        const initialExpanded = await parentItem.getAttribute('aria-expanded');

        // Click on label area (top of element) to avoid hitting children
        await parentItem.click({ position: { x: 10, y: 10 } });
        // Wait for the expansion state to change
        await expect(parentItem).not.toHaveAttribute('aria-expanded', initialExpanded!);

        const newExpanded = await parentItem.getAttribute('aria-expanded');
        expect(newExpanded).not.toBe(initialExpanded);

        // Click again to toggle back
        await parentItem.click({ position: { x: 10, y: 10 } });
        await expect(parentItem).toHaveAttribute('aria-expanded', initialExpanded!);
      }
    }
  });

  test('all frameworks have consistent ARIA structure', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/tree-view/${framework}/demo/`);
      await waitForHydration(page);

      const tree = getTree(page).first();

      // Check tree has accessible name
      const label = await tree.getAttribute('aria-label');
      expect(label).toBeTruthy();

      // Check treeitems exist
      const items = tree.getByRole('treeitem');
      const count = await items.count();
      expect(count).toBeGreaterThan(0);

      // Check first item has aria-selected
      const firstItem = items.first();
      const selected = await firstItem.getAttribute('aria-selected');
      expect(['true', 'false']).toContain(selected);
    }
  });
});

テストの実行

# Tree Viewのユニットテストを実行
npm run test -- treeview

# Tree ViewのE2Eテストを実行(全フレームワーク)
npm run test:e2e:pattern --pattern=tree-view

# 特定のフレームワークでE2Eテストを実行
npm run test:e2e:react:pattern --pattern=tree-view

テストツール

詳細なドキュメントについては、 testing-strategy.md (opens in new tab) を参照してください。

リソース