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.
- package/AGENTS.md +31 -17
- package/CHANGELOG.md +23 -0
- package/MIGRATING.md +222 -6
- package/README.md +159 -49
- package/agent/rules/basalt-app.md +8 -2
- package/agent/rules/basalt-charts.md +38 -2
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-data.md +45 -13
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-query.md +57 -0
- package/agent/rules/basalt-tokens.md +18 -5
- package/agent/skills/basalt-app/SKILL.md +32 -12
- package/agent/skills/basalt-charts/SKILL.md +4 -2
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/dist/charts/kinds/BandStrip.d.ts +20 -6
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +59 -6
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +13 -0
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +269 -27
- 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 +61 -4
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +41 -4
- 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 +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +50 -9
- 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/charts/kinds/BandStrip.test.tsx +87 -5
- package/src/charts/kinds/BandStrip.tsx +105 -12
- package/src/charts/kinds/MirroredBars.test.tsx +39 -0
- package/src/charts/kinds/MirroredBars.tsx +38 -2
- package/src/cli/check-theme.test.ts +34 -0
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +496 -41
- package/src/cli/tokens-css.test.ts +129 -0
- 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 +158 -0
- package/src/data/data-table.tsx +123 -5
- package/src/data/table.ts +6 -1
- package/src/guard/check-source.test.ts +214 -0
- package/src/guard/index.ts +169 -15
- 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
|
@@ -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
|
+
})
|
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. */
|
|
@@ -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
|
-
|
|
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'
|
|
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
|
-
{
|
|
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 {
|
|
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;
|