@svgrid/grid 3.0.5 → 3.0.7

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 (166) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +23 -3
  4. package/dist/SvGrid.controller.svelte.js +235 -54
  5. package/dist/SvGrid.css +143 -7
  6. package/dist/SvGrid.helpers.js +2 -4
  7. package/dist/SvGrid.svelte +223 -38
  8. package/dist/SvGrid.types.d.ts +77 -4
  9. package/dist/SvGridDropdown.svelte +20 -0
  10. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  11. package/dist/SvModal.svelte +22 -0
  12. package/dist/SvModal.svelte.d.ts +7 -0
  13. package/dist/a11y/dismissable.d.ts +10 -0
  14. package/dist/a11y/dismissable.js +24 -2
  15. package/dist/build-api.js +81 -31
  16. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-BgUS-W3o.js} +151 -142
  17. package/dist/cdn/GridMenus-CcwU8I4t.js +644 -0
  18. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  19. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  21. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-9yz-SbdZ.js} +2 -2
  23. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-D7-ql1Hd.js} +2 -2
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DMUOFxOe.js} +4 -4
  25. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-N7ik0oWy.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-CB_Jnm2k.js} +4 -4
  27. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-Ca7gF8ZK.js} +4 -4
  28. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
  29. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-B1LFgoPP.js} +2 -2
  31. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-By3n7DGZ.js} +2 -2
  32. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  33. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  34. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  35. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  36. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-Mnv84zoE.js} +28 -6
  37. package/dist/cdn/{chart-Dz7SqMXH.js → chart-By7h2Guv.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-BC-tOgFY.js} +1 -1
  39. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DUaXr-sb.js} +1 -1
  40. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-BDtrNEiA.js} +1 -1
  41. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-C1aOAPBZ.js} +1 -1
  42. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  43. package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
  44. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-y1wdyCcv.js} +93 -97
  45. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  46. package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  47. package/dist/cdn/{src-jcFMkBnN.js → src-25JmJ_7g.js} +4768 -4149
  48. package/dist/cdn/{src-Bklofsad.js → src-kE9B98r6.js} +4796 -4177
  49. package/dist/cdn/svgrid.js +18 -18
  50. package/dist/cdn/svgrid.svelte-external.js +18 -18
  51. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  52. package/dist/cell-formatting.d.ts +9 -0
  53. package/dist/cell-formatting.js +52 -1
  54. package/dist/cell-render.js +25 -6
  55. package/dist/clipboard.d.ts +2 -2
  56. package/dist/clipboard.js +72 -23
  57. package/dist/columns.js +7 -2
  58. package/dist/conditional-formatting.d.ts +2 -0
  59. package/dist/conditional-formatting.js +2 -0
  60. package/dist/core.d.ts +22 -6
  61. package/dist/core.js +478 -40
  62. package/dist/editing.js +24 -0
  63. package/dist/export-format.d.ts +8 -0
  64. package/dist/export-format.js +23 -16
  65. package/dist/grid-messages.d.ts +4 -0
  66. package/dist/grid-messages.js +4 -0
  67. package/dist/headless.d.ts +5 -0
  68. package/dist/headless.js +12 -0
  69. package/dist/history.d.ts +21 -0
  70. package/dist/history.js +34 -4
  71. package/dist/index.d.ts +1 -1
  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 +6 -0
  77. package/dist/row-resize.js +22 -1
  78. package/dist/selection.js +1 -1
  79. package/dist/server-block-cache.d.ts +5 -0
  80. package/dist/server-block-cache.js +8 -0
  81. package/dist/server-data-source.d.ts +35 -3
  82. package/dist/server-data-source.js +11 -2
  83. package/dist/server.d.ts +1 -1
  84. package/dist/sparkline.js +7 -5
  85. package/dist/svgrid-wrapper.types.d.ts +33 -0
  86. package/dist/validate.js +9 -1
  87. package/dist/virtualization/column-virtualizer.d.ts +1 -0
  88. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
  89. package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
  90. package/dist/virtualization/virtualizer.d.ts +3 -0
  91. package/dist/virtualization/virtualizer.js +14 -1
  92. package/package.json +6 -1
  93. package/src/GridMenus.svelte +34 -4
  94. package/src/SvGrid.controller.svelte.ts +236 -57
  95. package/src/SvGrid.css +143 -7
  96. package/src/SvGrid.helpers.ts +2 -4
  97. package/src/SvGrid.svelte +223 -38
  98. package/src/SvGrid.types.ts +77 -4
  99. package/src/SvGridDropdown.svelte +20 -0
  100. package/src/SvModal.svelte +22 -0
  101. package/src/SvModal.test.ts +30 -1
  102. package/src/a11y/dismissable.test.ts +10 -1
  103. package/src/a11y/dismissable.ts +22 -2
  104. package/src/build-api.ts +85 -27
  105. package/src/cell-formatting.test.ts +17 -0
  106. package/src/cell-formatting.ts +49 -1
  107. package/src/cell-render.test.ts +24 -2
  108. package/src/cell-render.ts +24 -8
  109. package/src/clipboard.test.ts +107 -0
  110. package/src/clipboard.ts +62 -23
  111. package/src/columns.test.ts +4 -4
  112. package/src/columns.ts +7 -2
  113. package/src/conditional-formatting.test.ts +8 -0
  114. package/src/conditional-formatting.ts +3 -0
  115. package/src/core.sort.test.ts +86 -0
  116. package/src/core.tick-repair.test.ts +287 -0
  117. package/src/core.ts +478 -40
  118. package/src/editing.test.ts +52 -0
  119. package/src/editing.ts +25 -0
  120. package/src/export-dates.test.ts +114 -0
  121. package/src/export-format.ts +26 -14
  122. package/src/grid-messages.test.ts +7 -0
  123. package/src/grid-messages.ts +10 -0
  124. package/src/headless.ts +24 -0
  125. package/src/history.test.ts +49 -1
  126. package/src/history.ts +48 -2
  127. package/src/index.ts +2 -0
  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 +6 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.ts +1 -1
  137. package/src/server-block-cache.test.ts +27 -0
  138. package/src/server-block-cache.ts +13 -0
  139. package/src/server-data-source.infinite.test.ts +62 -0
  140. package/src/server-data-source.test.ts +19 -0
  141. package/src/server-data-source.ts +48 -4
  142. package/src/server.ts +2 -0
  143. package/src/sparkline.test.ts +7 -0
  144. package/src/sparkline.ts +6 -4
  145. package/src/svgrid-wrapper.types.ts +32 -0
  146. package/src/svgrid.api.test.ts +21 -0
  147. package/src/svgrid.charting.test.ts +12 -4
  148. package/src/svgrid.context-menu.test.ts +12 -0
  149. package/src/svgrid.data-push.svelte.test.ts +94 -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 +1 -1
  153. package/src/svgrid.live-update-paths.svelte.test.ts +186 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  156. package/src/svgrid.row-pinning.test.ts +15 -1
  157. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  158. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  159. package/src/svgrid.upsell-license.test.ts +1 -1
  160. package/src/test-setup.ts +12 -0
  161. package/src/transaction.test.ts +58 -0
  162. package/src/validate.ts +7 -1
  163. package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
  164. package/src/virtualization/virtualizer.test.ts +44 -0
  165. package/src/virtualization/virtualizer.ts +13 -1
  166. package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Sticky group rows: the group a row belongs to stays under the header
3
+ * while its rows scroll past. Under virtualization the band renders a copy
4
+ * of each ancestor; without it the real rows stick. Driven through the
5
+ * `serverGroup` seam with plain data, so no row model is needed to say
6
+ * which rows are groups and how deep they sit.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { mount, unmount } from 'svelte'
10
+ import SvGrid from './SvGrid.svelte'
11
+ import { createCoreRowModel, tableFeatures } from './index'
12
+ import type { ColumnDef, SvGridApi } from './index'
13
+
14
+ type Row = { id: string; name: string; kind: 'group' | 'leaf'; level: number }
15
+ const features = tableFeatures({})
16
+ const cols: ColumnDef<typeof features, Row>[] = [
17
+ { field: 'name', header: 'Name', width: 200 },
18
+ { field: 'id', header: 'Id', width: 80 },
19
+ ]
20
+
21
+ const tick = () => new Promise<void>((r) => setTimeout(r))
22
+
23
+ /** A(0) > A1(1) > 20 leaves, A2(1) > 20 leaves, B(0) > B1(1) > 20 leaves: 65 rows. */
24
+ function tree(): Row[] {
25
+ const out: Row[] = []
26
+ let n = 0
27
+ const leaves = (under: string) => {
28
+ for (let i = 0; i < 20; i += 1) out.push({ id: `${under}-${i}`, name: `leaf ${n++}`, kind: 'leaf', level: 2 })
29
+ }
30
+ out.push({ id: 'A', name: 'A', kind: 'group', level: 0 })
31
+ out.push({ id: 'A1', name: 'A1', kind: 'group', level: 1 })
32
+ leaves('A1')
33
+ out.push({ id: 'A2', name: 'A2', kind: 'group', level: 1 })
34
+ leaves('A2')
35
+ out.push({ id: 'B', name: 'B', kind: 'group', level: 0 })
36
+ out.push({ id: 'B1', name: 'B1', kind: 'group', level: 1 })
37
+ leaves('B1')
38
+ return out
39
+ }
40
+
41
+ function mountGrid(extra: Record<string, unknown>) {
42
+ return new Promise<{ api: SvGridApi<typeof features, Row>; target: HTMLElement; destroy: () => void }>((res, rej) => {
43
+ const target = document.createElement('div')
44
+ document.body.appendChild(target)
45
+ const app = mount(SvGrid, {
46
+ target,
47
+ props: {
48
+ data: tree(),
49
+ columns: cols,
50
+ features,
51
+ _rowModels: { coreRowModel: createCoreRowModel() },
52
+ getRowId: (r: Row) => r.id,
53
+ rowHeight: 30,
54
+ containerHeight: 200,
55
+ stickyGroupRows: true,
56
+ serverGroup: {
57
+ isGroup: (r: Row) => r.kind === 'group',
58
+ level: (r: Row) => r.level,
59
+ expanded: () => true,
60
+ onToggle: () => {},
61
+ },
62
+ onApiReady(api: SvGridApi<typeof features, Row>) {
63
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
64
+ },
65
+ ...extra,
66
+ } as any,
67
+ })
68
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
69
+ })
70
+ }
71
+
72
+ async function scrollTo(target: HTMLElement, top: number) {
73
+ const container = target.querySelector('.sv-grid-container') as HTMLElement
74
+ container.scrollTop = top
75
+ container.dispatchEvent(new Event('scroll'))
76
+ // The virtualizer syncs to the scroll position on the next animation frame.
77
+ await new Promise<void>((r) => requestAnimationFrame(() => r()))
78
+ await tick()
79
+ await tick()
80
+ }
81
+
82
+ const stickyNames = (target: HTMLElement) =>
83
+ [...target.querySelectorAll<HTMLElement>('tr.sv-grid-row-sticky-group')].map((tr) => tr.textContent!.replace(/\s+/g, ' ').trim().split(' ')[0])
84
+
85
+ describe('stickyGroupRows under virtualization', () => {
86
+ it('renders copies of the ancestors of the first row under the header, deepest last', async () => {
87
+ const { target, destroy } = await mountGrid({ virtualization: true })
88
+ try {
89
+ await tick()
90
+ // Row 0 is A itself: nothing to hold.
91
+ expect(stickyNames(target)).toEqual([])
92
+ // Row 10 is a leaf under A1: A, then A1.
93
+ await scrollTo(target, 300)
94
+ expect(stickyNames(target)).toEqual(['A', 'A1'])
95
+ const band = [...target.querySelectorAll<HTMLElement>('tr.sv-grid-row-sticky-group')]
96
+ expect(band[0]!.getAttribute('aria-hidden')).toBe('true')
97
+ expect(band[1]!.classList.contains('sv-grid-row-sticky-group-last')).toBe(true)
98
+ // The second sits a row under the first.
99
+ expect(parseInt(band[1]!.style.top, 10) - parseInt(band[0]!.style.top, 10)).toBe(30)
100
+ // A copy carries no cell ids, so the real row's id stays unique.
101
+ expect(band[0]!.querySelector('[id]')).toBeNull()
102
+ // Row 30 is a leaf under A2.
103
+ await scrollTo(target, 900)
104
+ expect(stickyNames(target)).toEqual(['A', 'A2'])
105
+ // Row 50 is a leaf under B1.
106
+ await scrollTo(target, 1500)
107
+ expect(stickyNames(target)).toEqual(['B', 'B1'])
108
+ // Back to the top: the band is gone.
109
+ await scrollTo(target, 0)
110
+ expect(stickyNames(target)).toEqual([])
111
+ } finally {
112
+ destroy()
113
+ }
114
+ })
115
+
116
+ it('is off unless asked for', async () => {
117
+ const { target, destroy } = await mountGrid({ virtualization: true, stickyGroupRows: false })
118
+ try {
119
+ await scrollTo(target, 300)
120
+ expect(stickyNames(target)).toEqual([])
121
+ } finally {
122
+ destroy()
123
+ }
124
+ })
125
+ })
126
+
127
+ describe('stickyGroupRows without virtualization', () => {
128
+ it('makes the real ancestor rows sticky instead of copying them', async () => {
129
+ const { target, destroy } = await mountGrid({ virtualization: false })
130
+ try {
131
+ await scrollTo(target, 300)
132
+ const sticky = [...target.querySelectorAll<HTMLElement>('tr.sv-grid-row-sticky-group')]
133
+ expect(sticky.map((tr) => tr.textContent!.trim().split(/\s+/)[0])).toEqual(['A', 'A1'])
134
+ // They are the rows themselves: cells keep their row index.
135
+ expect(sticky[0]!.querySelector('[data-svgrid-row]')?.getAttribute('data-svgrid-row')).toBe('0')
136
+ expect(sticky[1]!.querySelector('[data-svgrid-row]')?.getAttribute('data-svgrid-row')).toBe('1')
137
+ expect(sticky[0]!.getAttribute('aria-hidden')).toBeNull()
138
+ // Every row renders exactly once.
139
+ const rows = [...target.querySelectorAll('tbody.sv-grid-body tr.sv-grid-row')]
140
+ expect(rows).toHaveLength(65)
141
+ } finally {
142
+ destroy()
143
+ }
144
+ })
145
+ })
@@ -95,7 +95,7 @@ describe('Enterprise upsell notes', () => {
95
95
  })
96
96
 
97
97
  const note = target.querySelector(LICENSE)!
98
- // Soft-gated: the feature runs without a key and the grid watermarks.
98
+ // The feature runs without a key and the grid watermarks.
99
99
  // The wording must not imply it is inert, because it is not.
100
100
  expect(note.textContent, name).toContain('license key')
101
101
  expect(note.textContent, name).toContain('watermark')
package/src/test-setup.ts CHANGED
@@ -59,4 +59,16 @@ if (typeof Element !== 'undefined' && !Element.prototype.animate) {
59
59
  }
60
60
  }
61
61
 
62
+ // jsdom has no canvas: getContext() returns null and prints "Not implemented:
63
+ // HTMLCanvasElement's getContext() method" through its virtual console on
64
+ // every call, hundreds of lines per run that bury the real warnings. The
65
+ // callers (the sheet's text measuring, the picture rasteriser) already treat a
66
+ // null context as "no canvas", so answering null quietly changes nothing a
67
+ // test can see; a test that needs a context still spies on the prototype.
68
+ if (typeof HTMLCanvasElement !== 'undefined') {
69
+ ;(HTMLCanvasElement.prototype as any).getContext = function () {
70
+ return null
71
+ }
72
+ }
73
+
62
74
  export {}
@@ -97,4 +97,62 @@ describe('api.applyTransaction', () => {
97
97
  destroy()
98
98
  }
99
99
  })
100
+ it('removes a row that the same transaction also updates, by id or by reference', async () => {
101
+ const { api, destroy } = await mountGrid()
102
+ try {
103
+ const byId = api.applyTransaction({ update: [{ id: 'a', symbol: 'AAPL', price: 1 }], remove: ['a'] })
104
+ await tick()
105
+ expect(byId).toEqual({ added: 0, updated: 1, removed: 1 })
106
+ expect(api.getData().map((o) => o.id)).toEqual(['b', 'c'])
107
+ const ref = api.getData()[0]! // 'b'
108
+ const byRef = api.applyTransaction({ update: [{ id: 'b', symbol: 'MSFT', price: 2 }], remove: [ref] })
109
+ await tick()
110
+ expect(byRef).toEqual({ added: 0, updated: 1, removed: 1 })
111
+ expect(api.getData().map((o) => o.id)).toEqual(['c'])
112
+ } finally {
113
+ destroy()
114
+ }
115
+ })
116
+
117
+ it('an update batch looks its rows up by id instead of walking the array', async () => {
118
+ // The id -> index map is built once per data array and carried across
119
+ // update-only transactions, so the second tick calls getRowId only for
120
+ // the rows in the batch. Before, every transaction called it for every
121
+ // row - 100k calls per tick on a 100k-row feed.
122
+ const target = document.createElement('div')
123
+ document.body.appendChild(target)
124
+ let calls = 0
125
+ let captured: SvGridApi<typeof features, Order> | null = null
126
+ const app = mount(SvGrid, {
127
+ target,
128
+ props: {
129
+ data: seed,
130
+ columns,
131
+ features,
132
+ getRowId: (o: Order) => { calls++; return o.id },
133
+ _rowModels: { coreRowModel: createCoreRowModel(), filteredRowModel: createFilteredRowModel(), sortedRowModel: createSortedRowModel(sortFns) },
134
+ containerHeight: 400,
135
+ virtualization: false,
136
+ onApiReady(api: SvGridApi<typeof features, Order>) { captured = api },
137
+ } as any,
138
+ })
139
+ try {
140
+ await tick()
141
+ const api = captured!
142
+ api.applyTransaction({ update: [{ id: 'b', symbol: 'MSFT', price: 201 }] })
143
+ await tick()
144
+ const afterFirst = calls
145
+ api.applyTransaction({ update: [{ id: 'b', symbol: 'MSFT', price: 202 }] })
146
+ await tick()
147
+ // One call: the id of the update itself. The row model's own getRowId
148
+ // calls for the replaced row are not part of this count because the
149
+ // map answered the lookup.
150
+ expect(calls - afterFirst).toBeLessThanOrEqual(2)
151
+ expect(api.getData()[1]!.price).toBe(202)
152
+ } finally {
153
+ unmount(app)
154
+ target.remove()
155
+ }
156
+ })
100
157
  })
158
+
package/src/validate.ts CHANGED
@@ -77,7 +77,13 @@ export function validateGridConfig<
77
77
  // (a key absent from row 0 but present later) does not trip it. A row
78
78
  // model shows placeholder rows before its first block lands; those carry
79
79
  // no data keys at all and say nothing about the columns.
80
- const sample = (input.data ?? []).filter((row) => !rowPlaceholderState(row)).slice(0, FIELD_SAMPLE_ROWS)
80
+ // Stops at the sample size: this runs on every data change, and a filter
81
+ // over all the rows first was O(n) per tick on a live feed.
82
+ const sample: TData[] = []
83
+ for (const row of input.data ?? []) {
84
+ if (sample.length >= FIELD_SAMPLE_ROWS) break
85
+ if (!rowPlaceholderState(row)) sample.push(row)
86
+ }
81
87
  // Under server-side grouping the first rows are group rows, which carry
82
88
  // a key and aggregates and none of the leaf fields; nothing to judge by.
83
89
  if (sample.length && !input.serverGroup) {
@@ -14,6 +14,7 @@ export function createSvelteVirtualizer(options: VirtualizerOptions) {
14
14
  return version
15
15
  },
16
16
  setOptions: virtualizer.setOptions,
17
+ measure: virtualizer.measure,
17
18
  setScrollOffset: virtualizer.setScrollOffset,
18
19
  setViewportHeight: virtualizer.setViewportHeight,
19
20
  scrollToIndex: virtualizer.scrollToIndex,
@@ -44,4 +44,48 @@ describe('virtualizer', () => {
44
44
  v.scrollToIndex(9_999)
45
45
  expect(v.getState().scrollOffset).toBe(2_700)
46
46
  })
47
+
48
+ it('re-measures a stable size function passed back through setOptions (#102)', () => {
49
+ const heights = new Map<number, number>()
50
+ const estimateSize = (i: number) => heights.get(i) ?? 20
51
+ const v = createVirtualizer({ count: 10, estimateSize, viewportHeight: 100 })
52
+ expect(v.getTotalSize()).toBe(200)
53
+
54
+ heights.set(0, 120)
55
+ v.setOptions({ estimateSize })
56
+ expect(v.getSizeForIndex(0)).toBe(120)
57
+ expect(v.getTotalSize()).toBe(300) // 9 x 20 + 120
58
+ expect(v.getOffsetForIndex(5)).toBe(200) // 120 + 4 x 20
59
+ })
60
+
61
+ it('measure() re-reads sizes and notifies subscribers', () => {
62
+ const heights = new Map<number, number>()
63
+ const v = createVirtualizer({
64
+ count: 10,
65
+ estimateSize: (i: number) => heights.get(i) ?? 20,
66
+ viewportHeight: 100,
67
+ })
68
+ let notified = 0
69
+ v.subscribe(() => (notified += 1))
70
+
71
+ heights.set(3, 50)
72
+ v.measure()
73
+ expect(v.getTotalSize()).toBe(230)
74
+ expect(v.getOffsetForIndex(4)).toBe(110)
75
+ expect(notified).toBe(1)
76
+ })
77
+
78
+ it('keeps the offset cache across scrolls', () => {
79
+ let calls = 0
80
+ const v = createVirtualizer({
81
+ count: 1000,
82
+ estimateSize: () => (calls++, 20),
83
+ viewportHeight: 100,
84
+ })
85
+ const afterBuild = calls
86
+ v.setScrollOffset(500)
87
+ v.setScrollOffset(900)
88
+ v.setViewportHeight(200)
89
+ expect(calls).toBe(afterBuild)
90
+ })
47
91
  })
@@ -261,8 +261,20 @@ export function createVirtualizer(initial: VirtualizerOptions) {
261
261
  return {
262
262
  setOptions(next: Partial<VirtualizerOptions>) {
263
263
  const merged = { ...options, ...next }
264
- if (sameOptions(options, merged)) return
264
+ // Passing a size function means "sizes may have changed", even when it is
265
+ // the same reference: the documented variable-height pattern is a stable
266
+ // function reading a mutable map, and keying the cache on identity alone
267
+ // left the offsets and total stale after a row grew (#102).
268
+ const remeasure = typeof next.estimateSize === 'function'
269
+ if (!remeasure && sameOptions(options, merged)) return
265
270
  options = merged
271
+ if (remeasure) offsetCache = null
272
+ recalc()
273
+ },
274
+ /** Drop the cached offsets and re-read every size from `estimateSize`.
275
+ * Call after the sizes a stable size function reports have changed. */
276
+ measure() {
277
+ offsetCache = null
266
278
  recalc()
267
279
  },
268
280
  setScrollOffset(scrollOffset: number) {