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

WAI-ARIA Roles

RoleTarget ElementDescription
gridContainerThe grid container (composite widget)
rowRow containerGroups cells horizontally
columnheaderHeader cellsColumn headers (not focusable in this implementation)
rowheaderRow header cellRow headers (optional)
gridcellData cellsInteractive 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

KeyAction
Move focus one cell right
Move focus one cell left
Move focus one row down
Move focus one row up
HomeMove focus to first cell in row
EndMove focus to last cell in row
Ctrl + HomeMove focus to first cell in grid
Ctrl + EndMove focus to last cell in grid
PageDownMove focus down by page size (default 5)
PageUpMove focus up by page size (default 5)

Selection & Activation

KeyAction
SpaceSelect/deselect focused cell (when selectable)
EnterActivate 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

EventBehavior
Roving tabindexOnly one cell has tabindex="0" (the focused cell), all others have tabindex="-1"
Single Tab stopGrid is a single Tab stop (Tab enters grid, Shift+Tab exits)
Header cellsHeader cells (columnheader) are NOT focusable (no sort functionality in this implementation)
Data cells onlyOnly gridcells in the data rows are included in keyboard navigation
Focus memoryLast focused cell is remembered when leaving and re-entering the grid

References

Source Code

Grid.svelte
<script lang="ts">
  import { SvelteMap } from 'svelte/reactivity';

  // =============================================================================
  // 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;
    selectedIds?: string[];
    defaultSelectedIds?: string[];
    defaultFocusedId?: string;
    totalColumns?: number;
    totalRows?: number;
    startRowIndex?: number;
    startColIndex?: number;
    wrapNavigation?: boolean;
    enablePageNavigation?: boolean;
    pageSize?: number;
    onSelectionChange?: (selectedIds: string[]) => void;
    onFocusChange?: (focusedId: string | null) => void;
    onCellActivate?: (cellId: string, rowId: string, colId: string) => void;
    renderCell?: (cell: GridCellData, rowId: string, colId: string) => string | number;
  }

  // =============================================================================
  // Props
  // =============================================================================

  let {
    columns,
    rows,
    ariaLabel,
    ariaLabelledby,
    selectable = false,
    multiselectable = false,
    selectedIds: controlledSelectedIds,
    defaultSelectedIds = [],
    defaultFocusedId,
    totalColumns,
    totalRows,
    startRowIndex = 1,
    startColIndex = 1,
    wrapNavigation = false,
    enablePageNavigation = false,
    pageSize = 5,
    onSelectionChange,
    onFocusChange,
    onCellActivate,
    renderCell,
  }: Props = $props();

  // =============================================================================
  // State
  // =============================================================================

  let internalSelectedIds = $state<string[]>([]);
  let focusedIdState = $state<string | null>(null);
  let initialized = $state(false);

  let gridRef: HTMLDivElement | null = $state(null);
  let cellRefs: Map<string, HTMLDivElement> = new SvelteMap();

  // Compute effective focused ID (use state if set, otherwise derive from props)
  const focusedId = $derived(focusedIdState ?? defaultFocusedId ?? rows[0]?.cells[0]?.id ?? null);

  // Initialize selection state on mount
  $effect(() => {
    if (!initialized && rows.length > 0) {
      internalSelectedIds = defaultSelectedIds ? [...defaultSelectedIds] : [];
      initialized = true;
    }
  });

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

  // Svelte action to register cell refs
  function registerCell(node: HTMLDivElement, cellId: string) {
    cellRefs.set(cellId, node);
    return {
      destroy() {
        cellRefs.delete(cellId);
      },
    };
  }

  // =============================================================================
  // Derived
  // =============================================================================

  const selectedIds = $derived(controlledSelectedIds ?? internalSelectedIds);

  // Map cellId to cell info for O(1) lookup
  const cellById = $derived.by(() => {
    const map = new SvelteMap<
      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;
  });

  // =============================================================================
  // Methods
  // =============================================================================

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

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

  function setFocusedId(id: string | null) {
    focusedIdState = id;
    onFocusChange?.(id);
  }

  function focusCell(cellId: string) {
    const cellEl = cellRefs.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);
    }
  }

  function findNextFocusableCell(
    startRow: number,
    startCol: number,
    direction: 'right' | 'left' | 'up' | 'down',
    skipDisabled = true
  ): { rowIndex: number; colIndex: number; cell: GridCellData } | null {
    const colCount = columns.length;
    const rowCount = rows.length;

    let rowIdx = startRow;
    let colIdx = startCol;

    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--;
              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;
    };

    if (!step()) return null;

    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;
  }

  function setSelectedIds(ids: string[]) {
    internalSelectedIds = ids;
    onSelectionChange?.(ids);
  }

  function toggleSelection(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);
    }
  }

  function selectAll() {
    if (!selectable || !multiselectable) {
      return;
    }

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

  function handleKeyDown(event: 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) {
          const firstCell = rows[0]?.cells[0];
          if (firstCell) focusCell(firstCell.id);
        } else {
          const firstCellInRow = rows[rowIndex]?.cells[0];
          if (firstCellInRow) focusCell(firstCellInRow.id);
        }
        break;
      }
      case 'End': {
        if (ctrlKey) {
          const lastRow = rows[rows.length - 1];
          const lastCell = lastRow?.cells[lastRow.cells.length - 1];
          if (lastCell) focusCell(lastCell.id);
        } else {
          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();
    }
  }
</script>

<div
  bind:this={gridRef}
  role="grid"
  aria-label={ariaLabel}
  aria-labelledby={ariaLabelledby}
  aria-multiselectable={multiselectable ? 'true' : undefined}
  aria-rowcount={totalRows}
  aria-colcount={totalColumns}
  class="apg-grid"
>
  <!-- Header Row -->
  <div role="row" aria-rowindex={totalRows ? 1 : undefined}>
    {#each columns as col, colIndex (col.id)}
      <div
        role="columnheader"
        aria-colindex={totalColumns ? startColIndex + colIndex : undefined}
        aria-colspan={col.colspan}
      >
        {col.header}
      </div>
    {/each}
  </div>

  <!-- Data Rows -->
  {#each rows as row, rowIndex (row.id)}
    <div role="row" aria-rowindex={totalRows ? startRowIndex + rowIndex : undefined}>
      {#each row.cells as cell, colIndex (cell.id)}
        {@const isRowHeader = row.hasRowHeader && colIndex === 0}
        {@const isFocused = cell.id === focusedId}
        {@const isSelected = selectedIds.includes(cell.id)}
        {@const colId = columns[colIndex]?.id ?? ''}
        <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
        <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}
          class="apg-grid-cell"
          class:focused={isFocused}
          class:selected={isSelected}
          class:disabled={cell.disabled}
          onkeydown={(e) => handleKeyDown(e, cell, row.id, colId)}
          onfocusin={() => setFocusedId(cell.id)}
          use:registerCell={cell.id}
        >
          {#if renderCell}
            <!-- eslint-disable-next-line svelte/no-at-html-tags -- renderCell returns sanitized HTML from the consuming application -->
            {@html renderCell(cell, row.id, colId)}
          {:else}
            {cell.value}
          {/if}
        </div>
      {/each}
    </div>
  {/each}
</div>

Usage

Example
<script lang="ts">
  import Grid from './Grid.svelte';

  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' },
      ],
    },
  ];

  let selectedIds = $state<string[]>([]);

  function handleSelectionChange(ids: string[]) {
    selectedIds = ids;
  }

  function handleCellActivate(cellId: string, rowId: string, colId: string) {
    console.log('Activated:', { cellId, rowId, colId });
  }
</script>

<Grid
  {columns}
  {rows}
  ariaLabel="User list"
  selectable
  multiselectable
  {selectedIds}
  onSelectionChange={handleSelectionChange}
  onCellActivate={handleCellActivate}
/>

API

Prop Type Default Description
columns GridColumnDef[] required Column definitions
rows GridRowData[] required Row data
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

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

See testing-strategy.md (opens in new tab) for full documentation.

Grid.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Grid from './Grid.svelte';

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

const createBasicRows = () => [
  {
    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' },
    ],
  },
];

const createRowsWithDisabled = () => [
  {
    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' },
    ],
  },
];

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

    it('has role="row" on all rows', () => {
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });
      expect(screen.getAllByRole('row')).toHaveLength(4);
    });

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

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

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

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

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

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

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

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

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[1]).toHaveFocus();
      });
    });

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

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

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

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[3]).toHaveFocus();
      });
    });

    it('ArrowUp stops at first data row', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

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

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

      await vi.waitFor(() => {
        expect(firstDataCell).toHaveFocus();
      });
    });

    it('Home moves to first cell in row', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: { columns: createBasicColumns(), rows: createBasicRows(), ariaLabel: 'Users' },
      });

      const lastCellInRow = screen.getAllByRole('gridcell')[2];
      lastCellInRow.focus();

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

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[0]).toHaveFocus();
      });
    });

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

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

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

      await vi.waitFor(() => {
        expect(screen.getAllByRole('gridcell')[2]).toHaveFocus();
      });
    });
  });

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

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

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

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

    it('columnheader cells are not focusable', () => {
      render(Grid, {
        props: { 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', async () => {
      const user = userEvent.setup();
      render(Grid, {
        props: {
          columns: createBasicColumns(),
          rows: createBasicRows(),
          ariaLabel: 'Users',
          selectable: true,
        },
      });

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

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

      await user.keyboard(' ');

      await vi.waitFor(() => {
        expect(firstCell).toHaveAttribute('aria-selected', 'true');
      });
    });
  });

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

Resources