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 therowsit is given, in the order given; sort is affordance-only, meaning asortablecolumn renders the caret and callsonSortToggle, 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:462withmaxHeight="480px"at:470(capped, scrolled not paged),speedway/app/components/usage/UsageTable.tsx:181,walmart-mvp/frontend/src/features/parts/PartPreviewModal.tsx:236and: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.mdrow 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 inEnrichmentTab.tsx,AttributesTab.tsx,TaxonomyTab.tsx,WalmartSubmit.tsx,ItemsTable.tsx, andSourceFilePicker.tsx. It shares its name with the kit'sDataTableand 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 toString(row[key])when omitted; prefer supplying this.numeric: right-aligns and gives the column tabular figures.align: explicit"left" | "center" | "right";numericalready 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,emailandurltruncate at a cap (40 to 48 characters) with the full value in the native tooltip once the pointer finds it clipped;number,date,status,chipandactionsshrink to their content and leave spare width to the others. A numeric column is anumber, the__actionscolumns areactions, and a plain string cell (norender) istext. A column with its ownrenderand noshapestays unmanaged, as before; name its shape to opt in, orshape: "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"orgrow: trueremoves 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 asminmax()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,639andspeedway/app/routes/workspaces/review/review.tsx:920.sortable: marks the header sortable. The table only draws the caret and callsonSortToggle.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 ofcopyCells.col.status,col.actionsandcol.rowActionsset it, and any cell whose root is aStatusPillskips 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 whoserowKeymatches 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 infrontend/src, and instead driveshighlightedKeyfrom its own?job=param through a local hook (walmart-mvp/frontend/src/pages/Jobs.tsx:633and:983, vialib/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.classNameis excluded fromgetRowProps; usegetRowClassName.sort/onSortToggle: the presentational sort affordance. Pass the currentSortStateand a toggle callback;DataTablewires these touseTableSortfor you.skeleton/skeletonRows: the kit's standard loading placeholder, default 5 rows. The page-level preset over it isTableSkeleton(+ToolbarSkeleton,table/table-skeleton.tsx): its own Card, a toolbar-shaped strip, and a skeletonDataTablefed the same columns array the loaded table renders. Lifted 2026-08-14 from walmart'slib/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 per04-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 exampleemptyState={{ title: "No jobs yet", description: "Create one to begin." }}. Ignored whenemptyis given.DataTablealso 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 withemptyNoun="jobs".- Empty values: a cell with no value (null, undefined, blank text) renders
N/A, uppercase and lighter than the ink around it. Thecolfactories and the default cell do this;renderEmpty()is exported for customrenderfunctions. - 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 tofalsein either app.density:"comfortable" | "compact". Real usage:speedway/app/components/FilePreview.tsx:265sets"compact"for the in-modal preview table.bleed: the kit bleeds by default, inside aCardand inside aModal(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 enclosingCard'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, canon05-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"pluscellClassName="py-3.5 first:pl-6 last:pr-6"andheadCellClassName="first:pl-6 last:pr-6",speedway/app/components/JobsTable.tsx:894-896) still earns its keep for themin-h-0 flex-1 overflow-y-autoscrollportbleedalone 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 ismin-h-0 flex-1on 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 ofrowsand 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'sgetRowPropsmay now carry aref; 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;
minWidthstill 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 takesoverflow-xwhenminWidthormaxHeightis 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"viascrollClassName, 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, includingspeedway/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;DataTabledrops aPagerhere 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.DataTablewires this tomodel.selection.selectedIdsautomatically (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: aDataTableModel<T>you built yourself withuseDataTable, for full control. When bothfeaturesandmodelcould apply,modelwins (data-table.tsx:118-119).rowMenu(row): returnsRowMenuItem<T>[]or fully customReactNode;DataTablebuilds 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".falsemeans you hand-place the checkbox column yourself viaSelectionCell/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). Passfalseon a table whose selection lives outside the model, meaning a hand-placed checkbox column the kit cannot see. LeavingclickSelecton 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:1155andwalmart-mvp/frontend/src/pages/ErrorManagement.tsx:998, both paired with the hand-placed selection pattern.toolbar: replaces the default toolbar entirely, soshowSearchandshowFilterBarbecome moot.- The default toolbar is one
FacetBar. Everymatchentry infeatures.filtersbecomes 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
FilterBarbeneath it. - On upgrade, an app that passed
toolbar,showSearch={false}and a hand-placedToolbar.Searchonly 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 itfalse, because the page rendersmodel.searchinside its own custom toolbar instead:walmart-mvp/frontend/src/pages/ActiveParts.tsx:235,UnlistedParts.tsx:337,ErrorManagement.tsx:1000.showFilterBar: auto-renders the kitFilterBarfromfeatures.filtersin the default toolbar, default true when filters are configured. No shipped usage found relying on this default; every real filtered list supplies its owntoolbarinstead (see FilterBar below).pager:"auto"(default),"show"or"hide". Auto draws the footerPageronly when there is more than one page to move between, or when a smallerpageSizeOptionschoice 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? }.accessorsis 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? }.fieldsnarrows 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.tsxall pass aregistry. No walmart page passesfilterstouseDataTableat all. Walmart's facet state (ActiveParts.tsx,Jobs.tsx,ErrorManagement.tsx) is plainuseStatearrays filtered by hand into avisiblearray before it reachesuseDataTable, and onlysort/search/paginationgo through the model. TheuseTableFiltershook itself is unused in walmart today.selection: { single?, getRowId?, value?, onChange?, default?, sync? }(minusitems, whichuseDataTablesupplies from the paginated page).getRowIdreal 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? }. Passingtotalswitches to server-driven mode, whereapplyis a no-op and you drive your own fetch frompage/pageSize; every real usage in both apps is client mode (nototal), withpageSizealone.columns: { value?, onChange?, default?, sync? }(column visibility toggles). No shipped usage found in either app;useColumnVisibilityis 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) }.toggleis a tri-state cycle per column: asc, then desc, then off (hooks/use-table-sort.ts:66-73).applyis pure and returns a new array;compareValuesorders 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 ownToolbar.Searchuses it internally (data-table.tsx:33-52). Default match is case-insensitive substring over every field on the row unlessfieldsnarrows it, orpredicatefully 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 ownvaluearray is OR-ed within (use-table-filters.ts:78-79).toggleDisabledgreys a filter out without removing it, so tweaking a query never loses the filter you built (:74-75, the "toggle without removing" affordance07-toolbars-and-filters.mdcalls out).applyskips disabled and empty-valued filters (:203-206).useTableSelection<T>(config)returns{ selectedIds, selectedRows, isSelected, toggle, selectAll, clear, isAllSelected, isAnySelected, getRowId, getCheckboxProps, getSelectAllProps }.itemsscopes what "select all" means: pass the current page's rows for page-scoped selection, the kit default when used insideuseDataTable, or a filter-wide array for whole-filter selection. Row identity defaults torow._id ?? row.id(use-table-selection.ts:38-41); passgetRowIdwhen the row shape differs.getCheckboxProps/getSelectAllPropsspread directly ontoSelectionCell/SelectAllCell.useTablePagination<T>(config)returns{ page, pageSize, pageCount, setPage, setPageSize, apply(rows), range }.pageis zero-based throughout the cluster, matchingPager's ownpageprop.setPageSizealways resetspageto 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) }.applyprunes 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 viatoLocaleString().col.date<T>(key, header?, overrides?)(:40-58):sortable: true, acceptsDate | 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 aStatusPillfrom a(row) => { kind, label }mapper you supply.col.link<T>(key, get, header?, overrides?)(added 2026-08-14): the navigating cell that says so.getreturns{ href, name, label? }. Thenamefield 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.renderLinkroutes through the app router's Link; the default is a plain anchor.col.actions<T>(menu, overrides?)(:84-103): a trailing kebab column built onRowMenu, for the leanTable(DataTable's ownrowMenuprop 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.monoSKU 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 tomodel.searchwhen present.searchreplaces that input for pages whose search state lives outside the table model;searchPlaceholderdefaults 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: doc07-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, andcountwarns 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 withoutchips;clearablekeeps it visible when only external state is active, andclearable={false}never shows it.selectClassName: classes added to every facet control. Each control floors atmin-w-36(a bareSelectfloors at 12rem, which wraps a bar of four filters); a facet's ownwidthclass wins over the floor.onRefresh/refreshing: explicit refetch for pages whose data can change under them. Draws therefreshpreset, 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#
| Combination | Produces | Where used | Why |
|---|---|---|---|
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.totalFiltered | speedway/app/components/JobsTable.tsx:517-521,842-914, walmart-mvp/frontend/src/pages/Jobs.tsx:705,948 | The 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 override | A self-contained nested or in-modal table with only the default search box | speedway/app/components/FilePreview.tsx:261-278, walmart-mvp/frontend/src/pages/SpecViewer.tsx:151-162 | Nothing 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 array | Selection that spans every filtered row, not just the visible page | speedway/app/routes/workspaces/review/review.tsx:868-872,1166, walmart-mvp/frontend/src/pages/ErrorManagement.tsx:805-821,999 | The 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 pageSizeOptions | The in-modal table preset | speedway/app/components/FilePreview.tsx:265,270,272 | 06-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 scroll | The full-bleed list-page table | walmart: ActiveParts.tsx:231; speedway: JobsTable.tsx:894-896 | Exactly one owner of horizontal inset per framed container (05-cards-and-surfaces.md §A4) |
shape on every column, no width, no table-fixed, no minWidth | A 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 value | Nulls sort last ascending, and first descending, instead of sorting as empty strings | speedway/app/routes/workspaces/jobs/parts.tsx:311 | The 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 isSearch, 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.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, DataTable is page content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? Walmart's
@/components/PlainTable(renamed fromDataTableon 2026-08-18, Layer choice above) is the raw-grid tier and unrelated to the kit'sDataTable; check the import. - Does this app's kit pin reach the version this component or prop landed in?
@versable-git/qsyncis published (0.1.0,private: false, bypublish-kit.yml); a product app takes the URL-sync adapter by adding that package, none has yet. - Does the component derive its own accessible name and keyboard path, or must the call site supply them?
Pager'skeyboardprop opts into Ctrl+Shift+arrow paging; off by default. - Which canon §A rules bind this surface, and which does the composition break? §A2/§A6 ban pairing a selection column with
clickSelectleft at its defaulttrue.
Travels with#
Card, as the list-page shell: a flex-columnCardowns the scroll container and padding, the table cancels that padding on its edge cells viableedor the hand-rolled equivalent (05-cards-and-surfaces.md §A4, andcard.md's own "Travels with" entry forDataTable).EmptyState, for every real list's true/filtered/in-progress branch, and asTable's own last-resort default.StatusPill, insidecol.statusand inside every hand-rolled status cell in both apps.- A peek panel or modal, as the row's detail surface:
renderRowDetailfor 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/FilterBarin speedway,FacetToolbarin walmart), wired tomodel.search/model.filtersand passed toDataTable'stoolbarprop.
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} /> }/>