Agent docs

Table

A lean presentational Table plus six independent feature hooks.

The kit's richest cluster: a lean presentational Table, six independent feature hooks (sort, search, filters, selection, pagination, column visibility) unified by one orchestrator hook useDataTable, and the DataTable component that composes all of it into the browsable-list shape. Kit source: packages/ui/src/table/.

When to reach for it#

Every browsable list, capped sub-list, and in-modal data grid in both apps goes through this cluster. It is the surface method/app-patterns/10-recipe-browsable-list.md is written against, and the canon (method/design-language/06-tables.md, 07-toolbars-and-filters.md) states its rules in terms of the three layers below. Reach for one of them whenever the page shows rows of the same shape; reach for a raw <table> only for a dense, inline-editable grid, per Layer choice below.

Layer choice#

Three layers, in order of how much the kit owns for you (06-tables.md §A1):

  • Table (packages/ui/src/table/table.tsx) is the lean presentational core. It draws exactly the rows it is given, in the order given; sort is affordance-only, meaning a sortable column renders the caret and calls onSortToggle, but the table never reorders anything itself (table.types.ts:92-94). Reach for it directly for a short, capped list that does not need filtering or paging: a job's run history, a part's attribute values, a key-value dump inside a modal. Real usage: speedway/app/routes/account/accounts.tsx:765 (team members), speedway/app/routes/workspaces/jobs/job.tsx:730 (a run's list), speedway/app/routes/workspaces/jobs/run.tsx:462 with maxHeight="480px" at :470 (capped, scrolled not paged), speedway/app/components/usage/UsageTable.tsx:181, walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:236 and :306 (in-modal key/value and attribute tables).
  • DataTable (packages/ui/src/table/data-table.tsx) is the orchestrated Layer B: filter, then search, then sort, then paginate, in that fixed order (hooks/use-data-table.ts:52-56), plus an auto checkbox column, row menus, a default toolbar, and a footer pager. This is what a browsable list means in this kit. See The features object below for how its state gets built.
  • Raw <table> is legitimate only for a dense, inline-editable form grid, never for a browsable list (06-tables.md §A1, method/app-patterns/10-recipe-browsable-list.md row 4). Walmart's own @/components/DataTable (walmart-mvp/frontend/src/components/DataTable.tsx:10-22) is exactly this tier: a thin <table>/<thead>/<tbody> styling wrapper, used for the catalog editor grids in EnrichmentTab.tsx, AttributesTab.tsx, TaxonomyTab.tsx, WalmartSubmit.tsx, ItemsTable.tsx, and SourceFilePicker.tsx. It shares its name with the kit's DataTable and is unrelated to it. See FilterBar and the two DataTables below.

Contract#

TableColumn<T> (table.types.ts:22-54)#

Every layer of this cluster is driven by the same column shape. Purely presentational: it describes how to draw a header and a cell for a row, and never holds state or fetches data.

  • key: stable identity, also the sort key and the classNameMap key.
  • header: a string gets the tiny-caps-turned-Title-Case default treatment; JSX passes through.
  • render(row, rowIndex): cell content. Defaults to String(row[key]) when omitted; prefer supplying this.
  • numeric: right-aligns and gives the column tabular figures.
  • align: explicit "left" | "center" | "right"; numeric already implies "right".
  • shape: what the cells hold (id, name, text, email, url, chip, chips, number, date, status, actions). The table turns it into a width floor, a cap and a wrapping rule, all plain CSS on the browser's auto layout (column-fit.ts), so an author writes no widths. Ids, names and numbers never wrap; id, name, text, email and url truncate at a cap (40 to 48 characters) with the full value in the native tooltip once the pointer finds it clipped; number, date, status, chip and actions shrink to their content and leave spare width to the others. A numeric column is a number, the __actions columns are actions, and a plain string cell (no render) is text. A column with its own render and no shape stays unmanaged, as before; name its shape to opt in, or shape: "none" to opt a plain-string column out.
  • size: optional budget over the shape, { min, max, grow }. A number is characters, a string any px, rem, ch or em length. max: "none" or grow: true removes the cap and lets the column take spare width. Reach for it when a rich cell has an intrinsic minimum (a stage stepper, an inline editor) or when two uncapped text columns would split the space by content and one must be capped.
  • width: a fixed override in px or rem ("38px"): floor and cap in one, for a control column such as a checkbox or grip. A percentage still works as before. Grid syntax such as minmax() means nothing on a table cell and is not supported.
  • priority: 1 | 2 | 3, default 1 (never hides). When the table's own measured width is smaller than the sum of its columns' floors, the highest numbers drop first (rightmost first within one number) and the last header shows a "+N columns" cue whose tooltip names them. It keys off the table, not the viewport, so a peek or drawer that squeezes the table sheds the same way. Server render shows every column; shedding applies after the first client measurement.
  • tip: one line of "what is this column" help behind an info glyph beside the header label. Real usage: speedway/app/routes/workspaces/jobs/job.tsx:629,639 and speedway/app/routes/workspaces/review/review.tsx:920.
  • sortable: marks the header sortable. The table only draws the caret and calls onSortToggle.
  • headerClassName / cellClassName: per-column classes, the latter can be a function of (row, rowIndex) for conditional styling.
  • rawHeader: keeps the header out of the Title Case treatment (an icon-only or checkbox head).
  • noCopy: opts this column out of copyCells. col.status, col.actions and col.rowActions set it, and any cell whose root is a StatusPill skips the copy button on its own, since a state is not a value anyone pastes. Real usage on action/expand columns: speedway/app/routes/workspaces/jobs/run.tsx:315,328, speedway/app/routes/workspaces/review/review.tsx:871,940,960,1013.
  • copyValue(row): what the copy button puts on the clipboard, when it should not be the raw cell value. Real usage: walmart-mvp/frontend/src/pages/Jobs.tsx:225,238, formatting a date column's copy text differently from its display text.

Table<T> props (table.types.ts:76-146)#

Beyond columns/rows:

  • rowKey(row, index): stable React key plus expansion/selection identity. Falls back to the row index and dev-warns once (table.tsx:121-124). Always pass this on anything with selection or expansion.
  • highlightedKey: tints the row whose rowKey matches and scrolls it into view once, not on every re-render (table.tsx:128-136). This is the ?hl=<id> "created and returned to the list" convention (06-tables.md §A3). Both apps drive this from a URL param, under different param names. Speedway owns the ?hl= name and the post-create return it serves; walmart has no ?hl= handling anywhere in frontend/src, and instead drives highlightedKey from its own ?job= param through a local hook (walmart-mvp/frontend/src/pages/Jobs.tsx:633 and :983, via lib/useQSync.ts). So the convention is speedway's, the mechanism is not. Do not build a second highlighting path in walmart on the assumption that it lacks one.
  • onRowClick(row, index): value-first row click.
  • getRowProps(row, index) / getRowClassName(row, index): arbitrary per-row DOM props (id, data-*, onContextMenu) and per-row classes. className is excluded from getRowProps; use getRowClassName.
  • sort / onSortToggle: the presentational sort affordance. Pass the current SortState and a toggle callback; DataTable wires these to useTableSort for you.
  • skeleton / skeletonRows: the kit's standard loading placeholder, default 5 rows. The page-level preset over it is TableSkeleton (+ ToolbarSkeleton, table/table-skeleton.tsx): its own Card, a toolbar-shaped strip, and a skeleton DataTable fed the same columns array the loaded table renders. Lifted 2026-08-14 from walmart's lib/tableSkeleton.tsx, which shipped the shape on four pages and keeps its local copy until walmart's next deliberate kit upgrade (the 0.0.24 pin stays by owner ruling; identical names make that swap a one-line import change).
  • empty: the empty slot. Falsy falls back to the kit <EmptyState compact Icon="Table" title="Nothing to show">. Every real page overrides this with its own true/filtered/in-progress three-way branch per 04-loading-and-states.md §A7; the built-in default is a bare fallback, not something to rely on in a shipped list.
  • emptyState: overrides for single parts of that default (Icon, title, description, action) with no other setup, for example emptyState={{ title: "No jobs yet", description: "Create one to begin." }}. Ignored when empty is given. DataTable also builds the filtered-empty state itself: when a search or filter hides every row it shows <FilteredEmpty> ("No rows match "query"" with a Clear search button); name the rows with emptyNoun="jobs".
  • Empty values: a cell with no value (null, undefined, blank text) renders N/A, uppercase and lighter than the ink around it. The col factories and the default cell do this; renderEmpty() is exported for custom render functions.
  • Scroll floor: the scroll box keeps room for up to three rows (and the header) whatever its parent squeezes it to, so a fill-height table never collapses to its header. When the space is smaller, the page scrolls; the app's shell must let it (a fit-to-viewport ancestor with clipped overflow still hides rows).
  • stickyHeader: default true. No shipped usage found overriding it to false in either app.
  • density: "comfortable" | "compact". Real usage: speedway/app/components/FilePreview.tsx:265 sets "compact" for the in-modal preview table.
  • bleed: the kit bleeds by default, inside a Card and inside a Modal (the dialog box publishes its 1.5rem padding as --card-inset, so a table in a modal body meets the dialog's edges and gains a hairline above it); bleed={false} keeps the inset. Cancels the enclosing Card's horizontal padding (published as --card-inset) and hands it back to only the first/last cell, so the table meets the card's edges while the toolbar above and pager below keep their padding (table.tsx:43-58, canon 05-cards-and-surfaces.md §A4). Outside a card the fallback matches the table's own density edge, so nothing changes. Walmart's five explicit passes (ActiveParts.tsx:231, Jobs.tsx:952, UnlistedParts.tsx:333, ErrorManagement.tsx:1001, SpecViewer.tsx:153,274,295) are now redundant, not broken. Speedway's hand-rolled equivalent (scrollClassName="-mx-6 min-h-0 flex-1 overflow-y-auto border-t border-base-200" plus cellClassName="py-3.5 first:pl-6 last:pr-6" and headCellClassName="first:pl-6 last:pr-6", speedway/app/components/JobsTable.tsx:894-896) still earns its keep for the min-h-0 flex-1 overflow-y-auto scrollport bleed alone does not provide; it can retire the edge-cancelling half on its next kit upgrade.
  • defaultShape: the shape every column without one gets, for tables whose columns come from data (a sheet preview, a review matrix). "none" turns shape defaults off for the whole table.
  • minRows: rows the scroll box always keeps in view, default 3 (minRows={0} removes the floor). The fill-height idiom is min-h-0 flex-1 on a parent plus this floor; the page, not the table, then scrolls.
  • reorderable / onReorder(fromKey, toKey): a grip column leads each row. Drag it, or focus it and press Alt with Up or Down. The caller owns the order of rows and moves the dragged key to the position of the target key. Uses native drag and drop, so touch screens do not drag; the keyboard path works everywhere. A row's getRowProps may now carry a ref; the kit's own highlight ref and the caller's both fire.
  • Scroll cue: when a table is wider than its box it scrolls sideways and a soft shade marks each edge that hides more. A table scrolls on its own once its floors outgrow the box; minWidth still forces it.
  • minWidth: per-table minimum width so narrow viewports scroll horizontally instead of squeezing columns (06-tables.md §A5, "never overlap"). The table only takes overflow-x when minWidth or maxHeight is set, because overflow on one axis makes the element the scrollport on both (06-tables.md §A9). Real usage: speedway/app/components/JobsTable.tsx:891 (extraAction ? "1536px" : "1436px"), speedway/app/routes/workspaces/jobs/parts.tsx, walmart-mvp/frontend/src/pages/Jobs.tsx:990 ("1400px").
  • maxHeight: caps the body height and scrolls it, the capped-sub-list preset. Real usage: speedway/app/routes/workspaces/jobs/run.tsx:470 ("480px"), speedway/app/components/FilePreview.tsx:270 ("max-h-[48dvh] overflow-y-auto" via scrollClassName, the in-modal preset).
  • numbered: leading row-number column. No shipped usage found in either app.
  • copyCells: gives every data cell a hover copy button trailing the content, not at the column's far edge (table.tsx:357-379). Opt-in per table: a read-only data table takes it, an editable grid should not (06-tables.md §A8). Widely used: at least 14 call sites across both apps, including speedway/app/components/ProductItem.tsx (3), walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx (2), walmart-mvp/frontend/src/pages/SpecViewer.tsx (2).
  • footer: a slot rendered inside the scroll container; DataTable drops a Pager here for you when pagination is on.
  • caption: <caption> for accessibility. No shipped usage found.
  • renderRowDetail(row, index) / expandedKeys / onToggleExpand: inline row expansion, presentational and controlled. The caller owns the expanded set. Real usage: speedway/app/components/JobsTable.tsx (workflow tab expansion), walmart-mvp/frontend/src/pages/Jobs.tsx:948-971 (the stage stepper expands under a job row).
  • selectedKeys: presentational only, styles the row; pass the same set the checkbox column already reads. DataTable wires this to model.selection.selectedIds automatically (data-table.tsx:228).
  • detailIndicator: default true, turns off the trailing caret column for tables whose own cells already carry the open/closed affordance. Real usage: speedway/app/routes/workspaces/review/review.tsx:1202, where the Fix button's own caret owns that affordance.

Tint priority is fixed across all of this: ?hl= highlight (bg-primary/10) outranks expanded (bg-primary/5) outranks click-picked (bg-primary/5, DataTable's own click-select tint) outranks hover (table.tsx:310-332, canon 06-tables.md §A3).

DataTable<T> props (data-table.types.ts:28-56)#

DataTable extends TableProps<T> (minus sort/onSortToggle/the classname slots, which it now derives from the model) and adds:

  • features: the shorthand described in The features object below.
  • model: a DataTableModel<T> you built yourself with useDataTable, for full control. When both features and model could apply, model wins (data-table.tsx:118-119).
  • rowMenu(row): returns RowMenuItem<T>[] or fully custom ReactNode; DataTable builds the trailing kebab-menu column for you. Real usage: walmart-mvp/frontend/src/pages/Jobs.tsx:974-981, speedway/app/routes/workspaces/jobs/parts.tsx:416-430.
  • selectionColumn: "leftmost" | "rightmost" | false, default "leftmost". false means you hand-place the checkbox column yourself via SelectionCell/SelectAllCell; see Row menus, selection, and the pager. No shipped usage found overriding it to "rightmost".
  • clickSelect: default true. On a table with no checkbox selection model, clicking a row keeps a quiet primary tint on it, because an expanded row wears the same tint and "open and picked read as one state" (owner ruling 2026-07-29, data-table.tsx:207-210). Pass false on a table whose selection lives outside the model, meaning a hand-placed checkbox column the kit cannot see. Leaving clickSelect on in that case would give the table two selection states that diverge (06-tables.md §A2). Real usage: speedway/app/routes/workspaces/review/review.tsx:1155 and walmart-mvp/frontend/src/pages/ErrorManagement.tsx:998, both paired with the hand-placed selection pattern.
  • toolbar: replaces the default toolbar entirely, so showSearch and showFilterBar become moot.
  • The default toolbar is one FacetBar. Every match entry in features.filters becomes a standing facet menu, and search and Refresh sit at the right end.
  • Registry entries of another kind (range, boolean, custom, search) keep the click-to-add FilterBar beneath it.
  • On upgrade, an app that passed toolbar, showSearch={false} and a hand-placed Toolbar.Search only to get search at the right end can delete all three.
  • onRefresh / refreshing: a circle Refresh icon button beside the search box.
  • bulkActions (nodes, or a function of the selection state): while rows are checked, a bar with "N selected", these actions and Clear selection replaces the toolbar in the same slot, so no row moves. Without it the footer still shows "N selected".
  • actions: table-level actions (Download, Upload) on their own right-aligned bar above the toolbar. They never go inside the filter row.
  • showSearch / searchPlaceholder: shows the built-in search box in the default toolbar, default true. Real usage setting it false, because the page renders model.search inside its own custom toolbar instead: walmart-mvp/frontend/src/pages/ActiveParts.tsx:235, UnlistedParts.tsx:337, ErrorManagement.tsx:1000.
  • showFilterBar: auto-renders the kit FilterBar from features.filters in the default toolbar, default true when filters are configured. No shipped usage found relying on this default; every real filtered list supplies its own toolbar instead (see FilterBar below).
  • pager: "auto" (default), "show" or "hide". Auto draws the footer Pager only when there is more than one page to move between, or when a smaller pageSizeOptions choice would split the rows. At one page the pager is gone but the footer still holds the quiet count ("Showing 1-3 of 3"), the one place the count lives; at zero rows nothing draws. showPager={false} still works and means "hide", which now drops the summary row too. "show" keeps the pager at one page. A selection count ("3 selected") still takes the footer while rows are checked.
  • pageSizeOptions: rows-per-page choices in the footer pager. [25, 50, 100] is the dominant choice across both apps (06-tables.md §B's stated browsable-list preset); real usage confirms it at 6+ call sites, with [10, 25, 50]/[10, 25] used for smaller in-modal or nested tables (FilePreview.tsx:272, AttributeSchemaView.tsx:278).

Slot classnames per DataTableClassNames (data-table.types.ts:18-26), on top of the core TableClassNames: wrapperClassName (the outer toolbar+table+footer column, the hook for flex-fill layouts where the table scrolls inside a viewport-bounded card), toolbarClassName, selectCellClassName, menuTriggerClassName, filterBarClassName.

The features object#

DataTable accepts feature configuration two ways, and real usage in both apps is split cleanly by why the table needs the model.

features={{ sort, search, pagination, filters, selection, columns }} is the shorthand: DataTable calls useDataTable internally and you never see the model. This is the right choice when nothing outside the table needs to read that state, which in practice means a self-contained or nested table whose default toolbar (or no toolbar) is enough. Real usage: speedway/app/components/FilePreview.tsx:274-278 (the in-modal preview table, density="compact", features={{ sort: {}, search: {...}, pagination: { pageSize: 10 } }}), walmart-mvp/frontend/src/pages/Jobs.tsx:615-624 and SpecViewer.tsx:157-162,298-302 (a job's nested parts table and the spec-model tables, all self-contained cards with no external toolbar reading the model).

Calling useDataTable yourself and passing model={model} is the pattern for every page-level browsable list in both apps, because the page's own toolbar (speedway's ~/components/FilterBar, walmart's FacetToolbar) needs model.search, model.filters, or model.totalFiltered to render outside the table. walmart-mvp/frontend/src/pages/ErrorManagement.tsx:557-559 states the reason directly in a comment: "Built standalone (not via DataTable's features shorthand) so the toolbar count and empty state can see the search-narrowed total, not just the outer category/part/type/job filters." Real usage: speedway/app/components/JobsTable.tsx:517-521, speedway/app/routes/workspaces/review/review.tsx:652-655, speedway/app/routes/workspaces/jobs/parts.tsx:306-330, speedway/app/routes/admin/teams.tsx:98-101, walmart-mvp/frontend/src/pages/Jobs.tsx:705, ActiveParts.tsx:161-166.

Either way the config shape is the same (hooks/use-data-table.ts:10-18):

interface UseDataTableConfig<T> {
rows: T[];
sort?: UseTableSortConfig<T>;
search?: UseTableSearchConfig<T>;
filters?: UseTableFiltersConfig<T>;
selection?: Omit<UseTableSelectionConfig<T>, "items">;
pagination?: UseTablePaginationConfig;
columns?: UseColumnVisibilityConfig;
}

A missing key means that feature is off, at zero cost (data-table.types.ts:32 on DataTableProps.features). The pipeline is fixed and always runs in this order, so a search query never narrows just the current page: filter, then search, then sort, then paginate (use-data-table.ts:52-56); selection is then computed over the paginated page, matching the "select all means the visible page" rule (06-tables.md §A6, use-data-table.ts:58). In client mode (no pagination.total), the real page count and range are derived from the post-filter row count, and the current page is clamped so a filter that shrinks the results below the current page never leaves the Pager pointing past the last real page (use-data-table.ts:64-75).

Turning each on:

  • sort: { accessors?, default?, value?, onChange?, sync? }. accessors is a per-key function for when the sort value is not the raw cell value, for example a nullable field that should sort last instead of floating nulls to the top: speedway/app/routes/workspaces/jobs/parts.tsx:311 (accessors: { partType: (r) => r.partType || null }).
  • search: { fields?, predicate?, placeholder? }. fields narrows matching to named keys; omit it to stringify-match every field on the row. Real usage always narrows: search: { fields: ["sku", "brand", "description", "partType"] } (parts.tsx:313).
  • filters: { registry, value?, onChange?, default?, exact? }. See the filters registry under FilterBar below. Real usage is speedway-heavy: JobsTable.tsx, parts.tsx, job.tsx, log.tsx, taxonomy.tsx, AttributeSchemaView.tsx all pass a registry. No walmart page passes filters to useDataTable at all. Walmart's facet state (ActiveParts.tsx, Jobs.tsx, ErrorManagement.tsx) is plain useState arrays filtered by hand into a visible array before it reaches useDataTable, and only sort/search/pagination go through the model. The useTableFilters hook itself is unused in walmart today.
  • selection: { single?, getRowId?, value?, onChange?, default?, sync? } (minus items, which useDataTable supplies from the paginated page). getRowId real usage: walmart-mvp/frontend/src/pages/ErrorManagement.tsx:575. single (single-select mode): no shipped usage found in either app.
  • pagination: { pageSize?, total?, value?, onChange?, default?, sync? }. Passing total switches to server-driven mode, where apply is a no-op and you drive your own fetch from page/pageSize; every real usage in both apps is client mode (no total), with pageSize alone.
  • columns: { value?, onChange?, default?, sync? } (column visibility toggles). No shipped usage found in either app; useColumnVisibility is unused today.

The hooks#

Every feature is independently exported and usable standalone (table/index.ts), not only through useDataTable. useTableSelection and useTableFilters in particular are commonly used outside a DataTable entirely, feeding a hand-built toolbar or a bulk-action bar.

  • useTableSort<T>(config) returns { sort, toggle(key), set(sort), apply(rows) }. toggle is a tri-state cycle per column: asc, then desc, then off (hooks/use-table-sort.ts:66-73). apply is pure and returns a new array; compareValues orders nullish last, numbers and dates numerically, everything else as a locale-aware natural string compare (:29-36).
  • useTableSearch<T>(config) returns { query, setQuery, isFiltering, apply(rows), getInputProps() }. getInputProps() spreads directly onto an <input>; DataTable's own Toolbar.Search uses it internally (data-table.tsx:33-52). Default match is case-insensitive substring over every field on the row unless fields narrows it, or predicate fully replaces it (use-table-search.ts:34-43).
  • useTableFilters<T>(config) returns { registry, filters, add(key), update(index, next), remove(index), toggleDisabled(index), clear(), hasActive, apply(rows) }. Filters AND together; each filter's own value array is OR-ed within (use-table-filters.ts:78-79). toggleDisabled greys a filter out without removing it, so tweaking a query never loses the filter you built (:74-75, the "toggle without removing" affordance 07-toolbars-and-filters.md calls out). apply skips disabled and empty-valued filters (:203-206).
  • useTableSelection<T>(config) returns { selectedIds, selectedRows, isSelected, toggle, selectAll, clear, isAllSelected, isAnySelected, getRowId, getCheckboxProps, getSelectAllProps }. items scopes what "select all" means: pass the current page's rows for page-scoped selection, the kit default when used inside useDataTable, or a filter-wide array for whole-filter selection. Row identity defaults to row._id ?? row.id (use-table-selection.ts:38-41); pass getRowId when the row shape differs. getCheckboxProps/getSelectAllProps spread directly onto SelectionCell/SelectAllCell.
  • useTablePagination<T>(config) returns { page, pageSize, pageCount, setPage, setPageSize, apply(rows), range }. page is zero-based throughout the cluster, matching Pager's own page prop. setPageSize always resets page to 0 (use-table-pagination.ts:71-77), so a size change never strands the view on a page past the new last page.
  • useColumnVisibility(config) returns { hiddenKeys, isVisible, toggle, show, hide, reset, apply(columns) }. apply prunes a column array, preserving order. No shipped usage found in either app.

All six route their controlled/uncontrolled state through one shared primitive, useFeatureState (hooks/sync.ts:58-99): pass value/onChange for fully controlled state, or omit them for internal state that optionally seeds from and writes back to a TableSyncAdapter (sync.ts:12-19). The adapter is a deliberate no-op today (noopSyncAdapter, sync.ts:29-32); it exists as "the seam a URL/query-string sync layer plugs into without the kit ever importing one," for a stated future @versable-git/qsync package. No shipped usage of sync/TableSyncAdapter was found in either app. Every real page that syncs filter state to the URL today does so by feeding value/onChange into the hooks rather than through this adapter seam, but they are not all hand-rolled. Speedway's review, log and taxonomy routes wire useSearchParams/setSearchParams directly. Walmart has a named local abstraction for it, useQSync (walmart-mvp/frontend/src/lib/useQSync.ts:6), used in four files: pages/ActiveParts.tsx, pages/Jobs.tsx, pages/SpecViewer.tsx and features/parts/PartPreviewModal.tsx. Adding URL-synced state to a walmart page means reaching for that hook, not writing a fifth copy beside it.

The playground is the exception worth knowing about, because it is the one place the seam itself runs: apps/playground/src/app/components/data-table/gallery.tsx:99 implements a complete in-memory TableSyncAdapter and drives two tables off it in lockstep. So the seam is proven, just not adopted by either product app.

Column factories and cell helpers#

column-factories.tsx exports a col object encoding house table conventions as data, so a screen can grab a preset instead of hand-writing the same render/align/width choices:

  • col.mono<T>(key, header?, overrides?) (:15-22): monospaced cell for ids/SKUs/hashes.
  • col.number<T>(key, header?, overrides?) (:25-37): right-aligned, sortable: true, locale-grouped via toLocaleString().
  • col.date<T>(key, header?, overrides?) (:40-58): sortable: true, accepts Date | ISO string | epoch ms, renders "Jul 10, 2026" or a plain dash for an unparseable value.
  • col.status<T>(key, get, header?, overrides?) (:61-81): sortable: true, renders a StatusPill from a (row) => { kind, label } mapper you supply.
  • col.link<T>(key, get, header?, overrides?) (added 2026-08-14): the navigating cell that says so. get returns { href, name, label? }. The name field is required at the type level and feeds the hover tooltip naming the destination. Primary ink and hover underline come baked in. Clicks stop at the cell, so a clickable row does not also fire. overrides.renderLink routes through the app router's Link; the default is a plain anchor.
  • col.actions<T>(menu, overrides?) (:84-103): a trailing kebab column built on RowMenu, for the lean Table (DataTable's own rowMenu prop already does this for Layer B).

facet-bar.tsx (added 2026-08-14) is the standing-facets toolbar; its full contract lives in the FacetBar section below.

Adoption is one file. Corrected 2026-08-13: an earlier draft of this doc said no shipped usage exists in either app. That is wrong. walmart-mvp/frontend/src/pages/Jobs.tsx imports col from @versable-git/ui and uses it twice, col.mono at :536 and col.status at :548. It is the only file in either app that does. A grep that appears to show more is picking up speedway's unrelated server-side col helper for Firestore collections, which shares the name and nothing else. Everywhere else, both apps build columns as plain object literals with inline render functions, including for exactly the shapes these factories cover. StatusPill cells, for example, are built by hand at speedway/app/components/JobsTable.tsx:147-149 and walmart-mvp/frontend/src/pages/ErrorManagement.tsx:876-878,980-982,1099-1101. This is a real gap between the canon's stated column convention, "Columns encode meaning, via factories" (06-tables.md §A4), and shipped code. Treat col.* as the documented, correct way to build a column, and expect to be the first real caller if you reach for it.

cell-helpers.ts exports three opt-in cell formatters, the explicit replacement for auto-rendering: a value only becomes a link/date/code because you called the helper, never by accident (cell-helpers.ts:6-8).

  • renderDate(value, options?): blank for nothing, the raw string back if it will not parse.
  • renderLink(href, label?): a truncated, target="_blank" rel="noreferrer noopener" link with a trailing external-link glyph.
  • renderCode(value): a monospace <code> chip, the .mono SKU look.

No shipped usage of renderDate, renderLink, or renderCode was found in either app. A fourth helper, renderEmpty() (cell-helpers.ts:44-46, "the empty-value dash, one tone everywhere"), is defined but not exported from table/index.ts (table/index.ts:59 exports only renderDate, renderLink, renderCode). It is unreachable from outside the package as shipped.

FacetBar: the standing-facets toolbar#

FacetBar<T> (facet-bar.tsx, added 2026-08-14, exported with FacetDef, FacetOption, FilterableCell, and facetTooltip from table/index.ts) is the union of both product toolbars per the owner's union ruling: every filter always visible as its own control, all controls sharing the field skin at h-8 so the row reads as one family.

Every facet is a kit Select, in both modes (2026-08-16). Multi facets are Select multiple, single facets are a Select whose "show everything" sentinel the caller names in allValue (required on a multi: false facet; allLabel, default "All", labels the option FacetBar prepends when none of the caller's options carries that value). It is required rather than inferred: FacetBar used to take the caller's first option as the sentinel, so a facet listing three real ranges displayed "Last 24 hours" as applied while the table showed every row (gcp row #492 K3). FacetBar shipped its own private MultiFacetControl until that date, because Select had no multi mode; the control is retired and the mode is the one implementation. Both kinds get icons, counts, and type-to-filter above 8 options (SEARCH_AUTO_THRESHOLD) from Select itself. The apps still run their local toolbars today; they adopt FacetBar at their next deliberate kit upgrade.

FacetBar<T> props (facet-bar.tsx:109-158)#

  • facets: FacetDef[], values: FilterValue[], onChange(next): the controlled filter state. FacetBar owns nothing beyond menu focus.
  • model: binds the search box to model.search when present. search replaces that input for pages whose search state lives outside the table model; searchPlaceholder defaults to "Search", searchClassName (now applied to the field's outer box, so a width class shrinks it correctly) to "w-72 max-w-full min-w-0 shrink max-sm:w-full max-sm:flex-1".
  • chips: the per-app knob; restates applied values as removable chips gathered after the controls. Off by default, because the triggers already say what is selected (owner 2026-07-30). Turn it on wherever a trigger can only summarize as a count: doc 07-toolbars-and-filters.md §A4's 2026-08-12 refinement makes chips mandatory there, after all controls and never inline. Chips render for EVERY applied value, including keys owned outside the facets, which fall back to the raw key as their prefix.
  • label: leading caption ("Filter"); omit for a bare control row.
  • trailing: slot at the row's right edge. extra: rendered after the chips, for controls whose filter state lives outside the facets.
  • count: a quiet inline label ("12 jobs") for a bar that is not above a table; a table prints its count in the footer, and count warns in development.
  • onClearAll: clear-all hook for pages with filter state beyond the facets; defaults to clearing the facet values. Clear all shows whenever a facet value is applied, with or without chips; clearable keeps it visible when only external state is active, and clearable={false} never shows it.
  • selectClassName: classes added to every facet control. Each control floors at min-w-36 (a bare Select floors at 12rem, which wraps a bar of four filters); a facet's own width class wins over the floor.
  • onRefresh / refreshing: explicit refetch for pages whose data can change under them. Draws the refresh preset, a circle icon button with a tooltip, right of the search box (it used to be an underlined text button on a second row).

Layout. One wrapping row: label, facets, chips, extra, Clear all, then the right end (ml-auto): search, Refresh, trailing. Search is w-72 max-w-full min-w-0 shrink, and below sm the right end takes its own full row with the search filling it. Actions (Download, Upload) do not belong in trailing; give them their own bar (DataTable actions) or the page title.

FacetDef (facet-bar.tsx:30-46)#

key · label · options · multi (default true; false renders the single-value Select) · searchable (forces the type-to-filter box on below the auto threshold) · helperText (one-line control tooltip; single facets append a "Selected: …" line once a non-sentinel value applies) · chipPrefix (the property word applied chips lead with, "Module: Extract"; defaults to label, set it when a shorter word reads better than the label, owner 2026-08-14) · chipColor (tint for this facet's applied chips) · width (a Tailwind width class for this facet's control, such as "w-44").

FacetOption (facet-bar.tsx:17-31)#

value · label · description (optional second line, smaller and lighter ink, and part of the search haystack per the owner's 2026-08-14 ruling, so typing a description word isolates its option) · count (rendered "(N)" in lighter ink) · icon (registry icon for the option row and the applied chip) · swatch (a colour dot for a module or stage's own colour, canon 07 §A2's "colored dot for stage/module"; leads the applied chip when the option has no icon) · trailing (a short right-edge slot on the row: a unit, a status word).

The applied-chip shape (owner rulings 2026-08-14)#

Applied chips are kit Chip at size="lg" with text-sm via presets="applied" (facet-bar.tsx:276-286), leading with a muted property prefix built from chipPrefix (facet-bar.tsx:272). Ruling D1 (docs/plan/44-showcase-refinement.md §Rulings) graduates that pairing to a first-class Chip preset; until it lands, FacetBar carries the pairing internally and no caller restates it.

Keyboard contract#

Both facet kinds ride Select's own keyboard contract, so there is one contract to learn and one place it is implemented (select.md, the Multiple mode keyboard table). For a multi facet that means focus lands on the type-to-filter input when there is one, arrows drive aria-activedescendant, Enter and Space toggle the active option with the trigger's count updating, the panel stays open across toggles, and Escape closes and returns focus to the control.

Companions#

FilterableCell is the click-to-filter cell value: dotted underline on hover, tooltip naming exactly what it filters for. facetTooltip(helperText, selectedLabel?) builds the helper-plus-selected tooltip body for callers rendering their own control.

FilterBar: the kit component and the app's own toolbar#

Two different things share the name "FilterBar," and the canon doc that describes them (07-toolbars-and-filters.md:3) names both in one sentence without saying which one a caller should reach for. Resolved here with source evidence.

The kit ships FilterBar<T> (packages/ui/src/table/filter-bar.tsx, exported from table/index.ts:52-53). It renders directly off a TableFiltersState<T>: one FilterChip per active filter (a disable checkbox, the key label, a value control shaped to the filter's kind, and a remove button), an "Add filter" Select built from the registry's keys, and a conditional Reset button (filter-bar.tsx:214-254). The filter registry (hooks/use-table-filters.ts:17-36) supports five FilterKinds: "match" (chip multi-select or a bare <select> when multiple: false), "search" (a text contains-input), "range" (number or date, with =/>/</>=/<=/between operators), "boolean" (yes/no/any), and "custom" (you render the control and supply the predicate; usable purely for predicate logic with renderControl: () => null, per 07-toolbars-and-filters.md §A7).

Neither app renders the kit's FilterBar component. Every <FilterBar> JSX call site in speedway resolves to speedway/app/components/FilterBar.tsx, an app-local component (~/components/FilterBar) built on the kit's Dropdown, Button, and Input primitives, typed against the kit's DataTableModel/FilterValue types but implementing its own dropdown-per-facet toolbar anatomy: search slot, one Dropdown trigger per filter with live counts, and a count-plus-Refresh row (07-toolbars-and-filters.md §A1). It is imported in JobsTable.tsx:31, review/log.tsx:54, review/review.tsx:57, schemas/taxonomy.tsx:10, AttributeSchemaView.tsx:3, and wrapped again by speedway/app/components/ProductsToolbar.tsx:5 for the parts list. Walmart never imports anything named FilterBar; its equivalent is walmart-mvp/frontend/src/components/FacetToolbar.tsx, built from Chip, Dropdown, Select, and Tooltip (FacetToolbar.tsx:1-2) with its own hand-rolled facet state, entirely independent of useTableFilters.

So the kit's FilterBar and its filters registry are real, documented, and typed for exactly this use, but the shipped convention in both apps is to build an app-owned facet toolbar (a dropdown-per-facet anatomy with live option counts, per 07-toolbars-and-filters.md §A1-§A2) and pass it to DataTable's toolbar prop, rather than render the kit's chip-based FilterBar. useTableFilters (the hook, not the bar) is still genuinely load-bearing in speedway; see The features object above. When building a third app's list toolbar, follow the shipped pattern (an app-local toolbar consuming model.search/model.filters/model.totalFiltered, wired to DataTable's toolbar prop) unless there is a specific reason to reach for the kit's own FilterBar chip UI, which today has no working example to copy from.

DataTable.Toolbar is a separate, smaller compound exported off the DataTable function itself (data-table.tsx:79-85,261): Toolbar.Search, Toolbar.SelectionInfo, Toolbar.Reset, Toolbar.Spacer, Toolbar.Slot. Only DataTable.Toolbar.Search has shipped usage, four times, all in walmart's own custom toolbars needing the model-bound search input inline with their facets: ActiveParts.tsx:220, Jobs.tsx:946, UnlistedParts.tsx:322, ErrorManagement.tsx:1021. Toolbar.SelectionInfo, .Reset, .Spacer, and .Slot have no shipped usage found in either app.

Row menus, selection, and the pager#

Two similarly named types serve this section and are not interchangeable. DataTable's rowMenu callback returns RowMenuItem<T>[] (data-table.types.ts:8-15): onSelect receives the row, and there is no trailing slot. RowMenu's own items prop takes RowMenuAction[] (row-menu.tsx:10-18): onSelect arrives already bound to its row, and a trailing accessory slot exists (shortcut hint, count). TypeScript rejects trailing on a RowMenuItem literal; build the richer shape only when you render RowMenu directly.

RowMenu (row-menu.tsx) is the trailing kebab menu, built on the kit's Dropdown positioning primitive. It takes items: RowMenuAction[], each with label, optional icon/trailing, danger (error-tone, destructive actions), and disabled. The trigger's click is stopped so it never also selects or expands the row (row-menu.tsx:57); picking an item runs onSelect and closes the menu (:75-79). DataTable's rowMenu prop builds one of these for you per row; reach for RowMenu directly only when on the lean Table or needing a bespoke actions column, as in speedway/app/components/JobsTable.tsx's Download/Edit/Review/Delete menu (:780-825).

SelectionCell/SelectAllCell (selection-cell.tsx) wrap a shared SelectionCheckbox that sets the DOM indeterminate property imperatively, since HTML can only express "some, not all" that way (:23-25). DataTable places these for you when model.selection is set and selectionColumn !== false (data-table.tsx:124). Hand-place them with clickSelect={false} when selection must span more than the visible page: both apps do this for a "select all in the whole filtered set" bulk-action bar, where useTableSelection's items is fed the full filtered array instead of just the current page. Real usage: speedway/app/routes/workspaces/review/review.tsx:1166 (the review queue's bulk approve/dismiss bar) and walmart-mvp/frontend/src/pages/ErrorManagement.tsx:999 (with a Tooltip on the header checkbox explaining how many selected rows are outside the current filter view).

clickSelect={false} is the whole requirement. Neither app passes selectionColumn, and neither needs to: both pass their selection outside useDataTable entirely, so model.selection is undefined and the auto column never renders in the first place. selectionColumn has no shipped caller in either app. Both comment the same reasoning: selection lives outside the model because the model's own selection is page-scoped, and two selection states on one table would diverge.

Pager (pager.tsx) is the standalone page control DataTable drops into its footer, also independently useful. mode="offset" (default) draws numbered page buttons windowed around the current page plus first/last with ellipses (pageTokens, :33-44), so the control stays a fixed width regardless of how deep you page. mode="cursor" drops the numbers for keyset/infinite lists that only know hasPrev/hasNext. keyboard opts into Ctrl+Shift+left/right paging; when several pagers are on the page, such as a table inside an open modal plus one in the background, only the active one responds, the first one inside an open dialog winning over the first one on the page (isActivePager, :19-26). No shipped usage of mode="cursor" or keyboard was found in either app; every real Pager (via DataTable's auto-footer) runs offset mode with the default keyboard: false.

RowActions: icon buttons plus a kebab, one shared list#

RowActions (row-actions.tsx) is the visible half RowMenu never covered: the two or three actions a reader reaches for constantly render as icon buttons, everything else folds into the same kebab. It takes row, actions: RowAction<T>[], visible (default 3), size (default "sm"), and menu, either "list" (default, a plain RowMenu) or a function (row, folded) => ReactNode that renders the fold as one caller-drawn role=dialog card instead.

Each RowAction<T> is exactly one of five kinds, enforced by the type: onSelect(row), href(row), modal: { id, key?, args? } (calls the shared openModal, mounting nothing itself), panel(row) (a caller-drawn popover, cached per row id, one open across the whole page at a time), or render(row) (fully custom JSX in the slot). key, label, and icon are common to all five; details is the tooltip's body under label as its heading, color/shade/danger (shorthand for color="error") style a visible button, disabled/disabledReason render bricked with the reason replacing the tooltip body, and active/activeIcon/activeLabel swap a toggle's face. primary marks the one action a reader came for: it is drawn with its label as a quiet toned text button (with an optional caret that turns over while active, for a button that opens a sub-row), always stays visible, and every other visible action is an icon circle with a tooltip. visible per action forces it onto the row or into the fold regardless of the component's own visible count; auto (default) competes for a slot in array order.

DataTable's rowActions?: (row) => RowAction<T>[] | Omit<RowActionsProps<T>, "row"> adds the trailing column beside rowMenu; col.rowActions(spec) builds the same column for the lean Table. rowMenu still exists and stays the kebab-only subset for a caller with no visible actions to promote; passing both to one DataTable is a dev-only console warning, since rowActions already folds its own overflow.

Sanctioned combinations#

CombinationProducesWhere usedWhy
useDataTable built explicitly, model={model} passed to DataTable, toolbar={<AppFilterBar model={model} .../>}A page-level browsable list whose custom facet toolbar can read model.search/model.filters/model.totalFilteredspeedway/app/components/JobsTable.tsx:517-521,842-914, walmart-mvp/frontend/src/pages/Jobs.tsx:705,948The toolbar and the empty/count states need the search-narrowed total, not just the outer filters (ErrorManagement.tsx:557-559)
features={{ sort: {}, search: {...}, pagination: {...} }} with no toolbar overrideA self-contained nested or in-modal table with only the default search boxspeedway/app/components/FilePreview.tsx:261-278, walmart-mvp/frontend/src/pages/SpecViewer.tsx:151-162Nothing outside the table needs the model, so the shorthand is strictly less code
clickSelect={false} on DataTable, paired with a hand-placed SelectAllCell/SelectionCell column and a useTableSelection whose items is the whole filtered arraySelection that spans every filtered row, not just the visible pagespeedway/app/routes/workspaces/review/review.tsx:868-872,1166, walmart-mvp/frontend/src/pages/ErrorManagement.tsx:805-821,999The model's own selection is page-scoped; two selection states on one table would diverge (06-tables.md §A6)
density="compact" with maxHeight via scrollClassName and a reduced pageSizeOptionsThe in-modal table presetspeedway/app/components/FilePreview.tsx:265,270,27206-tables.md §B's "In-modal table" row: density compact, capped scroll, small page sizes
bleed on DataTable, or the hand-rolled scrollClassName/cellClassName equivalent, inside a Card that owns the scrollThe full-bleed list-page tablewalmart: ActiveParts.tsx:231; speedway: JobsTable.tsx:894-896Exactly one owner of horizontal inset per framed container (05-cards-and-surfaces.md §A4)
shape on every column, no width, no table-fixed, no minWidthA table that sizes itself: ids whole, numbers and dates hugging, text truncating with a tooltip, scrolling with an edge shade when it cannot fit/components/table "Sizing by shape", /components/table/corpus (52 recorded tables)The browser's auto layout shares spare width; the kit only supplies each column's floor and cap
accessors on sort for a column whose sort value differs from its display valueNulls sort last ascending, and first descending, instead of sorting as empty stringsspeedway/app/routes/workspaces/jobs/parts.tsx:311The default comparator treats nullish as "sorts last" only when the accessor returns null, not "". The direction flip is not a special case: apply multiplies the comparison by the direction (hooks/use-table-sort.ts:79-80), so nullish moves with it

Banned combinations#

Do not pass a checkbox selection column via SelectionCell/SelectAllCell while leaving clickSelect at its default true. The row-click tint and the checkbox selection are two independent selection states the kit cannot reconcile, and they will visibly diverge; always pair a hand-placed selection column with clickSelect={false} (06-tables.md §A2 and §A6).

Do not combine table-fixed with hand-added minWidth and per-column pixel widths on a table whose columns have a shape. Under table-fixed a widthless column takes an equal share and the author adds up widths by hand, which is how a 1487px declaration landed in a 1436px floor. Name the shapes, delete the arithmetic.

Do not give two text columns size.grow and expect them to split by importance. Auto layout splits spare width by content; cap one of them with size.max.

Do not set priority on an identity column or an actions column. They are what the row is for and must stay on screen; leave them at 1.

Do not reach for the kit's FilterBar component expecting it to match either app's shipped toolbar look. It renders a different anatomy, active-filter chips, from the dropdown-per-facet toolbar both apps actually ship (07-toolbars-and-filters.md §A1); see FilterBar above. Building a new list's toolbar by copying the kit FilterBar will look wrong beside every sibling list.

Do not put copyCells on an editable grid. The cell's value is already selectable there via its own input, and a copy button competes with the control (06-tables.md §A8).

Do not rely on the default empty fallback (<EmptyState compact Icon="Table" title="Nothing to show">) as a shipped list's actual empty state. It is a bare last-resort inside Table itself; every real list overrides empty (or branches before the table renders at all) with the three-way true/filtered/in-progress split (04-loading-and-states.md §A7, method/app-patterns/10-recipe-browsable-list.md row 23).

Do not hand-roll a raw icon row beside a RowMenu. The kit's RowActions already draws that exact shape, fold, tooltip, and shade rule included; a second hand-rolled row of icon buttons beside the kebab leaves the row with two different answers to what it does.

Do not give a row more than one primary action, and do not label the rest: one labelled action per row, the others icon-only, and RowActions logs a dev warning when a second primary appears. Do not shade more than one visible action per row. A shade reads as "the one you came here for"; a row of equally filled controls has no answer to that question, and RowActions logs a dev warning the moment a second one appears.

Do not mount a Modal per row for a modal action. The shared-modal pattern is exactly one <Modal> outside the table reading modalArgs; a per-row <Modal elementId={\x-${row.id}`}>` defeats the point and ships twenty idle dialogs for twenty rows.

Do not build a column with a hand-rolled render for a shape a col.* factory already covers, such as a mono id, a right-aligned number, a formatted date, or a StatusPill status, without first checking whether the factory's contract fits. Both apps currently do this everywhere, which is the documented gap in Column factories above, not evidence that hand-rolling is preferred.

What this saves you#

Two bundle rows from AP-10 come free here, and one large one conspicuously does not.

Full-bleed inside a card (AP-10 row 6, 05 §A4). bleed on DataTable does it. Canon 05 §A4 describes only the hand-rolled idiom, scrollClassName="-mx-6 ..." with edge padding on first and last cells, so a reader arriving canon-first hand-rolls negative margins and a reader arriving here uses the prop. Both are following the docs. Use bleed, and keep the hand-rolled form for a surface that also needs a custom scrollport. Walmart uses it in five files; speedway in none.

Consistent column shapes (AP-10 row 10, 06 §A4). col is real and exported. The rule is retired for anyone who adopts it, and almost nobody has: one file in either app, walmart-mvp/frontend/src/pages/Jobs.tsx:536 and :548.

What is not free, and will not be. AP-10 row 8, one meaning per row click, is the most-repeated table defect in the corpus, and no prop can fix it. The kit tried: clickSelect's own comment says to pass false when selection lives outside the model, "the kit can't see that, and two selection states on one table diverge". A primitive cannot reconcile a state it does not own. That rule stays yours to hold.

Atomic filter writes to the URL (AP-10 row 15, 07 §A3). The sync seam graduated on 2026-08-13: @versable-git/qsync implements TableSyncAdapter against the URL with per-tick batching, and owns replace plus preventScrollReset for every call site. apps/playground runs two tables off it live. The boundary to know: the package is published (0.1.0 on the same registry, private: false) and no product app has added it yet, so row 15 stays hand-rolled law at product call sites until one does. Classified FREE, with that caveat, in method/app-patterns/12-primitives-and-rules.md.

Selection scope stated once (06 §A6). useTableSelection's items config is what "select all" means: the current page by default through useDataTable, or a filter-wide array when a caller feeds it one for a bulk-action bar. clickSelect={false} is the caller's half of the same rule.

Laws it enforces#

  • Show only what has something to say: the pager and its summary row are absent at one page and at zero rows; the filtered-empty state names the query once and offers Clear.
  • One signal, said once: the count lives in the footer only, never again in a toolbar line; one search per table, at the toolbar's right end.
  • Every composite declares its narrow behaviour: the toolbar is one wrapping row, the search shrinks with its container and fills the row at phone width, and Refresh is a small icon button beside it.
  • Things beside a label share its line: filters, Clear all, search and Refresh sit in one row; a second row appears only when the bar wraps.
  • Show only what has something to say: bulk actions swap in place of the toolbar while rows are checked, so checking a row never pushes the rows down.
  • Machine values stay exact, human text stays human: empty cells say N/A; the search placeholder is Search, with no ellipsis.
  • Fit, then truncate, never clip: a fill-height table keeps minRows (default three) in view and lets the page scroll, so it never collapses to its header.
  • Spacing is right by default and hard to break: a column names its shape and gets a floor, a cap and a wrapping rule, so no author adds widths or table-fixed, and no column is narrower than its shape's floor.
  • Truncate with a tooltip: any clipped cell names its full value on hover.
  • Tables bleed to the edge of the surface they sit in, card or modal.

Before you adopt this#

Five questions to answer before reaching for Table/DataTable. On upgrade from a version before column shapes, plain-string columns now truncate at 48 characters with a tooltip and a table whose floors outgrow its box scrolls sideways, so check any table that relied on wrapping long text; shape: "none" restores the old sizing for a column.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, DataTable is page content; no shell already renders it.
  2. Does this app already ship a local implementation of the same thing? Walmart's @/components/PlainTable (renamed from DataTable on 2026-08-18, Layer choice above) is the raw-grid tier and unrelated to the kit's DataTable; check the import.
  3. Does this app's kit pin reach the version this component or prop landed in? @versable-git/qsync is published (0.1.0, private: false, by publish-kit.yml); a product app takes the URL-sync adapter by adding that package, none has yet.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? Pager's keyboard prop opts into Ctrl+Shift+arrow paging; off by default.
  5. Which canon §A rules bind this surface, and which does the composition break? §A2/§A6 ban pairing a selection column with clickSelect left at its default true.

Travels with#

  • Card, as the list-page shell: a flex-column Card owns the scroll container and padding, the table cancels that padding on its edge cells via bleed or the hand-rolled equivalent (05-cards-and-surfaces.md §A4, and card.md's own "Travels with" entry for DataTable).
  • EmptyState, for every real list's true/filtered/in-progress branch, and as Table's own last-resort default.
  • StatusPill, inside col.status and inside every hand-rolled status cell in both apps.
  • A peek panel or modal, as the row's detail surface: renderRowDetail for inline expansion, or a row click that opens a peek/modal instead, never both on the same table (06-tables.md §A2).
  • The app's own facet toolbar (~/components/FilterBar in speedway, FacetToolbar in walmart), wired to model.search/model.filters and passed to DataTable's toolbar prop.

Snippet#

import { DataTable, FilterBar } from "@versable-git/ui";
// speedway/app/routes/workspaces/schemas/taxonomy.tsx:246-256, 416-419 (trimmed)
const model = useDataTable<TypeRow>({
rows: typeRows,
sort: {},
search: { fields: ["type"] },
filters: {
registry,
value: filterValues,
onChange: setFilterValues,
exact: true,
},
pagination: { pageSize: 25 },
});
// ...
<DataTable<TypeRow>
rows={typeRows}
model={model}
columns={columns}
rowKey={(r) => r.type}
pageSizeOptions={[25, 50, 100]}
toolbar={
<FilterBar
model={model}
defs={defs}
values={filterValues}
onChange={setFilterValues}
/>
}
/>
@versable-git/ui · reference, canon, and method, read in place