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
@@ -0,0 +1,349 @@
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, Text } from '@mantine/core'
17
+ import { fireEvent, render, screen, waitFor } 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
+ import type { ColumnFiltersState } from './table'
23
+
24
+ type Row = { project: string; cost: number }
25
+ const col = createColumnHelper<Row>()
26
+
27
+ const ROWS: Row[] = [
28
+ { project: 'argo', cost: 12 },
29
+ { project: 'linewatch', cost: 3 },
30
+ ]
31
+
32
+ const COLUMNS = [
33
+ col.accessor('project', { header: 'Project' }),
34
+ col.accessor('cost', { header: 'Cost', meta: { align: 'right' } }),
35
+ ]
36
+
37
+ function renderTable(props: Partial<BasaltDataTableProps<Row>> = {}) {
38
+ return render(
39
+ <MantineProvider>
40
+ <BasaltDataTable data={ROWS} columns={COLUMNS} {...props} />
41
+ </MantineProvider>,
42
+ )
43
+ }
44
+
45
+ describe('maxHeight — the capped body', () => {
46
+ test('no maxHeight renders no scroll container at all', () => {
47
+ const { container } = renderTable()
48
+ expect(container.querySelector('.mantine-TableScrollContainer-scrollContainer')).toBeNull()
49
+ })
50
+
51
+ test('maxHeight renders a native scroll container carrying the cap', () => {
52
+ const { container } = renderTable({ maxHeight: 480 })
53
+ const scroller = container.querySelector('.mantine-TableScrollContainer-scrollContainer')
54
+ if (!scroller) throw new Error('expected a Table.ScrollContainer')
55
+ // Mantine converts px → rem against --mantine-scale; 480 / 16 = 30rem.
56
+ expect(scroller.getAttribute('style') ?? '').toContain('--table-max-height: calc(30rem')
57
+ // type="native" — a ScrollArea viewport would be the wrong positioning context for a
58
+ // sticky <thead>, which is exactly why argo's hand-rolled version spelled the type out.
59
+ expect(container.querySelector('.mantine-ScrollArea-root')).toBeNull()
60
+ })
61
+
62
+ test('minWidth alone also opens the scroll container', () => {
63
+ const { container } = renderTable({ minWidth: 640 })
64
+ const scroller = container.querySelector('.mantine-TableScrollContainer-scrollContainer')
65
+ if (!scroller) throw new Error('expected a Table.ScrollContainer')
66
+ expect(scroller.getAttribute('style') ?? '').toContain('--table-min-width: calc(40rem')
67
+ })
68
+ })
69
+
70
+ describe('stickyHeader and table chrome', () => {
71
+ test('stickyHeader reaches the table element', () => {
72
+ const { container } = renderTable({ maxHeight: 480, stickyHeader: true })
73
+ expect(container.querySelector('thead')?.getAttribute('data-sticky')).toBe('true')
74
+ expect(renderTable().container.querySelector('thead')?.getAttribute('data-sticky')).toBeNull()
75
+ })
76
+
77
+ test('withTableBorder defaults on and can be turned off for a table inside a card', () => {
78
+ expect(
79
+ renderTable().container.querySelector('table')?.getAttribute('data-with-table-border'),
80
+ ).toBe('true')
81
+ expect(
82
+ renderTable({ withTableBorder: false })
83
+ .container.querySelector('table')
84
+ ?.getAttribute('data-with-table-border'),
85
+ ).toBeNull()
86
+ })
87
+ })
88
+
89
+ describe('per-column alignment', () => {
90
+ test('meta.align lands on the header AND every cell of that column', () => {
91
+ const { container } = renderTable()
92
+ const headers = [...container.querySelectorAll('th')]
93
+ const costHeader = headers.find((th) => th.textContent?.includes('Cost'))
94
+ expect(costHeader?.getAttribute('style') ?? '').toContain('text-align: right')
95
+
96
+ const costCells = [...container.querySelectorAll('tbody tr')].map((tr) => tr.children[1])
97
+ expect(costCells).toHaveLength(2)
98
+ for (const cell of costCells) {
99
+ expect(cell?.getAttribute('style') ?? '').toContain('text-align: right')
100
+ }
101
+ })
102
+
103
+ test('an unaligned column stays unstyled — the prop is opt-in, not a default', () => {
104
+ const { container } = renderTable()
105
+ const projectCell = container.querySelector('tbody tr')?.children[0]
106
+ expect(projectCell?.getAttribute('style') ?? '').not.toContain('text-align')
107
+ })
108
+
109
+ test('a misspelled alignment value throws rather than quietly left-aligning', () => {
110
+ const bad = [
111
+ col.accessor('project', { header: 'Project' }),
112
+ // The key is compile-checked; the VALUE can only fail here.
113
+ col.accessor('cost', { header: 'Cost', meta: { align: 'end' as 'right' } }),
114
+ ]
115
+ expect(() =>
116
+ render(
117
+ <MantineProvider>
118
+ <BasaltDataTable data={ROWS} columns={bad} />
119
+ </MantineProvider>,
120
+ ),
121
+ ).toThrow(/meta\.align="end"/)
122
+ })
123
+ })
124
+
125
+ describe('the auto mono-numeral style has an opt-out', () => {
126
+ const styled = [
127
+ col.accessor('project', { header: 'Project' }),
128
+ col.accessor('cost', { header: 'Cost' }),
129
+ ]
130
+ const opted = [
131
+ col.accessor('project', { header: 'Project' }),
132
+ col.accessor('cost', { header: 'Cost', meta: { numeral: false } }),
133
+ ]
134
+
135
+ const costStyle = (columns: typeof styled) =>
136
+ render(
137
+ <MantineProvider>
138
+ <BasaltDataTable data={ROWS} columns={columns} />
139
+ </MantineProvider>,
140
+ )
141
+ .container.querySelector('tbody tr')
142
+ ?.children[1]?.getAttribute('style') ?? ''
143
+
144
+ test('a numeric cell is mono by default', () => {
145
+ expect(costStyle(styled)).toContain('font-family: var(--mantine-font-family-monospace)')
146
+ })
147
+
148
+ test('meta.numeral=false leaves a bespoke cell renderer in charge of its own font', () => {
149
+ expect(costStyle(opted)).not.toContain('font-family')
150
+ })
151
+ })
152
+
153
+ describe('regression — the bare table is unchanged', () => {
154
+ test('renders rows with no new chrome', () => {
155
+ renderTable()
156
+ expect(screen.getByText('argo')).toBeTruthy()
157
+ expect(screen.getByText('linewatch')).toBeTruthy()
158
+ })
159
+ })
160
+
161
+ /**
162
+ * The manual-pagination contract (argo round 10, P1).
163
+ *
164
+ * `manualPagination` makes `data` one server page while the bar reads "Showing 1–25 of 412", so
165
+ * every remaining client-side control becomes a claim about 412 rows it can only make about 25.
166
+ * These pin the replacement for the old silence: loud where the props contradict each other, and
167
+ * honest — never plausible-but-wrong — where a production bundle has dropped the throw.
168
+ */
169
+
170
+ /** Run `fn` with the production dev-gate, restoring whatever the runner had set. */
171
+ function inProd(fn: () => void): void {
172
+ const previous = process.env['NODE_ENV']
173
+ process.env['NODE_ENV'] = 'production'
174
+ try {
175
+ fn()
176
+ } finally {
177
+ if (previous === undefined) delete process.env['NODE_ENV']
178
+ else process.env['NODE_ENV'] = previous
179
+ }
180
+ }
181
+
182
+ const SERVER_PAGE: Partial<BasaltDataTableProps<Row>> = {
183
+ enablePagination: true,
184
+ manualPagination: true,
185
+ rowCount: 412,
186
+ }
187
+
188
+ describe('manualPagination — the props that would misreport must be resolved', () => {
189
+ test('the bare adoption throws, naming the page-local sort and both ways out', () => {
190
+ expect(() => renderTable(SERVER_PAGE)).toThrow(
191
+ /client-side sorting is still armed[\s\S]*`manualSorting`[\s\S]*`enableSorting=\{false\}`/,
192
+ )
193
+ })
194
+
195
+ test('no rowCount and no pageCount throws — "of N" would count the page', () => {
196
+ expect(() =>
197
+ renderTable({ enablePagination: true, manualPagination: true, manualSorting: true }),
198
+ ).toThrow(/neither `rowCount` nor `pageCount`/)
199
+ })
200
+
201
+ test('manualPagination without enablePagination throws — the prop is inert, not harmless', () => {
202
+ expect(() => renderTable({ manualPagination: true })).toThrow(
203
+ /`enablePagination` is not set, so `manualPagination` never reaches the table/,
204
+ )
205
+ })
206
+
207
+ test('client-side search over one server page throws too — the same defect, one control over', () => {
208
+ expect(() =>
209
+ renderTable({ ...SERVER_PAGE, manualSorting: true, enableGlobalFilter: true }),
210
+ ).toThrow(/the search input \/ facets still filter client-side/)
211
+ })
212
+
213
+ test('facets trip the filtering breach even with no search input', () => {
214
+ expect(() =>
215
+ renderTable({
216
+ ...SERVER_PAGE,
217
+ manualSorting: true,
218
+ facets: [{ columnId: 'project', label: 'Project', options: [] }],
219
+ }),
220
+ ).toThrow(/still filter client-side/)
221
+ })
222
+
223
+ test('every unresolved breach is reported at once, not one per fix-and-rerun', () => {
224
+ expect(() => renderTable({ manualPagination: true, enablePagination: true })).toThrow(
225
+ /neither `rowCount`[\s\S]*client-side sorting is still armed/,
226
+ )
227
+ })
228
+ })
229
+
230
+ describe('manualPagination — the two sanctioned resolutions', () => {
231
+ test("enableSorting={false} renders, and no header claims to sort — argo's workaround", () => {
232
+ const { container } = renderTable({ ...SERVER_PAGE, enableSorting: false })
233
+ for (const th of container.querySelectorAll('th')) {
234
+ expect(th.getAttribute('aria-sort')).toBeNull()
235
+ expect(th.getAttribute('tabindex')).toBeNull()
236
+ }
237
+ expect(container.textContent).toContain('of 412')
238
+ })
239
+
240
+ test('manualSorting keeps the headers live but stops reordering the page locally', () => {
241
+ // asc by cost would put linewatch (3) above argo (12) — under manualSorting the server's
242
+ // order is what renders, because the server is the one that sorted.
243
+ const sorted = renderTable({
244
+ ...SERVER_PAGE,
245
+ manualSorting: true,
246
+ initialSorting: [{ id: 'cost', desc: false }],
247
+ })
248
+ expect(sorted.container.querySelector('tbody tr')?.textContent).toContain('argo')
249
+ expect(sorted.container.querySelector('th')?.getAttribute('aria-sort')).toBe('none')
250
+
251
+ // The control: the same initialSorting on a client-side table DOES reorder.
252
+ const local = renderTable({ initialSorting: [{ id: 'cost', desc: false }] })
253
+ expect(local.container.querySelector('tbody tr')?.textContent).toContain('linewatch')
254
+ })
255
+
256
+ test('manualFiltering hands the search to the server — the input filters nothing locally', () => {
257
+ const { container } = renderTable({
258
+ ...SERVER_PAGE,
259
+ manualSorting: true,
260
+ manualFiltering: true,
261
+ enableGlobalFilter: true,
262
+ initialGlobalFilter: 'zzzz',
263
+ })
264
+ // Client-side that term matches no row; the server page renders whole.
265
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(2)
266
+ expect(container.querySelector('input')).toBeTruthy()
267
+ })
268
+ })
269
+
270
+ describe('manualPagination — production degrades to the honest table, never the plausible one', () => {
271
+ test('no sort headers, no search input, and no "of N" it cannot stand behind', () => {
272
+ const errors: unknown[][] = []
273
+ const previous = console.error
274
+ console.error = (...args: unknown[]) => errors.push(args)
275
+ try {
276
+ inProd(() => {
277
+ const { container } = renderTable({
278
+ manualPagination: true,
279
+ enablePagination: true,
280
+ enableGlobalFilter: true,
281
+ })
282
+ // Sorting: the control is gone, so the table asserts nothing about the unseen rows.
283
+ for (const th of container.querySelectorAll('th')) {
284
+ expect(th.getAttribute('aria-sort')).toBeNull()
285
+ }
286
+ // Filtering: same — a control that can only narrow one page is not rendered.
287
+ expect(container.querySelector('input[placeholder="Search…"]')).toBeNull()
288
+ // The count: no rowCount was given, so the bar states the range and claims no total.
289
+ expect(container.textContent).toContain('Showing 1–2')
290
+ expect(container.textContent).not.toContain('of 2')
291
+ })
292
+ } finally {
293
+ console.error = previous
294
+ }
295
+ // The degradation is not its own silence.
296
+ expect(errors).toHaveLength(1)
297
+ expect(String(errors[0]?.[0])).toContain('BasaltDataTable: `manualPagination` is set')
298
+ })
299
+ })
300
+
301
+ /**
302
+ * Sibling defect found by reading, not by a consumer: the empty branch keyed off `data.length`,
303
+ * so any narrowing that left `data` non-empty but the row model empty rendered a `<tbody>` with
304
+ * nothing in it — no rows, no message. A blank body reads as a broken table, not as no matches.
305
+ */
306
+ describe('the empty state tracks the rendered rows, not the raw array', () => {
307
+ test('a search matching nothing shows the empty state instead of a blank tbody', () => {
308
+ const { container } = renderTable({
309
+ enableGlobalFilter: true,
310
+ initialGlobalFilter: 'no-such-project',
311
+ })
312
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(1)
313
+ expect(container.textContent).toContain('No data to display.')
314
+ })
315
+
316
+ test('a custom emptyState is honoured on a filtered-to-nothing table too', () => {
317
+ const { container } = renderTable({
318
+ enableGlobalFilter: true,
319
+ initialGlobalFilter: 'no-such-project',
320
+ emptyState: <Text>Nothing matches that search.</Text>,
321
+ })
322
+ expect(container.textContent).toContain('Nothing matches that search.')
323
+ })
324
+
325
+ test('a page index past the end shows the empty state rather than an empty body', () => {
326
+ const { container } = renderTable({
327
+ enablePagination: true,
328
+ initialPagination: { pageIndex: 9, pageSize: 10 },
329
+ })
330
+ expect(container.textContent).toContain('No data to display.')
331
+ })
332
+ })
333
+
334
+ describe('onColumnFiltersChange — the seam server-side faceting needs', () => {
335
+ test('a facet selection reports the new columnFilters', async () => {
336
+ const seen: ColumnFiltersState[] = []
337
+ const { container } = renderTable({
338
+ facets: [
339
+ { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
340
+ ],
341
+ onColumnFiltersChange: (filters) => seen.push(filters),
342
+ })
343
+ const select = container.querySelector('input[placeholder="Project"]')
344
+ if (!select) throw new Error('expected the facet Select')
345
+ fireEvent.click(select)
346
+ fireEvent.click(await screen.findByRole('option', { name: 'argo' }))
347
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
348
+ })
349
+ })