Table
A static tabular structure for displaying data with rows and columns.
🤖 AI Implementation GuideDemo
Basic Table
A simple static table displaying data. No interactive features.
Sortable Table
Click column headers to sort. Uses aria-sort to indicate sort direction.
With Row Headers
First cell in each row uses role="rowheader" for better screen reader navigation.
Virtualization Support
For large datasets, use aria-rowcount, aria-colcount, and
aria-rowindex to communicate the full table structure.
Spanning Cells
Cells can span multiple columns or rows using colspan and rowspan.
This implementation uses CSS Grid with grid-column: span N and
grid-row: span N for visual spanning, plus aria-colspan and
aria-rowspan for screen reader accessibility.
Native HTML
Use Native HTML First
Before using this custom component, consider using native <table>
elements.
They provide built-in semantics, work without JavaScript, and require no ARIA attributes.
<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>
Use custom implementations only when you need CSS Grid/Flexbox layouts for responsive tables, or
when you cannot use native <table> elements due to design constraints.
| Use Case | Native HTML | Custom Implementation |
|---|---|---|
| Basic tabular data | Recommended | Not needed |
| JavaScript disabled support | Works natively | Requires fallback |
| Built-in accessibility | Automatic | Manual ARIA required |
| CSS Grid/Flexbox layout | Limited (display: table) | Full control |
| Responsive column reordering | Limited | Full control |
| Virtualization support | Not built-in | With ARIA support |
The native <table> element with <thead>,
<tbody>, <th>, and <td> provides complete
semantic structure automatically. The ARIA table pattern is only necessary when using non-table elements
(e.g., <div>) for layout purposes.
Accessibility Features
WAI-ARIA Roles
| Role | Element | Description |
|---|---|---|
table | Container element | Identifies the element as a table structure containing rows and cells of data. |
rowgroup | Header/Body container |
Groups rows together (equivalent to <thead>, <tbody>, <tfoot>).
|
row | Row element |
A row of cells within the table (equivalent to <tr>).
|
columnheader | Header cell |
A header cell for a column (equivalent to <th> in header row).
|
rowheader | Header cell |
A header cell for a row (equivalent to <th scope="row">).
|
cell | Data cell | A data cell within a row (equivalent to <td>). |
The table role creates a static tabular structure for displaying data. Unlike the
grid role, tables are not interactive and do not support keyboard navigation between
cells.
WAI-ARIA Properties
aria-label / aria-labelledby
Provides an accessible name for the table. One of these is required for screen reader users to understand the table's purpose.
| Type | String / ID reference |
| Required | Yes (one or the other) |
| Example | aria-label="User List" |
aria-describedby
References an element providing additional description for the table.
| Type | ID reference |
| Required | No |
WAI-ARIA States
aria-sort
Indicates the current sort direction of a column. Applied to columnheader or
rowheader elements.
| Values | ascending, descending, none, other |
| Required | No (only for sortable columns) |
| Updated | When sort order changes |
Virtualization Support
For large tables that only render visible rows/columns, these attributes help assistive technologies understand the full table structure:
aria-colcount / aria-rowcount
Defines the total number of columns/rows in the table when only a subset is rendered.
| Type | Number |
| Applied to | table role element
|
| Required | No (only for virtualized tables) |
aria-colindex / aria-rowindex
Indicates the position of a cell or row within the full table.
| Type | Number (1-based) |
| Applied to | aria-colindex on cells, aria-rowindex on rows
|
| Required | No (only for virtualized tables) |
Keyboard Support
Not applicable. The table pattern is a static structure and is not interactive. Unlike
the grid pattern, users cannot navigate between cells using arrow keys. Interactive elements
within cells (buttons, links) receive focus through normal tab order.
Accessible Naming
Tables must have an accessible name. Options include:
-
aria-label- Provides an invisible label for the table -
aria-labelledby- References an external element as the label - Caption element - A visible caption can provide the accessible name if properly associated
Table vs Grid
Understanding when to use table vs grid roles:
| Feature | Table | Grid |
|---|---|---|
| Purpose | Static data display | Interactive data manipulation |
| Keyboard navigation | Not applicable | Arrow keys between cells |
| Cell selection | Not supported | Via aria-selected |
| Focus management | None required | Roving tabindex |
References
Source Code
<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> Usage
<script lang="ts">
import Table from './Table.svelte';
import type { TableColumn, TableRow } from './Table.svelte';
const columns: TableColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'age', header: 'Age' },
{ id: 'city', header: 'City' },
];
const rows: TableRow[] = [
{ id: '1', cells: ['Alice', '30', 'Tokyo'] },
{ id: '2', cells: ['Bob', '25', 'Osaka'] },
{ id: '3', cells: ['Charlie', '35', 'Kyoto'] },
];
// Sortable columns
let sortableColumns = $state<TableColumn[]>([
{ id: 'name', header: 'Name', sortable: true, sort: 'ascending' },
{ id: 'age', header: 'Age', sortable: true },
{ id: 'city', header: 'City' },
]);
function handleSortChange(columnId: string, direction: 'ascending' | 'descending') {
// Handle sort change
}
</script>
<!-- Basic table -->
<Table {columns} {rows} aria-label="User List" />
<!-- Sortable table -->
<Table
columns={sortableColumns}
{rows}
aria-label="Sortable User List"
onSortChange={handleSortChange}
/>
<!-- With row headers -->
<Table
{columns}
rows={rowsWithHeaders}
aria-label="User List with Row Headers"
/> API
Table Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | TableColumn[] | required | Column definitions |
rows | TableRow[] | required | Row data |
caption | string | - | Optional table caption |
onSortChange | (columnId: string, direction) => void | - | Callback when sort changes |
totalColumns | number | - | Total columns (for virtualization) |
totalRows | number | - | Total rows (for virtualization) |
startColIndex | number | - | Starting column index (1-based) |
TableColumn Interface
interface TableColumn {
id: string;
header: string;
sortable?: boolean;
sort?: 'ascending' | 'descending' | 'none';
} TableRow Interface
interface TableCell {
content: string;
colspan?: number; // Columns spanned (visual + aria-colspan)
rowspan?: number; // Rows spanned (visual + aria-rowspan)
}
type TableCellValue = string | TableCell;
interface TableRow {
id: string;
cells: TableCellValue[];
hasRowHeader?: boolean;
rowIndex?: number; // For virtualization (1-based)
} Testing
Tests verify APG compliance for ARIA roles, table structure, and accessibility requirements. Since Table is a static structure, keyboard interaction tests are not applicable.
Test Categories
High Priority: ARIA Structure
| Test | Description |
|---|---|
role="table" | Container element has the table role |
role="rowgroup" | Header and body groups have rowgroup role |
role="row" | All rows have the row role |
role="columnheader" | Header cells have columnheader role |
role="rowheader" | Row header cells have rowheader role when specified |
role="cell" | Data cells have the cell role |
High Priority: Accessible Name
| Test | Description |
|---|---|
aria-label | Accessible name via aria-label attribute |
aria-labelledby | Accessible name via external element reference |
caption | Caption is displayed when provided |
High Priority: Sort State
| Test | Description |
|---|---|
aria-sort="ascending" | Column sorted in ascending order |
aria-sort="descending" | Column sorted in descending order |
aria-sort="none" | Sortable column that is not currently sorted |
no aria-sort | Non-sortable columns have no aria-sort attribute |
onSortChange callback | Callback is invoked when sort button is clicked |
sort toggle | Clicking sorted column toggles direction |
Medium Priority: Virtualization Support
| Test | Description |
|---|---|
aria-colcount | Total column count for virtualized tables |
aria-rowcount | Total row count for virtualized tables |
aria-rowindex | Row position in the full table |
aria-colindex | Cell position in the full table |
Medium Priority: Visual Cell Spanning (Browser Tests)
| Test | Description |
|---|---|
colspan width | Cell with colspan=2 has ~2x width of normal cell |
rowspan height | Cell with rowspan=2 has ~2x height of normal cell |
full span | Cell spanning all columns matches table width |
combined spans | Cell with both colspan and rowspan has correct dimensions |
Note: Visual spanning tests use getBoundingClientRect() and require
a real browser environment (Vitest browser mode with Playwright). These tests are in
*.browser.test.ts files.
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No accessibility violations detected by axe-core |
sortable columns | No violations with sortable column headers |
row headers | No violations with row header cells |
empty table | No violations with empty data rows |
Medium Priority: Edge Cases
| Test | Description |
|---|---|
empty rows | Table renders correctly with no data rows |
single column | Table handles single column correctly |
Low Priority: HTML Attribute Inheritance
| Test | Description |
|---|---|
className | Custom class is applied to container |
id | ID attribute is set correctly |
data-* | Data attributes are passed through |
Testing Tools
- 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: Vitest with JSDOM for Web Component unit tests
- Accessibility: 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();
});
});
}); Resources
- WAI-ARIA APG: Table Pattern (opens in new tab)
- MDN: <table> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist