APG Patterns
日本語
日本語

Table

A static tabular structure for displaying data with rows and columns.

Demo

Basic Table

A simple static table displaying data. No interactive features.

Name
Age
City
Alice
30
Tokyo
Bob
25
Osaka
Charlie
35
Kyoto
Diana
28
Nagoya
Edward
42
Sapporo

Sortable Table

Click column headers to sort. Uses aria-sort to indicate sort direction. This demo uses a React component withclient:load for interactive sorting.

Alice
30
Tokyo
Bob
25
Osaka
Charlie
35
Kyoto
Diana
28
Nagoya
Edward
42
Sapporo

With Row Headers

First cell in each row uses role="rowheader" for better screen reader navigation.

Name
Age
City
Alice
30
Tokyo
Bob
25
Osaka
Charlie
35
Kyoto

Virtualization Support

For large datasets, use aria-rowcount,aria-colcount, andaria-rowindex to communicate the full table structure.

Name
Age
City
Alice
30
Tokyo
Bob
25
Osaka
Charlie
35
Kyoto
Diana
28
Nagoya
Edward
42
Sapporo

Spanning Cells

Cells can span multiple columns or rows using colspanandrowspan. This implementation uses CSS Grid with grid-column: span Nandgrid-row: span N for visual spanning, plus aria-colspanandaria-rowspan for screen reader accessibility.

Product
Q1
Q2
Q3
Q4
Electronics
150
180
200
220
175
190
210
240
Clothing
N/A
90
120
Total
1775

Open demo only →

Native HTML

Use Native HTML First

Before using this custom component, consider using native <table> elements. They provide built-in semantics, work without JavaScript, and require no ARIA attributes.

<table>
  <caption>User List</caption>
  <thead>
    <tr>
      <th>Name</th>
      <th>Age</th>
      <th>City</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Alice</td>
      <td>30</td>
      <td>Tokyo</td>
    </tr>
  </tbody>
</table>

Use custom implementations only when you need CSS Grid/Flexbox layouts for responsive tables, or when you cannot use native <table> elements due to design constraints.

Use Case Native HTML Custom Implementation
Basic tabular data Recommended Not needed
JavaScript disabled support Works natively Requires fallback
Built-in accessibility Automatic Manual ARIA required
CSS Grid/Flexbox layout Limited (display: table) Full control
Responsive column reordering Limited Full control
Virtualization support Not built-in With ARIA support

The native <table> element with <thead>, <tbody>, <th>, and <td> provides complete semantic structure automatically. The ARIA table pattern is only necessary when using non-table elements (e.g., <div>) for layout purposes.

Accessibility Features

WAI-ARIA Roles

RoleTarget ElementDescription
tableContainer elementIdentifies the element as a table structure containing rows and cells of data.
rowgroupHeader/Body containerGroups rows together (equivalent to <thead>, <tbody>, <tfoot>).
rowRow elementA row of cells within the table (equivalent to <tr>).
columnheaderHeader cellA header cell for a column (equivalent to <th> in header row).
rowheaderHeader cellA header cell for a row (equivalent to <th scope="row">).
cellData cellA data cell within a row (equivalent to <td>).

WAI-ARIA Properties

aria-label

Provides an accessible name for the table. Required for screen reader users to understand the table’s purpose.

Values
String
Required
Yes (or aria-labelledby)

aria-labelledby

References an element that provides the accessible name for the table.

Values
ID reference
Required
Yes (or aria-label)

aria-describedby

References an element providing additional description for the table.

Values
ID reference
Required
No

aria-colcount

Defines the total number of columns in the table when only a subset is rendered (virtualization).

Values
Number
Required
No

aria-rowcount

Defines the total number of rows in the table when only a subset is rendered (virtualization).

Values
Number
Required
No

aria-colindex

Indicates the position of a cell within the full table (virtualization).

Values
Number (1-based)
Required
No

aria-rowindex

Indicates the position of a row within the full table (virtualization).

Values
Number (1-based)
Required
No

aria-colspan

Indicates how many columns the cell spans. Only set when >1.

Values
Number
Required
No

aria-rowspan

Indicates how many rows the cell spans. Only set when >1.

Values
Number
Required
No

WAI-ARIA States

aria-sort

Target Element
columnheader/rowheader
Values
ascending | descending | none | other
Required
No
Change Trigger

When sort order changes (click sort button)

  • The table role creates a static tabular structure for displaying data. Unlike the grid role, tables are not interactive and do not support keyboard navigation between cells.
  • Keyboard support is not applicable for the table pattern. Interactive elements within cells (buttons, links) receive focus through normal tab order.

Focus Management

EventBehavior
Static tableNot applicable - no roving tabindex needed
Interactive elementsLinks/buttons receive focus via normal tab order

Visual Design

  • CSS Grid layout - Uses CSS Grid with subgrid for visual cell spanning support
  • Cell borders - Gap-based borders using background color
  • Sort indicators - Visual icons to indicate sort direction
  • Header styling - Distinct background for header cells

References

Source Code

Table.astro
---
/**
 * APG Table Pattern - Astro Implementation
 *
 * A static tabular structure for displaying data.
 * Uses Web Components for sort button interactions.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/table/
 */

export interface TableColumn {
  id: string;
  header: string;
  /** Column is sortable */
  sortable?: boolean;
  /** Current sort direction */
  sort?: 'ascending' | 'descending' | 'none';
}

/**
 * Cell with spanning support
 */
export interface TableCell {
  content: string;
  /** Number of columns this cell spans */
  colspan?: number;
  /** Number of rows this cell spans */
  rowspan?: number;
}

/**
 * Cell value - can be simple string or object with spanning
 */
export type TableCellValue = string | TableCell;

/**
 * Type guard to check if cell is a TableCell object
 */
function isTableCell(cell: TableCellValue): cell is TableCell {
  return typeof cell === 'object' && cell !== null && 'content' in cell;
}

export interface TableRow {
  id: string;
  cells: TableCellValue[];
  /** First cell is row header */
  hasRowHeader?: boolean;
  /** Row index for virtualization (1-based) */
  rowIndex?: number;
}

export interface Props {
  /** Column definitions */
  columns: TableColumn[];
  /** Row data */
  rows: TableRow[];
  /** Caption text (optional) */
  caption?: string;

  // Virtualization support
  /** Total number of columns (for virtualization) */
  totalColumns?: number;
  /** Total number of rows (for virtualization) */
  totalRows?: number;
  /** Starting column index (1-based, for virtualization) */
  startColIndex?: number;

  /** Additional CSS class */
  class?: string;
  /** Table id */
  id?: string;
  /** Accessible label */
  'aria-label'?: string;
  /** Reference to external label element */
  'aria-labelledby'?: string;
  /** Reference to description element */
  'aria-describedby'?: string;
}

const {
  columns,
  rows,
  caption,
  totalColumns,
  totalRows,
  startColIndex,
  class: className = '',
  id,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'aria-describedby': ariaDescribedby,
} = Astro.props;

function getSortIcon(sort?: 'ascending' | 'descending' | 'none'): string {
  if (sort === 'ascending') return '▲';
  if (sort === 'descending') return '▼';
  return '⇅';
}

function getCellGridStyle(cell: TableCellValue): string | undefined {
  if (!isTableCell(cell)) return undefined;
  const styles: string[] = [];
  if (cell.colspan && cell.colspan > 1) {
    styles.push(`grid-column: span ${cell.colspan}`);
  }
  if (cell.rowspan && cell.rowspan > 1) {
    styles.push(`grid-row: span ${cell.rowspan}`);
  }
  return styles.length > 0 ? styles.join('; ') : undefined;
}
---

<apg-table class={`apg-table ${className}`.trim()} style={`--table-cols: ${columns.length}`}>
  <div
    role="table"
    aria-label={ariaLabel}
    aria-labelledby={ariaLabelledby}
    aria-describedby={ariaDescribedby}
    aria-colcount={totalColumns}
    aria-rowcount={totalRows}
    id={id}
  >
    {caption && <div class="apg-table-caption">{caption}</div>}

    {/* Header rowgroup */}
    <div role="rowgroup" class="apg-table-header">
      <div role="row" class="apg-table-row">
        {
          columns.map((column, colIndex) => (
            <div
              role="columnheader"
              class="apg-table-columnheader"
              aria-sort={column.sortable ? column.sort || 'none' : undefined}
              aria-colindex={startColIndex !== undefined ? startColIndex + colIndex : undefined}
            >
              {column.sortable ? (
                <button
                  type="button"
                  class="apg-table-sort-button"
                  data-sort-column={column.id}
                  data-current-sort={column.sort || 'none'}
                  aria-label={`Sort by ${column.header}`}
                >
                  {column.header}
                  <span class="apg-table-sort-icon" aria-hidden="true">
                    {getSortIcon(column.sort)}
                  </span>
                </button>
              ) : (
                column.header
              )}
            </div>
          ))
        }
      </div>
    </div>

    {/* Body rowgroup */}
    <div role="rowgroup" class="apg-table-body">
      {
        rows.map((row) => (
          <div role="row" class="apg-table-row" aria-rowindex={row.rowIndex}>
            {row.cells.map((cell, cellIndex) => {
              const isRowHeader = row.hasRowHeader && cellIndex === 0;
              const cellRole = isRowHeader ? 'rowheader' : 'cell';
              const cellData = isTableCell(cell) ? cell : { content: cell };
              const gridStyle = getCellGridStyle(cell);
              return (
                <div
                  role={cellRole}
                  class={`apg-table-${cellRole}`}
                  style={gridStyle}
                  aria-colindex={
                    startColIndex !== undefined ? startColIndex + cellIndex : undefined
                  }
                  aria-colspan={
                    cellData.colspan && cellData.colspan > 1 ? cellData.colspan : undefined
                  }
                  aria-rowspan={
                    cellData.rowspan && cellData.rowspan > 1 ? cellData.rowspan : undefined
                  }
                >
                  {cellData.content}
                </div>
              );
            })}
          </div>
        ))
      }
    </div>
  </div>
</apg-table>

<script>
  class ApgTable extends HTMLElement {
    connectedCallback() {
      this.setupSortHandlers();
    }

    private setupSortHandlers() {
      const sortButtons = this.querySelectorAll('[data-sort-column]');
      sortButtons.forEach((button) => {
        button.addEventListener('click', (e) => {
          const target = e.currentTarget as HTMLElement;
          const columnId = target.dataset.sortColumn;
          const currentSort = target.dataset.currentSort;
          const newDirection = currentSort === 'ascending' ? 'descending' : 'ascending';

          this.dispatchEvent(
            new CustomEvent('sortchange', {
              detail: { columnId, direction: newDirection },
              bubbles: true,
            })
          );
        });
      });
    }
  }

  if (!customElements.get('apg-table')) {
    customElements.define('apg-table', ApgTable);
  }
</script>

Usage

Example
---
import Table from './Table.astro';

const columns = [
  { id: 'name', header: 'Name' },
  { id: 'age', header: 'Age' },
  { id: 'city', header: 'City' },
];

const rows = [
  { id: '1', cells: ['Alice', '30', 'Tokyo'] },
  { id: '2', cells: ['Bob', '25', 'Osaka'] },
  { id: '3', cells: ['Charlie', '35', 'Kyoto'] },
];

// Sortable columns
const sortableColumns = [
  { id: 'name', header: 'Name', sortable: true, sort: 'ascending' as const },
  { id: 'age', header: 'Age', sortable: true },
  { id: 'city', header: 'City' },
];

// With row headers
const rowsWithHeaders = [
  { id: '1', cells: ['Alice', '30', 'Tokyo'], hasRowHeader: true },
  { id: '2', cells: ['Bob', '25', 'Osaka'], hasRowHeader: true },
];
---

<!-- Basic table -->
<Table columns={columns} rows={rows} aria-label="User List" />

<!-- Sortable table -->
<Table
  columns={sortableColumns}
  rows={rows}
  aria-label="Sortable User List"
/>

<!-- With row headers -->
<Table
  columns={columns}
  rows={rowsWithHeaders}
  aria-label="User List with Row Headers"
/>

API

Prop Type Default Description
columns TableColumn[] required Column definitions
rows TableRow[] required Row data
caption string - Optional table caption
totalColumns number - Total columns (for virtualization)
totalRows number - Total rows (for virtualization)
startColIndex number - Starting column index (1-based)

TableColumn Props

Prop Type Default Description
id string required Unique column identifier
header string required Column header text
sortable boolean false Whether column is sortable
sort 'ascending' | 'descending' | 'none' - Current sort direction

TableCell Props

Prop Type Default Description
content string required Cell content
colspan number - Number of columns to span
rowspan number - Number of rows to span

TableRow Props

Prop Type Default Description
id string required Unique row identifier
cells (string | TableCell)[] required Array of cell data
hasRowHeader boolean false Whether first cell is a row header
rowIndex number - Row index for virtualization

Custom Events

Event Detail Description
sortchange { columnId: string, direction: string } Dispatched when sort buttons are clicked (Web Component)

Testing

Tests verify APG compliance for ARIA roles, table structure, and accessibility requirements. Since Table is a static structure, keyboard interaction tests are not applicable.

Test Categories

High Priority : ARIA Structure

Test Description
role="table" Container element has the table role
role="rowgroup" Header and body groups have rowgroup role
role="row" All rows have the row role
role="columnheader" Header cells have columnheader role
role="rowheader" Row header cells have rowheader role when specified
role="cell" Data cells have the cell role

High Priority : Accessible Name

Test Description
aria-label Accessible name via aria-label attribute
aria-labelledby Accessible name via external element reference
caption Caption is displayed when provided

High Priority : Sort State

Test Description
aria-sort="ascending" Column sorted in ascending order
aria-sort="descending" Column sorted in descending order
aria-sort="none" Sortable column that is not currently sorted
no aria-sort Non-sortable columns have no aria-sort attribute
onSortChange callback Callback is invoked when sort button is clicked
sort toggle Clicking sorted column toggles direction

Medium Priority : Virtualization Support

Test Description
aria-colcount Total column count for virtualized tables
aria-rowcount Total row count for virtualized tables
aria-rowindex Row position in the full table
aria-colindex Cell position in the full table

Medium Priority : Visual Cell Spanning (Browser Tests)

Test Description
colspan width Cell with colspan=2 has ~2x width of normal cell
rowspan height Cell with rowspan=2 has ~2x height of normal cell
full span Cell spanning all columns matches table width
combined spans Cell with both colspan and rowspan has correct dimensions

Note: Visual spanning tests use getBoundingClientRect() and require a real browser environment (Vitest browser mode with Playwright). These tests are in *.browser.test.ts files.

Medium Priority : Accessibility

Test Description
axe violations No accessibility violations detected by axe-core
sortable columns No violations with sortable column headers
row headers No violations with row header cells
empty table No violations with empty data rows

Medium Priority : Edge Cases

Test Description
empty rows Table renders correctly with no data rows
single column Table handles single column correctly

Low Priority : HTML Attribute Inheritance

Test Description
className Custom class is applied to container
id ID attribute is set correctly
data-* Data attributes are passed through

Testing Tools

E2E Test Code

The following Playwright tests verify Table behavior across all four frameworks (React, Vue, Svelte, Astro). Tests cover ARIA structure, sort state, virtualization attributes, and visual cell spanning.

e2e/table.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Table Pattern
 *
 * Test coverage based on APG Table pattern and llm.md Test Checklist:
 *
 * High Priority: ARIA Structure
 * - Container has role="table"
 * - All rows have role="row"
 * - Data cells have role="cell"
 * - Column headers have role="columnheader"
 * - Row headers have role="rowheader" (when present)
 * - Groups have role="rowgroup" (when present)
 *
 * High Priority: Accessible Name
 * - Table has accessible name via aria-label
 *
 * High Priority: Sort State
 * - Sorted column has aria-sort="ascending" or "descending"
 * - Unsorted sortable columns have aria-sort="none"
 * - Sort changes update aria-sort attribute
 *
 * Medium Priority: Virtualization
 * - aria-rowcount matches total rows
 * - aria-rowindex is 1-based on rows
 *
 * Medium Priority: Cell Spanning
 * - aria-colspan is set when cell spans >1 columns
 * - aria-rowspan is set when cell spans >1 rows
 * - Visual spanning matches ARIA attributes
 *
 * Medium Priority: Accessibility
 * - No axe-core violations (WCAG 2.1 AA)
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

for (const framework of frameworks) {
  test.describe(`Table (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/table/${framework}/`);
      // Wait for first table to be visible instead of networkidle (more stable)
      await expect(page.locator('[role="table"]').first()).toBeVisible();
    });

    // =========================================================================
    // High Priority: ARIA Structure
    // =========================================================================
    test.describe('APG: ARIA Structure', () => {
      test('required demos are present (sortable, row headers, spanning)', async ({ page }) => {
        // Verify all required demo tables are present
        await expect(page.locator('[role="table"][aria-label="Sortable User List"]')).toBeVisible();
        await expect(
          page.locator('[role="table"][aria-label="User List with Row Headers"]')
        ).toBeVisible();
        await expect(page.locator('[role="table"][aria-label*="Spanning"]')).toBeVisible();
      });

      test('has role="table" on container', async ({ page }) => {
        const tables = page.locator('[role="table"]');
        await expect(tables.first()).toBeVisible();
        // Multiple tables on the demo page
        expect(await tables.count()).toBeGreaterThanOrEqual(1);
      });

      test('has role="row" on all rows', async ({ page }) => {
        // Check the basic table (first one)
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const rows = basicTable.locator('[role="row"]');
        // 1 header row + 5 data rows = 6 rows
        await expect(rows).toHaveCount(6);
      });

      test('has role="columnheader" on header cells', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const headers = basicTable.locator('[role="columnheader"]');
        // 3 columns: Name, Age, City
        await expect(headers).toHaveCount(3);
        await expect(headers.nth(0)).toContainText('Name');
        await expect(headers.nth(1)).toContainText('Age');
        await expect(headers.nth(2)).toContainText('City');
      });

      test('has role="cell" on data cells', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const cells = basicTable.locator('[role="cell"]');
        // 5 rows x 3 columns = 15 cells
        await expect(cells).toHaveCount(15);
      });

      test('has role="rowgroup" for header and body sections', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const rowgroups = basicTable.locator('[role="rowgroup"]');
        // 2 rowgroups: header and body
        await expect(rowgroups).toHaveCount(2);
      });

      test('has role="rowheader" when hasRowHeader is true', async ({ page }) => {
        // Use the table with row headers
        const rowHeaderTable = page.locator(
          '[role="table"][aria-label="User List with Row Headers"]'
        );
        await expect(rowHeaderTable).toBeVisible();

        const rowheaders = rowHeaderTable.locator('[role="rowheader"]');
        // 3 data rows with row headers
        await expect(rowheaders).toHaveCount(3);

        // Verify the first row header contains expected content
        await expect(rowheaders.first()).toContainText('Alice');
      });
    });

    // =========================================================================
    // High Priority: Accessible Name
    // =========================================================================
    test.describe('APG: Accessible Name', () => {
      test('table has accessible name via aria-label', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();
        await expect(basicTable).toHaveAttribute('aria-label', 'User List');
      });

      test('all tables have accessible names', async ({ page }) => {
        const tables = page.locator('[role="table"]');
        const count = await tables.count();

        for (let i = 0; i < count; i++) {
          const table = tables.nth(i);
          const ariaLabel = await table.getAttribute('aria-label');
          const ariaLabelledby = await table.getAttribute('aria-labelledby');
          // Each table must have either aria-label or aria-labelledby
          expect(ariaLabel || ariaLabelledby).toBeTruthy();
        }
      });
    });

    // =========================================================================
    // High Priority: Sort State
    // =========================================================================
    test.describe('APG: Sort State', () => {
      test('sorted column has aria-sort="ascending"', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        // Name column is initially sorted ascending
        const nameHeader = sortableTable
          .locator('[role="columnheader"]')
          .filter({ hasText: 'Name' });
        await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
      });

      test('unsorted sortable columns have aria-sort="none"', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        // Age and City columns should have aria-sort="none"
        const ageHeader = sortableTable.locator('[role="columnheader"]').filter({ hasText: 'Age' });
        const cityHeader = sortableTable
          .locator('[role="columnheader"]')
          .filter({ hasText: 'City' });

        await expect(ageHeader).toHaveAttribute('aria-sort', 'none');
        await expect(cityHeader).toHaveAttribute('aria-sort', 'none');
      });

      test('clicking sort button changes aria-sort', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        const ageHeader = sortableTable.locator('[role="columnheader"]').filter({ hasText: 'Age' });
        const sortButton = ageHeader.locator('button');

        // Initially none
        await expect(ageHeader).toHaveAttribute('aria-sort', 'none');

        // Click to sort ascending
        await sortButton.click();
        await expect(ageHeader).toHaveAttribute('aria-sort', 'ascending');

        // Name should now be none
        const nameHeader = sortableTable
          .locator('[role="columnheader"]')
          .filter({ hasText: 'Name' });
        await expect(nameHeader).toHaveAttribute('aria-sort', 'none');

        // Click again to sort descending
        await sortButton.click();
        await expect(ageHeader).toHaveAttribute('aria-sort', 'descending');
      });

      test('data rows are reordered when sort changes', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        // Click Age column to sort ascending (youngest first)
        const ageHeader = sortableTable.locator('[role="columnheader"]').filter({ hasText: 'Age' });
        await ageHeader.locator('button').click();

        // The first data row should now have the youngest person (Bob, 25)
        // Use toContainText to handle whitespace variations
        const firstCell = sortableTable.locator(
          '[role="rowgroup"]:last-child [role="row"]:first-child [role="cell"]:first-child'
        );
        await expect(firstCell).toContainText('Bob');
      });
    });

    // =========================================================================
    // Medium Priority: Virtualization
    // =========================================================================
    test.describe('APG: Virtualization', () => {
      test('aria-rowcount indicates total rows', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        // Total rows is set to totalRows prop value (100 data rows, not including header)
        await expect(virtualizedTable).toHaveAttribute('aria-rowcount', '100');
      });

      test('aria-colcount indicates total columns', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        await expect(virtualizedTable).toHaveAttribute('aria-colcount', '3');
      });

      test('data rows have aria-rowindex indicating position', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        // Data rows have rowIndex values from the demo data (5, 6, 7, 8, 9)
        const dataRows = virtualizedTable.locator('[role="rowgroup"]:last-child [role="row"]');
        await expect(dataRows.nth(0)).toHaveAttribute('aria-rowindex', '5');
        await expect(dataRows.nth(1)).toHaveAttribute('aria-rowindex', '6');
        await expect(dataRows.nth(2)).toHaveAttribute('aria-rowindex', '7');
        await expect(dataRows.nth(3)).toHaveAttribute('aria-rowindex', '8');
        await expect(dataRows.nth(4)).toHaveAttribute('aria-rowindex', '9');
      });

      test('cells have aria-colindex indicating column position', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        // First row cells should have colindex 1, 2, 3
        const firstRowCells = virtualizedTable.locator(
          '[role="rowgroup"]:last-child [role="row"]:first-child [role="cell"]'
        );
        await expect(firstRowCells.nth(0)).toHaveAttribute('aria-colindex', '1');
        await expect(firstRowCells.nth(1)).toHaveAttribute('aria-colindex', '2');
        await expect(firstRowCells.nth(2)).toHaveAttribute('aria-colindex', '3');
      });
    });

    // =========================================================================
    // Medium Priority: Cell Spanning
    // =========================================================================
    test.describe('APG: Cell Spanning', () => {
      test('aria-colspan and aria-rowspan attributes are present', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        // Verify colspan attributes exist
        const colspanCells = spanningTable.locator('[aria-colspan]');
        await expect(colspanCells).toHaveCount(2); // N/A (colspan=2) and Total (colspan=4)

        // Verify rowspan attributes exist
        const rowspanCells = spanningTable.locator('[aria-rowspan]');
        await expect(rowspanCells).toHaveCount(1); // Electronics (rowspan=2)

        // Verify specific values
        await expect(spanningTable.locator('[aria-colspan="2"]')).toHaveCount(1);
        await expect(spanningTable.locator('[aria-colspan="4"]')).toHaveCount(1);
        await expect(spanningTable.locator('[aria-rowspan="2"]')).toHaveCount(1);
      });

      test('colspan=2 cell is visually wider than single column header', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        const colspanCell = spanningTable.locator('[aria-colspan="2"]').first();
        await expect(colspanCell).toBeVisible();

        // Use header cell as reference (more consistent sizing than data cells)
        const headerCell = spanningTable.locator('[role="columnheader"]').first();
        await expect(headerCell).toBeVisible();

        // Wait for layout to stabilize and verify colspan cell is wider
        // Using header width as baseline: colspan=2 should be ~2x header width
        await expect
          .poll(
            async () => {
              const [colspanBox, headerBox] = await Promise.all([
                colspanCell.boundingBox(),
                headerCell.boundingBox(),
              ]);
              if (!colspanBox || !headerBox || headerBox.width === 0) return 0;
              return colspanBox.width / headerBox.width;
            },
            { timeout: 5000 }
          )
          .toBeGreaterThan(1.5); // Allow some tolerance for padding/borders
      });

      test('colspan=4 cell spans most of the table width', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        const fullSpanCell = spanningTable.locator('[aria-colspan="4"]').first();
        await expect(fullSpanCell).toBeVisible();

        // Use header cell as reference
        const headerCell = spanningTable.locator('[role="columnheader"]').first();
        await expect(headerCell).toBeVisible();

        // colspan=4 should be significantly wider than a single header
        await expect
          .poll(
            async () => {
              const [fullSpanBox, headerBox] = await Promise.all([
                fullSpanCell.boundingBox(),
                headerCell.boundingBox(),
              ]);
              if (!fullSpanBox || !headerBox || headerBox.width === 0) return 0;
              return fullSpanBox.width / headerBox.width;
            },
            { timeout: 5000 }
          )
          .toBeGreaterThan(3.0); // Should be ~4x but allow tolerance
      });

      test('rowspan=2 cell is visually taller than single row', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        const rowspanCell = spanningTable.locator('[aria-rowspan="2"]').first();
        await expect(rowspanCell).toBeVisible();

        // Use a normal data cell in the same table as reference
        const normalCell = spanningTable
          .locator('[role="cell"]:not([aria-colspan]):not([aria-rowspan])')
          .first();
        await expect(normalCell).toBeVisible();

        // rowspan=2 should be ~2x the height of a normal cell
        await expect
          .poll(
            async () => {
              const [rowspanBox, normalBox] = await Promise.all([
                rowspanCell.boundingBox(),
                normalCell.boundingBox(),
              ]);
              if (!rowspanBox || !normalBox || normalBox.height === 0) return 0;
              return rowspanBox.height / normalBox.height;
            },
            { timeout: 5000 }
          )
          .toBeGreaterThan(1.5); // Allow some tolerance
      });
    });

    // =========================================================================
    // Medium Priority: Accessibility
    // =========================================================================
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const accessibilityScanResults = await new AxeBuilder({ page })
          .include('[role="table"]')
          .analyze();

        expect(accessibilityScanResults.violations).toEqual([]);
      });
    });
  });
}
Table.test.astro.ts
/**
 * Table Astro Component Tests using Container API
 *
 * These tests verify the actual Table.astro component output using Astro's Container API.
 * This ensures the component renders correct ARIA structure and attributes.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Table from './Table.astro';
// Import types from React implementation (types are shared across frameworks)
import type { TableColumn, TableRow, TableCell } from './Table';

describe('Table (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  // Helper to render and parse HTML
  async function renderTable(props: {
    columns: TableColumn[];
    rows: TableRow[];
    caption?: string;
    totalColumns?: number;
    totalRows?: number;
    startColIndex?: number;
    'aria-label'?: string;
    'aria-labelledby'?: string;
    'aria-describedby'?: string;
    class?: string;
    id?: string;
  }): Promise<Document> {
    const html = await container.renderToString(Table, { props });
    const dom = new JSDOM(html);
    return dom.window.document;
  }

  const basicColumns: TableColumn[] = [
    { id: 'name', header: 'Name' },
    { id: 'age', header: 'Age' },
    { id: 'city', header: 'City' },
  ];

  const basicRows: TableRow[] = [
    { id: '1', cells: ['Alice', '30', 'Tokyo'] },
    { id: '2', cells: ['Bob', '25', 'Osaka'] },
    { id: '3', cells: ['Charlie', '35', 'Kyoto'] },
  ];

  const sortableColumns: TableColumn[] = [
    { id: 'name', header: 'Name', sortable: true, sort: 'ascending' },
    { id: 'age', header: 'Age', sortable: true },
    { id: 'city', header: 'City' },
  ];

  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has role="table" on container', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const table = doc.querySelector('[role="table"]');
      expect(table).not.toBeNull();
    });

    it('has role="rowgroup" on header and body groups', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const rowgroups = doc.querySelectorAll('[role="rowgroup"]');
      expect(rowgroups).toHaveLength(2);
    });

    it('has role="row" on all rows', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const rows = doc.querySelectorAll('[role="row"]');
      expect(rows).toHaveLength(4); // 1 header + 3 data
    });

    it('has role="columnheader" on header cells', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const headers = doc.querySelectorAll('[role="columnheader"]');
      expect(headers).toHaveLength(3);
      expect(headers[0]?.textContent).toContain('Name');
    });

    it('has role="cell" on data cells', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const cells = doc.querySelectorAll('[role="cell"]');
      expect(cells).toHaveLength(9);
    });

    it('has role="rowheader" on row headers when hasRowHeader is true', async () => {
      const rowsWithRowHeader: TableRow[] = [
        { id: '1', cells: ['Alice', '30', 'Tokyo'], hasRowHeader: true },
        { id: '2', cells: ['Bob', '25', 'Osaka'], hasRowHeader: true },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithRowHeader,
        'aria-label': 'Test Table',
      });
      const rowheaders = doc.querySelectorAll('[role="rowheader"]');
      expect(rowheaders).toHaveLength(2);
      expect(rowheaders[0]?.textContent?.trim()).toBe('Alice');
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('APG: Accessible Name', () => {
    it('has accessible name via aria-label', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'User List',
      });
      const table = doc.querySelector('[role="table"]');
      expect(table?.getAttribute('aria-label')).toBe('User List');
    });

    it('has accessible name via aria-labelledby', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-labelledby': 'table-title',
      });
      const table = doc.querySelector('[role="table"]');
      expect(table?.getAttribute('aria-labelledby')).toBe('table-title');
    });

    it('has description via aria-describedby', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
        'aria-describedby': 'desc',
      });
      const table = doc.querySelector('[role="table"]');
      expect(table?.getAttribute('aria-describedby')).toBe('desc');
    });

    it('displays caption when provided', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
        caption: 'User Data',
      });
      const caption = doc.querySelector('.apg-table-caption');
      expect(caption?.textContent).toBe('User Data');
    });
  });

  // 🔴 High Priority: Sort State
  describe('APG: Sort State', () => {
    it('has aria-sort="ascending" on ascending sorted column', async () => {
      const doc = await renderTable({
        columns: sortableColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const nameHeader = doc.querySelector('[role="columnheader"]');
      expect(nameHeader?.getAttribute('aria-sort')).toBe('ascending');
    });

    it('has aria-sort="descending" on descending sorted column', async () => {
      const columns: TableColumn[] = [
        { id: 'name', header: 'Name', sortable: true, sort: 'descending' },
        { id: 'age', header: 'Age', sortable: true },
      ];
      const doc = await renderTable({
        columns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const nameHeader = doc.querySelector('[role="columnheader"]');
      expect(nameHeader?.getAttribute('aria-sort')).toBe('descending');
    });

    it('has aria-sort="none" on unsorted sortable columns', async () => {
      const doc = await renderTable({
        columns: sortableColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const headers = doc.querySelectorAll('[role="columnheader"]');
      // Second column (Age) is sortable but not sorted
      expect(headers[1]?.getAttribute('aria-sort')).toBe('none');
    });

    it('does not have aria-sort on non-sortable columns', async () => {
      const doc = await renderTable({
        columns: sortableColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const headers = doc.querySelectorAll('[role="columnheader"]');
      // Third column (City) is not sortable
      expect(headers[2]?.hasAttribute('aria-sort')).toBe(false);
    });

    it('sortable header has button with accessible name', async () => {
      const doc = await renderTable({
        columns: sortableColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const sortButton = doc.querySelector('[data-sort-column="name"]');
      expect(sortButton).not.toBeNull();
      expect(sortButton?.getAttribute('aria-label')).toContain('Name');
    });
  });

  // 🟡 Medium Priority: Virtualization Support
  describe('APG: Virtualization Support', () => {
    it('has aria-colcount when totalColumns is provided', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
        totalColumns: 10,
      });
      const table = doc.querySelector('[role="table"]');
      expect(table?.getAttribute('aria-colcount')).toBe('10');
    });

    it('has aria-rowcount when totalRows is provided', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
        totalRows: 100,
      });
      const table = doc.querySelector('[role="table"]');
      expect(table?.getAttribute('aria-rowcount')).toBe('100');
    });

    it('has aria-rowindex on rows when rowIndex is provided', async () => {
      const rowsWithIndex: TableRow[] = [
        { id: '1', cells: ['Alice', '30', 'Tokyo'], rowIndex: 5 },
        { id: '2', cells: ['Bob', '25', 'Osaka'], rowIndex: 6 },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithIndex,
        'aria-label': 'Test Table',
        totalRows: 100,
      });
      const rows = doc.querySelectorAll('[role="row"]');
      // Skip header row (index 0)
      expect(rows[1]?.getAttribute('aria-rowindex')).toBe('5');
      expect(rows[2]?.getAttribute('aria-rowindex')).toBe('6');
    });

    it('has aria-colindex on cells when startColIndex is provided', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
        totalColumns: 10,
        startColIndex: 3,
      });
      const firstDataRow = doc.querySelectorAll('[role="row"]')[1];
      const cells = firstDataRow?.querySelectorAll('[role="cell"]');
      expect(cells?.[0]?.getAttribute('aria-colindex')).toBe('3');
      expect(cells?.[1]?.getAttribute('aria-colindex')).toBe('4');
      expect(cells?.[2]?.getAttribute('aria-colindex')).toBe('5');
    });

    it('does not have virtualization attributes when not provided', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const table = doc.querySelector('[role="table"]');
      expect(table?.hasAttribute('aria-colcount')).toBe(false);
      expect(table?.hasAttribute('aria-rowcount')).toBe(false);

      const rows = doc.querySelectorAll('[role="row"]');
      expect(rows[1]?.hasAttribute('aria-rowindex')).toBe(false);

      const cells = doc.querySelectorAll('[role="cell"]');
      expect(cells[0]?.hasAttribute('aria-colindex')).toBe(false);
    });
  });

  // 🟡 Medium Priority: Cell Spanning
  describe('APG: Cell Spanning', () => {
    it('has aria-colspan when cell spans multiple columns', async () => {
      const rowsWithColspan: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Merged', colspan: 2 } as TableCell, 'Single'],
        },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithColspan,
        'aria-label': 'Test Table',
      });
      const cells = doc.querySelectorAll('[role="cell"]');
      expect(cells[0]?.getAttribute('aria-colspan')).toBe('2');
      expect(cells[1]?.hasAttribute('aria-colspan')).toBe(false);
    });

    it('has aria-rowspan when cell spans multiple rows', async () => {
      const rowsWithRowspan: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Spans 2 rows', rowspan: 2 } as TableCell, 'A', 'B'],
        },
        { id: '2', cells: ['C', 'D'] },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithRowspan,
        'aria-label': 'Test Table',
      });
      const firstDataRow = doc.querySelectorAll('[role="row"]')[1];
      const firstCell = firstDataRow?.querySelector('[role="cell"]');
      expect(firstCell?.getAttribute('aria-rowspan')).toBe('2');
    });

    it('has grid-column span style for colspan cells', async () => {
      const rowsWithColspan: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Merged', colspan: 2 } as TableCell, 'Single'],
        },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithColspan,
        'aria-label': 'Test Table',
      });
      const cells = doc.querySelectorAll('[role="cell"]');
      const style = cells[0]?.getAttribute('style') || '';
      expect(style).toContain('grid-column');
      expect(style).toContain('span 2');
    });

    it('has grid-row span style for rowspan cells', async () => {
      const rowsWithRowspan: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Spans 2 rows', rowspan: 2 } as TableCell, 'A', 'B'],
        },
        { id: '2', cells: ['C', 'D'] },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithRowspan,
        'aria-label': 'Test Table',
      });
      const firstDataRow = doc.querySelectorAll('[role="row"]')[1];
      const firstCell = firstDataRow?.querySelector('[role="cell"]');
      const style = firstCell?.getAttribute('style') || '';
      expect(style).toContain('grid-row');
      expect(style).toContain('span 2');
    });

    it('does not have aria-colspan when colspan is 1', async () => {
      const rowsWithColspanOne: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Single', colspan: 1 } as TableCell, 'B', 'C'],
        },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithColspanOne,
        'aria-label': 'Test Table',
      });
      const cells = doc.querySelectorAll('[role="cell"]');
      expect(cells[0]?.hasAttribute('aria-colspan')).toBe(false);
    });

    it('does not have aria-rowspan when rowspan is 1', async () => {
      const rowsWithRowspanOne: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Single', rowspan: 1 } as TableCell, 'B', 'C'],
        },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithRowspanOne,
        'aria-label': 'Test Table',
      });
      const cells = doc.querySelectorAll('[role="cell"]');
      expect(cells[0]?.hasAttribute('aria-rowspan')).toBe(false);
    });

    it('renders cell content correctly with TableCell object', async () => {
      const rowsWithTableCell: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Cell Content', colspan: 2 } as TableCell, 'Normal'],
        },
      ];
      const doc = await renderTable({
        columns: basicColumns,
        rows: rowsWithTableCell,
        'aria-label': 'Test Table',
      });
      const cells = doc.querySelectorAll('[role="cell"]');
      expect(cells[0]?.textContent?.trim()).toBe('Cell Content');
      expect(cells[1]?.textContent?.trim()).toBe('Normal');
    });
  });

  // 🟡 Medium Priority: CSS Grid Layout
  describe('CSS Grid Layout', () => {
    it('has --table-cols CSS variable set to column count', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const tableContainer = doc.querySelector('.apg-table');
      const style = tableContainer?.getAttribute('style') || '';
      expect(style).toContain('--table-cols');
      expect(style).toContain('3');
    });

    it('table container has apg-table class', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const tableContainer = doc.querySelector('.apg-table');
      expect(tableContainer).not.toBeNull();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('renders empty table with no rows', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: [],
        'aria-label': 'Empty Table',
      });
      const table = doc.querySelector('[role="table"]');
      expect(table).not.toBeNull();
      const cells = doc.querySelectorAll('[role="cell"]');
      expect(cells).toHaveLength(0);
    });

    it('handles single column', async () => {
      const singleColumn: TableColumn[] = [{ id: 'name', header: 'Name' }];
      const singleColumnRows: TableRow[] = [
        { id: '1', cells: ['Alice'] },
        { id: '2', cells: ['Bob'] },
      ];
      const doc = await renderTable({
        columns: singleColumn,
        rows: singleColumnRows,
        'aria-label': 'Single Column Table',
      });
      expect(doc.querySelectorAll('[role="columnheader"]')).toHaveLength(1);
      expect(doc.querySelectorAll('[role="cell"]')).toHaveLength(2);
    });

    it('handles single row', async () => {
      const singleRow: TableRow[] = [{ id: '1', cells: ['Alice', '30', 'Tokyo'] }];
      const doc = await renderTable({
        columns: basicColumns,
        rows: singleRow,
        'aria-label': 'Single Row Table',
      });
      const rows = doc.querySelectorAll('[role="row"]');
      expect(rows).toHaveLength(2); // 1 header + 1 data
    });

    it('applies custom class', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
        class: 'custom-table',
      });
      const tableContainer = doc.querySelector('.apg-table');
      expect(tableContainer?.classList.contains('custom-table')).toBe(true);
    });

    it('applies custom id', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
        id: 'my-table',
      });
      const tableContainer = doc.querySelector('#my-table');
      expect(tableContainer).not.toBeNull();
    });
  });

  // 🟢 Low Priority: HTML Attributes
  describe('HTML Attributes', () => {
    it('Web Component wrapper uses apg-table element', async () => {
      const doc = await renderTable({
        columns: basicColumns,
        rows: basicRows,
        'aria-label': 'Test Table',
      });
      const webComponent = doc.querySelector('apg-table');
      expect(webComponent).not.toBeNull();
    });
  });
});

Resources