@svgrid/grid 1.0.2 → 1.1.1
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.
- package/LICENSE +1 -1
- package/README.md +137 -39
- package/dist/GridFooter.svelte +164 -0
- package/dist/GridFooter.svelte.d.ts +29 -0
- package/dist/GridMenus.svelte +648 -0
- package/dist/GridMenus.svelte.d.ts +31 -0
- package/dist/SvGrid.controller.svelte.d.ts +472 -0
- package/dist/SvGrid.controller.svelte.js +1917 -0
- package/dist/SvGrid.css +2012 -0
- package/dist/SvGrid.helpers.d.ts +35 -0
- package/dist/SvGrid.helpers.js +160 -0
- package/dist/SvGrid.helpers.test.d.ts +1 -0
- package/dist/SvGrid.helpers.test.js +298 -0
- package/dist/SvGrid.svelte +2346 -8742
- package/dist/SvGrid.svelte.d.ts +4 -357
- package/dist/SvGrid.types.d.ts +523 -0
- package/dist/SvGrid.types.js +1 -0
- package/dist/SvGridChart.svelte +1060 -23
- package/dist/SvGridChart.svelte.d.ts +17 -0
- package/dist/aligned-grids.d.ts +6 -0
- package/dist/aligned-grids.js +84 -0
- package/dist/aligned-grids.test.d.ts +1 -0
- package/dist/aligned-grids.test.js +75 -0
- package/dist/build-api.coverage.test.d.ts +20 -0
- package/dist/build-api.coverage.test.js +505 -0
- package/dist/build-api.d.ts +5 -0
- package/dist/build-api.js +557 -0
- package/dist/cell-render.d.ts +15 -0
- package/dist/cell-render.js +246 -0
- package/dist/cell-render.test.d.ts +1 -0
- package/dist/cell-render.test.js +338 -0
- package/dist/cell-values.d.ts +28 -0
- package/dist/cell-values.js +89 -0
- package/dist/chart-export.test.d.ts +1 -0
- package/dist/chart-export.test.js +302 -0
- package/dist/chart.coverage.test.d.ts +1 -0
- package/dist/chart.coverage.test.js +748 -0
- package/dist/chart.d.ts +370 -3
- package/dist/chart.js +1135 -42
- package/dist/clipboard.d.ts +15 -0
- package/dist/clipboard.js +420 -0
- package/dist/clipboard.test.d.ts +1 -0
- package/dist/clipboard.test.js +700 -0
- package/dist/collaboration.coverage.test.d.ts +1 -0
- package/dist/collaboration.coverage.test.js +200 -0
- package/dist/column-groups.d.ts +19 -0
- package/dist/column-groups.js +62 -0
- package/dist/column-groups.test.d.ts +1 -0
- package/dist/column-groups.test.js +56 -0
- package/dist/column-types.d.ts +10 -0
- package/dist/column-types.js +63 -0
- package/dist/column-types.test.d.ts +1 -0
- package/dist/column-types.test.js +62 -0
- package/dist/columns.d.ts +30 -0
- package/dist/columns.js +277 -0
- package/dist/columns.test.d.ts +1 -0
- package/dist/columns.test.js +625 -0
- package/dist/core.d.ts +86 -1
- package/dist/css.d.ts +3 -0
- package/dist/editing.d.ts +31 -0
- package/dist/editing.js +529 -0
- package/dist/editing.test.d.ts +1 -0
- package/dist/editing.test.js +732 -0
- package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
- package/dist/editors/cell-editors.coverage.test.js +139 -0
- package/dist/editors/cell-editors.d.ts +1 -1
- package/dist/facet-buckets.d.ts +13 -0
- package/dist/facet-buckets.js +54 -0
- package/dist/facet-buckets.test.d.ts +1 -0
- package/dist/facet-buckets.test.js +296 -0
- package/dist/features.d.ts +5 -0
- package/dist/features.js +30 -0
- package/dist/filter-operators.d.ts +16 -0
- package/dist/filter-operators.js +69 -0
- package/dist/filter-operators.test.d.ts +1 -0
- package/dist/filter-operators.test.js +135 -0
- package/dist/hyperformula-adapter.d.ts +82 -0
- package/dist/hyperformula-adapter.js +73 -0
- package/dist/hyperformula-adapter.test.d.ts +1 -0
- package/dist/hyperformula-adapter.test.js +205 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +8 -2
- package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
- package/dist/keyboard-handlers.coverage.test.js +495 -0
- package/dist/keyboard-handlers.d.ts +7 -0
- package/dist/keyboard-handlers.js +202 -0
- package/dist/menus.d.ts +40 -0
- package/dist/menus.js +390 -0
- package/dist/menus.test.d.ts +1 -0
- package/dist/menus.test.js +560 -0
- package/dist/named-views.coverage.test.d.ts +1 -0
- package/dist/named-views.coverage.test.js +180 -0
- package/dist/named-views.d.ts +27 -0
- package/dist/named-views.js +39 -0
- package/dist/row-drag.d.ts +49 -0
- package/dist/row-drag.js +221 -0
- package/dist/row-drag.test.d.ts +1 -0
- package/dist/row-drag.test.js +142 -0
- package/dist/row-resize.d.ts +43 -0
- package/dist/row-resize.js +158 -0
- package/dist/row-resize.test.d.ts +1 -0
- package/dist/row-resize.test.js +329 -0
- package/dist/scroll-sync.d.ts +9 -0
- package/dist/scroll-sync.js +89 -0
- package/dist/scroll-sync.test.d.ts +1 -0
- package/dist/scroll-sync.test.js +290 -0
- package/dist/selection.d.ts +32 -0
- package/dist/selection.js +427 -0
- package/dist/selection.multi-range.test.d.ts +1 -0
- package/dist/selection.multi-range.test.js +55 -0
- package/dist/selection.test.d.ts +1 -0
- package/dist/selection.test.js +647 -0
- package/dist/server-data-source.coverage.test.d.ts +1 -0
- package/dist/server-data-source.coverage.test.js +154 -0
- package/dist/spreadsheet.d.ts +110 -0
- package/dist/spreadsheet.js +242 -0
- package/dist/spreadsheet.test.d.ts +1 -0
- package/dist/spreadsheet.test.js +446 -0
- package/dist/summaries.d.ts +12 -0
- package/dist/summaries.js +65 -0
- package/dist/sv-grid-scrollbar.js +13 -1
- package/dist/svgrid-wrapper.types.d.ts +31 -1
- package/dist/svgrid.behavior.test.js +20 -0
- package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
- package/dist/svgrid.comments-autocomplete.test.js +96 -0
- package/dist/svgrid.context-menu.test.d.ts +1 -0
- package/dist/svgrid.context-menu.test.js +102 -0
- package/dist/svgrid.interaction.test.js +31 -0
- package/dist/svgrid.new-features.wrapper.test.js +64 -6
- package/dist/svgrid.wrapper.test.js +27 -1
- package/dist/test-setup.js +9 -3
- package/dist/virtualization/column-virtualizer.d.ts +2 -0
- package/dist/virtualization/scroll-scaling.d.ts +45 -0
- package/dist/virtualization/scroll-scaling.js +99 -0
- package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
- package/dist/virtualization/scroll-scaling.test.js +127 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
- package/dist/virtualization/virtualizer.d.ts +7 -0
- package/dist/virtualization/virtualizer.js +30 -0
- package/package.json +2 -1
- package/src/GridFooter.svelte +164 -0
- package/src/GridMenus.svelte +648 -0
- package/src/SvGrid.controller.svelte.ts +2352 -0
- package/src/SvGrid.css +2012 -0
- package/src/SvGrid.helpers.test.ts +415 -0
- package/src/SvGrid.helpers.ts +185 -0
- package/src/SvGrid.svelte +2346 -8742
- package/src/SvGrid.types.ts +537 -0
- package/src/SvGridChart.svelte +1060 -23
- package/src/aligned-grids.test.ts +80 -0
- package/src/aligned-grids.ts +87 -0
- package/src/build-api.coverage.test.ts +532 -0
- package/src/build-api.ts +683 -0
- package/src/cell-render.test.ts +451 -0
- package/src/cell-render.ts +426 -0
- package/src/cell-values.ts +114 -0
- package/src/chart-export.test.ts +370 -0
- package/src/chart.coverage.test.ts +814 -0
- package/src/chart.ts +1352 -47
- package/src/clipboard.test.ts +780 -0
- package/src/clipboard.ts +552 -0
- package/src/collaboration.coverage.test.ts +220 -0
- package/src/column-groups.test.ts +59 -0
- package/src/column-groups.ts +80 -0
- package/src/column-types.test.ts +68 -0
- package/src/column-types.ts +82 -0
- package/src/columns.test.ts +702 -0
- package/src/columns.ts +419 -0
- package/src/core.ts +86 -0
- package/src/css.d.ts +3 -0
- package/src/editing.test.ts +837 -0
- package/src/editing.ts +669 -0
- package/src/editors/cell-editors.coverage.test.ts +156 -0
- package/src/editors/cell-editors.ts +1 -0
- package/src/facet-buckets.test.ts +353 -0
- package/src/facet-buckets.ts +67 -0
- package/src/features.ts +128 -0
- package/src/filter-operators.test.ts +174 -0
- package/src/filter-operators.ts +87 -0
- package/src/hyperformula-adapter.test.ts +256 -0
- package/src/hyperformula-adapter.ts +124 -0
- package/src/index.ts +49 -0
- package/src/keyboard-handlers.coverage.test.ts +560 -0
- package/src/keyboard-handlers.ts +353 -0
- package/src/keyboard.ts +97 -97
- package/src/menus.test.ts +620 -0
- package/src/menus.ts +555 -0
- package/src/named-views.coverage.test.ts +210 -0
- package/src/named-views.ts +48 -0
- package/src/row-drag.test.ts +168 -0
- package/src/row-drag.ts +255 -0
- package/src/row-resize.test.ts +369 -0
- package/src/row-resize.ts +171 -0
- package/src/scroll-sync.test.ts +330 -0
- package/src/scroll-sync.ts +218 -0
- package/src/selection.multi-range.test.ts +61 -0
- package/src/selection.test.ts +722 -0
- package/src/selection.ts +579 -0
- package/src/server-data-source.coverage.test.ts +180 -0
- package/src/spreadsheet.test.ts +489 -0
- package/src/spreadsheet.ts +304 -0
- package/src/summaries.ts +204 -0
- package/src/sv-grid-scrollbar.ts +13 -1
- package/src/svgrid-wrapper.types.ts +31 -1
- package/src/svgrid.behavior.test.ts +22 -0
- package/src/svgrid.comments-autocomplete.test.ts +112 -0
- package/src/svgrid.context-menu.test.ts +126 -0
- package/src/svgrid.interaction.test.ts +30 -0
- package/src/svgrid.new-features.wrapper.test.ts +67 -6
- package/src/svgrid.wrapper.test.ts +27 -1
- package/src/test-setup.ts +9 -6
- package/src/virtualization/scroll-scaling.test.ts +148 -0
- package/src/virtualization/scroll-scaling.ts +121 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
- package/src/virtualization/virtualizer.ts +26 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track C: free-text autocomplete cell type + editable cell comments.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { mount, unmount } from 'svelte'
|
|
6
|
+
import SvGrid from './SvGrid.svelte'
|
|
7
|
+
import { createCoreRowModel, tableFeatures } from './index'
|
|
8
|
+
import type { ColumnDef, SvGridApi } from './index'
|
|
9
|
+
|
|
10
|
+
type Row = { id: number; name: string; city: string }
|
|
11
|
+
const features = tableFeatures({})
|
|
12
|
+
const rows: Row[] = [
|
|
13
|
+
{ id: 1, name: 'Ann', city: 'Paris' },
|
|
14
|
+
{ id: 2, name: 'Bo', city: 'Berlin' },
|
|
15
|
+
]
|
|
16
|
+
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
17
|
+
|
|
18
|
+
function mountGrid(extraCols: ColumnDef<typeof features, Row>[], props: Record<string, unknown> = {}) {
|
|
19
|
+
return new Promise<{ api: SvGridApi<typeof features, Row>; target: HTMLElement; destroy: () => void }>(
|
|
20
|
+
(res) => {
|
|
21
|
+
const target = document.createElement('div')
|
|
22
|
+
document.body.appendChild(target)
|
|
23
|
+
const app = mount(SvGrid, {
|
|
24
|
+
target,
|
|
25
|
+
props: {
|
|
26
|
+
data: rows,
|
|
27
|
+
columns: [{ field: 'name', header: 'Name', width: 160, editorType: 'text' }, ...extraCols],
|
|
28
|
+
features,
|
|
29
|
+
_rowModels: { coreRowModel: createCoreRowModel() },
|
|
30
|
+
rowHeight: 32,
|
|
31
|
+
containerHeight: 360,
|
|
32
|
+
virtualization: false,
|
|
33
|
+
enableInlineEditing: true,
|
|
34
|
+
enableCellSelection: true,
|
|
35
|
+
onApiReady(api: SvGridApi<typeof features, Row>) {
|
|
36
|
+
res({ api, target, destroy: () => { unmount(app); target.remove() } })
|
|
37
|
+
},
|
|
38
|
+
...props,
|
|
39
|
+
} as any,
|
|
40
|
+
})
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dataCell(target: HTMLElement, col: number) {
|
|
46
|
+
return target.querySelectorAll('.sv-grid-cell[data-svgrid-row]')[col] as HTMLElement
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('autocomplete cell type', () => {
|
|
50
|
+
it('filters suggestions as you type and commits the picked option', async () => {
|
|
51
|
+
const { api, target, destroy } = await mountGrid([
|
|
52
|
+
{
|
|
53
|
+
field: 'city',
|
|
54
|
+
header: 'City',
|
|
55
|
+
width: 200,
|
|
56
|
+
editorType: 'autocomplete',
|
|
57
|
+
editorOptions: ['Paris', 'Berlin', 'Boston', 'Brussels'],
|
|
58
|
+
},
|
|
59
|
+
])
|
|
60
|
+
await tick()
|
|
61
|
+
// city is the 2nd column => index 1 in the first row's cells
|
|
62
|
+
const cell = target.querySelectorAll('.sv-grid-cell[data-svgrid-row="0"]')[1] as HTMLElement
|
|
63
|
+
cell.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
|
64
|
+
await tick()
|
|
65
|
+
const input = target.querySelector('.sv-grid-cell-editor-autocomplete') as HTMLInputElement
|
|
66
|
+
expect(input).not.toBeNull()
|
|
67
|
+
input.value = 'B'
|
|
68
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
69
|
+
await tick()
|
|
70
|
+
const opts = [...target.querySelectorAll('.sv-grid-autocomplete-option')].map((b) => b.textContent?.trim())
|
|
71
|
+
// 'B' matches Berlin, Boston, Brussels - not Paris
|
|
72
|
+
expect(opts).toEqual(['Berlin', 'Boston', 'Brussels'])
|
|
73
|
+
const berlin = [...target.querySelectorAll('.sv-grid-autocomplete-option')].find(
|
|
74
|
+
(b) => b.textContent?.trim() === 'Boston',
|
|
75
|
+
) as HTMLElement
|
|
76
|
+
berlin.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
77
|
+
await tick()
|
|
78
|
+
expect(api.getCellValue(0, 'city')).toBe('Boston')
|
|
79
|
+
destroy()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('editable comments', () => {
|
|
84
|
+
it('adds a comment via the context menu and emits onNoteChange', async () => {
|
|
85
|
+
const changes: Array<{ rowId: string; columnId: string; note: string }> = []
|
|
86
|
+
const { target, destroy } = await mountGrid(
|
|
87
|
+
[{ field: 'city', header: 'City', width: 200, editorType: 'text' }],
|
|
88
|
+
{ contextMenu: true, editableComments: true, onNoteChange: (e: any) => changes.push(e) },
|
|
89
|
+
)
|
|
90
|
+
await tick()
|
|
91
|
+
const cell = dataCell(target, 0)
|
|
92
|
+
cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
|
93
|
+
await tick()
|
|
94
|
+
const editItem = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find(
|
|
95
|
+
(b) => b.textContent?.trim() === 'Edit comment',
|
|
96
|
+
) as HTMLElement
|
|
97
|
+
expect(editItem).toBeTruthy()
|
|
98
|
+
editItem.click()
|
|
99
|
+
await tick()
|
|
100
|
+
const ta = target.querySelector('.sv-grid-comment-textarea') as HTMLTextAreaElement
|
|
101
|
+
expect(ta).not.toBeNull()
|
|
102
|
+
ta.value = 'Needs review'
|
|
103
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }))
|
|
104
|
+
await tick()
|
|
105
|
+
;(target.querySelector('.sv-grid-comment-save') as HTMLElement).click()
|
|
106
|
+
await tick()
|
|
107
|
+
expect(changes.length).toBe(1)
|
|
108
|
+
expect(changes[0]!.note).toBe('Needs review')
|
|
109
|
+
expect(target.querySelector('.sv-grid-comment-editor')).toBeNull()
|
|
110
|
+
destroy()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
|
|
@@ -44,7 +70,7 @@ describe('SvGrid wrapper - cellClass + rowClass', () => {
|
|
|
44
70
|
it('computes the row-level class once per render row and threads it onto the <tr>', () => {
|
|
45
71
|
expect(source).toMatch(/function computeRowClass/)
|
|
46
72
|
expect(source).toMatch(/userRowClass = computeRowClass\(row, rowIndex\)/)
|
|
47
|
-
expect(source).toMatch(/class=\{`sv-grid-row \$\{userRowClass\}
|
|
73
|
+
expect(source).toMatch(/class=\{`sv-grid-row \$\{userRowClass\}/)
|
|
48
74
|
})
|
|
49
75
|
|
|
50
76
|
it('computes the cell-level class per column and threads it onto the <td>', () => {
|
|
@@ -111,7 +137,7 @@ describe('SvGrid wrapper - between filter operator', () => {
|
|
|
111
137
|
})
|
|
112
138
|
|
|
113
139
|
it('forwards valueTo through applyExcelFilter', () => {
|
|
114
|
-
expect(source).toMatch(/valueTo
|
|
140
|
+
expect(source).toMatch(/valueTo:\s*(filter\.operator|op) === "between" \? (filter\.valueTo|valueTo) : undefined/)
|
|
115
141
|
})
|
|
116
142
|
|
|
117
143
|
it('publishes valueTo in the onFiltersChange payload only for between clauses', () => {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|