Grid
An interactive 2D data grid with keyboard navigation, cell selection, and activation.
Demo
Navigate with arrow keys. Press Space to select cells. Press Enter to activate.
Grid vs Table
Use grid role for interactive data grids, and table role for
static data presentation.
| Feature | Grid | Table |
|---|---|---|
| Keyboard Navigation | 2D (Arrow keys) | Table navigation (browser default) |
| Cell Focus | Required (roving tabindex) | Not required |
| Selection | aria-selected | Not supported |
| Editing | Optional | Not supported |
| Use Case | Spreadsheet-like, data grids | Static data display |
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
grid | Container | The grid container (composite widget) |
row | Row container | Groups cells horizontally |
columnheader | Header cells | Column headers (not focusable in this implementation) |
rowheader | Row header cell | Row headers (optional) |
gridcell | Data cells | Interactive cells (focusable) |
WAI-ARIA Properties
role="grid"
Identifies the container as a grid
- Values
- -
- Required
- Yes
aria-label
Accessible name for the grid
- Values
- String
- Required
- Yes* (either aria-label or aria-labelledby)
aria-labelledby
Alternative to aria-label
- Values
- ID reference
- Required
- Yes* (either aria-label or aria-labelledby)
aria-multiselectable
Only present for multi-select mode
- Values
- true
- Required
- No
aria-rowcount
Total rows (for virtualization)
- Values
- Number
- Required
- No
aria-colcount
Total columns (for virtualization)
- Values
- Number
- Required
- No
WAI-ARIA States
tabindex
- Target Element
- gridcell
- Values
- 0 | -1
- Required
- Yes
- Change Trigger
- Roving tabindex for focus management
aria-selected
- Target Element
- gridcell
- Values
- true | false
- Required
- No
- Change Trigger
Present when grid supports selection. When selection is supported, ALL gridcells should have aria-selected.
aria-disabled
- Target Element
- gridcell
- Values
- true
- Required
- No
- Change Trigger
- Indicates the cell is disabled
aria-rowindex
- Target Element
- row, gridcell
- Values
- Number
- Required
- No
- Change Trigger
- Row position (for virtualization)
aria-colindex
- Target Element
- gridcell
- Values
- Number
- Required
- No
- Change Trigger
- Column position (for virtualization)
Keyboard Support
2D Navigation
| Key | Action |
|---|---|
| → | Move focus one cell right |
| ← | Move focus one cell left |
| ↓ | Move focus one row down |
| ↑ | Move focus one row up |
| Home | Move focus to first cell in row |
| End | Move focus to last cell in row |
| Ctrl + Home | Move focus to first cell in grid |
| Ctrl + End | Move focus to last cell in grid |
| PageDown | Move focus down by page size (default 5) |
| PageUp | Move focus up by page size (default 5) |
Selection & Activation
| Key | Action |
|---|---|
| Space | Select/deselect focused cell (when selectable) |
| Enter | Activate focused cell (trigger onCellActivate) |
- Either aria-label or aria-labelledby is required on the grid container.
- Disabled cells have aria-disabled=“true”, are focusable (included in keyboard navigation), cannot be selected or activated, and are visually distinct (e.g., grayed out).
Focus Management
| Event | Behavior |
|---|---|
| Roving tabindex | Only one cell has tabindex="0" (the focused cell), all others have tabindex="-1" |
| Single Tab stop | Grid is a single Tab stop (Tab enters grid, Shift+Tab exits) |
| Header cells | Header cells (columnheader) are NOT focusable (no sort functionality in this implementation) |
| Data cells only | Only gridcells in the data rows are included in keyboard navigation |
| Focus memory | Last focused cell is remembered when leaving and re-entering the grid |
References
Source Code
---
// =============================================================================
// Types
// =============================================================================
export interface GridCellData {
id: string;
value: string | number;
disabled?: boolean;
colspan?: number;
rowspan?: number;
}
export interface GridColumnDef {
id: string;
header: string;
colspan?: number;
}
export interface GridRowData {
id: string;
cells: GridCellData[];
hasRowHeader?: boolean;
disabled?: boolean;
}
interface Props {
columns: GridColumnDef[];
rows: GridRowData[];
ariaLabel?: string;
ariaLabelledby?: string;
selectable?: boolean;
multiselectable?: boolean;
defaultSelectedIds?: string[];
defaultFocusedId?: string;
totalColumns?: number;
totalRows?: number;
startRowIndex?: number;
startColIndex?: number;
wrapNavigation?: boolean;
enablePageNavigation?: boolean;
pageSize?: number;
class?: string;
renderCell?: (cell: GridCellData, rowId: string, colId: string) => string;
}
// =============================================================================
// Props
// =============================================================================
const {
columns,
rows,
ariaLabel,
ariaLabelledby,
selectable = false,
multiselectable = false,
defaultSelectedIds = [],
defaultFocusedId,
totalColumns,
totalRows,
startRowIndex = 1,
startColIndex = 1,
wrapNavigation = false,
enablePageNavigation = false,
pageSize = 5,
class: className,
renderCell,
} = Astro.props;
// Determine initial focused cell
const initialFocusedId = defaultFocusedId ?? rows[0]?.cells[0]?.id ?? null;
---
<apg-grid
class={`apg-grid ${className ?? ''}`}
data-wrap-navigation={wrapNavigation}
data-enable-page-navigation={enablePageNavigation}
data-page-size={pageSize}
data-selectable={selectable}
data-multiselectable={multiselectable}
>
<div
role="grid"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-multiselectable={multiselectable ? 'true' : undefined}
aria-rowcount={totalRows}
aria-colcount={totalColumns}
>
{/* Header Row */}
<div role="row" aria-rowindex={totalRows ? 1 : undefined}>
{
columns.map((col, colIndex) => (
<div
role="columnheader"
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={col.colspan}
data-col-id={col.id}
>
{col.header}
</div>
))
}
</div>
{/* Data Rows */}
{
rows.map((row, rowIndex) => (
<div
role="row"
aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
data-row-id={row.id}
>
{row.cells.map((cell, colIndex) => {
const isRowHeader = row.hasRowHeader && colIndex === 0;
const isFocused = cell.id === initialFocusedId;
const isSelected = defaultSelectedIds.includes(cell.id);
const colId = columns[colIndex]?.id ?? '';
return (
<div
role={isRowHeader ? 'rowheader' : 'gridcell'}
tabindex={isFocused ? 0 : -1}
aria-selected={selectable ? (isSelected ? 'true' : 'false') : undefined}
aria-disabled={cell.disabled ? 'true' : undefined}
aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
aria-colspan={cell.colspan}
aria-rowspan={cell.rowspan}
data-cell-id={cell.id}
data-row-id={row.id}
data-col-id={colId}
data-row-index={rowIndex}
data-col-index={colIndex}
data-disabled={cell.disabled ? 'true' : undefined}
class={`apg-grid-cell ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''} ${cell.disabled ? 'disabled' : ''}`}
>
{renderCell ? <Fragment set:html={renderCell(cell, row.id, colId)} /> : cell.value}
</div>
);
})}
</div>
))
}
</div>
</apg-grid>
<script>
class ApgGrid extends HTMLElement {
private focusedId: string | null = null;
private selectedIds: Set<string> = new Set();
private wrapNavigation = false;
private enablePageNavigation = false;
private pageSize = 5;
private selectable = false;
private multiselectable = false;
connectedCallback() {
this.wrapNavigation = this.dataset.wrapNavigation === 'true';
this.enablePageNavigation = this.dataset.enablePageNavigation === 'true';
this.pageSize = parseInt(this.dataset.pageSize || '5', 10);
this.selectable = this.dataset.selectable === 'true';
this.multiselectable = this.dataset.multiselectable === 'true';
// Find initial focused cell
const focusedCell = this.querySelector<HTMLElement>('[tabindex="0"]');
this.focusedId = focusedCell?.dataset.cellId ?? null;
// Load initial selected ids
this.querySelectorAll<HTMLElement>('[aria-selected="true"]').forEach((el) => {
const cellId = el.dataset.cellId;
if (cellId) this.selectedIds.add(cellId);
});
// Set tabindex="-1" on all focusable elements inside grid cells
// This ensures Tab exits the grid instead of moving between widgets
this.querySelectorAll<HTMLElement>(
'[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
).forEach((el) => {
el.setAttribute('tabindex', '-1');
});
// Add event listeners to all cells
// Use focusin instead of focus because focus doesn't bubble
// This ensures we catch focus on widgets inside cells
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.addEventListener('focusin', this.handleFocus.bind(this) as EventListener);
}
);
}
disconnectedCallback() {
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]').forEach(
(cell) => {
cell.removeEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
cell.removeEventListener('focusin', this.handleFocus.bind(this) as EventListener);
}
);
}
private getCells(): HTMLElement[] {
return Array.from(
this.querySelectorAll<HTMLElement>('[role="gridcell"], [role="rowheader"]')
);
}
private getRows(): HTMLElement[] {
return Array.from(this.querySelectorAll<HTMLElement>('[role="row"]')).slice(1); // Skip header row
}
private getColumnCount(): number {
return this.querySelectorAll('[role="columnheader"]').length;
}
private getCellAt(rowIndex: number, colIndex: number): HTMLElement | null {
const rows = this.getRows();
const row = rows[rowIndex];
if (!row) return null;
const cells = row.querySelectorAll('[role="gridcell"], [role="rowheader"]');
return cells[colIndex] as HTMLElement | null;
}
private focusCell(cell: HTMLElement) {
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
// Check if cell contains a focusable element (link, button, etc.)
// Per APG: when cell contains a single widget, focus should be on the widget
const focusableChild = cell.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableChild) {
// Set tabindex="-1" so Tab skips this element and exits the grid
// The widget can still receive programmatic focus
focusableChild.setAttribute('tabindex', '-1');
focusableChild.focus();
} else {
cell.focus();
}
this.focusedId = cell.dataset.cellId ?? null;
}
private handleFocus(event: Event) {
// Use currentTarget (the cell) instead of target (which could be a link inside the cell)
const cell = event.currentTarget as HTMLElement;
const currentFocused = this.querySelector('[tabindex="0"]');
if (currentFocused && currentFocused !== cell) {
currentFocused.setAttribute('tabindex', '-1');
currentFocused.classList.remove('focused');
}
cell.setAttribute('tabindex', '0');
cell.classList.add('focused');
this.focusedId = cell.dataset.cellId ?? null;
}
private findNextCell(
rowIndex: number,
colIndex: number,
direction: 'right' | 'left' | 'up' | 'down'
): HTMLElement | null {
const colCount = this.getColumnCount();
const rowCount = this.getRows().length;
let newRow = rowIndex;
let newCol = colIndex;
switch (direction) {
case 'right':
newCol++;
if (newCol >= colCount) {
if (this.wrapNavigation) {
newCol = 0;
newRow++;
} else {
return null;
}
}
break;
case 'left':
newCol--;
if (newCol < 0) {
if (this.wrapNavigation) {
newCol = colCount - 1;
newRow--;
} else {
return null;
}
}
break;
case 'down':
newRow++;
break;
case 'up':
newRow--;
break;
}
if (newRow < 0 || newRow >= rowCount) return null;
const cell = this.getCellAt(newRow, newCol);
if (!cell) return null;
// Skip disabled cells
if (cell.dataset.disabled === 'true') {
return this.findNextCell(newRow, newCol, direction);
}
return cell;
}
private toggleSelection(cell: HTMLElement) {
if (!this.selectable) return;
if (cell.dataset.disabled === 'true') return;
const cellId = cell.dataset.cellId;
if (!cellId) return;
if (this.multiselectable) {
if (this.selectedIds.has(cellId)) {
this.selectedIds.delete(cellId);
cell.setAttribute('aria-selected', 'false');
} else {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
}
} else {
// Clear previous selection
this.querySelectorAll('[aria-selected="true"]').forEach((el) => {
el.setAttribute('aria-selected', 'false');
});
this.selectedIds.clear();
if (!this.selectedIds.has(cellId)) {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
}
}
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedIds: Array.from(this.selectedIds) },
})
);
}
private selectAll() {
if (!this.selectable || !this.multiselectable) return;
this.getCells().forEach((cell) => {
if (cell.dataset.disabled !== 'true') {
const cellId = cell.dataset.cellId;
if (cellId) {
this.selectedIds.add(cellId);
cell.setAttribute('aria-selected', 'true');
}
}
});
this.dispatchEvent(
new CustomEvent('selection-change', {
detail: { selectedIds: Array.from(this.selectedIds) },
})
);
}
private handleKeyDown(event: KeyboardEvent) {
// Use currentTarget (the cell) instead of target (which could be a link inside the cell)
const cell = event.currentTarget as HTMLElement;
const { key, ctrlKey } = event;
const {
rowIndex: rowIndexStr,
colIndex: colIndexStr,
disabled,
cellId,
rowId,
colId,
} = cell.dataset;
const rowIndex = parseInt(rowIndexStr || '0', 10);
const colIndex = parseInt(colIndexStr || '0', 10);
let handled = true;
switch (key) {
case 'ArrowRight': {
const next = this.findNextCell(rowIndex, colIndex, 'right');
if (next) this.focusCell(next);
break;
}
case 'ArrowLeft': {
const next = this.findNextCell(rowIndex, colIndex, 'left');
if (next) this.focusCell(next);
break;
}
case 'ArrowDown': {
const next = this.findNextCell(rowIndex, colIndex, 'down');
if (next) this.focusCell(next);
break;
}
case 'ArrowUp': {
const next = this.findNextCell(rowIndex, colIndex, 'up');
if (next) this.focusCell(next);
break;
}
case 'Home': {
if (ctrlKey) {
const firstCell = this.getCellAt(0, 0);
if (firstCell) this.focusCell(firstCell);
} else {
const firstInRow = this.getCellAt(rowIndex, 0);
if (firstInRow) this.focusCell(firstInRow);
}
break;
}
case 'End': {
const colCount = this.getColumnCount();
if (ctrlKey) {
const rowCount = this.getRows().length;
const lastCell = this.getCellAt(rowCount - 1, colCount - 1);
if (lastCell) this.focusCell(lastCell);
} else {
const lastInRow = this.getCellAt(rowIndex, colCount - 1);
if (lastInRow) this.focusCell(lastInRow);
}
break;
}
case 'PageDown': {
if (this.enablePageNavigation) {
const rowCount = this.getRows().length;
const targetRow = Math.min(rowIndex + this.pageSize, rowCount - 1);
const targetCell = this.getCellAt(targetRow, colIndex);
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case 'PageUp': {
if (this.enablePageNavigation) {
const targetRow = Math.max(rowIndex - this.pageSize, 0);
const targetCell = this.getCellAt(targetRow, colIndex);
if (targetCell) this.focusCell(targetCell);
} else {
handled = false;
}
break;
}
case ' ': {
this.toggleSelection(cell);
break;
}
case 'Enter': {
if (disabled !== 'true') {
this.dispatchEvent(
new CustomEvent('cell-activate', {
detail: { cellId, rowId, colId },
})
);
}
break;
}
case 'a': {
if (ctrlKey) {
this.selectAll();
} else {
handled = false;
}
break;
}
default:
handled = false;
}
if (handled) {
event.preventDefault();
event.stopPropagation();
}
}
}
customElements.define('apg-grid', ApgGrid);
</script> Usage
---
import Grid from '@patterns/grid/Grid.astro';
const columns = [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const rows = [
{
id: 'user1',
cells: [
{ id: 'user1-0', value: 'Alice Johnson' },
{ id: 'user1-1', value: 'alice@example.com' },
{ id: 'user1-2', value: 'Admin' },
],
},
];
---
<!-- Basic Grid -->
<Grid
columns={columns}
rows={rows}
ariaLabel="User list"
/>
<!-- With selection enabled -->
<Grid
columns={columns}
rows={rows}
ariaLabel="User list"
selectable
multiselectable
/> API
| Prop | Type | Default | Description |
|---|---|---|---|
columns | GridColumnDef[] | required | Column definitions |
rows | GridRowData[] | required | Row data |
ariaLabel | string | - | Accessible name for grid |
selectable | boolean | false | Enable cell selection |
multiselectable | boolean | false | Enable multi-cell selection |
<apg-grid> for client-side interactivity. Keyboard navigation and selection are handled via JavaScript after hydration. Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements. The Grid component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Testing Library)
Verify component rendering and interactions using framework-specific Testing Library utilities. These tests ensure correct component behavior in isolation.
- HTML structure and element hierarchy (grid, row, gridcell)
- Initial attribute values (role, aria-label, tabindex)
- Selection state changes (aria-selected)
- CSS class application
E2E Tests (Playwright)
Verify component behavior in a real browser environment across all four frameworks. These tests cover interactions that require full browser context.
- 2D keyboard navigation (Arrow keys)
- Extended navigation (Home, End, Ctrl+Home, Ctrl+End)
- Page navigation (PageUp, PageDown)
- Cell selection and activation
- Focus management and roving tabindex
- Cross-framework consistency
Test Categories
High Priority : APG ARIA Attributes
| Test | Description |
|---|---|
role="grid" | Container has grid role |
role="row" | All rows have row role |
role="gridcell" | Data cells have gridcell role |
role="columnheader" | Header cells have columnheader role |
role="rowheader" | Row header cells have rowheader role (when applicable) |
aria-label | Grid has accessible name via aria-label |
aria-labelledby | Grid has accessible name via aria-labelledby |
aria-multiselectable | Present when multi-selection is enabled |
aria-selected | Present on all cells when selection is enabled |
aria-disabled | Present on disabled cells |
High Priority : 2D Keyboard Navigation
| Test | Description |
|---|---|
ArrowRight | Moves focus one cell right |
ArrowLeft | Moves focus one cell left |
ArrowDown | Moves focus one row down |
ArrowUp | Moves focus one row up |
ArrowUp at first row | Stops at first data row (does not enter headers) |
ArrowRight at row end | Stops at row end (default) or wraps (wrapNavigation) |
High Priority : Extended Navigation
| Test | Description |
|---|---|
Home | Moves focus to first cell in row |
End | Moves focus to last cell in row |
Ctrl+Home | Moves focus to first cell in grid |
Ctrl+End | Moves focus to last cell in grid |
PageDown | Moves focus down by page size |
PageUp | Moves focus up by page size |
High Priority : Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabindex="0" | First focusable cell has tabindex="0" |
tabindex="-1" | Other cells have tabindex="-1" |
Headers not focusable | columnheader cells have no tabindex (not focusable) |
Tab exits grid | Tab moves focus out of grid |
Focus update | Focused cell updates tabindex on navigation |
Disabled cells | Disabled cells are focusable but not activatable |
High Priority : Selection
| Test | Description |
|---|---|
Space toggles | Space toggles cell selection (when selectable) |
Single select | Single selection clears previous on Space |
Multi select | Multi-selection allows multiple cells |
Enter activates | Enter triggers cell activation |
Disabled no select | Space does not select disabled cell |
Disabled no activate | Enter does not activate disabled cell |
Medium Priority : Virtualization Support
| Test | Description |
|---|---|
aria-rowcount | Present when totalRows provided |
aria-colcount | Present when totalColumns provided |
aria-rowindex | Present on rows/cells when virtualizing |
aria-colindex | Present on cells when virtualizing |
Medium Priority : Accessibility
| Test | Description |
|---|---|
axe-core | No accessibility violations |
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, expect, it } from 'vitest';
import Grid from './Grid.astro';
// Helper data
const basicColumns = [
{ id: 'name', header: 'Name' },
{ id: 'email', header: 'Email' },
{ id: 'role', header: 'Role' },
];
const basicRows = [
{
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' },
],
},
];
const rowsWithDisabled = [
{
id: 'row1',
cells: [
{ id: 'row1-0', value: 'Alice' },
{ id: 'row1-1', value: 'alice@example.com', disabled: true },
{ id: 'row1-2', value: 'Admin' },
],
},
];
describe('Grid (Astro)', () => {
describe('ARIA Attributes', () => {
it('renders role="grid" on container', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('role="grid"');
});
it('renders role="row" on rows', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
const rowMatches = result.match(/role="row"/g);
// Header row + 2 data rows = 3 rows
expect(rowMatches?.length).toBe(3);
});
it('renders role="gridcell" on data cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
const cellMatches = result.match(/role="gridcell"/g);
// 2 rows * 3 columns = 6 cells
expect(cellMatches?.length).toBe(6);
});
it('renders role="columnheader" on header cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
const headerMatches = result.match(/role="columnheader"/g);
expect(headerMatches?.length).toBe(3);
});
it('renders aria-label on grid', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('aria-label="Users"');
});
it('renders aria-labelledby when provided', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabelledby: 'grid-title' },
});
expect(result).toContain('aria-labelledby="grid-title"');
});
it('renders aria-multiselectable when multiselectable', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: {
columns: basicColumns,
rows: basicRows,
ariaLabel: 'Users',
selectable: true,
multiselectable: true,
},
});
expect(result).toContain('aria-multiselectable="true"');
});
it('renders aria-disabled on disabled cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: rowsWithDisabled, ariaLabel: 'Users' },
});
expect(result).toContain('aria-disabled="true"');
});
it('renders aria-selected on selectable cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users', selectable: true },
});
expect(result).toContain('aria-selected="false"');
});
});
describe('Structure', () => {
it('renders Web Component wrapper', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('<apg-grid');
expect(result).toContain('</apg-grid>');
});
it('renders cell values', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('Alice');
expect(result).toContain('alice@example.com');
expect(result).toContain('Admin');
});
it('renders column headers', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
expect(result).toContain('Name');
expect(result).toContain('Email');
expect(result).toContain('Role');
});
});
describe('Focus Management', () => {
it('renders tabindex="0" on first focusable cell', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
// First gridcell should have tabindex="0"
const gridcellPattern = /role="gridcell"[^>]*tabindex="0"/;
expect(result).toMatch(gridcellPattern);
});
it('renders tabindex="-1" on other cells', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
// Other gridcells should have tabindex="-1"
const negativeTabindexMatches = result.match(/tabindex="-1"/g);
expect(negativeTabindexMatches?.length).toBeGreaterThan(0);
});
it('columnheader cells do not have tabindex', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users' },
});
// columnheader should not have tabindex
const headerWithTabindex = /role="columnheader"[^>]*tabindex/;
expect(result).not.toMatch(headerWithTabindex);
});
});
describe('Virtualization', () => {
it('renders aria-rowcount when totalRows provided', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users', totalRows: 100 },
});
expect(result).toContain('aria-rowcount="100"');
});
it('renders aria-colcount when totalColumns provided', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Grid, {
props: { columns: basicColumns, rows: basicRows, ariaLabel: 'Users', totalColumns: 10 },
});
expect(result).toContain('aria-colcount="10"');
});
});
}); 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