basalt-ui 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.25.0](https://github.com/jkrumm/basalt-ui/compare/v1.24.0...v1.25.0) (2026-08-22)
2
+
3
+
4
+ ### Features
5
+
6
+ * make manualPagination refuse every prop that would misreport ([d18e5f1](https://github.com/jkrumm/basalt-ui/commit/d18e5f1d8091af82a589a140ece7484cca654084))
7
+
1
8
  # [1.24.0](https://github.com/jkrumm/basalt-ui/compare/v1.23.1...v1.24.0) (2026-08-22)
2
9
 
3
10
 
package/MIGRATING.md CHANGED
@@ -85,7 +85,7 @@ New: `maxHeight`, `minWidth`, `stickyHeader`, `stickyHeaderOffset`, `verticalSpa
85
85
  defaults to `true` in basalt, overriding Mantine's `false`; every other one is a conditional
86
86
  pass-through, so omitting it keeps Mantine's default.
87
87
 
88
- **Porting argo's three tables onto them made them 341 → 370 lines. 29 LONGER.** argo named these
88
+ **Porting argo's three tables onto them made them 341 → 370–379 lines. 29–38 LONGER.** argo named these
89
89
  props as the reason the tables stayed hand-rolled, and adding them shortened nothing: column defs
90
90
  cost more than JSX rows when every cell is bespoke — eight accessor blocks at 4–6 lines each against
91
91
  an eight-`<Table.Td>` row at ~3. **The ask was mis-specified.** Adopt them for what they buy —
package/README.md CHANGED
@@ -625,7 +625,7 @@ const columns = [
625
625
  - `emptyState` renders inside a `<td colSpan>`, so the header row survives an empty table. There is
626
626
  no mode that replaces the whole table.
627
627
 
628
- **Honest result:** porting argo's three tables onto these props made them 341 → **370** lines, 29
628
+ **Honest result:** porting argo's three tables onto these props made them 341 → **370–379** lines, 29–38
629
629
  longer. Column defs cost more than JSX rows when every cell is bespoke. Adopt the props for what
630
630
  they actually buy — the `type="native"` footgun, alignment stated once instead of on both `th` and
631
631
  `td`, and sorting/filtering/pagination owned by basalt — not for a line count.
@@ -76,6 +76,17 @@ basalt defaults to `true`:
76
76
  outside the union **throws** naming the column — a money column never silently left-aligns. Set it
77
77
  once on the column def instead of repeating `textAlign: 'right'` on the header and the cell.
78
78
 
79
+ **`meta.numeral: false` is needed less often than it looks.** The auto style sets `fontFamily`,
80
+ `fontSize`, `fontWeight`, `fontVariantNumeric` and `color` on the `<td>` whenever the raw value is a
81
+ number — but a child `Text` sets its own `color` and `fontWeight` and wins those, and never sets a
82
+ `fontFamily`. **Only `fontFamily` leaks.** So `c="dimmed"` is not a reason to opt out (and on a
83
+ percentage column the mono figures are what you want); `fw={600}` on an accent figure is, because
84
+ the `<td>`'s monospace still overrides the family the `Text` inherited. One consumer measured this
85
+ across three tables: **3 columns** genuinely needed the opt-out, not the 4 that were assumed. Reach
86
+ for it when the cell renders its own chrome — a coloured `Text`, a `Badge`, a sparkline — and check
87
+ what actually leaked before adding it. `null` is worth a thought too: `typeof null === 'object'`, so
88
+ a `number | null` column goes mono only on its non-null rows, which is worse than uniformly wrong.
89
+
79
90
  ### Controlled sorting and URL sync
80
91
 
81
92
  `initialSorting` seeds the internal `useState` and `onSortingChange` is called on every sort
@@ -148,8 +159,47 @@ Global search (`enableGlobalFilter`, `globalFilterPlaceholder`, `searchIcon`,
148
159
  pinning (`enablePinning`, `initialColumnPinning`). **Don't hand-roll any of these** — read the props
149
160
  on the type, they are all there.
150
161
 
151
- Still deferred: row selection, row expansion / sub-rows, and fully controlled (external) sorting —
152
- `onSortingChange` is the sync seam. Add them when a concrete consumer need arises; keep the API
162
+ ### `manualPagination` imposes a contract on the rest of the props
163
+
164
+ Adopting it makes `data` **one server page** while the bar still reads `Showing 1–25 of 412`. Every
165
+ other client-side control then becomes a claim about 412 rows it can only make about 25 — a sort
166
+ that reorders one page under a header chevron that says it sorted all of them is a plausible, wrong
167
+ answer with nothing on screen to give it away. So each control has to be resolved explicitly; there
168
+ is no safe default, because only the call site knows whether the server does the work.
169
+
170
+ | With `manualPagination` | Resolve it with |
171
+ | ------------------------- | ----------------------------------------------------------------------------------- |
172
+ | the pagination bar itself | `enablePagination` (without it `manualPagination` is inert) + `rowCount` |
173
+ | sorting | `manualSorting` + sort in the `onSortingChange` request, or `enableSorting={false}` |
174
+ | `enableGlobalFilter` | `manualFiltering` + filter in the `onGlobalFilterChange` request |
175
+ | `facets` | `manualFiltering` + `onColumnFiltersChange` |
176
+
177
+ ```tsx
178
+ <BasaltDataTable
179
+ data={page.rows}
180
+ columns={columns}
181
+ enablePagination
182
+ manualPagination
183
+ rowCount={page.total}
184
+ manualSorting
185
+ onSortingChange={(s) => refetch({ sort: s[0] })}
186
+ onPaginationChange={(p) => refetch({ page: p })}
187
+ />
188
+ ```
189
+
190
+ `manualSorting` / `manualFiltering` don't switch the controls off — the headers and inputs stay
191
+ live and keep reporting through their `on*Change` callbacks. They stop the table from _recomputing_
192
+ the answer locally, so what renders is the order and the selection the server sent.
193
+
194
+ **Unresolved, it is not silent.** A contradiction throws in dev naming every breach at once, and a
195
+ production bundle (where the throw is constant-folded away) degrades to the honest table instead:
196
+ no sort headers, no filter controls, no `of N` it cannot stand behind, plus one `console.error`.
197
+ You will never get the plausible wrong answer, but you also won't get sorting you thought you had —
198
+ so read the throw, don't work around it.
199
+
200
+ Still deferred: row selection, row expansion / sub-rows, and fully controlled (external) sorting
201
+ STATE — `manualSorting` hands the _work_ to the server, but the table still owns the `SortingState`
202
+ and reports it through `onSortingChange`. Add them when a concrete consumer need arises; keep the API
153
203
  additive.
154
204
 
155
205
  ### The blessed lane vs the raw escape hatch
@@ -196,11 +246,21 @@ takes no view here — it steers raw `overflow: auto`, and `Table.ScrollContaine
196
246
  component, not a raw scroll box.)
197
247
 
198
248
  **Adopt the blessed lane for ownership, not for a line count.** Porting one consumer's three tables
199
- onto these props made them 341 → **370** lines, 29 longer: eight accessor blocks at 4–6 lines each
200
- cost more than an eight-`<Table.Td>` row at ~3 when every cell is bespoke. What the port bought was
201
- the `type="native"` footgun, alignment stated once instead of on both `th` and `td` six times over,
202
- and sorting/filtering/pagination no longer being yours to maintain. Expect that trade, not a
203
- shrink.
249
+ onto these props made them 341 → **370–379** lines, **29–38 longer**: a scratch port measured the
250
+ low end, the consumer's own shipped port the high end, because it kept the comment blocks
251
+ justifying each table's `numeral` opt-outs and its `enableSorting` decision. Accessor blocks at
252
+ 4–6 lines each cost more than an eight-`<Table.Td>` row at ~3 when every cell is bespoke, and the
253
+ opt-outs are lines the hand-rolled version never had to write. Budget the high end. What the port
254
+ bought was the `type="native"` footgun, alignment stated once instead of on both `th` and `td` —
255
+ **4 literals in one table alone, 6 across the two that had alignment to state** — and
256
+ sorting/filtering/pagination no longer being yours to maintain. Expect that trade, not a shrink.
257
+
258
+ That consumer deliberately left the third table hand-rolled, and the reason generalises: four of
259
+ its six columns would have needed `enableSorting: false` (three sparkline cells over `number[]`
260
+ accessors, one status enum), and a numeric column rendering its own coloured `Text` would have
261
+ needed a `numeral: false` it did not need before. A table with no sorting, no pagination, no filter
262
+ and no alignment to declare is a layout grid that happens to use `<table>` — adopting the component
263
+ there buys nothing and taxes you to switch off things you never asked for.
204
264
 
205
265
  ## BasaltVirtualList
206
266
 
@@ -99,8 +99,8 @@ function Library() {
99
99
 
100
100
  **Rendering only the empty branch is the bug this retires.** A consumer's library showed
101
101
  `No images` on a **500**, and a share detail showed `Share not found` on a dropped connection —
102
- "nothing here" is what an app renders when nobody wrote the other three branches. One import
103
- replaced 204 hand-rolled lines across 10 call sites there.
102
+ "nothing here" is what an app renders when nobody wrote the other three branches. Swapping the
103
+ import replaced 204 hand-rolled lines across 10 call sites there, with no prop or JSX change.
104
104
 
105
105
  Precedence, in evaluation order:
106
106
 
@@ -5,7 +5,7 @@ export type QueryStateVariant = 'page' | 'section';
5
5
  * The structural subset of a TanStack `UseQueryResult` these components read.
6
6
  *
7
7
  * Typed as a SUBSET rather than as `UseQueryResult<T>` on purpose: a composed, derived or
8
- * hand-rolled result must be passable without a cast (3 of image-share's 10 call sites are), and
8
+ * hand-rolled result must be passable without a cast, and
9
9
  * these components stay independent of which `@tanstack/react-query` major a consumer pins. This
10
10
  * is NOT a claim that the root barrel is free of that package — `./connectivity` imports
11
11
  * `onlineManager` as a value and is re-exported from `src/index.ts`. The cost of the subset is
@@ -1,5 +1,5 @@
1
1
  import type { MantineSpacing } from '@mantine/core';
2
- import type { ColumnDef, ColumnPinningState, PaginationState, RowData, SortingState } from '@tanstack/react-table';
2
+ import type { ColumnDef, ColumnFiltersState, ColumnPinningState, PaginationState, RowData, SortingState } from '@tanstack/react-table';
3
3
  import type { ReactNode } from 'react';
4
4
  /** Horizontal alignment of a column's header AND its cells. */
5
5
  export type DataTableAlign = 'left' | 'center' | 'right';
@@ -74,15 +74,43 @@ export type BasaltDataTableProps<T> = {
74
74
  */
75
75
  columns: ColumnDef<T, any>[];
76
76
  /**
77
- * Enable column sorting via clickable headers.
77
+ * Enable column sorting via clickable headers. Client-side — the rows in `data` are what get
78
+ * reordered.
79
+ *
80
+ * Under `manualPagination` that is one page, so leaving this on without also passing
81
+ * `manualSorting` is a contract violation and throws in dev — see `manualPagination`.
78
82
  * @default true
79
83
  */
80
84
  enableSorting?: boolean;
85
+ /**
86
+ * Hand sorting to the server: the table stops reordering `data` locally and renders it in the
87
+ * order given, while the headers keep toggling `SortingState` and reporting it through
88
+ * `onSortingChange` — which is where you re-request the sorted page.
89
+ *
90
+ * This is the required companion to `manualPagination` whenever sort headers are live; without
91
+ * it a server-paginated table sorts one page and the "of N" bar presents that as a sort of all N.
92
+ * @default false
93
+ * @example
94
+ * <BasaltDataTable
95
+ * data={page.rows}
96
+ * columns={columns}
97
+ * enablePagination
98
+ * manualPagination
99
+ * manualSorting
100
+ * rowCount={page.total}
101
+ * onSortingChange={(s) => refetch({ sort: s[0] })}
102
+ * />
103
+ */
104
+ manualSorting?: boolean;
81
105
  /** Stripe alternate rows. Forwarded to Mantine `Table` — `'odd'`/`'even'` pick the phase. */
82
106
  striped?: boolean | 'odd' | 'even';
83
107
  /** Highlight hovered rows. Forwarded to Mantine `Table`. */
84
108
  highlightOnHover?: boolean;
85
- /** Rendered when `data` is empty. Falls back to a simple message when omitted. */
109
+ /**
110
+ * Rendered when no rows are VISIBLE — `data` is empty, or a filter/page matches none of it.
111
+ * Falls back to a simple message when omitted. (Before 1.25.0 this tracked `data.length` alone,
112
+ * so a search matching nothing rendered a blank `<tbody>` with no message at all.)
113
+ */
86
114
  emptyState?: ReactNode;
87
115
  /**
88
116
  * When true, renders skeleton placeholder rows instead of the empty-state branch.
@@ -141,6 +169,18 @@ export type BasaltDataTableProps<T> = {
141
169
  * facets={[{ columnId: 'department', label: 'Department', options: departmentOptions }]}
142
170
  */
143
171
  facets?: DataTableFacet[];
172
+ /** Called whenever the faceted column filters change — the seam for server-side faceting. */
173
+ onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
174
+ /**
175
+ * Hand filtering to the server: the table stops narrowing `data` locally and renders every row
176
+ * given, while the search input and facets keep reporting through `onGlobalFilterChange` /
177
+ * `onColumnFiltersChange` — which is where you re-request the filtered page.
178
+ *
179
+ * Required alongside `manualPagination` whenever `enableGlobalFilter` or `facets` is set;
180
+ * without it the controls narrow one page while "of N" keeps counting the whole set.
181
+ * @default false
182
+ */
183
+ manualFiltering?: boolean;
144
184
  /**
145
185
  * Right-aligned toolbar slot (e.g. an "Export" button). Renders the toolbar row even when no
146
186
  * search input or facets are configured.
@@ -169,6 +209,21 @@ export type BasaltDataTableProps<T> = {
169
209
  * Disables local pagination slicing — pass the already-paginated page of `data` plus `rowCount`
170
210
  * (and optionally `pageCount`) for server-driven pagination. `onPaginationChange` is where you
171
211
  * fetch the next page.
212
+ *
213
+ * **It imposes a contract, enforced at render.** `data` is now one page, but the pagination bar
214
+ * says "Showing 1–25 of 412" — so every OTHER client-side control becomes a claim about 412 rows
215
+ * that it can only make about 25. Each has to be resolved explicitly, because only the call site
216
+ * knows whether the server does the work:
217
+ *
218
+ * - `rowCount` (or `pageCount`) must be given, or "of N" counts the page and the pager collapses
219
+ * to a single page nobody can leave;
220
+ * - sorting must be handed over with `manualSorting`, or switched off with `enableSorting={false}`;
221
+ * - `enableGlobalFilter` / `facets`, if used, must be handed over with `manualFiltering`;
222
+ * - `enablePagination` must be on, or `manualPagination` is inert and the page renders as if it
223
+ * were the whole table.
224
+ *
225
+ * Unresolved, each throws in dev and degrades to the honest render in production (no sort
226
+ * headers, no filter controls, no "of N") — never to the plausible wrong answer.
172
227
  * @default false
173
228
  * @example
174
229
  * <BasaltDataTable
@@ -235,8 +290,13 @@ export type BasaltDataTableProps<T> = {
235
290
  * A sortable data table backed by TanStack Table and rendered with Mantine primitives.
236
291
  * Generic over `T` — column types are inferred from `ColumnDef<T>`.
237
292
  *
238
- * Sorting is local/client-side (no `any`). When `enableSorting` is `false` the table
239
- * is read-only. An `emptyState` node is rendered when `data` is empty.
293
+ * Sorting is local/client-side by default (no `any`); `manualSorting` hands it to the server.
294
+ * When `enableSorting` is `false` the table is read-only. An `emptyState` node is rendered
295
+ * whenever no rows are visible — empty `data`, or a filter that matches none of it.
296
+ *
297
+ * `manualPagination` imposes a contract on the rest of the props (see its docblock): every
298
+ * client-side control it would turn into a false claim about the unseen rows must be handed to the
299
+ * server or switched off, and an unresolved one throws in dev rather than misreporting.
240
300
  *
241
301
  * Toolbar (search + facets), pagination, and column pinning are opt-in chrome layered on top —
242
302
  * see the corresponding props below. None of them change rendering unless explicitly enabled.
@@ -251,5 +311,5 @@ export type BasaltDataTableProps<T> = {
251
311
  * emptyState={<Text c="dimmed">No users found.</Text>}
252
312
  * />
253
313
  */
254
- export declare function BasaltDataTable<T>({ data, columns, enableSorting, striped, highlightOnHover, emptyState, isLoading, skeletonRows, initialSorting, onSortingChange, enableGlobalFilter, globalFilterPlaceholder, searchIcon, initialGlobalFilter, onGlobalFilterChange, facets, toolbarActions, enablePagination, pageSizeOptions, initialPagination, onPaginationChange, manualPagination, rowCount, pageCount, enablePinning, initialColumnPinning, maxHeight, minWidth, stickyHeader, stickyHeaderOffset, verticalSpacing, horizontalSpacing, withRowBorders, withTableBorder, }: BasaltDataTableProps<T>): import("react").JSX.Element;
314
+ export declare function BasaltDataTable<T>({ data, columns, enableSorting, manualSorting, striped, highlightOnHover, emptyState, isLoading, skeletonRows, initialSorting, onSortingChange, enableGlobalFilter, globalFilterPlaceholder, searchIcon, initialGlobalFilter, onGlobalFilterChange, facets, onColumnFiltersChange, manualFiltering, toolbarActions, enablePagination, pageSizeOptions, initialPagination, onPaginationChange, manualPagination, rowCount, pageCount, enablePinning, initialColumnPinning, maxHeight, minWidth, stickyHeader, stickyHeaderOffset, verticalSpacing, horizontalSpacing, withRowBorders, withTableBorder, }: BasaltDataTableProps<T>): import("react").JSX.Element;
255
315
  //# sourceMappingURL=data-table.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"data-table.d.ts","sourceRoot":"","sources":["../../src/data/data-table.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"data-table.d.ts","sourceRoot":"","sources":["../../src/data/data-table.tsx"],"names":[],"mappings":"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"}
@@ -85,10 +85,43 @@ function getPinnedCellStyle(table, column) {
85
85
  boxShadow: isEdge ? `-4px 0 6px -4px ${alpha(VX.ink, 0.18)}` : void 0
86
86
  };
87
87
  }
88
+ function isDev() {
89
+ return process.env["NODE_ENV"] !== "production";
90
+ }
91
+ const BREACH_REMEDY = {
92
+ inert: "`enablePagination` is not set, so `manualPagination` never reaches the table and the server page renders as if it were the whole set, with no bar to say otherwise. Pass `enablePagination`, or drop `manualPagination`.",
93
+ total: 'neither `rowCount` nor `pageCount` was given, so "of N" counts only the rows on this page and the pager collapses to one page nobody can leave. Pass `rowCount={total}`.',
94
+ sorting: 'client-side sorting is still armed. It reorders only the rows in `data` \u2014 one page \u2014 while the header chevron and "of N" together present it as a sort of all of them. Pass `manualSorting` and sort in the request you make from `onSortingChange`, or `enableSorting={false}`.',
95
+ filtering: 'the search input / facets still filter client-side. They narrow only the rows in `data` \u2014 one page \u2014 while "of N" keeps counting the whole set. Pass `manualFiltering` and filter in the request you make from `onGlobalFilterChange` / `onColumnFiltersChange`, or drop `enableGlobalFilter` / `facets`.'
96
+ };
97
+ function manualPaginationBreaches(config) {
98
+ if (!config.manualPagination) return [];
99
+ if (!config.enablePagination) return ["inert"];
100
+ const breaches = [];
101
+ if (config.rowCount === void 0 && config.pageCount === void 0) breaches.push("total");
102
+ const anyColumnMaySort = config.columns.some((column) => column.enableSorting !== false);
103
+ if (config.enableSorting && anyColumnMaySort && !config.manualSorting) breaches.push("sorting");
104
+ if ((config.enableGlobalFilter || config.hasFacets) && !config.manualFiltering)
105
+ breaches.push("filtering");
106
+ return breaches;
107
+ }
108
+ function manualPaginationMessage(breaches) {
109
+ return "BasaltDataTable: `manualPagination` is set, so `data` is one server page \u2014 but:\n \xB7 " + breaches.map((breach) => BREACH_REMEDY[breach]).join("\n \xB7 ") + "\nEach has to be resolved explicitly: only the call site knows whether the server does the work, so there is no default that is not a guess about the data.";
110
+ }
111
+ const reportedBreaches = /* @__PURE__ */ new Set();
112
+ function enforceManualPaginationContract(breaches) {
113
+ if (breaches.length === 0) return;
114
+ const message = manualPaginationMessage(breaches);
115
+ if (isDev()) throw new Error(message);
116
+ if (reportedBreaches.has(message)) return;
117
+ reportedBreaches.add(message);
118
+ console.error(message);
119
+ }
88
120
  function BasaltDataTable({
89
121
  data,
90
122
  columns,
91
123
  enableSorting = true,
124
+ manualSorting = false,
92
125
  striped,
93
126
  highlightOnHover,
94
127
  emptyState,
@@ -102,6 +135,8 @@ function BasaltDataTable({
102
135
  initialGlobalFilter,
103
136
  onGlobalFilterChange,
104
137
  facets,
138
+ onColumnFiltersChange,
139
+ manualFiltering = false,
105
140
  toolbarActions,
106
141
  enablePagination = false,
107
142
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
@@ -148,6 +183,16 @@ function BasaltDataTable({
148
183
  },
149
184
  [onGlobalFilterChange]
150
185
  );
186
+ const handleColumnFiltersChange = useCallback(
187
+ (updater) => {
188
+ setColumnFilters((prev) => {
189
+ const next = typeof updater === "function" ? updater(prev) : updater;
190
+ onColumnFiltersChange?.(next);
191
+ return next;
192
+ });
193
+ },
194
+ [onColumnFiltersChange]
195
+ );
151
196
  const handlePaginationChange = useCallback(
152
197
  (updater) => {
153
198
  setPagination((prev) => {
@@ -170,6 +215,23 @@ function BasaltDataTable({
170
215
  return { ...column, filterFn };
171
216
  });
172
217
  }, [columns, facets]);
218
+ const hasFacets = Boolean(facets && facets.length > 0);
219
+ const breaches = manualPaginationBreaches({
220
+ columns,
221
+ enablePagination,
222
+ manualPagination,
223
+ rowCount,
224
+ pageCount,
225
+ enableSorting,
226
+ manualSorting,
227
+ enableGlobalFilter,
228
+ hasFacets,
229
+ manualFiltering
230
+ });
231
+ enforceManualPaginationContract(breaches);
232
+ const sortingEnabled = enableSorting && !breaches.includes("sorting");
233
+ const filteringEnabled = !breaches.includes("filtering");
234
+ const totalIsAuthoritative = !breaches.includes("total");
173
235
  const table = useReactTable({
174
236
  data,
175
237
  columns: tableColumns,
@@ -182,23 +244,33 @@ function BasaltDataTable({
182
244
  },
183
245
  onSortingChange: handleSortingChange,
184
246
  onGlobalFilterChange: handleGlobalFilterChange,
185
- onColumnFiltersChange: setColumnFilters,
247
+ onColumnFiltersChange: handleColumnFiltersChange,
186
248
  onColumnPinningChange: setColumnPinning,
187
249
  ...enablePagination && { onPaginationChange: handlePaginationChange },
188
250
  getCoreRowModel: getCoreRowModel(),
189
251
  getSortedRowModel: getSortedRowModel(),
190
252
  getFilteredRowModel: getFilteredRowModel(),
253
+ // Both row models stay installed and are BYPASSED by these flags, which is TanStack's own
254
+ // seam — so a degraded table renders `data` in the order and completeness the server sent it.
255
+ // Forced only by a BREACH, never by `enableSorting={false}` on its own: that pair still applies
256
+ // an `initialSorting` today (it disables the header control, not the row model) and a table
257
+ // that is not misreporting has no business losing it.
258
+ manualSorting: manualSorting || breaches.includes("sorting"),
259
+ manualFiltering: manualFiltering || breaches.includes("filtering"),
191
260
  ...enablePagination && {
192
261
  getPaginationRowModel: getPaginationRowModel(),
193
262
  manualPagination,
194
263
  ...manualPagination && rowCount !== void 0 && { rowCount },
195
264
  ...manualPagination && pageCount !== void 0 && { pageCount }
196
265
  },
197
- enableSorting,
266
+ enableSorting: sortingEnabled,
198
267
  enableColumnPinning: enablePinning
199
268
  });
200
- const showToolbar = enableGlobalFilter || Boolean(facets && facets.length > 0) || Boolean(toolbarActions);
269
+ const showSearch = enableGlobalFilter && filteringEnabled;
270
+ const showFacets = hasFacets && filteringEnabled;
271
+ const showToolbar = showSearch || showFacets || Boolean(toolbarActions);
201
272
  const headerGroups = enablePinning ? getOrderedHeaderGroups(table) : table.getHeaderGroups();
273
+ const rows = table.getRowModel().rows;
202
274
  const tableNode = /* @__PURE__ */ jsxs(
203
275
  Table,
204
276
  {
@@ -212,7 +284,7 @@ function BasaltDataTable({
212
284
  withTableBorder,
213
285
  children: [
214
286
  /* @__PURE__ */ jsx(Table.Thead, { children: headerGroups.map((headerGroup) => /* @__PURE__ */ jsx(Table.Tr, { children: headerGroup.headers.map((header) => {
215
- const canSort = enableSorting && header.column.getCanSort();
287
+ const canSort = sortingEnabled && header.column.getCanSort();
216
288
  const sorted = header.column.getIsSorted();
217
289
  const toggleSorting = header.column.getToggleSortingHandler();
218
290
  const pinnedStyle = enablePinning ? getPinnedCellStyle(table, header.column) : void 0;
@@ -241,7 +313,7 @@ function BasaltDataTable({
241
313
  header.id
242
314
  );
243
315
  }) }, headerGroup.id)) }),
244
- /* @__PURE__ */ jsx(Table.Tbody, { children: isLoading ? Array.from({ length: skeletonRows }, (_, rowIndex) => /* @__PURE__ */ jsx(Table.Tr, { children: columns.map((_2, colIndex) => /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsx(Skeleton, { height: 16, radius: "sm" }) }, `skeleton-${rowIndex}-${colIndex}`)) }, `skeleton-${rowIndex}`)) : data.length === 0 ? /* @__PURE__ */ jsx(Table.Tr, { children: /* @__PURE__ */ jsx(Table.Td, { colSpan: columns.length, children: emptyState ?? /* @__PURE__ */ jsx(Text, { c: "dimmed", ta: "center", size: "sm", py: "sm", children: "No data to display." }) }) }) : table.getRowModel().rows.map((row) => {
316
+ /* @__PURE__ */ jsx(Table.Tbody, { children: isLoading ? Array.from({ length: skeletonRows }, (_, rowIndex) => /* @__PURE__ */ jsx(Table.Tr, { children: columns.map((_2, colIndex) => /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsx(Skeleton, { height: 16, radius: "sm" }) }, `skeleton-${rowIndex}-${colIndex}`)) }, `skeleton-${rowIndex}`)) : rows.length === 0 ? /* @__PURE__ */ jsx(Table.Tr, { children: /* @__PURE__ */ jsx(Table.Td, { colSpan: columns.length, children: emptyState ?? /* @__PURE__ */ jsx(Text, { c: "dimmed", ta: "center", size: "sm", py: "sm", children: "No data to display." }) }) }) : rows.map((row) => {
245
317
  const cells = enablePinning ? [
246
318
  ...row.getLeftVisibleCells(),
247
319
  ...row.getCenterVisibleCells(),
@@ -275,7 +347,7 @@ function BasaltDataTable({
275
347
  return /* @__PURE__ */ jsxs(Fragment, { children: [
276
348
  showToolbar && /* @__PURE__ */ jsxs(Group, { justify: "space-between", align: "flex-end", wrap: "wrap", gap: "xs", mb: "xs", children: [
277
349
  /* @__PURE__ */ jsxs(Group, { gap: "xs", wrap: "wrap", align: "flex-end", children: [
278
- enableGlobalFilter && /* @__PURE__ */ jsx(
350
+ showSearch && /* @__PURE__ */ jsx(
279
351
  TextInput,
280
352
  {
281
353
  size: "xs",
@@ -287,7 +359,7 @@ function BasaltDataTable({
287
359
  w: 220
288
360
  }
289
361
  ),
290
- facets?.map((facet) => {
362
+ showFacets && facets?.map((facet) => {
291
363
  const column = table.getColumn(facet.columnId);
292
364
  if (!column) return null;
293
365
  if (facet.multiple) {
@@ -341,8 +413,7 @@ function BasaltDataTable({
341
413
  rangeStart,
342
414
  "\u2013",
343
415
  rangeEnd,
344
- " of ",
345
- total
416
+ totalIsAuthoritative ? ` of ${total}` : ""
346
417
  ] }),
347
418
  /* @__PURE__ */ jsxs(Group, { gap: "xs", align: "center", children: [
348
419
  /* @__PURE__ */ jsx(
@@ -20,5 +20,5 @@
20
20
  */
21
21
  export { BasaltDataTable } from './data-table.js';
22
22
  export type { BasaltDataTableProps, DataTableAlign, DataTableFacet, DataTableFacetOption, } from './data-table.js';
23
- export { createColumnHelper, useReactTable, flexRender, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, type ColumnDef, type SortingState, type ColumnHelper, type PaginationState, type ColumnPinningState, } from '@tanstack/react-table';
23
+ export { createColumnHelper, useReactTable, flexRender, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, type ColumnDef, type SortingState, type ColumnHelper, type PaginationState, type ColumnPinningState, type ColumnFiltersState, } from '@tanstack/react-table';
24
24
  //# sourceMappingURL=table.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"table.d.ts","sourceRoot":"","sources":["../../src/data/table.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,YAAY,EACV,oBAAoB,EACpB,cAAc,EACd,cAAc,EACd,oBAAoB,GACrB,MAAM,cAAc,CAAA;AASrB,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,UAAU,EACV,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,kBAAkB,GACxB,MAAM,uBAAuB,CAAA"}
1
+ {"version":3,"file":"table.d.ts","sourceRoot":"","sources":["../../src/data/table.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,YAAY,EACV,oBAAoB,EACpB,cAAc,EACd,cAAc,EACd,oBAAoB,GACrB,MAAM,cAAc,CAAA;AASrB,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,UAAU,EACV,eAAe,EACf,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EAEvB,KAAK,kBAAkB,GACxB,MAAM,uBAAuB,CAAA"}
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.24.0
1
+ # basalt-ui v1.25.0
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,7 +19,7 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.24.0
22
+ Version: 1.25.0
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -26,7 +26,7 @@ export type QueryStateVariant = 'page' | 'section'
26
26
  * The structural subset of a TanStack `UseQueryResult` these components read.
27
27
  *
28
28
  * Typed as a SUBSET rather than as `UseQueryResult<T>` on purpose: a composed, derived or
29
- * hand-rolled result must be passable without a cast (3 of image-share's 10 call sites are), and
29
+ * hand-rolled result must be passable without a cast, and
30
30
  * these components stay independent of which `@tanstack/react-query` major a consumer pins. This
31
31
  * is NOT a claim that the root barrel is free of that package — `./connectivity` imports
32
32
  * `onlineManager` as a value and is re-exported from `src/index.ts`. The cost of the subset is
@@ -13,12 +13,13 @@
13
13
  * (`apps/playground/src/data-table-align.type-guard.ts`); the value can only be caught here,
14
14
  * and a silent fallback to left-aligned would look correct in review.
15
15
  */
16
- import { MantineProvider } from '@mantine/core'
17
- import { render, screen } from '@testing-library/react'
16
+ import { MantineProvider, Text } from '@mantine/core'
17
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
18
18
  import { describe, expect, test } from 'bun:test'
19
19
  import { BasaltDataTable } from './data-table'
20
20
  import { createColumnHelper } from './table'
21
21
  import type { BasaltDataTableProps } from './data-table'
22
+ import type { ColumnFiltersState } from './table'
22
23
 
23
24
  type Row = { project: string; cost: number }
24
25
  const col = createColumnHelper<Row>()
@@ -156,3 +157,193 @@ describe('regression — the bare table is unchanged', () => {
156
157
  expect(screen.getByText('linewatch')).toBeTruthy()
157
158
  })
158
159
  })
160
+
161
+ /**
162
+ * The manual-pagination contract (argo round 10, P1).
163
+ *
164
+ * `manualPagination` makes `data` one server page while the bar reads "Showing 1–25 of 412", so
165
+ * every remaining client-side control becomes a claim about 412 rows it can only make about 25.
166
+ * These pin the replacement for the old silence: loud where the props contradict each other, and
167
+ * honest — never plausible-but-wrong — where a production bundle has dropped the throw.
168
+ */
169
+
170
+ /** Run `fn` with the production dev-gate, restoring whatever the runner had set. */
171
+ function inProd(fn: () => void): void {
172
+ const previous = process.env['NODE_ENV']
173
+ process.env['NODE_ENV'] = 'production'
174
+ try {
175
+ fn()
176
+ } finally {
177
+ if (previous === undefined) delete process.env['NODE_ENV']
178
+ else process.env['NODE_ENV'] = previous
179
+ }
180
+ }
181
+
182
+ const SERVER_PAGE: Partial<BasaltDataTableProps<Row>> = {
183
+ enablePagination: true,
184
+ manualPagination: true,
185
+ rowCount: 412,
186
+ }
187
+
188
+ describe('manualPagination — the props that would misreport must be resolved', () => {
189
+ test('the bare adoption throws, naming the page-local sort and both ways out', () => {
190
+ expect(() => renderTable(SERVER_PAGE)).toThrow(
191
+ /client-side sorting is still armed[\s\S]*`manualSorting`[\s\S]*`enableSorting=\{false\}`/,
192
+ )
193
+ })
194
+
195
+ test('no rowCount and no pageCount throws — "of N" would count the page', () => {
196
+ expect(() =>
197
+ renderTable({ enablePagination: true, manualPagination: true, manualSorting: true }),
198
+ ).toThrow(/neither `rowCount` nor `pageCount`/)
199
+ })
200
+
201
+ test('manualPagination without enablePagination throws — the prop is inert, not harmless', () => {
202
+ expect(() => renderTable({ manualPagination: true })).toThrow(
203
+ /`enablePagination` is not set, so `manualPagination` never reaches the table/,
204
+ )
205
+ })
206
+
207
+ test('client-side search over one server page throws too — the same defect, one control over', () => {
208
+ expect(() =>
209
+ renderTable({ ...SERVER_PAGE, manualSorting: true, enableGlobalFilter: true }),
210
+ ).toThrow(/the search input \/ facets still filter client-side/)
211
+ })
212
+
213
+ test('facets trip the filtering breach even with no search input', () => {
214
+ expect(() =>
215
+ renderTable({
216
+ ...SERVER_PAGE,
217
+ manualSorting: true,
218
+ facets: [{ columnId: 'project', label: 'Project', options: [] }],
219
+ }),
220
+ ).toThrow(/still filter client-side/)
221
+ })
222
+
223
+ test('every unresolved breach is reported at once, not one per fix-and-rerun', () => {
224
+ expect(() => renderTable({ manualPagination: true, enablePagination: true })).toThrow(
225
+ /neither `rowCount`[\s\S]*client-side sorting is still armed/,
226
+ )
227
+ })
228
+ })
229
+
230
+ describe('manualPagination — the two sanctioned resolutions', () => {
231
+ test("enableSorting={false} renders, and no header claims to sort — argo's workaround", () => {
232
+ const { container } = renderTable({ ...SERVER_PAGE, enableSorting: false })
233
+ for (const th of container.querySelectorAll('th')) {
234
+ expect(th.getAttribute('aria-sort')).toBeNull()
235
+ expect(th.getAttribute('tabindex')).toBeNull()
236
+ }
237
+ expect(container.textContent).toContain('of 412')
238
+ })
239
+
240
+ test('manualSorting keeps the headers live but stops reordering the page locally', () => {
241
+ // asc by cost would put linewatch (3) above argo (12) — under manualSorting the server's
242
+ // order is what renders, because the server is the one that sorted.
243
+ const sorted = renderTable({
244
+ ...SERVER_PAGE,
245
+ manualSorting: true,
246
+ initialSorting: [{ id: 'cost', desc: false }],
247
+ })
248
+ expect(sorted.container.querySelector('tbody tr')?.textContent).toContain('argo')
249
+ expect(sorted.container.querySelector('th')?.getAttribute('aria-sort')).toBe('none')
250
+
251
+ // The control: the same initialSorting on a client-side table DOES reorder.
252
+ const local = renderTable({ initialSorting: [{ id: 'cost', desc: false }] })
253
+ expect(local.container.querySelector('tbody tr')?.textContent).toContain('linewatch')
254
+ })
255
+
256
+ test('manualFiltering hands the search to the server — the input filters nothing locally', () => {
257
+ const { container } = renderTable({
258
+ ...SERVER_PAGE,
259
+ manualSorting: true,
260
+ manualFiltering: true,
261
+ enableGlobalFilter: true,
262
+ initialGlobalFilter: 'zzzz',
263
+ })
264
+ // Client-side that term matches no row; the server page renders whole.
265
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(2)
266
+ expect(container.querySelector('input')).toBeTruthy()
267
+ })
268
+ })
269
+
270
+ describe('manualPagination — production degrades to the honest table, never the plausible one', () => {
271
+ test('no sort headers, no search input, and no "of N" it cannot stand behind', () => {
272
+ const errors: unknown[][] = []
273
+ const previous = console.error
274
+ console.error = (...args: unknown[]) => errors.push(args)
275
+ try {
276
+ inProd(() => {
277
+ const { container } = renderTable({
278
+ manualPagination: true,
279
+ enablePagination: true,
280
+ enableGlobalFilter: true,
281
+ })
282
+ // Sorting: the control is gone, so the table asserts nothing about the unseen rows.
283
+ for (const th of container.querySelectorAll('th')) {
284
+ expect(th.getAttribute('aria-sort')).toBeNull()
285
+ }
286
+ // Filtering: same — a control that can only narrow one page is not rendered.
287
+ expect(container.querySelector('input[placeholder="Search…"]')).toBeNull()
288
+ // The count: no rowCount was given, so the bar states the range and claims no total.
289
+ expect(container.textContent).toContain('Showing 1–2')
290
+ expect(container.textContent).not.toContain('of 2')
291
+ })
292
+ } finally {
293
+ console.error = previous
294
+ }
295
+ // The degradation is not its own silence.
296
+ expect(errors).toHaveLength(1)
297
+ expect(String(errors[0]?.[0])).toContain('BasaltDataTable: `manualPagination` is set')
298
+ })
299
+ })
300
+
301
+ /**
302
+ * Sibling defect found by reading, not by a consumer: the empty branch keyed off `data.length`,
303
+ * so any narrowing that left `data` non-empty but the row model empty rendered a `<tbody>` with
304
+ * nothing in it — no rows, no message. A blank body reads as a broken table, not as no matches.
305
+ */
306
+ describe('the empty state tracks the rendered rows, not the raw array', () => {
307
+ test('a search matching nothing shows the empty state instead of a blank tbody', () => {
308
+ const { container } = renderTable({
309
+ enableGlobalFilter: true,
310
+ initialGlobalFilter: 'no-such-project',
311
+ })
312
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(1)
313
+ expect(container.textContent).toContain('No data to display.')
314
+ })
315
+
316
+ test('a custom emptyState is honoured on a filtered-to-nothing table too', () => {
317
+ const { container } = renderTable({
318
+ enableGlobalFilter: true,
319
+ initialGlobalFilter: 'no-such-project',
320
+ emptyState: <Text>Nothing matches that search.</Text>,
321
+ })
322
+ expect(container.textContent).toContain('Nothing matches that search.')
323
+ })
324
+
325
+ test('a page index past the end shows the empty state rather than an empty body', () => {
326
+ const { container } = renderTable({
327
+ enablePagination: true,
328
+ initialPagination: { pageIndex: 9, pageSize: 10 },
329
+ })
330
+ expect(container.textContent).toContain('No data to display.')
331
+ })
332
+ })
333
+
334
+ describe('onColumnFiltersChange — the seam server-side faceting needs', () => {
335
+ test('a facet selection reports the new columnFilters', async () => {
336
+ const seen: ColumnFiltersState[] = []
337
+ const { container } = renderTable({
338
+ facets: [
339
+ { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
340
+ ],
341
+ onColumnFiltersChange: (filters) => seen.push(filters),
342
+ })
343
+ const select = container.querySelector('input[placeholder="Project"]')
344
+ if (!select) throw new Error('expected the facet Select')
345
+ fireEvent.click(select)
346
+ fireEvent.click(await screen.findByRole('option', { name: 'argo' }))
347
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
348
+ })
349
+ })
@@ -171,15 +171,43 @@ export type BasaltDataTableProps<T> = {
171
171
  // oxlint-disable-next-line typescript/no-explicit-any -- TanStack-idiomatic heterogeneous column array
172
172
  columns: ColumnDef<T, any>[]
173
173
  /**
174
- * Enable column sorting via clickable headers.
174
+ * Enable column sorting via clickable headers. Client-side — the rows in `data` are what get
175
+ * reordered.
176
+ *
177
+ * Under `manualPagination` that is one page, so leaving this on without also passing
178
+ * `manualSorting` is a contract violation and throws in dev — see `manualPagination`.
175
179
  * @default true
176
180
  */
177
181
  enableSorting?: boolean
182
+ /**
183
+ * Hand sorting to the server: the table stops reordering `data` locally and renders it in the
184
+ * order given, while the headers keep toggling `SortingState` and reporting it through
185
+ * `onSortingChange` — which is where you re-request the sorted page.
186
+ *
187
+ * This is the required companion to `manualPagination` whenever sort headers are live; without
188
+ * it a server-paginated table sorts one page and the "of N" bar presents that as a sort of all N.
189
+ * @default false
190
+ * @example
191
+ * <BasaltDataTable
192
+ * data={page.rows}
193
+ * columns={columns}
194
+ * enablePagination
195
+ * manualPagination
196
+ * manualSorting
197
+ * rowCount={page.total}
198
+ * onSortingChange={(s) => refetch({ sort: s[0] })}
199
+ * />
200
+ */
201
+ manualSorting?: boolean
178
202
  /** Stripe alternate rows. Forwarded to Mantine `Table` — `'odd'`/`'even'` pick the phase. */
179
203
  striped?: boolean | 'odd' | 'even'
180
204
  /** Highlight hovered rows. Forwarded to Mantine `Table`. */
181
205
  highlightOnHover?: boolean
182
- /** Rendered when `data` is empty. Falls back to a simple message when omitted. */
206
+ /**
207
+ * Rendered when no rows are VISIBLE — `data` is empty, or a filter/page matches none of it.
208
+ * Falls back to a simple message when omitted. (Before 1.25.0 this tracked `data.length` alone,
209
+ * so a search matching nothing rendered a blank `<tbody>` with no message at all.)
210
+ */
183
211
  emptyState?: ReactNode
184
212
  /**
185
213
  * When true, renders skeleton placeholder rows instead of the empty-state branch.
@@ -241,6 +269,18 @@ export type BasaltDataTableProps<T> = {
241
269
  * facets={[{ columnId: 'department', label: 'Department', options: departmentOptions }]}
242
270
  */
243
271
  facets?: DataTableFacet[]
272
+ /** Called whenever the faceted column filters change — the seam for server-side faceting. */
273
+ onColumnFiltersChange?: (filters: ColumnFiltersState) => void
274
+ /**
275
+ * Hand filtering to the server: the table stops narrowing `data` locally and renders every row
276
+ * given, while the search input and facets keep reporting through `onGlobalFilterChange` /
277
+ * `onColumnFiltersChange` — which is where you re-request the filtered page.
278
+ *
279
+ * Required alongside `manualPagination` whenever `enableGlobalFilter` or `facets` is set;
280
+ * without it the controls narrow one page while "of N" keeps counting the whole set.
281
+ * @default false
282
+ */
283
+ manualFiltering?: boolean
244
284
  /**
245
285
  * Right-aligned toolbar slot (e.g. an "Export" button). Renders the toolbar row even when no
246
286
  * search input or facets are configured.
@@ -272,6 +312,21 @@ export type BasaltDataTableProps<T> = {
272
312
  * Disables local pagination slicing — pass the already-paginated page of `data` plus `rowCount`
273
313
  * (and optionally `pageCount`) for server-driven pagination. `onPaginationChange` is where you
274
314
  * fetch the next page.
315
+ *
316
+ * **It imposes a contract, enforced at render.** `data` is now one page, but the pagination bar
317
+ * says "Showing 1–25 of 412" — so every OTHER client-side control becomes a claim about 412 rows
318
+ * that it can only make about 25. Each has to be resolved explicitly, because only the call site
319
+ * knows whether the server does the work:
320
+ *
321
+ * - `rowCount` (or `pageCount`) must be given, or "of N" counts the page and the pager collapses
322
+ * to a single page nobody can leave;
323
+ * - sorting must be handed over with `manualSorting`, or switched off with `enableSorting={false}`;
324
+ * - `enableGlobalFilter` / `facets`, if used, must be handed over with `manualFiltering`;
325
+ * - `enablePagination` must be on, or `manualPagination` is inert and the page renders as if it
326
+ * were the whole table.
327
+ *
328
+ * Unresolved, each throws in dev and degrades to the honest render in production (no sort
329
+ * headers, no filter controls, no "of N") — never to the plausible wrong answer.
275
330
  * @default false
276
331
  * @example
277
332
  * <BasaltDataTable
@@ -423,14 +478,110 @@ function getPinnedCellStyle<T>(
423
478
  }
424
479
  }
425
480
 
481
+ // ── The manual-pagination contract ────────────────────────────────────────────
482
+
483
+ /** The house dev gate (`src/provider`, `src/charts/kinds/BandStrip` use the same expression):
484
+ * `basaltViteConfig` defines `process.env.NODE_ENV`, so a production bundle constant-folds this to
485
+ * `false` and drops the throw. Read per call, never hoisted, so a test can flip it. */
486
+ function isDev(): boolean {
487
+ return process.env['NODE_ENV'] !== 'production'
488
+ }
489
+
490
+ /** One client-side control that `manualPagination` turns into a claim the table cannot support. */
491
+ type ManualPaginationBreach = 'inert' | 'total' | 'sorting' | 'filtering'
492
+
493
+ const BREACH_REMEDY: Record<ManualPaginationBreach, string> = {
494
+ inert:
495
+ '`enablePagination` is not set, so `manualPagination` never reaches the table and the server ' +
496
+ 'page renders as if it were the whole set, with no bar to say otherwise. Pass ' +
497
+ '`enablePagination`, or drop `manualPagination`.',
498
+ total:
499
+ 'neither `rowCount` nor `pageCount` was given, so "of N" counts only the rows on this page and ' +
500
+ 'the pager collapses to one page nobody can leave. Pass `rowCount={total}`.',
501
+ sorting:
502
+ 'client-side sorting is still armed. It reorders only the rows in `data` — one page — while the ' +
503
+ 'header chevron and "of N" together present it as a sort of all of them. Pass `manualSorting` ' +
504
+ 'and sort in the request you make from `onSortingChange`, or `enableSorting={false}`.',
505
+ filtering:
506
+ 'the search input / facets still filter client-side. They narrow only the rows in `data` — one ' +
507
+ 'page — while "of N" keeps counting the whole set. Pass `manualFiltering` and filter in the ' +
508
+ 'request you make from `onGlobalFilterChange` / `onColumnFiltersChange`, or drop ' +
509
+ '`enableGlobalFilter` / `facets`.',
510
+ }
511
+
512
+ /**
513
+ * Every way the props on hand contradict `manualPagination`. Computed from PROPS only — never from
514
+ * the table instance, whose `getCanSort()` would read back the very `enableSorting` this decides.
515
+ */
516
+ function manualPaginationBreaches(config: {
517
+ columns: readonly { enableSorting?: boolean }[]
518
+ enablePagination: boolean
519
+ manualPagination: boolean
520
+ rowCount: number | undefined
521
+ pageCount: number | undefined
522
+ enableSorting: boolean
523
+ manualSorting: boolean
524
+ enableGlobalFilter: boolean
525
+ hasFacets: boolean
526
+ manualFiltering: boolean
527
+ }): ManualPaginationBreach[] {
528
+ if (!config.manualPagination) return []
529
+ if (!config.enablePagination) return ['inert']
530
+ const breaches: ManualPaginationBreach[] = []
531
+ if (config.rowCount === undefined && config.pageCount === undefined) breaches.push('total')
532
+ const anyColumnMaySort = config.columns.some((column) => column.enableSorting !== false)
533
+ if (config.enableSorting && anyColumnMaySort && !config.manualSorting) breaches.push('sorting')
534
+ if ((config.enableGlobalFilter || config.hasFacets) && !config.manualFiltering)
535
+ breaches.push('filtering')
536
+ return breaches
537
+ }
538
+
539
+ function manualPaginationMessage(breaches: ManualPaginationBreach[]): string {
540
+ return (
541
+ 'BasaltDataTable: `manualPagination` is set, so `data` is one server page — but:\n · ' +
542
+ breaches.map((breach) => BREACH_REMEDY[breach]).join('\n · ') +
543
+ '\nEach has to be resolved explicitly: only the call site knows whether the server does the ' +
544
+ 'work, so there is no default that is not a guess about the data.'
545
+ )
546
+ }
547
+
548
+ /** Bounded by the sixteen possible breach sets, so it cannot grow with render count. */
549
+ const reportedBreaches = new Set<string>()
550
+
551
+ /**
552
+ * Dev throws; production reports once and lets the caller degrade.
553
+ *
554
+ * Split deliberately from BandStrip's datum/prop rule rather than copied. That rule throws on a bad
555
+ * PROP because a prop is authored, deterministic, and caught the first time the component renders —
556
+ * all true here. What differs is that a bad `meta.align` has no honest render (every alignment we
557
+ * could pick might be the wrong one, and would look right in review), whereas this one does: a
558
+ * table with no sort headers, no filter controls and no "of N" asserts nothing false. Throwing in
559
+ * production would add no correctness the degradation does not already give, and would convert a
560
+ * misreport on a rarely-visited page into a blank one. The report is what keeps the degradation
561
+ * from being its own silence.
562
+ */
563
+ function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): void {
564
+ if (breaches.length === 0) return
565
+ const message = manualPaginationMessage(breaches)
566
+ if (isDev()) throw new Error(message)
567
+ if (reportedBreaches.has(message)) return
568
+ reportedBreaches.add(message)
569
+ console.error(message)
570
+ }
571
+
426
572
  // ── Component ─────────────────────────────────────────────────────────────────
427
573
 
428
574
  /**
429
575
  * A sortable data table backed by TanStack Table and rendered with Mantine primitives.
430
576
  * Generic over `T` — column types are inferred from `ColumnDef<T>`.
431
577
  *
432
- * Sorting is local/client-side (no `any`). When `enableSorting` is `false` the table
433
- * is read-only. An `emptyState` node is rendered when `data` is empty.
578
+ * Sorting is local/client-side by default (no `any`); `manualSorting` hands it to the server.
579
+ * When `enableSorting` is `false` the table is read-only. An `emptyState` node is rendered
580
+ * whenever no rows are visible — empty `data`, or a filter that matches none of it.
581
+ *
582
+ * `manualPagination` imposes a contract on the rest of the props (see its docblock): every
583
+ * client-side control it would turn into a false claim about the unseen rows must be handed to the
584
+ * server or switched off, and an unresolved one throws in dev rather than misreporting.
434
585
  *
435
586
  * Toolbar (search + facets), pagination, and column pinning are opt-in chrome layered on top —
436
587
  * see the corresponding props below. None of them change rendering unless explicitly enabled.
@@ -449,6 +600,7 @@ export function BasaltDataTable<T>({
449
600
  data,
450
601
  columns,
451
602
  enableSorting = true,
603
+ manualSorting = false,
452
604
  striped,
453
605
  highlightOnHover,
454
606
  emptyState,
@@ -462,6 +614,8 @@ export function BasaltDataTable<T>({
462
614
  initialGlobalFilter,
463
615
  onGlobalFilterChange,
464
616
  facets,
617
+ onColumnFiltersChange,
618
+ manualFiltering = false,
465
619
  toolbarActions,
466
620
  enablePagination = false,
467
621
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
@@ -511,6 +665,17 @@ export function BasaltDataTable<T>({
511
665
  [onGlobalFilterChange],
512
666
  )
513
667
 
668
+ const handleColumnFiltersChange = useCallback(
669
+ (updater: Updater<ColumnFiltersState>) => {
670
+ setColumnFilters((prev) => {
671
+ const next = typeof updater === 'function' ? updater(prev) : updater
672
+ onColumnFiltersChange?.(next)
673
+ return next
674
+ })
675
+ },
676
+ [onColumnFiltersChange],
677
+ )
678
+
514
679
  const handlePaginationChange = useCallback(
515
680
  (updater: Updater<PaginationState>) => {
516
681
  setPagination((prev) => {
@@ -550,6 +715,27 @@ export function BasaltDataTable<T>({
550
715
  })
551
716
  }, [columns, facets])
552
717
 
718
+ // Enforced before the table is built, so the degraded options below are the ones TanStack gets —
719
+ // a degraded table must not merely hide a control, it must not compute the answer either.
720
+ const hasFacets = Boolean(facets && facets.length > 0)
721
+ const breaches = manualPaginationBreaches({
722
+ columns,
723
+ enablePagination,
724
+ manualPagination,
725
+ rowCount,
726
+ pageCount,
727
+ enableSorting,
728
+ manualSorting,
729
+ enableGlobalFilter,
730
+ hasFacets,
731
+ manualFiltering,
732
+ })
733
+ enforceManualPaginationContract(breaches)
734
+ // Unreachable in dev (the line above threw); in production these are the honest fallbacks.
735
+ const sortingEnabled = enableSorting && !breaches.includes('sorting')
736
+ const filteringEnabled = !breaches.includes('filtering')
737
+ const totalIsAuthoritative = !breaches.includes('total')
738
+
553
739
  const table = useReactTable<T>({
554
740
  data,
555
741
  columns: tableColumns,
@@ -562,25 +748,36 @@ export function BasaltDataTable<T>({
562
748
  },
563
749
  onSortingChange: handleSortingChange,
564
750
  onGlobalFilterChange: handleGlobalFilterChange,
565
- onColumnFiltersChange: setColumnFilters,
751
+ onColumnFiltersChange: handleColumnFiltersChange,
566
752
  onColumnPinningChange: setColumnPinning,
567
753
  ...(enablePagination && { onPaginationChange: handlePaginationChange }),
568
754
  getCoreRowModel: getCoreRowModel(),
569
755
  getSortedRowModel: getSortedRowModel(),
570
756
  getFilteredRowModel: getFilteredRowModel(),
757
+ // Both row models stay installed and are BYPASSED by these flags, which is TanStack's own
758
+ // seam — so a degraded table renders `data` in the order and completeness the server sent it.
759
+ // Forced only by a BREACH, never by `enableSorting={false}` on its own: that pair still applies
760
+ // an `initialSorting` today (it disables the header control, not the row model) and a table
761
+ // that is not misreporting has no business losing it.
762
+ manualSorting: manualSorting || breaches.includes('sorting'),
763
+ manualFiltering: manualFiltering || breaches.includes('filtering'),
571
764
  ...(enablePagination && {
572
765
  getPaginationRowModel: getPaginationRowModel(),
573
766
  manualPagination,
574
767
  ...(manualPagination && rowCount !== undefined && { rowCount }),
575
768
  ...(manualPagination && pageCount !== undefined && { pageCount }),
576
769
  }),
577
- enableSorting,
770
+ enableSorting: sortingEnabled,
578
771
  enableColumnPinning: enablePinning,
579
772
  })
580
773
 
581
- const showToolbar =
582
- enableGlobalFilter || Boolean(facets && facets.length > 0) || Boolean(toolbarActions)
774
+ const showSearch = enableGlobalFilter && filteringEnabled
775
+ const showFacets = hasFacets && filteringEnabled
776
+ const showToolbar = showSearch || showFacets || Boolean(toolbarActions)
583
777
  const headerGroups = enablePinning ? getOrderedHeaderGroups(table) : table.getHeaderGroups()
778
+ // The rows actually rendered, not `data` — a filter or a page index that matches nothing leaves
779
+ // `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
780
+ const rows = table.getRowModel().rows
584
781
 
585
782
  const tableNode = (
586
783
  <Table
@@ -597,7 +794,7 @@ export function BasaltDataTable<T>({
597
794
  {headerGroups.map((headerGroup) => (
598
795
  <Table.Tr key={headerGroup.id}>
599
796
  {headerGroup.headers.map((header) => {
600
- const canSort = enableSorting && header.column.getCanSort()
797
+ const canSort = sortingEnabled && header.column.getCanSort()
601
798
  const sorted = header.column.getIsSorted()
602
799
  const toggleSorting = header.column.getToggleSortingHandler()
603
800
  const pinnedStyle = enablePinning
@@ -654,7 +851,7 @@ export function BasaltDataTable<T>({
654
851
  ))}
655
852
  </Table.Tr>
656
853
  ))
657
- ) : data.length === 0 ? (
854
+ ) : rows.length === 0 ? (
658
855
  <Table.Tr>
659
856
  <Table.Td colSpan={columns.length}>
660
857
  {emptyState ?? (
@@ -665,7 +862,7 @@ export function BasaltDataTable<T>({
665
862
  </Table.Td>
666
863
  </Table.Tr>
667
864
  ) : (
668
- table.getRowModel().rows.map((row) => {
865
+ rows.map((row) => {
669
866
  const cells = enablePinning
670
867
  ? [
671
868
  ...row.getLeftVisibleCells(),
@@ -718,7 +915,7 @@ export function BasaltDataTable<T>({
718
915
  {showToolbar && (
719
916
  <Group justify="space-between" align="flex-end" wrap="wrap" gap="xs" mb="xs">
720
917
  <Group gap="xs" wrap="wrap" align="flex-end">
721
- {enableGlobalFilter && (
918
+ {showSearch && (
722
919
  <TextInput
723
920
  size="xs"
724
921
  radius="md"
@@ -729,40 +926,41 @@ export function BasaltDataTable<T>({
729
926
  w={220}
730
927
  />
731
928
  )}
732
- {facets?.map((facet) => {
733
- const column = table.getColumn(facet.columnId)
734
- if (!column) return null
735
- if (facet.multiple) {
736
- const value = (column.getFilterValue() as string[] | undefined) ?? []
929
+ {showFacets &&
930
+ facets?.map((facet) => {
931
+ const column = table.getColumn(facet.columnId)
932
+ if (!column) return null
933
+ if (facet.multiple) {
934
+ const value = (column.getFilterValue() as string[] | undefined) ?? []
935
+ return (
936
+ <MultiSelect
937
+ key={facet.columnId}
938
+ size="xs"
939
+ radius="md"
940
+ placeholder={facet.label}
941
+ data={facet.options}
942
+ value={value}
943
+ onChange={(next) => column.setFilterValue(next.length > 0 ? next : undefined)}
944
+ clearable
945
+ w={200}
946
+ />
947
+ )
948
+ }
949
+ const value = (column.getFilterValue() as string | undefined) ?? null
737
950
  return (
738
- <MultiSelect
951
+ <Select
739
952
  key={facet.columnId}
740
953
  size="xs"
741
954
  radius="md"
742
955
  placeholder={facet.label}
743
956
  data={facet.options}
744
957
  value={value}
745
- onChange={(next) => column.setFilterValue(next.length > 0 ? next : undefined)}
958
+ onChange={(next) => column.setFilterValue(next ?? undefined)}
746
959
  clearable
747
- w={200}
960
+ w={180}
748
961
  />
749
962
  )
750
- }
751
- const value = (column.getFilterValue() as string | undefined) ?? null
752
- return (
753
- <Select
754
- key={facet.columnId}
755
- size="xs"
756
- radius="md"
757
- placeholder={facet.label}
758
- data={facet.options}
759
- value={value}
760
- onChange={(next) => column.setFilterValue(next ?? undefined)}
761
- clearable
762
- w={180}
763
- />
764
- )
765
- })}
963
+ })}
766
964
  </Group>
767
965
  {toolbarActions}
768
966
  </Group>
@@ -783,7 +981,8 @@ export function BasaltDataTable<T>({
783
981
  {enablePagination && (
784
982
  <Group justify="space-between" mt="xs" wrap="wrap" gap="xs" align="center">
785
983
  <Text style={RANGE_LABEL_STYLE}>
786
- Showing {rangeStart}–{rangeEnd} of {total}
984
+ Showing {rangeStart}–{rangeEnd}
985
+ {totalIsAuthoritative ? ` of ${total}` : ''}
787
986
  </Text>
788
987
  <Group gap="xs" align="center">
789
988
  <Select
package/src/data/table.ts CHANGED
@@ -48,4 +48,6 @@ export {
48
48
  type ColumnHelper,
49
49
  type PaginationState,
50
50
  type ColumnPinningState,
51
+ // The type `onColumnFiltersChange` hands back — needed at the call site for server-side faceting.
52
+ type ColumnFiltersState,
51
53
  } from '@tanstack/react-table'