APG Patterns
English GitHub
English GitHub

Tree View

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

🤖 AI Implementation Guide

デモ

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

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

複数選択

  • readme.md

無効化されたノード

  • 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.svelte
<script lang="ts">
  import { onMount, onDestroy, tick } from 'svelte';

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

  interface TreeViewProps {
    nodes: TreeNode[];
    multiselectable?: boolean;
    defaultSelectedIds?: string[];
    selectedIds?: string[];
    defaultExpandedIds?: string[];
    expandedIds?: string[];
    ariaLabel?: string;
    ariaLabelledby?: string;
    typeAheadTimeout?: number;
    onSelectionChange?: (selectedIds: string[]) => void;
    onExpandedChange?: (expandedIds: string[]) => void;
    onActivate?: (nodeId: string) => void;
    class?: string;
  }

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

  let {
    nodes = [],
    multiselectable = false,
    defaultSelectedIds = [],
    selectedIds: controlledSelectedIds = undefined,
    defaultExpandedIds = [],
    expandedIds: controlledExpandedIds = undefined,
    ariaLabel = undefined,
    ariaLabelledby = undefined,
    typeAheadTimeout = 500,
    onSelectionChange = () => {},
    onExpandedChange = () => {},
    onActivate = () => {},
    class: className = '',
  }: TreeViewProps = $props();

  let instanceId = $state('');
  let nodeRefs = new Map<string, HTMLLIElement>();
  let typeAheadBuffer = $state('');
  let typeAheadTimeoutId: number | null = null;
  let selectionAnchor = $state('');
  let focusedIdRef = $state('');

  // Internal state
  let internalExpandedIds = $state<Set<string>>(new Set());
  let internalSelectedIds = $state<Set<string>>(new Set());
  let focusedId = $state('');

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

  // Cleanup type-ahead timeout on destroy
  onDestroy(() => {
    if (typeAheadTimeoutId !== null) {
      clearTimeout(typeAheadTimeoutId);
    }
  });

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

  let allNodes = $derived(flattenTree(nodes));

  let nodeMap = $derived.by(() => {
    const map = new Map<string, FlatNode>();
    for (const flatNode of allNodes) {
      map.set(flatNode.node.id, flatNode);
    }
    return map;
  });

  // Expansion state (controlled or uncontrolled)
  let expandedIds = $derived(
    controlledExpandedIds ? new Set(controlledExpandedIds) : internalExpandedIds
  );

  // Selection state (controlled or uncontrolled)
  let selectedIds = $derived(
    controlledSelectedIds ? new Set(controlledSelectedIds) : internalSelectedIds
  );

  // Visible nodes (respecting expansion state)
  let visibleNodes = $derived.by(() => {
    const result: FlatNode[] = [];
    const collapsedParents = new Set<string>();

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

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

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

  let visibleIndexMap = $derived.by(() => {
    const map = new Map<string, number>();
    visibleNodes.forEach((flatNode, index) => map.set(flatNode.node.id, index));
    return map;
  });

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

  // Initialize state
  $effect(() => {
    if (allNodes.length > 0 && internalSelectedIds.size === 0 && internalExpandedIds.size === 0) {
      // Initialize expansion
      internalExpandedIds = new Set(defaultExpandedIds);

      // Initialize selection (filter out disabled nodes)
      if (defaultSelectedIds.length > 0) {
        const validIds = defaultSelectedIds.filter((id) => {
          const flatNode = nodeMap.get(id);
          return flatNode && !flatNode.node.disabled;
        });
        if (validIds.length > 0) {
          internalSelectedIds = new Set(validIds);
        }
      }
      // No auto-selection - user must explicitly select via Enter/Space/Click

      // Initialize focus
      const firstSelected = [...selectedIds][0];
      if (firstSelected) {
        const flatNode = nodeMap.get(firstSelected);
        if (flatNode && !flatNode.node.disabled) {
          focusedId = firstSelected;
          focusedIdRef = firstSelected;
          selectionAnchor = firstSelected;
          return;
        }
      }
      const firstEnabled = allNodes.find((fn) => !fn.node.disabled);
      if (firstEnabled) {
        focusedId = firstEnabled.node.id;
        focusedIdRef = firstEnabled.node.id;
        selectionAnchor = firstEnabled.node.id;
      }
    }
  });

  // Reactive guard: ensure focusedId and selectionAnchor remain valid when visibility changes
  $effect(() => {
    // Skip during initialization
    if (!focusedId) return;

    // Check if focusedId is still visible
    if (!visibleIndexMap.has(focusedId)) {
      // Find first visible non-disabled node
      const firstEnabled = visibleNodes.find((fn) => !fn.node.disabled);
      if (firstEnabled) {
        focusedId = firstEnabled.node.id;
        focusedIdRef = firstEnabled.node.id;
        selectionAnchor = firstEnabled.node.id;
        // Focus moves but selection does not change automatically
      }
    }

    // Check if selectionAnchor is still valid
    if (selectionAnchor && !visibleIndexMap.has(selectionAnchor)) {
      selectionAnchor = focusedId;
    }
  });

  // Action to track node element references
  function trackNodeRef(node: HTMLLIElement, nodeId: string) {
    nodeRefs.set(nodeId, node);
    return {
      destroy() {
        nodeRefs.delete(nodeId);
      },
    };
  }

  function updateExpandedIds(newExpandedIds: Set<string>) {
    if (!controlledExpandedIds) {
      internalExpandedIds = newExpandedIds;
    }
    onExpandedChange([...newExpandedIds]);
  }

  function updateSelectedIds(newSelectedIds: Set<string>) {
    if (!controlledSelectedIds) {
      internalSelectedIds = newSelectedIds;
    }
    onSelectionChange([...newSelectedIds]);
  }

  function setFocusedId(nodeId: string) {
    focusedIdRef = nodeId;
    focusedId = nodeId;
  }

  async function applyDomFocus(nodeId: string) {
    await tick();
    nodeRefs.get(nodeId)?.focus();
  }

  function focusNode(nodeId: string) {
    setFocusedId(nodeId);
    applyDomFocus(nodeId);
  }

  function focusByIndex(index: number) {
    const flatNode = visibleNodes[index];
    if (flatNode) {
      focusNode(flatNode.node.id);
    }
  }

  // Expansion helpers
  function expandNode(nodeId: string) {
    const flatNode = nodeMap.get(nodeId);
    if (!flatNode?.hasChildren || flatNode.node.disabled) return;
    if (expandedIds.has(nodeId)) return;

    const newExpanded = new Set(expandedIds);
    newExpanded.add(nodeId);
    updateExpandedIds(newExpanded);
  }

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

    const newExpanded = new Set(expandedIds);
    newExpanded.delete(nodeId);
    updateExpandedIds(newExpanded);

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

  function expandAllSiblings(nodeId: string) {
    const flatNode = nodeMap.get(nodeId);
    if (!flatNode) return;

    const newExpanded = new Set(expandedIds);
    for (const fn of allNodes) {
      if (fn.parentId === flatNode.parentId && fn.hasChildren && !fn.node.disabled) {
        newExpanded.add(fn.node.id);
      }
    }
    updateExpandedIds(newExpanded);
  }

  // Selection helpers
  function selectNode(nodeId: string) {
    const flatNode = nodeMap.get(nodeId);
    if (flatNode?.node.disabled) return;

    if (multiselectable) {
      const newSelected = new Set(selectedIds);
      if (newSelected.has(nodeId)) {
        newSelected.delete(nodeId);
      } else {
        newSelected.add(nodeId);
      }
      updateSelectedIds(newSelected);
    } else {
      updateSelectedIds(new Set([nodeId]));
    }
  }

  function selectRange(fromId: string, toId: string) {
    const fromIndex = visibleIndexMap.get(fromId) ?? 0;
    const toIndex = visibleIndexMap.get(toId) ?? 0;
    const start = Math.min(fromIndex, toIndex);
    const end = Math.max(fromIndex, toIndex);

    const newSelected = new Set(selectedIds);
    for (let i = start; i <= end; i++) {
      const flatNode = visibleNodes[i];
      if (flatNode && !flatNode.node.disabled) {
        newSelected.add(flatNode.node.id);
      }
    }
    updateSelectedIds(newSelected);
  }

  function selectAllVisible() {
    const newSelected = new Set<string>();
    for (const flatNode of visibleNodes) {
      if (!flatNode.node.disabled) {
        newSelected.add(flatNode.node.id);
      }
    }
    updateSelectedIds(newSelected);
  }

  // Type-ahead
  function handleTypeAhead(char: string) {
    if (visibleNodes.length === 0) return;

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

    typeAheadBuffer += char.toLowerCase();
    const buffer = typeAheadBuffer;

    const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);

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

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

    for (let i = 0; i < visibleNodes.length; i++) {
      const index = (startIndex + i) % visibleNodes.length;
      const flatNode = visibleNodes[index];
      // Skip disabled nodes in type-ahead
      if (flatNode.node.disabled) continue;
      if (flatNode.node.label.toLowerCase().startsWith(searchStr)) {
        focusNode(flatNode.node.id);
        // Update anchor in multiselect mode
        if (multiselectable) {
          selectionAnchor = flatNode.node.id;
        }
        // Type-ahead only moves focus, does not change selection
        break;
      }
    }

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

  function handleNodeClick(nodeId: string) {
    const flatNode = nodeMap.get(nodeId);
    if (!flatNode || flatNode.node.disabled) return;

    focusNode(nodeId);

    // Toggle expansion for parent nodes
    if (flatNode.hasChildren) {
      if (expandedIds.has(nodeId)) {
        collapseNode(nodeId);
      } else {
        expandNode(nodeId);
      }
    }

    // Select and activate
    if (multiselectable) {
      selectNode(nodeId);
      selectionAnchor = nodeId;
    } else {
      updateSelectedIds(new Set([nodeId]));
    }
    onActivate(nodeId);
  }

  function handleNodeFocus(nodeId: string) {
    focusedIdRef = nodeId;
    focusedId = nodeId;
  }

  // Determine if key should be handled
  function shouldHandleKey(key: string, ctrlKey: boolean, metaKey: boolean): boolean {
    const handledKeys = [
      'ArrowDown',
      'ArrowUp',
      'ArrowRight',
      'ArrowLeft',
      'Home',
      'End',
      'Enter',
      ' ',
      '*',
    ];
    if (handledKeys.includes(key)) return true;
    if (key.length === 1 && !ctrlKey && !metaKey) return true;
    if ((key === 'a' || key === 'A') && (ctrlKey || metaKey) && multiselectable) return true;
    return false;
  }

  function handleKeyDown(event: KeyboardEvent) {
    if (visibleNodes.length === 0) return;

    const { key, shiftKey, ctrlKey, metaKey } = event;

    // Call preventDefault synchronously for all handled keys
    if (shouldHandleKey(key, ctrlKey, metaKey)) {
      event.preventDefault();
    }

    const actualFocusedId = focusedIdRef;
    const currentIndex = visibleIndexMap.get(actualFocusedId) ?? 0;
    const currentFlatNode = visibleNodes[currentIndex];

    switch (key) {
      case 'ArrowDown': {
        if (currentIndex < visibleNodes.length - 1) {
          const nextIndex = currentIndex + 1;
          focusByIndex(nextIndex);
          const nextNode = visibleNodes[nextIndex];
          if (multiselectable && shiftKey) {
            selectRange(selectionAnchor, nextNode.node.id);
          } else if (multiselectable) {
            selectionAnchor = nextNode.node.id;
          }
          // Single-select: focus moves but selection does not change
        }
        break;
      }

      case 'ArrowUp': {
        if (currentIndex > 0) {
          const prevIndex = currentIndex - 1;
          focusByIndex(prevIndex);
          const prevNode = visibleNodes[prevIndex];
          if (multiselectable && shiftKey) {
            selectRange(selectionAnchor, prevNode.node.id);
          } else if (multiselectable) {
            selectionAnchor = prevNode.node.id;
          }
          // Single-select: focus moves but selection does not change
        }
        break;
      }

      case 'ArrowRight': {
        if (!currentFlatNode) break;

        if (currentFlatNode.hasChildren && !currentFlatNode.node.disabled) {
          if (!expandedIds.has(actualFocusedId)) {
            expandNode(actualFocusedId);
          } else {
            const nextIndex = currentIndex + 1;
            if (nextIndex < visibleNodes.length) {
              const nextNode = visibleNodes[nextIndex];
              if (nextNode.parentId === actualFocusedId) {
                focusByIndex(nextIndex);
                // Update anchor on lateral navigation in multiselect
                if (multiselectable) {
                  selectionAnchor = nextNode.node.id;
                }
                // Single-select: focus moves but selection does not change
              }
            }
          }
        }
        break;
      }

      case 'ArrowLeft': {
        if (!currentFlatNode) break;

        if (
          currentFlatNode.hasChildren &&
          expandedIds.has(actualFocusedId) &&
          !currentFlatNode.node.disabled
        ) {
          collapseNode(actualFocusedId);
        } else if (currentFlatNode.parentId) {
          focusNode(currentFlatNode.parentId);
          // Update anchor on lateral navigation in multiselect
          if (multiselectable) {
            selectionAnchor = currentFlatNode.parentId;
          }
          // Single-select: focus moves but selection does not change
        }
        break;
      }

      case 'Home': {
        focusByIndex(0);
        const firstNode = visibleNodes[0];
        if (multiselectable && shiftKey) {
          selectRange(selectionAnchor, firstNode.node.id);
        } else if (multiselectable) {
          selectionAnchor = firstNode.node.id;
        }
        // Single-select: focus moves but selection does not change
        break;
      }

      case 'End': {
        const lastIndex = visibleNodes.length - 1;
        focusByIndex(lastIndex);
        const lastNode = visibleNodes[lastIndex];
        if (multiselectable && shiftKey) {
          selectRange(selectionAnchor, lastNode.node.id);
        } else if (multiselectable) {
          selectionAnchor = lastNode.node.id;
        }
        // Single-select: focus moves but selection does not change
        break;
      }

      case 'Enter': {
        if (currentFlatNode && !currentFlatNode.node.disabled) {
          // Select the node (single-select replaces, multi-select behavior via selectNode)
          if (multiselectable) {
            selectNode(actualFocusedId);
            selectionAnchor = actualFocusedId;
          } else {
            updateSelectedIds(new Set([actualFocusedId]));
          }
          // Fire activation callback
          onActivate(actualFocusedId);
        }
        break;
      }

      case ' ': {
        if (currentFlatNode && !currentFlatNode.node.disabled) {
          if (multiselectable) {
            selectNode(actualFocusedId);
            // Ctrl+Space: toggle without updating anchor
            // Space alone: update anchor for subsequent Shift+Arrow operations
            if (!ctrlKey) {
              selectionAnchor = actualFocusedId;
            }
          } else {
            // Single-select: Space selects and activates (same as Enter)
            updateSelectedIds(new Set([actualFocusedId]));
            onActivate(actualFocusedId);
          }
        }
        break;
      }

      case '*': {
        expandAllSiblings(actualFocusedId);
        break;
      }

      case 'a':
      case 'A': {
        if ((ctrlKey || metaKey) && multiselectable) {
          selectAllVisible();
        } else if (!ctrlKey && !metaKey) {
          handleTypeAhead(key);
        }
        break;
      }

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

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

{#snippet renderNode(node: TreeNode, depth: number)}
  {@const hasChildren = Boolean(node.children && node.children.length > 0)}
  {@const isExpanded = expandedIds.has(node.id)}
  {@const isSelected = selectedIds.has(node.id)}
  {@const isFocused = focusedId === node.id}
  {@const labelId = `${instanceId}-label-${node.id}`}

  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
  <li
    use:trackNodeRef={node.id}
    role="treeitem"
    aria-labelledby={labelId}
    aria-expanded={hasChildren ? isExpanded : undefined}
    aria-selected={isSelected}
    aria-disabled={node.disabled || undefined}
    tabindex={isFocused ? 0 : -1}
    class={getNodeClass(node, hasChildren)}
    style="--depth: {depth}"
    onclick={(e) => {
      e.stopPropagation();
      handleNodeClick(node.id);
    }}
    onfocus={(e) => {
      if (e.target === e.currentTarget) {
        handleNodeFocus(node.id);
      }
    }}
  >
    <span class="apg-treeview-item-content">
      {#if hasChildren}
        <span class="apg-treeview-item-icon" aria-hidden="true">
          {isExpanded ? '\u25BC' : '\u25B6'}
        </span>
      {/if}
      <span id={labelId} class="apg-treeview-item-label">
        {node.label}
      </span>
    </span>
    {#if hasChildren && isExpanded && node.children}
      <ul role="group" class="apg-treeview-group">
        {#each node.children as child}
          {@render renderNode(child, depth + 1)}
        {/each}
      </ul>
    {/if}
  </li>
{/snippet}

<ul
  role="tree"
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  aria-multiselectable={multiselectable || undefined}
  class={containerClass}
  onkeydown={handleKeyDown}
>
  {#each nodes as node}
    {@render renderNode(node, 0)}
  {/each}
</ul>

使い方

Example
<script>
import TreeView from './TreeView.svelte';

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

function handleSelectionChange(event) {
  console.log('Selected:', event.detail);
}

function handleExpandedChange(event) {
  console.log('Expanded:', event.detail);
}

function handleActivate(event) {
  console.log('Activated:', event.detail);
}
</script>

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

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

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

<!-- コールバック付き -->
<TreeView
  {nodes}
  aria-label="ファイル"
  onselectionchange={handleSelectionChange}
  onexpandedchange={handleExpandedChange}
  onactivate={handleActivate}
/>

API

TreeNode インターフェース

プロパティ 必須 説明
id string はい ノードの一意な識別子
label string はい ノードの表示ラベル
children TreeNode[] いいえ 子ノード(これを親ノードにする)
disabled boolean いいえ 選択、アクティブ化、展開を無効化

Props

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

イベント

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

テスト

テストでは、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 と共に呼び出される

テストツール

リソース