APG Patterns
English
English

Table

行と列でデータを表示するための静的な表構造。

デモ

基本的なテーブル

シンプルなデータ表示用の静的テーブル。インタラクティブな機能なし。

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

ソート可能なテーブル

列ヘッダーをクリックしてソート。aria-sort でソート方向を示します。

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

行ヘッダー付き

各行の最初のセルに role="rowheader" を使用して、スクリーンリーダーでのナビゲーションを改善。

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

仮想化サポート

大規模なデータセットでは、aria-rowcountaria-colcountaria-rowindex を使用してテーブル全体の構造を伝えます。

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

セルの結合

colspanrowspan を使用してセルを複数列・複数行にまたがらせることができます。この実装では CSS Grid の grid-column: span Ngrid-row: span N で視覚的な結合を実現し、さらに aria-colspanaria-rowspan でスクリーンリーダーのアクセシビリティを確保しています。

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

ネイティブ 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.

アクセシビリティ

WAI-ARIA Roles

Role Element Description
table Container element Identifies the element as a table structure containing rows and cells of data.
rowgroup Header/Body container Groups rows together (equivalent to <thead>, <tbody>, <tfoot>).
row Row element A row of cells within the table (equivalent to <tr>).
columnheader Header cell A header cell for a column (equivalent to <th> in header row).
rowheader Header cell A header cell for a row (equivalent to <th scope="row">).
cell Data cell A data cell within a row (equivalent to <td>).

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.

WAI-ARIA Properties

aria-label / aria-labelledby

Provides an accessible name for the table. One of these is required for screen reader users to understand the table's purpose.

Type String / ID reference
Required Yes (one or the other)
Example aria-label="User List"

aria-describedby

References an element providing additional description for the table.

Type ID reference
Required No

WAI-ARIA States

aria-sort

Indicates the current sort direction of a column. Applied to columnheader or rowheader elements.

Values ascending, descending, none, other
Required No (only for sortable columns)
Updated When sort order changes

Virtualization Support

For large tables that only render visible rows/columns, these attributes help assistive technologies understand the full table structure:

aria-colcount / aria-rowcount

Defines the total number of columns/rows in the table when only a subset is rendered.

Type Number
Applied to table role element
Required No (only for virtualized tables)

aria-colindex / aria-rowindex

Indicates the position of a cell or row within the full table.

Type Number (1-based)
Applied to aria-colindex on cells, aria-rowindex on rows
Required No (only for virtualized tables)

Keyboard Support

Not applicable. The table pattern is a static structure and is not interactive. Unlike the grid pattern, users cannot navigate between cells using arrow keys. Interactive elements within cells (buttons, links) receive focus through normal tab order.

Accessible Naming

Tables must have an accessible name. Options include:

  • aria-label - Provides an invisible label for the table
  • aria-labelledby - References an external element as the label
  • Caption element - A visible caption can provide the accessible name if properly associated

Table vs Grid

Understanding when to use table vs grid roles:

Feature Table Grid
Purpose Static data display Interactive data manipulation
Keyboard navigation Not applicable Arrow keys between cells
Cell selection Not supported Via aria-selected
Focus management None required Roving tabindex

References

ソースコード

Table.tsx
import type { ReactNode } from 'react';

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 | ReactNode;
  /** Number of columns this cell spans */
  colspan?: number;
  /** Number of rows this cell spans */
  rowspan?: number;
}

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

/**
 * Type guard to check if cell is a TableCell object
 */
export 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 TableProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'role'> {
  /** Column definitions */
  columns: TableColumn[];
  /** Row data */
  rows: TableRow[];
  /** Caption text (optional) */
  caption?: string;
  /** Callback when sort changes */
  onSortChange?: (columnId: string, direction: 'ascending' | 'descending') => void;

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

export function Table({
  columns,
  rows,
  caption,
  onSortChange,
  totalColumns,
  totalRows,
  startColIndex,
  className,
  ...props
}: TableProps) {
  const handleSortClick = (column: TableColumn) => {
    if (!column.sortable || !onSortChange) return;

    const newDirection: 'ascending' | 'descending' =
      column.sort === 'ascending' ? 'descending' : 'ascending';
    onSortChange(column.id, newDirection);
  };

  // CSS Grid needs to know the column count (using const assertion for custom property)
  const cssVars = { '--table-cols': columns.length } as const;
  const tableStyle = { ...cssVars };

  return (
    <div
      role="table"
      className={`apg-table${className ? ` ${className}` : ''}`}
      style={tableStyle}
      aria-colcount={totalColumns}
      aria-rowcount={totalRows}
      {...props}
    >
      {caption && <div className="apg-table-caption">{caption}</div>}

      {/* Header rowgroup */}
      <div role="rowgroup" className="apg-table-header">
        <div role="row" className="apg-table-row">
          {columns.map((column, colIndex) => {
            const sortProps = column.sortable
              ? { 'aria-sort': column.sort || ('none' as const) }
              : {};
            const colIndexProps =
              startColIndex !== undefined ? { 'aria-colindex': startColIndex + colIndex } : {};

            return (
              <div
                key={column.id}
                role="columnheader"
                className="apg-table-columnheader"
                {...sortProps}
                {...colIndexProps}
              >
                {column.sortable ? (
                  <button
                    type="button"
                    className="apg-table-sort-button"
                    onClick={() => handleSortClick(column)}
                    aria-label={`Sort by ${column.header}`}
                  >
                    {column.header}
                    <span className="apg-table-sort-icon" aria-hidden="true">
                      {column.sort === 'ascending' ? '▲' : column.sort === 'descending' ? '▼' : '⇅'}
                    </span>
                  </button>
                ) : (
                  column.header
                )}
              </div>
            );
          })}
        </div>
      </div>

      {/* Body rowgroup */}
      <div role="rowgroup" className="apg-table-body">
        {rows.map((row) => {
          const rowIndexProps = row.rowIndex !== undefined ? { 'aria-rowindex': row.rowIndex } : {};

          return (
            <div key={row.id} role="row" className="apg-table-row" {...rowIndexProps}>
              {row.cells.map((cell, cellIndex) => {
                const isRowHeader = row.hasRowHeader && cellIndex === 0;
                const cellRole = isRowHeader ? 'rowheader' : 'cell';
                const colIndexProps =
                  startColIndex !== undefined ? { 'aria-colindex': startColIndex + cellIndex } : {};

                // Handle cell spanning
                const cellData = isTableCell(cell) ? cell : { content: cell };
                const spanProps: Record<string, number | undefined> = {};
                const gridStyle: React.CSSProperties = {};

                if (cellData.colspan && cellData.colspan > 1) {
                  spanProps['aria-colspan'] = cellData.colspan;
                  gridStyle.gridColumn = `span ${cellData.colspan}`;
                }
                if (cellData.rowspan && cellData.rowspan > 1) {
                  spanProps['aria-rowspan'] = cellData.rowspan;
                  gridStyle.gridRow = `span ${cellData.rowspan}`;
                }

                return (
                  <div
                    key={cellIndex}
                    role={cellRole}
                    className={`apg-table-${cellRole}`}
                    style={Object.keys(gridStyle).length > 0 ? gridStyle : undefined}
                    {...colIndexProps}
                    {...spanProps}
                  >
                    {cellData.content}
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>
    </div>
  );
}

使い方

Example
import { Table } from './Table';
import type { TableColumn, TableRow } from './Table';

const columns: TableColumn[] = [
  { id: 'name', header: '名前' },
  { id: 'age', header: '年齢' },
  { id: 'city', header: '都市' },
];

const rows: TableRow[] = [
  { id: '1', cells: ['Alice', '30', '東京'] },
  { id: '2', cells: ['Bob', '25', '大阪'] },
  { id: '3', cells: ['Charlie', '35', '京都'] },
];

// 基本的なテーブル
<Table
  columns={columns}
  rows={rows}
  aria-label="ユーザー一覧"
/>

// ソート可能な列
const sortableColumns: TableColumn[] = [
  { id: 'name', header: '名前', sortable: true, sort: 'ascending' },
  { id: 'age', header: '年齢', sortable: true },
  { id: 'city', header: '都市' },
];

<Table
  columns={sortableColumns}
  rows={rows}
  aria-label="ソート可能なユーザー一覧"
  onSortChange={(columnId, direction) => {
    console.log(`Sort ${columnId} ${direction}`);
  }}
/>

API

Table Props

プロパティ デフォルト 説明
columns TableColumn[] 必須 列定義
rows TableRow[] 必須 行データ
caption string - テーブルのキャプション(任意)
onSortChange (columnId: string, direction) => void - ソート変更時のコールバック
totalColumns number - 総列数(仮想化用)
totalRows number - 総行数(仮想化用)
startColIndex number - 開始列インデックス(1始まり)

TableColumn インターフェース

Types
interface TableColumn {
  id: string;
  header: string;
  sortable?: boolean;
  sort?: 'ascending' | 'descending' | 'none';
}

TableRow インターフェース

Types
interface TableCell {
  content: string | ReactNode;
  colspan?: number; // 結合する列数(視覚的 + aria-colspan)
  rowspan?: number; // 結合する行数(視覚的 + aria-rowspan)
}

type TableCellValue = string | ReactNode | TableCell;

interface TableRow {
  id: string;
  cells: TableCellValue[];
  hasRowHeader?: boolean;
  rowIndex?: number; // 仮想化用(1始まり)
}

テスト

テストはARIAロール、テーブル構造、アクセシビリティ要件についてのAPG準拠を検証します。Tableは静的な構造であるため、キーボードインタラクションのテストは該当しません。

テストカテゴリ

高優先度 : ARIA構造

テスト 説明
role="table" コンテナ要素がtableロールを持つ
role="rowgroup" ヘッダーとボディグループがrowgroupロールを持つ
role="row" すべての行がrowロールを持つ
role="columnheader" ヘッダーセルがcolumnheaderロールを持つ
role="rowheader" 指定時に行ヘッダーセルがrowheaderロールを持つ
role="cell" データセルがcellロールを持つ

高優先度 : アクセシブルな名前

テスト 説明
aria-label aria-label属性によるアクセシブルな名前
aria-labelledby 外部要素参照によるアクセシブルな名前
caption 提供時にキャプションが表示される

高優先度 : ソート状態

テスト 説明
aria-sort="ascending" 昇順でソートされた列
aria-sort="descending" 降順でソートされた列
aria-sort="none" 現在ソートされていないソート可能な列
no aria-sort ソート不可の列にはaria-sort属性がない
onSortChange callback ソートボタンクリック時にコールバックが呼び出される
sort toggle ソート済み列をクリックすると方向が切り替わる

中優先度 : 仮想化サポート

テスト 説明
aria-colcount 仮想化テーブルの総列数
aria-rowcount 仮想化テーブルの総行数
aria-rowindex テーブル全体における行の位置
aria-colindex テーブル全体におけるセルの位置

中優先度 : 視覚的セル結合(ブラウザテスト)

テスト 説明
colspan width colspan=2のセルが通常セルの約2倍の幅を持つ
rowspan height rowspan=2のセルが通常セルの約2倍の高さを持つ
full span 全列にまたがるセルがテーブル幅と一致する
combined spans colspanとrowspanの両方を持つセルが正しい寸法を持つ

注意: 視覚的スパンテストはgetBoundingClientRect()を使用し、実際のブラウザ環境(Vitest browser mode + Playwright)が必要です。これらのテストは*.browser.test.tsファイルにあります。

中優先度 : アクセシビリティ

テスト 説明
axe violations axe-coreによるアクセシビリティ違反なし
sortable columns ソート可能な列ヘッダーで違反なし
row headers 行ヘッダーセルで違反なし
empty table データ行が空でも違反なし

中優先度 : エッジケース

テスト 説明
empty rows データ行なしでテーブルが正しくレンダリングされる
single column テーブルが単一列を正しく処理する

低優先度 : HTML属性継承

テスト 説明
className カスタムクラスがコンテナに適用される
id id属性が正しく設定される
data-* data属性がパススルーされる

テストツール

E2Eテストコード

以下のPlaywrightテストは、4つのフレームワーク(React、Vue、Svelte、Astro)すべてでTableの動作を検証します。ARIA構造、ソート状態、仮想化属性、視覚的セル結合をカバーしています。

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.tsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Table, TableColumn, TableRow, TableCell } from './Table';

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

const rowsWithRowHeader: TableRow[] = [
  { id: '1', cells: ['Alice', '30', 'Tokyo'], hasRowHeader: true },
  { id: '2', cells: ['Bob', '25', 'Osaka'], hasRowHeader: true },
];

describe('Table', () => {
  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has role="table" on container', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      expect(screen.getByRole('table')).toBeInTheDocument();
    });

    it('has role="rowgroup" on header and body groups', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const rowgroups = screen.getAllByRole('rowgroup');
      expect(rowgroups).toHaveLength(2); // header + body
    });

    it('has role="row" on all rows', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const rows = screen.getAllByRole('row');
      expect(rows).toHaveLength(4); // 1 header row + 3 data rows
    });

    it('has role="columnheader" on header cells', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const headers = screen.getAllByRole('columnheader');
      expect(headers).toHaveLength(3);
      expect(headers[0]).toHaveTextContent('Name');
      expect(headers[1]).toHaveTextContent('Age');
      expect(headers[2]).toHaveTextContent('City');
    });

    it('has role="cell" on data cells', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const cells = screen.getAllByRole('cell');
      expect(cells).toHaveLength(9); // 3 columns x 3 rows
    });

    it('has role="rowheader" on row headers when hasRowHeader is true', () => {
      render(<Table columns={basicColumns} rows={rowsWithRowHeader} aria-label="Users" />);
      const rowheaders = screen.getAllByRole('rowheader');
      expect(rowheaders).toHaveLength(2);
      expect(rowheaders[0]).toHaveTextContent('Alice');
      expect(rowheaders[1]).toHaveTextContent('Bob');
    });

    it('does not have rowheader role when hasRowHeader is false', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      expect(screen.queryByRole('rowheader')).not.toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('APG: Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="User List" />);
      expect(screen.getByRole('table', { name: 'User List' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <h2 id="table-title">Employee Directory</h2>
          <Table columns={basicColumns} rows={basicRows} aria-labelledby="table-title" />
        </>
      );
      expect(screen.getByRole('table', { name: 'Employee Directory' })).toBeInTheDocument();
    });

    it('has description via aria-describedby', () => {
      render(
        <>
          <Table
            columns={basicColumns}
            rows={basicRows}
            aria-label="Users"
            aria-describedby="table-desc"
          />
          <p id="table-desc">A list of registered users</p>
        </>
      );
      const table = screen.getByRole('table');
      expect(table).toHaveAttribute('aria-describedby', 'table-desc');
    });

    it('displays caption when provided', () => {
      render(
        <Table columns={basicColumns} rows={basicRows} aria-label="Users" caption="User Data" />
      );
      expect(screen.getByText('User Data')).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Sort State
  describe('APG: Sort State', () => {
    it('has aria-sort="ascending" on ascending sorted column', () => {
      render(<Table columns={sortableColumns} rows={basicRows} aria-label="Users" />);
      const nameHeader = screen.getByRole('columnheader', { name: /name/i });
      expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
    });

    it('has aria-sort="descending" on descending sorted column', () => {
      const columns: TableColumn[] = [
        { id: 'name', header: 'Name', sortable: true, sort: 'descending' },
        { id: 'age', header: 'Age', sortable: true },
      ];
      render(<Table columns={columns} rows={basicRows} aria-label="Users" />);
      const nameHeader = screen.getByRole('columnheader', { name: /name/i });
      expect(nameHeader).toHaveAttribute('aria-sort', 'descending');
    });

    it('has aria-sort="none" on unsorted sortable columns', () => {
      render(<Table columns={sortableColumns} rows={basicRows} aria-label="Users" />);
      const ageHeader = screen.getByRole('columnheader', { name: /age/i });
      expect(ageHeader).toHaveAttribute('aria-sort', 'none');
    });

    it('does not have aria-sort on non-sortable columns', () => {
      render(<Table columns={sortableColumns} rows={basicRows} aria-label="Users" />);
      const cityHeader = screen.getByRole('columnheader', { name: /city/i });
      expect(cityHeader).not.toHaveAttribute('aria-sort');
    });

    it('calls onSortChange when sortable header is clicked', async () => {
      const user = userEvent.setup();
      const onSortChange = vi.fn();
      render(
        <Table
          columns={sortableColumns}
          rows={basicRows}
          aria-label="Users"
          onSortChange={onSortChange}
        />
      );

      const ageHeader = screen.getByRole('columnheader', { name: /age/i });
      const sortButton = within(ageHeader).getByRole('button');
      await user.click(sortButton);

      expect(onSortChange).toHaveBeenCalledWith('age', 'ascending');
    });

    it('toggles sort direction when already sorted column is clicked', async () => {
      const user = userEvent.setup();
      const onSortChange = vi.fn();
      render(
        <Table
          columns={sortableColumns}
          rows={basicRows}
          aria-label="Users"
          onSortChange={onSortChange}
        />
      );

      const nameHeader = screen.getByRole('columnheader', { name: /name/i });
      const sortButton = within(nameHeader).getByRole('button');
      await user.click(sortButton);

      expect(onSortChange).toHaveBeenCalledWith('name', 'descending');
    });

    it('sortable header button has accessible name', () => {
      render(<Table columns={sortableColumns} rows={basicRows} aria-label="Users" />);
      const nameHeader = screen.getByRole('columnheader', { name: /name/i });
      const sortButton = within(nameHeader).getByRole('button');
      expect(sortButton).toHaveAccessibleName(/sort by name/i);
    });
  });

  // 🟡 Medium Priority: Virtualization Support
  describe('APG: Virtualization Support', () => {
    it('has aria-colcount when totalColumns is provided', () => {
      render(
        <Table columns={basicColumns} rows={basicRows} aria-label="Users" totalColumns={10} />
      );
      const table = screen.getByRole('table');
      expect(table).toHaveAttribute('aria-colcount', '10');
    });

    it('has aria-rowcount when totalRows is provided', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" totalRows={100} />);
      const table = screen.getByRole('table');
      expect(table).toHaveAttribute('aria-rowcount', '100');
    });

    it('has aria-rowindex on rows when rowIndex is provided', () => {
      const rowsWithIndex: TableRow[] = [
        { id: '1', cells: ['Alice', '30', 'Tokyo'], rowIndex: 5 },
        { id: '2', cells: ['Bob', '25', 'Osaka'], rowIndex: 6 },
      ];
      render(
        <Table columns={basicColumns} rows={rowsWithIndex} aria-label="Users" totalRows={100} />
      );
      const rows = screen.getAllByRole('row');
      // Skip header row (index 0)
      expect(rows[1]).toHaveAttribute('aria-rowindex', '5');
      expect(rows[2]).toHaveAttribute('aria-rowindex', '6');
    });

    it('has aria-colindex on cells when startColIndex is provided', () => {
      render(
        <Table
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          totalColumns={10}
          startColIndex={3}
        />
      );
      const firstRow = screen.getAllByRole('row')[1]; // First data row
      const cells = within(firstRow).getAllByRole('cell');
      expect(cells[0]).toHaveAttribute('aria-colindex', '3');
      expect(cells[1]).toHaveAttribute('aria-colindex', '4');
      expect(cells[2]).toHaveAttribute('aria-colindex', '5');
    });

    it('does not have virtualization attributes when not provided', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const table = screen.getByRole('table');
      expect(table).not.toHaveAttribute('aria-colcount');
      expect(table).not.toHaveAttribute('aria-rowcount');

      const rows = screen.getAllByRole('row');
      expect(rows[1]).not.toHaveAttribute('aria-rowindex');

      const cells = screen.getAllByRole('cell');
      expect(cells[0]).not.toHaveAttribute('aria-colindex');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations with basic table', async () => {
      const { container } = render(
        <Table columns={basicColumns} rows={basicRows} aria-label="Users" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with sortable columns', async () => {
      const { container } = render(
        <Table columns={sortableColumns} rows={basicRows} aria-label="Users" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with row headers', async () => {
      const { container } = render(
        <Table columns={basicColumns} rows={rowsWithRowHeader} aria-label="Users" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <h2 id="title">Users</h2>
          <Table columns={basicColumns} rows={basicRows} aria-labelledby="title" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with virtualization attributes', async () => {
      const { container } = render(
        <Table
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          totalColumns={10}
          totalRows={100}
        />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with empty table', async () => {
      const { container } = render(
        <Table columns={basicColumns} rows={[]} aria-label="Empty Users" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Cell Spanning
  describe('APG: Cell Spanning', () => {
    it('has aria-colspan when cell spans multiple columns', () => {
      const rowsWithColspan: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Merged', colspan: 2 } as TableCell, 'Single'],
        },
      ];
      render(<Table columns={basicColumns} rows={rowsWithColspan} aria-label="Users" />);
      const cells = screen.getAllByRole('cell');
      expect(cells[0]).toHaveAttribute('aria-colspan', '2');
      expect(cells[1]).not.toHaveAttribute('aria-colspan');
    });

    it('has aria-rowspan when cell spans multiple rows', () => {
      const rowsWithRowspan: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Spans 2 rows', rowspan: 2 } as TableCell, 'A', 'B'],
        },
        { id: '2', cells: ['C', 'D'] },
      ];
      render(<Table columns={basicColumns} rows={rowsWithRowspan} aria-label="Users" />);
      const firstRowCells = within(screen.getAllByRole('row')[1]).getAllByRole('cell');
      expect(firstRowCells[0]).toHaveAttribute('aria-rowspan', '2');
    });

    it('does not have aria-colspan when colspan is 1', () => {
      const rowsWithColspanOne: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Single', colspan: 1 } as TableCell, 'B', 'C'],
        },
      ];
      render(<Table columns={basicColumns} rows={rowsWithColspanOne} aria-label="Users" />);
      const cells = screen.getAllByRole('cell');
      expect(cells[0]).not.toHaveAttribute('aria-colspan');
    });

    it('does not have aria-rowspan when rowspan is 1', () => {
      const rowsWithRowspanOne: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Single', rowspan: 1 } as TableCell, 'B', 'C'],
        },
      ];
      render(<Table columns={basicColumns} rows={rowsWithRowspanOne} aria-label="Users" />);
      const cells = screen.getAllByRole('cell');
      expect(cells[0]).not.toHaveAttribute('aria-rowspan');
    });

    it('renders cell content correctly with TableCell object', () => {
      const rowsWithTableCell: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Cell Content', colspan: 2 } as TableCell, 'Normal'],
        },
      ];
      render(<Table columns={basicColumns} rows={rowsWithTableCell} aria-label="Users" />);
      expect(screen.getByText('Cell Content')).toBeInTheDocument();
      expect(screen.getByText('Normal')).toBeInTheDocument();
    });

    it('has no axe violations with spanning cells', async () => {
      const rowsWithSpanning: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Merged', colspan: 2 } as TableCell, 'C'],
        },
        { id: '2', cells: ['D', 'E', 'F'] },
      ];
      const { container } = render(
        <Table columns={basicColumns} rows={rowsWithSpanning} aria-label="Users" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('renders empty table with no rows', () => {
      render(<Table columns={basicColumns} rows={[]} aria-label="Empty" />);
      const table = screen.getByRole('table');
      expect(table).toBeInTheDocument();
      const cells = screen.queryAllByRole('cell');
      expect(cells).toHaveLength(0);
    });

    it('handles ReactNode in cells', () => {
      const rowsWithNodes: TableRow[] = [
        {
          id: '1',
          cells: [
            <a key="name" href="/alice">
              Alice
            </a>,
            '30',
            <span key="city" className="highlight">
              Tokyo
            </span>,
          ],
        },
      ];
      render(<Table columns={basicColumns} rows={rowsWithNodes} aria-label="Users" />);
      expect(screen.getByRole('link', { name: 'Alice' })).toBeInTheDocument();
      expect(screen.getByText('Tokyo')).toHaveClass('highlight');
    });

    it('handles single column', () => {
      const singleColumn: TableColumn[] = [{ id: 'name', header: 'Name' }];
      const singleColumnRows: TableRow[] = [
        { id: '1', cells: ['Alice'] },
        { id: '2', cells: ['Bob'] },
      ];
      render(<Table columns={singleColumn} rows={singleColumnRows} aria-label="Names" />);
      expect(screen.getAllByRole('columnheader')).toHaveLength(1);
      expect(screen.getAllByRole('cell')).toHaveLength(2);
    });

    it('handles single row', () => {
      const singleRow: TableRow[] = [{ id: '1', cells: ['Alice', '30', 'Tokyo'] }];
      render(<Table columns={basicColumns} rows={singleRow} aria-label="User" />);
      const rows = screen.getAllByRole('row');
      expect(rows).toHaveLength(2); // 1 header + 1 data
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      render(
        <Table
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          className="custom-table"
        />
      );
      const table = screen.getByRole('table');
      expect(table).toHaveClass('custom-table');
    });

    it('sets id attribute', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" id="my-table" />);
      const table = screen.getByRole('table');
      expect(table).toHaveAttribute('id', 'my-table');
    });

    it('passes through data-* attributes', () => {
      render(
        <Table
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          data-testid="user-table"
        />
      );
      expect(screen.getByTestId('user-table')).toBeInTheDocument();
    });
  });
});

リソース