@svgrid/grid 3.0.4 → 4.0.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 (71) hide show
  1. package/README.md +3 -3
  2. package/dist/SvGrid.controller.svelte.d.ts +12 -2
  3. package/dist/SvGrid.controller.svelte.js +191 -8
  4. package/dist/SvGrid.css +73 -0
  5. package/dist/SvGrid.svelte +82 -3
  6. package/dist/SvGrid.types.d.ts +105 -3
  7. package/dist/build-api.js +11 -6
  8. package/dist/cdn/{GridMenus-7kbpnnBW.js → GridMenus-CLI93jke.js} +26 -26
  9. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-CwooL3QA.js} +26 -26
  10. package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
  11. package/dist/cdn/{src-BKhZ6eXd.js → src-B6HXdfHY.js} +4099 -4140
  12. package/dist/cdn/{src-V1uu8iE9.js → src-C6Iowdvs.js} +4052 -4093
  13. package/dist/cdn/svgrid.js +27 -26
  14. package/dist/cdn/svgrid.svelte-external.js +27 -26
  15. package/dist/cdn/validate-CchXzyrX.js +76 -0
  16. package/dist/core.js +36 -13
  17. package/dist/editing.js +10 -0
  18. package/dist/grid-icons.d.ts +2 -2
  19. package/dist/grid-icons.js +2 -2
  20. package/dist/grid-messages.d.ts +2 -0
  21. package/dist/grid-messages.js +2 -0
  22. package/dist/index.d.ts +3 -4
  23. package/dist/index.js +2 -3
  24. package/dist/row-model.d.ts +133 -0
  25. package/dist/row-model.js +16 -0
  26. package/dist/selection.d.ts +2 -1
  27. package/dist/selection.js +30 -2
  28. package/dist/server-block-cache.d.ts +214 -0
  29. package/dist/server-block-cache.js +531 -0
  30. package/dist/server-data-source.d.ts +240 -6
  31. package/dist/server-data-source.js +220 -20
  32. package/dist/server.d.ts +13 -0
  33. package/dist/server.js +13 -0
  34. package/dist/validate.d.ts +2 -0
  35. package/dist/validate.js +8 -3
  36. package/package.json +6 -1
  37. package/src/SvGrid.controller.svelte.ts +190 -8
  38. package/src/SvGrid.css +73 -0
  39. package/src/SvGrid.svelte +82 -3
  40. package/src/SvGrid.types.ts +105 -3
  41. package/src/build-api.ts +11 -4
  42. package/src/core.rowmodel-cache.test.ts +51 -0
  43. package/src/core.ts +33 -12
  44. package/src/editing.ts +8 -0
  45. package/src/grid-icons.ts +2 -2
  46. package/src/grid-messages.ts +5 -0
  47. package/src/icon-seam.test.ts +2 -4
  48. package/src/index.ts +19 -10
  49. package/src/row-model.ts +146 -0
  50. package/src/selection.test.ts +3 -0
  51. package/src/selection.ts +32 -2
  52. package/src/server-block-cache.test.ts +645 -0
  53. package/src/server-block-cache.ts +677 -0
  54. package/src/server-data-source.infinite.test.ts +343 -0
  55. package/src/server-data-source.ts +450 -27
  56. package/src/server.ts +47 -0
  57. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  58. package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
  59. package/src/validate.test.ts +10 -0
  60. package/src/validate.ts +10 -3
  61. package/dist/SvGroupCell.svelte +0 -141
  62. package/dist/SvGroupCell.svelte.d.ts +0 -49
  63. package/dist/SvRowGroupPanel.svelte +0 -186
  64. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  65. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  66. package/dist/server-group-model.d.ts +0 -98
  67. package/dist/server-group-model.js +0 -263
  68. package/src/SvGroupCell.svelte +0 -141
  69. package/src/SvRowGroupPanel.svelte +0 -186
  70. package/src/server-group-model.test.ts +0 -294
  71. package/src/server-group-model.ts +0 -370
package/src/server.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `@svgrid/grid/server` - the server-side data primitives, with no Svelte in
3
+ * the import graph.
4
+ *
5
+ * Everything here is also on the package root. This entry point exists for
6
+ * code that must not pull `SvGrid.svelte` into its bundle or its test
7
+ * runner: a SvelteKit `+server.ts` shaping a `ServerRequest` into a query,
8
+ * a worker feeding a block cache, or `@svgrid/enterprise`'s own row model,
9
+ * whose unit tests run without the Svelte compiler.
10
+ */
11
+ export {
12
+ createServerDataSource,
13
+ type ServerDataSource,
14
+ type ServerSelectionRule,
15
+ type ServerRequest,
16
+ type ServerResult,
17
+ type ServerController,
18
+ type ServerControllerOptions,
19
+ type ServerState,
20
+ type ServerSortModel,
21
+ type ServerFilterModel,
22
+ type ServerAggregation,
23
+ type ServerGroupRow,
24
+ type ServerLeafRow,
25
+ type ServerMoreRow,
26
+ type ServerFooterRow,
27
+ type ServerSkeletonRow,
28
+ type ServerGrandTotalRow,
29
+ type ServerPlaceholderRow,
30
+ type ServerDisplayRow,
31
+ } from './server-data-source'
32
+ export {
33
+ createBlockCache,
34
+ createRowPlaceholder,
35
+ rowPlaceholderState,
36
+ type BlockCache,
37
+ type BlockCacheOptions,
38
+ type BlockCacheState,
39
+ type BlockFetchResult,
40
+ type BlockState,
41
+ } from './server-block-cache'
42
+ export {
43
+ toServerFilterColumns,
44
+ type GridRowModel,
45
+ type GridFilterState,
46
+ type GridRowModelSort,
47
+ } from './row-model'
@@ -0,0 +1,352 @@
1
+ /**
2
+ * `<SvGrid rowModel={ctl} />` - the one-prop integration.
3
+ *
4
+ * The seams below it are tested in `svgrid.row-model-seam.svelte.test.ts`;
5
+ * this suite is about what the prop itself is for: that handing the grid a
6
+ * model wires all of those seams at once, that the model drives re-renders,
7
+ * and that writing a prop explicitly still wins - so adopting it is never
8
+ * all-or-nothing.
9
+ */
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { mount, unmount } from 'svelte'
12
+ import SvGrid from './SvGrid.svelte'
13
+ import {
14
+ createCoreRowModel,
15
+ createServerDataSource,
16
+ rowSelectionFeature,
17
+ rowSortingFeature,
18
+ tableFeatures,
19
+ type ColumnDef,
20
+ type GridRowModel,
21
+ type ServerDataSource,
22
+ type SvGridApi,
23
+ } from './index'
24
+
25
+ type Row = { id: number; name: string }
26
+ const features = tableFeatures({ rowSortingFeature, rowSelectionFeature })
27
+ const cols: ColumnDef<typeof features, Row>[] = [{ field: 'name', header: 'Name', width: 160 }]
28
+
29
+ const tick = () => new Promise<void>((r) => setTimeout(r))
30
+ const settle = async () => {
31
+ for (let i = 0; i < 20; i += 1) await Promise.resolve()
32
+ await tick()
33
+ }
34
+
35
+ function mountGrid(props: Record<string, unknown>) {
36
+ return new Promise<{
37
+ api: SvGridApi<typeof features, Row>
38
+ target: HTMLElement
39
+ destroy: () => void
40
+ }>((res, rej) => {
41
+ const target = document.createElement('div')
42
+ document.body.appendChild(target)
43
+ const app = mount(SvGrid, {
44
+ target,
45
+ props: {
46
+ columns: cols,
47
+ features,
48
+ _rowModels: { coreRowModel: createCoreRowModel() },
49
+ containerHeight: 200,
50
+ virtualization: false,
51
+ onApiReady(api: SvGridApi<typeof features, Row>) {
52
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
53
+ },
54
+ ...props,
55
+ } as any,
56
+ })
57
+ queueMicrotask(() => {
58
+ if (!target.querySelector('[role="grid"]')) rej(new Error('no grid'))
59
+ })
60
+ })
61
+ }
62
+
63
+ /** A hand-written model, to prove the grid depends on the shape not the class. */
64
+ function handWritten(rows: Row[]) {
65
+ const listeners = new Set<() => void>()
66
+ let current = rows
67
+ let loading = false
68
+ const sorts: Array<unknown> = []
69
+ return {
70
+ sorts,
71
+ push(next: Row[]) {
72
+ current = next
73
+ for (const l of listeners) l()
74
+ },
75
+ setLoading(next: boolean) {
76
+ loading = next
77
+ for (const l of listeners) l()
78
+ },
79
+ model: {
80
+ subscribe(onChange: () => void) {
81
+ listeners.add(onChange)
82
+ return () => listeners.delete(onChange)
83
+ },
84
+ getRows: () => current,
85
+ isLoading: () => loading,
86
+ getRowId: (r: Row) => String(r.id),
87
+ setSort: (s: unknown) => sorts.push(s),
88
+ } satisfies GridRowModel<Row>,
89
+ }
90
+ }
91
+
92
+ describe('the rowModel prop', () => {
93
+ it('renders the rows the model reports, with no data prop at all', async () => {
94
+ const source = handWritten([
95
+ { id: 1, name: 'Ada' },
96
+ { id: 2, name: 'Grace' },
97
+ ])
98
+ const { target, destroy } = await mountGrid({ rowModel: source.model })
99
+ await tick()
100
+
101
+ expect(target.textContent).toContain('Ada')
102
+ expect(target.textContent).toContain('Grace')
103
+ destroy()
104
+ })
105
+
106
+ it('re-renders when the model says it changed', async () => {
107
+ const source = handWritten([{ id: 1, name: 'Ada' }])
108
+ const { target, destroy } = await mountGrid({ rowModel: source.model })
109
+ await tick()
110
+ expect(target.textContent).not.toContain('Linus')
111
+
112
+ source.push([{ id: 1, name: 'Ada' }, { id: 9, name: 'Linus' }])
113
+ await vi.waitFor(() => expect(target.textContent).toContain('Linus'))
114
+ destroy()
115
+ })
116
+
117
+ it('unsubscribes on unmount', async () => {
118
+ const source = handWritten([{ id: 1, name: 'Ada' }])
119
+ const { destroy } = await mountGrid({ rowModel: source.model })
120
+ await tick()
121
+ destroy()
122
+ await tick()
123
+
124
+ // A model that outlives the grid must not keep it alive through a
125
+ // listener; pushing after unmount should reach nobody and throw nothing.
126
+ expect(() => source.push([{ id: 2, name: 'Grace' }])).not.toThrow()
127
+ })
128
+
129
+ it('takes over sorting when the model sorts, and says so to the grid', async () => {
130
+ const source = handWritten([
131
+ { id: 1, name: 'Zoe' },
132
+ { id: 2, name: 'Ada' },
133
+ ])
134
+ const { api, target, destroy } = await mountGrid({ rowModel: source.model, sortable: true })
135
+ await tick()
136
+
137
+ api.setSort('name', 'asc')
138
+ await tick()
139
+
140
+ // The model was told; the grid did NOT reorder locally, because a model
141
+ // with `setSort` implies `externalSort`. (The grid also reports the
142
+ // empty initial sort on mount, as it does for any onSortingChange.)
143
+ expect(source.sorts.at(-1)).toEqual([{ id: 'name', desc: false }])
144
+ const firstRow = target.querySelector('tbody .sv-grid-row')!
145
+ expect(firstRow.textContent).toContain('Zoe')
146
+ destroy()
147
+ })
148
+
149
+ it('lets an explicit prop win over the model', async () => {
150
+ const source = handWritten([{ id: 1, name: 'Ada' }])
151
+ const { target, destroy } = await mountGrid({
152
+ rowModel: source.model,
153
+ data: [{ id: 7, name: 'Override' }],
154
+ })
155
+ await tick()
156
+
157
+ expect(target.textContent).toContain('Override')
158
+ expect(target.textContent).not.toContain('Ada')
159
+ destroy()
160
+ })
161
+
162
+ it('drives a real server controller end to end', async () => {
163
+ const source: ServerDataSource<Row> = {
164
+ async getRows(req) {
165
+ const all: Row[] = Array.from({ length: 250 }, (_, i) => ({ id: i, name: `Row ${i}` }))
166
+ const sorted = req.sortModel[0]?.desc ? [...all].reverse() : all
167
+ return { rows: sorted.slice(req.startRow, req.endRow), rowCount: all.length }
168
+ },
169
+ }
170
+ const ctl = createServerDataSource<Row>(source, {
171
+ mode: 'infinite',
172
+ blockSize: 100,
173
+ onChange: () => {},
174
+ })
175
+
176
+ const { target, destroy } = await mountGrid({ rowModel: ctl })
177
+
178
+ // The grid reported its visible range, the controller fetched block 0, and
179
+ // the rows arrived - with no wiring in this test beyond the one prop.
180
+ await vi.waitFor(() => expect(target.textContent).toContain('Row 0'))
181
+ expect(ctl.getCacheState().length).toBeGreaterThan(0)
182
+ expect(ctl.getRows()).toHaveLength(250)
183
+ destroy()
184
+ ctl.dispose()
185
+ }, 20_000)
186
+
187
+ it('wires the pager from a page-mode controller', async () => {
188
+ const source: ServerDataSource<Row> = {
189
+ async getRows(req) {
190
+ const all: Row[] = Array.from({ length: 95 }, (_, i) => ({ id: i, name: `Row ${i}` }))
191
+ return { rows: all.slice(req.startRow, req.endRow), rowCount: all.length }
192
+ },
193
+ }
194
+ const ctl = createServerDataSource<Row>(source, { pageSize: 20, onChange: () => {} })
195
+ ctl.refresh()
196
+ await settle()
197
+
198
+ const { api, destroy } = await mountGrid({ rowModel: ctl, pageable: true })
199
+
200
+ // 95 rows on the server, not the 20 in hand: the count came from the model.
201
+ await vi.waitFor(() => expect(api.getPageInfo().pageCount).toBe(5))
202
+ expect(api.getPageInfo().pageSize).toBe(20)
203
+ destroy()
204
+ ctl.dispose()
205
+ }, 20_000)
206
+
207
+ it('takes the page-size choices from the model and sizes the page to the body on request', async () => {
208
+ const calls: number[] = []
209
+ let pageSize = 10
210
+ const rows: Row[] = Array.from({ length: 50 }, (_, i) => ({ id: i, name: `Row ${i}` }))
211
+ const model: GridRowModel<Row> = {
212
+ subscribe: () => () => {},
213
+ getRows: () => rows.slice(0, pageSize),
214
+ isLoading: () => false,
215
+ get pagination() {
216
+ return {
217
+ pageIndex: 0,
218
+ pageSize,
219
+ rowCount: 50,
220
+ setPage: () => {},
221
+ setPageSize: (n: number) => {
222
+ calls.push(n)
223
+ pageSize = n
224
+ },
225
+ pageSizes: [7, 14, 21],
226
+ autoPageSize: true,
227
+ }
228
+ },
229
+ }
230
+ // jsdom lays nothing out; give every element a 100px box so the grid
231
+ // measures its body on mount (the way the responsive test fakes width).
232
+ const desc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
233
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 100 })
234
+ try {
235
+ const { api, target, destroy } = await mountGrid({ rowModel: model, pageable: true, rowHeight: 20 })
236
+ // The footer offers the model's sizes, not the grid's defaults.
237
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-pagination')).toBeTruthy())
238
+ expect(api.getOption('pageSizeOptions')).toEqual([7, 14, 21])
239
+ // Auto size: 100px of body over 20px rows is a page of five.
240
+ await vi.waitFor(() => expect(calls.at(-1)).toBe(5))
241
+ destroy()
242
+ } finally {
243
+ if (desc) Object.defineProperty(HTMLElement.prototype, 'clientHeight', desc)
244
+ else delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight
245
+ }
246
+ }, 20_000)
247
+ })
248
+
249
+ describe('editing under a row model', () => {
250
+ type Node = Row & { kind: "group" | "leaf" }
251
+ it('refuses to edit a server group row or a placeholder, and edits a leaf', async () => {
252
+ const nodes: Node[] = [
253
+ { id: 1, name: 'EMEA', kind: 'group' },
254
+ { id: 2, name: 'Ada', kind: 'leaf' },
255
+ ]
256
+ const model: GridRowModel<Node> = {
257
+ subscribe: () => () => {},
258
+ getRows: () => nodes,
259
+ isLoading: () => false,
260
+ getRowId: (r) => String(r.id),
261
+ group: {
262
+ isGroup: (r) => r.kind === 'group',
263
+ level: () => 0,
264
+ expanded: () => false,
265
+ onToggle: () => {},
266
+ },
267
+ }
268
+ const { api, target, destroy } = await mountGrid({
269
+ rowModel: model,
270
+ columns: [{ field: 'name', header: 'Name', width: 160, editorType: 'text' }],
271
+ editable: true,
272
+ })
273
+ try {
274
+ // The group row: nothing opens, whatever the column says.
275
+ api.startEditing(0, 'name')
276
+ await tick()
277
+ expect(target.querySelector('.sv-grid-cell input')).toBeNull()
278
+ // The leaf: an editor.
279
+ api.startEditing(1, 'name')
280
+ await vi.waitFor(() => expect(target.querySelector('.sv-grid-cell input')).not.toBeNull())
281
+ } finally {
282
+ destroy()
283
+ }
284
+ })
285
+ })
286
+
287
+ describe('the rowModel prop with virtualization on', () => {
288
+ /** 100k rows, so only a window is rendered and most rows are placeholders. */
289
+ function bigSource(): ServerDataSource<Row> {
290
+ return {
291
+ async getRows(req) {
292
+ const rows: Row[] = []
293
+ for (let i = req.startRow; i < Math.min(req.endRow, 100_000); i += 1) {
294
+ rows.push({ id: i, name: `Row ${i}` })
295
+ }
296
+ return { rows, rowCount: 100_000 }
297
+ },
298
+ }
299
+ }
300
+
301
+ it('renders skeletons for the rows it has not loaded', async () => {
302
+ const ctl = createServerDataSource<Row>(bigSource(), {
303
+ mode: 'infinite',
304
+ blockSize: 100,
305
+ onChange: () => {},
306
+ })
307
+ const { target, destroy } = await mountGrid({
308
+ rowModel: ctl,
309
+ virtualization: true,
310
+ containerHeight: 300,
311
+ rowHeight: 30,
312
+ })
313
+
314
+ // Block 0 landed, so the window at the top is real data.
315
+ await vi.waitFor(() => expect(target.textContent).toContain('Row 0'))
316
+
317
+ // The grid claims the whole table, and everything outside the loaded
318
+ // block is a placeholder - which is what keeps the scrollbar honest.
319
+ const rows = ctl.getRows()
320
+ expect(rows).toHaveLength(100_000)
321
+ expect(ctl.rowPlaceholder?.(rows[50_000] as Row, 50_000)).toBe('loading')
322
+ destroy()
323
+ ctl.dispose()
324
+ }, 20_000)
325
+
326
+ it('draws skeleton cells for a row the model calls a placeholder', async () => {
327
+ // Straight through the seam, with no fetching involved: the question here
328
+ // is only whether `rowModel.rowPlaceholder` reaches the renderer. Driving
329
+ // it from a real controller cannot answer that - with virtualization off
330
+ // the grid reports every row as visible, so every block loads and no
331
+ // placeholder survives to be rendered.
332
+ const rows: Row[] = [
333
+ { id: 0, name: 'Ada' },
334
+ { id: 1, name: 'Grace' },
335
+ { id: 2, name: 'Linus' },
336
+ ]
337
+ const model: GridRowModel<Row> = {
338
+ subscribe: () => () => {},
339
+ getRows: () => rows,
340
+ isLoading: () => false,
341
+ rowPlaceholder: (row) => (row.id === 2 ? 'loading' : null),
342
+ }
343
+ const { target, destroy } = await mountGrid({ rowModel: model })
344
+ await tick()
345
+
346
+ const placeholder = target.querySelector('.sv-grid-placeholder-row')!
347
+ expect(placeholder).not.toBeNull()
348
+ expect(placeholder.querySelectorAll('.sv-grid-placeholder-skeleton').length).toBe(cols.length)
349
+ expect(target.textContent).toContain('Ada')
350
+ destroy()
351
+ })
352
+ })
@@ -0,0 +1,289 @@
1
+ /**
2
+ * The four seams a server-backed row model plugs into, all free:
3
+ *
4
+ * - `onVisibleRangeChange` - which rows are on screen, by index
5
+ * - `rowPlaceholder` / `onRetryRow` - rows whose data has not arrived
6
+ * - `rowSelectionModel` - selection the grid does not own
7
+ *
8
+ * They are deliberately generic: none of them mentions groups, blocks or a
9
+ * datasource, so the free infinite controller and the Enterprise row model
10
+ * plug into the same four holes, and an app with its own fetching can too.
11
+ */
12
+ import { describe, expect, it, vi } from 'vitest'
13
+ import { mount, unmount } from 'svelte'
14
+ import SvGrid from './SvGrid.svelte'
15
+ import {
16
+ createCoreRowModel,
17
+ rowSelectionFeature,
18
+ rowSortingFeature,
19
+ tableFeatures,
20
+ } from './index'
21
+ import type { ColumnDef, SvGridApi } from './index'
22
+
23
+ type Row = { id: number; name: string }
24
+ const features = tableFeatures({ rowSortingFeature, rowSelectionFeature })
25
+ const cols: ColumnDef<typeof features, Row>[] = [
26
+ { field: 'name', header: 'Name', width: 160 },
27
+ { field: 'id', header: 'Id', width: 80 },
28
+ ]
29
+
30
+ const tick = () => new Promise<void>((r) => setTimeout(r))
31
+ const rowsOf = (n: number): Row[] =>
32
+ Array.from({ length: n }, (_, i) => ({ id: i, name: `Row ${i}` }))
33
+
34
+ function mountGrid(extra: Record<string, unknown>, data: Row[] = rowsOf(5)) {
35
+ return new Promise<{
36
+ api: SvGridApi<typeof features, Row>
37
+ target: HTMLElement
38
+ destroy: () => void
39
+ }>((res, rej) => {
40
+ const target = document.createElement('div')
41
+ document.body.appendChild(target)
42
+ const app = mount(SvGrid, {
43
+ target,
44
+ props: {
45
+ data,
46
+ columns: cols,
47
+ features,
48
+ _rowModels: { coreRowModel: createCoreRowModel() },
49
+ getRowId: (r: Row) => String(r.id),
50
+ containerHeight: 200,
51
+ virtualization: false,
52
+ onApiReady(api: SvGridApi<typeof features, Row>) {
53
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
54
+ },
55
+ ...extra,
56
+ } as any,
57
+ })
58
+ queueMicrotask(() => {
59
+ if (!target.querySelector('[role="grid"]')) rej(new Error('no grid'))
60
+ })
61
+ })
62
+ }
63
+
64
+ describe('onVisibleRangeChange', () => {
65
+ it('reports the whole list when virtualization is off', async () => {
66
+ const seen: Array<{ startIndex: number; endIndex: number }> = []
67
+ const { destroy } = await mountGrid({ onVisibleRangeChange: (r: any) => seen.push(r) }, rowsOf(5))
68
+ await tick()
69
+
70
+ expect(seen.at(-1)).toEqual({ startIndex: 0, endIndex: 4 })
71
+ destroy()
72
+ })
73
+
74
+ it('reports row indices, not pixels, when virtualization is on', async () => {
75
+ const seen: Array<{ startIndex: number; endIndex: number }> = []
76
+ const { destroy } = await mountGrid(
77
+ {
78
+ onVisibleRangeChange: (r: any) => seen.push(r),
79
+ virtualization: true,
80
+ containerHeight: 200,
81
+ },
82
+ rowsOf(10_000),
83
+ )
84
+ await tick()
85
+
86
+ const last = seen.at(-1)!
87
+ expect(last.startIndex).toBe(0)
88
+ // A 200px viewport cannot be showing ten thousand rows, and the numbers
89
+ // are row indices - if these were the pixel offsets the old deriveds
90
+ // carry, endIndex would be in the hundreds.
91
+ expect(last.endIndex).toBeGreaterThan(0)
92
+ expect(last.endIndex).toBeLessThan(200)
93
+ destroy()
94
+ })
95
+
96
+ it('does not fire again while the range is unchanged', async () => {
97
+ const seen: Array<unknown> = []
98
+ const { api, destroy } = await mountGrid({ onVisibleRangeChange: (r: any) => seen.push(r) })
99
+ await tick()
100
+ const count = seen.length
101
+
102
+ // Something unrelated changes; the visible rows do not.
103
+ api.selectRows(['1'])
104
+ await tick()
105
+ expect(seen).toHaveLength(count)
106
+ destroy()
107
+ })
108
+ })
109
+
110
+ describe('rowPlaceholder', () => {
111
+ it('draws a skeleton cell per column for a loading row', async () => {
112
+ const { target, destroy } = await mountGrid({
113
+ rowPlaceholder: (row: Row) => (row.id === 2 ? 'loading' : null),
114
+ })
115
+ await tick()
116
+
117
+ const placeholder = target.querySelector('.sv-grid-placeholder-row')!
118
+ expect(placeholder).not.toBeNull()
119
+ expect(placeholder.getAttribute('aria-busy')).toBe('true')
120
+ // One shimmer per real column, so the table keeps its shape.
121
+ expect(placeholder.querySelectorAll('.sv-grid-placeholder-skeleton')).toHaveLength(cols.length)
122
+ // And the rows around it are untouched.
123
+ expect(target.querySelectorAll('.sv-grid-placeholder-row')).toHaveLength(1)
124
+ destroy()
125
+ })
126
+
127
+ it('draws one full-width message with a Retry button for a failed row', async () => {
128
+ const retried: Array<{ row: Row; index: number }> = []
129
+ const { target, destroy } = await mountGrid({
130
+ rowPlaceholder: (row: Row) => (row.id === 3 ? 'failed' : null),
131
+ onRetryRow: (row: Row, index: number) => retried.push({ row, index }),
132
+ })
133
+ await tick()
134
+
135
+ const failed = target.querySelector('.sv-grid-placeholder-failed')!
136
+ expect(failed).not.toBeNull()
137
+ expect(failed.textContent).toContain('Could not load')
138
+ expect(failed.querySelectorAll('.sv-grid-placeholder-skeleton')).toHaveLength(0)
139
+
140
+ const retry = failed.querySelector<HTMLButtonElement>('.sv-grid-placeholder-retry')!
141
+ retry.click()
142
+ expect(retried).toHaveLength(1)
143
+ expect(retried[0]!.row.id).toBe(3)
144
+ expect(retried[0]!.index).toBe(3)
145
+ destroy()
146
+ })
147
+
148
+ it('gives a run of failed rows one message, and a band under it', async () => {
149
+ // A failed block is claimed row by row; the message and Retry sit on
150
+ // the first row of the run, the rest are the same tint with nothing
151
+ // to read, so a hundred-row block is not a hundred red lines.
152
+ const { target, destroy } = await mountGrid(
153
+ {
154
+ rowPlaceholder: (row: Row) => (row.id >= 2 && row.id <= 4 ? 'failed' : null),
155
+ onRetryRow: () => {},
156
+ },
157
+ rowsOf(7),
158
+ )
159
+ await tick()
160
+ const failed = [...target.querySelectorAll('.sv-grid-placeholder-failed')]
161
+ expect(failed).toHaveLength(3)
162
+ expect(failed.map((r) => r.classList.contains('sv-grid-placeholder-failed-cont'))).toEqual([false, true, true])
163
+ expect(target.querySelectorAll('.sv-grid-placeholder-retry')).toHaveLength(1)
164
+ expect(failed[0]!.textContent).toContain('Could not load')
165
+ expect(failed[1]!.textContent!.trim()).toBe('')
166
+ // Every row of the run keeps its own slot: the scrollbar does not move.
167
+ expect(target.querySelectorAll('tbody .sv-grid-row').length).toBe(7)
168
+ destroy()
169
+ })
170
+
171
+ it('omits the Retry button when there is no handler to run', async () => {
172
+ const { target, destroy } = await mountGrid({
173
+ rowPlaceholder: (row: Row) => (row.id === 3 ? 'failed' : null),
174
+ })
175
+ await tick()
176
+ expect(target.querySelector('.sv-grid-placeholder-retry')).toBeNull()
177
+ destroy()
178
+ })
179
+
180
+ it('keeps placeholder rows out of select-all', async () => {
181
+ const { api, destroy } = await mountGrid({
182
+ showRowSelection: true,
183
+ rowPlaceholder: (row: Row) => (row.id >= 3 ? 'loading' : null),
184
+ })
185
+ await tick()
186
+
187
+ api.selectAllRows()
188
+ await tick()
189
+ // Selecting a row that has not loaded would leave an id that never
190
+ // resolves to anything.
191
+ expect(api.getSelectedRowIds().sort()).toEqual(['0', '1', '2'])
192
+ destroy()
193
+ })
194
+
195
+ it('changes nothing for a grid that does not use it', async () => {
196
+ const { target, destroy } = await mountGrid({})
197
+ await tick()
198
+ expect(target.querySelector('.sv-grid-placeholder-row')).toBeNull()
199
+ expect(target.querySelectorAll('.sv-grid-row').length).toBeGreaterThan(0)
200
+ destroy()
201
+ })
202
+ })
203
+
204
+ describe('rowSelectionModel', () => {
205
+ /**
206
+ * "Everything except these", the shape a server-side selection needs.
207
+ *
208
+ * Its state is a rune, so the header checkbox re-derives when the model
209
+ * changes. That is a requirement of the seam, not an accident of the test:
210
+ * the grid re-reads `headerState()` only when something reactive that it
211
+ * touched has changed.
212
+ */
213
+ function exceptModel(totalRows: number) {
214
+ const state = $state({ selectAll: false, toggled: new Set<string>() })
215
+ return {
216
+ state,
217
+ isSelected: (rowId: string) => state.selectAll !== state.toggled.has(rowId),
218
+ headerState: () => {
219
+ if (state.selectAll) return state.toggled.size === 0 ? 'all' : 'some'
220
+ return state.toggled.size === 0 ? 'none' : 'some'
221
+ },
222
+ toggle: (rowId: string) => {
223
+ const next = new Set(state.toggled)
224
+ if (next.has(rowId)) next.delete(rowId)
225
+ else next.add(rowId)
226
+ state.toggled = next
227
+ },
228
+ toggleAll: (next: boolean) => {
229
+ state.selectAll = next
230
+ state.toggled = new Set()
231
+ },
232
+ selectedCount: () => (state.selectAll ? totalRows - state.toggled.size : state.toggled.size),
233
+ }
234
+ }
235
+
236
+ it('answers the header checkbox instead of counting loaded rows', async () => {
237
+ const model = exceptModel(1_000_000)
238
+ const { target, destroy } = await mountGrid({ showRowSelection: true, rowSelectionModel: model })
239
+ await tick()
240
+
241
+ const header = target.querySelector('thead [role="checkbox"], thead input[type="checkbox"]')!
242
+ expect(header.getAttribute('aria-checked')).toBe('false')
243
+
244
+ model.toggleAll(true)
245
+ await vi.waitFor(() =>
246
+ expect(
247
+ target.querySelector('thead [role="checkbox"], thead input[type="checkbox"]')!
248
+ .getAttribute('aria-checked'),
249
+ ).toBe('true'),
250
+ )
251
+ // Five rows are loaded; a million are selected. The grid says "all"
252
+ // because the model said so, which is the whole point of the seam.
253
+ expect(model.selectedCount()).toBe(1_000_000)
254
+ destroy()
255
+ })
256
+
257
+ it('routes the header checkbox click to toggleAll, not to the loaded rows', async () => {
258
+ const model = exceptModel(1_000_000)
259
+ const { target, destroy } = await mountGrid({ showRowSelection: true, rowSelectionModel: model })
260
+ await tick()
261
+
262
+ const header = target.querySelector<HTMLElement>('thead [role="checkbox"], thead input[type="checkbox"]')!
263
+ header.click()
264
+ await tick()
265
+
266
+ expect(model.state.selectAll).toBe(true)
267
+ expect(model.selectedCount()).toBe(1_000_000)
268
+ destroy()
269
+ })
270
+
271
+ it('routes a row checkbox through the model', async () => {
272
+ const model = exceptModel(1_000_000)
273
+ const { api, destroy } = await mountGrid({ showRowSelection: true, rowSelectionModel: model })
274
+ await tick()
275
+
276
+ api.toggleRowSelected('2')
277
+ await tick()
278
+ expect(model.state.toggled.has('2')).toBe(true)
279
+ expect(model.isSelected('2')).toBe(true)
280
+
281
+ model.toggleAll(true)
282
+ await tick()
283
+ api.toggleRowSelected('2')
284
+ // Under select-all, toggling a row means "except this one".
285
+ expect(model.isSelected('2')).toBe(false)
286
+ expect(model.selectedCount()).toBe(999_999)
287
+ destroy()
288
+ })
289
+ })