basalt-ui 1.23.1 → 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/AGENTS.md +11 -6
- package/CHANGELOG.md +21 -0
- package/MIGRATING.md +138 -4
- package/README.md +123 -32
- package/agent/rules/basalt-data.md +104 -12
- package/agent/rules/basalt-query.md +57 -0
- package/agent/skills/basalt-app/SKILL.md +7 -0
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +101 -17
- package/dist/dashboard/empty-state.d.ts +6 -2
- package/dist/dashboard/empty-state.d.ts.map +1 -1
- package/dist/dashboard/empty-state.js +1 -1
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +8 -0
- package/dist/dashboard/query-state.d.ts +96 -0
- package/dist/dashboard/query-state.d.ts.map +1 -0
- package/dist/dashboard/query-state.js +123 -0
- package/dist/dashboard/query-state.test.d.ts +2 -0
- package/dist/dashboard/query-state.test.d.ts.map +1 -0
- package/dist/data/data-table.d.ts +125 -8
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +121 -13
- package/dist/data/data-table.test.d.ts +2 -0
- package/dist/data/data-table.test.d.ts.map +1 -0
- package/dist/data/table.d.ts +2 -2
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +21 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/query/error-message.d.ts +41 -0
- package/dist/query/error-message.d.ts.map +1 -0
- package/dist/query/error-message.js +30 -0
- package/dist/query/error-message.test.d.ts +2 -0
- package/dist/query/error-message.test.d.ts.map +1 -0
- package/dist/query/index.d.ts +1 -19
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +2 -14
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/cli/check-theme.test.ts +34 -0
- package/src/cli/index.ts +222 -44
- package/src/cli/tokens-css.test.ts +73 -6
- package/src/cli/toolchain-wiring.test.ts +146 -0
- package/src/dashboard/empty-state.tsx +9 -3
- package/src/dashboard/index.ts +11 -0
- package/src/dashboard/query-state.test.tsx +152 -0
- package/src/dashboard/query-state.tsx +272 -0
- package/src/data/data-table.test.tsx +349 -0
- package/src/data/data-table.tsx +359 -42
- package/src/data/table.ts +8 -1
- package/src/guard/check-source.test.ts +141 -0
- package/src/guard/index.ts +102 -8
- package/src/index.ts +9 -0
- package/src/query/error-message.test.ts +67 -0
- package/src/query/error-message.ts +70 -0
- package/src/query/index.ts +2 -34
- package/src/surfaces.ts +1 -1
package/src/data/data-table.tsx
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
Text,
|
|
43
43
|
TextInput,
|
|
44
44
|
} from '@mantine/core'
|
|
45
|
+
import type { MantineSpacing } from '@mantine/core'
|
|
45
46
|
import type {
|
|
46
47
|
Column,
|
|
47
48
|
ColumnDef,
|
|
@@ -49,6 +50,7 @@ import type {
|
|
|
49
50
|
ColumnPinningState,
|
|
50
51
|
FilterFn,
|
|
51
52
|
PaginationState,
|
|
53
|
+
RowData,
|
|
52
54
|
SortingState,
|
|
53
55
|
Table as TanstackTable,
|
|
54
56
|
Updater,
|
|
@@ -65,6 +67,50 @@ import type { CSSProperties, ReactNode } from 'react'
|
|
|
65
67
|
import { useCallback, useMemo, useState } from 'react'
|
|
66
68
|
import { alpha, VX } from '../tokens'
|
|
67
69
|
|
|
70
|
+
// ── Column alignment ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** Horizontal alignment of a column's header AND its cells. */
|
|
73
|
+
export type DataTableAlign = 'left' | 'center' | 'right'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Alignment lives on the column def's `meta`, not on a parallel prop array, so it travels with the
|
|
77
|
+
* column it describes and survives reordering.
|
|
78
|
+
*
|
|
79
|
+
* Declared as a module augmentation of TanStack's own `ColumnMeta` so `meta: { aling: 'right' }` is
|
|
80
|
+
* a TYPE error (excess-property check on the object literal) rather than a silently ignored key —
|
|
81
|
+
* and a wrong VALUE (`'end'`, `'centre'`) throws at render. A misspelled alignment that quietly
|
|
82
|
+
* left-aligns a money column is the class of defect that looks correct in review.
|
|
83
|
+
*/
|
|
84
|
+
declare module '@tanstack/react-table' {
|
|
85
|
+
// TData/TValue are TanStack's own parameters — required to match its declaration, unused here.
|
|
86
|
+
// oxlint-disable-next-line no-unused-vars
|
|
87
|
+
interface ColumnMeta<TData extends RowData, TValue> {
|
|
88
|
+
align?: DataTableAlign
|
|
89
|
+
/**
|
|
90
|
+
* Opt OUT of the automatic mono-numeral cell style. The table applies it whenever the cell's
|
|
91
|
+
* raw value is a number — which is right for a plain figure and wrong for a numeric accessor
|
|
92
|
+
* whose `cell` renders its own chrome (a coloured `Text`, a `Badge`, a sparkline), because the
|
|
93
|
+
* `<td>`'s mono font then overrides what the cell asked for.
|
|
94
|
+
*/
|
|
95
|
+
numeral?: boolean
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ALIGNMENTS: ReadonlySet<string> = new Set<DataTableAlign>(['left', 'center', 'right'])
|
|
100
|
+
|
|
101
|
+
/** Reads `meta.align` off a column, throwing on a value that is not one of the three. */
|
|
102
|
+
function resolveAlign<T>(column: Column<T, unknown>): DataTableAlign | undefined {
|
|
103
|
+
const align = column.columnDef.meta?.align
|
|
104
|
+
if (align === undefined) return undefined
|
|
105
|
+
if (!ALIGNMENTS.has(align)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`BasaltDataTable: column "${column.id}" has meta.align=${JSON.stringify(align)} — ` +
|
|
108
|
+
"expected 'left' | 'center' | 'right'.",
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return align
|
|
112
|
+
}
|
|
113
|
+
|
|
68
114
|
// ── Facets ────────────────────────────────────────────────────────────────────
|
|
69
115
|
|
|
70
116
|
/** A single selectable option inside a {@link DataTableFacet}'s Select/MultiSelect. */
|
|
@@ -125,15 +171,43 @@ export type BasaltDataTableProps<T> = {
|
|
|
125
171
|
// oxlint-disable-next-line typescript/no-explicit-any -- TanStack-idiomatic heterogeneous column array
|
|
126
172
|
columns: ColumnDef<T, any>[]
|
|
127
173
|
/**
|
|
128
|
-
* 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`.
|
|
129
179
|
* @default true
|
|
130
180
|
*/
|
|
131
181
|
enableSorting?: boolean
|
|
132
|
-
/**
|
|
133
|
-
|
|
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
|
|
202
|
+
/** Stripe alternate rows. Forwarded to Mantine `Table` — `'odd'`/`'even'` pick the phase. */
|
|
203
|
+
striped?: boolean | 'odd' | 'even'
|
|
134
204
|
/** Highlight hovered rows. Forwarded to Mantine `Table`. */
|
|
135
205
|
highlightOnHover?: boolean
|
|
136
|
-
/**
|
|
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
|
+
*/
|
|
137
211
|
emptyState?: ReactNode
|
|
138
212
|
/**
|
|
139
213
|
* When true, renders skeleton placeholder rows instead of the empty-state branch.
|
|
@@ -195,6 +269,18 @@ export type BasaltDataTableProps<T> = {
|
|
|
195
269
|
* facets={[{ columnId: 'department', label: 'Department', options: departmentOptions }]}
|
|
196
270
|
*/
|
|
197
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
|
|
198
284
|
/**
|
|
199
285
|
* Right-aligned toolbar slot (e.g. an "Export" button). Renders the toolbar row even when no
|
|
200
286
|
* search input or facets are configured.
|
|
@@ -226,6 +312,21 @@ export type BasaltDataTableProps<T> = {
|
|
|
226
312
|
* Disables local pagination slicing — pass the already-paginated page of `data` plus `rowCount`
|
|
227
313
|
* (and optionally `pageCount`) for server-driven pagination. `onPaginationChange` is where you
|
|
228
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.
|
|
229
330
|
* @default false
|
|
230
331
|
* @example
|
|
231
332
|
* <BasaltDataTable
|
|
@@ -257,6 +358,42 @@ export type BasaltDataTableProps<T> = {
|
|
|
257
358
|
enablePinning?: boolean
|
|
258
359
|
/** Initial column-pinning state — which column ids stick to the left/right edge. */
|
|
259
360
|
initialColumnPinning?: ColumnPinningState
|
|
361
|
+
|
|
362
|
+
// ── Body chrome ─────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Caps the scrolling body (px number or CSS length) so a long table cannot blow a card out
|
|
366
|
+
* vertically — the header stays put and the rows scroll under it. Renders Mantine's
|
|
367
|
+
* `Table.ScrollContainer type="native"`, which is the same node the docs sanction as the raw
|
|
368
|
+
* escape for a bespoke table: the blessed lane and the escape hatch produce identical DOM, so
|
|
369
|
+
* adopting one does not contradict the other.
|
|
370
|
+
*
|
|
371
|
+
* `type="native"` is required rather than preferred: `ScrollArea`'s custom viewport is the
|
|
372
|
+
* positioning context a sticky `<thead>` resolves against, so the default `'scrollarea'` type
|
|
373
|
+
* pins the header to the page viewport instead of the table's box.
|
|
374
|
+
*/
|
|
375
|
+
maxHeight?: number | string
|
|
376
|
+
/**
|
|
377
|
+
* `min-width` below which the body scrolls horizontally. Setting either this or `maxHeight`
|
|
378
|
+
* turns on the scroll container; `maxHeight` alone implies `minWidth: 0`.
|
|
379
|
+
*/
|
|
380
|
+
minWidth?: number | string
|
|
381
|
+
/** Sticky header row. Pair with `maxHeight` to stick within the table rather than the page. */
|
|
382
|
+
stickyHeader?: boolean
|
|
383
|
+
/** Offset for a sticky header sitting under a fixed app header (px number or CSS length). */
|
|
384
|
+
stickyHeaderOffset?: number | string
|
|
385
|
+
/** Vertical cell padding. Forwarded to Mantine `Table`. */
|
|
386
|
+
verticalSpacing?: MantineSpacing
|
|
387
|
+
/** Horizontal cell padding. Forwarded to Mantine `Table`. */
|
|
388
|
+
horizontalSpacing?: MantineSpacing
|
|
389
|
+
/** Row separators. Forwarded to Mantine `Table`. @default true (Mantine's own default) */
|
|
390
|
+
withRowBorders?: boolean
|
|
391
|
+
/**
|
|
392
|
+
* Outer table border. Turn it OFF for a table that already sits inside a `Card`/`ChartCard`,
|
|
393
|
+
* where the card owns the frame and a second one reads as a nested box.
|
|
394
|
+
* @default true
|
|
395
|
+
*/
|
|
396
|
+
withTableBorder?: boolean
|
|
260
397
|
}
|
|
261
398
|
|
|
262
399
|
// ── Sort indicator ────────────────────────────────────────────────────────────
|
|
@@ -341,14 +478,110 @@ function getPinnedCellStyle<T>(
|
|
|
341
478
|
}
|
|
342
479
|
}
|
|
343
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
|
+
|
|
344
572
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
345
573
|
|
|
346
574
|
/**
|
|
347
575
|
* A sortable data table backed by TanStack Table and rendered with Mantine primitives.
|
|
348
576
|
* Generic over `T` — column types are inferred from `ColumnDef<T>`.
|
|
349
577
|
*
|
|
350
|
-
* Sorting is local/client-side (no `any`)
|
|
351
|
-
* 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.
|
|
352
585
|
*
|
|
353
586
|
* Toolbar (search + facets), pagination, and column pinning are opt-in chrome layered on top —
|
|
354
587
|
* see the corresponding props below. None of them change rendering unless explicitly enabled.
|
|
@@ -367,6 +600,7 @@ export function BasaltDataTable<T>({
|
|
|
367
600
|
data,
|
|
368
601
|
columns,
|
|
369
602
|
enableSorting = true,
|
|
603
|
+
manualSorting = false,
|
|
370
604
|
striped,
|
|
371
605
|
highlightOnHover,
|
|
372
606
|
emptyState,
|
|
@@ -380,6 +614,8 @@ export function BasaltDataTable<T>({
|
|
|
380
614
|
initialGlobalFilter,
|
|
381
615
|
onGlobalFilterChange,
|
|
382
616
|
facets,
|
|
617
|
+
onColumnFiltersChange,
|
|
618
|
+
manualFiltering = false,
|
|
383
619
|
toolbarActions,
|
|
384
620
|
enablePagination = false,
|
|
385
621
|
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
|
|
@@ -390,6 +626,14 @@ export function BasaltDataTable<T>({
|
|
|
390
626
|
pageCount,
|
|
391
627
|
enablePinning = false,
|
|
392
628
|
initialColumnPinning,
|
|
629
|
+
maxHeight,
|
|
630
|
+
minWidth,
|
|
631
|
+
stickyHeader,
|
|
632
|
+
stickyHeaderOffset,
|
|
633
|
+
verticalSpacing,
|
|
634
|
+
horizontalSpacing,
|
|
635
|
+
withRowBorders,
|
|
636
|
+
withTableBorder = true,
|
|
393
637
|
}: BasaltDataTableProps<T>) {
|
|
394
638
|
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
|
395
639
|
const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter ?? '')
|
|
@@ -421,6 +665,17 @@ export function BasaltDataTable<T>({
|
|
|
421
665
|
[onGlobalFilterChange],
|
|
422
666
|
)
|
|
423
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
|
+
|
|
424
679
|
const handlePaginationChange = useCallback(
|
|
425
680
|
(updater: Updater<PaginationState>) => {
|
|
426
681
|
setPagination((prev) => {
|
|
@@ -460,6 +715,27 @@ export function BasaltDataTable<T>({
|
|
|
460
715
|
})
|
|
461
716
|
}, [columns, facets])
|
|
462
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
|
+
|
|
463
739
|
const table = useReactTable<T>({
|
|
464
740
|
data,
|
|
465
741
|
columns: tableColumns,
|
|
@@ -472,42 +748,59 @@ export function BasaltDataTable<T>({
|
|
|
472
748
|
},
|
|
473
749
|
onSortingChange: handleSortingChange,
|
|
474
750
|
onGlobalFilterChange: handleGlobalFilterChange,
|
|
475
|
-
onColumnFiltersChange:
|
|
751
|
+
onColumnFiltersChange: handleColumnFiltersChange,
|
|
476
752
|
onColumnPinningChange: setColumnPinning,
|
|
477
753
|
...(enablePagination && { onPaginationChange: handlePaginationChange }),
|
|
478
754
|
getCoreRowModel: getCoreRowModel(),
|
|
479
755
|
getSortedRowModel: getSortedRowModel(),
|
|
480
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'),
|
|
481
764
|
...(enablePagination && {
|
|
482
765
|
getPaginationRowModel: getPaginationRowModel(),
|
|
483
766
|
manualPagination,
|
|
484
767
|
...(manualPagination && rowCount !== undefined && { rowCount }),
|
|
485
768
|
...(manualPagination && pageCount !== undefined && { pageCount }),
|
|
486
769
|
}),
|
|
487
|
-
enableSorting,
|
|
770
|
+
enableSorting: sortingEnabled,
|
|
488
771
|
enableColumnPinning: enablePinning,
|
|
489
772
|
})
|
|
490
773
|
|
|
491
|
-
const
|
|
492
|
-
|
|
774
|
+
const showSearch = enableGlobalFilter && filteringEnabled
|
|
775
|
+
const showFacets = hasFacets && filteringEnabled
|
|
776
|
+
const showToolbar = showSearch || showFacets || Boolean(toolbarActions)
|
|
493
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
|
|
494
781
|
|
|
495
782
|
const tableNode = (
|
|
496
783
|
<Table
|
|
497
784
|
{...(striped !== undefined && { striped })}
|
|
498
785
|
{...(highlightOnHover !== undefined && { highlightOnHover })}
|
|
499
|
-
|
|
786
|
+
{...(stickyHeader !== undefined && { stickyHeader })}
|
|
787
|
+
{...(stickyHeaderOffset !== undefined && { stickyHeaderOffset })}
|
|
788
|
+
{...(verticalSpacing !== undefined && { verticalSpacing })}
|
|
789
|
+
{...(horizontalSpacing !== undefined && { horizontalSpacing })}
|
|
790
|
+
{...(withRowBorders !== undefined && { withRowBorders })}
|
|
791
|
+
withTableBorder={withTableBorder}
|
|
500
792
|
>
|
|
501
793
|
<Table.Thead>
|
|
502
794
|
{headerGroups.map((headerGroup) => (
|
|
503
795
|
<Table.Tr key={headerGroup.id}>
|
|
504
796
|
{headerGroup.headers.map((header) => {
|
|
505
|
-
const canSort =
|
|
797
|
+
const canSort = sortingEnabled && header.column.getCanSort()
|
|
506
798
|
const sorted = header.column.getIsSorted()
|
|
507
799
|
const toggleSorting = header.column.getToggleSortingHandler()
|
|
508
800
|
const pinnedStyle = enablePinning
|
|
509
801
|
? getPinnedCellStyle(table, header.column)
|
|
510
802
|
: undefined
|
|
803
|
+
const align = resolveAlign(header.column)
|
|
511
804
|
return (
|
|
512
805
|
<Table.Th
|
|
513
806
|
key={header.id}
|
|
@@ -533,6 +826,7 @@ export function BasaltDataTable<T>({
|
|
|
533
826
|
}
|
|
534
827
|
style={{
|
|
535
828
|
...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
|
|
829
|
+
...(align !== undefined && { textAlign: align }),
|
|
536
830
|
...pinnedStyle,
|
|
537
831
|
}}
|
|
538
832
|
>
|
|
@@ -557,7 +851,7 @@ export function BasaltDataTable<T>({
|
|
|
557
851
|
))}
|
|
558
852
|
</Table.Tr>
|
|
559
853
|
))
|
|
560
|
-
) :
|
|
854
|
+
) : rows.length === 0 ? (
|
|
561
855
|
<Table.Tr>
|
|
562
856
|
<Table.Td colSpan={columns.length}>
|
|
563
857
|
{emptyState ?? (
|
|
@@ -568,7 +862,7 @@ export function BasaltDataTable<T>({
|
|
|
568
862
|
</Table.Td>
|
|
569
863
|
</Table.Tr>
|
|
570
864
|
) : (
|
|
571
|
-
|
|
865
|
+
rows.map((row) => {
|
|
572
866
|
const cells = enablePinning
|
|
573
867
|
? [
|
|
574
868
|
...row.getLeftVisibleCells(),
|
|
@@ -582,11 +876,16 @@ export function BasaltDataTable<T>({
|
|
|
582
876
|
const pinnedStyle = enablePinning
|
|
583
877
|
? getPinnedCellStyle(table, cell.column)
|
|
584
878
|
: undefined
|
|
879
|
+
const align = resolveAlign(cell.column)
|
|
585
880
|
return (
|
|
586
881
|
<Table.Td
|
|
587
882
|
key={cell.id}
|
|
588
883
|
style={{
|
|
589
|
-
...(typeof cell.getValue() === 'number'
|
|
884
|
+
...(typeof cell.getValue() === 'number' &&
|
|
885
|
+
cell.column.columnDef.meta?.numeral !== false
|
|
886
|
+
? NUMERIC_CELL_STYLE
|
|
887
|
+
: undefined),
|
|
888
|
+
...(align !== undefined && { textAlign: align }),
|
|
590
889
|
...pinnedStyle,
|
|
591
890
|
}}
|
|
592
891
|
>
|
|
@@ -602,6 +901,10 @@ export function BasaltDataTable<T>({
|
|
|
602
901
|
</Table>
|
|
603
902
|
)
|
|
604
903
|
|
|
904
|
+
// A capped body or a horizontal floor both need the same native scroll node; pinning keeps its
|
|
905
|
+
// simpler overflow-x Box when neither is set.
|
|
906
|
+
const scrolls = maxHeight !== undefined || minWidth !== undefined
|
|
907
|
+
|
|
605
908
|
const paginationState = table.getState().pagination
|
|
606
909
|
const total = table.getRowCount()
|
|
607
910
|
const rangeStart = total === 0 ? 0 : paginationState.pageIndex * paginationState.pageSize + 1
|
|
@@ -612,7 +915,7 @@ export function BasaltDataTable<T>({
|
|
|
612
915
|
{showToolbar && (
|
|
613
916
|
<Group justify="space-between" align="flex-end" wrap="wrap" gap="xs" mb="xs">
|
|
614
917
|
<Group gap="xs" wrap="wrap" align="flex-end">
|
|
615
|
-
{
|
|
918
|
+
{showSearch && (
|
|
616
919
|
<TextInput
|
|
617
920
|
size="xs"
|
|
618
921
|
radius="md"
|
|
@@ -623,49 +926,63 @@ export function BasaltDataTable<T>({
|
|
|
623
926
|
w={220}
|
|
624
927
|
/>
|
|
625
928
|
)}
|
|
626
|
-
{
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
631
950
|
return (
|
|
632
|
-
<
|
|
951
|
+
<Select
|
|
633
952
|
key={facet.columnId}
|
|
634
953
|
size="xs"
|
|
635
954
|
radius="md"
|
|
636
955
|
placeholder={facet.label}
|
|
637
956
|
data={facet.options}
|
|
638
957
|
value={value}
|
|
639
|
-
onChange={(next) => column.setFilterValue(next
|
|
958
|
+
onChange={(next) => column.setFilterValue(next ?? undefined)}
|
|
640
959
|
clearable
|
|
641
|
-
w={
|
|
960
|
+
w={180}
|
|
642
961
|
/>
|
|
643
962
|
)
|
|
644
|
-
}
|
|
645
|
-
const value = (column.getFilterValue() as string | undefined) ?? null
|
|
646
|
-
return (
|
|
647
|
-
<Select
|
|
648
|
-
key={facet.columnId}
|
|
649
|
-
size="xs"
|
|
650
|
-
radius="md"
|
|
651
|
-
placeholder={facet.label}
|
|
652
|
-
data={facet.options}
|
|
653
|
-
value={value}
|
|
654
|
-
onChange={(next) => column.setFilterValue(next ?? undefined)}
|
|
655
|
-
clearable
|
|
656
|
-
w={180}
|
|
657
|
-
/>
|
|
658
|
-
)
|
|
659
|
-
})}
|
|
963
|
+
})}
|
|
660
964
|
</Group>
|
|
661
965
|
{toolbarActions}
|
|
662
966
|
</Group>
|
|
663
967
|
)}
|
|
664
|
-
{
|
|
968
|
+
{scrolls ? (
|
|
969
|
+
<Table.ScrollContainer
|
|
970
|
+
type="native"
|
|
971
|
+
minWidth={minWidth ?? 0}
|
|
972
|
+
{...(maxHeight !== undefined && { maxHeight })}
|
|
973
|
+
>
|
|
974
|
+
{tableNode}
|
|
975
|
+
</Table.ScrollContainer>
|
|
976
|
+
) : enablePinning ? (
|
|
977
|
+
<Box style={{ overflowX: 'auto' }}>{tableNode}</Box>
|
|
978
|
+
) : (
|
|
979
|
+
tableNode
|
|
980
|
+
)}
|
|
665
981
|
{enablePagination && (
|
|
666
982
|
<Group justify="space-between" mt="xs" wrap="wrap" gap="xs" align="center">
|
|
667
983
|
<Text style={RANGE_LABEL_STYLE}>
|
|
668
|
-
Showing {rangeStart}–{rangeEnd}
|
|
984
|
+
Showing {rangeStart}–{rangeEnd}
|
|
985
|
+
{totalIsAuthoritative ? ` of ${total}` : ''}
|
|
669
986
|
</Text>
|
|
670
987
|
<Group gap="xs" align="center">
|
|
671
988
|
<Select
|
package/src/data/table.ts
CHANGED
|
@@ -21,7 +21,12 @@
|
|
|
21
21
|
|
|
22
22
|
// ── BasaltDataTable ───────────────────────────────────────────────────────────
|
|
23
23
|
export { BasaltDataTable } from './data-table'
|
|
24
|
-
export type {
|
|
24
|
+
export type {
|
|
25
|
+
BasaltDataTableProps,
|
|
26
|
+
DataTableAlign,
|
|
27
|
+
DataTableFacet,
|
|
28
|
+
DataTableFacetOption,
|
|
29
|
+
} from './data-table'
|
|
25
30
|
|
|
26
31
|
// ── @tanstack/react-table convenience re-exports ──────────────────────────────
|
|
27
32
|
// These are the handful of table primitives a consumer needs at the call site;
|
|
@@ -43,4 +48,6 @@ export {
|
|
|
43
48
|
type ColumnHelper,
|
|
44
49
|
type PaginationState,
|
|
45
50
|
type ColumnPinningState,
|
|
51
|
+
// The type `onColumnFiltersChange` hands back — needed at the call site for server-side faceting.
|
|
52
|
+
type ColumnFiltersState,
|
|
46
53
|
} from '@tanstack/react-table'
|