Grid
キーボードナビゲーション、セル選択、アクティベーションを備えたインタラクティブな2Dデータグリッド。
デモ
矢印キーで移動します。スペースキーでセルを選択します。Enterキーでアクティブにします。
Use arrow keys to navigate between cells. Press Space to select/deselect cells. Press Enter to activate a cell.
Grid vs Table
インタラクティブなデータグリッドにはgridロールを、静的なデータ表示には
tableロールを使用します。
| 機能 | Grid | Table |
|---|---|---|
| キーボードナビゲーション | 2D(矢印キー) | テーブルナビゲーション(ブラウザデフォルト) |
| セルフォーカス | 必須(roving tabindex) | 不要 |
| 選択 | aria-selected | 未対応 |
| 編集 | オプション | 未対応 |
| ユースケース | スプレッドシート風、データグリッド | 静的データ表示 |
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
grid | コンテナ | グリッドコンテナ(複合ウィジェット) |
row | 行コンテナ | セルを水平方向にグループ化 |
columnheader | ヘッダーセル | 列ヘッダー(この実装ではフォーカス不可) |
rowheader | 行ヘッダーセル | 行ヘッダー(オプション) |
gridcell | データセル | インタラクティブセル(フォーカス可能) |
WAI-ARIA プロパティ
role="grid"
コンテナをグリッドとして識別
- 値
- -
- 必須
- はい
aria-label
グリッドのアクセシブルな名前
- 値
- String
- 必須
はい*(aria-label または aria-labelledby のいずれか)
aria-labelledby
aria-labelの代替
- 値
- ID reference
- 必須
はい*(aria-label または aria-labelledby のいずれか)
aria-multiselectable
複数選択モード時のみ存在
- 値
- true
- 必須
- いいえ
aria-rowcount
総行数(仮想化用)
- 値
- 数値
- 必須
- いいえ
aria-colcount
総列数(仮想化用)
- 値
- 数値
- 必須
- いいえ
WAI-ARIA ステート
tabindex
- 対象要素
- gridcell
- 値
- 0 | -1
- 必須
- はい
- 変更トリガー
- フォーカス管理用のroving tabindex
aria-selected
- 対象要素
- gridcell
- 値
- true | false
- 必須
- いいえ
- 変更トリガー
グリッドが選択をサポートする場合に存在。選択をサポートする場合、すべてのgridcellにaria-selectedが必要。
aria-disabled
- 対象要素
- gridcell
- 値
- true
- 必須
- いいえ
- 変更トリガー
- セルが無効であることを示す
aria-rowindex
- 対象要素
- row, gridcell
- 値
- 数値
- 必須
- いいえ
- 変更トリガー
- 行位置(仮想化用)
aria-colindex
- 対象要素
- gridcell
- 値
- 数値
- 必須
- いいえ
- 変更トリガー
- 列位置(仮想化用)
キーボードサポート
2Dナビゲーション
| キー | アクション |
|---|---|
| → | フォーカスを右のセルに移動 |
| ← | フォーカスを左のセルに移動 |
| ↓ | フォーカスを下の行に移動 |
| ↑ | フォーカスを上の行に移動 |
| Home | フォーカスを行の最初のセルに移動 |
| End | フォーカスを行の最後のセルに移動 |
| Ctrl + Home | フォーカスをグリッドの最初のセルに移動 |
| Ctrl + End | フォーカスをグリッドの最後のセルに移動 |
| PageDown | フォーカスをページサイズ分下に移動(デフォルト5) |
| PageUp | フォーカスをページサイズ分上に移動(デフォルト5) |
選択とアクティベーション
| キー | アクション |
|---|---|
| Space | フォーカス中のセルを選択/選択解除(選択可能時) |
| Enter | フォーカス中のセルをアクティベート(onCellActivateをトリガー) |
- グリッドコンテナには aria-label または aria-labelledby のいずれかが必須です。
- 無効化セルはaria-disabled=“true”を持ち、フォーカス可能(キーボードナビゲーションに含まれる)ですが、選択またはアクティベートできず、視覚的に区別されます(例:グレーアウト)。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| Roving tabindex | 1つのセルのみがtabindex="0"(フォーカス中のセル)を持ち、他のすべてのセルはtabindex="-1"を持つ |
| 単一Tabストップ | グリッドは単一のTabストップ(Tabでグリッドに入り、Shift+Tabで離脱) |
| ヘッダーセル | ヘッダーセル(columnheader)はフォーカス不可(この実装ではソート機能なし) |
| データセルのみ | データ行のgridcellのみがキーボードナビゲーションに含まれる |
| フォーカスメモリ | グリッドを離れて再入場した際、最後にフォーカスされたセルが記憶される |
参考資料
ソースコード
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// =============================================================================
// Types
// =============================================================================
export interface GridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
rowspan?: number;
}
export interface GridColumnDef {
id: string;
header: string;
colspan?: number;
}
export interface GridRowData {
id: string;
cells: GridCellData[];
hasRowHeader?: boolean;
disabled?: boolean;
}
export interface GridProps {
columns: GridColumnDef[];
rows: GridRowData[];
// Accessible name (one required)
ariaLabel?: string;
ariaLabelledby?: string;
// Selection
selectable?: boolean;
multiselectable?: boolean;
selectedIds?: string[];
defaultSelectedIds?: string[];
onSelectionChange?: (selectedIds: string[]) => void;
// Focus
focusedId?: string | null;
defaultFocusedId?: string;
onFocusChange?: (focusedId: string | null) => void;
// Virtualization
totalColumns?: number;
totalRows?: number;
startRowIndex?: number; // 1-based
startColIndex?: number; // 1-based
// Behavior
wrapNavigation?: boolean;
enablePageNavigation?: boolean;
pageSize?: number;
// Callbacks
onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
renderCell?: (cell: GridCellData, rowId: string, colId: string) => React.ReactNode;
// Styling
className?: string;
}
// =============================================================================
// Component
// =============================================================================
export function Grid({
columns,
rows,
ariaLabel,
ariaLabelledby,
selectable = false,
multiselectable = false,
selectedIds: controlledSelectedIds,
defaultSelectedIds = [],
onSelectionChange,
focusedId: controlledFocusedId,
defaultFocusedId,
onFocusChange,
totalColumns,
totalRows,
startRowIndex = 1,
startColIndex = 1,
wrapNavigation = false,
enablePageNavigation = false,
pageSize = 5,
onCellActivate,
renderCell,
className,
}: GridProps) {
// ==========================================================================
// State
// ==========================================================================
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(defaultSelectedIds);
const selectedIds = controlledSelectedIds ?? internalSelectedIds;
const [internalFocusedId, setInternalFocusedId] = useState<string | null>(() => {
if (defaultFocusedId) return defaultFocusedId;
// Default to first cell
return rows[0]?.cells[0]?.id ?? null;
});
const focusedId = controlledFocusedId !== undefined ? controlledFocusedId : internalFocusedId;
const gridRef = useRef<HTMLDivElement>(null);
const cellRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// ==========================================================================
// Computed values
// ==========================================================================
// Map cellId to cell info for O(1) lookup
const cellById = useMemo(() => {
const map = new Map<
string,
{ rowIndex: number; colIndex: number; cell: GridCellData; rowId: string }
>();
rows.forEach((row, rowIndex) => {
row.cells.forEach((cell, colIndex) => {
map.set(cell.id, { rowIndex, colIndex, cell, rowId: row.id });
});
});
return map;
}, [rows]);
const getCellPosition = useCallback(
(cellId: string) => {
const entry = cellById.get(cellId);
if (!entry) {
return null;
}
const { rowIndex, colIndex } = entry;
return { rowIndex, colIndex };
},
[cellById]
);
const getCellAt = useCallback(
(rowIndex: number, colIndex: number) => {
const cell = rows[rowIndex]?.cells[colIndex];
if (!cell) {
return undefined;
}
return cellById.get(cell.id);
},
[cellById, rows]
);
const getColumnCount = useCallback(() => {
return columns.length;
}, [columns]);
const getRowCount = useCallback(() => {
return rows.length;
}, [rows]);
// ==========================================================================
// Focus Management
// ==========================================================================
const setFocusedId = useCallback(
(id: string | null) => {
setInternalFocusedId(id);
onFocusChange?.(id);
},
[onFocusChange]
);
const focusCell = useCallback(
(cellId: string) => {
const cellEl = cellRefs.current.get(cellId);
if (cellEl) {
// Check if cell contains a focusable element (link, button, etc.)
// Per APG: when cell contains a single widget, focus should be on the widget
const focusableChild = cellEl.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableChild) {
// Set tabindex="-1" so Tab skips this element and exits the grid
// The widget can still receive programmatic focus
focusableChild.setAttribute('tabindex', '-1');
focusableChild.focus();
} else {
cellEl.focus();
}
setFocusedId(cellId);
}
},
[setFocusedId]
);
// Find next focusable cell (skipping disabled cells if needed)
const findNextFocusableCell = useCallback(
(
startRowIndex: number,
startColIndex: number,
direction: 'right' | 'left' | 'up' | 'down',
skipDisabled = true
): { rowIndex: number; colIndex: number; cell: GridCellData } | null => {
const colCount = getColumnCount();
const rowCount = getRowCount();
let rowIdx = startRowIndex;
let colIdx = startColIndex;
const step = () => {
switch (direction) {
case 'right':
colIdx++;
if (colIdx >= colCount) {
if (wrapNavigation) {
colIdx = 0;
rowIdx++;
if (rowIdx >= rowCount) {
return false; // End of grid
}
} else {
return false; // Stay at edge
}
}
break;
case 'left':
colIdx--;
if (colIdx < 0) {
if (wrapNavigation) {
colIdx = colCount - 1;
rowIdx--;
if (rowIdx < 0) {
return false;
}
} else {
return false;
}
}
break;
case 'down':
rowIdx++;
if (rowIdx >= rowCount) {
return false;
}
break;
case 'up':
rowIdx--;
if (rowIdx < 0) {
return false;
}
break;
}
return true;
};
// Take one step first
if (!step()) return null;
// Find non-disabled cell
let iterations = 0;
const maxIterations = colCount * rowCount;
while (iterations < maxIterations) {
const entry = getCellAt(rowIdx, colIdx);
if (entry && (!skipDisabled || !entry.cell.disabled)) {
return { rowIndex: rowIdx, colIndex: colIdx, cell: entry.cell };
}
if (!step()) break;
iterations++;
}
return null;
},
[getColumnCount, getRowCount, wrapNavigation, getCellAt]
);
// ==========================================================================
// Selection Management
// ==========================================================================
const setSelectedIds = useCallback(
(ids: string[]) => {
setInternalSelectedIds(ids);
onSelectionChange?.(ids);
},
[onSelectionChange]
);
const toggleSelection = useCallback(
(cellId: string, cell: GridCellData) => {
if (!selectable || cell.disabled) {
return;
}
if (multiselectable) {
const newIds = selectedIds.includes(cellId)
? selectedIds.filter((id) => id !== cellId)
: [...selectedIds, cellId];
setSelectedIds(newIds);
} else {
const newIds = selectedIds.includes(cellId) ? [] : [cellId];
setSelectedIds(newIds);
}
},
[selectable, multiselectable, selectedIds, setSelectedIds]
);
const selectAll = useCallback(() => {
if (!selectable || !multiselectable) {
return;
}
const allIds = Array.from(cellById.values())
.filter(({ cell }) => !cell.disabled)
.map(({ cell }) => cell.id);
setSelectedIds(allIds);
}, [selectable, multiselectable, cellById, setSelectedIds]);
// ==========================================================================
// Keyboard Handling
// ==========================================================================
const handleKeyDown = useCallback(
(event: React.KeyboardEvent, cell: GridCellData, rowId: string, colId: string) => {
const pos = getCellPosition(cell.id);
if (!pos) {
return;
}
const { rowIndex, colIndex } = pos;
const { key, ctrlKey } = event;
let handled = true;
switch (key) {
case 'ArrowRight': {
const next = findNextFocusableCell(rowIndex, colIndex, 'right');
if (next) focusCell(next.cell.id);
break;
}
case 'ArrowLeft': {
const next = findNextFocusableCell(rowIndex, colIndex, 'left');
if (next) focusCell(next.cell.id);
break;
}
case 'ArrowDown': {
const next = findNextFocusableCell(rowIndex, colIndex, 'down');
if (next) focusCell(next.cell.id);
break;
}
case 'ArrowUp': {
const next = findNextFocusableCell(rowIndex, colIndex, 'up');
if (next) focusCell(next.cell.id);
break;
}
case 'Home': {
if (ctrlKey) {
// Ctrl+Home: Go to first cell in grid
const firstCell = rows[0]?.cells[0];
if (firstCell) focusCell(firstCell.id);
} else {
// Home: Go to first cell in row
const firstCellInRow = rows[rowIndex]?.cells[0];
if (firstCellInRow) focusCell(firstCellInRow.id);
}
break;
}
case 'End': {
if (ctrlKey) {
// Ctrl+End: Go to last cell in grid
const lastRow = rows[rows.length - 1];
const lastCell = lastRow?.cells[lastRow.cells.length - 1];
if (lastCell) focusCell(lastCell.id);
} else {
// End: Go to last cell in row
const currentRow = rows[rowIndex];
const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
if (lastCellInRow) focusCell(lastCellInRow.id);
}
break;
}
case 'PageDown': {
if (enablePageNavigation) {
const targetRowIndex = Math.min(rowIndex + pageSize, rows.length - 1);
const targetCell = rows[targetRowIndex]?.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (enablePageNavigation) {
const targetRowIndex = Math.max(rowIndex - pageSize, 0);
const targetCell = rows[targetRowIndex]?.cells[colIndex];
if (targetCell) focusCell(targetCell.id);
} else {
handled = false;
}
break;
}
case ' ': {
toggleSelection(cell.id, cell);
break;
}
case 'Enter': {
if (!cell.disabled) {
onCellActivate?.(cell.id, rowId, colId);
}
break;
}
case 'a': {
if (ctrlKey) {
selectAll();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[
getCellPosition,
findNextFocusableCell,
focusCell,
rows,
enablePageNavigation,
pageSize,
toggleSelection,
onCellActivate,
selectAll,
]
);
// ==========================================================================
// Effects
// ==========================================================================
// Set tabindex="-1" on all focusable elements inside grid cells
// This ensures Tab exits the grid instead of moving between widgets
useEffect(() => {
if (gridRef.current) {
const focusableElements = gridRef.current.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
);
focusableElements.forEach((el) => {
el.setAttribute('tabindex', '-1');
});
}
}, [rows]);
// Focus the focused cell when focusedId changes externally
useEffect(() => {
if (focusedId) {
const cellEl = cellRefs.current.get(focusedId);
if (cellEl && document.activeElement !== cellEl) {
// Only focus if grid is already focused
if (gridRef.current?.contains(document.activeElement)) {
cellEl.focus();
}
}
}
}, [focusedId]);
// ==========================================================================
// Render
// ==========================================================================
return (
<div
ref={gridRef}
role="grid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns}
className={`apg-grid ${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}
aria-colspan={col.colspan}
>
{col.header}
</div>
))}
</div>
{/* Data Rows */}
{rows.map((row, rowIndex) => (
<div
key={row.id}
role="row"
aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
>
{row.cells.map((cell, colIndex) => {
const isRowHeader = row.hasRowHeader && colIndex === 0;
const isFocused = cell.id === focusedId;
const isSelected = selectedIds.includes(cell.id);
const colId = columns[colIndex]?.id ?? '';
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-selected={selectable ? (isSelected ? 'true' : 'false') : undefined}
aria-disabled={cell.disabled ? 'true' : undefined}
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={cell.colspan}
aria-rowspan={cell.rowspan}
onKeyDown={(e) => handleKeyDown(e, cell, row.id, colId)}
onFocus={() => setFocusedId(cell.id)}
className={`apg-grid-cell ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''} ${cell.disabled ? 'disabled' : ''}`}
>
{renderCell ? renderCell(cell, row.id, colId) : cell.value}
</div>
);
})}
</div>
))}
</div>
);
}
export default Grid; 使い方
import { Grid } from './Grid';
import type { GridColumnDef, GridRowData } from './Grid';
const columns: GridColumnDef[] = [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const rows: GridRowData[] = [
{
id: 'user1',
cells: [
{ id: 'user1-0', value: 'Alice Johnson' },
{ id: 'user1-1', value: 'alice@example.com' },
{ id: 'user1-2', value: 'Admin' },
],
},
{
id: 'user2',
cells: [
{ id: 'user2-0', value: 'Bob Smith' },
{ id: 'user2-1', value: 'bob@example.com' },
{ id: 'user2-2', value: 'User' },
],
},
];
// Basic Grid
<Grid
columns={columns}
rows={rows}
ariaLabel="User list"
/>
// With selection
<Grid
columns={columns}
rows={rows}
ariaLabel="User list"
selectable
multiselectable
selectedIds={selectedIds}
onSelectionChange={(ids) => setSelectedIds(ids)}
onCellActivate={(cellId, rowId, colId) => {
console.log('Activated:', { cellId, rowId, colId });
}}
/> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
columns | GridColumnDef[] | required | 列定義 |
rows | GridRowData[] | required | 行データ |
ariaLabel | string | - | グリッドのアクセシブルな名前 |
ariaLabelledby | string | - | アクセシブルな名前のID参照 |
selectable | boolean | false | セル選択を有効化 |
multiselectable | boolean | false | 複数セル選択を有効化 |
selectedIds | string[] | [] | 選択中のセルID |
onSelectionChange | (ids: string[]) => void | - | 選択変更コールバック |
onCellActivate | (cellId, rowId, colId) => void | - | セルアクティベーションコールバック |
wrapNavigation | boolean | false | 行端でナビゲーションを折り返す |
pageSize | number | 5 | PageUp/Downでスキップする行数 |
テスト
テストはキーボード操作、ARIA属性、アクセシビリティ要件にわたるAPG準拠を検証します。Gridコンポーネントは2層のテスト戦略を採用しています。
テスト戦略
ユニットテスト (Testing Library)
フレームワーク固有のTesting Libraryユーティリティを使用して、コンポーネントのレンダリングとインタラクションを検証します。これらのテストはコンポーネントの動作を分離して確認します。
- HTML構造と要素階層(grid, row, gridcell)
- 初期属性値(role, aria-label, tabindex)
- 選択状態の変更(aria-selected)
- CSSクラスの適用
E2Eテスト (Playwright)
4つのすべてのフレームワークで、実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはフルブラウザコンテキストを必要とするインタラクションをカバーします。
- 2Dキーボードナビゲーション(矢印キー)
- 拡張ナビゲーション(Home, End, Ctrl+Home, Ctrl+End)
- ページナビゲーション(PageUp, PageDown)
- セルの選択とアクティベーション
- フォーカス管理とroving tabindex
- クロスフレームワークの一貫性
テストカテゴリー
高優先度 : APG ARIA属性
| テスト | 説明 |
|---|---|
role="grid" | コンテナがgridロールを持つ |
role="row" | すべての行がrowロールを持つ |
role="gridcell" | データセルがgridcellロールを持つ |
role="columnheader" | ヘッダーセルがcolumnheaderロールを持つ |
role="rowheader" | 行ヘッダーセルがrowheaderロールを持つ(該当時) |
aria-label | グリッドがaria-labelでアクセシブルな名前を持つ |
aria-labelledby | グリッドがaria-labelledbyでアクセシブルな名前を持つ |
aria-multiselectable | 複数選択が有効な場合に存在 |
aria-selected | 選択が有効な場合、すべてのセルに存在 |
aria-disabled | 無効なセルに存在 |
高優先度 : 2Dキーボードナビゲーション
| テスト | 説明 |
|---|---|
ArrowRight | フォーカスを右に1セル移動 |
ArrowLeft | フォーカスを左に1セル移動 |
ArrowDown | フォーカスを下に1行移動 |
ArrowUp | フォーカスを上に1行移動 |
ArrowUp at first row | 最初のデータ行で停止(ヘッダーには移動しない) |
ArrowRight at row end | 行末で停止(デフォルト)または折り返し(wrapNavigation) |
高優先度 : 拡張ナビゲーション
| テスト | 説明 |
|---|---|
Home | フォーカスを行の最初のセルに移動 |
End | フォーカスを行の最後のセルに移動 |
Ctrl+Home | フォーカスをグリッドの最初のセルに移動 |
Ctrl+End | フォーカスをグリッドの最後のセルに移動 |
PageDown | フォーカスをページサイズ分下に移動 |
PageUp | フォーカスをページサイズ分上に移動 |
高優先度 : フォーカス管理 (Roving Tabindex)
| テスト | 説明 |
|---|---|
tabindex="0" | 最初のフォーカス可能なセルがtabindex="0"を持つ |
tabindex="-1" | 他のセルがtabindex="-1"を持つ |
Headers not focusable | columnheaderセルにtabindexがない(フォーカス不可) |
Tab exits grid | Tabでフォーカスがグリッド外に移動 |
Focus update | ナビゲーション時にフォーカスセルのtabindexが更新される |
Disabled cells | 無効セルはフォーカス可能だがアクティベート不可 |
高優先度 : 選択
| テスト | 説明 |
|---|---|
Space toggles | Spaceでセル選択をトグル(選択可能時) |
Single select | 単一選択モードではSpaceで前の選択をクリア |
Multi select | 複数選択モードでは複数セルを選択可能 |
Enter activates | Enterでセルをアクティベート |
Disabled no select | Spaceで無効セルは選択できない |
Disabled no activate | Enterで無効セルはアクティベートできない |
中優先度 : 仮想化サポート
| テスト | 説明 |
|---|---|
aria-rowcount | totalRows指定時に存在 |
aria-colcount | totalColumns指定時に存在 |
aria-rowindex | 仮想化時に行/セルに存在 |
aria-colindex | 仮想化時にセルに存在 |
中優先度 : アクセシビリティ
| テスト | 説明 |
|---|---|
axe-core | アクセシビリティ違反がないこと |
テストツール
- 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) を参照してください。
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Grid, type GridColumnDef, type GridRowData } from './Grid';
// Helper function to create basic grid data
const createBasicColumns = (): GridColumnDef[] => [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const createBasicRows = (): GridRowData[] => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com' },
{ id: 'row1-2', value: 'Admin' },
],
},
{
id: 'row2',
cells: [
{ id: 'row2-0', value: 'Bob' },
{ id: 'row2-1', value: 'bob@example.com' },
{ id: 'row2-2', value: 'User' },
],
},
{
id: 'row3',
cells: [
{ id: 'row3-0', value: 'Charlie' },
{ id: 'row3-1', value: 'charlie@example.com' },
{ id: 'row3-2', value: 'User' },
],
},
];
// Rows with disabled cells
const createRowsWithDisabled = (): GridRowData[] => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com', disabled: true },
{ id: 'row1-2', value: 'Admin' },
],
},
{
id: 'row2',
cells: [
{ id: 'row2-0', value: 'Bob' },
{ id: 'row2-1', value: 'bob@example.com' },
{ id: 'row2-2', value: 'User' },
],
},
];
// Rows with row header
const createRowsWithRowHeader = (): GridRowData[] => [
{
id: 'row1',
hasRowHeader: true,
cells: [
{ id: 'row1-0', value: '1' },
{ id: 'row1-1', value: 'Alice' },
{ id: 'row1-2', value: 'Admin' },
],
},
{
id: 'row2',
hasRowHeader: true,
cells: [
{ id: 'row2-0', value: '2' },
{ id: 'row2-1', value: 'Bob' },
{ id: 'row2-2', value: 'User' },
],
},
];
// Rows with spanned cells
const createRowsWithSpan = (): GridRowData[] => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Merged', colspan: 2 },
{ id: 'row1-2', value: 'Normal' },
],
},
{
id: 'row2',
cells: [
{ id: 'row2-0', value: 'A' },
{ id: 'row2-1', value: 'B' },
{ id: 'row2-2', value: 'C' },
],
},
];
// Columns with span
const createColumnsWithSpan = (): GridColumnDef[] => [
{ id: 'info', header: 'Info', colspan: 2 },
{ id: 'role', header: 'Role' },
];
describe('Grid', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="grid" on container', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
expect(screen.getByRole('grid')).toBeInTheDocument();
});
it('has role="row" on all rows', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
// Header row + 3 data rows = 4 rows
expect(screen.getAllByRole('row')).toHaveLength(4);
});
it('has role="gridcell" on data cells', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
// 3 rows * 3 columns = 9 cells
expect(screen.getAllByRole('gridcell')).toHaveLength(9);
});
it('has role="columnheader" on header cells', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
});
it('has role="rowheader" when hasRowHeader', () => {
render(
<Grid columns={createBasicColumns()} rows={createRowsWithRowHeader()} ariaLabel="Users" />
);
expect(screen.getAllByRole('rowheader')).toHaveLength(2);
});
it('has accessible name via aria-label', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
expect(screen.getByRole('grid', { name: 'Users' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<div>
<h2 id="grid-title">User List</h2>
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabelledby="grid-title"
/>
</div>
);
const grid = screen.getByRole('grid');
expect(grid).toHaveAttribute('aria-labelledby', 'grid-title');
});
it('has aria-multiselectable when multiselectable', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
multiselectable
/>
);
expect(screen.getByRole('grid')).toHaveAttribute('aria-multiselectable', 'true');
});
it('has aria-selected on selectable cells', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
/>
);
const cells = screen.getAllByRole('gridcell');
cells.forEach((cell) => {
expect(cell).toHaveAttribute('aria-selected', 'false');
});
});
it('has aria-disabled on disabled cells', () => {
render(
<Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
);
const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
expect(disabledCell).toHaveAttribute('aria-disabled', 'true');
});
it('has aria-colspan on spanned cells', () => {
render(<Grid columns={createBasicColumns()} rows={createRowsWithSpan()} ariaLabel="Users" />);
const mergedCell = screen.getByRole('gridcell', { name: 'Merged' });
expect(mergedCell).toHaveAttribute('aria-colspan', '2');
});
it('has aria-colspan on spanned columnheader', () => {
render(<Grid columns={createColumnsWithSpan()} rows={createBasicRows()} ariaLabel="Users" />);
const infoHeader = screen.getByRole('columnheader', { name: 'Info' });
expect(infoHeader).toHaveAttribute('aria-colspan', '2');
});
});
// 🔴 High Priority: Keyboard - 2D Navigation
describe('Keyboard - 2D Navigation', () => {
it('ArrowRight moves focus one cell right', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{ArrowRight}');
expect(screen.getAllByRole('gridcell')[1]).toHaveFocus();
});
it('ArrowLeft moves focus one cell left', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const secondCell = screen.getAllByRole('gridcell')[1];
secondCell.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
it('ArrowDown moves focus one row down', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
firstCell.focus();
await user.keyboard('{ArrowDown}');
// Should move to row2, col0
expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
});
it('ArrowUp moves focus one row up', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const secondRowFirstCell = screen.getAllByRole('gridcell')[3]; // row2, col0
secondRowFirstCell.focus();
await user.keyboard('{ArrowUp}');
// Should move to row1, col0
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
it('ArrowRight stops at row end (default)', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2 (last in row)
lastCellInRow.focus();
await user.keyboard('{ArrowRight}');
// Should stay at the same cell
expect(lastCellInRow).toHaveFocus();
});
it('ArrowRight wraps to next row when wrapNavigation is true', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
wrapNavigation
/>
);
const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2 (last in row)
lastCellInRow.focus();
await user.keyboard('{ArrowRight}');
// Should wrap to first cell of next row
expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
});
it('ArrowDown stops at grid bottom', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const lastRowCell = screen.getAllByRole('gridcell')[6]; // row3, col0 (last row)
lastRowCell.focus();
await user.keyboard('{ArrowDown}');
// Should stay at the same cell
expect(lastRowCell).toHaveFocus();
});
it('ArrowUp stops at first data row (does not enter headers)', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const firstDataCell = screen.getAllByRole('gridcell')[0]; // row1, col0
firstDataCell.focus();
await user.keyboard('{ArrowUp}');
// Should stay at the first data cell, not move to header
expect(firstDataCell).toHaveFocus();
});
it('skips disabled cells during horizontal navigation', async () => {
const user = userEvent.setup();
render(
<Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
);
const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0 (Alice)
firstCell.focus();
await user.keyboard('{ArrowRight}');
// Should skip disabled cell (alice@example.com) and focus Admin
expect(screen.getByRole('gridcell', { name: 'Admin' })).toHaveFocus();
});
});
// 🔴 High Priority: Keyboard - Extended Navigation
describe('Keyboard - Extended Navigation', () => {
it('Home moves to first cell in row', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2
lastCellInRow.focus();
await user.keyboard('{Home}');
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
it('End moves to last cell in row', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
firstCell.focus();
await user.keyboard('{End}');
expect(screen.getAllByRole('gridcell')[2]).toHaveFocus();
});
it('Ctrl+Home moves to first cell in grid', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const lastCell = screen.getAllByRole('gridcell')[8]; // row3, col2 (last cell)
lastCell.focus();
await user.keyboard('{Control>}{Home}{/Control}');
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
it('Ctrl+End moves to last cell in grid', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
firstCell.focus();
await user.keyboard('{Control>}{End}{/Control}');
expect(screen.getAllByRole('gridcell')[8]).toHaveFocus();
});
it('PageDown moves down by pageSize', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enablePageNavigation
pageSize={2}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
firstCell.focus();
await user.keyboard('{PageDown}');
// Should move 2 rows down
expect(screen.getAllByRole('gridcell')[6]).toHaveFocus();
});
it('PageUp moves up by pageSize', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enablePageNavigation
pageSize={2}
/>
);
const lastRowCell = screen.getAllByRole('gridcell')[6]; // row3, col0
lastRowCell.focus();
await user.keyboard('{PageUp}');
// Should move 2 rows up
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('first focusable cell has tabIndex="0" by default', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const firstCell = screen.getAllByRole('gridcell')[0];
expect(firstCell).toHaveAttribute('tabindex', '0');
});
it('defaultFocusedId sets initial focus', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
defaultFocusedId="row2-1"
/>
);
const targetCell = screen.getByRole('gridcell', { name: 'bob@example.com' });
expect(targetCell).toHaveAttribute('tabindex', '0');
});
it('other cells have tabIndex="-1"', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const cells = screen.getAllByRole('gridcell');
// First cell should have tabindex="0", others should have tabindex="-1"
expect(cells[0]).toHaveAttribute('tabindex', '0');
expect(cells[1]).toHaveAttribute('tabindex', '-1');
expect(cells[2]).toHaveAttribute('tabindex', '-1');
});
it('focused cell updates tabIndex on navigation', async () => {
const user = userEvent.setup();
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const cells = screen.getAllByRole('gridcell');
cells[0].focus();
await user.keyboard('{ArrowRight}');
expect(cells[0]).toHaveAttribute('tabindex', '-1');
expect(cells[1]).toHaveAttribute('tabindex', '0');
});
it('disabled cells are focusable', async () => {
const user = userEvent.setup();
render(
<Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
);
const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
// Disabled cell should still have tabindex (either 0 or -1)
expect(disabledCell).toHaveAttribute('tabindex');
});
it('Tab focuses grid then exits', async () => {
const user = userEvent.setup();
render(
<div>
<button>Before</button>
<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
<button>After</button>
</div>
);
const beforeButton = screen.getByRole('button', { name: 'Before' });
beforeButton.focus();
await user.tab();
// Should focus grid (first cell)
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
await user.tab();
// Should exit grid to next element
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('Shift+Tab exits grid to previous element', async () => {
const user = userEvent.setup();
render(
<div>
<button>Before</button>
<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
<button>After</button>
</div>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
// Use fireEvent for Shift+Tab due to jsdom limitations
fireEvent.keyDown(firstCell, { key: 'Tab', shiftKey: true });
// Note: actual focus behavior depends on browser, but we verify the event is handled
});
it('columnheader cells are not focusable', () => {
render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
const headers = screen.getAllByRole('columnheader');
headers.forEach((header) => {
expect(header).not.toHaveAttribute('tabindex');
});
});
});
// 🔴 High Priority: Selection
describe('Selection', () => {
it('Space toggles selection (single)', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
expect(firstCell).toHaveAttribute('aria-selected', 'false');
await user.keyboard(' ');
expect(firstCell).toHaveAttribute('aria-selected', 'true');
});
it('Space toggles selection (multi)', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
multiselectable
/>
);
const cells = screen.getAllByRole('gridcell');
cells[0].focus();
await user.keyboard(' ');
expect(cells[0]).toHaveAttribute('aria-selected', 'true');
await user.keyboard('{ArrowRight}');
await user.keyboard(' ');
// Both should be selected in multiselect mode
expect(cells[0]).toHaveAttribute('aria-selected', 'true');
expect(cells[1]).toHaveAttribute('aria-selected', 'true');
});
it('single selection clears previous on Space', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
/>
);
const cells = screen.getAllByRole('gridcell');
cells[0].focus();
await user.keyboard(' ');
expect(cells[0]).toHaveAttribute('aria-selected', 'true');
await user.keyboard('{ArrowRight}');
await user.keyboard(' ');
// Previous selection should be cleared
expect(cells[0]).toHaveAttribute('aria-selected', 'false');
expect(cells[1]).toHaveAttribute('aria-selected', 'true');
});
it('Enter activates cell', async () => {
const user = userEvent.setup();
const onCellActivate = vi.fn();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
onCellActivate={onCellActivate}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{Enter}');
expect(onCellActivate).toHaveBeenCalledWith('row1-0', 'row1', 'name');
});
it('Enter does not activate disabled cell', async () => {
const user = userEvent.setup();
const onCellActivate = vi.fn();
render(
<Grid
columns={createBasicColumns()}
rows={createRowsWithDisabled()}
ariaLabel="Users"
onCellActivate={onCellActivate}
/>
);
const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
disabledCell.focus();
await user.keyboard('{Enter}');
expect(onCellActivate).not.toHaveBeenCalled();
});
it('Space does not select disabled cell', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createRowsWithDisabled()}
ariaLabel="Users"
selectable
/>
);
const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
disabledCell.focus();
await user.keyboard(' ');
expect(disabledCell).toHaveAttribute('aria-selected', 'false');
});
it('Ctrl+A selects all (multiselectable only)', async () => {
const user = userEvent.setup();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
multiselectable
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{Control>}a{/Control}');
const cells = screen.getAllByRole('gridcell');
cells.forEach((cell) => {
expect(cell).toHaveAttribute('aria-selected', 'true');
});
});
it('calls onSelectionChange callback', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
onSelectionChange={onSelectionChange}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard(' ');
expect(onSelectionChange).toHaveBeenCalledWith(['row1-0']);
});
it('controlled selectedIds overrides internal state', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
selectedIds={['row2-1']}
/>
);
const targetCell = screen.getByRole('gridcell', { name: 'bob@example.com' });
expect(targetCell).toHaveAttribute('aria-selected', 'true');
const otherCells = screen.getAllByRole('gridcell').filter((cell) => cell !== targetCell);
otherCells.forEach((cell) => {
expect(cell).toHaveAttribute('aria-selected', 'false');
});
});
});
// 🟡 Medium Priority: Virtualization Support
describe('Virtualization Support', () => {
it('has aria-rowcount when totalRows provided', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
totalRows={100}
/>
);
expect(screen.getByRole('grid')).toHaveAttribute('aria-rowcount', '100');
});
it('has aria-colcount when totalColumns provided', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
totalColumns={10}
/>
);
expect(screen.getByRole('grid')).toHaveAttribute('aria-colcount', '10');
});
it('has aria-rowindex on rows when virtualizing', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
totalRows={100}
startRowIndex={10}
/>
);
const rows = screen.getAllByRole('row');
// Skip header row (index 0), check data rows
expect(rows[1]).toHaveAttribute('aria-rowindex', '10');
expect(rows[2]).toHaveAttribute('aria-rowindex', '11');
expect(rows[3]).toHaveAttribute('aria-rowindex', '12');
});
it('has aria-colindex on cells when virtualizing', () => {
render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
totalColumns={10}
startColIndex={5}
/>
);
const firstRowCells = screen.getAllByRole('gridcell').slice(0, 3);
expect(firstRowCells[0]).toHaveAttribute('aria-colindex', '5');
expect(firstRowCells[1]).toHaveAttribute('aria-colindex', '6');
expect(firstRowCells[2]).toHaveAttribute('aria-colindex', '7');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(
<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with selection enabled', async () => {
const { container } = render(
<Grid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
multiselectable
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
}); リソース
- WAI-ARIA APG: Grid パターン (opens in new tab)
- WAI-ARIA APG: Data Grid 例 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist