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 +7 -0
- package/MIGRATING.md +1 -1
- package/README.md +1 -1
- package/agent/rules/basalt-data.md +67 -7
- package/agent/rules/basalt-query.md +2 -2
- package/dist/dashboard/query-state.d.ts +1 -1
- package/dist/data/data-table.d.ts +66 -6
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +80 -9
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/dashboard/query-state.tsx +1 -1
- package/src/data/data-table.test.tsx +193 -2
- package/src/data/data-table.tsx +236 -37
- package/src/data/table.ts +2 -0
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
|
-
|
|
152
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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`)
|
|
239
|
-
* is read-only. An `emptyState` node is rendered
|
|
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":"AA4CA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACnD,OAAO,KAAK,EAEV,SAAS,
|
|
1
|
+
{"version":3,"file":"data-table.d.ts","sourceRoot":"","sources":["../../src/data/data-table.tsx"],"names":[],"mappings":"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"}
|
package/dist/data/data-table.js
CHANGED
|
@@ -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:
|
|
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
|
|
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 =
|
|
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}`)) :
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
total
|
|
416
|
+
totalIsAuthoritative ? ` of ${total}` : ""
|
|
346
417
|
] }),
|
|
347
418
|
/* @__PURE__ */ jsxs(Group, { gap: "xs", align: "center", children: [
|
|
348
419
|
/* @__PURE__ */ jsx(
|
package/dist/data/table.d.ts
CHANGED
|
@@ -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
|
package/dist/data/table.d.ts.map
CHANGED
|
@@ -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.
|
|
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.
|
|
22
|
+
Version: 1.25.0
|
|
23
23
|
|
|
24
24
|
## basalt-ui
|
|
25
25
|
Import: import { ... } from 'basalt-ui'
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
+
})
|
package/src/data/data-table.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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`)
|
|
433
|
-
* is read-only. An `emptyState` node is rendered
|
|
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:
|
|
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
|
|
582
|
-
|
|
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 =
|
|
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
|
-
) :
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
<
|
|
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
|
|
958
|
+
onChange={(next) => column.setFilterValue(next ?? undefined)}
|
|
746
959
|
clearable
|
|
747
|
-
w={
|
|
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}
|
|
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