APG Patterns
English GitHub
English GitHub

Table

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

🤖 AI Implementation Guide

デモ

基本的なテーブル

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

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

ネイティブ HTML を優先

このカスタムコンポーネントを使用する前に、ネイティブの <table> 要素の使用を検討してください。 ネイティブ要素は組み込みのセマンティクスを提供し、JavaScript なしで動作し、ARIA 属性を必要としません。

<table>
  <caption>ユーザー一覧</caption>
  <thead>
    <tr>
      <th>名前</th>
      <th>年齢</th>
      <th>都市</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Alice</td>
      <td>30</td>
      <td>東京</td>
    </tr>
  </tbody>
</table>

カスタム実装は、レスポンシブテーブルのために CSS Grid/Flexbox レイアウトが必要な場合、またはデザイン上の制約でネイティブの <table> 要素を使用できない場合にのみ使用してください。

ユースケース ネイティブ HTML カスタム実装
基本的な表データ 推奨 不要
JavaScript 無効時のサポート ネイティブで動作 フォールバックが必要
組み込みアクセシビリティ 自動 手動で ARIA が必要
CSS Grid/Flexbox レイアウト 制限あり(display: table) 完全に制御可能
レスポンシブでの列の並べ替え 制限あり 完全に制御可能
仮想化サポート 組み込みなし ARIA サポート付き

ネイティブの <table> 要素と <thead><tbody><th><td> を使用すれば、完全なセマンティック構造が自動的に提供されます。ARIA の table パターンは、レイアウトの目的で非テーブル要素(例: <div> )を使用する場合にのみ必要です。

アクセシビリティ

WAI-ARIA ロール

ロール 要素 説明
table コンテナ要素 要素をデータの行とセルを含むテーブル構造として識別します。
rowgroup ヘッダー/ボディコンテナ 行をグループ化します(<thead><tbody><tfoot> に相当)。
row 行要素 テーブル内の1行(<tr> に相当)。
columnheader ヘッダーセル 列の見出しセル(ヘッダー行の <th> に相当)。
rowheader ヘッダーセル 行の見出しセル(<th scope="row"> に相当)。
cell データセル 行内のデータセル(<td> に相当)。

table ロールはデータを表示するための静的な表構造を作成します。grid ロールとは異なり、テーブルはインタラクティブではなく、セル間のキーボードナビゲーションをサポートしません。

WAI-ARIA プロパティ

aria-label / aria-labelledby

テーブルのアクセシブル名を提供します。スクリーンリーダーのユーザーがテーブルの目的を理解するために、いずれか一方が必要です。

文字列 / ID 参照
必須 はい(どちらか一方)
aria-label="ユーザー一覧"

aria-describedby

テーブルの追加説明を提供する要素を参照します。

ID 参照
必須 いいえ

WAI-ARIA ステート

aria-sort

列の現在のソート方向を示します。columnheader または rowheader 要素に適用します。

ascendingdescendingnoneother
必須 いいえ(ソート可能な列のみ)
更新タイミング ソート順が変更されたとき

仮想化サポート

表示されている行/列のみをレンダリングする大規模なテーブルでは、これらの属性が支援技術にテーブル全体の構造を理解させるのに役立ちます:

aria-colcount / aria-rowcount

一部のみがレンダリングされている場合のテーブルの総列数/行数を定義します。

数値
適用先 table ロールの要素
必須 いいえ(仮想化テーブルのみ)

aria-colindex / aria-rowindex

テーブル全体におけるセルまたは行の位置を示します。

数値(1始まり)
適用先 セルに aria-colindex、行に aria-rowindex
必須 いいえ(仮想化テーブルのみ)

キーボードサポート

該当なし。table パターンは静的な構造であり、インタラクティブではありません。grid パターンとは異なり、ユーザーは矢印キーでセル間を移動できません。セル内のインタラクティブな要素(ボタン、リンク)は通常の Tab 順序でフォーカスを受け取ります。

アクセシブルな名前

テーブルにはアクセシブルな名前が必要です。オプションには以下があります:

  • aria-label - テーブルに非表示のラベルを提供
  • aria-labelledby - 外部要素をラベルとして参照
  • キャプション要素 - 適切に関連付けられていれば、表示されるキャプションがアクセシブルな名前を提供可能

Table と Grid の違い

table ロールと grid ロールの使い分け:

機能 Table Grid
目的 静的なデータ表示 インタラクティブなデータ操作
キーボードナビゲーション 該当なし セル間を矢印キーで移動
セル選択 サポートなし aria-selected で選択
フォーカス管理 不要 Roving tabindex

リファレンス

ソースコード

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" 現在ソートされていないソート可能な列
aria-sort なし ソート不可の列には aria-sort 属性がない
onSortChange コールバック ソートボタンクリック時にコールバックが呼び出される
ソートトグル ソート済み列をクリックすると方向が切り替わる

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

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

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

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

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

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

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

中優先度: エッジケース

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

低優先度: HTML 属性継承

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

テストツール

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 href="/alice">Alice</a>, '30', <span 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();
    });
  });
});

リソース