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.vue
<script setup lang="ts">
import type { VNode } from 'vue';

defineOptions({ inheritAttrs: false });

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

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

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

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

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

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

const props = defineProps<Props>();

const emit = defineEmits<{
  sortChange: [columnId: string, direction: 'ascending' | 'descending'];
}>();

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

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

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

<template>
  <div
    role="table"
    class="apg-table"
    :style="{ '--table-cols': columns.length }"
    :aria-colcount="totalColumns"
    :aria-rowcount="totalRows"
    v-bind="$attrs"
  >
    <div v-if="caption" class="apg-table-caption">{{ caption }}</div>

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

    <!-- Body rowgroup -->
    <div role="rowgroup" class="apg-table-body">
      <div
        v-for="row in rows"
        :key="row.id"
        role="row"
        class="apg-table-row"
        :aria-rowindex="row.rowIndex"
      >
        <div
          v-for="(cell, cellIndex) in row.cells"
          :key="cellIndex"
          :role="row.hasRowHeader && cellIndex === 0 ? 'rowheader' : 'cell'"
          :class="`apg-table-${row.hasRowHeader && cellIndex === 0 ? 'rowheader' : 'cell'}`"
          :style="{
            gridColumn:
              isTableCell(cell) && cell.colspan && cell.colspan > 1
                ? `span ${cell.colspan}`
                : undefined,
            gridRow:
              isTableCell(cell) && cell.rowspan && cell.rowspan > 1
                ? `span ${cell.rowspan}`
                : undefined,
          }"
          :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>
      </div>
    </div>
  </div>
</template>

使い方

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

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>

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

  <!-- ソート可能なテーブル -->
  <Table
    :columns="sortableColumns"
    :rows="rows"
    aria-label="ソート可能なユーザー一覧"
    :on-sort-change="handleSortChange"
  />
</template>

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

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

    it('has role="rowgroup" on header and body groups', () => {
      render(Table, {
        props: { columns: basicColumns, rows: basicRows },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { 'aria-label': 'User List' },
      });
      expect(screen.getByRole('table', { name: 'User List' })).toBeInTheDocument();
    });

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

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

    it('displays caption when provided', () => {
      render(Table, {
        props: { columns: basicColumns, rows: basicRows, caption: 'User Data' },
        attrs: { 'aria-label': 'Users' },
      });
      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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { 'aria-label': 'Users' },
      });
      const cityHeader = screen.getByRole('columnheader', { name: /city/i });
      expect(cityHeader).not.toHaveAttribute('aria-sort');
    });

    it('emits sortChange when sortable header is clicked', async () => {
      const user = userEvent.setup();
      const { emitted } = render(Table, {
        props: { columns: sortableColumns, rows: basicRows },
        attrs: { 'aria-label': 'Users' },
      });

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

      expect(emitted()).toHaveProperty('sortChange');
      expect(emitted().sortChange[0]).toEqual(['age', 'ascending']);
    });

    it('toggles sort direction when already sorted column is clicked', async () => {
      const user = userEvent.setup();
      const { emitted } = render(Table, {
        props: { columns: sortableColumns, rows: basicRows },
        attrs: { 'aria-label': 'Users' },
      });

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

      expect(emitted().sortChange[0]).toEqual(['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, totalColumns: 10 },
        attrs: { 'aria-label': 'Users' },
      });
      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, totalRows: 100 },
        attrs: { 'aria-label': 'Users' },
      });
      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, totalRows: 100 },
        attrs: { 'aria-label': 'Users' },
      });
      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,
          totalColumns: 10,
          startColIndex: 3,
        },
        attrs: { 'aria-label': 'Users' },
      });
      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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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: [] },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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: [] },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { 'aria-label': 'Users', 'data-testid': 'user-table' },
      });
      expect(screen.getByTestId('user-table')).toBeInTheDocument();
    });
  });
});

リソース