APG Patterns
English GitHub
English GitHub

Tree View

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

🤖 AI Implementation Guide

デモ

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

  • Documents
    • report.pdf
    • notes.txt
  • readme.md
アクティベート: Enter、Space、またはクリックでノードを選択してください

複数選択

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

無効化されたノード

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

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA tree ロール (opens in new tab)

WAI-ARIA プロパティ(ツリーコンテナ)

属性 必須 説明
role="tree" - はい コンテナをツリーウィジェットとして識別
aria-label 文字列 はい* ツリーのアクセシブル名
aria-labelledby ID参照 はい* aria-label の代替(優先される)
aria-multiselectable true いいえ 複数選択モード時のみ設定

* aria-label または aria-labelledby のいずれかが必須

WAI-ARIA ステート(ツリーアイテム)

属性 必須 説明
aria-expanded true | false はい* 親ノードのみに設定。展開状態を示す
aria-selected true | false はい** すべてのtreeitemに必須(選択=true、その他=false)
aria-disabled true いいえ ノードが無効であることを示す
tabindex 0 | -1 はい フォーカス管理用のローヴィングタブインデックス

* 親ノードに必須。リーフノードには aria-expanded を設定しない
** 選択がサポートされている場合、すべてのtreeitemに aria-selected が必須

キーボードサポート

ナビゲーション

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

選択(単一選択モード)

キー アクション
/ フォーカスのみ移動(選択はフォーカスに追従しない)
Enter フォーカス中のノードを選択してアクティベート(onActivate発火)
Space フォーカス中のノードを選択してアクティベート(onActivate発火)
クリック クリックしたノードを選択してアクティベート(onActivate発火)

選択(複数選択モード)

キー アクション
Space フォーカス中のノードの選択をトグル
Ctrl + Space フォーカスを移動せずに選択をトグル
Shift + / アンカーから選択範囲を拡張
Shift + Home 最初のノードまで選択を拡張
Shift + End 最後の表示されているノードまで選択を拡張
Ctrl + A 表示されているすべてのノードを選択

フォーカス管理

このコンポーネントは、フォーカス管理にローヴィングタブインデックスを使用します:

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

無効化されたノード

  • aria-disabled="true" を持つ
  • フォーカス可能(キーボードナビゲーションに含まれる)
  • 選択またはアクティブ化できない
  • 親ノードの場合、展開/折りたたみができない
  • 視覚的に区別される(例: グレーアウト)

ソースコード

TreeView.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}
    set:html={treeHTML}
  />
</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' },
];
---

<!-- 基本的な単一選択 -->
<TreeView
  nodes={nodes}
  aria-label="ファイルエクスプローラー"
/>

<!-- デフォルトで展開 -->
<TreeView
  nodes={nodes}
  aria-label="ファイル"
  defaultExpandedIds={['documents']}
/>

<!-- 複数選択モード -->
<TreeView
  nodes={nodes}
  aria-label="ファイル"
  multiselectable
/>

<!-- カスタムイベントでコールバック -->
<TreeView
  nodes={nodes}
  aria-label="ファイル"
/>

<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

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 - ツリーのアクセシブル名

カスタムイベント

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

テスト

テストでは、ARIA 属性、キーボード操作、展開/折りたたみ動作、選択モデル、およびアクセシビリティ要件の APG 準拠を検証します。

テストカテゴリ

高優先度: ARIA 構造

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

高優先度: アクセシブル名

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

高優先度: ナビゲーション

テスト 説明
ArrowDown 次の表示されているノードに移動
ArrowUp 前の表示されているノードに移動
Home 最初のノードに移動
End 最後の表示されているノードに移動
先行入力 文字入力で一致する表示されているノードにフォーカス

高優先度: 展開/折りたたみ

テスト 説明
ArrowRight(閉じた親) 親ノードを展開
ArrowRight(開いた親) 最初の子に移動
ArrowRight(リーフ) 何もしない
ArrowLeft(開いた親) 親ノードを折りたたむ
ArrowLeft(子/閉じた親) 親ノードに移動
ArrowLeft(ルート) 何もしない
*(アスタリスク) 現在のレベルのすべての兄弟を展開
Enter ノードをアクティブ化(展開/折りたたみはしない)

高優先度: 選択(単一選択)

テスト 説明
矢印ナビゲーション 選択がフォーカスに追従(矢印キーで選択が変更)
Space 単一選択モードでは効果なし
選択は1つのみ 一度に1つのノードのみ選択可能

高優先度: 選択(複数選択)

テスト 説明
Space フォーカス中のノードの選択をトグル
Ctrl+Space フォーカスを移動せずに選択をトグル
Shift+矢印 アンカーから選択範囲を拡張
Shift+Home 最初のノードまで選択を拡張
Shift+End 最後の表示されているノードまで選択を拡張
Ctrl+A 表示されているすべてのノードを選択
複数選択 複数のノードを同時に選択可能

高優先度: フォーカス管理

テスト 説明
単一タブストップ ツリーは単一のタブストップ(Tab/Shift+Tab)
tabindex="0" フォーカス中のノードが tabindex="0" を持つ
tabindex="-1" その他のノードが tabindex="-1" を持つ
折りたたまれた子をスキップ ナビゲーション中に折りたたまれた子はスキップ
親へフォーカス移動 子の親が折りたたまれるとフォーカスが親に移動

中優先度: 無効化されたノード

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

中優先度: 先行入力

テスト 説明
表示ノードのみ 先行入力は表示されているノードのみを検索
繰り返しでサイクル 同じ文字の繰り返しでマッチを順に巡回
複数文字プレフィックス 複数の文字が検索プレフィックスを形成
タイムアウトリセット タイムアウト後にバッファがリセット(デフォルト500ms)
無効ノードをスキップ 先行入力は無効化されたノードをスキップ

中優先度: マウス操作

テスト 説明
親をクリック 展開をトグルしてノードを選択
リーフをクリック リーフノードを選択
無効ノードをクリック 無効化されたノードは選択または展開できない

低優先度: コールバック

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

テストツール

リソース