@svgrid/grid 3.0.4 → 3.0.6

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 (176) hide show
  1. package/README.md +32 -11
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +33 -5
  4. package/dist/SvGrid.controller.svelte.js +397 -58
  5. package/dist/SvGrid.css +200 -2
  6. package/dist/SvGrid.svelte +350 -40
  7. package/dist/SvGrid.types.d.ts +433 -7
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +92 -37
  15. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-C-NtjlrJ.js} +155 -146
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/server-block-cache-DIqb3VZn.js +241 -0
  45. package/dist/cdn/{src-V1uu8iE9.js → src-CgoeSM8k.js} +5339 -4753
  46. package/dist/cdn/{src-BKhZ6eXd.js → src-DfwxB1TH.js} +9817 -9231
  47. package/dist/cdn/svgrid.js +28 -27
  48. package/dist/cdn/svgrid.svelte-external.js +28 -27
  49. package/dist/cdn/validate-AZoD8BoR.js +80 -0
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +401 -38
  59. package/dist/editing.js +34 -0
  60. package/dist/gantt-view.svelte.d.ts +24 -0
  61. package/dist/gantt-view.svelte.js +13 -0
  62. package/dist/grid-icons.d.ts +2 -2
  63. package/dist/grid-icons.js +2 -2
  64. package/dist/grid-messages.d.ts +10 -0
  65. package/dist/grid-messages.js +9 -0
  66. package/dist/headless.d.ts +5 -0
  67. package/dist/headless.js +12 -0
  68. package/dist/history.d.ts +21 -0
  69. package/dist/history.js +34 -4
  70. package/dist/index.d.ts +5 -4
  71. package/dist/index.js +3 -3
  72. package/dist/keyboard-handlers.js +24 -1
  73. package/dist/keyboard.js +4 -0
  74. package/dist/row-drag-touch.d.ts +1 -1
  75. package/dist/row-drag.js +35 -1
  76. package/dist/row-model.d.ts +139 -0
  77. package/dist/row-model.js +16 -0
  78. package/dist/row-resize.js +22 -1
  79. package/dist/selection.d.ts +2 -1
  80. package/dist/selection.js +31 -3
  81. package/dist/server-block-cache.d.ts +219 -0
  82. package/dist/server-block-cache.js +539 -0
  83. package/dist/server-data-source.d.ts +274 -8
  84. package/dist/server-data-source.js +229 -20
  85. package/dist/server.d.ts +13 -0
  86. package/dist/server.js +13 -0
  87. package/dist/sparkline.js +7 -5
  88. package/dist/svgrid-wrapper.types.d.ts +33 -0
  89. package/dist/validate.d.ts +4 -0
  90. package/dist/validate.js +29 -3
  91. package/package.json +11 -1
  92. package/src/GridMenus.svelte +34 -4
  93. package/src/SvGrid.controller.svelte.ts +397 -61
  94. package/src/SvGrid.css +200 -2
  95. package/src/SvGrid.svelte +350 -40
  96. package/src/SvGrid.types.ts +453 -7
  97. package/src/SvGridDropdown.svelte +20 -0
  98. package/src/SvModal.svelte +22 -0
  99. package/src/SvModal.test.ts +30 -1
  100. package/src/a11y/dismissable.test.ts +10 -1
  101. package/src/a11y/dismissable.ts +22 -2
  102. package/src/build-api.ts +96 -31
  103. package/src/cell-formatting.test.ts +17 -0
  104. package/src/cell-formatting.ts +18 -1
  105. package/src/cell-render.test.ts +24 -2
  106. package/src/cell-render.ts +24 -8
  107. package/src/clipboard.test.ts +107 -0
  108. package/src/clipboard.ts +62 -23
  109. package/src/columns.test.ts +4 -4
  110. package/src/columns.ts +7 -2
  111. package/src/conditional-formatting.test.ts +8 -0
  112. package/src/conditional-formatting.ts +3 -0
  113. package/src/core.rowmodel-cache.test.ts +51 -0
  114. package/src/core.tick-repair.test.ts +287 -0
  115. package/src/core.ts +407 -38
  116. package/src/editing.test.ts +52 -0
  117. package/src/editing.ts +33 -0
  118. package/src/gantt-stub.test.svelte +38 -0
  119. package/src/gantt-view.svelte.ts +35 -0
  120. package/src/grid-icons.ts +2 -2
  121. package/src/grid-messages.test.ts +7 -0
  122. package/src/grid-messages.ts +22 -0
  123. package/src/headless.ts +24 -0
  124. package/src/history.test.ts +49 -1
  125. package/src/history.ts +48 -2
  126. package/src/icon-seam.test.ts +2 -4
  127. package/src/index.ts +38 -10
  128. package/src/keyboard-handlers.coverage.test.ts +65 -0
  129. package/src/keyboard-handlers.ts +26 -1
  130. package/src/keyboard.ts +3 -0
  131. package/src/row-drag-touch.ts +2 -2
  132. package/src/row-drag.test.ts +51 -0
  133. package/src/row-drag.ts +35 -2
  134. package/src/row-model.ts +152 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.test.ts +3 -0
  137. package/src/selection.ts +33 -3
  138. package/src/server-block-cache.test.ts +672 -0
  139. package/src/server-block-cache.ts +690 -0
  140. package/src/server-data-source.infinite.test.ts +405 -0
  141. package/src/server-data-source.test.ts +19 -0
  142. package/src/server-data-source.ts +496 -29
  143. package/src/server.ts +49 -0
  144. package/src/sparkline.test.ts +7 -0
  145. package/src/sparkline.ts +6 -4
  146. package/src/svgrid-wrapper.types.ts +32 -0
  147. package/src/svgrid.api.test.ts +21 -0
  148. package/src/svgrid.charting.test.ts +12 -4
  149. package/src/svgrid.context-menu.test.ts +12 -0
  150. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  151. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  152. package/src/svgrid.gantt-seam.test.ts +199 -0
  153. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  156. package/src/svgrid.row-model-seam.svelte.test.ts +305 -0
  157. package/src/svgrid.row-pinning.test.ts +15 -1
  158. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  159. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  160. package/src/svgrid.upsell-license.test.ts +8 -5
  161. package/src/test-setup.ts +12 -0
  162. package/src/transaction.test.ts +58 -0
  163. package/src/validate.test.ts +29 -0
  164. package/src/validate.ts +34 -3
  165. package/dist/SvGroupCell.svelte +0 -141
  166. package/dist/SvGroupCell.svelte.d.ts +0 -49
  167. package/dist/SvRowGroupPanel.svelte +0 -186
  168. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  169. package/dist/cdn/GridMenus-7kbpnnBW.js +0 -635
  170. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  171. package/dist/server-group-model.d.ts +0 -98
  172. package/dist/server-group-model.js +0 -263
  173. package/src/SvGroupCell.svelte +0 -141
  174. package/src/SvRowGroupPanel.svelte +0 -186
  175. package/src/server-group-model.test.ts +0 -294
  176. package/src/server-group-model.ts +0 -370
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The two ways a live feed can change a row, and what each one reaches.
3
+ *
4
+ * A `$state` array passed as `data` has two update paths and they are not
5
+ * interchangeable, which is the whole design problem of a ticking grid:
6
+ *
7
+ * 1. Mutate a field through the proxy (`rows[i].price = x`). The visible
8
+ * cell re-renders because its template reads the field through the
9
+ * proxy. The row model does NOT run - it caches on the array reference
10
+ * and the reference did not change - so an active sort or filter is
11
+ * stale for that row until something replaces the array.
12
+ * 2. Replace the array (`rows = next`, or `api.applyTransaction`). The row
13
+ * model rebuilds and re-runs the pipeline over every row; sort and filter
14
+ * are current again.
15
+ *
16
+ * Replacing one ELEMENT through the proxy (`rows[i] = {...}`) is the case a
17
+ * React-trained hand reaches for, and it is the one that does neither: the
18
+ * base row keeps its old `original`, so the cell keeps its old value.
19
+ *
20
+ * These are the facts the real-time docs and the blog describe; if one of
21
+ * them changes, this file is where it shows up first.
22
+ *
23
+ * Lives in a `.svelte.test.ts` so `$state` is available.
24
+ */
25
+ import { describe, expect, it } from 'vitest'
26
+ import { mount, unmount, flushSync } from 'svelte'
27
+ import SvGrid from './SvGrid.svelte'
28
+ import {
29
+ createCoreRowModel,
30
+ createFilteredRowModel,
31
+ createSortedRowModel,
32
+ rowSortingFeature,
33
+ sortFns,
34
+ tableFeatures,
35
+ } from './index'
36
+ import type { SvGridApi } from './index'
37
+
38
+ type Quote = { id: string; symbol: string; price: number }
39
+
40
+ const features = tableFeatures({ rowSortingFeature })
41
+ const columns = [
42
+ { field: 'symbol', header: 'Symbol', width: 120 },
43
+ { field: 'price', header: 'Price', width: 120, editorType: 'number' as const },
44
+ ]
45
+
46
+ function seed(): Quote[] {
47
+ return [
48
+ { id: 'a', symbol: 'AAA', price: 10 },
49
+ { id: 'b', symbol: 'BBB', price: 20 },
50
+ { id: 'c', symbol: 'CCC', price: 30 },
51
+ ]
52
+ }
53
+
54
+ /**
55
+ * The props object must be the `$state` proxy itself, not a copy of it: a
56
+ * spread would snapshot `data` at mount and the reassignment tests would be
57
+ * testing nothing.
58
+ */
59
+ function liveProps(data: Quote[]) {
60
+ const props = $state({
61
+ data,
62
+ columns,
63
+ features,
64
+ _rowModels: {
65
+ coreRowModel: createCoreRowModel(),
66
+ filteredRowModel: createFilteredRowModel(),
67
+ sortedRowModel: createSortedRowModel(sortFns),
68
+ },
69
+ getRowId: (r: Quote) => r.id,
70
+ // Price descending: CCC, BBB, AAA. A tick that lifts AAA above CCC is
71
+ // the observable difference between "cell updated" and "re-sorted".
72
+ initialSorting: [{ id: 'price', desc: true }],
73
+ containerHeight: 400,
74
+ virtualization: false,
75
+ onApiReady: undefined as ((next: SvGridApi<typeof features, Quote>) => void) | undefined,
76
+ })
77
+ return props
78
+ }
79
+
80
+ function mountGrid(props: ReturnType<typeof liveProps>) {
81
+ const target = document.createElement('div')
82
+ document.body.appendChild(target)
83
+ let api: SvGridApi<typeof features, Quote> | null = null
84
+ props.onApiReady = (next) => (api = next)
85
+ const app = mount(SvGrid, { target, props: props as never })
86
+ flushSync()
87
+ const dataRows = () =>
88
+ Array.from(target.querySelectorAll('tbody.sv-grid-body tr.sv-grid-row:not(.sv-grid-row-spacer)'))
89
+ const cellsOf = (tr: Element) =>
90
+ Array.from(tr.querySelectorAll('.sv-grid-cell:not(.sv-grid-selection-cell)')).map((td) =>
91
+ td.textContent?.trim(),
92
+ )
93
+ const symbolsInOrder = () => dataRows().map((tr) => cellsOf(tr)[0])
94
+ const priceOf = (symbol: string) => {
95
+ const tr = dataRows().find((row) => cellsOf(row)[0] === symbol)
96
+ return tr ? cellsOf(tr)[1] : undefined
97
+ }
98
+ return {
99
+ app,
100
+ target,
101
+ api: () => api!,
102
+ symbolsInOrder,
103
+ priceOf,
104
+ done() {
105
+ unmount(app)
106
+ target.remove()
107
+ },
108
+ }
109
+ }
110
+
111
+ describe('live update paths', () => {
112
+ it('starts sorted by price descending', () => {
113
+ const props = liveProps(seed())
114
+ const g = mountGrid(props)
115
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
116
+ g.done()
117
+ })
118
+
119
+ it('proxy field write: the cell updates, the sort does not re-run', () => {
120
+ const props = liveProps(seed())
121
+ const g = mountGrid(props)
122
+
123
+ props.data[0]!.price = 99 // AAA now the highest price
124
+ flushSync()
125
+
126
+ expect(g.priceOf('AAA')).toBe('99')
127
+ // Still where it was: the row model never saw the write.
128
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
129
+ g.done()
130
+ })
131
+
132
+ it('proxy element replacement: neither the cell nor the sort updates', () => {
133
+ const props = liveProps(seed())
134
+ const g = mountGrid(props)
135
+
136
+ props.data[0] = { ...props.data[0]!, price: 99 }
137
+ flushSync()
138
+
139
+ // The base row still points at the object it was built from.
140
+ expect(g.priceOf('AAA')).toBe('10')
141
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
142
+ g.done()
143
+ })
144
+
145
+ it('array reassignment: the cell updates and the sort re-runs', () => {
146
+ const props = liveProps(seed())
147
+ const g = mountGrid(props)
148
+
149
+ props.data = props.data.map((r) => (r.id === 'a' ? { ...r, price: 99 } : r))
150
+ flushSync()
151
+
152
+ expect(g.priceOf('AAA')).toBe('99')
153
+ expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
154
+ g.done()
155
+ })
156
+
157
+ it('applyTransaction: the cell updates and the sort re-runs', () => {
158
+ const props = liveProps(seed())
159
+ const g = mountGrid(props)
160
+
161
+ g.api().applyTransaction({ update: [{ id: 'a', symbol: 'AAA', price: 99 }] })
162
+ flushSync()
163
+
164
+ expect(g.priceOf('AAA')).toBe('99')
165
+ expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
166
+ g.done()
167
+ })
168
+
169
+ it('a proxy field write is caught up by the next array replacement', () => {
170
+ const props = liveProps(seed())
171
+ const g = mountGrid(props)
172
+
173
+ props.data[0]!.price = 99
174
+ flushSync()
175
+ expect(g.symbolsInOrder()).toEqual(['CCC', 'BBB', 'AAA'])
176
+
177
+ // Any structural change re-runs the pipeline over the current values,
178
+ // including the one the proxy write left behind.
179
+ props.data = [...props.data]
180
+ flushSync()
181
+ expect(g.symbolsInOrder()).toEqual(['AAA', 'CCC', 'BBB'])
182
+ g.done()
183
+ })
184
+ })
@@ -75,6 +75,14 @@ function mountGrid(extra: Record<string, unknown> = {}) {
75
75
  )
76
76
  }
77
77
 
78
+ /**
79
+ * The close-on-scroll listener arms one animation frame after a popover
80
+ * mounts, so a scroll queued before the opening click cannot dismiss what
81
+ * that click opened. Every open helper waits that frame out before a case
82
+ * dispatches its scroll.
83
+ */
84
+ const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
85
+
78
86
  async function openContextMenu(target: HTMLElement) {
79
87
  const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]') as HTMLElement
80
88
  const r = cell.getBoundingClientRect()
@@ -86,6 +94,7 @@ async function openContextMenu(target: HTMLElement) {
86
94
  expect(menu).not.toBeNull()
87
95
  expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
88
96
  })
97
+ await nextFrame()
89
98
  return target.querySelector('.sv-grid-context-menu') as HTMLElement
90
99
  }
91
100
 
@@ -100,6 +109,7 @@ async function openCommentEditor(target: HTMLElement) {
100
109
  await vi.waitFor(() => {
101
110
  expect(target.querySelector('.sv-grid-comment-editor')).not.toBeNull()
102
111
  })
112
+ await nextFrame()
103
113
  return target.querySelector('.sv-grid-comment-editor') as HTMLElement
104
114
  }
105
115
 
@@ -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
+ })