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.

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.

FeatureGridTable
Keyboard Navigation2D (Arrow keys)Table navigation (browser default)
Cell FocusRequired (roving tabindex)Not required
Selectionaria-selectedNot supported
EditingOptionalNot supported
Use CaseSpreadsheet-like, data gridsStatic 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

Grid.astro
---
// =============================================================================
// 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

Example
---
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

PropTypeDefaultDescription
columnsGridColumnDef[]requiredColumn definitions
rowsGridRowData[]requiredRow data
ariaLabelstring-Accessible name for grid
selectablebooleanfalseEnable cell selection
multiselectablebooleanfalseEnable multi-cell selection
The Astro version uses a custom element <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

TestDescription
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-labelGrid has accessible name via aria-label
aria-labelledbyGrid has accessible name via aria-labelledby
aria-multiselectablePresent when multi-selection is enabled
aria-selectedPresent on all cells when selection is enabled
aria-disabledPresent on disabled cells

High Priority: 2D Keyboard Navigation

TestDescription
ArrowRightMoves focus one cell right
ArrowLeftMoves focus one cell left
ArrowDownMoves focus one row down
ArrowUpMoves focus one row up
ArrowUp at first rowStops at first data row (does not enter headers)
ArrowRight at row endStops at row end (default) or wraps (wrapNavigation)

High Priority: Extended Navigation

TestDescription
HomeMoves focus to first cell in row
EndMoves focus to last cell in row
Ctrl+HomeMoves focus to first cell in grid
Ctrl+EndMoves focus to last cell in grid
PageDownMoves focus down by page size
PageUpMoves focus up by page size

High Priority: Focus Management (Roving Tabindex)

TestDescription
tabindex="0"First focusable cell has tabindex="0"
tabindex="-1"Other cells have tabindex="-1"
Headers not focusablecolumnheader cells have no tabindex (not focusable)
Tab exits gridTab moves focus out of grid
Focus updateFocused cell updates tabindex on navigation
Disabled cellsDisabled cells are focusable but not activatable

High Priority: Selection

TestDescription
Space togglesSpace toggles cell selection (when selectable)
Single selectSingle selection clears previous on Space
Multi selectMulti-selection allows multiple cells
Enter activatesEnter triggers cell activation
Disabled no selectSpace does not select disabled cell
Disabled no activateEnter does not activate disabled cell

Medium Priority: Virtualization Support

TestDescription
aria-rowcountPresent when totalRows provided
aria-colcountPresent when totalColumns provided
aria-rowindexPresent on rows/cells when virtualizing
aria-colindexPresent on cells when virtualizing

Medium Priority: Accessibility

TestDescription
axe-coreNo accessibility violations

Testing Tools

See the Testing Strategy guide for details.

Grid.test.astro.ts
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