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.

Rendered from the package · open on its own

Summary

A table whose rows are meant to be compared.

APG patternTable
Built onnative elements
Statusstable
Since0.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.

PartElementRequired
cell<cell>yes
control<input>yes
descriptionthe caller'sthe caller's
empty<p>no
filter<label>no
filters<tr>no
labelthe caller'sthe caller's
search<label>yes
sort<button>yes
sr<span>yes
toolbar<div>no
value<span>yes

Properties

PropertyValuesDefaultWhat it does
labelstringnoneAccessible name for the table. Required unless labelledBy is given.
labelledBystringnoneId of a visible heading that names the table. Wins over label.
columnsDataTableColumn[]requiredThe columns, in display order. A column is only sortable if it says so, so the cursor changes only where something happens.
rowsArray<Record<string, unknown>>requiredOne object per row, keyed by the column key. The table does not sort or page them — it renders what it is given.
densitydefault · compactdefaultcompact uses --se-control-h-sm rows. Type never changes.
initialSortstringnoneThe column key to sort by on first render. Sorting after that is the table's own state.
selectablebooleannoneAdds a checkbox column and a select-all in the header. Requires getId.
selectedIdsstring[]noneThe selected row ids. Passing this makes selection controlled; omitting it lets the table keep its own.
onSelect(id: string) => voidnoneCalled with the id of the row whose checkbox changed. Toggling is the caller's to apply.
getId(row: Record<string, unknown>, index: number) => stringnoneDerives a row's id. Defaults to the row index.
searchableboolean | undefinednoneAdds 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.
searchLabelstringSearchThe search box's accessible name and placeholder.
emptyLabelstringNo rows match.Shown in place of rows when a filter or search matches nothing.

States

StateExpressed as
Checked:checked
Disabled:disabled
Focused:focus-visible
Hover:hover
Mixed:indeterminate
Selectedaria-selected
Sortedaria-sort
Disableddata-disabled
Invaliddata-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

AttributeSet by
aria-describedbyset at render
aria-hiddenalways true
aria-labelset at render
aria-labelledbyset at render
aria-livealways polite
aria-selectedset at render
aria-sortset at render
rolealways 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.jsx
  • test/apg/partials-sweep.test.jsx
  • test/apg/table-narrowing.test.jsx
  • test/browser/choice-sizing.spec.mjs
  • test/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

TokenUsed for
--se-accentbackground-color, border-color
--se-bgbackground
--se-borderborder-block-end, border-color
--se-border-hoverborder-color
--se-border-strongborder, border-block-end-color
--se-border-widthborder, border-block-end
--se-choice-boxblock-size, border-radius, inline-size
--se-choice-rowmin-block-size
--se-col-minflex
--se-control-hblock-size, inset-block-start
--se-control-h-smblock-size
--se-duration-fasttransition
--se-duration-instanttransition
--se-ease-standardtransition
--se-focus-coloroutline
--se-focus-offsetoutline-offset
--se-focus-widthoutline
--se-font-sansfont-family
--se-icon-smblock-size, inline-size
--se-layer-raisedz-index
--se-leading-bodyline-height
--se-leading-uiline-height
--se-negativeborder-color
--se-radius-pillborder-radius
--se-radius-smborder-radius
--se-space-1gap, row-gap
--se-space-2column-gap, gap, padding-block, padding-inline
--se-space-3gap, padding, padding-inline
--se-space-5padding
--se-surfacebackground, background-color
--se-surface-selectedbackground
--se-surface-sunkbackground, background-color
--se-switch-trackinline-size
--se-target-minmin-block-size
--se-textbox-shadow, color
--se-text-disabledbackground, background-color, border-color, color
--se-text-mutedbackground, color
--se-text-on-intentbackground, background-color, background-image
--se-text-placeholdercolor
--se-text-smfont-size
--se-text-xsfont-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.