Data Grid
An advanced interactive data grid with sorting, row selection, range selection, and cell editing capabilities.
Demo
Navigation: Arrow keys to navigate, Home/End for row bounds, Ctrl+Home/End for grid bounds.
Sorting: Click or press Enter/Space on a sortable column header to cycle sort direction.
Row Selection: Click checkboxes or press Space to select/deselect rows.
Editing: Press Enter or F2 on an editable cell (Role/Status, indicated by pen icon) to edit. Role uses combobox with autocomplete, Status uses select dropdown. Escape to cancel.
- ↑ ↓ ← →
- Move between cells
- Home / End
- First / last cell in row
- Ctrl + Home
- First cell in grid
- Ctrl + End
- Last cell in grid
- Page Up / Page Down
- Jump by page
- Enter / Space
- Sort column (on header)
- Space
- Toggle row selection (on checkbox)
- Shift + Arrow
- Extend cell selection
- Ctrl + A
- Select all cells
- Enter / F2
- Start editing cell
- Escape
- Cancel editing
- Tab
- Move within cell widgets
Data Grid vs Grid
Data Grid extends the basic Grid pattern with additional features for data manipulation.
| Feature | Grid | Data Grid |
|---|---|---|
| 2D Navigation | Yes | Yes |
| Cell Selection | Yes | Yes |
| Column Sorting | No | Yes (aria-sort) |
| Row Selection | No | Yes (checkbox) |
| Range Selection | No | Yes (Shift+Arrow) |
| Cell Editing | No | Yes (Enter/F2) |
| Header Navigation | No | Yes (sortable headers) |
Accessibility Features
When to Use Data Grid
Data Grid extends the basic grid role with spreadsheet-like features. Use Data Grid when you need:
- Sorting: Column headers that sort data on click/keyboard
- Row Selection: Checkbox-based selection of entire rows
- Range Selection: Shift+Arrow to select multiple cells
- Cell Editing: In-place editing with Enter/F2 to start, Escape to cancel
For simple static tables, use native
| Role | Target Element | Description |
|---|---|---|
grid | Container | Identifies the element as a grid. The grid contains rows of cells. |
row | Each row | Identifies a row of cells |
gridcell | Each cell | Identifies an interactive cell in the grid |
rowheader | Row header cell | Identifies a cell as a header for its row |
columnheader | Column header cell | Identifies a cell as a header for its column |
APG Data Grids Examples (opens in new tab)
WAI-ARIA Properties (Grid Container)
| Attribute | Values | Required | Description |
|---|---|---|---|
role="grid" | - | Yes | Identifies the container as a grid |
aria-label | String | Yes* | Accessible name for the grid |
aria-labelledby | ID reference | Yes* | Alternative to aria-label |
aria-multiselectable | true | No | Present when multi-select (row or cell) is enabled |
aria-readonly | true | No | Present when entire grid is read-only |
aria-rowcount | Number (1-based) | No | Total rows for virtualization |
aria-colcount | Number (1-based) | No | Total columns for virtualization |
* Either aria-label or aria-labelledby is required.
WAI-ARIA States (Column Headers)
| Attribute | Values | Required | Description |
|---|---|---|---|
aria-sort | ascending | descending | none | other | Yes* | Current sort direction (only on sortable headers) |
tabindex | 0 | -1 | Yes* | Roving tabindex (only on sortable headers) |
* Only required for sortable column headers.
WAI-ARIA States (Rows)
| Attribute | Values | Required | Description |
|---|---|---|---|
aria-selected | true | false | Yes* | Row selection state (when rowSelectable) |
aria-disabled | true | No | Indicates the row is disabled |
aria-rowindex | Number (1-based) | No | Row position for virtualization |
* Only required when row selection is enabled.
WAI-ARIA States (Grid Cells)
| Attribute | Values | Required | Description |
|---|---|---|---|
tabindex | 0 | -1 | Yes | Roving tabindex for focus management |
aria-selected | true | false | No* | Cell selection state (when cell selectable) |
aria-readonly | true | false | No* | Cell editability (when grid is editable) |
aria-disabled | true | No | Indicates the cell is disabled |
aria-colindex | Number (1-based) | No | Column position for virtualization |
* When selection/editing is supported, ALL gridcells should have the corresponding attribute.
Keyboard Support
2D Navigation
| Key | Action |
|---|---|
| → | Move focus one cell right |
| ← | Move focus one cell left |
| ↓ | Move focus one row down (from header to first data row) |
| ↑ | Move focus one row up (from first row to header if sortable) |
| 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 |
| PageUp | Move focus up by page size |
Sorting (Column Headers)
| Key | Action |
|---|---|
| Enter | Cycle sort direction (none → ascending → descending → ascending) |
| Space | Cycle sort direction |
Range Selection
| Key | Action |
|---|---|
| Shift + ↓ | Extend selection downward |
| Shift + ↑ | Extend selection upward |
| Shift + → | Extend selection rightward |
| Shift + ← | Extend selection leftward |
| Shift + Home | Extend selection to row start |
| Shift + End | Extend selection to row end |
| Ctrl + Shift + Home | Extend selection to grid start |
| Ctrl + Shift + End | Extend selection to grid end |
Cell Editing
| Key | Action |
|---|---|
| Enter | Enter edit mode (on editable cell) or commit edit (in edit mode) |
| F2 | Enter edit mode |
| Escape | Cancel edit and restore original value |
| Tab | Commit edit (in edit mode) |
Selection & Activation
| Key | Action |
|---|---|
| Space | Select/deselect focused cell or toggle row checkbox |
| Ctrl + A | Select all cells (when multiselectable) |
Focus Management
This component uses roving tabindex for focus management:
- Only one element has tabindex="0" (current focus position)
- All other focusable elements have tabindex="-1"
- Grid is a single Tab stop (Tab enters grid, Shift+Tab exits)
- Sortable column headers participate in roving tabindex (non-sortable headers are not focusable)
- Arrow Up from first data row moves to sortable header at same column
- Arrow Down from header moves to first data row at same column
- Edit mode: Focus moves to input field, grid navigation is disabled until edit ends
- Focus memory: last focused element is remembered when leaving and re-entering the grid
Row Selection Behavior
- Row selection checkboxes are placed in a dedicated column (gridcell)
- Each row checkbox has an accessible label (e.g., "Select row user1")
- When rowMultiselectable is true, a "Select all" checkbox appears in the header
- Select all checkbox shows indeterminate state when some (but not all) rows are selected
- Row selection state is reflected via aria-selected on the row element
Edit Mode Behavior
- Only cells with editable: true can enter edit mode
- Cells with readonly: true cannot be edited even if editable
- Grid-level readonly prop disables all editing
- aria-readonly on cells indicates editability to screen readers
- When editing, focus is on the input field inside the cell
- Grid keyboard navigation (arrows, Home, End) is suppressed during edit mode
- On edit end (Enter, Tab, blur), focus returns to the cell
- On cancel (Escape), original value is restored
Source Code
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// =============================================================================
// Types
// =============================================================================
export type SortDirection = 'ascending' | 'descending' | 'none' | 'other';
// =============================================================================
// Helper Functions
// =============================================================================
/** Get sort indicator character based on sort direction */
function getSortIndicator(direction?: SortDirection): string {
if (direction === 'ascending') return ' ▲';
if (direction === 'descending') return ' ▼';
return ' ⇅';
}
export type EditType = 'text' | 'select' | 'combobox';
export interface DataGridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
rowspan?: number;
editable?: boolean;
readonly?: boolean;
}
export interface DataGridColumnDef {
id: string;
header: string;
sortable?: boolean;
sortDirection?: SortDirection;
colspan?: number;
isRowLabel?: boolean; // This column provides accessible labels for row checkboxes
editable?: boolean; // Column-level editable flag
editType?: EditType; // Type of editor: text, select, or combobox
options?: string[]; // Options for select/combobox
}
export interface DataGridRowData {
id: string;
cells: DataGridCellData[];
hasRowHeader?: boolean;
disabled?: boolean;
}
export interface DataGridProps {
columns: DataGridColumnDef[];
rows: DataGridRowData[];
// Accessible name (one required)
ariaLabel?: string;
ariaLabelledby?: string;
// Row Selection
rowSelectable?: boolean;
rowMultiselectable?: boolean;
selectedRowIds?: string[];
defaultSelectedRowIds?: string[];
onRowSelectionChange?: (rowIds: string[]) => void;
// Sorting
onSort?: (columnId: string, direction: SortDirection) => void;
// Range Selection
enableRangeSelection?: boolean;
onRangeSelect?: (cellIds: string[]) => void;
// Cell Editing
editable?: boolean;
readonly?: boolean;
editingCellId?: string | null;
onEditStart?: (cellId: string, rowId: string, colId: string) => void;
onEditEnd?: (cellId: string, value: string, cancelled: boolean) => void;
onCellValueChange?: (cellId: string, newValue: string) => void;
// Focus
focusedId?: string | null;
defaultFocusedId?: string;
onFocusChange?: (focusedId: string | null) => void;
// Cell Selection (from Grid)
selectable?: boolean;
multiselectable?: boolean;
selectedIds?: string[];
defaultSelectedIds?: string[];
onSelectionChange?: (selectedIds: string[]) => 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: DataGridCellData, rowId: string, colId: string) => React.ReactNode;
// Styling
className?: string;
}
// =============================================================================
// Helper Functions
// =============================================================================
function getNextSortDirection(current: SortDirection | undefined): SortDirection {
switch (current) {
case 'ascending':
return 'descending';
case 'descending':
return 'ascending';
case 'none':
default:
return 'ascending';
}
}
// =============================================================================
// Component
// =============================================================================
export function DataGrid({
columns,
rows,
ariaLabel,
ariaLabelledby,
rowSelectable = false,
rowMultiselectable = false,
selectedRowIds: controlledSelectedRowIds,
defaultSelectedRowIds = [],
onRowSelectionChange,
onSort,
enableRangeSelection = false,
onRangeSelect,
editable = false,
readonly = false,
editingCellId: controlledEditingCellId,
onEditStart,
onEditEnd,
onCellValueChange,
focusedId: controlledFocusedId,
defaultFocusedId,
onFocusChange,
selectable = false,
multiselectable = false,
selectedIds: controlledSelectedIds,
defaultSelectedIds = [],
onSelectionChange,
totalColumns,
totalRows,
startRowIndex = 1,
startColIndex = 1,
wrapNavigation = false,
enablePageNavigation = false,
pageSize = 5,
onCellActivate,
renderCell,
className,
}: DataGridProps) {
// ==========================================================================
// State
// ==========================================================================
// Row selection
const [internalSelectedRowIds, setInternalSelectedRowIds] =
useState<string[]>(defaultSelectedRowIds);
const selectedRowIds = controlledSelectedRowIds ?? internalSelectedRowIds;
// Cell selection
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(defaultSelectedIds);
const selectedIds = controlledSelectedIds ?? internalSelectedIds;
// Focus
const [internalFocusedId, setInternalFocusedId] = useState<string | null>(() => {
if (defaultFocusedId) return defaultFocusedId;
// Default to first focusable item based on row selection mode
// rowMultiselectable: header checkbox cell is first (Select all rows)
// rowSelectable only: first row's checkbox cell
// Otherwise: first data cell
if (rowSelectable && rowMultiselectable) {
return 'header-checkbox';
}
if (rowSelectable) {
return rows[0] ? `checkbox-${rows[0].id}` : null;
}
return rows[0]?.cells[0]?.id ?? null;
});
const focusedId = controlledFocusedId !== undefined ? controlledFocusedId : internalFocusedId;
// Edit mode
const [internalEditingCellId, setInternalEditingCellId] = useState<string | null>(null);
const editingCellId =
controlledEditingCellId !== undefined ? controlledEditingCellId : internalEditingCellId;
const [editValue, setEditValue] = useState<string>('');
const [originalEditValue, setOriginalEditValue] = useState<string>('');
const [editingColId, setEditingColId] = useState<string | null>(null);
// Combobox state
const [comboboxExpanded, setComboboxExpanded] = useState(false);
const [comboboxActiveIndex, setComboboxActiveIndex] = useState(-1);
const [filteredOptions, setFilteredOptions] = useState<string[]>([]);
// Range selection anchor
const [anchorCellId, setAnchorCellId] = useState<string | null>(null);
// Ref to track if edit is being ended (to prevent double callback)
const isEndingEditRef = useRef(false);
const gridRef = useRef<HTMLDivElement>(null);
const cellRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const headerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const inputRef = useRef<HTMLInputElement>(null);
const selectRef = useRef<HTMLSelectElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);
// ==========================================================================
// Computed values
// ==========================================================================
// Check if we have sortable headers
const hasSortableHeaders = useMemo(() => columns.some((col) => col.sortable), [columns]);
// Check if header row has focusable items (sortable headers OR header checkbox)
const hasHeaderFocusable = useMemo(
() => hasSortableHeaders || (rowSelectable && rowMultiselectable),
[hasSortableHeaders, rowSelectable, rowMultiselectable]
);
// Find the column that provides row labels (for aria-labelledby on row checkboxes)
// Priority: 1. Column with isRowLabel: true, 2. First column (fallback)
const rowLabelColumn = useMemo(() => {
const labelColumn = columns.find((col) => col.isRowLabel);
return labelColumn ?? columns[0];
}, [columns]);
// Build a flat list of focusable items (sortable headers + cells)
const focusableItems = useMemo(() => {
const items: Array<{
id: string;
type: 'header' | 'cell' | 'checkbox' | 'header-checkbox';
rowIndex: number; // -1 for header
colIndex: number;
columnId?: string;
rowId?: string;
cell?: DataGridCellData;
disabled?: boolean;
}> = [];
// Column offset when rowSelectable is enabled (checkbox column takes index 0)
const colOffset = rowSelectable ? 1 : 0;
// Header checkbox cell at row index -1, colIndex 0 (when rowMultiselectable)
if (rowSelectable && rowMultiselectable) {
items.push({
id: 'header-checkbox',
type: 'header-checkbox',
rowIndex: -1,
colIndex: 0,
});
}
// Sortable headers at row index -1
columns.forEach((col, colIndex) => {
if (col.sortable) {
items.push({
id: `header-${col.id}`,
type: 'header',
rowIndex: -1,
colIndex: colIndex + colOffset,
columnId: col.id,
});
}
});
// Checkbox cells and data cells
rows.forEach((row, rowIndex) => {
// Add checkbox cell if row selection is enabled
if (rowSelectable) {
items.push({
id: `checkbox-${row.id}`,
type: 'checkbox',
rowIndex,
colIndex: 0,
rowId: row.id,
disabled: row.disabled,
});
}
// Data cells
row.cells.forEach((cell, colIndex) => {
items.push({
id: cell.id,
type: 'cell',
rowIndex,
colIndex: colIndex + colOffset,
rowId: row.id,
columnId: columns[colIndex]?.id,
cell,
disabled: cell.disabled || row.disabled,
});
});
});
return items;
}, [columns, rows, rowSelectable, rowMultiselectable]);
// Map for quick lookup
const itemById = useMemo(() => {
const map = new Map<string, (typeof focusableItems)[0]>();
focusableItems.forEach((item) => map.set(item.id, item));
return map;
}, [focusableItems]);
// Get position of a cell/header
const getItemPosition = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return null;
return { rowIndex: item.rowIndex, colIndex: item.colIndex };
},
[itemById]
);
// Get item at position
const getItemAt = useCallback(
(rowIndex: number, colIndex: number) => {
if (rowIndex === -1) {
// Header row - find header-checkbox or sortable header at this column
return focusableItems.find(
(item) =>
(item.type === 'header' || item.type === 'header-checkbox') &&
item.rowIndex === -1 &&
item.colIndex === colIndex
);
}
// Data row - find cell or checkbox at this position
return focusableItems.find(
(item) =>
(item.type === 'cell' || item.type === 'checkbox') &&
item.rowIndex === rowIndex &&
item.colIndex === colIndex
);
},
[focusableItems]
);
const getColumnCount = useCallback(
() => columns.length + (rowSelectable ? 1 : 0),
[columns, rowSelectable]
);
const getRowCount = useCallback(() => rows.length, [rows]);
// ==========================================================================
// Focus Management
// ==========================================================================
const setFocusedId = useCallback(
(id: string | null) => {
setInternalFocusedId(id);
onFocusChange?.(id);
},
[onFocusChange]
);
const focusItem = useCallback(
(id: string) => {
const item = itemById.get(id);
if (!item) return;
if (item.type === 'header') {
const headerEl = headerRefs.current.get(item.columnId!);
if (headerEl) {
headerEl.focus();
setFocusedId(id);
}
} else if (item.type === 'header-checkbox') {
const cellEl = cellRefs.current.get(id);
if (cellEl) {
cellEl.focus();
setFocusedId(id);
}
} else {
const cellEl = cellRefs.current.get(id);
if (cellEl) {
cellEl.focus();
setFocusedId(id);
}
}
},
[itemById, setFocusedId]
);
// Find next focusable item (skipping disabled cells)
const findNextFocusable = useCallback(
(
startRowIndex: number,
startColIndex: number,
direction: 'right' | 'left' | 'up' | 'down',
skipDisabled = true
): (typeof focusableItems)[0] | 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;
} else {
return false;
}
}
break;
case 'left':
colIdx--;
if (colIdx < 0) {
if (wrapNavigation) {
colIdx = colCount - 1;
rowIdx--;
// Allow going up to header row (-1) if header has focusable items
if (rowIdx < (hasHeaderFocusable ? -1 : 0)) return false;
} else {
return false;
}
}
break;
case 'down':
rowIdx++;
if (rowIdx >= rowCount) return false;
break;
case 'up':
rowIdx--;
// Allow going up to header row (-1) if header has focusable items
if (rowIdx < (hasHeaderFocusable ? -1 : 0)) return false;
break;
}
return true;
};
// Take one step first
if (!step()) return null;
// Find non-disabled item
let iterations = 0;
const maxIterations = colCount * (rowCount + 1);
while (iterations < maxIterations) {
const item = getItemAt(rowIdx, colIdx);
if (item) {
// For header row (-1), always allow (headers are not disabled)
if (rowIdx === -1) {
return item;
}
// For data cells, check disabled state
if (!skipDisabled || !item.disabled) {
return item;
}
}
if (!step()) break;
iterations++;
}
return null;
},
[getColumnCount, getRowCount, wrapNavigation, hasHeaderFocusable, getItemAt]
);
// ==========================================================================
// Row Selection
// ==========================================================================
const setSelectedRowIds = useCallback(
(ids: string[]) => {
setInternalSelectedRowIds(ids);
onRowSelectionChange?.(ids);
},
[onRowSelectionChange]
);
const toggleRowSelection = useCallback(
(rowId: string, row: DataGridRowData) => {
if (!rowSelectable || row.disabled) return;
if (rowMultiselectable) {
const newIds = selectedRowIds.includes(rowId)
? selectedRowIds.filter((id) => id !== rowId)
: [...selectedRowIds, rowId];
setSelectedRowIds(newIds);
} else {
const newIds = selectedRowIds.includes(rowId) ? [] : [rowId];
setSelectedRowIds(newIds);
}
},
[rowSelectable, rowMultiselectable, selectedRowIds, setSelectedRowIds]
);
// Toggle all row selection
const toggleAllRowSelection = useCallback(() => {
if (!rowSelectable || !rowMultiselectable) return;
const allRowIds = rows.filter((r) => !r.disabled).map((r) => r.id);
const allSelected = allRowIds.every((id) => selectedRowIds.includes(id));
if (allSelected) {
setSelectedRowIds([]);
} else {
setSelectedRowIds(allRowIds);
}
}, [rowSelectable, rowMultiselectable, rows, selectedRowIds, setSelectedRowIds]);
// Get select all checkbox state
const getSelectAllState = useCallback((): 'all' | 'some' | 'none' => {
const allRowIds = rows.filter((r) => !r.disabled).map((r) => r.id);
if (allRowIds.length === 0) return 'none';
const selectedCount = allRowIds.filter((id) => selectedRowIds.includes(id)).length;
if (selectedCount === 0) return 'none';
if (selectedCount === allRowIds.length) return 'all';
return 'some';
}, [rows, selectedRowIds]);
// ==========================================================================
// Cell Selection
// ==========================================================================
const setSelectedIds = useCallback(
(ids: string[]) => {
setInternalSelectedIds(ids);
onSelectionChange?.(ids);
},
[onSelectionChange]
);
const toggleSelection = useCallback(
(cellId: string, cell: DataGridCellData) => {
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 = focusableItems
.filter((item) => item.type === 'cell' && !item.disabled)
.map((item) => item.id);
setSelectedIds(allIds);
}, [selectable, multiselectable, focusableItems, setSelectedIds]);
// ==========================================================================
// Range Selection
// ==========================================================================
const getCellsInRange = useCallback(
(startId: string, endId: string): string[] => {
const startItem = itemById.get(startId);
const endItem = itemById.get(endId);
if (!startItem || !endItem || startItem.type === 'header' || endItem.type === 'header') {
return [];
}
const minRow = Math.min(startItem.rowIndex, endItem.rowIndex);
const maxRow = Math.max(startItem.rowIndex, endItem.rowIndex);
const minCol = Math.min(startItem.colIndex, endItem.colIndex);
const maxCol = Math.max(startItem.colIndex, endItem.colIndex);
const cellIds: string[] = [];
for (let r = minRow; r <= maxRow; r++) {
for (let c = minCol; c <= maxCol; c++) {
const item = getItemAt(r, c);
if (item && item.type === 'cell' && !item.disabled) {
cellIds.push(item.id);
}
}
}
return cellIds;
},
[itemById, getItemAt]
);
const extendRangeSelection = useCallback(
(currentCellId: string, newFocusId: string) => {
if (!enableRangeSelection) return;
// If no anchor yet, use the current cell (before movement) as anchor
const anchor = anchorCellId ?? currentCellId;
if (!anchorCellId) {
setAnchorCellId(currentCellId);
}
const cellIds = getCellsInRange(anchor, newFocusId);
onRangeSelect?.(cellIds);
},
[enableRangeSelection, anchorCellId, getCellsInRange, onRangeSelect]
);
// ==========================================================================
// Sorting
// ==========================================================================
const handleSort = useCallback(
(columnId: string) => {
const column = columns.find((col) => col.id === columnId);
if (!column?.sortable || !onSort) return;
const nextDirection = getNextSortDirection(column.sortDirection);
onSort(columnId, nextDirection);
},
[columns, onSort]
);
// ==========================================================================
// Cell Editing
// ==========================================================================
// Helper to check if a cell is editable (cell-level or column-level)
const isCellEditable = useCallback(
(cell: DataGridCellData, colId: string) => {
if (cell.readonly) return false;
// Cell-level editable takes priority
if (cell.editable !== undefined) return cell.editable;
// Column-level editable
const column = columns.find((col) => col.id === colId);
return column?.editable ?? false;
},
[columns]
);
// Helper to get column's editType
const getColumnEditType = useCallback(
(colId: string): EditType => {
const column = columns.find((col) => col.id === colId);
return column?.editType ?? 'text';
},
[columns]
);
// Helper to get column's options
const getColumnOptions = useCallback(
(colId: string): string[] => {
const column = columns.find((col) => col.id === colId);
return column?.options ?? [];
},
[columns]
);
const startEdit = useCallback(
(cellId: string, rowId: string, colId: string) => {
if (!editable || readonly) return;
const item = itemById.get(cellId);
if (!item || item.type === 'header' || !item.cell) return;
// Check if cell is editable (cell-level or column-level)
if (!isCellEditable(item.cell, colId)) return;
const value = String(item.cell.value);
setOriginalEditValue(value);
setEditValue(value);
setEditingColId(colId);
setInternalEditingCellId(cellId);
// Initialize combobox state if editType is combobox
const editType = getColumnEditType(colId);
if (editType === 'combobox') {
const options = getColumnOptions(colId);
setFilteredOptions(options);
setComboboxExpanded(true);
setComboboxActiveIndex(-1);
}
onEditStart?.(cellId, rowId, colId);
},
[editable, readonly, itemById, isCellEditable, getColumnEditType, getColumnOptions, onEditStart]
);
const endEdit = useCallback(
(cellId: string, cancelled: boolean, explicitValue?: string) => {
// Guard: prevent double callback using ref
if (isEndingEditRef.current) return;
// Guard: only end edit if we're currently editing this cell
if (internalEditingCellId !== cellId) return;
isEndingEditRef.current = true;
// Use explicit value if provided (for combobox/select option clicks),
// otherwise fall back to current editValue state
const finalValue = cancelled ? originalEditValue : (explicitValue ?? editValue);
setInternalEditingCellId(null);
setEditingColId(null);
setComboboxExpanded(false);
setComboboxActiveIndex(-1);
onEditEnd?.(cellId, finalValue, cancelled);
// Focus back to cell
const cellEl = cellRefs.current.get(cellId);
if (cellEl) {
cellEl.focus();
}
// Reset the flag after the current event loop
setTimeout(() => {
isEndingEditRef.current = false;
}, 0);
},
[editValue, originalEditValue, onEditEnd, internalEditingCellId]
);
// ==========================================================================
// Keyboard Handling - Header
// ==========================================================================
const handleHeaderKeyDown = useCallback(
(event: React.KeyboardEvent, column: DataGridColumnDef) => {
const pos = getItemPosition(`header-${column.id}`);
if (!pos) return;
const { colIndex } = pos;
const { key, ctrlKey } = event;
let handled = true;
switch (key) {
case 'ArrowRight': {
// colIndex includes colOffset, so we need to adjust for columns array access
const colOffset = rowSelectable ? 1 : 0;
// Find next sortable header or wrap to data if none
let nextColIdx = colIndex - colOffset + 1;
while (nextColIdx < columns.length) {
if (columns[nextColIdx].sortable) {
focusItem(`header-${columns[nextColIdx].id}`);
return (event.preventDefault(), event.stopPropagation());
}
nextColIdx++;
}
// No more sortable headers to the right, stay at current
handled = false;
break;
}
case 'ArrowLeft': {
// colIndex includes colOffset, so we need to adjust for columns array access
const colOffset = rowSelectable ? 1 : 0;
let prevColIdx = colIndex - colOffset - 1;
while (prevColIdx >= 0) {
if (columns[prevColIdx].sortable) {
focusItem(`header-${columns[prevColIdx].id}`);
return (event.preventDefault(), event.stopPropagation());
}
prevColIdx--;
}
// No more sortable headers to the left, try header checkbox
if (rowMultiselectable) {
focusItem('header-checkbox');
break;
}
handled = false;
break;
}
case 'ArrowDown': {
// Move to first data row, same column
// colIndex includes colOffset, but rows[].cells[] doesn't include checkbox column
const colOffset = rowSelectable ? 1 : 0;
const cellColIndex = colIndex - colOffset;
const firstRowCell = rows[0]?.cells[cellColIndex];
if (firstRowCell) {
focusItem(firstRowCell.id);
}
break;
}
case 'Home': {
if (ctrlKey) {
// Ctrl+Home: Go to first sortable header or first cell
const firstSortable = columns.find((col) => col.sortable);
if (firstSortable) {
focusItem(`header-${firstSortable.id}`);
} else {
const firstCell = rows[0]?.cells[0];
if (firstCell) focusItem(firstCell.id);
}
} else {
// Home: First sortable header in row
const firstSortable = columns.find((col) => col.sortable);
if (firstSortable) {
focusItem(`header-${firstSortable.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) focusItem(lastCell.id);
} else {
// End: Last sortable header in row
const lastSortable = [...columns].reverse().find((col) => col.sortable);
if (lastSortable) {
focusItem(`header-${lastSortable.id}`);
}
}
break;
}
case 'Enter':
case ' ': {
if (column.sortable) {
handleSort(column.id);
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[columns, rows, getItemPosition, focusItem, handleSort, rowMultiselectable]
);
// ==========================================================================
// Keyboard Handling - Header Checkbox Cell
// ==========================================================================
const handleHeaderCheckboxKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const { key, ctrlKey } = event;
let handled = true;
switch (key) {
case 'ArrowRight': {
// Move to first sortable header if exists
const firstSortable = columns.find((col) => col.sortable);
if (firstSortable) {
focusItem(`header-${firstSortable.id}`);
}
break;
}
case 'ArrowLeft': {
// Already at leftmost position
handled = false;
break;
}
case 'ArrowDown': {
// Move to first data row checkbox
if (rows[0]) {
focusItem(`checkbox-${rows[0].id}`);
}
break;
}
case 'ArrowUp': {
// Already at top row
handled = false;
break;
}
case 'Home': {
// Already at home position for header row
if (ctrlKey) {
// Stay at current position (first cell in grid)
}
break;
}
case 'End': {
if (ctrlKey) {
// Go to last cell in grid
const lastRow = rows[rows.length - 1];
const lastCell = lastRow?.cells[lastRow.cells.length - 1];
if (lastCell) focusItem(lastCell.id);
} else {
// Go to last sortable header or stay
const lastSortable = [...columns].reverse().find((col) => col.sortable);
if (lastSortable) {
focusItem(`header-${lastSortable.id}`);
}
}
break;
}
case ' ':
case 'Enter': {
toggleAllRowSelection();
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[columns, rows, focusItem, toggleAllRowSelection]
);
// ==========================================================================
// Keyboard Handling - Cell
// ==========================================================================
const handleCellKeyDown = useCallback(
(event: React.KeyboardEvent, cell: DataGridCellData, rowId: string, colId: string) => {
// If in edit mode, handle differently
if (editingCellId === cell.id) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
endEdit(cell.id, true);
}
// Let other keys work normally in input
return;
}
const pos = getItemPosition(cell.id);
if (!pos) return;
const { rowIndex, colIndex } = pos;
const { key, ctrlKey, shiftKey } = event;
let handled = true;
switch (key) {
case 'ArrowRight': {
if (shiftKey && enableRangeSelection) {
const next = findNextFocusable(rowIndex, colIndex, 'right');
if (next) {
focusItem(next.id);
extendRangeSelection(cell.id, next.id);
}
} else {
const next = findNextFocusable(rowIndex, colIndex, 'right');
if (next) {
focusItem(next.id);
setAnchorCellId(null);
}
}
break;
}
case 'ArrowLeft': {
if (shiftKey && enableRangeSelection) {
const next = findNextFocusable(rowIndex, colIndex, 'left');
if (next) {
focusItem(next.id);
extendRangeSelection(cell.id, next.id);
}
} else {
const next = findNextFocusable(rowIndex, colIndex, 'left');
if (next) {
focusItem(next.id);
setAnchorCellId(null);
}
}
break;
}
case 'ArrowDown': {
if (shiftKey && enableRangeSelection) {
const next = findNextFocusable(rowIndex, colIndex, 'down');
if (next) {
focusItem(next.id);
extendRangeSelection(cell.id, next.id);
}
} else {
const next = findNextFocusable(rowIndex, colIndex, 'down');
if (next) {
focusItem(next.id);
setAnchorCellId(null);
}
}
break;
}
case 'ArrowUp': {
if (shiftKey && enableRangeSelection) {
const next = findNextFocusable(rowIndex, colIndex, 'up');
if (next) {
focusItem(next.id);
extendRangeSelection(cell.id, next.id);
}
} else {
const next = findNextFocusable(rowIndex, colIndex, 'up');
if (next) {
focusItem(next.id);
setAnchorCellId(null);
}
}
break;
}
case 'Home': {
if (ctrlKey && shiftKey && enableRangeSelection) {
// Ctrl+Shift+Home: extend selection to grid start
const firstCell = rows[0]?.cells[0];
if (firstCell) {
focusItem(firstCell.id);
extendRangeSelection(cell.id, firstCell.id);
}
} else if (ctrlKey) {
// Ctrl+Home: Go to first cell in grid
const firstCell = rows[0]?.cells[0];
if (firstCell) {
focusItem(firstCell.id);
setAnchorCellId(null);
}
} else if (shiftKey && enableRangeSelection) {
// Shift+Home: extend selection to row start
const firstCellInRow = rows[rowIndex]?.cells[0];
if (firstCellInRow) {
focusItem(firstCellInRow.id);
extendRangeSelection(cell.id, firstCellInRow.id);
}
} else {
// Home: Go to first cell in row
const firstCellInRow = rows[rowIndex]?.cells[0];
if (firstCellInRow) {
focusItem(firstCellInRow.id);
setAnchorCellId(null);
}
}
break;
}
case 'End': {
const currentRow = rows[rowIndex];
const lastRow = rows[rows.length - 1];
if (ctrlKey && shiftKey && enableRangeSelection) {
// Ctrl+Shift+End: extend selection to grid end
const lastCell = lastRow?.cells[lastRow.cells.length - 1];
if (lastCell) {
focusItem(lastCell.id);
extendRangeSelection(cell.id, lastCell.id);
}
} else if (ctrlKey) {
// Ctrl+End: Go to last cell in grid
const lastCell = lastRow?.cells[lastRow.cells.length - 1];
if (lastCell) {
focusItem(lastCell.id);
setAnchorCellId(null);
}
} else if (shiftKey && enableRangeSelection) {
// Shift+End: extend selection to row end
const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
if (lastCellInRow) {
focusItem(lastCellInRow.id);
extendRangeSelection(cell.id, lastCellInRow.id);
}
} else {
// End: Go to last cell in row
const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
if (lastCellInRow) {
focusItem(lastCellInRow.id);
setAnchorCellId(null);
}
}
break;
}
case 'PageDown': {
if (enablePageNavigation) {
const targetRowIndex = Math.min(rowIndex + pageSize, rows.length - 1);
const targetCell = rows[targetRowIndex]?.cells[colIndex];
if (targetCell) {
focusItem(targetCell.id);
setAnchorCellId(null);
}
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (enablePageNavigation) {
const targetRowIndex = Math.max(rowIndex - pageSize, 0);
const targetCell = rows[targetRowIndex]?.cells[colIndex];
if (targetCell) {
focusItem(targetCell.id);
setAnchorCellId(null);
}
} else {
handled = false;
}
break;
}
case ' ': {
if (selectable) {
toggleSelection(cell.id, cell);
}
break;
}
case 'Enter': {
if (editable && isCellEditable(cell, colId) && !cell.disabled) {
startEdit(cell.id, rowId, colId);
} else if (!cell.disabled) {
onCellActivate?.(cell.id, rowId, colId);
}
break;
}
case 'F2': {
if (editable && isCellEditable(cell, colId) && !cell.disabled) {
startEdit(cell.id, rowId, colId);
}
break;
}
case 'a': {
if (ctrlKey) {
selectAll();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[
editingCellId,
endEdit,
getItemPosition,
findNextFocusable,
focusItem,
enableRangeSelection,
extendRangeSelection,
rows,
enablePageNavigation,
pageSize,
selectable,
toggleSelection,
editable,
isCellEditable,
startEdit,
onCellActivate,
selectAll,
]
);
// ==========================================================================
// Keyboard Handling - Checkbox Cell
// ==========================================================================
const handleCheckboxCellKeyDown = useCallback(
(event: React.KeyboardEvent, rowId: string, row: DataGridRowData) => {
const checkboxCellId = `checkbox-${rowId}`;
const pos = getItemPosition(checkboxCellId);
if (!pos) return;
const { rowIndex, colIndex } = pos;
const { key, ctrlKey } = event;
let handled = true;
switch (key) {
case 'ArrowRight': {
const next = findNextFocusable(rowIndex, colIndex, 'right');
if (next) {
focusItem(next.id);
}
break;
}
case 'ArrowLeft': {
const next = findNextFocusable(rowIndex, colIndex, 'left');
if (next) {
focusItem(next.id);
}
break;
}
case 'ArrowDown': {
const next = findNextFocusable(rowIndex, colIndex, 'down');
if (next) {
focusItem(next.id);
}
break;
}
case 'ArrowUp': {
const next = findNextFocusable(rowIndex, colIndex, 'up');
if (next) {
focusItem(next.id);
}
break;
}
case 'Home': {
if (ctrlKey) {
// Ctrl+Home: Go to first cell in grid (first checkbox cell)
const firstCheckboxId = `checkbox-${rows[0]?.id}`;
if (firstCheckboxId) {
focusItem(firstCheckboxId);
}
} else {
// Home: Stay on checkbox (it's the first cell in the row)
// Do nothing, already at home position
}
break;
}
case 'End': {
const currentRow = rows[rowIndex];
const lastRow = rows[rows.length - 1];
if (ctrlKey) {
// Ctrl+End: Go to last cell in grid
const lastCell = lastRow?.cells[lastRow.cells.length - 1];
if (lastCell) {
focusItem(lastCell.id);
}
} else {
// End: Go to last cell in row
const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
if (lastCellInRow) {
focusItem(lastCellInRow.id);
}
}
break;
}
case ' ':
case 'Enter': {
// Toggle row selection
if (!row.disabled) {
toggleRowSelection(rowId, row);
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
},
[getItemPosition, findNextFocusable, focusItem, rows, toggleRowSelection]
);
// ==========================================================================
// Effects
// ==========================================================================
// Focus input/select when entering edit mode
useEffect(() => {
if (editingCellId && editingColId) {
const editType = getColumnEditType(editingColId);
if (editType === 'select' && selectRef.current) {
selectRef.current.focus();
} else if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}
}, [editingCellId, editingColId, getColumnEditType]);
// Focus the focused cell when focusedId changes externally
useEffect(() => {
if (focusedId && !editingCellId) {
const item = itemById.get(focusedId);
if (item) {
if (item.type === 'header') {
const headerEl = headerRefs.current.get(item.columnId!);
if (headerEl && document.activeElement !== headerEl) {
if (gridRef.current?.contains(document.activeElement)) {
headerEl.focus();
}
}
} else {
const cellEl = cellRefs.current.get(focusedId);
if (cellEl && document.activeElement !== cellEl) {
if (gridRef.current?.contains(document.activeElement)) {
cellEl.focus();
}
}
}
}
}
}, [focusedId, editingCellId, itemById]);
// ==========================================================================
// Render
// ==========================================================================
// Determine aria-multiselectable
const showMultiselectable = rowMultiselectable || multiselectable;
// CSS variable for grid column count
const gridStyle: Record<string, string | number> = {
'--apg-data-grid-columns': columns.length,
};
return (
<div
ref={gridRef}
role="grid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={showMultiselectable ? 'true' : undefined}
aria-readonly={readonly ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns}
className={`apg-data-grid ${className ?? ''}`}
style={gridStyle}
>
{/* Header Row */}
<div role="row" aria-rowindex={totalRows ? 1 : undefined}>
{rowSelectable &&
(() => {
const isHeaderCheckboxFocused = focusedId === 'header-checkbox';
return (
<div
ref={(el) => {
if (el) {
cellRefs.current.set('header-checkbox', el);
} else {
cellRefs.current.delete('header-checkbox');
}
}}
role="columnheader"
tabIndex={rowMultiselectable ? (isHeaderCheckboxFocused ? 0 : -1) : undefined}
aria-colindex={totalColumns ? startColIndex : undefined}
className={`apg-data-grid-header apg-data-grid-checkbox-cell ${isHeaderCheckboxFocused ? 'focused' : ''}`}
onKeyDown={rowMultiselectable ? handleHeaderCheckboxKeyDown : undefined}
onFocus={() => rowMultiselectable && setFocusedId('header-checkbox')}
>
{rowMultiselectable && (
<input
type="checkbox"
tabIndex={-1}
checked={getSelectAllState() === 'all'}
ref={(el) => {
if (el) {
el.indeterminate = getSelectAllState() === 'some';
}
}}
aria-label="Select all rows"
onChange={(e) => {
e.stopPropagation();
toggleAllRowSelection();
}}
/>
)}
</div>
);
})()}
{columns.map((col, colIndex) => {
const isSortable = col.sortable;
const headerId = `header-${col.id}`;
const isFocused = focusedId === headerId;
return (
<div
key={col.id}
ref={(el) => {
if (el) {
headerRefs.current.set(col.id, el);
} else {
headerRefs.current.delete(col.id);
}
}}
role="columnheader"
tabIndex={isSortable ? (isFocused ? 0 : -1) : undefined}
aria-colindex={
totalColumns ? startColIndex + colIndex + (rowSelectable ? 1 : 0) : undefined
}
aria-colspan={col.colspan}
aria-sort={isSortable ? col.sortDirection || 'none' : undefined}
onKeyDown={(e) => isSortable && handleHeaderKeyDown(e, col)}
onFocus={() => isSortable && setFocusedId(headerId)}
onClick={() => isSortable && handleSort(col.id)}
className={`apg-data-grid-header ${isSortable ? 'sortable' : ''} ${isFocused ? 'focused' : ''}`}
>
{col.header}
{isSortable && (
<span
aria-hidden="true"
className={`sort-indicator ${!col.sortDirection || col.sortDirection === 'none' ? 'unsorted' : ''}`}
>
{getSortIndicator(col.sortDirection)}
</span>
)}
</div>
);
})}
</div>
{/* Data Rows */}
{rows.map((row, rowIndex) => {
const isRowSelected = selectedRowIds.includes(row.id);
const isRowDisabled = row.disabled;
return (
<div
key={row.id}
role="row"
aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
aria-selected={rowSelectable ? (isRowSelected ? 'true' : 'false') : undefined}
aria-disabled={isRowDisabled ? 'true' : undefined}
>
{/* Row selection checkbox */}
{rowSelectable &&
(() => {
const checkboxCellId = `checkbox-${row.id}`;
const isCheckboxFocused = focusedId === checkboxCellId;
return (
<div
ref={(el) => {
if (el) {
cellRefs.current.set(checkboxCellId, el);
} else {
cellRefs.current.delete(checkboxCellId);
}
}}
role="gridcell"
tabIndex={isCheckboxFocused ? 0 : -1}
aria-colindex={totalColumns ? startColIndex : undefined}
className={`apg-data-grid-cell apg-data-grid-checkbox-cell ${isCheckboxFocused ? 'focused' : ''}`}
onKeyDown={(e) => handleCheckboxCellKeyDown(e, row.id, row)}
onFocus={() => setFocusedId(checkboxCellId)}
>
<input
type="checkbox"
tabIndex={-1}
checked={isRowSelected}
disabled={isRowDisabled}
aria-labelledby={
rowLabelColumn ? `cell-${row.id}-${rowLabelColumn.id}` : undefined
}
onChange={(e) => {
e.stopPropagation();
toggleRowSelection(row.id, row);
}}
/>
</div>
);
})()}
{/* Data cells */}
{row.cells.map((cell, colIndex) => {
const isRowHeader = row.hasRowHeader && colIndex === 0;
const cellId = cell.id;
const isFocused = focusedId === cellId;
const isCellSelected = selectedIds.includes(cellId);
const colId = columns[colIndex]?.id ?? '';
const isDisabled = cell.disabled || isRowDisabled;
const isEditing = editingCellId === cellId;
const cellIsEditable = editable && isCellEditable(cell, colId) && !isDisabled;
const editType = getColumnEditType(colId);
const columnOptions = getColumnOptions(colId);
// Determine aria-readonly for this cell
// APG: In editable grids, non-editable cells should have aria-readonly="true"
const getAriaReadonly = (): 'true' | 'false' | undefined => {
if (!editable) return undefined;
if (cell.readonly === true) return 'true';
if (cellIsEditable) return 'false';
return 'true'; // Non-editable cell in editable grid
};
const showAriaReadonly = getAriaReadonly();
// Generate id for label column cell to be referenced by row checkbox aria-labelledby
const isLabelColumn = rowLabelColumn && colId === rowLabelColumn.id;
const labelCellId = isLabelColumn ? `cell-${row.id}-${colId}` : undefined;
// Unique IDs for combobox ARIA
const comboboxListId = `${cellId}-listbox`;
// Render edit content based on editType
const renderEditContent = () => {
if (editType === 'select') {
return (
<select
ref={selectRef}
value={editValue}
onChange={(e) => {
const newValue = e.target.value;
setEditValue(newValue);
onCellValueChange?.(cellId, newValue);
// End edit immediately with explicit value
endEdit(cellId, false, newValue);
}}
onBlur={() => endEdit(cellId, false)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
endEdit(cellId, true);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
endEdit(cellId, false);
}
}}
className="apg-data-grid-select"
>
{columnOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
}
if (editType === 'combobox') {
return (
<div className="apg-data-grid-combobox">
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={comboboxExpanded}
aria-controls={comboboxListId}
aria-autocomplete="list"
aria-activedescendant={
comboboxActiveIndex >= 0
? `${cellId}-option-${comboboxActiveIndex}`
: undefined
}
value={editValue}
onChange={(e) => {
const newValue = e.target.value;
setEditValue(newValue);
onCellValueChange?.(cellId, newValue);
// Filter options based on input
const filtered = columnOptions.filter((opt) =>
opt.toLowerCase().includes(newValue.toLowerCase())
);
setFilteredOptions(filtered);
setComboboxExpanded(true);
setComboboxActiveIndex(-1);
}}
onBlur={(e) => {
// Check if focus is moving to listbox
if (
e.relatedTarget instanceof Node &&
listboxRef.current?.contains(e.relatedTarget)
) {
return;
}
setComboboxExpanded(false);
endEdit(cellId, false);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setComboboxExpanded(false);
endEdit(cellId, true);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
const selectedOption =
comboboxActiveIndex >= 0
? filteredOptions[comboboxActiveIndex]
: undefined;
if (selectedOption) {
setEditValue(selectedOption);
onCellValueChange?.(cellId, selectedOption);
}
setComboboxExpanded(false);
endEdit(cellId, false, selectedOption);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (!comboboxExpanded) {
setComboboxExpanded(true);
} else {
setComboboxActiveIndex((prev) =>
Math.min(prev + 1, filteredOptions.length - 1)
);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setComboboxActiveIndex((prev) => Math.max(prev - 1, -1));
}
}}
className="apg-data-grid-input"
/>
{comboboxExpanded && filteredOptions.length > 0 && (
<ul
ref={listboxRef}
id={comboboxListId}
role="listbox"
className="apg-data-grid-listbox"
>
{filteredOptions.map((option, index) => (
<li
key={option}
id={`${cellId}-option-${index}`}
role="option"
aria-selected={index === comboboxActiveIndex}
className={`apg-data-grid-option ${index === comboboxActiveIndex ? 'active' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
setEditValue(option);
onCellValueChange?.(cellId, option);
setComboboxExpanded(false);
endEdit(cellId, false, option);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
// Default: text input
return (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => {
setEditValue(e.target.value);
onCellValueChange?.(cellId, e.target.value);
}}
onBlur={() => endEdit(cellId, false)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
endEdit(cellId, true);
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
endEdit(cellId, false);
}
}}
className="apg-data-grid-input"
/>
);
};
return (
<div
key={cellId}
id={labelCellId}
ref={(el) => {
if (el) {
cellRefs.current.set(cellId, el);
} else {
cellRefs.current.delete(cellId);
}
}}
role={isRowHeader ? 'rowheader' : 'gridcell'}
tabIndex={isFocused && !isEditing ? 0 : -1}
aria-selected={
selectable && !rowSelectable ? (isCellSelected ? 'true' : 'false') : undefined
}
aria-disabled={isDisabled ? 'true' : undefined}
aria-colindex={
totalColumns ? startColIndex + colIndex + (rowSelectable ? 1 : 0) : undefined
}
aria-colspan={cell.colspan}
aria-rowspan={cell.rowspan}
aria-readonly={showAriaReadonly}
onKeyDown={(e) => handleCellKeyDown(e, cell, row.id, colId)}
onFocus={() => !isEditing && setFocusedId(cellId)}
onDoubleClick={() => cellIsEditable && startEdit(cellId, row.id, colId)}
className={`apg-data-grid-cell ${isFocused ? 'focused' : ''} ${isCellSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''} ${isEditing ? 'editing' : ''} ${cellIsEditable && !isEditing ? 'editable' : ''}`}
>
{(() => {
if (isEditing) return renderEditContent();
if (renderCell) return renderCell(cell, row.id, colId);
return cell.value;
})()}
</div>
);
})}
</div>
);
})}
</div>
);
}
export default DataGrid; Usage
import { DataGrid } from './DataGrid';
import type { DataGridColumnDef, DataGridRowData, SortDirection } from './DataGrid';
const columns: DataGridColumnDef[] = [
{ id: 'name', header: 'Name', sortable: true },
{ id: 'email', header: 'Email', sortable: true },
{ id: 'role', header: 'Role', sortable: true },
];
const rows: DataGridRowData[] = [
{
id: 'user1',
cells: [
{ id: 'user1-name', value: 'Alice Johnson', editable: true },
{ id: 'user1-email', value: 'alice@example.com', editable: true },
{ id: 'user1-role', value: 'Admin' },
],
},
{
id: 'user2',
cells: [
{ id: 'user2-name', value: 'Bob Smith', editable: true },
{ id: 'user2-email', value: 'bob@example.com', editable: true },
{ id: 'user2-role', value: 'User' },
],
},
];
// Basic Data Grid with sorting
<DataGrid
columns={columns}
rows={rows}
ariaLabel="User list"
onSort={(columnId, direction) => handleSort(columnId, direction)}
/>
// With row selection
<DataGrid
columns={columns}
rows={rows}
ariaLabel="User list"
rowSelectable
rowMultiselectable
selectedRowIds={selectedRowIds}
onRowSelectionChange={(ids) => setSelectedRowIds(ids)}
/>
// With range selection and editing
<DataGrid
columns={columns}
rows={rows}
ariaLabel="User list"
enableRangeSelection
editable
onEditEnd={(cellId, value, cancelled) => {
if (!cancelled) updateCell(cellId, value);
}}
/> API
DataGrid Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | DataGridColumnDef[] | required | Column definitions |
rows | DataGridRowData[] | required | Row data |
ariaLabel | string | - | Accessible name for grid |
rowSelectable | boolean | false | Enable row selection with checkboxes |
rowMultiselectable | boolean | false | Enable multiple row selection |
selectedRowIds | string[] | [] | Selected row IDs |
onRowSelectionChange | (ids: string[]) => void | - | Row selection change callback |
onSort | (columnId, direction) => void | - | Sort callback |
enableRangeSelection | boolean | false | Enable Shift+Arrow range selection |
editable | boolean | false | Enable cell editing |
onEditEnd | (cellId, value, cancelled) => void | - | Edit end callback |
Type Definitions
type SortDirection = 'ascending' | 'descending' | 'none' | 'other';
interface DataGridColumnDef {
id: string;
header: string;
sortable?: boolean;
sortDirection?: SortDirection;
}
interface DataGridCellData {
id: string;
value: string | number;
disabled?: boolean;
editable?: boolean;
readonly?: boolean;
}
interface DataGridRowData {
id: string;
cells: DataGridCellData[];
hasRowHeader?: boolean;
disabled?: boolean;
} Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Data Grid component extends the basic Grid testing strategy with additional tests for sorting, row selection, range selection, and cell editing.
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, aria-sort)
- Selection state changes (aria-selected on rows and cells)
- Edit mode state (aria-readonly)
- Sort direction updates (aria-sort)
- 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)
- Header navigation and sorting
- Range selection with Shift+Arrow
- Cell editing workflow (Enter, F2, Escape)
- Row selection with checkboxes
- Focus management between headers and cells
- 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 |
aria-sort | Sortable headers have aria-sort |
aria-sort updates | aria-sort updates on sort action |
aria-selected on rows | Rows have aria-selected when rowSelectable |
aria-readonly on grid | Grid has aria-readonly when readonly prop |
aria-readonly on cells | Cells have aria-readonly based on editability |
aria-multiselectable | Present when row or cell multi-select enabled |
High Priority: Sorting
| Test | Description |
|---|---|
Enter on header | Enter on sortable header triggers sort |
Space on header | Space on sortable header triggers sort |
Sort cycle | Sort cycles: none → ascending → descending → ascending |
Non-sortable headers | Non-sortable headers do not respond to Enter/Space |
High Priority: Range Selection
| Test | Description |
|---|---|
Shift+ArrowDown | Extends selection downward |
Shift+ArrowUp | Extends selection upward |
Shift+Home | Extends selection to row start |
Shift+End | Extends selection to row end |
Ctrl+Shift+Home | Extends selection to grid start |
Ctrl+Shift+End | Extends selection to grid end |
Selection anchor | Selection anchor is set on first selection |
High Priority: Row Selection
| Test | Description |
|---|---|
Checkbox toggle | Checkbox click toggles row selection |
aria-selected | aria-selected updates on row element |
Callback fires | onRowSelectionChange callback fires |
Select all | Select all checkbox selects/deselects all rows |
Indeterminate | Select all shows indeterminate when some selected |
High Priority: Cell Editing
| Test | Description |
|---|---|
Enter starts edit | Enter on editable cell enters edit mode |
F2 starts edit | F2 on editable cell enters edit mode |
Escape cancels | Escape cancels edit and restores original value |
Navigation disabled | Grid navigation disabled during edit mode |
Focus on input | Focus moves to input field on edit start |
Focus returns | Focus returns to cell on edit end |
onEditStart | onEditStart callback fires when entering edit mode |
onEditEnd | onEditEnd callback fires when exiting edit mode |
Readonly cell | Readonly cell does not enter edit mode |
High Priority: Focus Management
| Test | Description |
|---|---|
Sortable headers focusable | Sortable headers have tabindex |
Non-sortable not focusable | Non-sortable headers have no tabindex |
First has tabindex=0 | First focusable element has tabindex="0" |
Header to data | ArrowDown from header enters first data row |
Data to header | ArrowUp from first row enters sortable header |
Roving tabindex | Roving tabindex updates correctly |
Medium Priority: Virtualization Support
| Test | Description |
|---|---|
aria-rowcount | Present when totalRows provided (1-based) |
aria-colcount | Present when totalColumns provided (1-based) |
aria-rowindex | Present on rows when virtualizing (1-based) |
aria-colindex | Present on cells when virtualizing (1-based) |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe-core | No accessibility violations |
Sort indicators | Sort indicators have accessible names |
Checkbox labels | Checkboxes have accessible labels |
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, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import {
DataGrid,
type DataGridColumnDef,
type DataGridRowData,
type SortDirection,
} from './DataGrid';
// Helper function to create sortable columns
const createSortableColumns = (): DataGridColumnDef[] => [
{ id: 'name', header: 'Name', sortable: true, sortDirection: 'none' },
{ id: 'email', header: 'Email', sortable: true, sortDirection: 'none' },
{ id: 'role', header: 'Role', sortable: false },
];
// Helper function to create basic columns (no sort)
const createBasicColumns = (): DataGridColumnDef[] => [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
// Helper function to create basic rows
const createBasicRows = (): DataGridRowData[] => [
{
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 editable cells
const createEditableRows = (): DataGridRowData[] => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice', editable: true },
{ id: 'row1-1', value: 'alice@example.com', editable: true },
{ id: 'row1-2', value: 'Admin', readonly: true },
],
},
{
id: 'row2',
cells: [
{ id: 'row2-0', value: 'Bob', editable: true },
{ id: 'row2-1', value: 'bob@example.com', editable: true },
{ id: 'row2-2', value: 'User' },
],
},
];
// Rows with disabled cells/rows
const createRowsWithDisabled = (): DataGridRowData[] => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com', disabled: true },
{ id: 'row1-2', value: 'Admin' },
],
},
{
id: 'row2',
disabled: true,
cells: [
{ id: 'row2-0', value: 'Bob' },
{ id: 'row2-1', value: 'bob@example.com' },
{ id: 'row2-2', value: 'User' },
],
},
];
// Rows with row header
const createRowsWithRowHeader = (): DataGridRowData[] => [
{
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' },
],
},
];
// Columns with span
const createColumnsWithSpan = (): DataGridColumnDef[] => [
{ id: 'info', header: 'Info', colspan: 2 },
{ id: 'role', header: 'Role' },
];
// Rows with spanned cells
const createRowsWithSpan = (): DataGridRowData[] => [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Merged', colspan: 2 },
{ id: 'row1-2', value: 'Normal' },
],
},
{
id: 'row2',
hasRowHeader: true,
cells: [
{ id: 'row2-0', value: 'Header', rowspan: 2 },
{ id: 'row2-1', value: 'A' },
{ id: 'row2-2', value: 'B' },
],
},
];
describe('DataGrid', () => {
// ========================================
// High Priority: ARIA Attributes
// ========================================
describe('ARIA Attributes', () => {
it('has role="grid" on container', () => {
render(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
expect(screen.getByRole('grid')).toBeInTheDocument();
});
it('has role="row" on all rows', () => {
render(
<DataGrid 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(
<DataGrid 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(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
});
it('has role="rowheader" when hasRowHeader is true', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createRowsWithRowHeader()}
ariaLabel="Users"
/>
);
expect(screen.getAllByRole('rowheader')).toHaveLength(2);
});
it('sortable columnheader has aria-sort', () => {
render(
<DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
expect(nameHeader).toHaveAttribute('aria-sort', 'none');
});
it('non-sortable columnheader does NOT have aria-sort', () => {
render(
<DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const roleHeader = screen.getByRole('columnheader', { name: 'Role' });
expect(roleHeader).not.toHaveAttribute('aria-sort');
});
it('aria-sort updates on sort action', async () => {
const columns: DataGridColumnDef[] = [
{ id: 'name', header: 'Name', sortable: true, sortDirection: 'none' },
];
const onSort = vi.fn((_columnId: string, direction: SortDirection) => {
columns[0].sortDirection = direction;
});
const { rerender } = render(
<DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
);
const header = screen.getByRole('columnheader', { name: 'Name' });
header.focus();
await userEvent.setup().keyboard('{Enter}');
// Rerender with updated column
rerender(
<DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
);
expect(header).toHaveAttribute('aria-sort', 'ascending');
});
it('row has aria-selected when rowSelectable', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
/>
);
const rows = screen.getAllByRole('row');
// Skip header row, check data rows
expect(rows[1]).toHaveAttribute('aria-selected', 'false');
expect(rows[2]).toHaveAttribute('aria-selected', 'false');
});
it('has accessible name via aria-label', () => {
render(
<DataGrid 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>
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabelledby="grid-title"
/>
</div>
);
const grid = screen.getByRole('grid');
expect(grid).toHaveAttribute('aria-labelledby', 'grid-title');
});
it('has aria-multiselectable="true" when rowMultiselectable', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
rowMultiselectable
/>
);
expect(screen.getByRole('grid')).toHaveAttribute('aria-multiselectable', 'true');
});
it('has aria-multiselectable="true" when cell multiselectable', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
multiselectable
/>
);
expect(screen.getByRole('grid')).toHaveAttribute('aria-multiselectable', 'true');
});
it('has aria-readonly="true" when readonly prop is true', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
editable
readonly
/>
);
expect(screen.getByRole('grid')).toHaveAttribute('aria-readonly', 'true');
});
it('editable cells have aria-readonly="false" or omitted', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
const ariaReadonly = editableCell.getAttribute('aria-readonly');
expect(ariaReadonly === null || ariaReadonly === 'false').toBe(true);
});
it('readonly cells have aria-readonly="true"', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
/>
);
const readonlyCell = screen.getByRole('gridcell', { name: 'Admin' });
expect(readonlyCell).toHaveAttribute('aria-readonly', 'true');
});
it('has aria-rowcount/aria-colcount when virtualizing', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
totalRows={100}
totalColumns={10}
/>
);
const grid = screen.getByRole('grid');
expect(grid).toHaveAttribute('aria-rowcount', '100');
expect(grid).toHaveAttribute('aria-colcount', '10');
});
it('has aria-rowindex on rows when virtualizing (1-based, header row = 1)', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
totalRows={100}
startRowIndex={10}
/>
);
const rows = screen.getAllByRole('row');
// Header row should have aria-rowindex="1"
expect(rows[0]).toHaveAttribute('aria-rowindex', '1');
// Data rows start at startRowIndex
expect(rows[1]).toHaveAttribute('aria-rowindex', '10');
expect(rows[2]).toHaveAttribute('aria-rowindex', '11');
});
it('has aria-colindex on cells/headers when virtualizing', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
totalColumns={10}
startColIndex={5}
/>
);
const headers = screen.getAllByRole('columnheader');
expect(headers[0]).toHaveAttribute('aria-colindex', '5');
expect(headers[1]).toHaveAttribute('aria-colindex', '6');
const cells = screen.getAllByRole('gridcell').slice(0, 3);
expect(cells[0]).toHaveAttribute('aria-colindex', '5');
expect(cells[1]).toHaveAttribute('aria-colindex', '6');
});
it('has aria-disabled="true" on disabled rows/cells', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createRowsWithDisabled()}
ariaLabel="Users"
/>
);
const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
expect(disabledCell).toHaveAttribute('aria-disabled', 'true');
// Disabled row should have aria-disabled on its cells
const disabledRow = screen.getAllByRole('row')[2];
expect(disabledRow).toHaveAttribute('aria-disabled', 'true');
});
it('has aria-colspan on gridcells with colspan > 1', () => {
render(
<DataGrid columns={createBasicColumns()} rows={createRowsWithSpan()} ariaLabel="Users" />
);
const mergedCell = screen.getByRole('gridcell', { name: 'Merged' });
expect(mergedCell).toHaveAttribute('aria-colspan', '2');
});
it('has aria-colspan on columnheaders with colspan > 1', () => {
render(
<DataGrid columns={createColumnsWithSpan()} rows={createBasicRows()} ariaLabel="Users" />
);
const infoHeader = screen.getByRole('columnheader', { name: 'Info' });
expect(infoHeader).toHaveAttribute('aria-colspan', '2');
});
it('has aria-rowspan on gridcells/rowheaders with rowspan > 1', () => {
render(
<DataGrid columns={createBasicColumns()} rows={createRowsWithSpan()} ariaLabel="Users" />
);
const spannedCell = screen.getByRole('rowheader', { name: 'Header' });
expect(spannedCell).toHaveAttribute('aria-rowspan', '2');
});
});
// ========================================
// High Priority: Keyboard - Base Navigation
// ========================================
describe('Keyboard - Base Navigation', () => {
it('ArrowRight moves focus one cell right', async () => {
const user = userEvent.setup();
render(
<DataGrid 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(
<DataGrid 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(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
});
it('ArrowUp moves focus one row up', async () => {
const user = userEvent.setup();
render(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const secondRowFirstCell = screen.getAllByRole('gridcell')[3];
secondRowFirstCell.focus();
await user.keyboard('{ArrowUp}');
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
it('ArrowRight stops at row end (no wrap by default)', async () => {
const user = userEvent.setup();
render(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const lastCellInRow = screen.getAllByRole('gridcell')[2];
lastCellInRow.focus();
await user.keyboard('{ArrowRight}');
expect(lastCellInRow).toHaveFocus();
});
it('ArrowUp from first data row enters sortable header', async () => {
const user = userEvent.setup();
render(
<DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const firstDataCell = screen.getAllByRole('gridcell')[0];
firstDataCell.focus();
await user.keyboard('{ArrowUp}');
// Should move to sortable header
const sortableHeader = screen.getByRole('columnheader', { name: 'Name' });
expect(sortableHeader).toHaveFocus();
});
it('ArrowDown from header enters first data row', async () => {
const user = userEvent.setup();
render(
<DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const sortableHeader = screen.getByRole('columnheader', { name: 'Name' });
sortableHeader.focus();
await user.keyboard('{ArrowDown}');
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
it('Home moves to first cell in row', async () => {
const user = userEvent.setup();
render(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const lastCellInRow = screen.getAllByRole('gridcell')[2];
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(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const firstCell = screen.getAllByRole('gridcell')[0];
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(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const lastCell = screen.getAllByRole('gridcell')[8];
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(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{Control>}{End}{/Control}');
expect(screen.getAllByRole('gridcell')[8]).toHaveFocus();
});
it('PageDown moves down by pageSize (when enabled)', async () => {
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enablePageNavigation
pageSize={2}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{PageDown}');
expect(screen.getAllByRole('gridcell')[6]).toHaveFocus();
});
it('PageUp moves up by pageSize (when enabled)', async () => {
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enablePageNavigation
pageSize={2}
/>
);
const lastRowCell = screen.getAllByRole('gridcell')[6];
lastRowCell.focus();
await user.keyboard('{PageUp}');
expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
});
it('Tab exits grid to next focusable element', async () => {
const user = userEvent.setup();
render(
<div>
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
<button>After</button>
</div>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.tab();
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('Shift+Tab exits grid to previous focusable element', async () => {
render(
<div>
<button>Before</button>
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
</div>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
fireEvent.keyDown(firstCell, { key: 'Tab', shiftKey: true });
// Note: actual focus behavior depends on browser
});
it('navigation skips disabled cells', async () => {
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createRowsWithDisabled()}
ariaLabel="Users"
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{ArrowRight}');
// Should skip disabled cell and focus Admin
expect(screen.getByRole('gridcell', { name: 'Admin' })).toHaveFocus();
});
});
// ========================================
// High Priority: Keyboard - Sorting
// ========================================
describe('Keyboard - Sorting', () => {
it('Enter on sortable header triggers sort', async () => {
const onSort = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createSortableColumns()}
rows={createBasicRows()}
ariaLabel="Users"
onSort={onSort}
/>
);
const header = screen.getByRole('columnheader', { name: 'Name' });
header.focus();
await user.keyboard('{Enter}');
expect(onSort).toHaveBeenCalledWith('name', 'ascending');
});
it('Space on sortable header triggers sort', async () => {
const onSort = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createSortableColumns()}
rows={createBasicRows()}
ariaLabel="Users"
onSort={onSort}
/>
);
const header = screen.getByRole('columnheader', { name: 'Name' });
header.focus();
await user.keyboard(' ');
expect(onSort).toHaveBeenCalledWith('name', 'ascending');
});
it('sort cycles: none -> ascending -> descending -> ascending', async () => {
const onSort = vi.fn();
const user = userEvent.setup();
const columns: DataGridColumnDef[] = [
{ id: 'name', header: 'Name', sortable: true, sortDirection: 'none' },
];
const { rerender } = render(
<DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
);
const header = screen.getByRole('columnheader', { name: 'Name' });
header.focus();
// First: none -> ascending
await user.keyboard('{Enter}');
expect(onSort).toHaveBeenLastCalledWith('name', 'ascending');
// Update column and rerender
columns[0].sortDirection = 'ascending';
rerender(
<DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
);
// Second: ascending -> descending
await user.keyboard('{Enter}');
expect(onSort).toHaveBeenLastCalledWith('name', 'descending');
// Update column and rerender
columns[0].sortDirection = 'descending';
rerender(
<DataGrid columns={columns} rows={createBasicRows()} ariaLabel="Users" onSort={onSort} />
);
// Third: descending -> ascending (loop)
await user.keyboard('{Enter}');
expect(onSort).toHaveBeenLastCalledWith('name', 'ascending');
});
it('non-sortable headers do not respond to Enter/Space', async () => {
const onSort = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createSortableColumns()}
rows={createBasicRows()}
ariaLabel="Users"
onSort={onSort}
/>
);
// Focus on non-sortable header (Role column)
// Non-sortable headers should not be focusable, so this test verifies the behavior
const roleHeader = screen.getByRole('columnheader', { name: 'Role' });
roleHeader.focus();
await user.keyboard('{Enter}');
await user.keyboard(' ');
// onSort should not have been called for non-sortable header
expect(onSort).not.toHaveBeenCalled();
});
});
// ========================================
// High Priority: Keyboard - Range Selection
// ========================================
describe('Keyboard - Range Selection', () => {
it('Shift+ArrowDown extends selection downward', async () => {
const onRangeSelect = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enableRangeSelection
onRangeSelect={onRangeSelect}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
expect(onRangeSelect).toHaveBeenCalled();
// Should include first cell and cell below
const selectedIds = onRangeSelect.mock.calls[0][0];
expect(selectedIds).toContain('row1-0');
expect(selectedIds).toContain('row2-0');
});
it('Shift+ArrowUp extends selection upward', async () => {
const onRangeSelect = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enableRangeSelection
onRangeSelect={onRangeSelect}
/>
);
const secondRowCell = screen.getAllByRole('gridcell')[3];
secondRowCell.focus();
await user.keyboard('{Shift>}{ArrowUp}{/Shift}');
expect(onRangeSelect).toHaveBeenCalled();
const selectedIds = onRangeSelect.mock.calls[0][0];
expect(selectedIds).toContain('row1-0');
expect(selectedIds).toContain('row2-0');
});
it('Shift+Home extends selection to row start', async () => {
const onRangeSelect = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enableRangeSelection
onRangeSelect={onRangeSelect}
/>
);
const lastCellInRow = screen.getAllByRole('gridcell')[2];
lastCellInRow.focus();
await user.keyboard('{Shift>}{Home}{/Shift}');
expect(onRangeSelect).toHaveBeenCalled();
const selectedIds = onRangeSelect.mock.calls[0][0];
expect(selectedIds).toContain('row1-0');
expect(selectedIds).toContain('row1-1');
expect(selectedIds).toContain('row1-2');
});
it('Shift+End extends selection to row end', async () => {
const onRangeSelect = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enableRangeSelection
onRangeSelect={onRangeSelect}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{Shift>}{End}{/Shift}');
expect(onRangeSelect).toHaveBeenCalled();
const selectedIds = onRangeSelect.mock.calls[0][0];
expect(selectedIds).toContain('row1-0');
expect(selectedIds).toContain('row1-1');
expect(selectedIds).toContain('row1-2');
});
it('Ctrl+Shift+Home extends selection to grid start', async () => {
const onRangeSelect = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enableRangeSelection
onRangeSelect={onRangeSelect}
/>
);
const lastCell = screen.getAllByRole('gridcell')[8];
lastCell.focus();
await user.keyboard('{Control>}{Shift>}{Home}{/Shift}{/Control}');
expect(onRangeSelect).toHaveBeenCalled();
// Should include all cells from start to current
});
it('Ctrl+Shift+End extends selection to grid end', async () => {
const onRangeSelect = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enableRangeSelection
onRangeSelect={onRangeSelect}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
await user.keyboard('{Control>}{Shift>}{End}{/Shift}{/Control}');
expect(onRangeSelect).toHaveBeenCalled();
// Should include all cells from current to end
});
it('selection anchor is set on first selection', async () => {
const onRangeSelect = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
enableRangeSelection
onRangeSelect={onRangeSelect}
/>
);
const firstCell = screen.getAllByRole('gridcell')[0];
firstCell.focus();
// First Shift+ArrowDown
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
const firstCall = onRangeSelect.mock.calls[0][0];
// Second Shift+ArrowDown (should extend from anchor)
await user.keyboard('{Shift>}{ArrowDown}{/Shift}');
const secondCall = onRangeSelect.mock.calls[1][0];
// Second call should have more cells (anchor + 2 rows down)
expect(secondCall.length).toBeGreaterThan(firstCall.length);
});
});
// ========================================
// High Priority: Row Selection
// ========================================
describe('Row Selection', () => {
it('checkbox click toggles row selection', async () => {
const onRowSelectionChange = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
onRowSelectionChange={onRowSelectionChange}
/>
);
const checkbox = screen.getAllByRole('checkbox')[0];
await user.click(checkbox);
expect(onRowSelectionChange).toHaveBeenCalledWith(['row1']);
});
it('Space on checkbox cell toggles row selection', async () => {
const onRowSelectionChange = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
onRowSelectionChange={onRowSelectionChange}
/>
);
const checkbox = screen.getAllByRole('checkbox')[0];
checkbox.focus();
await user.keyboard(' ');
expect(onRowSelectionChange).toHaveBeenCalledWith(['row1']);
});
it('aria-selected updates on row element', async () => {
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
/>
);
const rows = screen.getAllByRole('row');
const dataRow = rows[1];
expect(dataRow).toHaveAttribute('aria-selected', 'false');
const checkbox = screen.getAllByRole('checkbox')[0];
await user.click(checkbox);
expect(dataRow).toHaveAttribute('aria-selected', 'true');
});
it('onRowSelectionChange callback fires', async () => {
const onRowSelectionChange = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
onRowSelectionChange={onRowSelectionChange}
/>
);
const checkbox = screen.getAllByRole('checkbox')[0];
await user.click(checkbox);
expect(onRowSelectionChange).toHaveBeenCalledTimes(1);
expect(onRowSelectionChange).toHaveBeenCalledWith(['row1']);
});
});
// ========================================
// High Priority: Selection Model Exclusivity
// ========================================
describe('Selection Model Exclusivity', () => {
it('when rowSelectable: aria-selected on rows only', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
/>
);
const rows = screen.getAllByRole('row');
// Data rows should have aria-selected
expect(rows[1]).toHaveAttribute('aria-selected');
// Cells should NOT have aria-selected
const cells = screen.getAllByRole('gridcell');
cells.forEach((cell) => {
expect(cell).not.toHaveAttribute('aria-selected');
});
});
it('when selectable (cell): aria-selected on gridcells only', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
selectable
/>
);
// Cells should have aria-selected
const cells = screen.getAllByRole('gridcell');
cells.forEach((cell) => {
expect(cell).toHaveAttribute('aria-selected');
});
// Data rows should NOT have aria-selected
const rows = screen.getAllByRole('row');
expect(rows[1]).not.toHaveAttribute('aria-selected');
});
it('aria-multiselectable on grid (not on individual elements)', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
rowMultiselectable
/>
);
const grid = screen.getByRole('grid');
expect(grid).toHaveAttribute('aria-multiselectable', 'true');
// Rows/cells should NOT have aria-multiselectable
const rows = screen.getAllByRole('row');
rows.forEach((row) => {
expect(row).not.toHaveAttribute('aria-multiselectable');
});
});
});
// ========================================
// High Priority: Focus Management
// ========================================
describe('Focus Management', () => {
it('sortable columnheaders are focusable (tabindex)', () => {
render(
<DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const sortableHeader = screen.getByRole('columnheader', { name: 'Name' });
expect(sortableHeader).toHaveAttribute('tabindex');
});
it('non-sortable columnheaders are NOT focusable', () => {
render(
<DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const nonSortableHeader = screen.getByRole('columnheader', { name: 'Role' });
expect(nonSortableHeader).not.toHaveAttribute('tabindex');
});
it('first focusable cell has tabindex="0"', () => {
render(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const firstCell = screen.getAllByRole('gridcell')[0];
expect(firstCell).toHaveAttribute('tabindex', '0');
});
it('roving tabindex updates correctly', async () => {
const user = userEvent.setup();
render(
<DataGrid 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');
});
});
// ========================================
// High Priority: Cell Editing
// ========================================
describe('Cell Editing', () => {
it('Enter on editable cell enters edit mode', async () => {
const onEditStart = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
onEditStart={onEditStart}
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{Enter}');
expect(onEditStart).toHaveBeenCalledWith('row1-0', 'row1', 'name');
});
it('F2 on editable cell enters edit mode', async () => {
const onEditStart = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
onEditStart={onEditStart}
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{F2}');
expect(onEditStart).toHaveBeenCalledWith('row1-0', 'row1', 'name');
});
it('Escape in edit mode cancels and restores grid navigation', async () => {
const onEditEnd = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
onEditEnd={onEditEnd}
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{Enter}');
// Should be in edit mode, type something
await user.keyboard('New Value');
await user.keyboard('{Escape}');
expect(onEditEnd).toHaveBeenCalledWith('row1-0', expect.any(String), true);
});
it('edit mode disables grid keyboard navigation', async () => {
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{Enter}');
// Arrow keys should work within input, not navigate grid
await user.keyboard('{ArrowRight}');
// Should still be in edit mode (focus on input, not next cell)
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
});
it('focus moves to input field on edit start', async () => {
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{Enter}');
const input = screen.getByRole('textbox');
expect(input).toHaveFocus();
});
it('focus returns to cell on edit end', async () => {
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{Enter}');
// Wait for edit mode to be active
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
await user.keyboard('{Escape}');
// Wait for focus to return to cell
await waitFor(() => {
expect(editableCell).toHaveFocus();
});
});
it('onEditStart callback fires when entering edit mode', async () => {
const onEditStart = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
onEditStart={onEditStart}
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{Enter}');
expect(onEditStart).toHaveBeenCalledTimes(1);
});
it('onEditEnd callback fires when exiting edit mode', async () => {
const onEditEnd = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
onEditEnd={onEditEnd}
/>
);
const editableCell = screen.getByRole('gridcell', { name: 'Alice' });
editableCell.focus();
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');
expect(onEditEnd).toHaveBeenCalledTimes(1);
});
it('non-editable cell does not enter edit mode on Enter/F2', async () => {
const onEditStart = vi.fn();
const user = userEvent.setup();
render(
<DataGrid
columns={createBasicColumns()}
rows={createEditableRows()}
ariaLabel="Users"
editable
onEditStart={onEditStart}
/>
);
// Admin cell is readonly
const readonlyCell = screen.getByRole('gridcell', { name: 'Admin' });
readonlyCell.focus();
await user.keyboard('{Enter}');
await user.keyboard('{F2}');
expect(onEditStart).not.toHaveBeenCalled();
});
});
// ========================================
// Medium Priority: Accessibility
// ========================================
describe('Accessibility', () => {
it('has no axe violations (WCAG 2.1 AA)', async () => {
const { container } = render(
<DataGrid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with row selection enabled', async () => {
const { container } = render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
rowMultiselectable
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with sorting enabled', async () => {
const { container } = render(
<DataGrid columns={createSortableColumns()} rows={createBasicRows()} ariaLabel="Users" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('sort indicators have accessible names', () => {
render(
<DataGrid
columns={[{ id: 'name', header: 'Name', sortable: true, sortDirection: 'ascending' }]}
rows={createBasicRows()}
ariaLabel="Users"
/>
);
const header = screen.getByRole('columnheader', { name: /Name/ });
// Header should have aria-sort which provides accessible state
expect(header).toHaveAttribute('aria-sort', 'ascending');
});
it('checkboxes have accessible labels', () => {
render(
<DataGrid
columns={createBasicColumns()}
rows={createBasicRows()}
ariaLabel="Users"
rowSelectable
/>
);
const checkboxes = screen.getAllByRole('checkbox');
checkboxes.forEach((checkbox) => {
// Each checkbox should have an accessible name
const label =
checkbox.getAttribute('aria-label') || checkbox.getAttribute('aria-labelledby');
expect(label).toBeTruthy();
});
});
});
}); 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