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.
Files changed (63) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +21 -0
  3. package/MIGRATING.md +138 -4
  4. package/README.md +123 -32
  5. package/agent/rules/basalt-data.md +104 -12
  6. package/agent/rules/basalt-query.md +57 -0
  7. package/agent/skills/basalt-app/SKILL.md +7 -0
  8. package/dist/cli/index.d.ts +4 -2
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +101 -17
  11. package/dist/dashboard/empty-state.d.ts +6 -2
  12. package/dist/dashboard/empty-state.d.ts.map +1 -1
  13. package/dist/dashboard/empty-state.js +1 -1
  14. package/dist/dashboard/index.d.ts +1 -0
  15. package/dist/dashboard/index.d.ts.map +1 -1
  16. package/dist/dashboard/index.js +8 -0
  17. package/dist/dashboard/query-state.d.ts +96 -0
  18. package/dist/dashboard/query-state.d.ts.map +1 -0
  19. package/dist/dashboard/query-state.js +123 -0
  20. package/dist/dashboard/query-state.test.d.ts +2 -0
  21. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  22. package/dist/data/data-table.d.ts +125 -8
  23. package/dist/data/data-table.d.ts.map +1 -1
  24. package/dist/data/data-table.js +121 -13
  25. package/dist/data/data-table.test.d.ts +2 -0
  26. package/dist/data/data-table.test.d.ts.map +1 -0
  27. package/dist/data/table.d.ts +2 -2
  28. package/dist/data/table.d.ts.map +1 -1
  29. package/dist/guard/index.d.ts.map +1 -1
  30. package/dist/guard/index.js +21 -6
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -1
  34. package/dist/query/error-message.d.ts +41 -0
  35. package/dist/query/error-message.d.ts.map +1 -0
  36. package/dist/query/error-message.js +30 -0
  37. package/dist/query/error-message.test.d.ts +2 -0
  38. package/dist/query/error-message.test.d.ts.map +1 -0
  39. package/dist/query/index.d.ts +1 -19
  40. package/dist/query/index.d.ts.map +1 -1
  41. package/dist/query/index.js +2 -14
  42. package/dist/surfaces.d.ts +1 -1
  43. package/dist/surfaces.js +1 -1
  44. package/llms.txt +3 -3
  45. package/package.json +1 -1
  46. package/src/cli/check-theme.test.ts +34 -0
  47. package/src/cli/index.ts +222 -44
  48. package/src/cli/tokens-css.test.ts +73 -6
  49. package/src/cli/toolchain-wiring.test.ts +146 -0
  50. package/src/dashboard/empty-state.tsx +9 -3
  51. package/src/dashboard/index.ts +11 -0
  52. package/src/dashboard/query-state.test.tsx +152 -0
  53. package/src/dashboard/query-state.tsx +272 -0
  54. package/src/data/data-table.test.tsx +349 -0
  55. package/src/data/data-table.tsx +359 -42
  56. package/src/data/table.ts +8 -1
  57. package/src/guard/check-source.test.ts +141 -0
  58. package/src/guard/index.ts +102 -8
  59. package/src/index.ts +9 -0
  60. package/src/query/error-message.test.ts +67 -0
  61. package/src/query/error-message.ts +70 -0
  62. package/src/query/index.ts +2 -34
  63. package/src/surfaces.ts +1 -1
@@ -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
- /** Stripe alternate rows. Forwarded to Mantine `Table`. */
133
- striped?: 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
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
- /** Rendered when `data` is empty. Falls back to a simple message when omitted. */
206
+ /**
207
+ * Rendered when no rows are VISIBLE — `data` is empty, or a filter/page matches none of it.
208
+ * Falls back to a simple message when omitted. (Before 1.25.0 this tracked `data.length` alone,
209
+ * so a search matching nothing rendered a blank `<tbody>` with no message at all.)
210
+ */
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`). When `enableSorting` is `false` the table
351
- * is read-only. An `emptyState` node is rendered when `data` is empty.
578
+ * Sorting is local/client-side by default (no `any`); `manualSorting` hands it to the server.
579
+ * When `enableSorting` is `false` the table is read-only. An `emptyState` node is rendered
580
+ * whenever no rows are visible — empty `data`, or a filter that matches none of it.
581
+ *
582
+ * `manualPagination` imposes a contract on the rest of the props (see its docblock): every
583
+ * client-side control it would turn into a false claim about the unseen rows must be handed to the
584
+ * server or switched off, and an unresolved one throws in dev rather than misreporting.
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: setColumnFilters,
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 showToolbar =
492
- enableGlobalFilter || Boolean(facets && facets.length > 0) || Boolean(toolbarActions)
774
+ const showSearch = enableGlobalFilter && filteringEnabled
775
+ const showFacets = hasFacets && filteringEnabled
776
+ const showToolbar = showSearch || showFacets || Boolean(toolbarActions)
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
- withTableBorder
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 = enableSorting && header.column.getCanSort()
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
- ) : data.length === 0 ? (
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
- table.getRowModel().rows.map((row) => {
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' ? NUMERIC_CELL_STYLE : undefined),
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
- {enableGlobalFilter && (
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
- {facets?.map((facet) => {
627
- const column = table.getColumn(facet.columnId)
628
- if (!column) return null
629
- if (facet.multiple) {
630
- const value = (column.getFilterValue() as string[] | undefined) ?? []
929
+ {showFacets &&
930
+ facets?.map((facet) => {
931
+ const column = table.getColumn(facet.columnId)
932
+ if (!column) return null
933
+ if (facet.multiple) {
934
+ const value = (column.getFilterValue() as string[] | undefined) ?? []
935
+ return (
936
+ <MultiSelect
937
+ key={facet.columnId}
938
+ size="xs"
939
+ radius="md"
940
+ placeholder={facet.label}
941
+ data={facet.options}
942
+ value={value}
943
+ onChange={(next) => column.setFilterValue(next.length > 0 ? next : undefined)}
944
+ clearable
945
+ w={200}
946
+ />
947
+ )
948
+ }
949
+ const value = (column.getFilterValue() as string | undefined) ?? null
631
950
  return (
632
- <MultiSelect
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.length > 0 ? next : undefined)}
958
+ onChange={(next) => column.setFilterValue(next ?? undefined)}
640
959
  clearable
641
- w={200}
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
- {enablePinning ? <Box style={{ overflowX: 'auto' }}>{tableNode}</Box> : tableNode}
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} of {total}
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 { BasaltDataTableProps, DataTableFacet, DataTableFacetOption } from './data-table'
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'