Components / data
DataTable
Static table and data grid are two different products, and keeping them apart is why this one has no keyboard model at all.
Summary
A table whose rows are meant to be compared.
| APG pattern | Table |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
If nothing is being compared, use Rows.
<DataTable selectable density="default" getId={(row) => row.name}
columns={[{ key: 'name', label: 'Project', sortable: true },
{ key: 'views', label: 'Views', numeric: true, sortable: true }]}
rows={projects} />
Anatomy
A native <table data-se="table"> with a sticky header.
| Part | Element | Required |
|---|---|---|
cell | <cell> | yes |
control | <input> | yes |
description | the caller's | the caller's |
empty | <p> | no |
filter | <label> | no |
filters | <tr> | no |
label | the caller's | the caller's |
search | <label> | yes |
sort | <button> | yes |
sr | <span> | yes |
toolbar | <div> | no |
value | <span> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
label | string | none | Accessible name for the table. Required unless labelledBy is given. |
labelledBy | string | none | Id of a visible heading that names the table. Wins over label. |
columns | DataTableColumn[] | required | The columns, in display order. A column is only sortable if it says so, so the cursor changes only where something happens. |
rows | Array<Record<string, unknown>> | required | One object per row, keyed by the column key. The table does not sort or page them — it renders what it is given. |
density | default · compact | default | compact uses --se-control-h-sm rows. Type never changes. |
initialSort | string | none | The column key to sort by on first render. Sorting after that is the table's own state. |
selectable | boolean | none | Adds a checkbox column and a select-all in the header. Requires getId. |
selectedIds | string[] | none | The selected row ids. Passing this makes selection controlled; omitting it lets the table keep its own. |
onSelect | (id: string) => void | none | Called with the id of the row whose checkbox changed. Toggling is the caller's to apply. |
getId | (row: Record<string, unknown>, index: number) => string | none | Derives a row's id. Defaults to the row index. |
searchable | boolean | undefined | none | Adds one search box above the table. true searches every column; otherwise only the columns that say searchable. Searching and filtering narrow which rows are drawn and change nothing else — the table is still read row by row. What would make this a DataGrid is a cell taking focus. |
searchLabel | string | Search | The search box's accessible name and placeholder. |
emptyLabel | string | No rows match. | Shown in place of rows when a filter or search matches nothing. |
States
| State | Expressed as | |
|---|---|---|
| Checked | :checked | |
| Disabled | :disabled | |
| Focused | :focus-visible | |
| Hover | :hover | |
| Mixed | :indeterminate | |
| Selected | aria-selected | |
| Sorted | aria-sort | |
| Disabled | data-disabled | |
| Invalid | data-invalid |
Behavior
Sorting is per column and only where sortable is set, so the cursor changes only where something happens.
Selection paints --se-surface-selected and suppresses hover, so the two never stack into a third color the reader has to decode.
Accessibility
The table needs an accessible name. The React binding was fine and the markup a Vue or Astro consumer copies was not, until the upstream port found it — a specimen that renders correctly can still be missing the thing that matters.
A sorted column carries aria-sort, so the direction is announced rather than shown by a glyph alone. The caret appears on the sorted column and rotates rather than swapping for a second icon: one element, two states.
Cells are not focusable. A table that is merely read is a table; putting grid semantics on it makes every cell an obstacle for no gain, and a screen-reader user then has to escape a grid they never wanted to enter.
Keyboard
None of its own. Sort buttons are ordinary tab stops; Enter or Space sorts.
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-describedby | set at render |
aria-hidden | always true |
aria-label | set at render |
aria-labelledby | set at render |
aria-live | always polite |
aria-selected | set at render |
aria-sort | set at render |
role | always status |
Focus
Cells are not focusable. A table that is merely read is a table — putting grid semantics on it makes every cell an obstacle, and a screen-reader user then has to escape a grid they never wanted to enter. If the cells need focus, that is a DataGrid.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/batch-a.test.jsxtest/apg/partials-sweep.test.jsxtest/apg/table-narrowing.test.jsxtest/browser/choice-sizing.spec.mjstest/browser/upstream-natives.spec.mjs
Known gaps
- 1 upstream ref — APG's own page or markup, not the pattern
- 1 upstream ref — APG makes it conditional on the table having a description; ours has none to point at
Appearance
| Token | Used for |
|---|---|
--se-accent | background-color, border-color |
--se-bg | background |
--se-border | border-block-end, border-color |
--se-border-hover | border-color |
--se-border-strong | border, border-block-end-color |
--se-border-width | border, border-block-end |
--se-choice-box | block-size, border-radius, inline-size |
--se-choice-row | min-block-size |
--se-col-min | flex |
--se-control-h | block-size, inset-block-start |
--se-control-h-sm | block-size |
--se-duration-fast | transition |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-offset | outline-offset |
--se-focus-width | outline |
--se-font-sans | font-family |
--se-icon-sm | block-size, inline-size |
--se-layer-raised | z-index |
--se-leading-body | line-height |
--se-leading-ui | line-height |
--se-negative | border-color |
--se-radius-pill | border-radius |
--se-radius-sm | border-radius |
--se-space-1 | gap, row-gap |
--se-space-2 | column-gap, gap, padding-block, padding-inline |
--se-space-3 | gap, padding, padding-inline |
--se-space-5 | padding |
--se-surface | background, background-color |
--se-surface-selected | background |
--se-surface-sunk | background, background-color |
--se-switch-track | inline-size |
--se-target-min | min-block-size |
--se-text | box-shadow, color |
--se-text-disabled | background, background-color, border-color, color |
--se-text-muted | background, color |
--se-text-on-intent | background, background-color, background-image |
--se-text-placeholder | color |
--se-text-sm | font-size |
--se-text-xs | font-size |
The column header takes the eyebrow treatment. It used to set its own tracking as a literal, 0.02em away from the same treatment on a listbox group label — two places rendering one thing, already apart. Both point at the shared token now.
Motion
Row background crosses --se-duration-instant on hover.
Content
Column labels are nouns, short. The header is read for every cell beneath it by anyone navigating a table with a screen reader.
Mark quantities numeric — right alignment plus tabular figures is what makes a column read as a magnitude rather than a string. Both are needed: right alignment so the units column is shared, and font-variant-numeric: tabular-nums so every digit takes the same width. Proportional figures make 1,111 narrower than 8,888 and the column stops reading as a quantity at all. Text columns stay start-aligned; only numbers move.
Usage
Compact density earns its place here and almost nowhere else: a table is the one surface where fitting two more rows changes what the reader can do. Rows drop from --se-control-h to --se-control-h-sm and the type does not change — density moves space, never type.
When not to use it. If the cells are navigable rather than read, that is a DataGrid. If the rows nest, that is a Treegrid. If nothing is compared, that is Rows.
Notes
Static table and data grid are two different products, and keeping them apart is why this one has no keyboard model at all.