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.svelte
<script lang="ts" module>
  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
   */
  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;
  }
</script>

<script lang="ts">
  interface Props {
    /** 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;

    // HTML attributes
    class?: string;
    id?: string;
    'aria-label'?: string;
    'aria-labelledby'?: string;
    'aria-describedby'?: string;
    'data-testid'?: string;
  }

  let {
    columns,
    rows,
    caption,
    onSortChange,
    totalColumns,
    totalRows,
    startColIndex,
    class: className,
    ...restProps
  }: Props = $props();

  function handleSortClick(column: TableColumn) {
    if (!column.sortable) return;

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

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

  function getCellRole(row: TableRow, cellIndex: number): 'rowheader' | 'cell' {
    return row.hasRowHeader && cellIndex === 0 ? 'rowheader' : 'cell';
  }

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

<div
  role="table"
  class={`apg-table${className ? ` ${className}` : ''}`}
  style="--table-cols: {columns.length}"
  aria-colcount={totalColumns}
  aria-rowcount={totalRows}
  {...restProps}
>
  {#if caption}
    <div class="apg-table-caption">{caption}</div>
  {/if}

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

  <!-- Body rowgroup -->
  <div role="rowgroup" class="apg-table-body">
    {#each rows as row (row.id)}
      <div role="row" class="apg-table-row" aria-rowindex={row.rowIndex}>
        {#each row.cells as cell, cellIndex (cellIndex)}
          <div
            role={getCellRole(row, cellIndex)}
            class={`apg-table-${getCellRole(row, cellIndex)}`}
            style={getCellGridStyle(cell)}
            aria-colindex={startColIndex !== undefined ? startColIndex + cellIndex : undefined}
            aria-colspan={isTableCell(cell) && cell.colspan && cell.colspan > 1
              ? cell.colspan
              : undefined}
            aria-rowspan={isTableCell(cell) && cell.rowspan && cell.rowspan > 1
              ? cell.rowspan
              : undefined}
          >
            {isTableCell(cell) ? cell.content : cell}
          </div>
        {/each}
      </div>
    {/each}
  </div>
</div>

使い方

Example
<script lang="ts">
  import Table from './Table.svelte';
  import type { TableColumn, TableRow } from './Table.svelte';

  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', '京都'] },
  ];
</script>

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

<!-- ソート可能なテーブル -->
<Table
  columns={sortableColumns}
  {rows}
  aria-label="ソート可能なユーザー一覧"
  onSortChange={handleSortChange}
/>

API

Table Props

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

TableColumn インターフェース

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

TableRow インターフェース

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

type TableCellValue = string | 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.svelte.ts
import { render, screen, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Table from './Table.svelte';
import TableWithLabelledby from './TableWithLabelledby.test.svelte';
import type { TableColumn, TableRow, TableCell } from './Table.svelte';

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

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

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

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

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

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

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

    it('has accessible name via aria-labelledby', () => {
      render(TableWithLabelledby);
      expect(screen.getByRole('table', { name: 'Employee Directory' })).toBeInTheDocument();
    });

    it('displays caption when provided', () => {
      render(Table, {
        props: {
          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, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: {
          columns: sortableColumns,
          rows: basicRows,
          'aria-label': 'Users',
          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, {
        props: {
          columns: sortableColumns,
          rows: basicRows,
          'aria-label': 'Users',
          onSortChange,
        },
      });

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

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

  // 🟡 Medium Priority: Virtualization Support
  describe('APG: Virtualization Support', () => {
    it('has aria-colcount when totalColumns is provided', () => {
      render(Table, {
        props: {
          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, {
        props: {
          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, {
        props: {
          columns: basicColumns,
          rows: rowsWithIndex,
          'aria-label': 'Users',
          totalRows: 100,
        },
      });
      const rows = screen.getAllByRole('row');
      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, {
        props: {
          columns: basicColumns,
          rows: basicRows,
          'aria-label': 'Users',
          totalColumns: 10,
          startColIndex: 3,
        },
      });
      const firstRow = screen.getAllByRole('row')[1];
      const cells = within(firstRow).getAllByRole('cell');
      expect(cells[0]).toHaveAttribute('aria-colindex', '3');
      expect(cells[1]).toHaveAttribute('aria-colindex', '4');
    });
  });

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

    it('has no axe violations with empty table', async () => {
      const { container } = render(Table, {
        props: { 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, {
        props: { 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, {
        props: { 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, {
        props: { columns: basicColumns, rows: rowsWithColspanOne, 'aria-label': 'Users' },
      });
      const cells = screen.getAllByRole('cell');
      expect(cells[0]).not.toHaveAttribute('aria-colspan');
    });

    it('renders cell content correctly with TableCell object', () => {
      const rowsWithTableCell: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Cell Content', colspan: 2 } as TableCell, 'Normal'],
        },
      ];
      render(Table, {
        props: { 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, {
        props: { 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, {
        props: { 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 single column', () => {
      const singleColumn: TableColumn[] = [{ id: 'name', header: 'Name' }];
      const singleColumnRows: TableRow[] = [
        { id: '1', cells: ['Alice'] },
        { id: '2', cells: ['Bob'] },
      ];
      render(Table, {
        props: { columns: singleColumn, rows: singleColumnRows, 'aria-label': 'Names' },
      });
      expect(screen.getAllByRole('columnheader')).toHaveLength(1);
      expect(screen.getAllByRole('cell')).toHaveLength(2);
    });
  });

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

    it('sets id attribute', () => {
      render(Table, {
        props: {
          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, {
        props: {
          columns: basicColumns,
          rows: basicRows,
          'aria-label': 'Users',
          'data-testid': 'user-table',
        },
      });
      expect(screen.getByTestId('user-table')).toBeInTheDocument();
    });
  });
});

リソース