Grid
An interactive 2D data grid with keyboard navigation, cell selection, and activation.
Demo
Navigate with arrow keys. Press Space to select cells. Press Enter to activate.
Use arrow keys to navigate between cells. Press Space to select/deselect cells. Press Enter to activate a cell.
Grid vs Table
Use grid role for interactive data grids, and table role for
static data presentation.
| Feature | Grid | Table |
|---|---|---|
| Keyboard Navigation | 2D (Arrow keys) | Table navigation (browser default) |
| Cell Focus | Required (roving tabindex) | Not required |
| Selection | aria-selected | Not supported |
| Editing | Optional | Not supported |
| Use Case | Spreadsheet-like, data grids | Static data display |
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
grid | Container | The grid container (composite widget) |
row | Row container | Groups cells horizontally |
columnheader | Header cells | Column headers (not focusable in this implementation) |
rowheader | Row header cell | Row headers (optional) |
gridcell | Data cells | Interactive cells (focusable) |
WAI-ARIA Properties
role="grid"
Identifies the container as a grid
- Values
- -
- Required
- Yes
aria-label
Accessible name for the grid
- Values
- String
- Required
- Yes* (either aria-label or aria-labelledby)
aria-labelledby
Alternative to aria-label
- Values
- ID reference
- Required
- Yes* (either aria-label or aria-labelledby)
aria-multiselectable
Only present for multi-select mode
- Values
- true
- Required
- No
aria-rowcount
Total rows (for virtualization)
- Values
- Number
- Required
- No
aria-colcount
Total columns (for virtualization)
- Values
- Number
- Required
- No
WAI-ARIA States
tabindex
- Target Element
- gridcell
- Values
- 0 | -1
- Required
- Yes
- Change Trigger
- Roving tabindex for focus management
aria-selected
- Target Element
- gridcell
- Values
- true | false
- Required
- No
- Change Trigger
Present when grid supports selection. When selection is supported, ALL gridcells should have aria-selected.
aria-disabled
- Target Element
- gridcell
- Values
- true
- Required
- No
- Change Trigger
- Indicates the cell is disabled
aria-rowindex
- Target Element
- row, gridcell
- Values
- Number
- Required
- No
- Change Trigger
- Row position (for virtualization)
aria-colindex
- Target Element
- gridcell
- Values
- Number
- Required
- No
- Change Trigger
- Column position (for virtualization)
Keyboard Support
2D Navigation
| Key | Action |
|---|---|
| → | Move focus one cell right |
| ← | Move focus one cell left |
| ↓ | Move focus one row down |
| ↑ | Move focus one row up |
| Home | Move focus to first cell in row |
| End | Move focus to last cell in row |
| Ctrl + Home | Move focus to first cell in grid |
| Ctrl + End | Move focus to last cell in grid |
| PageDown | Move focus down by page size (default 5) |
| PageUp | Move focus up by page size (default 5) |
Selection & Activation
| Key | Action |
|---|---|
| Space | Select/deselect focused cell (when selectable) |
| Enter | Activate focused cell (trigger onCellActivate) |
- Either aria-label or aria-labelledby is required on the grid container.
- Disabled cells have aria-disabled=“true”, are focusable (included in keyboard navigation), cannot be selected or activated, and are visually distinct (e.g., grayed out).
Focus Management
| Event | Behavior |
|---|---|
| Roving tabindex | Only one cell has tabindex="0" (the focused cell), all others have tabindex="-1" |
| Single Tab stop | Grid is a single Tab stop (Tab enters grid, Shift+Tab exits) |
| Header cells | Header cells (columnheader) are NOT focusable (no sort functionality in this implementation) |
| Data cells only | Only gridcells in the data rows are included in keyboard navigation |
| Focus memory | Last focused cell is remembered when leaving and re-entering the grid |
References
Source Code
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; Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
columns | GridColumnDef[] | required | Column definitions |
rows | GridRowData[] | required | Row data |
ariaLabel | string | - | Accessible name for grid |
ariaLabelledby | string | - | ID reference for accessible name |
selectable | boolean | false | Enable cell selection |
multiselectable | boolean | false | Enable multi-cell selection |
selectedIds | string[] | [] | Selected cell IDs |
onSelectionChange | (ids: string[]) => void | - | Selection change callback |
onCellActivate | (cellId, rowId, colId) => void | - | Cell activation callback |
wrapNavigation | boolean | false | Wrap navigation at row edges |
pageSize | number | 5 | Rows to skip with PageUp/Down |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Grid component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.
- HTML structure and element hierarchy (grid, row, gridcell)
- Initial attribute values (role, aria-label, tabindex)
- Selection state changes (aria-selected)
- CSS class application
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.
- 2D keyboard navigation (Arrow keys)
- Extended navigation (Home, End, Ctrl+Home, Ctrl+End)
- Page navigation (PageUp, PageDown)
- Cell selection and activation
- Focus management and roving tabindex
- Cross-framework consistency
Test Categories
High Priority : APG ARIA Attributes
| Test | Description |
|---|---|
role="grid" | Container has grid role |
role="row" | All rows have row role |
role="gridcell" | Data cells have gridcell role |
role="columnheader" | Header cells have columnheader role |
role="rowheader" | Row header cells have rowheader role (when applicable) |
aria-label | Grid has accessible name via aria-label |
aria-labelledby | Grid has accessible name via aria-labelledby |
aria-multiselectable | Present when multi-selection is enabled |
aria-selected | Present on all cells when selection is enabled |
aria-disabled | Present on disabled cells |
High Priority : 2D Keyboard Navigation
| Test | Description |
|---|---|
ArrowRight | Moves focus one cell right |
ArrowLeft | Moves focus one cell left |
ArrowDown | Moves focus one row down |
ArrowUp | Moves focus one row up |
ArrowUp at first row | Stops at first data row (does not enter headers) |
ArrowRight at row end | Stops at row end (default) or wraps (wrapNavigation) |
High Priority : Extended Navigation
| Test | Description |
|---|---|
Home | Moves focus to first cell in row |
End | Moves focus to last cell in row |
Ctrl+Home | Moves focus to first cell in grid |
Ctrl+End | Moves focus to last cell in grid |
PageDown | Moves focus down by page size |
PageUp | Moves focus up by page size |
High Priority : Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabindex="0" | First focusable cell has tabindex="0" |
tabindex="-1" | Other cells have tabindex="-1" |
Headers not focusable | columnheader cells have no tabindex (not focusable) |
Tab exits grid | Tab moves focus out of grid |
Focus update | Focused cell updates tabindex on navigation |
Disabled cells | Disabled cells are focusable but not activatable |
High Priority : Selection
| Test | Description |
|---|---|
Space toggles | Space toggles cell selection (when selectable) |
Single select | Single selection clears previous on Space |
Multi select | Multi-selection allows multiple cells |
Enter activates | Enter triggers cell activation |
Disabled no select | Space does not select disabled cell |
Disabled no activate | Enter does not activate disabled cell |
Medium Priority : Virtualization Support
| Test | Description |
|---|---|
aria-rowcount | Present when totalRows provided |
aria-colcount | Present when totalColumns provided |
aria-rowindex | Present on rows/cells when virtualizing |
aria-colindex | Present on cells when virtualizing |
Medium Priority : Accessibility
| Test | Description |
|---|---|
axe-core | No accessibility violations |
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
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();
});
});
}); Resources
- WAI-ARIA APG: Grid Pattern (opens in new tab)
- WAI-ARIA APG: Data Grid Examples (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist