← All stories

COMPONENT · ui-data-table

ui-data-table

Component Tier 1 (primitive) Used by every list view in the admin (event dashboard, guest list, jobs, activity log, deliverability, transfers)

The most-used surface in Voyage admin. A table that pages, sorts, selects, scrolls a sticky header, distinguishes empty from loading from error, and never lies about what it's showing. Branches inherit this contract — they don't re-test sorting, pagination, or selection mechanics.

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) => void
  • onPageChange?: (page) => void
  • onRowClick?: (row) => void — optional row activation.
  • selectable?: "single" | "multi" | false — adds selection column.
  • selectedIds?: string[] + onSelectionChange?: (ids) => void
  • state: "ready" | "loading" | "empty" | "error" — exclusive states.
  • emptyMessage?: ReactNode · errorMessage?: ReactNode + onRetry?: () => void
  • stickyHeader?: boolean (default true) · rowKey: (row) => string

Interaction surface

  1. Header renders sortable columns with arrow indicators.

    Click a sortable column header → onSortChange fires. Direction cycles asc → desc → unsorted (or asc → desc → asc for required-sort tables). Active sort column has visible direction arrow.

  2. 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).

  3. 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 fires onRowClick.

  4. 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.

  5. 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 onRetry is provided. Never zero output.

  6. 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> with aria-sort reflecting 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 has aria-label referencing 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-testWherePurpose
ui-data-tableOuter <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-headerEach <th>Column header; data-column-id identifies which column
ui-data-table-sort-toggleInside sortable headersThe clickable button that toggles sort
ui-data-table-rowEach <tr> in bodyRow; data-row-id identifies which row
ui-data-table-cellEach <td>Cell; data-column-id on each
ui-data-table-select-allHeader selection checkboxPage-level select-all
ui-data-table-row-selectRow selection checkboxPer-row select
ui-data-table-emptyEmpty-state message rowVisible only when state=empty
ui-data-table-errorError-state message rowVisible only when state=error
ui-data-table-error-retryRetry button in error rowVisible only when onRetry is provided
ui-data-table-loading-skeletonSkeleton rows in tbodyVisible only when state=loading
ui-data-table-bulk-select-all-matchingBanner above tableCross-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