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.vue
<template>
  <ul
    role="tree"
    :aria-label="ariaLabel"
    :aria-labelledby="ariaLabelledby"
    :aria-multiselectable="multiselectable || undefined"
    :class="containerClass"
    @keydown="handleKeyDown"
  >
    <TreeNode
      v-for="node in nodes"
      :key="node.id"
      :node="node"
      :depth="0"
      :instance-id="instanceId"
      :expanded-ids="expandedIds"
      :selected-ids="selectedIds"
      :focused-id="focusedId"
      :visible-index-map="visibleIndexMap"
      @node-click="handleNodeClick"
      @node-focus="handleNodeFocus"
      @set-ref="setNodeRef"
    />
  </ul>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import TreeNode from './TreeNode.vue';

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

export interface TreeViewProps {
  nodes: TreeNodeData[];
  multiselectable?: boolean;
  defaultSelectedIds?: string[];
  selectedIds?: string[];
  defaultExpandedIds?: string[];
  expandedIds?: string[];
  ariaLabel?: string;
  ariaLabelledby?: string;
  typeAheadTimeout?: number;
  className?: string;
}

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

const props = withDefaults(defineProps<TreeViewProps>(), {
  multiselectable: false,
  defaultSelectedIds: () => [],
  defaultExpandedIds: () => [],
  typeAheadTimeout: 500,
  className: '',
});

const emit = defineEmits<{
  selectionChange: [selectedIds: string[]];
  expandedChange: [expandedIds: string[]];
  activate: [nodeId: string];
}>();

const instanceId = ref('');
const nodeRefs = ref<Record<string, HTMLLIElement>>({});
const typeAheadBuffer = ref('');
const typeAheadTimeoutId = ref<number | null>(null);
const selectionAnchor = ref('');
const focusedIdRef = ref('');

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

// Flatten tree for navigation
const flattenTree = (
  treeNodes: TreeNodeData[],
  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 = computed(() => flattenTree(props.nodes));

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

// Expansion state (controlled or uncontrolled)
const expandedIds = computed(() => {
  if (props.expandedIds) {
    return new Set(props.expandedIds);
  }
  return internalExpandedIds.value;
});

// Selection state (controlled or uncontrolled)
const selectedIds = computed(() => {
  if (props.selectedIds) {
    return new Set(props.selectedIds);
  }
  return internalSelectedIds.value;
});

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

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

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

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

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

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

// Initialize on mount
onMounted(() => {
  instanceId.value = `treeview-${Math.random().toString(36).slice(2, 11)}`;

  // Initialize expansion
  internalExpandedIds.value = new Set(props.defaultExpandedIds);

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

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

const setNodeRef = (id: string, el: HTMLLIElement | null) => {
  if (el) {
    nodeRefs.value[id] = el;
  } else {
    delete nodeRefs.value[id];
  }
};

const updateExpandedIds = (newExpandedIds: Set<string>) => {
  if (!props.expandedIds) {
    internalExpandedIds.value = newExpandedIds;
  }
  emit('expandedChange', [...newExpandedIds]);
};

const updateSelectedIds = (newSelectedIds: Set<string>) => {
  if (!props.selectedIds) {
    internalSelectedIds.value = newSelectedIds;
  }
  emit('selectionChange', [...newSelectedIds]);
};

// Synchronous focus state update (for roving tabindex)
const setFocusedId = (nodeId: string) => {
  focusedIdRef.value = nodeId;
  focusedId.value = nodeId;
};

// Deferred DOM focus (after Vue re-render)
const applyDomFocus = async (nodeId: string) => {
  await nextTick();
  nodeRefs.value[nodeId]?.focus();
};

const focusNode = (nodeId: string) => {
  setFocusedId(nodeId);
  applyDomFocus(nodeId);
};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  for (let i = 0; i < visibleNodes.value.length; i++) {
    const index = (startIndex + i) % visibleNodes.value.length;
    const flatNode = visibleNodes.value[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 (props.multiselectable) {
        selectionAnchor.value = flatNode.node.id;
      }
      // Type-ahead only moves focus, does not change selection
      break;
    }
  }

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

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

  focusNode(nodeId);

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

  // Select and activate
  if (props.multiselectable) {
    selectNode(nodeId);
    selectionAnchor.value = nodeId;
  } else {
    updateSelectedIds(new Set([nodeId]));
  }
  emit('activate', nodeId);
};

const handleNodeFocus = (nodeId: string) => {
  focusedIdRef.value = nodeId;
  focusedId.value = nodeId;
};

// Determine if key should be handled and call preventDefault synchronously
const shouldHandleKey = (key: string, ctrlKey: boolean, metaKey: boolean): boolean => {
  const handledKeys = [
    'ArrowDown',
    'ArrowUp',
    'ArrowRight',
    'ArrowLeft',
    'Home',
    'End',
    'Enter',
    ' ',
    '*',
  ];
  if (handledKeys.includes(key)) return true;
  // Type-ahead: single printable character without ctrl/meta
  if (key.length === 1 && !ctrlKey && !metaKey) return true;
  // Ctrl+A in multiselect
  if ((key === 'a' || key === 'A') && (ctrlKey || metaKey) && props.multiselectable) return true;
  return false;
};

const handleKeyDown = (event: KeyboardEvent) => {
  if (visibleNodes.value.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.value;
  const currentIndex = visibleIndexMap.value.get(actualFocusedId) ?? 0;
  const currentFlatNode = visibleNodes.value[currentIndex];

  switch (key) {
    case 'ArrowDown': {
      if (currentIndex < visibleNodes.value.length - 1) {
        const nextIndex = currentIndex + 1;
        focusByIndex(nextIndex);
        const nextNode = visibleNodes.value[nextIndex];
        if (props.multiselectable && shiftKey) {
          selectRange(selectionAnchor.value, nextNode.node.id);
        } else if (props.multiselectable) {
          selectionAnchor.value = 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.value[prevIndex];
        if (props.multiselectable && shiftKey) {
          selectRange(selectionAnchor.value, prevNode.node.id);
        } else if (props.multiselectable) {
          selectionAnchor.value = 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.value.has(actualFocusedId)) {
          expandNode(actualFocusedId);
        } else {
          const nextIndex = currentIndex + 1;
          if (nextIndex < visibleNodes.value.length) {
            const nextNode = visibleNodes.value[nextIndex];
            if (nextNode.parentId === actualFocusedId) {
              focusByIndex(nextIndex);
              // Update anchor on lateral navigation in multiselect
              if (props.multiselectable) {
                selectionAnchor.value = nextNode.node.id;
              }
              // Single-select: focus moves but selection does not change
            }
          }
        }
      }
      break;
    }

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

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

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

    case 'End': {
      const lastIndex = visibleNodes.value.length - 1;
      focusByIndex(lastIndex);
      const lastNode = visibleNodes.value[lastIndex];
      if (props.multiselectable && shiftKey) {
        selectRange(selectionAnchor.value, lastNode.node.id);
      } else if (props.multiselectable) {
        selectionAnchor.value = 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 (props.multiselectable) {
          selectNode(actualFocusedId);
          selectionAnchor.value = actualFocusedId;
        } else {
          updateSelectedIds(new Set([actualFocusedId]));
        }
        // Fire activation callback
        emit('activate', actualFocusedId);
      }
      break;
    }

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

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

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

    default: {
      if (key.length === 1 && !ctrlKey && !metaKey) {
        handleTypeAhead(key);
      }
    }
  }
};
</script>
TreeNode.vue
<template>
  <li
    :ref="(el) => emit('setRef', node.id, el as HTMLLIElement | null)"
    role="treeitem"
    :aria-labelledby="labelId"
    :aria-expanded="hasChildren ? isExpanded : undefined"
    :aria-selected="isSelected"
    :aria-disabled="node.disabled || undefined"
    :tabindex="isFocused ? 0 : -1"
    :class="nodeClass"
    :style="{ '--depth': depth }"
    @click.stop="handleClick"
    @focus="handleFocus"
  >
    <span class="apg-treeview-item-content">
      <span v-if="hasChildren" class="apg-treeview-item-icon" aria-hidden="true">
        {{ isExpanded ? '\u25BC' : '\u25B6' }}
      </span>
      <span :id="labelId" class="apg-treeview-item-label">
        {{ node.label }}
      </span>
    </span>
    <ul v-if="hasChildren && isExpanded && node.children" role="group" class="apg-treeview-group">
      <TreeNode
        v-for="child in node.children"
        :key="child.id"
        :node="child"
        :depth="depth + 1"
        :instance-id="instanceId"
        :expanded-ids="expandedIds"
        :selected-ids="selectedIds"
        :focused-id="focusedId"
        :visible-index-map="visibleIndexMap"
        @node-click="(id: string) => emit('nodeClick', id)"
        @node-focus="(id: string) => emit('nodeFocus', id)"
        @set-ref="(id: string, el: HTMLLIElement | null) => emit('setRef', id, el)"
      />
    </ul>
  </li>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { TreeNodeData } from './TreeView.vue';

interface TreeNodeProps {
  node: TreeNodeData;
  depth: number;
  instanceId: string;
  expandedIds: Set<string>;
  selectedIds: Set<string>;
  focusedId: string;
  visibleIndexMap: Map<string, number>;
}

const props = defineProps<TreeNodeProps>();

const emit = defineEmits<{
  nodeClick: [nodeId: string];
  nodeFocus: [nodeId: string];
  setRef: [id: string, el: HTMLLIElement | null];
}>();

const hasChildren = computed(() => Boolean(props.node.children && props.node.children.length > 0));
const isExpanded = computed(() => props.expandedIds.has(props.node.id));
const isSelected = computed(() => props.selectedIds.has(props.node.id));
const isFocused = computed(() => props.focusedId === props.node.id);

const labelId = computed(() => `${props.instanceId}-label-${props.node.id}`);

const nodeClass = computed(() => {
  const classes = ['apg-treeview-item'];
  if (isSelected.value) {
    classes.push('apg-treeview-item--selected');
  }
  if (props.node.disabled) {
    classes.push('apg-treeview-item--disabled');
  }
  if (hasChildren.value) {
    classes.push('apg-treeview-item--parent');
  } else {
    classes.push('apg-treeview-item--leaf');
  }
  return classes.join(' ');
});

const handleClick = () => {
  emit('nodeClick', props.node.id);
};

const handleFocus = (e: FocusEvent) => {
  // Only handle focus if this element is the actual target (not bubbled from child)
  if (e.target === e.currentTarget) {
    emit('nodeFocus', props.node.id);
  }
};
</script>

使い方

Example
<script setup>
import TreeView from './TreeView.vue';

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

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

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

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

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

  <!-- デフォルトで展開 -->
  <TreeView
    :nodes="nodes"
    aria-label="ファイル"
    :default-expanded-ids="['documents']"
  />

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

  <!-- コールバック付き -->
  <TreeView
    :nodes="nodes"
    aria-label="ファイル"
    @selection-change="handleSelectionChange"
    @expanded-change="handleExpandedChange"
    @activate="handleActivate"
  />
</template>

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 と共に呼び出される

テストツール

リソース