Component contract
Every list view in Voyage admin is <UiDataTable>. Branches that need a table render this and supply column defs + a paginated row source. The linter scans the React source and flags any <table> not rendered through it.
columns: ColumnDef[]— id, header, accessor, sortable, sortKey, width, align, cell renderer.rows: T[]— current page only. Component does NOT fetch.page, pageSize, total: number— pagination state controlled by parent.sort?: { columnId, dir: "asc" | "desc" }— controlled.onSortChange?: (sort) => voidonPageChange?: (page) => voidonRowClick?: (row) => void— optional row activation.selectable?: "single" | "multi" | false— adds selection column.selectedIds?: string[]+onSelectionChange?: (ids) => voidstate: "ready" | "loading" | "empty" | "error"— exclusive states.emptyMessage?: ReactNode·errorMessage?: ReactNode+onRetry?: () => voidstickyHeader?: boolean(default true) ·rowKey: (row) => string
Interaction surface
-
Header renders sortable columns with arrow indicators.
Click a sortable column header →
onSortChangefires. Direction cycles asc → desc → unsorted (or asc → desc → asc for required-sort tables). Active sort column has visible direction arrow. -
Sticky header pinned during vertical scroll.
Header row stays visible at the top of the table viewport when scrolling rows. Z-index above row content. Background opaque (no see-through).
-
Row click activates row, NOT child controls.
Clicking a button or link inside a row does not trigger
onRowClick— event must stop at the inner control. Clicking row whitespace firesonRowClick. -
Selection scope is the current page.
"Select all" checks every row on the current page only. To select across pages, the parent must offer "Select all N matching" as a separate action — the component never silently expands selection across pagination boundaries.
-
Empty / loading / error are visually + semantically distinct.
Loading: skeleton rows in the table body, header still rendered. Empty: a single message row spanning all columns with optional CTA. Error: same message-row treatment with a retry button if
onRetryis provided. Never zero output. -
Keyboard navigation.
Tab focuses the first sortable header, then row controls in DOM order. Up/Down arrow keys move row focus when a row is focused. Enter activates the focused row. Space toggles selection on the focused row when
selectable.
Failure modes
Sort claims to sort but only sorts the current page
Trigger: implementation sorts the rows prop client-side instead of calling onSortChange for parent to re-fetch.
Sort must round-trip through the parent. The harness asserts: page 2 with sort=name-asc must contain rows after page 1's last row in alphabetical order. Client-side sort would scramble this. Fixture mock returns paginated results; failed assertion fires when client sort breaks pagination order.
Sticky header bleeds under scrolled content
Trigger: header CSS missing opaque background, or z-index lower than row content.
Harness asserts: scroll the table to mid-content, screenshot the header region, no row text overlaps header. Color-contrast check still passes on header text against header background.
Row click fires when clicking a child action
Trigger: missing stopPropagation on inner button click handler.
Harness asserts: render a row with a delete button. Click the delete button. Assert onRowClick was NOT called AND the delete handler WAS called. Common regression — passes naïve unit tests, fails this contract.
"Select all" silently selects across pages
Trigger: implementation interprets "select all" as "select every row in the dataset" without UI signaling.
"Select all" header checkbox MUST select only current-page rows. Cross-page selection is opt-in via a separate "Select all N matching" button that appears below the header after page-select. Harness: page-1 select all → asserts selectedIds.length === pageSize. Then click "Select all N matching" → asserts selectedIds.length === total.
Empty state and loading state look identical
Trigger: empty state renders no message; loading state renders no skeleton; both produce a blank table body.
Loading must show skeleton rows AND aria-busy="true". Empty must show a message row AND data-state="empty" on the tbody. The harness asserts these are distinguishable both visually and semantically before declaring either state correct.
Error state hides the retry button when error is "transient"
Trigger: implementation tries to be clever — auto-retries silently on first error, only shows error UI on second.
Auto-retry is a parent decision, not a table decision. The component renders error + retry every time state === "error". Parent decides whether to suppress the error state by retrying internally. Harness: simulate fetch error, assert error message + retry button visible immediately.
Total count drifts during pagination
Trigger: parent updates total on every page fetch and races with stale responses.
The component does not fetch — but it must surface total as-given. The pagination footer renders "Showing X–Y of Z" based on the current props. Stale total leads to "Showing 21–40 of 19" which is impossible. Harness: harness the parent fetcher to return total=100, navigate pages, total stays 100 across pages.
Long cell content blows the row height
Trigger: a cell receives a 4000-character string and the row stretches past viewport.
Cells truncate by default with text-overflow: ellipsis + a tooltip on hover/focus that shows the full content. white-space: nowrap on the cell. Column-level opt-out for cells that should wrap (e.g., notes columns). Harness: render a row with a known long string, assert row height ≤ 1.5× standard row height.
Sort indicator out of sync with actual sort
Trigger: sort state managed in two places (column header local state + parent state) and they diverge.
Sort is parent-controlled. The header reads sort from props. Local sort state in the component is forbidden. Harness: dispatch sort=date-desc via parent, assert the date column header shows desc arrow AND no other column shows an arrow.
Keyboard activation fires onRowClick on a non-clickable row
Trigger: onRowClick not provided but Enter still calls it (or throws).
When onRowClick is absent, rows are not focusable as activatable elements. tabindex is omitted. Pressing Enter on a focused inner control activates that control, not the row. Harness: render without onRowClick, assert no row has tabindex=0; render with onRowClick, assert each row tabindex=0 and Enter fires the handler exactly once.
Accessibility
- Underlying element is
<table>with<thead>,<tbody>,<tr>,<th scope="col">,<td>. - Sortable column headers are
<button>inside<th>witharia-sortreflecting current state. - Loading state: tbody has
aria-busy="true". - Empty state: a single tr.message-row with
role="row", single td spanning all columns. - Selection column: column header has
aria-label="Select rows"+ visually-hidden text. Each row checkbox hasaria-labelreferencing the row identity. - Color contrast: header text ≥ 4.5:1, row text ≥ 4.5:1, sort arrows ≥ 3:1 against header background.
- axe-clean at severity ≥ serious.
Stable test attributes
Visibility teeth. Each attribute must be present AND effectively visible in its applicable state. A hidden empty-state row is a Ratchet violation.
| data-test | Where | Purpose |
|---|---|---|
ui-data-table | Outer <table> | Component identity |
ui-data-table-head | <thead> | Header region |
ui-data-table-body | <tbody> | Body region; carries data-state attribute |
ui-data-table-column-header | Each <th> | Column header; data-column-id identifies which column |
ui-data-table-sort-toggle | Inside sortable headers | The clickable button that toggles sort |
ui-data-table-row | Each <tr> in body | Row; data-row-id identifies which row |
ui-data-table-cell | Each <td> | Cell; data-column-id on each |
ui-data-table-select-all | Header selection checkbox | Page-level select-all |
ui-data-table-row-select | Row selection checkbox | Per-row select |
ui-data-table-empty | Empty-state message row | Visible only when state=empty |
ui-data-table-error | Error-state message row | Visible only when state=error |
ui-data-table-error-retry | Retry button in error row | Visible only when onRetry is provided |
ui-data-table-loading-skeleton | Skeleton rows in tbody | Visible only when state=loading |
ui-data-table-bulk-select-all-matching | Banner above table | Cross-page select; appears only after page-level select-all |
Agent test plan
Standalone probes against /admin-test/ui-data-table-fixture with variants: ready (10 rows), ready (1 row), empty, loading, error, selectable=multi, selectable=single, no-onRowClick.
Probe list
- header-renders-all-columns: assert ui-data-table-column-header count === columns.length
- sort-fires-onSortChange: click sort-toggle on a sortable column, assert handler called with that columnId
- sort-arrow-reflects-state: dispatch sort=name-asc, assert that column's header has aria-sort=ascending
- sort-roundtrips-through-parent: page-1 sort=name-asc → page-2 sort=name-asc, last row of page-1 alphabetically before first row of page-2
- sticky-header-no-overlap: scroll body, screenshot header, assert no row text overlaps
- row-click-fires-handler: click row whitespace, onRowClick called once with that row
- inner-button-stops-propagation: click delete button inside row, onRowClick NOT called, delete handler called
- select-all-page-only: page-1 select all → selectedIds.length === pageSize
- bulk-select-all-matching-banner: after page-select, banner visible offering cross-page select
- bulk-select-all-matching-applies: click banner CTA → selectedIds.length === total
- state-loading-aria-busy: state=loading, tbody aria-busy=true, skeleton visible
- state-empty-message: state=empty, ui-data-table-empty visible, no rows
- state-error-with-retry: state=error, ui-data-table-error visible, retry button visible
- state-error-without-retry: state=error, no onRetry, error visible, retry absent
- empty-vs-loading-distinguishable: render both, screenshots differ AND data-state attr differs
- total-stable-across-pages: navigate pages 1→2→3, total prop unchanged, footer text "of N" consistent
- long-cell-truncates: cell with 4000-char string, row height ≤ 1.5× standard
- long-cell-tooltip: hover/focus a truncated cell, tooltip shows full content
- keyboard-row-focus: render with onRowClick, Tab to first row, arrow-down moves to row 2
- keyboard-row-enter: focus row, Enter fires onRowClick once
- keyboard-row-space-toggles-selection: focus row when selectable=multi, Space toggles ui-data-table-row-select
- color-contrast-header-text: ≥ 4.5:1
- color-contrast-row-text: ≥ 4.5:1
- color-contrast-sort-arrow: ≥ 3:1
- axe-clean-serious: no axe violations at serious or critical