Table
行と列でデータを表示するための静的な表構造。
🤖 AI Implementation Guideデモ
基本的なテーブル
シンプルなデータ表示用の静的テーブル。インタラクティブな機能なし。
ソート可能なテーブル
列ヘッダーをクリックしてソート。aria-sort でソート方向を示します。
行ヘッダー付き
各行の最初のセルに role="rowheader" を使用して、スクリーンリーダーでのナビゲーションを改善。
仮想化サポート
大規模なデータセットでは、aria-rowcount、aria-colcount、
aria-rowindex を使用してテーブル全体の構造を伝えます。
セルの結合
colspan と rowspan
を使用してセルを複数列・複数行にまたがらせることができます。この実装では CSS Grid の
grid-column: span N と grid-row: span N
で視覚的な結合を実現し、さらに aria-colspan と aria-rowspan
でスクリーンリーダーのアクセシビリティを確保しています。
ネイティブ 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
要素に適用します。
| 値 | ascending、descending、none、other |
| 必須 | いいえ(ソート可能な列のみ) |
| 更新タイミング | ソート順が変更されたとき |
仮想化サポート
表示されている行/列のみをレンダリングする大規模なテーブルでは、これらの属性が支援技術にテーブル全体の構造を理解させるのに役立ちます:
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 |
リファレンス
ソースコード
<script lang="ts" module>
export interface TableColumn {
id: string;
header: string;
/** Column is sortable */
sortable?: boolean;
/** Current sort direction */
sort?: 'ascending' | 'descending' | 'none';
}
/**
* Cell with spanning support
*/
export interface TableCell {
content: string;
/** Number of columns this cell spans */
colspan?: number;
/** Number of rows this cell spans */
rowspan?: number;
}
/**
* Cell value - can be simple string or object with spanning
*/
export type TableCellValue = string | TableCell;
/**
* Type guard to check if cell is a TableCell object
*/
export function isTableCell(cell: TableCellValue): cell is TableCell {
return typeof cell === 'object' && cell !== null && 'content' in cell;
}
export interface TableRow {
id: string;
cells: TableCellValue[];
/** First cell is row header */
hasRowHeader?: boolean;
/** Row index for virtualization (1-based) */
rowIndex?: number;
}
</script>
<script lang="ts">
interface Props {
/** Column definitions */
columns: TableColumn[];
/** Row data */
rows: TableRow[];
/** Caption text (optional) */
caption?: string;
/** Callback when sort changes */
onSortChange?: (columnId: string, direction: 'ascending' | 'descending') => void;
// Virtualization support
/** Total number of columns (for virtualization) */
totalColumns?: number;
/** Total number of rows (for virtualization) */
totalRows?: number;
/** Starting column index (1-based, for virtualization) */
startColIndex?: number;
// HTML attributes
class?: string;
id?: string;
'aria-label'?: string;
'aria-labelledby'?: string;
'aria-describedby'?: string;
'data-testid'?: string;
}
let {
columns,
rows,
caption,
onSortChange,
totalColumns,
totalRows,
startColIndex,
class: className,
...restProps
}: Props = $props();
function handleSortClick(column: TableColumn) {
if (!column.sortable) return;
const newDirection: 'ascending' | 'descending' =
column.sort === 'ascending' ? 'descending' : 'ascending';
onSortChange?.(column.id, newDirection);
}
function getSortIcon(sort?: 'ascending' | 'descending' | 'none'): string {
if (sort === 'ascending') return '▲';
if (sort === 'descending') return '▼';
return '⇅';
}
function getCellRole(row: TableRow, cellIndex: number): 'rowheader' | 'cell' {
return row.hasRowHeader && cellIndex === 0 ? 'rowheader' : 'cell';
}
function getCellGridStyle(cell: TableCellValue): string | undefined {
if (!isTableCell(cell)) return undefined;
const styles: string[] = [];
if (cell.colspan && cell.colspan > 1) {
styles.push(`grid-column: span ${cell.colspan}`);
}
if (cell.rowspan && cell.rowspan > 1) {
styles.push(`grid-row: span ${cell.rowspan}`);
}
return styles.length > 0 ? styles.join('; ') : undefined;
}
</script>
<div
role="table"
class={`apg-table${className ? ` ${className}` : ''}`}
style="--table-cols: {columns.length}"
aria-colcount={totalColumns}
aria-rowcount={totalRows}
{...restProps}
>
{#if caption}
<div class="apg-table-caption">{caption}</div>
{/if}
<!-- Header rowgroup -->
<div role="rowgroup" class="apg-table-header">
<div role="row" class="apg-table-row">
{#each columns as column, colIndex (column.id)}
<div
role="columnheader"
class="apg-table-columnheader"
aria-sort={column.sortable ? column.sort || 'none' : undefined}
aria-colindex={startColIndex !== undefined ? startColIndex + colIndex : undefined}
>
{#if column.sortable}
<button
type="button"
class="apg-table-sort-button"
aria-label={`Sort by ${column.header}`}
onclick={() => handleSortClick(column)}
>
{column.header}
<span class="apg-table-sort-icon" aria-hidden="true">
{getSortIcon(column.sort)}
</span>
</button>
{:else}
{column.header}
{/if}
</div>
{/each}
</div>
</div>
<!-- Body rowgroup -->
<div role="rowgroup" class="apg-table-body">
{#each rows as row (row.id)}
<div role="row" class="apg-table-row" aria-rowindex={row.rowIndex}>
{#each row.cells as cell, cellIndex (cellIndex)}
<div
role={getCellRole(row, cellIndex)}
class={`apg-table-${getCellRole(row, cellIndex)}`}
style={getCellGridStyle(cell)}
aria-colindex={startColIndex !== undefined ? startColIndex + cellIndex : undefined}
aria-colspan={isTableCell(cell) && cell.colspan && cell.colspan > 1
? cell.colspan
: undefined}
aria-rowspan={isTableCell(cell) && cell.rowspan && cell.rowspan > 1
? cell.rowspan
: undefined}
>
{isTableCell(cell) ? cell.content : cell}
</div>
{/each}
</div>
{/each}
</div>
</div> 使い方
<script lang="ts">
import Table from './Table.svelte';
import type { TableColumn, TableRow } from './Table.svelte';
const columns: TableColumn[] = [
{ id: 'name', header: '名前' },
{ id: 'age', header: '年齢' },
{ id: 'city', header: '都市' },
];
const rows: TableRow[] = [
{ id: '1', cells: ['Alice', '30', '東京'] },
{ id: '2', cells: ['Bob', '25', '大阪'] },
{ id: '3', cells: ['Charlie', '35', '京都'] },
];
</script>
<!-- 基本的なテーブル -->
<Table {columns} {rows} aria-label="ユーザー一覧" />
<!-- ソート可能なテーブル -->
<Table
columns={sortableColumns}
{rows}
aria-label="ソート可能なユーザー一覧"
onSortChange={handleSortChange}
/> API
Table Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
columns | TableColumn[] | 必須 | 列定義 |
rows | TableRow[] | 必須 | 行データ |
caption | string | - | テーブルのキャプション(任意) |
onSortChange | (columnId: string, direction) => void | - | ソート変更時のコールバック |
totalColumns | number | - | 総列数(仮想化用) |
totalRows | number | - | 総行数(仮想化用) |
TableColumn インターフェース
interface TableColumn {
id: string;
header: string;
sortable?: boolean;
sort?: 'ascending' | 'descending' | 'none';
} TableRow インターフェース
interface TableCell {
content: string;
colspan?: number; // 結合する列数(視覚的 + aria-colspan)
rowspan?: number; // 結合する行数(視覚的 + aria-rowspan)
}
type TableCellValue = string | TableCell;
interface TableRow {
id: string;
cells: TableCellValue[];
hasRowHeader?: boolean;
rowIndex?: number; // 仮想化用(1始まり)
} テスト
テストは ARIA ロール、テーブル構造、アクセシビリティ要件についての APG 準拠を検証します。Table は静的な構造であるため、キーボードインタラクションのテストは該当しません。
テストカテゴリ
高優先度: ARIA 構造
| テスト | 説明 |
|---|---|
role="table" | コンテナ要素が table ロールを持つ |
role="rowgroup" | ヘッダーとボディグループが rowgroup ロールを持つ |
role="row" | すべての行が row ロールを持つ |
role="columnheader" | ヘッダーセルが columnheader ロールを持つ |
role="rowheader" | 指定時に行ヘッダーセルが rowheader ロールを持つ |
role="cell" | データセルが cell ロールを持つ |
高優先度: アクセシブルな名前
| テスト | 説明 |
|---|---|
aria-label | aria-label 属性によるアクセシブルな名前 |
aria-labelledby | 外部要素参照によるアクセシブルな名前 |
caption | 提供時にキャプションが表示される |
高優先度: ソート状態
| テスト | 説明 |
|---|---|
aria-sort="ascending" | 昇順でソートされた列 |
aria-sort="descending" | 降順でソートされた列 |
aria-sort="none" | 現在ソートされていないソート可能な列 |
aria-sort なし | ソート不可の列には aria-sort 属性がない |
onSortChange コールバック | ソートボタンクリック時にコールバックが呼び出される |
ソートトグル | ソート済み列をクリックすると方向が切り替わる |
中優先度: 仮想化サポート
| テスト | 説明 |
|---|---|
aria-colcount | 仮想化テーブルの総列数 |
aria-rowcount | 仮想化テーブルの総行数 |
aria-rowindex | テーブル全体における行の位置 |
aria-colindex | テーブル全体におけるセルの位置 |
中優先度: 視覚的セル結合(ブラウザテスト)
| テスト | 説明 |
|---|---|
colspan 幅 | colspan=2 のセルが通常セルの約2倍の幅を持つ |
rowspan 高さ | rowspan=2 のセルが通常セルの約2倍の高さを持つ |
全列スパン | 全列にまたがるセルがテーブル幅と一致する |
複合スパン | colspan と rowspan の両方を持つセルが正しい寸法を持つ |
注意: 視覚的スパンテストは getBoundingClientRect()
を使用し、実際のブラウザ環境(Vitest browser mode + Playwright)が必要です。これらのテストは
*.browser.test.ts ファイルにあります。
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe 違反 | axe-core によるアクセシビリティ違反なし |
ソート可能列 | ソート可能な列ヘッダーで違反なし |
行ヘッダー | 行ヘッダーセルで違反なし |
空のテーブル | データ行が空でも違反なし |
中優先度: エッジケース
| テスト | 説明 |
|---|---|
空の行 | データ行なしでテーブルが正しくレンダリングされる |
単一列 | テーブルが単一列を正しく処理する |
低優先度: HTML 属性継承
| テスト | 説明 |
|---|---|
className | カスタムクラスがコンテナに適用される |
id | id 属性が正しく設定される |
data-* | data 属性がパススルーされる |
テストツール
- React: React Testing Library (opens in new tab)
- Vue: Vue Testing Library (opens in new tab)
- Svelte: Svelte Testing Library (opens in new tab)
- Astro: Web Components 単体テスト用の Vitest + JSDOM
- アクセシビリティ: axe-core (opens in new tab)
import { render, screen, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Table from './Table.svelte';
import TableWithLabelledby from './TableWithLabelledby.test.svelte';
import type { TableColumn, TableRow, TableCell } from './Table.svelte';
const basicColumns: TableColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'age', header: 'Age' },
{ id: 'city', header: 'City' },
];
const basicRows: TableRow[] = [
{ id: '1', cells: ['Alice', '30', 'Tokyo'] },
{ id: '2', cells: ['Bob', '25', 'Osaka'] },
{ id: '3', cells: ['Charlie', '35', 'Kyoto'] },
];
const sortableColumns: TableColumn[] = [
{ id: 'name', header: 'Name', sortable: true, sort: 'ascending' },
{ id: 'age', header: 'Age', sortable: true },
{ id: 'city', header: 'City' },
];
const rowsWithRowHeader: TableRow[] = [
{ id: '1', cells: ['Alice', '30', 'Tokyo'], hasRowHeader: true },
{ id: '2', cells: ['Bob', '25', 'Osaka'], hasRowHeader: true },
];
describe('Table (Svelte)', () => {
// 🔴 High Priority: APG ARIA Structure
describe('APG: ARIA Structure', () => {
it('has role="table" on container', () => {
render(Table, {
props: { columns: basicColumns, rows: basicRows, 'aria-label': 'Users' },
});
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('has role="rowgroup" on header and body groups', () => {
render(Table, {
props: { columns: basicColumns, rows: basicRows, 'aria-label': 'Users' },
});
const rowgroups = screen.getAllByRole('rowgroup');
expect(rowgroups).toHaveLength(2);
});
it('has role="row" on all rows', () => {
render(Table, {
props: { columns: basicColumns, rows: basicRows, 'aria-label': 'Users' },
});
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(4); // 1 header + 3 data
});
it('has role="columnheader" on header cells', () => {
render(Table, {
props: { columns: basicColumns, rows: basicRows, 'aria-label': 'Users' },
});
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(3);
expect(headers[0]).toHaveTextContent('Name');
});
it('has role="cell" on data cells', () => {
render(Table, {
props: { columns: basicColumns, rows: basicRows, 'aria-label': 'Users' },
});
const cells = screen.getAllByRole('cell');
expect(cells).toHaveLength(9);
});
it('has role="rowheader" on row headers when hasRowHeader is true', () => {
render(Table, {
props: { columns: basicColumns, rows: rowsWithRowHeader, 'aria-label': 'Users' },
});
const rowheaders = screen.getAllByRole('rowheader');
expect(rowheaders).toHaveLength(2);
expect(rowheaders[0]).toHaveTextContent('Alice');
});
});
// 🔴 High Priority: Accessible Name
describe('APG: Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(Table, {
props: { columns: basicColumns, rows: basicRows, 'aria-label': 'User List' },
});
expect(screen.getByRole('table', { name: 'User List' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(TableWithLabelledby);
expect(screen.getByRole('table', { name: 'Employee Directory' })).toBeInTheDocument();
});
it('displays caption when provided', () => {
render(Table, {
props: {
columns: basicColumns,
rows: basicRows,
'aria-label': 'Users',
caption: 'User Data',
},
});
expect(screen.getByText('User Data')).toBeInTheDocument();
});
});
// 🔴 High Priority: Sort State
describe('APG: Sort State', () => {
it('has aria-sort="ascending" on ascending sorted column', () => {
render(Table, {
props: { columns: sortableColumns, rows: basicRows, 'aria-label': 'Users' },
});
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
});
it('has aria-sort="descending" on descending sorted column', () => {
const columns: TableColumn[] = [
{ id: 'name', header: 'Name', sortable: true, sort: 'descending' },
{ id: 'age', header: 'Age', sortable: true },
];
render(Table, {
props: { columns, rows: basicRows, 'aria-label': 'Users' },
});
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
expect(nameHeader).toHaveAttribute('aria-sort', 'descending');
});
it('has aria-sort="none" on unsorted sortable columns', () => {
render(Table, {
props: { columns: sortableColumns, rows: basicRows, 'aria-label': 'Users' },
});
const ageHeader = screen.getByRole('columnheader', { name: /age/i });
expect(ageHeader).toHaveAttribute('aria-sort', 'none');
});
it('does not have aria-sort on non-sortable columns', () => {
render(Table, {
props: { columns: sortableColumns, rows: basicRows, 'aria-label': 'Users' },
});
const cityHeader = screen.getByRole('columnheader', { name: /city/i });
expect(cityHeader).not.toHaveAttribute('aria-sort');
});
it('calls onSortChange when sortable header is clicked', async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(Table, {
props: {
columns: sortableColumns,
rows: basicRows,
'aria-label': 'Users',
onSortChange,
},
});
const ageHeader = screen.getByRole('columnheader', { name: /age/i });
const sortButton = within(ageHeader).getByRole('button');
await user.click(sortButton);
expect(onSortChange).toHaveBeenCalledWith('age', 'ascending');
});
it('toggles sort direction when already sorted column is clicked', async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(Table, {
props: {
columns: sortableColumns,
rows: basicRows,
'aria-label': 'Users',
onSortChange,
},
});
const nameHeader = screen.getByRole('columnheader', { name: /name/i });
const sortButton = within(nameHeader).getByRole('button');
await user.click(sortButton);
expect(onSortChange).toHaveBeenCalledWith('name', 'descending');
});
});
// 🟡 Medium Priority: Virtualization Support
describe('APG: Virtualization Support', () => {
it('has aria-colcount when totalColumns is provided', () => {
render(Table, {
props: {
columns: basicColumns,
rows: basicRows,
'aria-label': 'Users',
totalColumns: 10,
},
});
const table = screen.getByRole('table');
expect(table).toHaveAttribute('aria-colcount', '10');
});
it('has aria-rowcount when totalRows is provided', () => {
render(Table, {
props: {
columns: basicColumns,
rows: basicRows,
'aria-label': 'Users',
totalRows: 100,
},
});
const table = screen.getByRole('table');
expect(table).toHaveAttribute('aria-rowcount', '100');
});
it('has aria-rowindex on rows when rowIndex is provided', () => {
const rowsWithIndex: TableRow[] = [
{ id: '1', cells: ['Alice', '30', 'Tokyo'], rowIndex: 5 },
{ id: '2', cells: ['Bob', '25', 'Osaka'], rowIndex: 6 },
];
render(Table, {
props: {
columns: basicColumns,
rows: rowsWithIndex,
'aria-label': 'Users',
totalRows: 100,
},
});
const rows = screen.getAllByRole('row');
expect(rows[1]).toHaveAttribute('aria-rowindex', '5');
expect(rows[2]).toHaveAttribute('aria-rowindex', '6');
});
it('has aria-colindex on cells when startColIndex is provided', () => {
render(Table, {
props: {
columns: basicColumns,
rows: basicRows,
'aria-label': 'Users',
totalColumns: 10,
startColIndex: 3,
},
});
const firstRow = screen.getAllByRole('row')[1];
const cells = within(firstRow).getAllByRole('cell');
expect(cells[0]).toHaveAttribute('aria-colindex', '3');
expect(cells[1]).toHaveAttribute('aria-colindex', '4');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations with basic table', async () => {
const { container } = render(Table, {
props: { columns: basicColumns, rows: basicRows, 'aria-label': 'Users' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with sortable columns', async () => {
const { container } = render(Table, {
props: { columns: sortableColumns, rows: basicRows, 'aria-label': 'Users' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with row headers', async () => {
const { container } = render(Table, {
props: { columns: basicColumns, rows: rowsWithRowHeader, 'aria-label': 'Users' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with empty table', async () => {
const { container } = render(Table, {
props: { columns: basicColumns, rows: [], 'aria-label': 'Empty Users' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Cell Spanning
describe('APG: Cell Spanning', () => {
it('has aria-colspan when cell spans multiple columns', () => {
const rowsWithColspan: TableRow[] = [
{
id: '1',
cells: [{ content: 'Merged', colspan: 2 } as TableCell, 'Single'],
},
];
render(Table, {
props: { columns: basicColumns, rows: rowsWithColspan, 'aria-label': 'Users' },
});
const cells = screen.getAllByRole('cell');
expect(cells[0]).toHaveAttribute('aria-colspan', '2');
expect(cells[1]).not.toHaveAttribute('aria-colspan');
});
it('has aria-rowspan when cell spans multiple rows', () => {
const rowsWithRowspan: TableRow[] = [
{
id: '1',
cells: [{ content: 'Spans 2 rows', rowspan: 2 } as TableCell, 'A', 'B'],
},
{ id: '2', cells: ['C', 'D'] },
];
render(Table, {
props: { columns: basicColumns, rows: rowsWithRowspan, 'aria-label': 'Users' },
});
const firstRowCells = within(screen.getAllByRole('row')[1]).getAllByRole('cell');
expect(firstRowCells[0]).toHaveAttribute('aria-rowspan', '2');
});
it('does not have aria-colspan when colspan is 1', () => {
const rowsWithColspanOne: TableRow[] = [
{
id: '1',
cells: [{ content: 'Single', colspan: 1 } as TableCell, 'B', 'C'],
},
];
render(Table, {
props: { columns: basicColumns, rows: rowsWithColspanOne, 'aria-label': 'Users' },
});
const cells = screen.getAllByRole('cell');
expect(cells[0]).not.toHaveAttribute('aria-colspan');
});
it('renders cell content correctly with TableCell object', () => {
const rowsWithTableCell: TableRow[] = [
{
id: '1',
cells: [{ content: 'Cell Content', colspan: 2 } as TableCell, 'Normal'],
},
];
render(Table, {
props: { columns: basicColumns, rows: rowsWithTableCell, 'aria-label': 'Users' },
});
expect(screen.getByText('Cell Content')).toBeInTheDocument();
expect(screen.getByText('Normal')).toBeInTheDocument();
});
it('has no axe violations with spanning cells', async () => {
const rowsWithSpanning: TableRow[] = [
{
id: '1',
cells: [{ content: 'Merged', colspan: 2 } as TableCell, 'C'],
},
{ id: '2', cells: ['D', 'E', 'F'] },
];
const { container } = render(Table, {
props: { columns: basicColumns, rows: rowsWithSpanning, 'aria-label': 'Users' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('renders empty table with no rows', () => {
render(Table, {
props: { columns: basicColumns, rows: [], 'aria-label': 'Empty' },
});
const table = screen.getByRole('table');
expect(table).toBeInTheDocument();
const cells = screen.queryAllByRole('cell');
expect(cells).toHaveLength(0);
});
it('handles single column', () => {
const singleColumn: TableColumn[] = [{ id: 'name', header: 'Name' }];
const singleColumnRows: TableRow[] = [
{ id: '1', cells: ['Alice'] },
{ id: '2', cells: ['Bob'] },
];
render(Table, {
props: { columns: singleColumn, rows: singleColumnRows, 'aria-label': 'Names' },
});
expect(screen.getAllByRole('columnheader')).toHaveLength(1);
expect(screen.getAllByRole('cell')).toHaveLength(2);
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies class to container', () => {
render(Table, {
props: {
columns: basicColumns,
rows: basicRows,
'aria-label': 'Users',
class: 'custom-table',
},
});
const table = screen.getByRole('table');
expect(table).toHaveClass('custom-table');
});
it('sets id attribute', () => {
render(Table, {
props: {
columns: basicColumns,
rows: basicRows,
'aria-label': 'Users',
id: 'my-table',
},
});
const table = screen.getByRole('table');
expect(table).toHaveAttribute('id', 'my-table');
});
it('passes through data-* attributes', () => {
render(Table, {
props: {
columns: basicColumns,
rows: basicRows,
'aria-label': 'Users',
'data-testid': 'user-table',
},
});
expect(screen.getByTestId('user-table')).toBeInTheDocument();
});
});
}); リソース
- 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