APG Patterns
English GitHub
English GitHub

Table

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

🤖 AI Implementation Guide

デモ

基本的なテーブル

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

名前
年齢
都市
Alice
30
東京
Bob
25
大阪
Charlie
35
京都
Diana
28
名古屋
Edward
42
札幌

ソート可能なテーブル

列ヘッダーをクリックしてソート。aria-sort でソート方向を示します。注: Astro コンポーネントは静的なため、ソートにはページリロードまたはクライアントサイド JavaScript が必要です。

Alice
30
東京
Bob
25
大阪
Charlie
35
京都
Diana
28
名古屋
Edward
42
札幌

行ヘッダー付き

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

名前
年齢
都市
Alice
30
東京
Bob
25
大阪
Charlie
35
京都

仮想化サポート

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

名前
年齢
都市
Alice
30
東京
Bob
25
大阪
Charlie
35
京都
Diana
28
名古屋
Edward
42
札幌

セルの結合

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

製品
Q1
Q2
Q3
Q4
電子機器
150
180
200
220
175
190
210
240
衣料品
N/A
90
120
合計
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.astro
---
/**
 * APG Table Pattern - Astro Implementation
 *
 * A static tabular structure for displaying data.
 * Uses Web Components for sort button interactions.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/table/
 */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

使い方

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

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

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

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

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

API

Table Props

プロパティ デフォルト 説明
columns TableColumn[] 必須 列定義
rows TableRow[] 必須 行データ
caption string - テーブルのキャプション(任意)
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始まり)
}

Web Component イベント

Astro 実装は Web Component (apg-table) を使用し、ソートボタンがクリックされると sortchange カスタムイベントを発行します。

イベントハンドリング
// ソート変更をリッスン
document.querySelector('apg-table').addEventListener('sortchange', (e) => {
  console.log(e.detail); // { columnId: 'name', direction: 'descending' }
});

テスト

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

リソース