APG Patterns
English
English

TreeGrid

Gridの2Dナビゲーションと、TreeViewの展開可能な行を組み合わせた階層データグリッド。

デモ

矢印キーで移動。rowheaderでArrowRight/Leftで展開/折りたたみ。Spaceで行を選択。

矢印キーでセル間を移動します。最初の列(行ヘッダー)では、右矢印キーで折りたたまれた行を展開し、左矢印キーで展開された行を折りたたみます。スペースキーで行の選択/解除を行います。Enterキーでセルをアクティブにします。

名前
サイズ
更新日
ドキュメント
--
2024-01-15
README.md
4 KB
2024-01-01

デモのみ表示 →

TreeGrid vs Grid

展開/折りたたみ可能な階層データにはtreegridロールを使用します。

機能 TreeGrid Grid
階層 展開/折りたたみ可能な行 フラット構造
選択 行選択(行のaria-selected) セル選択(セルのaria-selected)
rowheaderでの矢印 ツリーの展開/折りたたみ フォーカス移動
必須ARIA aria-level, aria-expanded なし(階層固有)
ユースケース ファイルブラウザ、組織図、ネストデータ スプレッドシート、フラットデータテーブル

アクセシビリティ

TreeGrid vs Grid

treegridロールは、Gridの2Dキーボードナビゲーションと、TreeViewの階層展開/折りたたみ機能を組み合わせています。Gridとの主な違い:

  • 行を展開/折りたたみして子行の表示/非表示を切り替えられます
  • セル選択ではなく行選択(aria-selectedはgridcellではなくrowに設定)
  • ツリー操作(展開/折りたたみ)はrowheader列でのみ機能します
  • 行には階層の深さを示すaria-levelがあります

WAI-ARIA ロール

ロール 対象要素 説明
treegrid コンテナ treegridコンテナ(複合ウィジェット)
row 行コンテナ セルを水平方向にグループ化し、子を持つことができます
columnheader ヘッダーセル 列ヘッダー(フォーカス不可)
rowheader 最初の列セル ツリー操作が行われる行ヘッダー
gridcell データセル インタラクティブなセル(フォーカス可能)

W3C ARIA: treegrid role (opens in new tab)

WAI-ARIA プロパティ (TreeGrid Container)

属性 必須 説明
role="treegrid" - はい コンテナをtreegridとして識別します
aria-label 文字列 はい(aria-labelまたはaria-labelledbyのいずれか) treegridのアクセシブルな名前
aria-labelledby ID参照 はい(aria-labelまたはaria-labelledbyのいずれか) aria-labelの代替
aria-multiselectable true いいえ 複数選択モードの場合のみ存在
aria-rowcount 数値 いいえ 総行数(仮想化用)
aria-colcount 数値 いいえ 総列数(仮想化用)

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

WAI-ARIA ステート (Rows)

属性 必須 説明
aria-level 数値(1始まり) はい 行ごとに静的(階層構造により決定)
aria-expanded true | false はい* rowheaderでのArrowRight/Left、展開アイコンのクリック
aria-selected true | false いいえ** Spaceキー、クリック(gridcellではなく行に設定)
aria-disabled true いいえ 行が無効な場合のみ
aria-rowindex 数値 いいえ 静的(仮想化用)

* 親行(子を持つ行)のみにaria-expandedがあります。リーフ行にはこの属性はありません。
** 選択がサポートされている場合、すべての行にaria-selectedが必要です。

キーボードサポート

2Dナビゲーション

キー アクション
Arrow Down 次の表示行の同じ列にフォーカスを移動
Arrow Up 前の表示行の同じ列にフォーカスを移動
Arrow Right フォーカスを右に1セル移動(非rowheaderセルの場合)
Arrow Left フォーカスを左に1セル移動(非rowheaderセルの場合)
Home 行の最初のセルにフォーカスを移動
End 行の最後のセルにフォーカスを移動
Ctrl + Home treegridの最初のセルにフォーカスを移動
Ctrl + End treegridの最後のセルにフォーカスを移動

ツリー操作(rowheaderのみ)

キー アクション
Arrow Right (at rowheader) 折りたたまれた親の場合: 行を展開。展開された親の場合: 最初の子のrowheaderに移動。リーフの場合: 何もしない
Arrow Left (at rowheader) 展開された親の場合: 行を折りたたみ。折りたたみ済み/リーフの場合: 親のrowheaderに移動。ルートレベルで折りたたみ済みの場合: 何もしない

行選択とセルアクティベーション

キー アクション
Space 行の選択を切り替え(セル選択ではない)
Enter フォーカスされたセルをアクティブ化(展開/折りたたみはしない)
Ctrl + A すべての表示行を選択(複数選択可能な場合)

重要: 非rowheaderセルでの矢印キーはフォーカスの移動のみで、展開/折りたたみは行いません。

フォーカス管理

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

  • ローヴィングタブインデックス - 1つのセルのみがtabindex="0"を持つ
  • tabindex="-1"
  • 単一のTabストップ(Tabでグリッドに入る/出る)
  • フォーカス不可(tabindexなし)
  • キーボードナビゲーションに含まれない
  • 子にフォーカスがあった場合、親にフォーカスを移動

Gridとの主な違い

  • 選択: 行選択(rowのaria-selected)vs Gridのセル選択
  • rowheaderでの矢印キー: ツリーの展開/折りたたみ vs Gridのフォーカス移動
  • Enterキー: セルのアクティベーションのみ(展開/折りたたみはしない)
  • 階層: 行にaria-levelとaria-expandedが必須
  • ナビゲーション: 折りたたまれた子はナビゲーションでスキップ

ソースコード

TreeGrid.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

// =============================================================================
// Types
// =============================================================================

export interface TreeGridCellData {
  id: string;
  value: string | number;
  disabled?: boolean;
  colspan?: number;
}

export interface TreeGridNodeData {
  id: string;
  cells: TreeGridCellData[];
  children?: TreeGridNodeData[];
  disabled?: boolean;
}

export interface TreeGridColumnDef {
  id: string;
  header: string;
  isRowHeader?: boolean;
}

export interface TreeGridProps {
  columns: TreeGridColumnDef[];
  nodes: TreeGridNodeData[];

  // Accessible name (one required)
  ariaLabel?: string;
  ariaLabelledby?: string;

  // Expansion
  expandedIds?: string[];
  defaultExpandedIds?: string[];
  onExpandedChange?: (ids: string[]) => void;

  // Selection (row-based)
  selectable?: boolean;
  multiselectable?: boolean;
  selectedRowIds?: string[];
  defaultSelectedRowIds?: string[];
  onSelectionChange?: (rowIds: string[]) => void;

  // Focus
  focusedCellId?: string | null;
  defaultFocusedCellId?: string;
  onFocusChange?: (cellId: string | null) => void;

  // Virtualization
  totalRows?: number;
  totalColumns?: number;
  startRowIndex?: number;
  startColIndex?: number;

  // Behavior
  enablePageNavigation?: boolean;
  pageSize?: number;

  // Callbacks
  onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
  onRowActivate?: (rowId: string) => void;

  // Styling
  className?: string;
}

// Flattened node for easier navigation
interface FlatRow {
  node: TreeGridNodeData;
  level: number;
  parentId: string | null;
  hasChildren: boolean;
}

// =============================================================================
// Component
// =============================================================================

export function TreeGrid({
  columns,
  nodes,
  ariaLabel,
  ariaLabelledby,
  expandedIds: controlledExpandedIds,
  defaultExpandedIds = [],
  onExpandedChange,
  selectable = false,
  multiselectable = false,
  selectedRowIds: controlledSelectedRowIds,
  defaultSelectedRowIds = [],
  onSelectionChange,
  focusedCellId: controlledFocusedCellId,
  defaultFocusedCellId,
  onFocusChange,
  totalRows,
  totalColumns,
  startRowIndex = 1,
  startColIndex = 1,
  enablePageNavigation = false,
  pageSize = 5,
  onCellActivate,
  onRowActivate,
  className,
}: TreeGridProps) {
  // ==========================================================================
  // State
  // ==========================================================================

  const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>(defaultExpandedIds);
  const expandedIds = controlledExpandedIds ?? internalExpandedIds;
  const expandedSet = useMemo(() => new Set(expandedIds), [expandedIds]);

  const [internalSelectedRowIds, setInternalSelectedRowIds] =
    useState<string[]>(defaultSelectedRowIds);
  const selectedRowIds = controlledSelectedRowIds ?? internalSelectedRowIds;
  const selectedRowSet = useMemo(() => new Set(selectedRowIds), [selectedRowIds]);

  const [internalFocusedCellId, setInternalFocusedCellId] = useState<string | null>(() => {
    if (defaultFocusedCellId) return defaultFocusedCellId;
    // Default to first cell of first node
    return nodes[0]?.cells[0]?.id ?? null;
  });
  const focusedCellId =
    controlledFocusedCellId !== undefined ? controlledFocusedCellId : internalFocusedCellId;

  const gridRef = useRef<HTMLDivElement>(null);
  const cellRefs = useRef<Map<string, HTMLDivElement>>(new Map());

  // ==========================================================================
  // Computed values - Flatten tree
  // ==========================================================================

  /* eslint-disable react-hooks/immutability -- Recursive function requires self-reference */
  const flattenTree = useCallback(
    (
      treeNodes: TreeGridNodeData[],
      level: number = 1,
      parentId: string | null = null
    ): FlatRow[] => {
      const result: FlatRow[] = [];
      for (const node of treeNodes) {
        const hasChildren = Boolean(node.children && node.children.length > 0);
        result.push({ node, level, parentId, hasChildren });
        if (node.children) {
          result.push(...flattenTree(node.children, level + 1, node.id));
        }
      }
      return result;
    },
    []
  );
  /* eslint-enable react-hooks/immutability */

  const allRows = useMemo(() => flattenTree(nodes), [nodes, flattenTree]);

  const rowMap = useMemo(() => {
    const map = new Map<string, FlatRow>();
    for (const flatRow of allRows) {
      map.set(flatRow.node.id, flatRow);
    }
    return map;
  }, [allRows]);

  // Visible rows based on expansion state
  const visibleRows = useMemo(() => {
    const result: FlatRow[] = [];
    const collapsedParents = new Set<string>();

    for (const flatRow of allRows) {
      // Check if any ancestor is collapsed
      let isHidden = false;
      let currentParentId = flatRow.parentId;
      while (currentParentId) {
        if (collapsedParents.has(currentParentId) || !expandedSet.has(currentParentId)) {
          isHidden = true;
          break;
        }
        const parent = rowMap.get(currentParentId);
        currentParentId = parent?.parentId ?? null;
      }

      if (!isHidden) {
        result.push(flatRow);
        if (flatRow.hasChildren && !expandedSet.has(flatRow.node.id)) {
          collapsedParents.add(flatRow.node.id);
        }
      }
    }
    return result;
  }, [allRows, expandedSet, rowMap]);

  const visibleRowIndexMap = useMemo(() => {
    const map = new Map<string, number>();
    visibleRows.forEach((flatRow, index) => map.set(flatRow.node.id, index));
    return map;
  }, [visibleRows]);

  // Cell lookup
  const cellById = useMemo(() => {
    const map = new Map<
      string,
      { rowId: string; colIndex: number; cell: TreeGridCellData; flatRow: FlatRow }
    >();
    for (const flatRow of allRows) {
      flatRow.node.cells.forEach((cell, colIndex) => {
        map.set(cell.id, { rowId: flatRow.node.id, colIndex, cell, flatRow });
      });
    }
    return map;
  }, [allRows]);

  // ==========================================================================
  // Helpers
  // ==========================================================================

  const getRowHeaderColumnIndex = useCallback(() => {
    return columns.findIndex((col) => col.isRowHeader);
  }, [columns]);

  // ==========================================================================
  // Focus Management
  // ==========================================================================

  const setFocusedCellId = useCallback(
    (id: string | null) => {
      setInternalFocusedCellId(id);
      onFocusChange?.(id);
    },
    [onFocusChange]
  );

  const focusCell = useCallback(
    (cellId: string) => {
      const cellEl = cellRefs.current.get(cellId);
      if (cellEl) {
        cellEl.focus();
        setFocusedCellId(cellId);
      }
    },
    [setFocusedCellId]
  );

  // ==========================================================================
  // Expansion Management
  // ==========================================================================

  const setExpandedIds = useCallback(
    (ids: string[]) => {
      setInternalExpandedIds(ids);
      onExpandedChange?.(ids);
    },
    [onExpandedChange]
  );

  const expandNode = useCallback(
    (rowId: string) => {
      const flatRow = rowMap.get(rowId);
      if (!flatRow?.hasChildren || flatRow.node.disabled) return;
      if (expandedSet.has(rowId)) return;

      const newExpanded = [...expandedIds, rowId];
      setExpandedIds(newExpanded);
    },
    [rowMap, expandedSet, expandedIds, setExpandedIds]
  );

  const collapseNode = useCallback(
    (rowId: string) => {
      const flatRow = rowMap.get(rowId);
      if (!flatRow?.hasChildren || flatRow.node.disabled) return;
      if (!expandedSet.has(rowId)) return;

      const newExpanded = expandedIds.filter((id) => id !== rowId);
      setExpandedIds(newExpanded);

      // If focus is on a descendant, move focus to the collapsed row
      if (focusedCellId) {
        const focusedEntry = cellById.get(focusedCellId);
        if (focusedEntry) {
          let parentId = focusedEntry.flatRow.parentId;
          while (parentId) {
            if (parentId === rowId) {
              // Focus the rowheader of the collapsed row
              const rowHeaderColIndex = getRowHeaderColumnIndex();
              const collapsedRowCell = flatRow.node.cells[rowHeaderColIndex];
              if (collapsedRowCell) {
                focusCell(collapsedRowCell.id);
              }
              break;
            }
            const parent = rowMap.get(parentId);
            parentId = parent?.parentId ?? null;
          }
        }
      }
    },
    [
      rowMap,
      expandedSet,
      expandedIds,
      setExpandedIds,
      focusedCellId,
      cellById,
      getRowHeaderColumnIndex,
      focusCell,
    ]
  );

  // ==========================================================================
  // Selection Management
  // ==========================================================================

  const setSelectedRowIds = useCallback(
    (ids: string[]) => {
      setInternalSelectedRowIds(ids);
      onSelectionChange?.(ids);
    },
    [onSelectionChange]
  );

  const toggleRowSelection = useCallback(
    (rowId: string) => {
      const flatRow = rowMap.get(rowId);
      if (!selectable || flatRow?.node.disabled) return;

      if (multiselectable) {
        const newIds = selectedRowSet.has(rowId)
          ? selectedRowIds.filter((id) => id !== rowId)
          : [...selectedRowIds, rowId];
        setSelectedRowIds(newIds);
      } else {
        const newIds = selectedRowSet.has(rowId) ? [] : [rowId];
        setSelectedRowIds(newIds);
      }
    },
    [rowMap, selectable, multiselectable, selectedRowSet, selectedRowIds, setSelectedRowIds]
  );

  const selectAllVisibleRows = useCallback(() => {
    if (!selectable || !multiselectable) return;

    const allVisibleRowIds = visibleRows
      .filter((flatRow) => !flatRow.node.disabled)
      .map((flatRow) => flatRow.node.id);
    setSelectedRowIds(allVisibleRowIds);
  }, [selectable, multiselectable, visibleRows, setSelectedRowIds]);

  // ==========================================================================
  // Navigation
  // ==========================================================================

  const getVisibleRowByIndex = useCallback(
    (index: number) => {
      return visibleRows[index] ?? null;
    },
    [visibleRows]
  );

  const navigateToCell = useCallback(
    (rowId: string, colIndex: number) => {
      const flatRow = rowMap.get(rowId);
      if (!flatRow) return;

      const cell = flatRow.node.cells[colIndex];
      if (cell) {
        focusCell(cell.id);
      }
    },
    [rowMap, focusCell]
  );

  // ==========================================================================
  // Keyboard Handling
  // ==========================================================================

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent, cell: TreeGridCellData, rowId: string, colIndex: number) => {
      const { key, ctrlKey } = event;
      const flatRow = rowMap.get(rowId);
      if (!flatRow) return;

      const visibleRowIndex = visibleRowIndexMap.get(rowId);
      if (visibleRowIndex === undefined) return;

      const rowHeaderColIndex = getRowHeaderColumnIndex();
      const isRowHeader = colIndex === rowHeaderColIndex;

      let handled = true;

      switch (key) {
        case 'ArrowDown': {
          const nextVisibleRow = getVisibleRowByIndex(visibleRowIndex + 1);
          if (nextVisibleRow) {
            navigateToCell(nextVisibleRow.node.id, colIndex);
          }
          break;
        }
        case 'ArrowUp': {
          if (visibleRowIndex > 0) {
            const prevVisibleRow = getVisibleRowByIndex(visibleRowIndex - 1);
            if (prevVisibleRow) {
              navigateToCell(prevVisibleRow.node.id, colIndex);
            }
          }
          break;
        }
        case 'ArrowRight': {
          if (
            isRowHeader &&
            flatRow.hasChildren &&
            !flatRow.node.disabled &&
            !expandedSet.has(rowId)
          ) {
            // Collapsed parent at rowheader: expand
            expandNode(rowId);
          } else {
            // Expanded parent at rowheader, leaf row at rowheader, or non-rowheader: move right
            if (colIndex < columns.length - 1) {
              const nextCell = flatRow.node.cells[colIndex + 1];
              if (nextCell) {
                focusCell(nextCell.id);
              }
            }
          }
          break;
        }
        case 'ArrowLeft': {
          if (isRowHeader) {
            if (flatRow.hasChildren && expandedSet.has(rowId) && !flatRow.node.disabled) {
              // Collapse expanded parent
              collapseNode(rowId);
            } else if (flatRow.parentId) {
              // Move to parent
              const parentRow = rowMap.get(flatRow.parentId);
              if (parentRow) {
                navigateToCell(parentRow.node.id, rowHeaderColIndex);
              }
            }
            // Root level collapsed: do nothing
          } else {
            // Non-rowheader: move left
            if (colIndex > 0) {
              const prevCell = flatRow.node.cells[colIndex - 1];
              if (prevCell) {
                focusCell(prevCell.id);
              }
            }
          }
          break;
        }
        case 'Home': {
          if (ctrlKey) {
            // Ctrl+Home: First cell in grid
            const firstRow = visibleRows[0];
            if (firstRow) {
              navigateToCell(firstRow.node.id, 0);
            }
          } else {
            // Home: First cell in current row
            const firstCell = flatRow.node.cells[0];
            if (firstCell) {
              focusCell(firstCell.id);
            }
          }
          break;
        }
        case 'End': {
          if (ctrlKey) {
            // Ctrl+End: Last cell in grid
            const lastRow = visibleRows[visibleRows.length - 1];
            if (lastRow) {
              navigateToCell(lastRow.node.id, columns.length - 1);
            }
          } else {
            // End: Last cell in current row
            const lastCell = flatRow.node.cells[flatRow.node.cells.length - 1];
            if (lastCell) {
              focusCell(lastCell.id);
            }
          }
          break;
        }
        case 'PageDown': {
          if (enablePageNavigation) {
            const targetIndex = Math.min(visibleRowIndex + pageSize, visibleRows.length - 1);
            const targetRow = visibleRows[targetIndex];
            if (targetRow) {
              navigateToCell(targetRow.node.id, colIndex);
            }
          } else {
            handled = false;
          }
          break;
        }
        case 'PageUp': {
          if (enablePageNavigation) {
            const targetIndex = Math.max(visibleRowIndex - pageSize, 0);
            const targetRow = visibleRows[targetIndex];
            if (targetRow) {
              navigateToCell(targetRow.node.id, colIndex);
            }
          } else {
            handled = false;
          }
          break;
        }
        case ' ': {
          toggleRowSelection(rowId);
          break;
        }
        case 'Enter': {
          if (!cell.disabled && !flatRow.node.disabled) {
            const colId = columns[colIndex]?.id ?? '';
            onCellActivate?.(cell.id, rowId, colId);
            onRowActivate?.(rowId);
          }
          break;
        }
        case 'a': {
          if (ctrlKey) {
            selectAllVisibleRows();
          } else {
            handled = false;
          }
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [
      rowMap,
      visibleRowIndexMap,
      getRowHeaderColumnIndex,
      getVisibleRowByIndex,
      navigateToCell,
      expandedSet,
      expandNode,
      collapseNode,
      columns,
      focusCell,
      visibleRows,
      enablePageNavigation,
      pageSize,
      toggleRowSelection,
      onCellActivate,
      onRowActivate,
      selectAllVisibleRows,
    ]
  );

  // ==========================================================================
  // Effects
  // ==========================================================================

  // Focus the focused cell when focusedCellId changes externally
  useEffect(() => {
    if (focusedCellId) {
      const cellEl = cellRefs.current.get(focusedCellId);
      if (cellEl && document.activeElement !== cellEl) {
        if (gridRef.current?.contains(document.activeElement)) {
          cellEl.focus();
        }
      }
    }
  }, [focusedCellId]);

  // ==========================================================================
  // Render
  // ==========================================================================

  return (
    <div
      ref={gridRef}
      role="treegrid"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-multiselectable={multiselectable ? 'true' : undefined}
      aria-rowcount={totalRows}
      aria-colcount={totalColumns}
      className={`apg-treegrid ${className ?? ''}`}
    >
      {/* Header Row */}
      <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
        {columns.map((col, colIndex) => (
          <div
            key={col.id}
            role="columnheader"
            aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
          >
            {col.header}
          </div>
        ))}
      </div>

      {/* Data Rows */}
      {visibleRows.map((flatRow, visibleIndex) => {
        const { node, level, hasChildren } = flatRow;
        const isExpanded = expandedSet.has(node.id);
        const isSelected = selectedRowSet.has(node.id);

        return (
          <div
            key={node.id}
            role="row"
            aria-level={level}
            aria-expanded={hasChildren ? isExpanded : undefined}
            aria-selected={selectable ? isSelected : undefined}
            aria-disabled={node.disabled ? 'true' : undefined}
            aria-rowindex={totalRows ? startRowIndex + visibleIndex : undefined}
            className={`apg-treegrid-row ${isSelected ? 'selected' : ''} ${node.disabled ? 'disabled' : ''}`}
            style={{ '--level': level } satisfies React.CSSProperties}
          >
            {node.cells.map((cell, colIndex) => {
              const col = columns[colIndex];
              const isRowHeader = col?.isRowHeader ?? false;
              const isFocused = cell.id === focusedCellId;

              return (
                <div
                  key={cell.id}
                  ref={(el) => {
                    if (el) {
                      cellRefs.current.set(cell.id, el);
                    } else {
                      cellRefs.current.delete(cell.id);
                    }
                  }}
                  role={isRowHeader ? 'rowheader' : 'gridcell'}
                  tabIndex={isFocused ? 0 : -1}
                  aria-disabled={cell.disabled ? 'true' : undefined}
                  aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
                  aria-colspan={cell.colspan}
                  onKeyDown={(e) => handleKeyDown(e, cell, node.id, colIndex)}
                  onFocus={() => setFocusedCellId(cell.id)}
                  className={`apg-treegrid-cell ${isFocused ? 'focused' : ''} ${cell.disabled ? 'disabled' : ''}`}
                >
                  {isRowHeader && hasChildren && (
                    <span className="apg-treegrid-expand-icon" aria-hidden="true">
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
                        <polyline points="9 6 15 12 9 18" />
                      </svg>
                    </span>
                  )}
                  {cell.value}
                </div>
              );
            })}
          </div>
        );
      })}
    </div>
  );
}

export default TreeGrid;

使い方

使用例
import { TreeGrid } from './TreeGrid';
import type { TreeGridColumnDef, TreeGridNodeData } from './TreeGrid';

const columns: TreeGridColumnDef[] = [
  { id: 'name', header: '名前', isRowHeader: true },
  { id: 'size', header: 'サイズ' },
  { id: 'date', header: '更新日' },
];

const nodes: TreeGridNodeData[] = [
  {
    id: 'folder1',
    cells: [
      { id: 'folder1-name', value: 'ドキュメント' },
      { id: 'folder1-size', value: '--' },
      { id: 'folder1-date', value: '2024-01-15' },
    ],
    children: [
      {
        id: 'file1',
        cells: [
          { id: 'file1-name', value: 'レポート.pdf' },
          { id: 'file1-size', value: '2.5 MB' },
          { id: 'file1-date', value: '2024-01-10' },
        ],
      },
    ],
  },
];

// 基本的なTreeGrid
<TreeGrid
  columns={columns}
  nodes={nodes}
  ariaLabel="ファイルブラウザ"
/>

// 選択と展開制御付き
<TreeGrid
  columns={columns}
  nodes={nodes}
  ariaLabel="ファイルブラウザ"
  selectable
  multiselectable
  expandedIds={expandedIds}
  selectedRowIds={selectedRowIds}
  onExpandedChange={(ids) => setExpandedIds(ids)}
  onSelectionChange={(ids) => setSelectedRowIds(ids)}
/>

API

TreeGrid Props

Prop デフォルト 説明
columns TreeGridColumnDef[] 必須 列定義
nodes TreeGridNodeData[] 必須 階層ノードデータ
ariaLabel string - アクセシブルな名前
expandedIds string[] [] 展開された行ID
onExpandedChange (ids: string[]) => void - 展開状態変更コールバック
selectable boolean false 行選択を有効化
multiselectable boolean false 複数行選択を有効化
selectedRowIds string[] [] 選択された行ID
onSelectionChange (ids: string[]) => void - 選択変更コールバック
onCellActivate (cellId, rowId, colId) => void - セルアクティベーションコールバック

型定義

Types
interface TreeGridColumnDef {
  id: string;
  header: string;
  isRowHeader?: boolean;
}

interface TreeGridCellData {
  id: string;
  value: string | number;
  disabled?: boolean;
}

interface TreeGridNodeData {
  id: string;
  cells: TreeGridCellData[];
  children?: TreeGridNodeData[];
  disabled?: boolean;
}

テスト

テストは、キーボードインタラクション、ARIA属性、アクセシビリティ要件全体でAPG準拠を検証します。TreeGridコンポーネントはGridとTreeViewのテスト戦略を組み合わせています。

テスト戦略

ユニットテスト(Testing Library)

フレームワーク固有のTesting Libraryユーティリティを使用してコンポーネントのレンダリングとインタラクションを検証します。これらのテストはコンポーネントの分離された動作を確認します。

  • HTML構造と要素の階層(treegrid、row、rowheader、gridcell)
  • 初期属性値(role、aria-label、aria-level、aria-expanded)
  • 選択状態の変更(行のaria-selected)
  • 階層深度インジケーター(aria-level)
  • CSSクラスの適用

E2Eテスト(Playwright)

4つのフレームワークすべてで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストは完全なブラウザコンテキストが必要なインタラクションをカバーします。

  • 2Dキーボードナビゲーション(矢印キー)
  • rowheaderでのツリー操作(展開/折りたたみのArrowRight/Left)
  • 拡張ナビゲーション(Home、End、Ctrl+Home、Ctrl+End)
  • Spaceでの行選択
  • フォーカス管理とローヴィングタブインデックス
  • 非表示行の処理(折りたたまれた子)
  • フレームワーク間の一貫性

テストカテゴリ

高優先度: APG ARIA属性 ( Unit + E2E )

テスト 説明
role="treegrid" コンテナにtreegridロールがある
role="row" すべての行にrowロールがある
role="rowheader" 行の最初のセルにrowheaderロールがある
role="gridcell" 他のセルにgridcellロールがある
role="columnheader" ヘッダーセルにcolumnheaderロールがある
aria-label TreeGridにaria-label経由のアクセシブルな名前がある
aria-level すべての行にaria-level(1始まりの深さ)がある
aria-expanded 親行にaria-expanded(true/false)がある
aria-selected on row 行要素に選択がある(セルではない)
aria-multiselectable 複数選択が有効な場合に存在

高優先度: ツリー操作(Rowheaderで) ( E2E )

テスト 説明
ArrowRight expands 折りたたまれた親行を展開
ArrowRight to child 既に展開されている場合、最初の子に移動
ArrowLeft collapses 展開された親行を折りたたみ
ArrowLeft to parent 折りたたみまたはリーフの場合、親行に移動
Enter activates only Enterは展開/折りたたみをしない(Treeとは異なる)
Children hidden 親が折りたたまれると子行が非表示

高優先度: 2Dキーボードナビゲーション ( E2E )

テスト 説明
ArrowRight (non-rowheader) フォーカスを右に1セル移動
ArrowLeft (non-rowheader) フォーカスを左に1セル移動
ArrowDown 次の表示行にフォーカスを移動
ArrowUp 前の表示行にフォーカスを移動
Skip hidden rows ArrowDown/Upは折りたたまれた子をスキップ

高優先度: 拡張ナビゲーション ( E2E )

テスト 説明
Home 行の最初のセルにフォーカスを移動
End 行の最後のセルにフォーカスを移動
Ctrl+Home 最初の表示行の最初のセルにフォーカスを移動
Ctrl+End 最後の表示行の最後のセルにフォーカスを移動

高優先度: フォーカス管理(ローヴィングタブインデックス) ( Unit + E2E )

テスト 説明
tabindex="0" 最初のフォーカス可能なセルにtabindex="0"がある
tabindex="-1" 他のセルにtabindex="-1"がある
Headers not focusable columnheaderセルにtabindexがない
Tab exits treegrid Tabでtreegridからフォーカスが外れる
Focus update ナビゲーション時にフォーカスされたセルのtabindexが更新される

高優先度: 行選択 ( E2E )

テスト 説明
Space toggles row Spaceで行の選択を切り替え(セルではない)
Single select 単一選択ではSpaceで前の選択をクリア
Multi select 複数選択では複数行を選択可能
Enter activates cell Enterでセルをアクティブ化

中優先度: アクセシビリティ ( E2E )

テスト 説明
axe-core アクセシビリティ違反がない

テストコード例

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

e2e/treegrid.spec.ts
import { expect, test, type Locator, type Page } from '@playwright/test';

/**
 * E2E Tests for TreeGrid Pattern
 *
 * Tests verify the TreeGrid component behavior in a real browser,
 * including 2D keyboard navigation, tree operations (expand/collapse),
 * row selection, and focus management.
 *
 * Test coverage:
 * - ARIA structure and attributes (treegrid, aria-level, aria-expanded)
 * - 2D keyboard navigation (Arrow keys, Home, End, Ctrl+Home, Ctrl+End)
 * - Tree operations at rowheader (ArrowRight/Left for expand/collapse)
 * - Row selection (Space toggles row selection, not cell)
 * - Focus management (roving tabindex)
 *
 * Key differences from Grid:
 * - Tree operations only at rowheader cells
 * - Row selection (aria-selected on row, not cell)
 * - aria-level and aria-expanded on rows
 */

/**
 * Helper to check if a cell or a focusable element within it is focused.
 */
async function expectCellOrChildFocused(_page: Page, cell: Locator): Promise<void> {
  const cellIsFocused = await cell.evaluate((el) => document.activeElement === el);
  if (cellIsFocused) {
    await expect(cell).toBeFocused();
    return;
  }

  const focusedChild = cell.locator(
    'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
  );
  const childCount = await focusedChild.count();
  if (childCount > 0) {
    for (let i = 0; i < childCount; i++) {
      const child = focusedChild.nth(i);
      const childIsFocused = await child.evaluate((el) => document.activeElement === el);
      if (childIsFocused) {
        await expect(child).toBeFocused();
        return;
      }
    }
  }

  await expect(cell).toBeFocused();
}

/**
 * Helper to focus a cell, handling cells that contain links/buttons.
 */
async function focusCell(_page: Page, cell: Locator): Promise<void> {
  await cell.click({ position: { x: 5, y: 5 } });
}

/**
 * Helper to get the row containing a cell.
 */
async function getRowForCell(cell: Locator): Promise<Locator> {
  return cell.locator('xpath=ancestor::*[@role="row"]').first();
}

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

for (const framework of frameworks) {
  test.describe(`TreeGrid (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/treegrid/${framework}/demo/`);
      await page.waitForLoadState('networkidle');
      await page.waitForSelector('[role="treegrid"]');
    });

    // 🔴 High Priority: ARIA Attributes
    test.describe('ARIA Attributes', () => {
      test('has role="treegrid" on container', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        await expect(treegrid).toBeVisible();
      });

      test('has role="row" on rows', async ({ page }) => {
        const rows = page.getByRole('row');
        await expect(rows.first()).toBeVisible();
        expect(await rows.count()).toBeGreaterThan(1);
      });

      test('has role="gridcell" on data cells', async ({ page }) => {
        const cells = page.getByRole('gridcell');
        await expect(cells.first()).toBeVisible();
      });

      test('has role="columnheader" on header cells', async ({ page }) => {
        const headers = page.getByRole('columnheader');
        await expect(headers.first()).toBeVisible();
      });

      test('has role="rowheader" on row header cells', async ({ page }) => {
        const rowheaders = page.getByRole('rowheader');
        await expect(rowheaders.first()).toBeVisible();
      });

      test('has accessible name', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const label = await treegrid.getAttribute('aria-label');
        const labelledby = await treegrid.getAttribute('aria-labelledby');
        expect(label || labelledby).toBeTruthy();
      });

      test('parent rows have aria-expanded', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        let foundParentRow = false;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded !== null) {
            foundParentRow = true;
            expect(['true', 'false']).toContain(ariaExpanded);
          }
        }
        expect(foundParentRow).toBe(true);
      });

      test('all data rows have aria-level', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaLevel = await row.getAttribute('aria-level');
          // Skip header row (no aria-level)
          if (ariaLevel !== null) {
            const level = parseInt(ariaLevel, 10);
            expect(level).toBeGreaterThanOrEqual(1);
          }
        }
      });

      test('aria-level is 1-based and increments with depth', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        const levels: number[] = [];
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaLevel = await row.getAttribute('aria-level');
          if (ariaLevel !== null) {
            levels.push(parseInt(ariaLevel, 10));
          }
        }

        // Check that level 1 exists (root level)
        expect(levels).toContain(1);
      });

      test('has aria-selected on row (not gridcell) when selectable', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        let hasSelectableRow = false;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            hasSelectableRow = true;
            expect(['true', 'false']).toContain(ariaSelected);
          }
        }

        if (hasSelectableRow) {
          // Verify gridcells don't have aria-selected
          const cells = treegrid.getByRole('gridcell');
          const cellCount = await cells.count();
          for (let i = 0; i < cellCount; i++) {
            const cell = cells.nth(i);
            const ariaSelected = await cell.getAttribute('aria-selected');
            expect(ariaSelected).toBeNull();
          }
        }
      });
    });

    // 🔴 High Priority: Keyboard - Row Navigation
    test.describe('Keyboard - Row Navigation', () => {
      test('ArrowDown moves to same column in next visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await focusCell(page, firstRowheader);

        await page.keyboard.press('ArrowDown');

        const secondRowheader = rowheaders.nth(1);
        await expectCellOrChildFocused(page, secondRowheader);
      });

      test('ArrowUp moves to same column in previous visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const secondRowheader = rowheaders.nth(1);
        await focusCell(page, secondRowheader);

        await page.keyboard.press('ArrowUp');

        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('ArrowUp stops at first visible row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await focusCell(page, firstRowheader);

        await page.keyboard.press('ArrowUp');

        // Should stay on first row
        await expectCellOrChildFocused(page, firstRowheader);
      });
    });

    // 🔴 High Priority: Keyboard - Cell Navigation
    test.describe('Keyboard - Cell Navigation', () => {
      test('ArrowRight at non-rowheader moves right', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first();
        await focusCell(page, firstCell);

        await page.keyboard.press('ArrowRight');

        const secondCell = cells.nth(1);
        await expectCellOrChildFocused(page, secondCell);
      });

      test('ArrowLeft at non-rowheader moves left', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const secondCell = cells.nth(1);
        await focusCell(page, secondCell);

        await page.keyboard.press('ArrowLeft');

        const firstCell = cells.first();
        await expectCellOrChildFocused(page, firstCell);
      });

      test('Home moves to first cell in row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const rowheaders = treegrid.getByRole('rowheader');
        const secondCell = cells.nth(1);
        await focusCell(page, secondCell);

        await page.keyboard.press('Home');

        // Should move to rowheader (first cell in row)
        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('End moves to last cell in row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await focusCell(page, firstRowheader);

        await page.keyboard.press('End');

        // Should move to last cell in first data row
        // Get cells in the same row
        const row = await getRowForCell(firstRowheader);
        const cellsInRow = row.getByRole('gridcell');
        const lastCell = cellsInRow.last();
        await expectCellOrChildFocused(page, lastCell);
      });

      test('Ctrl+Home moves to first cell in grid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const rowheaders = treegrid.getByRole('rowheader');
        const lastCell = cells.last();
        await focusCell(page, lastCell);

        await page.keyboard.press('Control+Home');

        // Should move to first rowheader
        const firstRowheader = rowheaders.first();
        await expectCellOrChildFocused(page, firstRowheader);
      });

      test('Ctrl+End moves to last cell in grid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const cells = treegrid.getByRole('gridcell');
        const firstRowheader = rowheaders.first();
        await focusCell(page, firstRowheader);

        await page.keyboard.press('Control+End');

        // Should move to last cell
        const lastCell = cells.last();
        await expectCellOrChildFocused(page, lastCell);
      });
    });

    // 🔴 High Priority: Keyboard - Tree Operations
    test.describe('Keyboard - Tree Operations', () => {
      test('ArrowRight at collapsed rowheader expands row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');
        await focusCell(page, rowheader);

        await page.keyboard.press('ArrowRight');

        await expect(row).toHaveAttribute('aria-expanded', 'true');
      });

      test('expanding a row makes child rows visible', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');

        // Get initial visible rowheader count
        const visibleRowheadersBefore = await treegrid.getByRole('rowheader').count();

        await focusCell(page, rowheader);
        await page.keyboard.press('ArrowRight');

        await expect(row).toHaveAttribute('aria-expanded', 'true');

        // After expansion, there should be more visible rowheaders (child rows appeared)
        const visibleRowheadersAfter = await treegrid.getByRole('rowheader').count();
        expect(visibleRowheadersAfter).toBeGreaterThan(visibleRowheadersBefore);
      });

      test('ArrowLeft at expanded rowheader collapses row', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find an expanded parent row
        let expandedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'true') {
            expandedRowIndex = i;
            break;
          }
        }

        if (expandedRowIndex === -1) {
          // Try to expand first, then collapse
          for (let i = 0; i < rowCount; i++) {
            const row = rows.nth(i);
            const ariaExpanded = await row.getAttribute('aria-expanded');
            if (ariaExpanded === 'false') {
              const rowheader = row.getByRole('rowheader');
              await focusCell(page, rowheader);
              await page.keyboard.press('ArrowRight');
              await expect(row).toHaveAttribute('aria-expanded', 'true');
              expandedRowIndex = i;
              break;
            }
          }
        }

        if (expandedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(expandedRowIndex);
        const rowheader = row.getByRole('rowheader');
        await focusCell(page, rowheader);

        await page.keyboard.press('ArrowLeft');

        await expect(row).toHaveAttribute('aria-expanded', 'false');
      });

      test('ArrowRight at expanded rowheader moves right to next cell', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find an expanded parent row
        let expandedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'true') {
            expandedRowIndex = i;
            break;
          }
        }

        if (expandedRowIndex === -1) {
          // Try to expand a collapsed row first
          for (let i = 0; i < rowCount; i++) {
            const row = rows.nth(i);
            const ariaExpanded = await row.getAttribute('aria-expanded');
            if (ariaExpanded === 'false') {
              const rowheader = row.getByRole('rowheader');
              await focusCell(page, rowheader);
              await page.keyboard.press('ArrowRight');
              await expect(row).toHaveAttribute('aria-expanded', 'true');
              expandedRowIndex = i;
              break;
            }
          }
        }

        if (expandedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(expandedRowIndex);
        const rowheader = row.getByRole('rowheader');
        const cells = row.getByRole('gridcell');
        const firstCell = cells.first();

        await focusCell(page, rowheader);

        // ArrowRight at expanded rowheader should move to the next cell (not expand again)
        await page.keyboard.press('ArrowRight');

        // Row should still be expanded
        await expect(row).toHaveAttribute('aria-expanded', 'true');

        // Focus should move to first gridcell in same row
        await expectCellOrChildFocused(page, firstCell);
      });

      test('ArrowRight at non-rowheader does NOT expand', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const cells = row.getByRole('gridcell');
        const firstCell = cells.first();
        await focusCell(page, firstCell);

        await page.keyboard.press('ArrowRight');

        // Should NOT expand - still collapsed
        await expect(row).toHaveAttribute('aria-expanded', 'false');
      });

      test('Enter does NOT expand/collapse', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a collapsed parent row
        let collapsedRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaExpanded = await row.getAttribute('aria-expanded');
          if (ariaExpanded === 'false') {
            collapsedRowIndex = i;
            break;
          }
        }

        if (collapsedRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(collapsedRowIndex);
        const rowheader = row.getByRole('rowheader');
        await focusCell(page, rowheader);

        await page.keyboard.press('Enter');

        // Should still be collapsed
        await expect(row).toHaveAttribute('aria-expanded', 'false');
      });
    });

    // 🔴 High Priority: Row Selection
    test.describe('Row Selection', () => {
      test('Space toggles row selection', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a selectable row
        let selectableRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            selectableRowIndex = i;
            break;
          }
        }

        if (selectableRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(selectableRowIndex);
        const rowheader = row.getByRole('rowheader');
        await focusCell(page, rowheader);

        await page.keyboard.press('Space');

        await expect(row).toHaveAttribute('aria-selected', 'true');
      });

      test('Space toggles row selection off', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rows = treegrid.getByRole('row');
        const rowCount = await rows.count();

        // Find a selectable row
        let selectableRowIndex = -1;
        for (let i = 0; i < rowCount; i++) {
          const row = rows.nth(i);
          const ariaSelected = await row.getAttribute('aria-selected');
          if (ariaSelected !== null) {
            selectableRowIndex = i;
            break;
          }
        }

        if (selectableRowIndex === -1) {
          test.skip();
          return;
        }

        const row = rows.nth(selectableRowIndex);
        const rowheader = row.getByRole('rowheader');
        await focusCell(page, rowheader);

        // Select
        await page.keyboard.press('Space');
        await expect(row).toHaveAttribute('aria-selected', 'true');

        // Deselect
        await page.keyboard.press('Space');
        await expect(row).toHaveAttribute('aria-selected', 'false');
      });
    });

    // 🔴 High Priority: Focus Management
    test.describe('Focus Management', () => {
      test('first focusable cell has tabIndex="0"', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await expect(firstRowheader).toHaveAttribute('tabindex', '0');
      });

      test('other cells have tabIndex="-1"', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first();
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
      });

      test('columnheader cells are not focusable', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const headers = treegrid.getByRole('columnheader');
        const firstHeader = headers.first();
        const tabindex = await firstHeader.getAttribute('tabindex');
        expect(tabindex).toBeNull();
      });

      test('Tab exits treegrid', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const rowheaders = treegrid.getByRole('rowheader');
        const firstRowheader = rowheaders.first();
        await focusCell(page, firstRowheader);

        await page.keyboard.press('Tab');

        const treegridContainsFocus = await treegrid.evaluate((el) =>
          el.contains(document.activeElement)
        );
        expect(treegridContainsFocus).toBe(false);
      });

      test('roving tabindex updates on arrow navigation', async ({ page }) => {
        const treegrid = page.getByRole('treegrid');
        const cells = treegrid.getByRole('gridcell');
        const firstCell = cells.first(); // First gridcell (not rowheader) = Size column of first row
        const secondCell = cells.nth(1); // Date column of first row

        // Initially first rowheader has tabindex="0", gridcells have "-1"
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
        await expect(secondCell).toHaveAttribute('tabindex', '-1');

        // Focus first gridcell (Size column) and navigate right to Date column
        await focusCell(page, firstCell);
        await expect(firstCell).toHaveAttribute('tabindex', '0');

        await page.keyboard.press('ArrowRight');

        // After navigation, tabindex should update
        await expect(firstCell).toHaveAttribute('tabindex', '-1');
        await expect(secondCell).toHaveAttribute('tabindex', '0');
      });
    });
  });
}

テストの実行

          
            # TreeGridのユニットテストを実行
npm run test -- treegrid

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

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

npm run test:e2e:vue:pattern --pattern=treegrid

npm run test:e2e:svelte:pattern --pattern=treegrid

npm run test:e2e:astro:pattern --pattern=treegrid
          
        

Gridとの主な違い

  • 選択対象: TreeGridは行を選択(rowのaria-selected)、Gridはセルを選択
  • rowheaderでの矢印の動作: ArrowRight/Leftはツリー操作を行い、セルナビゲーションは行わない
  • 階層: aria-levelの値と親子関係をテストする必要がある
  • 非表示行: ナビゲーション中に折りたたまれた子をスキップする必要がある

テストツール

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

リソース