テーブル
行と列でデータを表示するための静的な表構造。
デモ
基本テーブル
データを表示するシンプルな静的テーブル。インタラクティブな機能はありません。
ソート可能なテーブル
列ヘッダーをクリックしてソートします。 Uses aria-sort でソート方向を示します。このデモではインタラクティブなソートのために
React コンポーネントをclient:load で使用しています。
行ヘッダー付き
各行の最初のセルが role="rowheader" を使用し、スクリーンリーダーのナビゲーションを改善します。
仮想化対応
大規模なデータセットでは、 aria-rowcount、aria-colcount、aria-rowindex を使用してテーブル全体の構造を伝えます。
セル結合
セルは複数の列や行にまたがることができます。この実装では、視覚的な結合に CSS Grid の colspanとrowspan を使用し、CSS Grid の grid-column: span Nとgrid-row: span N で視覚的な結合を行い、スクリーンリーダーのアクセシビリティのために aria-colspanとaria-rowspan を使用しています。
ネイティブ 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を使用
- セルの罫線 - 背景色を使用したギャップベースの罫線
- ソートインジケーター - ソート方向を示す視覚的なアイコン
- ヘッダースタイリング - ヘッダーセルの明確な背景色
参考資料
ソースコード
---
/**
* 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> 使い方
---
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属性がパススルーされる |
テストツール
- Playwright (opens in new tab) - クロスフレームワーク検証のためのE2Eテスト
- Testing Library (opens in new tab) - フレームワーク固有のテストユーティリティ(React, Vue, Svelte)
- axe-core (opens in new tab) - 自動アクセシビリティテスト
E2Eテストコード
以下のPlaywrightテストは、4つのフレームワーク(React、Vue、Svelte、Astro)すべてでTableの動作を検証します。ARIA構造、ソート状態、仮想化属性、視覚的セル結合をカバーしています。
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 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();
});
});
}); リソース
- WAI-ARIA APG: Table パターン (opens in new tab)
- MDN: <table> 要素 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist