basalt-ui 1.23.0 → 1.24.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 (81) hide show
  1. package/AGENTS.md +31 -17
  2. package/CHANGELOG.md +23 -0
  3. package/MIGRATING.md +222 -6
  4. package/README.md +159 -49
  5. package/agent/rules/basalt-app.md +8 -2
  6. package/agent/rules/basalt-charts.md +38 -2
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-data.md +45 -13
  9. package/agent/rules/basalt-mantine.md +4 -4
  10. package/agent/rules/basalt-query.md +57 -0
  11. package/agent/rules/basalt-tokens.md +18 -5
  12. package/agent/skills/basalt-app/SKILL.md +32 -12
  13. package/agent/skills/basalt-charts/SKILL.md +4 -2
  14. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  15. package/dist/charts/kinds/BandStrip.d.ts +20 -6
  16. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  17. package/dist/charts/kinds/BandStrip.js +59 -6
  18. package/dist/charts/kinds/MirroredBars.d.ts +4 -2
  19. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  20. package/dist/charts/kinds/MirroredBars.js +13 -0
  21. package/dist/cli/index.d.ts +4 -2
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +269 -27
  24. package/dist/dashboard/empty-state.d.ts +6 -2
  25. package/dist/dashboard/empty-state.d.ts.map +1 -1
  26. package/dist/dashboard/empty-state.js +1 -1
  27. package/dist/dashboard/index.d.ts +1 -0
  28. package/dist/dashboard/index.d.ts.map +1 -1
  29. package/dist/dashboard/index.js +8 -0
  30. package/dist/dashboard/query-state.d.ts +96 -0
  31. package/dist/dashboard/query-state.d.ts.map +1 -0
  32. package/dist/dashboard/query-state.js +123 -0
  33. package/dist/dashboard/query-state.test.d.ts +2 -0
  34. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  35. package/dist/data/data-table.d.ts +61 -4
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +41 -4
  38. package/dist/data/data-table.test.d.ts +2 -0
  39. package/dist/data/data-table.test.d.ts.map +1 -0
  40. package/dist/data/table.d.ts +1 -1
  41. package/dist/data/table.d.ts.map +1 -1
  42. package/dist/guard/index.d.ts.map +1 -1
  43. package/dist/guard/index.js +50 -9
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +7 -1
  47. package/dist/query/error-message.d.ts +41 -0
  48. package/dist/query/error-message.d.ts.map +1 -0
  49. package/dist/query/error-message.js +30 -0
  50. package/dist/query/error-message.test.d.ts +2 -0
  51. package/dist/query/error-message.test.d.ts.map +1 -0
  52. package/dist/query/index.d.ts +1 -19
  53. package/dist/query/index.d.ts.map +1 -1
  54. package/dist/query/index.js +2 -14
  55. package/dist/surfaces.d.ts +1 -1
  56. package/dist/surfaces.js +1 -1
  57. package/llms.txt +3 -3
  58. package/package.json +1 -1
  59. package/src/charts/kinds/BandStrip.test.tsx +87 -5
  60. package/src/charts/kinds/BandStrip.tsx +105 -12
  61. package/src/charts/kinds/MirroredBars.test.tsx +39 -0
  62. package/src/charts/kinds/MirroredBars.tsx +38 -2
  63. package/src/cli/check-theme.test.ts +34 -0
  64. package/src/cli/help.test.ts +74 -1
  65. package/src/cli/index.ts +496 -41
  66. package/src/cli/tokens-css.test.ts +129 -0
  67. package/src/cli/toolchain-wiring.test.ts +146 -0
  68. package/src/dashboard/empty-state.tsx +9 -3
  69. package/src/dashboard/index.ts +11 -0
  70. package/src/dashboard/query-state.test.tsx +152 -0
  71. package/src/dashboard/query-state.tsx +272 -0
  72. package/src/data/data-table.test.tsx +158 -0
  73. package/src/data/data-table.tsx +123 -5
  74. package/src/data/table.ts +6 -1
  75. package/src/guard/check-source.test.ts +214 -0
  76. package/src/guard/index.ts +169 -15
  77. package/src/index.ts +9 -0
  78. package/src/query/error-message.test.ts +67 -0
  79. package/src/query/error-message.ts +70 -0
  80. package/src/query/index.ts +2 -34
  81. package/src/surfaces.ts +1 -1
@@ -0,0 +1,272 @@
1
+ /**
2
+ * QueryState — the one wrapper a page puts around an async result, and the sibling `EmptyState`
3
+ * shipped without.
4
+ *
5
+ * The argument is correctness, not convenience. A framework that ships only the EMPTY branch
6
+ * steers every consumer into rendering "nothing here" for "the server failed": image-share's
7
+ * library rendered *No images* on a 500, and a share detail rendered *Share not found — it may
8
+ * have been deleted* on a dropped connection, until 204 hand-rolled lines were written to stop it.
9
+ * basalt already owned both ends of that file — `EmptyState` and `toErrorMessage` — and nothing in
10
+ * between.
11
+ *
12
+ * Component, not a hook: the product IS the branch precedence below. A hook returning
13
+ * `{ status, data }` would hand every call site the same four-way switch back, which is exactly
14
+ * the re-derivation this exists to delete. `LoadingState` / `ErrorState` ship alongside as the
15
+ * escape hatch for a page that must place its branches in different DOM positions.
16
+ */
17
+ import { Alert, Button, Center, Group, Loader, Stack, Text } from '@mantine/core'
18
+ import type { ReactNode } from 'react'
19
+ import { toErrorMessage } from '../query/error-message'
20
+ import { EmptyState } from './empty-state'
21
+
22
+ /** `'page'` = a full route body (generous padding). `'section'` = a card/panel region (compact). */
23
+ export type QueryStateVariant = 'page' | 'section'
24
+
25
+ /**
26
+ * The structural subset of a TanStack `UseQueryResult` these components read.
27
+ *
28
+ * Typed as a SUBSET rather than as `UseQueryResult<T>` on purpose: a composed, derived or
29
+ * hand-rolled result must be passable without a cast (3 of image-share's 10 call sites are), and
30
+ * these components stay independent of which `@tanstack/react-query` major a consumer pins. This
31
+ * is NOT a claim that the root barrel is free of that package — `./connectivity` imports
32
+ * `onlineManager` as a value and is re-exported from `src/index.ts`. The cost of the subset is
33
+ * that the compiler stops policing the shape — which is why {@link QueryState} asserts it at
34
+ * runtime.
35
+ */
36
+ export type QueryStateLike<TData> = {
37
+ data: TData | undefined
38
+ isError: boolean
39
+ error: unknown
40
+ fetchStatus: 'fetching' | 'paused' | 'idle'
41
+ refetch: () => unknown
42
+ }
43
+
44
+ /** Copy for the empty branch. Rendered through `EmptyState`, so it looks like every other one. */
45
+ export type QueryEmptyCopy = {
46
+ title: string
47
+ /** Optional — a one-word empty panel should not have to invent a sentence. */
48
+ description?: string
49
+ /** Optional glyph slot, rendered faint. */
50
+ icon?: ReactNode
51
+ /** Call-to-action below the copy (e.g. "Reset filters"). */
52
+ action?: ReactNode
53
+ }
54
+
55
+ // ── LoadingState ──────────────────────────────────────────────────────────────────────────────────
56
+
57
+ export type LoadingStateProps = {
58
+ variant?: QueryStateVariant
59
+ /** Accessible name for the spinner. Say what is loading. */
60
+ label?: string
61
+ }
62
+
63
+ /** A spinner on its own — `variant="page"` centres it in a generous block, `'section'` is bare. */
64
+ export function LoadingState({
65
+ variant = 'page',
66
+ label = 'Loading',
67
+ }: LoadingStateProps): ReactNode {
68
+ const loader = <Loader size="sm" aria-label={label} />
69
+ if (variant === 'section') return loader
70
+ return <Center py={64}>{loader}</Center>
71
+ }
72
+
73
+ // ── ErrorState ────────────────────────────────────────────────────────────────────────────────────
74
+
75
+ export type ErrorStateProps = {
76
+ /** Whatever was thrown — the raw Eden/fetch envelope is fine, it gets decoded. */
77
+ error: unknown
78
+ /** Alert heading. Say what failed, e.g. `Could not load images`. */
79
+ title?: string
80
+ /** Shown only when the server body carries no readable message. */
81
+ fallback?: string
82
+ onRetry?: () => void
83
+ /** Disables/spins the retry button while a refetch is in flight. */
84
+ retrying?: boolean
85
+ variant?: QueryStateVariant
86
+ /** Extra controls beside Retry (e.g. a "Back to shares" link). */
87
+ action?: ReactNode
88
+ }
89
+
90
+ /**
91
+ * The error branch: the REAL server message (via `toErrorMessage`) plus a retry. Usable on its own,
92
+ * outside a query — an auth gate or a boundary fallback renders it directly.
93
+ */
94
+ export function ErrorState({
95
+ error,
96
+ title = 'Something went wrong',
97
+ fallback = 'The request failed.',
98
+ onRetry,
99
+ retrying = false,
100
+ variant = 'page',
101
+ action,
102
+ }: ErrorStateProps): ReactNode {
103
+ const alert = (
104
+ <Alert color="red" variant="light" title={title}>
105
+ <Stack gap="sm">
106
+ <Text size="sm">{toErrorMessage(error, fallback)}</Text>
107
+ {(onRetry ?? action) !== undefined && (
108
+ <Group gap="xs">
109
+ {onRetry && (
110
+ <Button size="xs" variant="default" loading={retrying} onClick={onRetry}>
111
+ Retry
112
+ </Button>
113
+ )}
114
+ {action}
115
+ </Group>
116
+ )}
117
+ </Stack>
118
+ </Alert>
119
+ )
120
+ if (variant === 'section') return alert
121
+ return <Stack py="md">{alert}</Stack>
122
+ }
123
+
124
+ // ── QueryState ────────────────────────────────────────────────────────────────────────────────────
125
+
126
+ /** `[]`, `{ data: [], total }` (the common pagination envelope) and null all count as empty. */
127
+ function defaultIsEmpty(data: unknown): boolean {
128
+ if (data === null || data === undefined) return true
129
+ if (Array.isArray(data)) return data.length === 0
130
+ if (typeof data === 'object' && Array.isArray((data as { data?: unknown }).data)) {
131
+ return (data as { data: unknown[] }).data.length === 0
132
+ }
133
+ return false
134
+ }
135
+
136
+ const FETCH_STATUSES = new Set(['fetching', 'paused', 'idle'])
137
+
138
+ /**
139
+ * `QueryStateLike` is a structural subset, so TypeScript cannot protect a hand-composed result the
140
+ * way it protects a real `UseQueryResult` — and every field it drops fails SILENTLY in the exact
141
+ * direction this component exists to prevent. A missing `isError` renders "no data" over a 500. A
142
+ * misspelled `fetchStatus` never leaves the spinner. A missing `refetch` renders a Retry button
143
+ * that does nothing. Each of those is a false claim about the data, so it throws.
144
+ */
145
+ function assertQueryStateLike(query: unknown): asserts query is QueryStateLike<unknown> {
146
+ const bad = (detail: string): never => {
147
+ throw new Error(
148
+ `QueryState: \`query\` ${detail}. It must carry { data, isError, error, fetchStatus, refetch } — ` +
149
+ 'a TanStack UseQueryResult does; a hand-composed one must spell every field, because a ' +
150
+ 'missing branch flag renders a false claim about the data instead of an error.',
151
+ )
152
+ }
153
+ if (query === null || typeof query !== 'object')
154
+ bad(`is ${query === null ? 'null' : typeof query}`)
155
+ const q = query as Record<string, unknown>
156
+ if (!('data' in q)) bad('has no `data` key')
157
+ if (typeof q['isError'] !== 'boolean') bad('has no boolean `isError`')
158
+ if (typeof q['fetchStatus'] !== 'string' || !FETCH_STATUSES.has(q['fetchStatus']))
159
+ bad(`has fetchStatus=${JSON.stringify(q['fetchStatus'])}, not 'fetching' | 'paused' | 'idle'`)
160
+ if (typeof q['refetch'] !== 'function') bad('has no `refetch()`')
161
+ }
162
+
163
+ export type QueryStateProps<TData> = {
164
+ query: QueryStateLike<TData>
165
+ /** A render function gets `data` narrowed to non-undefined; a plain node also works. */
166
+ children: ReactNode | ((data: TData) => ReactNode)
167
+ /** Omit to render nothing when the result is empty. */
168
+ empty?: QueryEmptyCopy
169
+ /** Override the default `[]` / `{ data: [] }` emptiness test — `() => false` disables it. */
170
+ isEmpty?: (data: TData) => boolean
171
+ /** Alert heading on the error branch — say what failed. */
172
+ errorTitle?: string
173
+ /** Shown only when the server body carries no readable message. */
174
+ errorFallback?: string
175
+ /** Extra controls beside Retry on the error branch. */
176
+ errorAction?: ReactNode
177
+ variant?: QueryStateVariant
178
+ /** Replace the default spinner (e.g. with a skeleton grid). */
179
+ loading?: ReactNode
180
+ }
181
+
182
+ /**
183
+ * Renders loading / error-with-retry / empty / children for a query result, in that order.
184
+ * Branch precedence is the whole point, and it is deliberate:
185
+ *
186
+ * - error AND no cached data → the full error state (the page cannot render)
187
+ * - error WITH cached data → children, plus a compact "showing cached data" banner on top
188
+ * (a background refetch failing must not blank a page that already works)
189
+ * - no data, fetch idle → empty (this is an `enabled: false` query, not a pending one)
190
+ * - no data, fetching → `loading` (or the default spinner)
191
+ *
192
+ * @example
193
+ * <QueryState
194
+ * query={q}
195
+ * errorTitle="Could not load images"
196
+ * empty={{ title: 'No images match', description: 'Widen the capture dates.' }}
197
+ * >
198
+ * {(data) => <ImageGrid images={data.rows} />}
199
+ * </QueryState>
200
+ */
201
+ export function QueryState<TData>({
202
+ query,
203
+ children,
204
+ empty,
205
+ isEmpty,
206
+ errorTitle = 'Could not load',
207
+ errorFallback = 'The request failed.',
208
+ errorAction,
209
+ variant = 'page',
210
+ loading,
211
+ }: QueryStateProps<TData>): ReactNode {
212
+ assertQueryStateLike(query)
213
+ const { data, isError, error, fetchStatus } = query
214
+ const retrying = fetchStatus === 'fetching'
215
+ const retry = (): void => void query.refetch()
216
+
217
+ if (isError && data === undefined) {
218
+ return (
219
+ <ErrorState
220
+ error={error}
221
+ title={errorTitle}
222
+ fallback={errorFallback}
223
+ onRetry={retry}
224
+ retrying={retrying}
225
+ variant={variant}
226
+ {...(errorAction !== undefined && { action: errorAction })}
227
+ />
228
+ )
229
+ }
230
+
231
+ if (data === undefined) {
232
+ if (fetchStatus === 'idle' && !isError) return empty ? renderEmpty(empty, variant) : null
233
+ return loading ?? <LoadingState variant={variant} />
234
+ }
235
+
236
+ const emptyNow = isEmpty ? isEmpty(data) : defaultIsEmpty(data)
237
+ const body = emptyNow
238
+ ? empty
239
+ ? renderEmpty(empty, variant)
240
+ : null
241
+ : typeof children === 'function'
242
+ ? children(data)
243
+ : children
244
+
245
+ if (!isError) return body
246
+
247
+ return (
248
+ <Stack gap="sm">
249
+ <ErrorState
250
+ error={error}
251
+ title="Showing cached data"
252
+ fallback="The last refresh failed."
253
+ onRetry={retry}
254
+ retrying={retrying}
255
+ variant="section"
256
+ />
257
+ {body}
258
+ </Stack>
259
+ )
260
+ }
261
+
262
+ function renderEmpty(empty: QueryEmptyCopy, variant: QueryStateVariant): ReactNode {
263
+ return (
264
+ <EmptyState
265
+ title={empty.title}
266
+ variant={variant}
267
+ {...(empty.description !== undefined && { description: empty.description })}
268
+ {...(empty.icon !== undefined && { icon: empty.icon })}
269
+ {...(empty.action !== undefined && { action: empty.action })}
270
+ />
271
+ )
272
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The three body-chrome props argo's round-6/7 feedback kept 341 hand-rolled lines for, asserted
3
+ * against real markup rather than against props:
4
+ *
5
+ * 1. `maxHeight` renders Mantine's NATIVE scroll container — the same node the docs sanction as
6
+ * the raw escape. `type="scrollarea"` would silently break a sticky header (ScrollArea's
7
+ * viewport becomes the positioning context), so the emitted `--table-max-height` var and the
8
+ * absence of a ScrollArea root are both asserted.
9
+ * 2. `stickyHeader` reaches the `<table>`.
10
+ * 3. `meta.align` reaches BOTH the `<th>` and the `<td>` — a right-aligned header over
11
+ * left-aligned money is the defect this replaces.
12
+ * 4. A misspelled alignment VALUE throws. The key is a compile error
13
+ * (`apps/playground/src/data-table-align.type-guard.ts`); the value can only be caught here,
14
+ * and a silent fallback to left-aligned would look correct in review.
15
+ */
16
+ import { MantineProvider } from '@mantine/core'
17
+ import { render, screen } from '@testing-library/react'
18
+ import { describe, expect, test } from 'bun:test'
19
+ import { BasaltDataTable } from './data-table'
20
+ import { createColumnHelper } from './table'
21
+ import type { BasaltDataTableProps } from './data-table'
22
+
23
+ type Row = { project: string; cost: number }
24
+ const col = createColumnHelper<Row>()
25
+
26
+ const ROWS: Row[] = [
27
+ { project: 'argo', cost: 12 },
28
+ { project: 'linewatch', cost: 3 },
29
+ ]
30
+
31
+ const COLUMNS = [
32
+ col.accessor('project', { header: 'Project' }),
33
+ col.accessor('cost', { header: 'Cost', meta: { align: 'right' } }),
34
+ ]
35
+
36
+ function renderTable(props: Partial<BasaltDataTableProps<Row>> = {}) {
37
+ return render(
38
+ <MantineProvider>
39
+ <BasaltDataTable data={ROWS} columns={COLUMNS} {...props} />
40
+ </MantineProvider>,
41
+ )
42
+ }
43
+
44
+ describe('maxHeight — the capped body', () => {
45
+ test('no maxHeight renders no scroll container at all', () => {
46
+ const { container } = renderTable()
47
+ expect(container.querySelector('.mantine-TableScrollContainer-scrollContainer')).toBeNull()
48
+ })
49
+
50
+ test('maxHeight renders a native scroll container carrying the cap', () => {
51
+ const { container } = renderTable({ maxHeight: 480 })
52
+ const scroller = container.querySelector('.mantine-TableScrollContainer-scrollContainer')
53
+ if (!scroller) throw new Error('expected a Table.ScrollContainer')
54
+ // Mantine converts px → rem against --mantine-scale; 480 / 16 = 30rem.
55
+ expect(scroller.getAttribute('style') ?? '').toContain('--table-max-height: calc(30rem')
56
+ // type="native" — a ScrollArea viewport would be the wrong positioning context for a
57
+ // sticky <thead>, which is exactly why argo's hand-rolled version spelled the type out.
58
+ expect(container.querySelector('.mantine-ScrollArea-root')).toBeNull()
59
+ })
60
+
61
+ test('minWidth alone also opens the scroll container', () => {
62
+ const { container } = renderTable({ minWidth: 640 })
63
+ const scroller = container.querySelector('.mantine-TableScrollContainer-scrollContainer')
64
+ if (!scroller) throw new Error('expected a Table.ScrollContainer')
65
+ expect(scroller.getAttribute('style') ?? '').toContain('--table-min-width: calc(40rem')
66
+ })
67
+ })
68
+
69
+ describe('stickyHeader and table chrome', () => {
70
+ test('stickyHeader reaches the table element', () => {
71
+ const { container } = renderTable({ maxHeight: 480, stickyHeader: true })
72
+ expect(container.querySelector('thead')?.getAttribute('data-sticky')).toBe('true')
73
+ expect(renderTable().container.querySelector('thead')?.getAttribute('data-sticky')).toBeNull()
74
+ })
75
+
76
+ test('withTableBorder defaults on and can be turned off for a table inside a card', () => {
77
+ expect(
78
+ renderTable().container.querySelector('table')?.getAttribute('data-with-table-border'),
79
+ ).toBe('true')
80
+ expect(
81
+ renderTable({ withTableBorder: false })
82
+ .container.querySelector('table')
83
+ ?.getAttribute('data-with-table-border'),
84
+ ).toBeNull()
85
+ })
86
+ })
87
+
88
+ describe('per-column alignment', () => {
89
+ test('meta.align lands on the header AND every cell of that column', () => {
90
+ const { container } = renderTable()
91
+ const headers = [...container.querySelectorAll('th')]
92
+ const costHeader = headers.find((th) => th.textContent?.includes('Cost'))
93
+ expect(costHeader?.getAttribute('style') ?? '').toContain('text-align: right')
94
+
95
+ const costCells = [...container.querySelectorAll('tbody tr')].map((tr) => tr.children[1])
96
+ expect(costCells).toHaveLength(2)
97
+ for (const cell of costCells) {
98
+ expect(cell?.getAttribute('style') ?? '').toContain('text-align: right')
99
+ }
100
+ })
101
+
102
+ test('an unaligned column stays unstyled — the prop is opt-in, not a default', () => {
103
+ const { container } = renderTable()
104
+ const projectCell = container.querySelector('tbody tr')?.children[0]
105
+ expect(projectCell?.getAttribute('style') ?? '').not.toContain('text-align')
106
+ })
107
+
108
+ test('a misspelled alignment value throws rather than quietly left-aligning', () => {
109
+ const bad = [
110
+ col.accessor('project', { header: 'Project' }),
111
+ // The key is compile-checked; the VALUE can only fail here.
112
+ col.accessor('cost', { header: 'Cost', meta: { align: 'end' as 'right' } }),
113
+ ]
114
+ expect(() =>
115
+ render(
116
+ <MantineProvider>
117
+ <BasaltDataTable data={ROWS} columns={bad} />
118
+ </MantineProvider>,
119
+ ),
120
+ ).toThrow(/meta\.align="end"/)
121
+ })
122
+ })
123
+
124
+ describe('the auto mono-numeral style has an opt-out', () => {
125
+ const styled = [
126
+ col.accessor('project', { header: 'Project' }),
127
+ col.accessor('cost', { header: 'Cost' }),
128
+ ]
129
+ const opted = [
130
+ col.accessor('project', { header: 'Project' }),
131
+ col.accessor('cost', { header: 'Cost', meta: { numeral: false } }),
132
+ ]
133
+
134
+ const costStyle = (columns: typeof styled) =>
135
+ render(
136
+ <MantineProvider>
137
+ <BasaltDataTable data={ROWS} columns={columns} />
138
+ </MantineProvider>,
139
+ )
140
+ .container.querySelector('tbody tr')
141
+ ?.children[1]?.getAttribute('style') ?? ''
142
+
143
+ test('a numeric cell is mono by default', () => {
144
+ expect(costStyle(styled)).toContain('font-family: var(--mantine-font-family-monospace)')
145
+ })
146
+
147
+ test('meta.numeral=false leaves a bespoke cell renderer in charge of its own font', () => {
148
+ expect(costStyle(opted)).not.toContain('font-family')
149
+ })
150
+ })
151
+
152
+ describe('regression — the bare table is unchanged', () => {
153
+ test('renders rows with no new chrome', () => {
154
+ renderTable()
155
+ expect(screen.getByText('argo')).toBeTruthy()
156
+ expect(screen.getByText('linewatch')).toBeTruthy()
157
+ })
158
+ })
@@ -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. */
@@ -129,8 +175,8 @@ export type BasaltDataTableProps<T> = {
129
175
  * @default true
130
176
  */
131
177
  enableSorting?: boolean
132
- /** Stripe alternate rows. Forwarded to Mantine `Table`. */
133
- striped?: boolean
178
+ /** Stripe alternate rows. Forwarded to Mantine `Table` — `'odd'`/`'even'` pick the phase. */
179
+ striped?: boolean | 'odd' | 'even'
134
180
  /** Highlight hovered rows. Forwarded to Mantine `Table`. */
135
181
  highlightOnHover?: boolean
136
182
  /** Rendered when `data` is empty. Falls back to a simple message when omitted. */
@@ -257,6 +303,42 @@ export type BasaltDataTableProps<T> = {
257
303
  enablePinning?: boolean
258
304
  /** Initial column-pinning state — which column ids stick to the left/right edge. */
259
305
  initialColumnPinning?: ColumnPinningState
306
+
307
+ // ── Body chrome ─────────────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * Caps the scrolling body (px number or CSS length) so a long table cannot blow a card out
311
+ * vertically — the header stays put and the rows scroll under it. Renders Mantine's
312
+ * `Table.ScrollContainer type="native"`, which is the same node the docs sanction as the raw
313
+ * escape for a bespoke table: the blessed lane and the escape hatch produce identical DOM, so
314
+ * adopting one does not contradict the other.
315
+ *
316
+ * `type="native"` is required rather than preferred: `ScrollArea`'s custom viewport is the
317
+ * positioning context a sticky `<thead>` resolves against, so the default `'scrollarea'` type
318
+ * pins the header to the page viewport instead of the table's box.
319
+ */
320
+ maxHeight?: number | string
321
+ /**
322
+ * `min-width` below which the body scrolls horizontally. Setting either this or `maxHeight`
323
+ * turns on the scroll container; `maxHeight` alone implies `minWidth: 0`.
324
+ */
325
+ minWidth?: number | string
326
+ /** Sticky header row. Pair with `maxHeight` to stick within the table rather than the page. */
327
+ stickyHeader?: boolean
328
+ /** Offset for a sticky header sitting under a fixed app header (px number or CSS length). */
329
+ stickyHeaderOffset?: number | string
330
+ /** Vertical cell padding. Forwarded to Mantine `Table`. */
331
+ verticalSpacing?: MantineSpacing
332
+ /** Horizontal cell padding. Forwarded to Mantine `Table`. */
333
+ horizontalSpacing?: MantineSpacing
334
+ /** Row separators. Forwarded to Mantine `Table`. @default true (Mantine's own default) */
335
+ withRowBorders?: boolean
336
+ /**
337
+ * Outer table border. Turn it OFF for a table that already sits inside a `Card`/`ChartCard`,
338
+ * where the card owns the frame and a second one reads as a nested box.
339
+ * @default true
340
+ */
341
+ withTableBorder?: boolean
260
342
  }
261
343
 
262
344
  // ── Sort indicator ────────────────────────────────────────────────────────────
@@ -390,6 +472,14 @@ export function BasaltDataTable<T>({
390
472
  pageCount,
391
473
  enablePinning = false,
392
474
  initialColumnPinning,
475
+ maxHeight,
476
+ minWidth,
477
+ stickyHeader,
478
+ stickyHeaderOffset,
479
+ verticalSpacing,
480
+ horizontalSpacing,
481
+ withRowBorders,
482
+ withTableBorder = true,
393
483
  }: BasaltDataTableProps<T>) {
394
484
  const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
395
485
  const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter ?? '')
@@ -496,7 +586,12 @@ export function BasaltDataTable<T>({
496
586
  <Table
497
587
  {...(striped !== undefined && { striped })}
498
588
  {...(highlightOnHover !== undefined && { highlightOnHover })}
499
- withTableBorder
589
+ {...(stickyHeader !== undefined && { stickyHeader })}
590
+ {...(stickyHeaderOffset !== undefined && { stickyHeaderOffset })}
591
+ {...(verticalSpacing !== undefined && { verticalSpacing })}
592
+ {...(horizontalSpacing !== undefined && { horizontalSpacing })}
593
+ {...(withRowBorders !== undefined && { withRowBorders })}
594
+ withTableBorder={withTableBorder}
500
595
  >
501
596
  <Table.Thead>
502
597
  {headerGroups.map((headerGroup) => (
@@ -508,6 +603,7 @@ export function BasaltDataTable<T>({
508
603
  const pinnedStyle = enablePinning
509
604
  ? getPinnedCellStyle(table, header.column)
510
605
  : undefined
606
+ const align = resolveAlign(header.column)
511
607
  return (
512
608
  <Table.Th
513
609
  key={header.id}
@@ -533,6 +629,7 @@ export function BasaltDataTable<T>({
533
629
  }
534
630
  style={{
535
631
  ...(canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined),
632
+ ...(align !== undefined && { textAlign: align }),
536
633
  ...pinnedStyle,
537
634
  }}
538
635
  >
@@ -582,11 +679,16 @@ export function BasaltDataTable<T>({
582
679
  const pinnedStyle = enablePinning
583
680
  ? getPinnedCellStyle(table, cell.column)
584
681
  : undefined
682
+ const align = resolveAlign(cell.column)
585
683
  return (
586
684
  <Table.Td
587
685
  key={cell.id}
588
686
  style={{
589
- ...(typeof cell.getValue() === 'number' ? NUMERIC_CELL_STYLE : undefined),
687
+ ...(typeof cell.getValue() === 'number' &&
688
+ cell.column.columnDef.meta?.numeral !== false
689
+ ? NUMERIC_CELL_STYLE
690
+ : undefined),
691
+ ...(align !== undefined && { textAlign: align }),
590
692
  ...pinnedStyle,
591
693
  }}
592
694
  >
@@ -602,6 +704,10 @@ export function BasaltDataTable<T>({
602
704
  </Table>
603
705
  )
604
706
 
707
+ // A capped body or a horizontal floor both need the same native scroll node; pinning keeps its
708
+ // simpler overflow-x Box when neither is set.
709
+ const scrolls = maxHeight !== undefined || minWidth !== undefined
710
+
605
711
  const paginationState = table.getState().pagination
606
712
  const total = table.getRowCount()
607
713
  const rangeStart = total === 0 ? 0 : paginationState.pageIndex * paginationState.pageSize + 1
@@ -661,7 +767,19 @@ export function BasaltDataTable<T>({
661
767
  {toolbarActions}
662
768
  </Group>
663
769
  )}
664
- {enablePinning ? <Box style={{ overflowX: 'auto' }}>{tableNode}</Box> : tableNode}
770
+ {scrolls ? (
771
+ <Table.ScrollContainer
772
+ type="native"
773
+ minWidth={minWidth ?? 0}
774
+ {...(maxHeight !== undefined && { maxHeight })}
775
+ >
776
+ {tableNode}
777
+ </Table.ScrollContainer>
778
+ ) : enablePinning ? (
779
+ <Box style={{ overflowX: 'auto' }}>{tableNode}</Box>
780
+ ) : (
781
+ tableNode
782
+ )}
665
783
  {enablePagination && (
666
784
  <Group justify="space-between" mt="xs" wrap="wrap" gap="xs" align="center">
667
785
  <Text style={RANGE_LABEL_STYLE}>
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;