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 docs/app-patterns/10-recipe-browsable-list.md is written against, and the canon (docs/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,docs/app-patterns/10-recipe-browsable-list.mdrow 4). Walmart's own@/components/PlainTable(walmart-mvp/frontend/src/components/PlainTable.tsx:10-23, renamed fromDataTableon 2026-08-18 so no local component shares a kit export's name) 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".width: any CSS width string ("38px","20%","minmax(120px,1fr)").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. 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="No data">(table.tsx:294). 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.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: 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-48, canon05-cards-and-surfaces.md §A4). Real usage is walmart-only:ActiveParts.tsx:231,Jobs.tsx:952,UnlistedParts.tsx:333,ErrorManagement.tsx:1001,SpecViewer.tsx:153,274,295. Speedway never uses this prop. Its list pages (JobsTable.tsx,parts.tsx) achieve the identical full-bleed look by hand: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). The hand-rolled version additionally owns amin-h-0 flex-1 overflow-y-autoscrollport thatbleedalone does not provide, which is why speedway composes it manually instead of combiningbleedwith an extrascrollClassName. Either path is a legitimate way to satisfy the canon's "exactly one owner of horizontal inset" rule;bleedis the shorter path when a custom scrollport is not also needed.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. When given,showSearch/showFilterBarbecome moot (data-table.tsx:167-178); both apps pass this on every page-level list to mount their own facet toolbar. See FilterBar below.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).showPager: auto footerPagerwhen pagination is on, default true.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:31 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:56 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 (facet-bar.tsx:253-264), single facets are a Select whose
first option is the "all" sentinel (facet-bar.tsx:240-247). 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",searchClassNameto"w-72".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: the always-visible line under the controls ("12 jobs").onClearAll: clear-all hook for pages with filter state beyond the facets; defaults to clearing the facet values.clearablekeeps Clear all visible when external state is active with no facet values.onRefresh/refreshing: explicit refetch for pages whose data can change under them.
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).
FacetOption (facet-bar.tsx:17-26)#
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).
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.
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) |
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 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="No data">) 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, docs/app-patterns/10-recipe-browsable-list.md row 23).
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 private and unpublished, so a
product app cannot take it until it ships to the registry; until then row 15
stays hand-rolled law at product call sites. Classified FREE, with that caveat,
in docs/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.
Before you adopt this#
Five questions to answer before reaching for Table/DataTable.
- 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 own
@/components/DataTableshares the kit's name and is unrelated; check the import. - Does this app's kit pin reach the version this component or prop landed in? The
qsyncURL-sync adapter is unpublished; a product app can't take it 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#
// 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} /> }/>