APG Patterns
English
English

テーブル

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

デモ

基本テーブル

データを表示するシンプルな静的テーブル。インタラクティブな機能はありません。

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

ソート可能なテーブル

列ヘッダーをクリックしてソートします。 Uses aria-sort でソート方向を示します。このデモではインタラクティブなソートのために React コンポーネントをclient:load で使用しています。

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

セル結合

セルは複数の列や行にまたがることができます。この実装では、視覚的な結合に CSS Grid の 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>User List</caption>
  <thead>
    <tr>
      <th>Name</th>
      <th>Age</th>
      <th>City</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Alice</td>
      <td>30</td>
      <td>Tokyo</td>
    </tr>
  </tbody>
</table>

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

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

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

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA プロパティ

aria-label

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

文字列
必須
はい(または aria-labelledby)

aria-labelledby

テーブルのアクセシブル名を提供する要素を参照します。

ID参照
必須
はい(または aria-label)

aria-describedby

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

ID参照
必須
いいえ

aria-colcount

一部のみがレンダリングされている場合のテーブルの総列数を定義します(仮想化用)。

数値
必須
いいえ

aria-rowcount

一部のみがレンダリングされている場合のテーブルの総行数を定義します(仮想化用)。

数値
必須
いいえ

aria-colindex

テーブル全体におけるセルの位置を示します(仮想化用)。

数値(1始まり)
必須
いいえ

aria-rowindex

テーブル全体における行の位置を示します(仮想化用)。

数値(1始まり)
必須
いいえ

aria-colspan

セルが何列にまたがるかを示します。1より大きい場合のみ設定します。

数値
必須
いいえ

aria-rowspan

セルが何行にまたがるかを示します。1より大きい場合のみ設定します。

数値
必須
いいえ

WAI-ARIA ステート

aria-sort

対象要素
columnheader/rowheader 要素
ascending | descending | none | other
必須
いいえ
変更トリガー

ソート順が変更されたとき(ソートボタンクリック)

  • tableロールはデータを表示するための静的な表構造を作成します。gridロールとは異なり、テーブルはインタラクティブではなく、セル間のキーボードナビゲーションをサポートしません。
  • tableパターンにはキーボードサポートは該当しません。セル内のインタラクティブな要素(ボタン、リンク)は通常のTab順序でフォーカスを受け取ります。

フォーカス管理

イベント振る舞い
静的テーブル該当なし - roving tabindexは不要
インタラクティブ要素リンク/ボタンは通常のTab順序でフォーカスを受け取る

ビジュアルデザイン

  • CSS Gridレイアウト - 視覚的なセル結合サポートのためにCSS Grid + subgridを使用
  • セルの罫線 - 背景色を使用したギャップベースの罫線
  • ソートインジケーター - ソート方向を示す視覚的なアイコン
  • ヘッダースタイリング - ヘッダーセルの明確な背景色

参考資料

ソースコード

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: 'Name' },
  { id: 'age', header: 'Age' },
  { id: 'city', header: 'City' },
];

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

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

// With row headers
const rowsWithHeaders = [
  { id: '1', cells: ['Alice', '30', 'Tokyo'], hasRowHeader: true },
  { id: '2', cells: ['Bob', '25', 'Osaka'], hasRowHeader: true },
];
---

<!-- Basic table -->
<Table columns={columns} rows={rows} aria-label="User List" />

<!-- Sortable table -->
<Table
  columns={sortableColumns}
  rows={rows}
  aria-label="Sortable User List"
/>

<!-- With row headers -->
<Table
  columns={columns}
  rows={rowsWithHeaders}
  aria-label="User List with Row Headers"
/>

API

プロパティ デフォルト 説明
columns TableColumn[] required 列定義
rows TableRow[] required 行データ
caption string - テーブルのキャプション(任意)
totalColumns number - 総列数(仮想化用)
totalRows number - 総行数(仮想化用)
startColIndex number - 開始列インデックス(1始まり)

TableColumn Props

プロパティ デフォルト 説明
id string required 列の一意な識別子
header string required 列ヘッダーテキスト
sortable boolean false 列がソート可能かどうか
sort 'ascending' | 'descending' | 'none' - 現在のソート方向

TableCell Props

プロパティ デフォルト 説明
content string required セルの内容
colspan number - 結合する列数
rowspan number - 結合する行数

TableRow Props

プロパティ デフォルト 説明
id string required 行の一意な識別子
cells (string | TableCell)[] required セルデータの配列
hasRowHeader boolean false 最初のセルを行ヘッダーにするかどうか
rowIndex number - 仮想化用の行インデックス

Custom Events

イベント Detail 説明
sortchange { columnId: string, direction: string } ソートボタンがクリックされたときに発行(Web Component)

テスト

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

テストカテゴリ

高優先度 : ARIA構造

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

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

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

高優先度 : ソート状態

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

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

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

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

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

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

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

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

中優先度 : エッジケース

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

低優先度 : HTML属性継承

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

テストツール

E2Eテストコード

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

e2e/table.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Table Pattern
 *
 * Test coverage based on APG Table pattern and llm.md Test Checklist:
 *
 * High Priority: ARIA Structure
 * - Container has role="table"
 * - All rows have role="row"
 * - Data cells have role="cell"
 * - Column headers have role="columnheader"
 * - Row headers have role="rowheader" (when present)
 * - Groups have role="rowgroup" (when present)
 *
 * High Priority: Accessible Name
 * - Table has accessible name via aria-label
 *
 * High Priority: Sort State
 * - Sorted column has aria-sort="ascending" or "descending"
 * - Unsorted sortable columns have aria-sort="none"
 * - Sort changes update aria-sort attribute
 *
 * Medium Priority: Virtualization
 * - aria-rowcount matches total rows
 * - aria-rowindex is 1-based on rows
 *
 * Medium Priority: Cell Spanning
 * - aria-colspan is set when cell spans >1 columns
 * - aria-rowspan is set when cell spans >1 rows
 * - Visual spanning matches ARIA attributes
 *
 * Medium Priority: Accessibility
 * - No axe-core violations (WCAG 2.1 AA)
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

for (const framework of frameworks) {
  test.describe(`Table (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/table/${framework}/`);
      // Wait for first table to be visible instead of networkidle (more stable)
      await expect(page.locator('[role="table"]').first()).toBeVisible();
    });

    // =========================================================================
    // High Priority: ARIA Structure
    // =========================================================================
    test.describe('APG: ARIA Structure', () => {
      test('required demos are present (sortable, row headers, spanning)', async ({ page }) => {
        // Verify all required demo tables are present
        await expect(page.locator('[role="table"][aria-label="Sortable User List"]')).toBeVisible();
        await expect(
          page.locator('[role="table"][aria-label="User List with Row Headers"]')
        ).toBeVisible();
        await expect(page.locator('[role="table"][aria-label*="Spanning"]')).toBeVisible();
      });

      test('has role="table" on container', async ({ page }) => {
        const tables = page.locator('[role="table"]');
        await expect(tables.first()).toBeVisible();
        // Multiple tables on the demo page
        expect(await tables.count()).toBeGreaterThanOrEqual(1);
      });

      test('has role="row" on all rows', async ({ page }) => {
        // Check the basic table (first one)
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const rows = basicTable.locator('[role="row"]');
        // 1 header row + 5 data rows = 6 rows
        await expect(rows).toHaveCount(6);
      });

      test('has role="columnheader" on header cells', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const headers = basicTable.locator('[role="columnheader"]');
        // 3 columns: Name, Age, City
        await expect(headers).toHaveCount(3);
        await expect(headers.nth(0)).toContainText('Name');
        await expect(headers.nth(1)).toContainText('Age');
        await expect(headers.nth(2)).toContainText('City');
      });

      test('has role="cell" on data cells', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const cells = basicTable.locator('[role="cell"]');
        // 5 rows x 3 columns = 15 cells
        await expect(cells).toHaveCount(15);
      });

      test('has role="rowgroup" for header and body sections', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();

        const rowgroups = basicTable.locator('[role="rowgroup"]');
        // 2 rowgroups: header and body
        await expect(rowgroups).toHaveCount(2);
      });

      test('has role="rowheader" when hasRowHeader is true', async ({ page }) => {
        // Use the table with row headers
        const rowHeaderTable = page.locator(
          '[role="table"][aria-label="User List with Row Headers"]'
        );
        await expect(rowHeaderTable).toBeVisible();

        const rowheaders = rowHeaderTable.locator('[role="rowheader"]');
        // 3 data rows with row headers
        await expect(rowheaders).toHaveCount(3);

        // Verify the first row header contains expected content
        await expect(rowheaders.first()).toContainText('Alice');
      });
    });

    // =========================================================================
    // High Priority: Accessible Name
    // =========================================================================
    test.describe('APG: Accessible Name', () => {
      test('table has accessible name via aria-label', async ({ page }) => {
        const basicTable = page.locator('[role="table"][aria-label="User List"]');
        await expect(basicTable).toBeVisible();
        await expect(basicTable).toHaveAttribute('aria-label', 'User List');
      });

      test('all tables have accessible names', async ({ page }) => {
        const tables = page.locator('[role="table"]');
        const count = await tables.count();

        for (let i = 0; i < count; i++) {
          const table = tables.nth(i);
          const ariaLabel = await table.getAttribute('aria-label');
          const ariaLabelledby = await table.getAttribute('aria-labelledby');
          // Each table must have either aria-label or aria-labelledby
          expect(ariaLabel || ariaLabelledby).toBeTruthy();
        }
      });
    });

    // =========================================================================
    // High Priority: Sort State
    // =========================================================================
    test.describe('APG: Sort State', () => {
      test('sorted column has aria-sort="ascending"', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        // Name column is initially sorted ascending
        const nameHeader = sortableTable
          .locator('[role="columnheader"]')
          .filter({ hasText: 'Name' });
        await expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
      });

      test('unsorted sortable columns have aria-sort="none"', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        // Age and City columns should have aria-sort="none"
        const ageHeader = sortableTable.locator('[role="columnheader"]').filter({ hasText: 'Age' });
        const cityHeader = sortableTable
          .locator('[role="columnheader"]')
          .filter({ hasText: 'City' });

        await expect(ageHeader).toHaveAttribute('aria-sort', 'none');
        await expect(cityHeader).toHaveAttribute('aria-sort', 'none');
      });

      test('clicking sort button changes aria-sort', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        const ageHeader = sortableTable.locator('[role="columnheader"]').filter({ hasText: 'Age' });
        const sortButton = ageHeader.locator('button');

        // Initially none
        await expect(ageHeader).toHaveAttribute('aria-sort', 'none');

        // Click to sort ascending
        await sortButton.click();
        await expect(ageHeader).toHaveAttribute('aria-sort', 'ascending');

        // Name should now be none
        const nameHeader = sortableTable
          .locator('[role="columnheader"]')
          .filter({ hasText: 'Name' });
        await expect(nameHeader).toHaveAttribute('aria-sort', 'none');

        // Click again to sort descending
        await sortButton.click();
        await expect(ageHeader).toHaveAttribute('aria-sort', 'descending');
      });

      test('data rows are reordered when sort changes', async ({ page }) => {
        const sortableTable = page.locator('[role="table"][aria-label="Sortable User List"]');
        await expect(sortableTable).toBeVisible();

        // Click Age column to sort ascending (youngest first)
        const ageHeader = sortableTable.locator('[role="columnheader"]').filter({ hasText: 'Age' });
        await ageHeader.locator('button').click();

        // The first data row should now have the youngest person (Bob, 25)
        // Use toContainText to handle whitespace variations
        const firstCell = sortableTable.locator(
          '[role="rowgroup"]:last-child [role="row"]:first-child [role="cell"]:first-child'
        );
        await expect(firstCell).toContainText('Bob');
      });
    });

    // =========================================================================
    // Medium Priority: Virtualization
    // =========================================================================
    test.describe('APG: Virtualization', () => {
      test('aria-rowcount indicates total rows', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        // Total rows is set to totalRows prop value (100 data rows, not including header)
        await expect(virtualizedTable).toHaveAttribute('aria-rowcount', '100');
      });

      test('aria-colcount indicates total columns', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        await expect(virtualizedTable).toHaveAttribute('aria-colcount', '3');
      });

      test('data rows have aria-rowindex indicating position', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        // Data rows have rowIndex values from the demo data (5, 6, 7, 8, 9)
        const dataRows = virtualizedTable.locator('[role="rowgroup"]:last-child [role="row"]');
        await expect(dataRows.nth(0)).toHaveAttribute('aria-rowindex', '5');
        await expect(dataRows.nth(1)).toHaveAttribute('aria-rowindex', '6');
        await expect(dataRows.nth(2)).toHaveAttribute('aria-rowindex', '7');
        await expect(dataRows.nth(3)).toHaveAttribute('aria-rowindex', '8');
        await expect(dataRows.nth(4)).toHaveAttribute('aria-rowindex', '9');
      });

      test('cells have aria-colindex indicating column position', async ({ page }) => {
        const virtualizedTable = page.locator('[role="table"][aria-label*="Virtualized"]');
        await expect(virtualizedTable).toBeVisible();

        // First row cells should have colindex 1, 2, 3
        const firstRowCells = virtualizedTable.locator(
          '[role="rowgroup"]:last-child [role="row"]:first-child [role="cell"]'
        );
        await expect(firstRowCells.nth(0)).toHaveAttribute('aria-colindex', '1');
        await expect(firstRowCells.nth(1)).toHaveAttribute('aria-colindex', '2');
        await expect(firstRowCells.nth(2)).toHaveAttribute('aria-colindex', '3');
      });
    });

    // =========================================================================
    // Medium Priority: Cell Spanning
    // =========================================================================
    test.describe('APG: Cell Spanning', () => {
      test('aria-colspan and aria-rowspan attributes are present', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        // Verify colspan attributes exist
        const colspanCells = spanningTable.locator('[aria-colspan]');
        await expect(colspanCells).toHaveCount(2); // N/A (colspan=2) and Total (colspan=4)

        // Verify rowspan attributes exist
        const rowspanCells = spanningTable.locator('[aria-rowspan]');
        await expect(rowspanCells).toHaveCount(1); // Electronics (rowspan=2)

        // Verify specific values
        await expect(spanningTable.locator('[aria-colspan="2"]')).toHaveCount(1);
        await expect(spanningTable.locator('[aria-colspan="4"]')).toHaveCount(1);
        await expect(spanningTable.locator('[aria-rowspan="2"]')).toHaveCount(1);
      });

      test('colspan=2 cell is visually wider than single column header', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        const colspanCell = spanningTable.locator('[aria-colspan="2"]').first();
        await expect(colspanCell).toBeVisible();

        // Use header cell as reference (more consistent sizing than data cells)
        const headerCell = spanningTable.locator('[role="columnheader"]').first();
        await expect(headerCell).toBeVisible();

        // Wait for layout to stabilize and verify colspan cell is wider
        // Using header width as baseline: colspan=2 should be ~2x header width
        await expect
          .poll(
            async () => {
              const [colspanBox, headerBox] = await Promise.all([
                colspanCell.boundingBox(),
                headerCell.boundingBox(),
              ]);
              if (!colspanBox || !headerBox || headerBox.width === 0) return 0;
              return colspanBox.width / headerBox.width;
            },
            { timeout: 5000 }
          )
          .toBeGreaterThan(1.5); // Allow some tolerance for padding/borders
      });

      test('colspan=4 cell spans most of the table width', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        const fullSpanCell = spanningTable.locator('[aria-colspan="4"]').first();
        await expect(fullSpanCell).toBeVisible();

        // Use header cell as reference
        const headerCell = spanningTable.locator('[role="columnheader"]').first();
        await expect(headerCell).toBeVisible();

        // colspan=4 should be significantly wider than a single header
        await expect
          .poll(
            async () => {
              const [fullSpanBox, headerBox] = await Promise.all([
                fullSpanCell.boundingBox(),
                headerCell.boundingBox(),
              ]);
              if (!fullSpanBox || !headerBox || headerBox.width === 0) return 0;
              return fullSpanBox.width / headerBox.width;
            },
            { timeout: 5000 }
          )
          .toBeGreaterThan(3.0); // Should be ~4x but allow tolerance
      });

      test('rowspan=2 cell is visually taller than single row', async ({ page }) => {
        const spanningTable = page.locator('[role="table"][aria-label*="Spanning"]');
        await expect(spanningTable).toBeVisible();

        const rowspanCell = spanningTable.locator('[aria-rowspan="2"]').first();
        await expect(rowspanCell).toBeVisible();

        // Use a normal data cell in the same table as reference
        const normalCell = spanningTable
          .locator('[role="cell"]:not([aria-colspan]):not([aria-rowspan])')
          .first();
        await expect(normalCell).toBeVisible();

        // rowspan=2 should be ~2x the height of a normal cell
        await expect
          .poll(
            async () => {
              const [rowspanBox, normalBox] = await Promise.all([
                rowspanCell.boundingBox(),
                normalCell.boundingBox(),
              ]);
              if (!rowspanBox || !normalBox || normalBox.height === 0) return 0;
              return rowspanBox.height / normalBox.height;
            },
            { timeout: 5000 }
          )
          .toBeGreaterThan(1.5); // Allow some tolerance
      });
    });

    // =========================================================================
    // Medium Priority: Accessibility
    // =========================================================================
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const accessibilityScanResults = await new AxeBuilder({ page })
          .include('[role="table"]')
          .analyze();

        expect(accessibilityScanResults.violations).toEqual([]);
      });
    });
  });
}
Table.test.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();
    });
  });
});

リソース