APG Patterns
日本語
日本語

Grid

An interactive 2D data grid with keyboard navigation, cell selection, and activation.

Demo

Navigate with arrow keys. Press Space to select cells. Press Enter to activate.

Use arrow keys to navigate between cells. Press Space to select/deselect cells. Press Enter to activate a cell.

Name
Email
Role
Status
alice@example.com
Admin
Active
bob@example.com
Editor
Active
charlie@example.com
Viewer
Inactive
diana@example.com
Admin
Active
eve@example.com
Editor
Active

Open demo only →

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

Native HTML vs Grid Role

The grid role creates an interactive data grid with 2D keyboard navigation. For static data tables, use native <table> elements instead. Use grid when:

  • Cells are focusable and interactive (editable, selectable, or contain widgets)
  • Static data display without interactivity
  • Interface similar to spreadsheet or data grid

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 grid role (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 Only present for multi-select mode
aria-rowcount Number No Total rows (for virtualization)
aria-colcount Number No Total columns (for virtualization)

* Either aria-label or aria-labelledby is required on the grid container.

WAI-ARIA States (Grid Cells)

Attribute Values Required Description
tabindex 0 | -1 Yes Roving tabindex for focus management
aria-selected true | false No* Present when grid supports selection. When selection is supported, ALL gridcells should have aria-selected.
aria-disabled true No* Indicates the cell is disabled
aria-rowindex Number No* Row position (for virtualization)
aria-colindex Number No* Column position (for virtualization)

* When selection is supported, ALL gridcells should have aria-selected.

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)

Focus Management

This component uses roving tabindex for focus management:

  • Only one cell has tabindex="0" (the focused cell), all others have tabindex="-1"
  • Grid is a single Tab stop (Tab enters grid, Shift+Tab exits)
  • Header cells (columnheader) are NOT focusable (no sort functionality in this implementation)
  • Only gridcells in the data rows are included in keyboard navigation
  • Last focused cell is remembered when leaving and re-entering the grid

Disabled Cells

  • Have aria-disabled="true"
  • Are focusable (included in keyboard navigation)
  • Cannot be selected or activated
  • Visually distinct (e.g., grayed out)

Source Code

Grid.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

// =============================================================================
// Types
// =============================================================================

export interface GridCellData {
  id: string;
  value: string | number;
  disabled?: boolean;
  colspan?: number;
  rowspan?: number;
}

export interface GridColumnDef {
  id: string;
  header: string;
  colspan?: number;
}

export interface GridRowData {
  id: string;
  cells: GridCellData[];
  hasRowHeader?: boolean;
  disabled?: boolean;
}

export interface GridProps {
  columns: GridColumnDef[];
  rows: GridRowData[];

  // Accessible name (one required)
  ariaLabel?: string;
  ariaLabelledby?: string;

  // Selection
  selectable?: boolean;
  multiselectable?: boolean;
  selectedIds?: string[];
  defaultSelectedIds?: string[];
  onSelectionChange?: (selectedIds: string[]) => void;

  // Focus
  focusedId?: string | null;
  defaultFocusedId?: string;
  onFocusChange?: (focusedId: string | null) => void;

  // Virtualization
  totalColumns?: number;
  totalRows?: number;
  startRowIndex?: number; // 1-based
  startColIndex?: number; // 1-based

  // Behavior
  wrapNavigation?: boolean;
  enablePageNavigation?: boolean;
  pageSize?: number;

  // Callbacks
  onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
  renderCell?: (cell: GridCellData, rowId: string, colId: string) => React.ReactNode;

  // Styling
  className?: string;
}

// =============================================================================
// Component
// =============================================================================

export function Grid({
  columns,
  rows,
  ariaLabel,
  ariaLabelledby,
  selectable = false,
  multiselectable = false,
  selectedIds: controlledSelectedIds,
  defaultSelectedIds = [],
  onSelectionChange,
  focusedId: controlledFocusedId,
  defaultFocusedId,
  onFocusChange,
  totalColumns,
  totalRows,
  startRowIndex = 1,
  startColIndex = 1,
  wrapNavigation = false,
  enablePageNavigation = false,
  pageSize = 5,
  onCellActivate,
  renderCell,
  className,
}: GridProps) {
  // ==========================================================================
  // State
  // ==========================================================================

  const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(defaultSelectedIds);
  const selectedIds = controlledSelectedIds ?? internalSelectedIds;

  const [internalFocusedId, setInternalFocusedId] = useState<string | null>(() => {
    if (defaultFocusedId) return defaultFocusedId;
    // Default to first cell
    return rows[0]?.cells[0]?.id ?? null;
  });
  const focusedId = controlledFocusedId !== undefined ? controlledFocusedId : internalFocusedId;

  const gridRef = useRef<HTMLDivElement>(null);
  const cellRefs = useRef<Map<string, HTMLDivElement>>(new Map());

  // ==========================================================================
  // Computed values
  // ==========================================================================

  // Map cellId to cell info for O(1) lookup
  const cellById = useMemo(() => {
    const map = new Map<
      string,
      { rowIndex: number; colIndex: number; cell: GridCellData; rowId: string }
    >();
    rows.forEach((row, rowIndex) => {
      row.cells.forEach((cell, colIndex) => {
        map.set(cell.id, { rowIndex, colIndex, cell, rowId: row.id });
      });
    });
    return map;
  }, [rows]);

  const getCellPosition = useCallback(
    (cellId: string) => {
      const entry = cellById.get(cellId);
      if (!entry) {
        return null;
      }
      const { rowIndex, colIndex } = entry;
      return { rowIndex, colIndex };
    },
    [cellById]
  );

  const getCellAt = useCallback(
    (rowIndex: number, colIndex: number) => {
      const cell = rows[rowIndex]?.cells[colIndex];
      if (!cell) {
        return undefined;
      }
      return cellById.get(cell.id);
    },
    [cellById, rows]
  );

  const getColumnCount = useCallback(() => {
    return columns.length;
  }, [columns]);

  const getRowCount = useCallback(() => {
    return rows.length;
  }, [rows]);

  // ==========================================================================
  // Focus Management
  // ==========================================================================

  const setFocusedId = useCallback(
    (id: string | null) => {
      setInternalFocusedId(id);
      onFocusChange?.(id);
    },
    [onFocusChange]
  );

  const focusCell = useCallback(
    (cellId: string) => {
      const cellEl = cellRefs.current.get(cellId);
      if (cellEl) {
        // Check if cell contains a focusable element (link, button, etc.)
        // Per APG: when cell contains a single widget, focus should be on the widget
        const focusableChild = cellEl.querySelector<HTMLElement>(
          'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
        );
        if (focusableChild) {
          // Set tabindex="-1" so Tab skips this element and exits the grid
          // The widget can still receive programmatic focus
          focusableChild.setAttribute('tabindex', '-1');
          focusableChild.focus();
        } else {
          cellEl.focus();
        }
        setFocusedId(cellId);
      }
    },
    [setFocusedId]
  );

  // Find next focusable cell (skipping disabled cells if needed)
  const findNextFocusableCell = useCallback(
    (
      startRowIndex: number,
      startColIndex: number,
      direction: 'right' | 'left' | 'up' | 'down',
      skipDisabled = true
    ): { rowIndex: number; colIndex: number; cell: GridCellData } | null => {
      const colCount = getColumnCount();
      const rowCount = getRowCount();

      let rowIdx = startRowIndex;
      let colIdx = startColIndex;

      const step = () => {
        switch (direction) {
          case 'right':
            colIdx++;
            if (colIdx >= colCount) {
              if (wrapNavigation) {
                colIdx = 0;
                rowIdx++;
                if (rowIdx >= rowCount) {
                  return false; // End of grid
                }
              } else {
                return false; // Stay at edge
              }
            }
            break;
          case 'left':
            colIdx--;
            if (colIdx < 0) {
              if (wrapNavigation) {
                colIdx = colCount - 1;
                rowIdx--;
                if (rowIdx < 0) {
                  return false;
                }
              } else {
                return false;
              }
            }
            break;
          case 'down':
            rowIdx++;
            if (rowIdx >= rowCount) {
              return false;
            }
            break;
          case 'up':
            rowIdx--;
            if (rowIdx < 0) {
              return false;
            }
            break;
        }
        return true;
      };

      // Take one step first
      if (!step()) return null;

      // Find non-disabled cell
      let iterations = 0;
      const maxIterations = colCount * rowCount;

      while (iterations < maxIterations) {
        const entry = getCellAt(rowIdx, colIdx);
        if (entry && (!skipDisabled || !entry.cell.disabled)) {
          return { rowIndex: rowIdx, colIndex: colIdx, cell: entry.cell };
        }
        if (!step()) break;
        iterations++;
      }

      return null;
    },
    [getColumnCount, getRowCount, wrapNavigation, getCellAt]
  );

  // ==========================================================================
  // Selection Management
  // ==========================================================================

  const setSelectedIds = useCallback(
    (ids: string[]) => {
      setInternalSelectedIds(ids);
      onSelectionChange?.(ids);
    },
    [onSelectionChange]
  );

  const toggleSelection = useCallback(
    (cellId: string, cell: GridCellData) => {
      if (!selectable || cell.disabled) {
        return;
      }

      if (multiselectable) {
        const newIds = selectedIds.includes(cellId)
          ? selectedIds.filter((id) => id !== cellId)
          : [...selectedIds, cellId];
        setSelectedIds(newIds);
      } else {
        const newIds = selectedIds.includes(cellId) ? [] : [cellId];
        setSelectedIds(newIds);
      }
    },
    [selectable, multiselectable, selectedIds, setSelectedIds]
  );

  const selectAll = useCallback(() => {
    if (!selectable || !multiselectable) {
      return;
    }

    const allIds = Array.from(cellById.values())
      .filter(({ cell }) => !cell.disabled)
      .map(({ cell }) => cell.id);
    setSelectedIds(allIds);
  }, [selectable, multiselectable, cellById, setSelectedIds]);

  // ==========================================================================
  // Keyboard Handling
  // ==========================================================================

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent, cell: GridCellData, rowId: string, colId: string) => {
      const pos = getCellPosition(cell.id);
      if (!pos) {
        return;
      }

      const { rowIndex, colIndex } = pos;
      const { key, ctrlKey } = event;

      let handled = true;

      switch (key) {
        case 'ArrowRight': {
          const next = findNextFocusableCell(rowIndex, colIndex, 'right');
          if (next) focusCell(next.cell.id);
          break;
        }
        case 'ArrowLeft': {
          const next = findNextFocusableCell(rowIndex, colIndex, 'left');
          if (next) focusCell(next.cell.id);
          break;
        }
        case 'ArrowDown': {
          const next = findNextFocusableCell(rowIndex, colIndex, 'down');
          if (next) focusCell(next.cell.id);
          break;
        }
        case 'ArrowUp': {
          const next = findNextFocusableCell(rowIndex, colIndex, 'up');
          if (next) focusCell(next.cell.id);
          break;
        }
        case 'Home': {
          if (ctrlKey) {
            // Ctrl+Home: Go to first cell in grid
            const firstCell = rows[0]?.cells[0];
            if (firstCell) focusCell(firstCell.id);
          } else {
            // Home: Go to first cell in row
            const firstCellInRow = rows[rowIndex]?.cells[0];
            if (firstCellInRow) focusCell(firstCellInRow.id);
          }
          break;
        }
        case 'End': {
          if (ctrlKey) {
            // Ctrl+End: Go to last cell in grid
            const lastRow = rows[rows.length - 1];
            const lastCell = lastRow?.cells[lastRow.cells.length - 1];
            if (lastCell) focusCell(lastCell.id);
          } else {
            // End: Go to last cell in row
            const currentRow = rows[rowIndex];
            const lastCellInRow = currentRow?.cells[currentRow.cells.length - 1];
            if (lastCellInRow) focusCell(lastCellInRow.id);
          }
          break;
        }
        case 'PageDown': {
          if (enablePageNavigation) {
            const targetRowIndex = Math.min(rowIndex + pageSize, rows.length - 1);
            const targetCell = rows[targetRowIndex]?.cells[colIndex];
            if (targetCell) focusCell(targetCell.id);
          } else {
            handled = false;
          }
          break;
        }
        case 'PageUp': {
          if (enablePageNavigation) {
            const targetRowIndex = Math.max(rowIndex - pageSize, 0);
            const targetCell = rows[targetRowIndex]?.cells[colIndex];
            if (targetCell) focusCell(targetCell.id);
          } else {
            handled = false;
          }
          break;
        }
        case ' ': {
          toggleSelection(cell.id, cell);
          break;
        }
        case 'Enter': {
          if (!cell.disabled) {
            onCellActivate?.(cell.id, rowId, colId);
          }
          break;
        }
        case 'a': {
          if (ctrlKey) {
            selectAll();
          } else {
            handled = false;
          }
          break;
        }
        default:
          handled = false;
      }

      if (handled) {
        event.preventDefault();
        event.stopPropagation();
      }
    },
    [
      getCellPosition,
      findNextFocusableCell,
      focusCell,
      rows,
      enablePageNavigation,
      pageSize,
      toggleSelection,
      onCellActivate,
      selectAll,
    ]
  );

  // ==========================================================================
  // Effects
  // ==========================================================================

  // Set tabindex="-1" on all focusable elements inside grid cells
  // This ensures Tab exits the grid instead of moving between widgets
  useEffect(() => {
    if (gridRef.current) {
      const focusableElements = gridRef.current.querySelectorAll<HTMLElement>(
        '[role="gridcell"] a[href], [role="gridcell"] button, [role="rowheader"] a[href], [role="rowheader"] button'
      );
      focusableElements.forEach((el) => {
        el.setAttribute('tabindex', '-1');
      });
    }
  }, [rows]);

  // Focus the focused cell when focusedId changes externally
  useEffect(() => {
    if (focusedId) {
      const cellEl = cellRefs.current.get(focusedId);
      if (cellEl && document.activeElement !== cellEl) {
        // Only focus if grid is already focused
        if (gridRef.current?.contains(document.activeElement)) {
          cellEl.focus();
        }
      }
    }
  }, [focusedId]);

  // ==========================================================================
  // Render
  // ==========================================================================

  return (
    <div
      ref={gridRef}
      role="grid"
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-multiselectable={multiselectable ? 'true' : undefined}
      aria-rowcount={totalRows}
      aria-colcount={totalColumns}
      className={`apg-grid ${className ?? ''}`}
    >
      {/* Header Row */}
      <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
        {columns.map((col, colIndex) => (
          <div
            key={col.id}
            role="columnheader"
            aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
            aria-colspan={col.colspan}
          >
            {col.header}
          </div>
        ))}
      </div>

      {/* Data Rows */}
      {rows.map((row, rowIndex) => (
        <div
          key={row.id}
          role="row"
          aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}
        >
          {row.cells.map((cell, colIndex) => {
            const isRowHeader = row.hasRowHeader && colIndex === 0;
            const isFocused = cell.id === focusedId;
            const isSelected = selectedIds.includes(cell.id);
            const colId = columns[colIndex]?.id ?? '';

            return (
              <div
                key={cell.id}
                ref={(el) => {
                  if (el) {
                    cellRefs.current.set(cell.id, el);
                  } else {
                    cellRefs.current.delete(cell.id);
                  }
                }}
                role={isRowHeader ? 'rowheader' : 'gridcell'}
                tabIndex={isFocused ? 0 : -1}
                aria-selected={selectable ? (isSelected ? 'true' : 'false') : undefined}
                aria-disabled={cell.disabled ? 'true' : undefined}
                aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
                aria-colspan={cell.colspan}
                aria-rowspan={cell.rowspan}
                onKeyDown={(e) => handleKeyDown(e, cell, row.id, colId)}
                onFocus={() => setFocusedId(cell.id)}
                className={`apg-grid-cell ${isFocused ? 'focused' : ''} ${isSelected ? 'selected' : ''} ${cell.disabled ? 'disabled' : ''}`}
              >
                {renderCell ? renderCell(cell, row.id, colId) : cell.value}
              </div>
            );
          })}
        </div>
      ))}
    </div>
  );
}

export default Grid;

Usage

Example
import { Grid } from './Grid';
import type { GridColumnDef, GridRowData } from './Grid';

const columns: GridColumnDef[] = [
  { id: 'name', header: 'Name' },
  { id: 'email', header: 'Email' },
  { id: 'role', header: 'Role' },
];

const rows: GridRowData[] = [
  {
    id: 'user1',
    cells: [
      { id: 'user1-0', value: 'Alice Johnson' },
      { id: 'user1-1', value: 'alice@example.com' },
      { id: 'user1-2', value: 'Admin' },
    ],
  },
  {
    id: 'user2',
    cells: [
      { id: 'user2-0', value: 'Bob Smith' },
      { id: 'user2-1', value: 'bob@example.com' },
      { id: 'user2-2', value: 'User' },
    ],
  },
];

// Basic Grid
<Grid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
/>

// With selection
<Grid
  columns={columns}
  rows={rows}
  ariaLabel="User list"
  selectable
  multiselectable
  selectedIds={selectedIds}
  onSelectionChange={(ids) => setSelectedIds(ids)}
  onCellActivate={(cellId, rowId, colId) => {
    console.log('Activated:', { cellId, rowId, colId });
  }}
/>

API

Grid Props

Prop Type Default Description
columns GridColumnDef[] required Column definitions
rows GridRowData[] required Row data
ariaLabel string - Accessible name for grid
ariaLabelledby string - ID reference for accessible name
selectable boolean false Enable cell selection
multiselectable boolean false Enable multi-cell selection
selectedIds string[] [] Selected cell IDs
onSelectionChange (ids: string[]) => void - Selection change callback
onCellActivate (cellId, rowId, colId) => void - Cell activation callback
wrapNavigation boolean false Wrap navigation at row edges
pageSize number 5 Rows to skip with PageUp/Down

Type Definitions

Types
interface GridColumnDef {
  id: string;
  header: string;
}

interface GridCellData {
  id: string;
  value: string;
  disabled?: boolean;
}

interface GridRowData {
  id: string;
  cells: GridCellData[];
}

Testing

Grid tests focus on 2D keyboard navigation, cell selection, and ARIA attributes.

Grid.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Grid, type GridColumnDef, type GridRowData } from './Grid';

// Helper function to create basic grid data
const createBasicColumns = (): GridColumnDef[] => [
  { id: 'name', header: 'Name' },
  { id: 'email', header: 'Email' },
  { id: 'role', header: 'Role' },
];

const createBasicRows = (): GridRowData[] => [
  {
    id: 'row1',
    cells: [
      { id: 'row1-0', value: 'Alice' },
      { id: 'row1-1', value: 'alice@example.com' },
      { id: 'row1-2', value: 'Admin' },
    ],
  },
  {
    id: 'row2',
    cells: [
      { id: 'row2-0', value: 'Bob' },
      { id: 'row2-1', value: 'bob@example.com' },
      { id: 'row2-2', value: 'User' },
    ],
  },
  {
    id: 'row3',
    cells: [
      { id: 'row3-0', value: 'Charlie' },
      { id: 'row3-1', value: 'charlie@example.com' },
      { id: 'row3-2', value: 'User' },
    ],
  },
];

// Rows with disabled cells
const createRowsWithDisabled = (): GridRowData[] => [
  {
    id: 'row1',
    cells: [
      { id: 'row1-0', value: 'Alice' },
      { id: 'row1-1', value: 'alice@example.com', disabled: true },
      { id: 'row1-2', value: 'Admin' },
    ],
  },
  {
    id: 'row2',
    cells: [
      { id: 'row2-0', value: 'Bob' },
      { id: 'row2-1', value: 'bob@example.com' },
      { id: 'row2-2', value: 'User' },
    ],
  },
];

// Rows with row header
const createRowsWithRowHeader = (): GridRowData[] => [
  {
    id: 'row1',
    hasRowHeader: true,
    cells: [
      { id: 'row1-0', value: '1' },
      { id: 'row1-1', value: 'Alice' },
      { id: 'row1-2', value: 'Admin' },
    ],
  },
  {
    id: 'row2',
    hasRowHeader: true,
    cells: [
      { id: 'row2-0', value: '2' },
      { id: 'row2-1', value: 'Bob' },
      { id: 'row2-2', value: 'User' },
    ],
  },
];

// Rows with spanned cells
const createRowsWithSpan = (): GridRowData[] => [
  {
    id: 'row1',
    cells: [
      { id: 'row1-0', value: 'Merged', colspan: 2 },
      { id: 'row1-2', value: 'Normal' },
    ],
  },
  {
    id: 'row2',
    cells: [
      { id: 'row2-0', value: 'A' },
      { id: 'row2-1', value: 'B' },
      { id: 'row2-2', value: 'C' },
    ],
  },
];

// Columns with span
const createColumnsWithSpan = (): GridColumnDef[] => [
  { id: 'info', header: 'Info', colspan: 2 },
  { id: 'role', header: 'Role' },
];

describe('Grid', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="grid" on container', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
      expect(screen.getByRole('grid')).toBeInTheDocument();
    });

    it('has role="row" on all rows', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
      // Header row + 3 data rows = 4 rows
      expect(screen.getAllByRole('row')).toHaveLength(4);
    });

    it('has role="gridcell" on data cells', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
      // 3 rows * 3 columns = 9 cells
      expect(screen.getAllByRole('gridcell')).toHaveLength(9);
    });

    it('has role="columnheader" on header cells', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
      expect(screen.getAllByRole('columnheader')).toHaveLength(3);
    });

    it('has role="rowheader" when hasRowHeader', () => {
      render(
        <Grid columns={createBasicColumns()} rows={createRowsWithRowHeader()} ariaLabel="Users" />
      );
      expect(screen.getAllByRole('rowheader')).toHaveLength(2);
    });

    it('has accessible name via aria-label', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);
      expect(screen.getByRole('grid', { name: 'Users' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <div>
          <h2 id="grid-title">User List</h2>
          <Grid
            columns={createBasicColumns()}
            rows={createBasicRows()}
            ariaLabelledby="grid-title"
          />
        </div>
      );
      const grid = screen.getByRole('grid');
      expect(grid).toHaveAttribute('aria-labelledby', 'grid-title');
    });

    it('has aria-multiselectable when multiselectable', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          multiselectable
        />
      );
      expect(screen.getByRole('grid')).toHaveAttribute('aria-multiselectable', 'true');
    });

    it('has aria-selected on selectable cells', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
        />
      );
      const cells = screen.getAllByRole('gridcell');
      cells.forEach((cell) => {
        expect(cell).toHaveAttribute('aria-selected', 'false');
      });
    });

    it('has aria-disabled on disabled cells', () => {
      render(
        <Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
      );
      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      expect(disabledCell).toHaveAttribute('aria-disabled', 'true');
    });

    it('has aria-colspan on spanned cells', () => {
      render(<Grid columns={createBasicColumns()} rows={createRowsWithSpan()} ariaLabel="Users" />);
      const mergedCell = screen.getByRole('gridcell', { name: 'Merged' });
      expect(mergedCell).toHaveAttribute('aria-colspan', '2');
    });

    it('has aria-colspan on spanned columnheader', () => {
      render(<Grid columns={createColumnsWithSpan()} rows={createBasicRows()} ariaLabel="Users" />);
      const infoHeader = screen.getByRole('columnheader', { name: 'Info' });
      expect(infoHeader).toHaveAttribute('aria-colspan', '2');
    });
  });

  // 🔴 High Priority: Keyboard - 2D Navigation
  describe('Keyboard - 2D Navigation', () => {
    it('ArrowRight moves focus one cell right', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard('{ArrowRight}');

      expect(screen.getAllByRole('gridcell')[1]).toHaveFocus();
    });

    it('ArrowLeft moves focus one cell left', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const secondCell = screen.getAllByRole('gridcell')[1];
      secondCell.focus();

      await user.keyboard('{ArrowLeft}');

      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

    it('ArrowDown moves focus one row down', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

      await user.keyboard('{ArrowDown}');

      // Should move to row2, col0
      expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
    });

    it('ArrowUp moves focus one row up', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const secondRowFirstCell = screen.getAllByRole('gridcell')[3]; // row2, col0
      secondRowFirstCell.focus();

      await user.keyboard('{ArrowUp}');

      // Should move to row1, col0
      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

    it('ArrowRight stops at row end (default)', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2 (last in row)
      lastCellInRow.focus();

      await user.keyboard('{ArrowRight}');

      // Should stay at the same cell
      expect(lastCellInRow).toHaveFocus();
    });

    it('ArrowRight wraps to next row when wrapNavigation is true', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          wrapNavigation
        />
      );

      const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2 (last in row)
      lastCellInRow.focus();

      await user.keyboard('{ArrowRight}');

      // Should wrap to first cell of next row
      expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
    });

    it('ArrowDown stops at grid bottom', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastRowCell = screen.getAllByRole('gridcell')[6]; // row3, col0 (last row)
      lastRowCell.focus();

      await user.keyboard('{ArrowDown}');

      // Should stay at the same cell
      expect(lastRowCell).toHaveFocus();
    });

    it('ArrowUp stops at first data row (does not enter headers)', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstDataCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstDataCell.focus();

      await user.keyboard('{ArrowUp}');

      // Should stay at the first data cell, not move to header
      expect(firstDataCell).toHaveFocus();
    });

    it('skips disabled cells during horizontal navigation', async () => {
      const user = userEvent.setup();
      render(
        <Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
      );

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0 (Alice)
      firstCell.focus();

      await user.keyboard('{ArrowRight}');

      // Should skip disabled cell (alice@example.com) and focus Admin
      expect(screen.getByRole('gridcell', { name: 'Admin' })).toHaveFocus();
    });
  });

  // 🔴 High Priority: Keyboard - Extended Navigation
  describe('Keyboard - Extended Navigation', () => {
    it('Home moves to first cell in row', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastCellInRow = screen.getAllByRole('gridcell')[2]; // row1, col2
      lastCellInRow.focus();

      await user.keyboard('{Home}');

      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

    it('End moves to last cell in row', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

      await user.keyboard('{End}');

      expect(screen.getAllByRole('gridcell')[2]).toHaveFocus();
    });

    it('Ctrl+Home moves to first cell in grid', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const lastCell = screen.getAllByRole('gridcell')[8]; // row3, col2 (last cell)
      lastCell.focus();

      await user.keyboard('{Control>}{Home}{/Control}');

      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });

    it('Ctrl+End moves to last cell in grid', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

      await user.keyboard('{Control>}{End}{/Control}');

      expect(screen.getAllByRole('gridcell')[8]).toHaveFocus();
    });

    it('PageDown moves down by pageSize', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enablePageNavigation
          pageSize={2}
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0]; // row1, col0
      firstCell.focus();

      await user.keyboard('{PageDown}');

      // Should move 2 rows down
      expect(screen.getAllByRole('gridcell')[6]).toHaveFocus();
    });

    it('PageUp moves up by pageSize', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          enablePageNavigation
          pageSize={2}
        />
      );

      const lastRowCell = screen.getAllByRole('gridcell')[6]; // row3, col0
      lastRowCell.focus();

      await user.keyboard('{PageUp}');

      // Should move 2 rows up
      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('first focusable cell has tabIndex="0" by default', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const firstCell = screen.getAllByRole('gridcell')[0];
      expect(firstCell).toHaveAttribute('tabindex', '0');
    });

    it('defaultFocusedId sets initial focus', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          defaultFocusedId="row2-1"
        />
      );

      const targetCell = screen.getByRole('gridcell', { name: 'bob@example.com' });
      expect(targetCell).toHaveAttribute('tabindex', '0');
    });

    it('other cells have tabIndex="-1"', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const cells = screen.getAllByRole('gridcell');
      // First cell should have tabindex="0", others should have tabindex="-1"
      expect(cells[0]).toHaveAttribute('tabindex', '0');
      expect(cells[1]).toHaveAttribute('tabindex', '-1');
      expect(cells[2]).toHaveAttribute('tabindex', '-1');
    });

    it('focused cell updates tabIndex on navigation', async () => {
      const user = userEvent.setup();
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const cells = screen.getAllByRole('gridcell');
      cells[0].focus();

      await user.keyboard('{ArrowRight}');

      expect(cells[0]).toHaveAttribute('tabindex', '-1');
      expect(cells[1]).toHaveAttribute('tabindex', '0');
    });

    it('disabled cells are focusable', async () => {
      const user = userEvent.setup();
      render(
        <Grid columns={createBasicColumns()} rows={createRowsWithDisabled()} ariaLabel="Users" />
      );

      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      // Disabled cell should still have tabindex (either 0 or -1)
      expect(disabledCell).toHaveAttribute('tabindex');
    });

    it('Tab focuses grid then exits', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <button>Before</button>
          <Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
          <button>After</button>
        </div>
      );

      const beforeButton = screen.getByRole('button', { name: 'Before' });
      beforeButton.focus();

      await user.tab();
      // Should focus grid (first cell)
      expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();

      await user.tab();
      // Should exit grid to next element
      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });

    it('Shift+Tab exits grid to previous element', async () => {
      const user = userEvent.setup();
      render(
        <div>
          <button>Before</button>
          <Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
          <button>After</button>
        </div>
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      // Use fireEvent for Shift+Tab due to jsdom limitations
      fireEvent.keyDown(firstCell, { key: 'Tab', shiftKey: true });

      // Note: actual focus behavior depends on browser, but we verify the event is handled
    });

    it('columnheader cells are not focusable', () => {
      render(<Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />);

      const headers = screen.getAllByRole('columnheader');
      headers.forEach((header) => {
        expect(header).not.toHaveAttribute('tabindex');
      });
    });
  });

  // 🔴 High Priority: Selection
  describe('Selection', () => {
    it('Space toggles selection (single)', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      expect(firstCell).toHaveAttribute('aria-selected', 'false');

      await user.keyboard(' ');

      expect(firstCell).toHaveAttribute('aria-selected', 'true');
    });

    it('Space toggles selection (multi)', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          multiselectable
        />
      );

      const cells = screen.getAllByRole('gridcell');
      cells[0].focus();

      await user.keyboard(' ');
      expect(cells[0]).toHaveAttribute('aria-selected', 'true');

      await user.keyboard('{ArrowRight}');
      await user.keyboard(' ');

      // Both should be selected in multiselect mode
      expect(cells[0]).toHaveAttribute('aria-selected', 'true');
      expect(cells[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('single selection clears previous on Space', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
        />
      );

      const cells = screen.getAllByRole('gridcell');
      cells[0].focus();

      await user.keyboard(' ');
      expect(cells[0]).toHaveAttribute('aria-selected', 'true');

      await user.keyboard('{ArrowRight}');
      await user.keyboard(' ');

      // Previous selection should be cleared
      expect(cells[0]).toHaveAttribute('aria-selected', 'false');
      expect(cells[1]).toHaveAttribute('aria-selected', 'true');
    });

    it('Enter activates cell', async () => {
      const user = userEvent.setup();
      const onCellActivate = vi.fn();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          onCellActivate={onCellActivate}
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard('{Enter}');

      expect(onCellActivate).toHaveBeenCalledWith('row1-0', 'row1', 'name');
    });

    it('Enter does not activate disabled cell', async () => {
      const user = userEvent.setup();
      const onCellActivate = vi.fn();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createRowsWithDisabled()}
          ariaLabel="Users"
          onCellActivate={onCellActivate}
        />
      );

      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      disabledCell.focus();

      await user.keyboard('{Enter}');

      expect(onCellActivate).not.toHaveBeenCalled();
    });

    it('Space does not select disabled cell', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createRowsWithDisabled()}
          ariaLabel="Users"
          selectable
        />
      );

      const disabledCell = screen.getByRole('gridcell', { name: 'alice@example.com' });
      disabledCell.focus();

      await user.keyboard(' ');

      expect(disabledCell).toHaveAttribute('aria-selected', 'false');
    });

    it('Ctrl+A selects all (multiselectable only)', async () => {
      const user = userEvent.setup();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          multiselectable
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard('{Control>}a{/Control}');

      const cells = screen.getAllByRole('gridcell');
      cells.forEach((cell) => {
        expect(cell).toHaveAttribute('aria-selected', 'true');
      });
    });

    it('calls onSelectionChange callback', async () => {
      const user = userEvent.setup();
      const onSelectionChange = vi.fn();
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          onSelectionChange={onSelectionChange}
        />
      );

      const firstCell = screen.getAllByRole('gridcell')[0];
      firstCell.focus();

      await user.keyboard(' ');

      expect(onSelectionChange).toHaveBeenCalledWith(['row1-0']);
    });

    it('controlled selectedIds overrides internal state', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          selectedIds={['row2-1']}
        />
      );

      const targetCell = screen.getByRole('gridcell', { name: 'bob@example.com' });
      expect(targetCell).toHaveAttribute('aria-selected', 'true');

      const otherCells = screen.getAllByRole('gridcell').filter((cell) => cell !== targetCell);
      otherCells.forEach((cell) => {
        expect(cell).toHaveAttribute('aria-selected', 'false');
      });
    });
  });

  // 🟡 Medium Priority: Virtualization Support
  describe('Virtualization Support', () => {
    it('has aria-rowcount when totalRows provided', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalRows={100}
        />
      );

      expect(screen.getByRole('grid')).toHaveAttribute('aria-rowcount', '100');
    });

    it('has aria-colcount when totalColumns provided', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalColumns={10}
        />
      );

      expect(screen.getByRole('grid')).toHaveAttribute('aria-colcount', '10');
    });

    it('has aria-rowindex on rows when virtualizing', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalRows={100}
          startRowIndex={10}
        />
      );

      const rows = screen.getAllByRole('row');
      // Skip header row (index 0), check data rows
      expect(rows[1]).toHaveAttribute('aria-rowindex', '10');
      expect(rows[2]).toHaveAttribute('aria-rowindex', '11');
      expect(rows[3]).toHaveAttribute('aria-rowindex', '12');
    });

    it('has aria-colindex on cells when virtualizing', () => {
      render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          totalColumns={10}
          startColIndex={5}
        />
      );

      const firstRowCells = screen.getAllByRole('gridcell').slice(0, 3);
      expect(firstRowCells[0]).toHaveAttribute('aria-colindex', '5');
      expect(firstRowCells[1]).toHaveAttribute('aria-colindex', '6');
      expect(firstRowCells[2]).toHaveAttribute('aria-colindex', '7');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(
        <Grid columns={createBasicColumns()} rows={createBasicRows()} ariaLabel="Users" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with selection enabled', async () => {
      const { container } = render(
        <Grid
          columns={createBasicColumns()}
          rows={createBasicRows()}
          ariaLabel="Users"
          selectable
          multiselectable
        />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });
});

Resources