Table
A real <table> for a list of records: caption, a <th scope="col"> per column, one row per item. It takes data, not JSX, which is the decision that lets the a11y tier sort the same array before it reaches the same element.
Example (a11y tier)
Click a sortable header, or Tab to it and press Enter, to cycle none, ascending, descending. When the table is wider than its box, Tab to the scroll region and use the arrow keys.
| Tier | ||
|---|---|---|
button | a11y | 557 |
popover | a11y | 495 |
menu | a11y | 1120 |
tabs | a11y | 786 |
dialog | a11y | 580 |
toggle | a11y | 419 |
Import
import Table from 'abaabil/table' // structure onlyimport Table from 'abaabil/table/styled' // + CSSimport Table from 'abaabil/table/a11y' // + scroll region, sortable headersData, not JSX
A <table> with a <caption> has a name, <th scope="col"> ties every cell to its column so a screen reader announces “Price, 12.00” rather than “12.00”, and the reader's own table commands walk rows and columns. None of that has to be rebuilt, and a grid of divs wearing table semantics is the one design this component exists to avoid.
The caller passes columns and rows instead of writing markup. That is what lets the a11y tier reorder the array and hand it to the same element, with no second copy of the table. A column that needs a link or a formatted number still owns its cell through cell(row); the Component column above renders a <code> that way.
The scroll region is a keyboard stop
A wide table sits in an overflow-x: auto box. A box that scrolls with a mouse wheel but cannot be focused is unreachable from a keyboard: the columns off the right edge simply do not exist. So at the a11y tier the wrapper gets tabIndex=0, role="region" and aria-labelledby pointing at the caption, the WAI pattern for a scrollable table. Focusing it announces the table's name and the arrow keys scroll it.
useId connects the caption to the region, which is why this tier carries 'use client' and the other two render from a Server Component with no client JS. Without a caption or an aria-label the region has no name, and the component warns in development.
aria-sort is the only source
A sortable column draws its header inside a real <button> that fills the cell, and the <th> carries aria-sort, so a screen reader hears “JS bytes, sorted ascending” rather than a button that appears to do nothing. The arrow is drawn in CSS from that same attribute: the state has one source and is never read out twice.
Sorting is uncontrolled and happens inside the component. There is no sort prop; rows are reordered client-side from rows as passed, and onSort only reports { key, direction }. For server-side sorting, pass rows already ordered and mark no column sortable.
Sticky needs a height
stickyHeader adds one class that makes each <th> position: sticky. Sticky only bites once something scrolls, so give the wrapper a max-block-size through scrollProps; without one the prop changes nothing. The header keeps a shadow rather than a border underneath because, with border-collapse, a sticky header leaves its borders behind when it scrolls.
Props
| Prop | Type | Default | Tier | Description |
|---|---|---|---|---|
columns | Array<{ key, header, align?, width?, cell?, headerProps? }> | — | all | align is 'start' | 'end' | 'center', applied as data-align. cell(row) renders a custom cell. headerProps spreads onto that <th>. |
rows | object[] | — | all | Keyed by column key, plus an optional id. |
caption | ReactNode | — | all | Names the table. Rendered as a visible heading above it. |
rowKey | string | (row, index) => Key | 'id' | all | Falls back to the index. |
scrollProps | object | — | all | Spread onto the scroll wrapper. |
captionProps | object | — | all | Spread onto the <caption>. |
className | string | — | all | Merged with the base class, on the <table>. Rest props go there too. |
columns[i].sortable | boolean | false | a11y | Header becomes a <button> and the <th> carries aria-sort. |
columns[i].compare | (a, b) => number | — | a11y | Replaces the default comparator (localeCompare for strings, subtraction otherwise). |
onSort | ({ key, direction }) => void | — | a11y | Fires on every change. direction is 'none' | 'ascending' | 'descending'; key is null when none. |
stickyHeader | boolean | false | a11y | Adds abaabil-table--sticky. Give the wrapper a max-block-size for it to matter. |