APG Patterns
日本語 GitHub
日本語 GitHub

Table

A static tabular structure for displaying data with rows and columns.

🤖 AI Implementation Guide

Demo

Basic Table

A simple static table displaying data. No interactive features.

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

Sortable Table

Click column headers to sort. Uses aria-sort to indicate sort direction.

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

With Row Headers

First cell in each row uses role="rowheader" for better screen reader navigation.

Name
Age
City
Alice
30
Tokyo
Bob
25
Osaka
Charlie
35
Kyoto

Virtualization Support

For large datasets, use aria-rowcount, aria-colcount, and aria-rowindex to communicate the full table structure.

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

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.

Product
Q1
Q2
Q3
Q4
Electronics
150
180
200
220
175
190
210
240
Clothing
N/A
90
120
Total
1775

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

Table.tsx
import type { ReactNode } from 'react';

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 | ReactNode;
  /** Number of columns this cell spans */
  colspan?: number;
  /** Number of rows this cell spans */
  rowspan?: number;
}

/**
 * Cell value - can be simple string/node or object with spanning
 */
export type TableCellValue = string | ReactNode | 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;
}

export interface TableProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'role'> {
  /** 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;
}

export function Table({
  columns,
  rows,
  caption,
  onSortChange,
  totalColumns,
  totalRows,
  startColIndex,
  className,
  ...props
}: TableProps) {
  const handleSortClick = (column: TableColumn) => {
    if (!column.sortable || !onSortChange) return;

    const newDirection: 'ascending' | 'descending' =
      column.sort === 'ascending' ? 'descending' : 'ascending';
    onSortChange(column.id, newDirection);
  };

  // CSS Grid needs to know the column count (using const assertion for custom property)
  const cssVars = { '--table-cols': columns.length } as const;
  const tableStyle = { ...cssVars };

  return (
    <div
      role="table"
      className={`apg-table${className ? ` ${className}` : ''}`}
      style={tableStyle}
      aria-colcount={totalColumns}
      aria-rowcount={totalRows}
      {...props}
    >
      {caption && <div className="apg-table-caption">{caption}</div>}

      {/* Header rowgroup */}
      <div role="rowgroup" className="apg-table-header">
        <div role="row" className="apg-table-row">
          {columns.map((column, colIndex) => {
            const sortProps = column.sortable
              ? { 'aria-sort': column.sort || ('none' as const) }
              : {};
            const colIndexProps =
              startColIndex !== undefined ? { 'aria-colindex': startColIndex + colIndex } : {};

            return (
              <div
                key={column.id}
                role="columnheader"
                className="apg-table-columnheader"
                {...sortProps}
                {...colIndexProps}
              >
                {column.sortable ? (
                  <button
                    type="button"
                    className="apg-table-sort-button"
                    onClick={() => handleSortClick(column)}
                    aria-label={`Sort by ${column.header}`}
                  >
                    {column.header}
                    <span className="apg-table-sort-icon" aria-hidden="true">
                      {column.sort === 'ascending' ? '▲' : column.sort === 'descending' ? '▼' : '⇅'}
                    </span>
                  </button>
                ) : (
                  column.header
                )}
              </div>
            );
          })}
        </div>
      </div>

      {/* Body rowgroup */}
      <div role="rowgroup" className="apg-table-body">
        {rows.map((row) => {
          const rowIndexProps = row.rowIndex !== undefined ? { 'aria-rowindex': row.rowIndex } : {};

          return (
            <div key={row.id} role="row" className="apg-table-row" {...rowIndexProps}>
              {row.cells.map((cell, cellIndex) => {
                const isRowHeader = row.hasRowHeader && cellIndex === 0;
                const cellRole = isRowHeader ? 'rowheader' : 'cell';
                const colIndexProps =
                  startColIndex !== undefined ? { 'aria-colindex': startColIndex + cellIndex } : {};

                // Handle cell spanning
                const cellData = isTableCell(cell) ? cell : { content: cell };
                const spanProps: Record<string, number | undefined> = {};
                const gridStyle: React.CSSProperties = {};

                if (cellData.colspan && cellData.colspan > 1) {
                  spanProps['aria-colspan'] = cellData.colspan;
                  gridStyle.gridColumn = `span ${cellData.colspan}`;
                }
                if (cellData.rowspan && cellData.rowspan > 1) {
                  spanProps['aria-rowspan'] = cellData.rowspan;
                  gridStyle.gridRow = `span ${cellData.rowspan}`;
                }

                return (
                  <div
                    key={cellIndex}
                    role={cellRole}
                    className={`apg-table-${cellRole}`}
                    style={Object.keys(gridStyle).length > 0 ? gridStyle : undefined}
                    {...colIndexProps}
                    {...spanProps}
                  >
                    {cellData.content}
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Usage

Example
import { Table } from './Table';
import type { TableColumn, TableRow } from './Table';

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'] },
];

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

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

<Table
  columns={sortableColumns}
  rows={rows}
  aria-label="Sortable User List"
  onSortChange={(columnId, direction) => {
    console.log(`Sort ${columnId} ${direction}`);
  }}
/>

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

<Table
  columns={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

Types
interface TableColumn {
  id: string;
  header: string;
  sortable?: boolean;
  sort?: 'ascending' | 'descending' | 'none';
}

TableRow Interface

Types
interface TableCell {
  content: string | ReactNode;
  colspan?: number; // Columns spanned
  rowspan?: number; // Rows spanned
}

type TableCellValue = string | ReactNode | 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

Table.test.tsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Table, TableColumn, TableRow, TableCell } from './Table';

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', () => {
  // 🔴 High Priority: APG ARIA Structure
  describe('APG: ARIA Structure', () => {
    it('has role="table" on container', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      expect(screen.getByRole('table')).toBeInTheDocument();
    });

    it('has role="rowgroup" on header and body groups', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const rowgroups = screen.getAllByRole('rowgroup');
      expect(rowgroups).toHaveLength(2); // header + body
    });

    it('has role="row" on all rows', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const rows = screen.getAllByRole('row');
      expect(rows).toHaveLength(4); // 1 header row + 3 data rows
    });

    it('has role="columnheader" on header cells', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const headers = screen.getAllByRole('columnheader');
      expect(headers).toHaveLength(3);
      expect(headers[0]).toHaveTextContent('Name');
      expect(headers[1]).toHaveTextContent('Age');
      expect(headers[2]).toHaveTextContent('City');
    });

    it('has role="cell" on data cells', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const cells = screen.getAllByRole('cell');
      expect(cells).toHaveLength(9); // 3 columns x 3 rows
    });

    it('has role="rowheader" on row headers when hasRowHeader is true', () => {
      render(<Table columns={basicColumns} rows={rowsWithRowHeader} aria-label="Users" />);
      const rowheaders = screen.getAllByRole('rowheader');
      expect(rowheaders).toHaveLength(2);
      expect(rowheaders[0]).toHaveTextContent('Alice');
      expect(rowheaders[1]).toHaveTextContent('Bob');
    });

    it('does not have rowheader role when hasRowHeader is false', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      expect(screen.queryByRole('rowheader')).not.toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('APG: Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="User List" />);
      expect(screen.getByRole('table', { name: 'User List' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <h2 id="table-title">Employee Directory</h2>
          <Table columns={basicColumns} rows={basicRows} aria-labelledby="table-title" />
        </>
      );
      expect(screen.getByRole('table', { name: 'Employee Directory' })).toBeInTheDocument();
    });

    it('has description via aria-describedby', () => {
      render(
        <>
          <Table
            columns={basicColumns}
            rows={basicRows}
            aria-label="Users"
            aria-describedby="table-desc"
          />
          <p id="table-desc">A list of registered users</p>
        </>
      );
      const table = screen.getByRole('table');
      expect(table).toHaveAttribute('aria-describedby', 'table-desc');
    });

    it('displays caption when provided', () => {
      render(
        <Table 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 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 columns={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 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 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
          columns={sortableColumns}
          rows={basicRows}
          aria-label="Users"
          onSortChange={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
          columns={sortableColumns}
          rows={basicRows}
          aria-label="Users"
          onSortChange={onSortChange}
        />
      );

      const nameHeader = screen.getByRole('columnheader', { name: /name/i });
      const sortButton = within(nameHeader).getByRole('button');
      await user.click(sortButton);

      expect(onSortChange).toHaveBeenCalledWith('name', 'descending');
    });

    it('sortable header button has accessible name', () => {
      render(<Table columns={sortableColumns} rows={basicRows} aria-label="Users" />);
      const nameHeader = screen.getByRole('columnheader', { name: /name/i });
      const sortButton = within(nameHeader).getByRole('button');
      expect(sortButton).toHaveAccessibleName(/sort by name/i);
    });
  });

  // 🟡 Medium Priority: Virtualization Support
  describe('APG: Virtualization Support', () => {
    it('has aria-colcount when totalColumns is provided', () => {
      render(
        <Table 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 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 columns={basicColumns} rows={rowsWithIndex} aria-label="Users" totalRows={100} />
      );
      const rows = screen.getAllByRole('row');
      // Skip header row (index 0)
      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
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          totalColumns={10}
          startColIndex={3}
        />
      );
      const firstRow = screen.getAllByRole('row')[1]; // First data row
      const cells = within(firstRow).getAllByRole('cell');
      expect(cells[0]).toHaveAttribute('aria-colindex', '3');
      expect(cells[1]).toHaveAttribute('aria-colindex', '4');
      expect(cells[2]).toHaveAttribute('aria-colindex', '5');
    });

    it('does not have virtualization attributes when not provided', () => {
      render(<Table columns={basicColumns} rows={basicRows} aria-label="Users" />);
      const table = screen.getByRole('table');
      expect(table).not.toHaveAttribute('aria-colcount');
      expect(table).not.toHaveAttribute('aria-rowcount');

      const rows = screen.getAllByRole('row');
      expect(rows[1]).not.toHaveAttribute('aria-rowindex');

      const cells = screen.getAllByRole('cell');
      expect(cells[0]).not.toHaveAttribute('aria-colindex');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations with basic table', async () => {
      const { container } = render(
        <Table 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 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 columns={basicColumns} rows={rowsWithRowHeader} aria-label="Users" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <h2 id="title">Users</h2>
          <Table columns={basicColumns} rows={basicRows} aria-labelledby="title" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with virtualization attributes', async () => {
      const { container } = render(
        <Table
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          totalColumns={10}
          totalRows={100}
        />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with empty table', async () => {
      const { container } = render(
        <Table 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 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 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 columns={basicColumns} rows={rowsWithColspanOne} aria-label="Users" />);
      const cells = screen.getAllByRole('cell');
      expect(cells[0]).not.toHaveAttribute('aria-colspan');
    });

    it('does not have aria-rowspan when rowspan is 1', () => {
      const rowsWithRowspanOne: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Single', rowspan: 1 } as TableCell, 'B', 'C'],
        },
      ];
      render(<Table columns={basicColumns} rows={rowsWithRowspanOne} aria-label="Users" />);
      const cells = screen.getAllByRole('cell');
      expect(cells[0]).not.toHaveAttribute('aria-rowspan');
    });

    it('renders cell content correctly with TableCell object', () => {
      const rowsWithTableCell: TableRow[] = [
        {
          id: '1',
          cells: [{ content: 'Cell Content', colspan: 2 } as TableCell, 'Normal'],
        },
      ];
      render(<Table 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 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 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 ReactNode in cells', () => {
      const rowsWithNodes: TableRow[] = [
        {
          id: '1',
          cells: [<a href="/alice">Alice</a>, '30', <span className="highlight">Tokyo</span>],
        },
      ];
      render(<Table columns={basicColumns} rows={rowsWithNodes} aria-label="Users" />);
      expect(screen.getByRole('link', { name: 'Alice' })).toBeInTheDocument();
      expect(screen.getByText('Tokyo')).toHaveClass('highlight');
    });

    it('handles single column', () => {
      const singleColumn: TableColumn[] = [{ id: 'name', header: 'Name' }];
      const singleColumnRows: TableRow[] = [
        { id: '1', cells: ['Alice'] },
        { id: '2', cells: ['Bob'] },
      ];
      render(<Table columns={singleColumn} rows={singleColumnRows} aria-label="Names" />);
      expect(screen.getAllByRole('columnheader')).toHaveLength(1);
      expect(screen.getAllByRole('cell')).toHaveLength(2);
    });

    it('handles single row', () => {
      const singleRow: TableRow[] = [{ id: '1', cells: ['Alice', '30', 'Tokyo'] }];
      render(<Table columns={basicColumns} rows={singleRow} aria-label="User" />);
      const rows = screen.getAllByRole('row');
      expect(rows).toHaveLength(2); // 1 header + 1 data
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      render(
        <Table
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          className="custom-table"
        />
      );
      const table = screen.getByRole('table');
      expect(table).toHaveClass('custom-table');
    });

    it('sets id attribute', () => {
      render(<Table 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
          columns={basicColumns}
          rows={basicRows}
          aria-label="Users"
          data-testid="user-table"
        />
      );
      expect(screen.getByTestId('user-table')).toBeInTheDocument();
    });
  });
});

Resources