@svgrid/grid 1.0.2 → 1.1.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 (143) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +570 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +429 -0
  8. package/dist/SvGrid.controller.svelte.js +1732 -0
  9. package/dist/SvGrid.css +1709 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.svelte +344 -7043
  13. package/dist/SvGrid.svelte.d.ts +4 -357
  14. package/dist/SvGrid.types.d.ts +436 -0
  15. package/dist/SvGrid.types.js +1 -0
  16. package/dist/SvGridChart.svelte +1060 -23
  17. package/dist/SvGridChart.svelte.d.ts +17 -0
  18. package/dist/build-api.d.ts +5 -0
  19. package/dist/build-api.js +527 -0
  20. package/dist/cell-render.d.ts +15 -0
  21. package/dist/cell-render.js +246 -0
  22. package/dist/cell-values.d.ts +28 -0
  23. package/dist/cell-values.js +89 -0
  24. package/dist/chart.d.ts +370 -3
  25. package/dist/chart.js +1135 -42
  26. package/dist/clipboard.d.ts +15 -0
  27. package/dist/clipboard.js +356 -0
  28. package/dist/columns.d.ts +30 -0
  29. package/dist/columns.js +277 -0
  30. package/dist/core.d.ts +1 -1
  31. package/dist/css.d.ts +3 -0
  32. package/dist/editing.d.ts +24 -0
  33. package/dist/editing.js +343 -0
  34. package/dist/editors/cell-editors.d.ts +1 -1
  35. package/dist/facet-buckets.d.ts +13 -0
  36. package/dist/facet-buckets.js +54 -0
  37. package/dist/features.d.ts +5 -0
  38. package/dist/features.js +30 -0
  39. package/dist/filter-operators.d.ts +16 -0
  40. package/dist/filter-operators.js +69 -0
  41. package/dist/hyperformula-adapter.d.ts +82 -0
  42. package/dist/hyperformula-adapter.js +73 -0
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.js +5 -2
  45. package/dist/keyboard-handlers.d.ts +7 -0
  46. package/dist/keyboard-handlers.js +197 -0
  47. package/dist/menus.d.ts +40 -0
  48. package/dist/menus.js +389 -0
  49. package/dist/named-views.d.ts +27 -0
  50. package/dist/named-views.js +39 -0
  51. package/dist/row-resize.d.ts +43 -0
  52. package/dist/row-resize.js +158 -0
  53. package/dist/scroll-sync.d.ts +9 -0
  54. package/dist/scroll-sync.js +86 -0
  55. package/dist/selection.d.ts +26 -0
  56. package/dist/selection.js +387 -0
  57. package/dist/spreadsheet.d.ts +80 -0
  58. package/dist/spreadsheet.js +194 -0
  59. package/dist/summaries.d.ts +12 -0
  60. package/dist/summaries.js +65 -0
  61. package/dist/svgrid-wrapper.types.d.ts +12 -1
  62. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  63. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  64. package/dist/svgrid.context-menu.test.d.ts +1 -0
  65. package/dist/svgrid.context-menu.test.js +102 -0
  66. package/dist/svgrid.new-features.wrapper.test.js +30 -4
  67. package/dist/svgrid.wrapper.test.js +27 -1
  68. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  69. package/dist/virtualization/scroll-scaling.d.ts +28 -0
  70. package/dist/virtualization/scroll-scaling.js +64 -0
  71. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  72. package/dist/virtualization/scroll-scaling.test.js +86 -0
  73. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  74. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  75. package/dist/virtualization/virtualizer.d.ts +7 -0
  76. package/dist/virtualization/virtualizer.js +30 -0
  77. package/package.json +1 -1
  78. package/src/GridFooter.svelte +164 -0
  79. package/src/GridMenus.svelte +570 -0
  80. package/src/SvGrid.controller.svelte.ts +2195 -0
  81. package/src/SvGrid.css +1747 -0
  82. package/src/SvGrid.helpers.test.ts +415 -0
  83. package/src/SvGrid.helpers.ts +185 -0
  84. package/src/SvGrid.svelte +348 -7043
  85. package/src/SvGrid.types.ts +456 -0
  86. package/src/SvGridChart.svelte +1060 -23
  87. package/src/build-api.coverage.test.ts +532 -0
  88. package/src/build-api.ts +663 -0
  89. package/src/cell-render.test.ts +451 -0
  90. package/src/cell-render.ts +426 -0
  91. package/src/cell-values.ts +114 -0
  92. package/src/chart-export.test.ts +370 -0
  93. package/src/chart.coverage.test.ts +814 -0
  94. package/src/chart.ts +1352 -47
  95. package/src/clipboard.test.ts +731 -0
  96. package/src/clipboard.ts +524 -0
  97. package/src/collaboration.coverage.test.ts +220 -0
  98. package/src/columns.test.ts +702 -0
  99. package/src/columns.ts +419 -0
  100. package/src/core.ts +8 -0
  101. package/src/css.d.ts +3 -0
  102. package/src/editing.test.ts +837 -0
  103. package/src/editing.ts +513 -0
  104. package/src/editors/cell-editors.coverage.test.ts +156 -0
  105. package/src/editors/cell-editors.ts +1 -0
  106. package/src/facet-buckets.test.ts +353 -0
  107. package/src/facet-buckets.ts +67 -0
  108. package/src/features.ts +128 -0
  109. package/src/filter-operators.test.ts +174 -0
  110. package/src/filter-operators.ts +87 -0
  111. package/src/hyperformula-adapter.test.ts +256 -0
  112. package/src/hyperformula-adapter.ts +124 -0
  113. package/src/index.ts +37 -0
  114. package/src/keyboard-handlers.coverage.test.ts +560 -0
  115. package/src/keyboard-handlers.ts +353 -0
  116. package/src/keyboard.ts +97 -97
  117. package/src/menus.test.ts +620 -0
  118. package/src/menus.ts +554 -0
  119. package/src/named-views.coverage.test.ts +210 -0
  120. package/src/named-views.ts +48 -0
  121. package/src/row-resize.test.ts +369 -0
  122. package/src/row-resize.ts +171 -0
  123. package/src/scroll-sync.test.ts +330 -0
  124. package/src/scroll-sync.ts +216 -0
  125. package/src/selection.test.ts +722 -0
  126. package/src/selection.ts +545 -0
  127. package/src/server-data-source.coverage.test.ts +180 -0
  128. package/src/spreadsheet.test.ts +445 -0
  129. package/src/spreadsheet.ts +246 -0
  130. package/src/summaries.ts +204 -0
  131. package/src/sv-grid-scrollbar.ts +13 -1
  132. package/src/svgrid-wrapper.types.ts +12 -1
  133. package/src/svgrid.behavior.test.ts +22 -0
  134. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  135. package/src/svgrid.context-menu.test.ts +126 -0
  136. package/src/svgrid.interaction.test.ts +30 -0
  137. package/src/svgrid.new-features.wrapper.test.ts +65 -4
  138. package/src/svgrid.wrapper.test.ts +27 -1
  139. package/src/test-setup.ts +9 -6
  140. package/src/virtualization/scroll-scaling.test.ts +148 -0
  141. package/src/virtualization/scroll-scaling.ts +121 -0
  142. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  143. package/src/virtualization/virtualizer.ts +26 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Right-click context menu (Track A). Mounts a grid with `contextMenu`,
3
+ * fires a synthetic `contextmenu` event on a cell, and asserts the menu
4
+ * opens with the expected items and that actions/close work.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount } from 'svelte'
8
+ import SvGrid from './SvGrid.svelte'
9
+ import {
10
+ createCoreRowModel,
11
+ createFilteredRowModel,
12
+ createSortedRowModel,
13
+ rowSelectionFeature,
14
+ rowSortingFeature,
15
+ sortFns,
16
+ tableFeatures,
17
+ } from './index'
18
+ import type { ColumnDef, SvGridApi } from './index'
19
+ import type { ContextMenuItem } from './SvGrid.types'
20
+
21
+ type Row = { id: number; name: string; team: string }
22
+ const features = tableFeatures({ rowSortingFeature, rowSelectionFeature })
23
+ const rows: Row[] = Array.from({ length: 6 }, (_, i) => ({
24
+ id: i + 1,
25
+ name: `Person ${i + 1}`,
26
+ team: ['A', 'B'][i % 2]!,
27
+ }))
28
+ const cols: ColumnDef<typeof features, Row>[] = [
29
+ { field: 'name', header: 'Name', width: 200, editorType: 'text' },
30
+ { field: 'team', header: 'Team', width: 160, editorType: 'text' },
31
+ ]
32
+ const tick = () => new Promise<void>((r) => queueMicrotask(r))
33
+
34
+ function mountGrid(contextMenu: unknown) {
35
+ return new Promise<{ api: SvGridApi<typeof features, Row>; target: HTMLElement; destroy: () => void }>(
36
+ (res, rej) => {
37
+ const target = document.createElement('div')
38
+ document.body.appendChild(target)
39
+ const app = mount(SvGrid, {
40
+ target,
41
+ props: {
42
+ data: rows,
43
+ columns: cols,
44
+ features,
45
+ _rowModels: {
46
+ coreRowModel: createCoreRowModel(),
47
+ filteredRowModel: createFilteredRowModel(),
48
+ sortedRowModel: createSortedRowModel(sortFns),
49
+ },
50
+ rowHeight: 32,
51
+ containerHeight: 400,
52
+ virtualization: false,
53
+ enableCellSelection: true,
54
+ contextMenu,
55
+ onApiReady(api: SvGridApi<typeof features, Row>) {
56
+ res({ api, target, destroy: () => { unmount(app); target.remove() } })
57
+ },
58
+ } as any,
59
+ })
60
+ queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
61
+ },
62
+ )
63
+ }
64
+
65
+ function rightClickFirstCell(target: HTMLElement) {
66
+ const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]') as HTMLElement
67
+ const r = cell.getBoundingClientRect()
68
+ cell.dispatchEvent(
69
+ new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: r.x + 4, clientY: r.y + 4 }),
70
+ )
71
+ return cell
72
+ }
73
+
74
+ describe('SvGrid context menu', () => {
75
+ it('opens the default menu on right-click with the built-in items', async () => {
76
+ const { target, destroy } = await mountGrid(true)
77
+ await tick()
78
+ rightClickFirstCell(target)
79
+ await tick()
80
+ const menu = target.querySelector('.sv-grid-context-menu')
81
+ expect(menu).not.toBeNull()
82
+ const labels = [...menu!.querySelectorAll('.sv-grid-menu-item')].map((b) => b.textContent?.trim())
83
+ expect(labels).toContain('Copy')
84
+ expect(labels).toContain('Paste')
85
+ expect(labels).toContain('Remove row')
86
+ expect(labels).toContain('Remove column')
87
+ destroy()
88
+ })
89
+
90
+ it('does not open when contextMenu is omitted (native menu shows)', async () => {
91
+ const { target, destroy } = await mountGrid(undefined)
92
+ await tick()
93
+ const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]') as HTMLElement
94
+ const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
95
+ cell.dispatchEvent(ev)
96
+ await tick()
97
+ expect(target.querySelector('.sv-grid-context-menu')).toBeNull()
98
+ expect(ev.defaultPrevented).toBe(false)
99
+ destroy()
100
+ })
101
+
102
+ it('renders only the configured custom items and runs the action', async () => {
103
+ let ran = 0
104
+ const items: ContextMenuItem<Row>[] = [
105
+ 'copy',
106
+ 'separator',
107
+ { key: 'flag', label: 'Flag row', action: () => { ran += 1 } },
108
+ ]
109
+ const { target, destroy } = await mountGrid(items)
110
+ await tick()
111
+ rightClickFirstCell(target)
112
+ await tick()
113
+ const labels = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].map((b) =>
114
+ b.textContent?.trim(),
115
+ )
116
+ expect(labels).toEqual(['Copy', 'Flag row'])
117
+ const flag = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find(
118
+ (b) => b.textContent?.trim() === 'Flag row',
119
+ ) as HTMLElement
120
+ flag.click()
121
+ expect(ran).toBe(1)
122
+ await tick()
123
+ expect(target.querySelector('.sv-grid-context-menu')).toBeNull()
124
+ destroy()
125
+ })
126
+ })
@@ -309,6 +309,36 @@ describe('SvGrid interactions - cell pointer events', () => {
309
309
  destroy()
310
310
  }
311
311
  })
312
+
313
+ // issue #23: a mouse drag extends the cell range, but a touch drag must NOT -
314
+ // it should leave the single tapped cell selected so the finger-drag scrolls.
315
+ async function dragSelectCount(pointerType: 'mouse' | 'touch'): Promise<number> {
316
+ const { target, destroy } = await mountInteractive()
317
+ try {
318
+ const a = target.querySelector('[data-svgrid-row="0"][data-svgrid-col="0"]') as HTMLElement | null
319
+ const c = target.querySelector('[data-svgrid-row="0"][data-svgrid-col="2"]') as HTMLElement | null
320
+ if (!a || !c) return -1 // jsdom didn't lay the cells out; caller skips
321
+ a.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0, pointerType }))
322
+ await tick()
323
+ c.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1, pointerType }))
324
+ await tick()
325
+ return target.querySelectorAll('[data-selected-range="true"]').length
326
+ } finally {
327
+ destroy()
328
+ }
329
+ }
330
+
331
+ it('mouse drag extends the selection across cells', async () => {
332
+ const n = await dragSelectCount('mouse')
333
+ if (n < 0) return
334
+ expect(n).toBeGreaterThan(1) // (0,0) -> (0,2) selects the row's 3 cells
335
+ })
336
+
337
+ it('touch drag does not start a range selection (issue #23)', async () => {
338
+ const n = await dragSelectCount('touch')
339
+ if (n < 0) return
340
+ expect(n).toBeLessThanOrEqual(1) // only the tapped cell; finger-drag scrolls
341
+ })
312
342
  })
313
343
 
314
344
  describe('SvGrid interactions - copy', () => {
@@ -16,8 +16,34 @@ import { readFileSync } from 'node:fs'
16
16
  import { resolve } from 'node:path'
17
17
  import { describe, expect, it } from 'vitest'
18
18
 
19
- const sourcePath = resolve(__dirname, './SvGrid.svelte')
20
- const source = readFileSync(sourcePath, 'utf-8')
19
+ // The wrapper implementation spans SvGrid.svelte plus its extracted
20
+ // sibling modules (types + pure helpers). Concatenate them so these
21
+ // "the wrapper declares/exposes X" assertions hold wherever the code
22
+ // physically lives.
23
+ const source = [
24
+ './SvGrid.svelte',
25
+ './SvGrid.controller.svelte.ts',
26
+ './GridMenus.svelte',
27
+ './GridFooter.svelte',
28
+ './SvGrid.types.ts',
29
+ './SvGrid.helpers.ts',
30
+ './filter-operators.ts',
31
+ './facet-buckets.ts',
32
+ './cell-values.ts',
33
+ './clipboard.ts',
34
+ './build-api.ts',
35
+ './columns.ts',
36
+ './selection.ts',
37
+ './editing.ts',
38
+ './cell-render.ts',
39
+ './menus.ts',
40
+ './summaries.ts',
41
+ './keyboard-handlers.ts',
42
+ './scroll-sync.ts',
43
+ './features.ts',
44
+ ]
45
+ .map((p) => readFileSync(resolve(__dirname, p), 'utf-8'))
46
+ .join('\n')
21
47
  const typesPath = resolve(__dirname, './svgrid-wrapper.types.ts')
22
48
  const types = readFileSync(typesPath, 'utf-8')
23
49
 
@@ -143,7 +169,7 @@ describe('SvGrid wrapper - multi-cell paste + cut + delete', () => {
143
169
 
144
170
  it('wires Ctrl/Cmd+X to cutSelectionToClipboard', () => {
145
171
  expect(source).toMatch(/lower === "x"/)
146
- expect(source).toMatch(/void cutSelectionToClipboard\(\)/)
172
+ expect(source).toMatch(/void (?:ctx\.)?cutSelectionToClipboard\(\)/)
147
173
  expect(source).toMatch(/async function cutSelectionToClipboard/)
148
174
  // Confirm both calls live inside the cutSelectionToClipboard body
149
175
  // (copy first, then clear). A comment between them is fine.
@@ -159,6 +185,41 @@ describe('SvGrid wrapper - multi-cell paste + cut + delete', () => {
159
185
 
160
186
  it('wires Delete + Backspace to clearSelectedCells (without clipboard interaction)', () => {
161
187
  expect(source).toMatch(/event\.key === "Delete" \|\| event\.key === "Backspace"/)
162
- expect(source).toMatch(/if \(clearSelectedCells\(\)\)\s*\{/)
188
+ expect(source).toMatch(/if \((?:ctx\.)?clearSelectedCells\(\)\)\s*\{/)
189
+ })
190
+
191
+ // Regression: the controller's ctx is an object literal of getters, and the
192
+ // copy/cut/paste handlers in keyboard-handlers + menus reach the functions
193
+ // via `ctx.<name>()`. A refactor once shipped the `ctx.cutSelectionToClipboard`
194
+ // / `ctx.pasteFromClipboard` CALLS but dropped their getters, so those threw
195
+ // "is not a function" at runtime - copy worked, cut + paste were dead. Assert
196
+ // every clipboard function called through ctx has a matching getter.
197
+ it('exposes a ctx getter for every clipboard command it calls through ctx', () => {
198
+ for (const fn of [
199
+ 'copySelectionToClipboard',
200
+ 'cutSelectionToClipboard',
201
+ 'pasteFromClipboard',
202
+ 'onGridPaste',
203
+ 'clearSelectedCells',
204
+ ]) {
205
+ expect(source).toMatch(new RegExp(`get ${fn}\\(\\)\\s*\\{\\s*return ${fn};?\\s*\\}`))
206
+ }
207
+ })
208
+
209
+ it('copy/cut fall back to execCommand when navigator.clipboard is absent (insecure context)', () => {
210
+ expect(source).toMatch(/function writeClipboardText\(/)
211
+ expect(source).toMatch(/document\.execCommand\("copy"\)/)
212
+ })
213
+
214
+ it('paste falls back to a native paste event when the async API is unavailable', () => {
215
+ // keydown only preventDefaults (and uses the async API) in a secure context;
216
+ // otherwise it lets the browser deliver a native paste event to onGridPaste.
217
+ // Accept either the positive branch or the inverted early-return guard
218
+ // (`if (!navigator.clipboard?.readText) return`) - both gate on the async API.
219
+ expect(source).toMatch(/if \(!?navigator\.clipboard\?\.readText\)/)
220
+ expect(source).toMatch(/function onGridPaste\(event: ClipboardEvent\)/)
221
+ expect(source).toMatch(/clipboardData\?\.getData\("text\/plain"\)/)
222
+ // The grid root wires the handler.
223
+ expect(source).toMatch(/onpaste=\{onGridPaste\}/)
163
224
  })
164
225
  })
@@ -9,7 +9,33 @@ describe('SvGrid wrapper', () => {
9
9
  })
10
10
 
11
11
  it('contains full-wrapper controls and aria-activedescendant wiring', () => {
12
- const source = readFileSync(resolve(__dirname, './SvGrid.svelte'), 'utf-8')
12
+ // The wrapper spans SvGrid.svelte plus its extracted sibling modules
13
+ // (types + pure helpers); read them together so these "contains X"
14
+ // assertions hold wherever the code physically lives.
15
+ const source = [
16
+ './SvGrid.svelte',
17
+ './SvGrid.controller.svelte.ts',
18
+ './GridMenus.svelte',
19
+ './GridFooter.svelte',
20
+ './SvGrid.types.ts',
21
+ './SvGrid.helpers.ts',
22
+ './filter-operators.ts',
23
+ './facet-buckets.ts',
24
+ './cell-values.ts',
25
+ './clipboard.ts',
26
+ './build-api.ts',
27
+ './columns.ts',
28
+ './selection.ts',
29
+ './editing.ts',
30
+ './cell-render.ts',
31
+ './menus.ts',
32
+ './summaries.ts',
33
+ './keyboard-handlers.ts',
34
+ './scroll-sync.ts',
35
+ './features.ts',
36
+ ]
37
+ .map((p) => readFileSync(resolve(__dirname, p), 'utf-8'))
38
+ .join('\n')
13
39
  expect(source).toContain('Filter all rows')
14
40
  expect(source).toContain('Previous')
15
41
  expect(source).toContain('role="status"')
package/src/test-setup.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  // Vitest setup. jsdom doesn't ship ResizeObserver, IntersectionObserver,
2
2
  // or the layout APIs SvGrid touches inside its mount effects. Provide
3
3
  // minimal no-op stubs so component mounting completes without crashing.
4
+ //
5
+ // These assign to globals/prototypes the jsdom env doesn't fully type, so
6
+ // the targets are cast to `any`. We deliberately avoid expect-error
7
+ // suppression directives: when a stub happens to line up with the lib types,
8
+ // such a directive becomes "unused" and svelte-check fails the build (an
9
+ // unused expect-error directive is itself an error).
4
10
 
5
11
  if (typeof globalThis.ResizeObserver === 'undefined') {
6
- // @ts-expect-error - assigning to a globalThis property the env doesn't ship
7
- globalThis.ResizeObserver = class {
12
+ ;(globalThis as any).ResizeObserver = class {
8
13
  observe() {}
9
14
  unobserve() {}
10
15
  disconnect() {}
@@ -12,8 +17,7 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
12
17
  }
13
18
 
14
19
  if (typeof globalThis.IntersectionObserver === 'undefined') {
15
- // @ts-expect-error - same
16
- globalThis.IntersectionObserver = class {
20
+ ;(globalThis as any).IntersectionObserver = class {
17
21
  observe() {}
18
22
  unobserve() {}
19
23
  disconnect() {}
@@ -26,8 +30,7 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
26
30
  // jsdom's HTMLElement.scrollIntoView is a no-op; some grid code calls it
27
31
  // during the first effect. Make sure the method exists on every element.
28
32
  if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
29
- // @ts-expect-error - patching a missing DOM method
30
- Element.prototype.scrollIntoView = function () {}
33
+ ;(Element.prototype as any).scrollIntoView = function () {}
31
34
  }
32
35
 
33
36
  // jsdom returns 0 for offset* and getBoundingClientRect; the grid only uses
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createRowScrollScaling, resolveMaxDomHeight } from './scroll-scaling'
3
+
4
+ const VIEWPORT = 600
5
+ const FALLBACK = 8_000_000
6
+
7
+ describe('createRowScrollScaling - inert (content fits under the cap)', () => {
8
+ // 1M rows * 18px = 18M, under a 33.5M (Chrome) cap -> no scaling.
9
+ const s = createRowScrollScaling(18_000_000, 33_554_400, VIEWPORT)
10
+
11
+ it('is inactive and exposes the true height as the DOM height', () => {
12
+ expect(s.active).toBe(false)
13
+ expect(s.domTotal).toBe(18_000_000)
14
+ })
15
+
16
+ it('both maps are the identity', () => {
17
+ for (const v of [0, 1, 1234, 9_000_000, 17_999_400]) {
18
+ expect(s.domToLogical(v)).toBe(v)
19
+ expect(s.logicalToDom(v)).toBe(v)
20
+ }
21
+ })
22
+ })
23
+
24
+ describe('createRowScrollScaling - active (content exceeds the cap)', () => {
25
+ // 1M rows * 32px = 32M, over Firefox's ~17.9M cap -> scaling engages.
26
+ const trueTotal = 32_000_000
27
+ const maxDom = 17_895_697
28
+ const s = createRowScrollScaling(trueTotal, maxDom, VIEWPORT)
29
+ const domMax = s.domTotal - VIEWPORT
30
+ const logicalMax = trueTotal - VIEWPORT
31
+
32
+ it('caps the DOM height at the browser limit', () => {
33
+ expect(s.active).toBe(true)
34
+ expect(s.domTotal).toBe(maxDom)
35
+ expect(s.domTotal).toBeLessThanOrEqual(maxDom)
36
+ })
37
+
38
+ it('maps the endpoints exactly so the first and last rows are reachable', () => {
39
+ expect(s.domToLogical(0)).toBe(0)
40
+ // The bottom of the DOM scroll range reaches the bottom of the logical range.
41
+ expect(s.domToLogical(domMax)).toBeCloseTo(logicalMax, 3)
42
+ expect(s.logicalToDom(0)).toBe(0)
43
+ expect(s.logicalToDom(logicalMax)).toBeCloseTo(domMax, 3)
44
+ })
45
+
46
+ it('clamps out-of-range inputs instead of overshooting', () => {
47
+ expect(s.domToLogical(-100)).toBe(0)
48
+ expect(s.domToLogical(domMax + 10_000)).toBeCloseTo(logicalMax, 3)
49
+ expect(s.logicalToDom(-100)).toBe(0)
50
+ expect(s.logicalToDom(logicalMax + 10_000)).toBeCloseTo(domMax, 3)
51
+ })
52
+
53
+ it('logicalToDom is the inverse of domToLogical across the range', () => {
54
+ for (let i = 0; i <= 10; i += 1) {
55
+ const domTop = (domMax * i) / 10
56
+ const roundTrip = s.logicalToDom(s.domToLogical(domTop))
57
+ expect(roundTrip).toBeCloseTo(domTop, 2)
58
+ }
59
+ })
60
+
61
+ it('both maps are monotonic non-decreasing', () => {
62
+ let prevL = -1
63
+ let prevD = -1
64
+ for (let i = 0; i <= 20; i += 1) {
65
+ const domTop = (domMax * i) / 20
66
+ const logical = s.domToLogical(domTop)
67
+ expect(logical).toBeGreaterThanOrEqual(prevL)
68
+ prevL = logical
69
+
70
+ const logIn = (logicalMax * i) / 20
71
+ const dom = s.logicalToDom(logIn)
72
+ expect(dom).toBeGreaterThanOrEqual(prevD)
73
+ prevD = dom
74
+ }
75
+ })
76
+ })
77
+
78
+ describe('resolveMaxDomHeight - picks the smaller, real scrollable cap', () => {
79
+ it('keeps the desktop cap when both signals agree', () => {
80
+ // Chrome ~33.5M; offsetHeight and scrollHeight match. 0.5% safety shave.
81
+ const cap = resolveMaxDomHeight(33_554_400, 33_554_400, FALLBACK)
82
+ expect(cap).toBeLessThan(33_554_400)
83
+ expect(cap).toBeGreaterThan(33_000_000)
84
+ })
85
+
86
+ it('uses the SCROLL cap when a phone over-reports offsetHeight', () => {
87
+ // High-DPR mobile: layout says 24M, but the container only scrolls ~6M.
88
+ // Trusting offsetHeight is exactly what stranded the last rows.
89
+ const cap = resolveMaxDomHeight(24_000_000, 6_000_000, FALLBACK)
90
+ expect(cap).toBeLessThanOrEqual(6_000_000)
91
+ expect(cap).toBeGreaterThan(5_900_000)
92
+ })
93
+
94
+ it('shaves a safety margin so the spacer never sits at the exact edge', () => {
95
+ const cap = resolveMaxDomHeight(10_000_000, 10_000_000, FALLBACK)
96
+ expect(cap).toBeLessThan(10_000_000)
97
+ })
98
+
99
+ it('falls back when both readings are junk (jsdom returns 0)', () => {
100
+ expect(resolveMaxDomHeight(0, 0, FALLBACK)).toBe(FALLBACK)
101
+ })
102
+
103
+ it('falls back when the browser did not clamp (returned ~1e9)', () => {
104
+ expect(resolveMaxDomHeight(1_000_000_000, 1_000_000_000, FALLBACK)).toBe(FALLBACK)
105
+ })
106
+
107
+ it('ignores a junk signal but trusts the valid one', () => {
108
+ // offsetHeight unclamped/garbage, scrollHeight is the real cap.
109
+ const cap = resolveMaxDomHeight(1_000_000_000, 5_000_000, FALLBACK)
110
+ expect(cap).toBeLessThanOrEqual(5_000_000)
111
+ expect(cap).toBeGreaterThan(4_900_000)
112
+ })
113
+
114
+ it('feeds straight into the scaler so the last row stays reachable', () => {
115
+ const maxDom = resolveMaxDomHeight(24_000_000, 6_000_000, FALLBACK)
116
+ const trueTotal = 500_000 * 32 // 16M px of rows, over the phone cap
117
+ const s = createRowScrollScaling(trueTotal, maxDom, VIEWPORT)
118
+ expect(s.active).toBe(true)
119
+ expect(s.domTotal).toBeLessThanOrEqual(maxDom)
120
+ const domMax = s.domTotal - VIEWPORT
121
+ expect(s.domToLogical(domMax)).toBeCloseTo(trueTotal - VIEWPORT, 0)
122
+ })
123
+ })
124
+
125
+ describe('createRowScrollScaling - edge cases', () => {
126
+ it('handles an empty grid without dividing by zero', () => {
127
+ const s = createRowScrollScaling(0, 17_895_697, VIEWPORT)
128
+ expect(s.active).toBe(false)
129
+ expect(s.domTotal).toBe(0)
130
+ expect(s.domToLogical(0)).toBe(0)
131
+ expect(s.logicalToDom(0)).toBe(0)
132
+ })
133
+
134
+ it('handles content shorter than the viewport', () => {
135
+ const s = createRowScrollScaling(200, 17_895_697, VIEWPORT)
136
+ expect(s.active).toBe(false)
137
+ expect(s.domToLogical(0)).toBe(0)
138
+ })
139
+
140
+ it('extreme scale (100M rows) keeps the last row reachable', () => {
141
+ const trueTotal = 100_000_000 * 32 // 3.2 billion px
142
+ const s = createRowScrollScaling(trueTotal, 33_554_400, VIEWPORT)
143
+ expect(s.active).toBe(true)
144
+ expect(s.domTotal).toBe(33_554_400)
145
+ const domMax = s.domTotal - VIEWPORT
146
+ expect(s.domToLogical(domMax)).toBeCloseTo(trueTotal - VIEWPORT, 0)
147
+ })
148
+ })
@@ -0,0 +1,121 @@
1
+ // Pure math for "huge list" vertical scroll scaling.
2
+ //
3
+ // Browsers cap how tall a single element may be (Chrome/Safari ~33.5M px,
4
+ // Firefox ~17.9M, mobile/high-DPR lower). Once a virtualized grid's true
5
+ // content height (count * rowHeight) exceeds that cap the scroll container
6
+ // silently clamps its scrollHeight and the tail rows become unreachable - a
7
+ // 1,000,000-row grid that only scrolls to ~994,000 on a phone.
8
+ //
9
+ // The fix (the technique react-virtualized calls "scaling"): size the DOM
10
+ // scroll spacer to a capped height the browser CAN render, and map between the
11
+ // limited DOM scroll range and the full logical range the virtualizer works
12
+ // in. This module is the pure, side-effect-free core of that mapping so the
13
+ // invariants can be unit-tested independently of the Svelte controller.
14
+ //
15
+ // When the true height fits under the cap, scaling is INERT: every mapping is
16
+ // the identity and callers behave exactly as before.
17
+
18
+ export type RowScrollScaling = {
19
+ /** True when the true content height exceeds the cap and mapping applies. */
20
+ readonly active: boolean
21
+ /** Height (px) the DOM scroll spacer should occupy - capped at maxDomHeight. */
22
+ readonly domTotal: number
23
+ /** Map a DOM scrollTop into the virtualizer's logical scroll offset. */
24
+ domToLogical(domTop: number): number
25
+ /** Map a virtualizer logical offset back into a DOM scrollTop. */
26
+ logicalToDom(logical: number): number
27
+ }
28
+
29
+ function clamp01(value: number): number {
30
+ if (value <= 0) return 0
31
+ if (value >= 1) return 1
32
+ return value
33
+ }
34
+
35
+ /** Below this a reading is junk (0 from jsdom, a sub-cap layout quirk, etc). */
36
+ const MAX_DOM_HEIGHT_SANITY_FLOOR = 100_000
37
+ /** At/above this the browser didn't clamp (returned ~the 1e9 we asked for). */
38
+ const MAX_DOM_HEIGHT_SANITY_CEIL = 900_000_000
39
+ /**
40
+ * Shave a hair off the detected cap so the spacer never sits at the exact
41
+ * physical edge, where sub-pixel rounding could leave the final row a touch
42
+ * out of reach. 0.5% is invisible at desktop caps (~33.5M -> ~33.3M) yet
43
+ * keeps a safety gap proportional to the cap.
44
+ */
45
+ const MAX_DOM_HEIGHT_SAFETY = 0.995
46
+
47
+ /**
48
+ * Resolve the usable max element height from the two raw DOM signals.
49
+ *
50
+ * `layoutCap` is a tall probe's clamped `offsetHeight` - the height layout
51
+ * assigns the element. `scrollCap` is the `scrollHeight` a real `overflow:auto`
52
+ * container exposes for that same probe - the height the user can actually
53
+ * scroll through. They differ on mobile / high-DPR engines, which report a
54
+ * generous `offsetHeight` but then expose a SMALLER scrollable range (the
55
+ * physical limit is in device px, so a 3x-DPR phone has ~1/3 the CSS-px scroll
56
+ * cap). Trusting `offsetHeight` alone is exactly what strands the last rows of
57
+ * a huge grid on a phone, so we take the smaller of the two.
58
+ *
59
+ * A junk reading (<= the sanity floor, or so large the browser clearly didn't
60
+ * clamp) is dropped; if neither signal survives, the caller's conservative
61
+ * `fallback` is returned unchanged.
62
+ */
63
+ export function resolveMaxDomHeight(
64
+ layoutCap: number,
65
+ scrollCap: number,
66
+ fallback: number,
67
+ ): number {
68
+ const usable = [layoutCap, scrollCap].filter(
69
+ (v) =>
70
+ Number.isFinite(v) &&
71
+ v > MAX_DOM_HEIGHT_SANITY_FLOOR &&
72
+ v < MAX_DOM_HEIGHT_SANITY_CEIL,
73
+ )
74
+ if (usable.length === 0) return fallback
75
+ return Math.floor(Math.min(...usable) * MAX_DOM_HEIGHT_SAFETY)
76
+ }
77
+
78
+ /**
79
+ * Build the scaling mapping for one axis.
80
+ *
81
+ * @param trueTotal The real content height in logical px (count * rowH, or
82
+ * the cumulative offset total for variable rows).
83
+ * @param maxDomHeight The browser's max element height (detected at runtime).
84
+ * @param viewport The scroll viewport height in px.
85
+ *
86
+ * Invariants (verified in scroll-scaling.test.ts):
87
+ * - Inert when `trueTotal <= maxDomHeight`: both maps are the identity.
88
+ * - `domTotal <= maxDomHeight` always, so the spacer never exceeds the cap.
89
+ * - Endpoints line up: domTop 0 -> logical 0, and the DOM max
90
+ * (`domTotal - viewport`) -> the logical max (`trueTotal - viewport`), so
91
+ * the very last row is always reachable.
92
+ * - `logicalToDom` is the inverse of `domToLogical` across the range.
93
+ * - Both maps are monotonic non-decreasing and clamp out-of-range inputs.
94
+ */
95
+ export function createRowScrollScaling(
96
+ trueTotal: number,
97
+ maxDomHeight: number,
98
+ viewport: number,
99
+ ): RowScrollScaling {
100
+ const safeMax = Math.max(maxDomHeight, 1)
101
+ const domTotal = Math.min(Math.max(trueTotal, 0), safeMax)
102
+ const active = trueTotal > safeMax
103
+ // Scrollable ranges (content minus one viewport). Guard against zero so the
104
+ // ratios below never divide by zero on tiny / empty grids.
105
+ const domRange = Math.max(domTotal - viewport, 1)
106
+ const logicalRange = Math.max(trueTotal - viewport, 0)
107
+
108
+ return {
109
+ active,
110
+ domTotal,
111
+ domToLogical(domTop: number): number {
112
+ if (!active) return domTop
113
+ return clamp01(domTop / domRange) * logicalRange
114
+ },
115
+ logicalToDom(logical: number): number {
116
+ if (!active) return logical
117
+ const lr = Math.max(logicalRange, 1)
118
+ return clamp01(logical / lr) * Math.max(domTotal - viewport, 0)
119
+ },
120
+ }
121
+ }
@@ -19,6 +19,8 @@ export function createSvelteVirtualizer(options: VirtualizerOptions) {
19
19
  scrollToIndex: virtualizer.scrollToIndex,
20
20
  getVirtualItems: virtualizer.getVirtualItems,
21
21
  getTotalSize: virtualizer.getTotalSize,
22
+ getOffsetForIndex: virtualizer.getOffsetForIndex,
23
+ getSizeForIndex: virtualizer.getSizeForIndex,
22
24
  getState: virtualizer.getState,
23
25
  }
24
26
  }
@@ -259,6 +259,32 @@ export function createVirtualizer(initial: VirtualizerOptions) {
259
259
  getTotalSize() {
260
260
  return state.totalSize
261
261
  },
262
+ /** Cumulative offset of row `index` from the top in px, regardless
263
+ * of whether `estimateSize` is uniform or per-index. Uses the
264
+ * cached offsets array under function-form sizing so the lookup
265
+ * is O(1) instead of O(index). */
266
+ getOffsetForIndex(index: number): number {
267
+ if (index <= 0) return 0
268
+ const count = Math.max(options.count, 0)
269
+ const bounded = Math.min(index, count)
270
+ if (typeof options.estimateSize === 'function') {
271
+ const offsets = getOffsets()
272
+ if (offsets) return offsets[bounded] ?? 0
273
+ let acc = 0
274
+ const fn = options.estimateSize
275
+ for (let i = 0; i < bounded; i += 1) acc += Math.max(fn(i), 1)
276
+ return acc
277
+ }
278
+ return bounded * Math.max(options.estimateSize, 1)
279
+ },
280
+ /** Height of row `index` in px (whichever estimateSize provides). */
281
+ getSizeForIndex(index: number): number {
282
+ if (index < 0 || index >= options.count) return 0
283
+ if (typeof options.estimateSize === 'function') {
284
+ return Math.max(options.estimateSize(index), 1)
285
+ }
286
+ return Math.max(options.estimateSize, 1)
287
+ },
262
288
  getState() {
263
289
  return state
264
290
  },