TreeGrid
Gridの2Dナビゲーションと、TreeViewの展開可能な行を組み合わせた階層データグリッド。
デモ
矢印キーで移動。rowheaderでArrowRight/Leftで展開/折りたたみ。Spaceで行を選択。
矢印キーでセル間を移動します。最初の列(行ヘッダー)では、右矢印キーで折りたたまれた行を展開し、左矢印キーで展開された行を折りたたみます。スペースキーで行の選択/解除を行います。Enterキーでセルをアクティブにします。
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が必須
- ナビゲーション: 折りたたまれた子はナビゲーションでスキップ
ソースコード
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 | - | セルアクティベーションコールバック |
型定義
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).
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の値と親子関係をテストする必要がある
- 非表示行: ナビゲーション中に折りたたまれた子をスキップする必要がある
テストツール
- Vitest (opens in new tab) - ユニットテスト用テストランナー
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
リソース
- WAI-ARIA APG: TreeGrid パターン (opens in new tab)
- WAI-ARIA APG: TreeGrid 例 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist