@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,96 @@
|
|
|
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
|
+
const features = tableFeatures({});
|
|
9
|
+
const rows = [
|
|
10
|
+
{ id: 1, name: 'Ann', city: 'Paris' },
|
|
11
|
+
{ id: 2, name: 'Bo', city: 'Berlin' },
|
|
12
|
+
];
|
|
13
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
14
|
+
function mountGrid(extraCols, props = {}) {
|
|
15
|
+
return new Promise((res) => {
|
|
16
|
+
const target = document.createElement('div');
|
|
17
|
+
document.body.appendChild(target);
|
|
18
|
+
const app = mount(SvGrid, {
|
|
19
|
+
target,
|
|
20
|
+
props: {
|
|
21
|
+
data: rows,
|
|
22
|
+
columns: [{ field: 'name', header: 'Name', width: 160, editorType: 'text' }, ...extraCols],
|
|
23
|
+
features,
|
|
24
|
+
_rowModels: { coreRowModel: createCoreRowModel() },
|
|
25
|
+
rowHeight: 32,
|
|
26
|
+
containerHeight: 360,
|
|
27
|
+
virtualization: false,
|
|
28
|
+
enableInlineEditing: true,
|
|
29
|
+
enableCellSelection: true,
|
|
30
|
+
onApiReady(api) {
|
|
31
|
+
res({ api, target, destroy: () => { unmount(app); target.remove(); } });
|
|
32
|
+
},
|
|
33
|
+
...props,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function dataCell(target, col) {
|
|
39
|
+
return target.querySelectorAll('.sv-grid-cell[data-svgrid-row]')[col];
|
|
40
|
+
}
|
|
41
|
+
describe('autocomplete cell type', () => {
|
|
42
|
+
it('filters suggestions as you type and commits the picked option', async () => {
|
|
43
|
+
const { api, target, destroy } = await mountGrid([
|
|
44
|
+
{
|
|
45
|
+
field: 'city',
|
|
46
|
+
header: 'City',
|
|
47
|
+
width: 200,
|
|
48
|
+
editorType: 'autocomplete',
|
|
49
|
+
editorOptions: ['Paris', 'Berlin', 'Boston', 'Brussels'],
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
await tick();
|
|
53
|
+
// city is the 2nd column => index 1 in the first row's cells
|
|
54
|
+
const cell = target.querySelectorAll('.sv-grid-cell[data-svgrid-row="0"]')[1];
|
|
55
|
+
cell.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
56
|
+
await tick();
|
|
57
|
+
const input = target.querySelector('.sv-grid-cell-editor-autocomplete');
|
|
58
|
+
expect(input).not.toBeNull();
|
|
59
|
+
input.value = 'B';
|
|
60
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
61
|
+
await tick();
|
|
62
|
+
const opts = [...target.querySelectorAll('.sv-grid-autocomplete-option')].map((b) => b.textContent?.trim());
|
|
63
|
+
// 'B' matches Berlin, Boston, Brussels - not Paris
|
|
64
|
+
expect(opts).toEqual(['Berlin', 'Boston', 'Brussels']);
|
|
65
|
+
const berlin = [...target.querySelectorAll('.sv-grid-autocomplete-option')].find((b) => b.textContent?.trim() === 'Boston');
|
|
66
|
+
berlin.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
|
67
|
+
await tick();
|
|
68
|
+
expect(api.getCellValue(0, 'city')).toBe('Boston');
|
|
69
|
+
destroy();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('editable comments', () => {
|
|
73
|
+
it('adds a comment via the context menu and emits onNoteChange', async () => {
|
|
74
|
+
const changes = [];
|
|
75
|
+
const { target, destroy } = await mountGrid([{ field: 'city', header: 'City', width: 200, editorType: 'text' }], { contextMenu: true, editableComments: true, onNoteChange: (e) => changes.push(e) });
|
|
76
|
+
await tick();
|
|
77
|
+
const cell = dataCell(target, 0);
|
|
78
|
+
cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
|
|
79
|
+
await tick();
|
|
80
|
+
const editItem = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find((b) => b.textContent?.trim() === 'Edit comment');
|
|
81
|
+
expect(editItem).toBeTruthy();
|
|
82
|
+
editItem.click();
|
|
83
|
+
await tick();
|
|
84
|
+
const ta = target.querySelector('.sv-grid-comment-textarea');
|
|
85
|
+
expect(ta).not.toBeNull();
|
|
86
|
+
ta.value = 'Needs review';
|
|
87
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
88
|
+
await tick();
|
|
89
|
+
target.querySelector('.sv-grid-comment-save').click();
|
|
90
|
+
await tick();
|
|
91
|
+
expect(changes.length).toBe(1);
|
|
92
|
+
expect(changes[0].note).toBe('Needs review');
|
|
93
|
+
expect(target.querySelector('.sv-grid-comment-editor')).toBeNull();
|
|
94
|
+
destroy();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
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 { createCoreRowModel, createFilteredRowModel, createSortedRowModel, rowSelectionFeature, rowSortingFeature, sortFns, tableFeatures, } from './index';
|
|
10
|
+
const features = tableFeatures({ rowSortingFeature, rowSelectionFeature });
|
|
11
|
+
const rows = Array.from({ length: 6 }, (_, i) => ({
|
|
12
|
+
id: i + 1,
|
|
13
|
+
name: `Person ${i + 1}`,
|
|
14
|
+
team: ['A', 'B'][i % 2],
|
|
15
|
+
}));
|
|
16
|
+
const cols = [
|
|
17
|
+
{ field: 'name', header: 'Name', width: 200, editorType: 'text' },
|
|
18
|
+
{ field: 'team', header: 'Team', width: 160, editorType: 'text' },
|
|
19
|
+
];
|
|
20
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
21
|
+
function mountGrid(contextMenu) {
|
|
22
|
+
return new Promise((res, rej) => {
|
|
23
|
+
const target = document.createElement('div');
|
|
24
|
+
document.body.appendChild(target);
|
|
25
|
+
const app = mount(SvGrid, {
|
|
26
|
+
target,
|
|
27
|
+
props: {
|
|
28
|
+
data: rows,
|
|
29
|
+
columns: cols,
|
|
30
|
+
features,
|
|
31
|
+
_rowModels: {
|
|
32
|
+
coreRowModel: createCoreRowModel(),
|
|
33
|
+
filteredRowModel: createFilteredRowModel(),
|
|
34
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
35
|
+
},
|
|
36
|
+
rowHeight: 32,
|
|
37
|
+
containerHeight: 400,
|
|
38
|
+
virtualization: false,
|
|
39
|
+
enableCellSelection: true,
|
|
40
|
+
contextMenu,
|
|
41
|
+
onApiReady(api) {
|
|
42
|
+
res({ api, target, destroy: () => { unmount(app); target.remove(); } });
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
queueMicrotask(() => { if (!target.querySelector('[role="grid"]'))
|
|
47
|
+
rej(new Error('no grid')); });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function rightClickFirstCell(target) {
|
|
51
|
+
const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]');
|
|
52
|
+
const r = cell.getBoundingClientRect();
|
|
53
|
+
cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: r.x + 4, clientY: r.y + 4 }));
|
|
54
|
+
return cell;
|
|
55
|
+
}
|
|
56
|
+
describe('SvGrid context menu', () => {
|
|
57
|
+
it('opens the default menu on right-click with the built-in items', async () => {
|
|
58
|
+
const { target, destroy } = await mountGrid(true);
|
|
59
|
+
await tick();
|
|
60
|
+
rightClickFirstCell(target);
|
|
61
|
+
await tick();
|
|
62
|
+
const menu = target.querySelector('.sv-grid-context-menu');
|
|
63
|
+
expect(menu).not.toBeNull();
|
|
64
|
+
const labels = [...menu.querySelectorAll('.sv-grid-menu-item')].map((b) => b.textContent?.trim());
|
|
65
|
+
expect(labels).toContain('Copy');
|
|
66
|
+
expect(labels).toContain('Paste');
|
|
67
|
+
expect(labels).toContain('Remove row');
|
|
68
|
+
expect(labels).toContain('Remove column');
|
|
69
|
+
destroy();
|
|
70
|
+
});
|
|
71
|
+
it('does not open when contextMenu is omitted (native menu shows)', async () => {
|
|
72
|
+
const { target, destroy } = await mountGrid(undefined);
|
|
73
|
+
await tick();
|
|
74
|
+
const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]');
|
|
75
|
+
const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true });
|
|
76
|
+
cell.dispatchEvent(ev);
|
|
77
|
+
await tick();
|
|
78
|
+
expect(target.querySelector('.sv-grid-context-menu')).toBeNull();
|
|
79
|
+
expect(ev.defaultPrevented).toBe(false);
|
|
80
|
+
destroy();
|
|
81
|
+
});
|
|
82
|
+
it('renders only the configured custom items and runs the action', async () => {
|
|
83
|
+
let ran = 0;
|
|
84
|
+
const items = [
|
|
85
|
+
'copy',
|
|
86
|
+
'separator',
|
|
87
|
+
{ key: 'flag', label: 'Flag row', action: () => { ran += 1; } },
|
|
88
|
+
];
|
|
89
|
+
const { target, destroy } = await mountGrid(items);
|
|
90
|
+
await tick();
|
|
91
|
+
rightClickFirstCell(target);
|
|
92
|
+
await tick();
|
|
93
|
+
const labels = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].map((b) => b.textContent?.trim());
|
|
94
|
+
expect(labels).toEqual(['Copy', 'Flag row']);
|
|
95
|
+
const flag = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find((b) => b.textContent?.trim() === 'Flag row');
|
|
96
|
+
flag.click();
|
|
97
|
+
expect(ran).toBe(1);
|
|
98
|
+
await tick();
|
|
99
|
+
expect(target.querySelector('.sv-grid-context-menu')).toBeNull();
|
|
100
|
+
destroy();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -272,6 +272,37 @@ describe('SvGrid interactions - cell pointer events', () => {
|
|
|
272
272
|
destroy();
|
|
273
273
|
}
|
|
274
274
|
});
|
|
275
|
+
// issue #23: a mouse drag extends the cell range, but a touch drag must NOT -
|
|
276
|
+
// it should leave the single tapped cell selected so the finger-drag scrolls.
|
|
277
|
+
async function dragSelectCount(pointerType) {
|
|
278
|
+
const { target, destroy } = await mountInteractive();
|
|
279
|
+
try {
|
|
280
|
+
const a = target.querySelector('[data-svgrid-row="0"][data-svgrid-col="0"]');
|
|
281
|
+
const c = target.querySelector('[data-svgrid-row="0"][data-svgrid-col="2"]');
|
|
282
|
+
if (!a || !c)
|
|
283
|
+
return -1; // jsdom didn't lay the cells out; caller skips
|
|
284
|
+
a.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0, pointerType }));
|
|
285
|
+
await tick();
|
|
286
|
+
c.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1, pointerType }));
|
|
287
|
+
await tick();
|
|
288
|
+
return target.querySelectorAll('[data-selected-range="true"]').length;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
destroy();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
it('mouse drag extends the selection across cells', async () => {
|
|
295
|
+
const n = await dragSelectCount('mouse');
|
|
296
|
+
if (n < 0)
|
|
297
|
+
return;
|
|
298
|
+
expect(n).toBeGreaterThan(1); // (0,0) -> (0,2) selects the row's 3 cells
|
|
299
|
+
});
|
|
300
|
+
it('touch drag does not start a range selection (issue #23)', async () => {
|
|
301
|
+
const n = await dragSelectCount('touch');
|
|
302
|
+
if (n < 0)
|
|
303
|
+
return;
|
|
304
|
+
expect(n).toBeLessThanOrEqual(1); // only the tapped cell; finger-drag scrolls
|
|
305
|
+
});
|
|
275
306
|
});
|
|
276
307
|
describe('SvGrid interactions - copy', () => {
|
|
277
308
|
it('Ctrl+C with a cell range does not throw', async () => {
|
|
@@ -15,8 +15,34 @@
|
|
|
15
15
|
import { readFileSync } from 'node:fs';
|
|
16
16
|
import { resolve } from 'node:path';
|
|
17
17
|
import { describe, expect, it } from 'vitest';
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// The wrapper implementation spans SvGrid.svelte plus its extracted
|
|
19
|
+
// sibling modules (types + pure helpers). Concatenate them so these
|
|
20
|
+
// "the wrapper declares/exposes X" assertions hold wherever the code
|
|
21
|
+
// physically lives.
|
|
22
|
+
const source = [
|
|
23
|
+
'./SvGrid.svelte',
|
|
24
|
+
'./SvGrid.controller.svelte.ts',
|
|
25
|
+
'./GridMenus.svelte',
|
|
26
|
+
'./GridFooter.svelte',
|
|
27
|
+
'./SvGrid.types.ts',
|
|
28
|
+
'./SvGrid.helpers.ts',
|
|
29
|
+
'./filter-operators.ts',
|
|
30
|
+
'./facet-buckets.ts',
|
|
31
|
+
'./cell-values.ts',
|
|
32
|
+
'./clipboard.ts',
|
|
33
|
+
'./build-api.ts',
|
|
34
|
+
'./columns.ts',
|
|
35
|
+
'./selection.ts',
|
|
36
|
+
'./editing.ts',
|
|
37
|
+
'./cell-render.ts',
|
|
38
|
+
'./menus.ts',
|
|
39
|
+
'./summaries.ts',
|
|
40
|
+
'./keyboard-handlers.ts',
|
|
41
|
+
'./scroll-sync.ts',
|
|
42
|
+
'./features.ts',
|
|
43
|
+
]
|
|
44
|
+
.map((p) => readFileSync(resolve(__dirname, p), 'utf-8'))
|
|
45
|
+
.join('\n');
|
|
20
46
|
const typesPath = resolve(__dirname, './svgrid-wrapper.types.ts');
|
|
21
47
|
const types = readFileSync(typesPath, 'utf-8');
|
|
22
48
|
describe('SvGrid wrapper - getRowId prop', () => {
|
|
@@ -38,7 +64,7 @@ describe('SvGrid wrapper - cellClass + rowClass', () => {
|
|
|
38
64
|
it('computes the row-level class once per render row and threads it onto the <tr>', () => {
|
|
39
65
|
expect(source).toMatch(/function computeRowClass/);
|
|
40
66
|
expect(source).toMatch(/userRowClass = computeRowClass\(row, rowIndex\)/);
|
|
41
|
-
expect(source).toMatch(/class=\{`sv-grid-row \$\{userRowClass\}
|
|
67
|
+
expect(source).toMatch(/class=\{`sv-grid-row \$\{userRowClass\}/);
|
|
42
68
|
});
|
|
43
69
|
it('computes the cell-level class per column and threads it onto the <td>', () => {
|
|
44
70
|
expect(source).toMatch(/function computeCellClass/);
|
|
@@ -94,7 +120,7 @@ describe('SvGrid wrapper - between filter operator', () => {
|
|
|
94
120
|
expect(source).toMatch(/if \(f\.operator === "between"\) \{[^}]*f\.value\.trim\(\)\.length > 0[^}]*f\.valueTo[^}]*\.trim\(\)\.length > 0/);
|
|
95
121
|
});
|
|
96
122
|
it('forwards valueTo through applyExcelFilter', () => {
|
|
97
|
-
expect(source).toMatch(/valueTo
|
|
123
|
+
expect(source).toMatch(/valueTo:\s*(filter\.operator|op) === "between" \? (filter\.valueTo|valueTo) : undefined/);
|
|
98
124
|
});
|
|
99
125
|
it('publishes valueTo in the onFiltersChange payload only for between clauses', () => {
|
|
100
126
|
expect(source).toMatch(/f\.operator === "between" && f\.valueTo\s*\?\s*\{ valueTo: f\.valueTo \}/);
|
|
@@ -120,7 +146,7 @@ describe('SvGrid wrapper - multi-cell paste + cut + delete', () => {
|
|
|
120
146
|
});
|
|
121
147
|
it('wires Ctrl/Cmd+X to cutSelectionToClipboard', () => {
|
|
122
148
|
expect(source).toMatch(/lower === "x"/);
|
|
123
|
-
expect(source).toMatch(/void cutSelectionToClipboard\(\)/);
|
|
149
|
+
expect(source).toMatch(/void (?:ctx\.)?cutSelectionToClipboard\(\)/);
|
|
124
150
|
expect(source).toMatch(/async function cutSelectionToClipboard/);
|
|
125
151
|
// Confirm both calls live inside the cutSelectionToClipboard body
|
|
126
152
|
// (copy first, then clear). A comment between them is fine.
|
|
@@ -131,6 +157,38 @@ describe('SvGrid wrapper - multi-cell paste + cut + delete', () => {
|
|
|
131
157
|
});
|
|
132
158
|
it('wires Delete + Backspace to clearSelectedCells (without clipboard interaction)', () => {
|
|
133
159
|
expect(source).toMatch(/event\.key === "Delete" \|\| event\.key === "Backspace"/);
|
|
134
|
-
expect(source).toMatch(/if \(clearSelectedCells\(\)\)\s*\{/);
|
|
160
|
+
expect(source).toMatch(/if \((?:ctx\.)?clearSelectedCells\(\)\)\s*\{/);
|
|
161
|
+
});
|
|
162
|
+
// Regression: the controller's ctx is an object literal of getters, and the
|
|
163
|
+
// copy/cut/paste handlers in keyboard-handlers + menus reach the functions
|
|
164
|
+
// via `ctx.<name>()`. A refactor once shipped the `ctx.cutSelectionToClipboard`
|
|
165
|
+
// / `ctx.pasteFromClipboard` CALLS but dropped their getters, so those threw
|
|
166
|
+
// "is not a function" at runtime - copy worked, cut + paste were dead. Assert
|
|
167
|
+
// every clipboard function called through ctx has a matching getter.
|
|
168
|
+
it('exposes a ctx getter for every clipboard command it calls through ctx', () => {
|
|
169
|
+
for (const fn of [
|
|
170
|
+
'copySelectionToClipboard',
|
|
171
|
+
'cutSelectionToClipboard',
|
|
172
|
+
'pasteFromClipboard',
|
|
173
|
+
'onGridPaste',
|
|
174
|
+
'clearSelectedCells',
|
|
175
|
+
]) {
|
|
176
|
+
expect(source).toMatch(new RegExp(`get ${fn}\\(\\)\\s*\\{\\s*return ${fn};?\\s*\\}`));
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
it('copy/cut fall back to execCommand when navigator.clipboard is absent (insecure context)', () => {
|
|
180
|
+
expect(source).toMatch(/function writeClipboardText\(/);
|
|
181
|
+
expect(source).toMatch(/document\.execCommand\("copy"\)/);
|
|
182
|
+
});
|
|
183
|
+
it('paste falls back to a native paste event when the async API is unavailable', () => {
|
|
184
|
+
// keydown only preventDefaults (and uses the async API) in a secure context;
|
|
185
|
+
// otherwise it lets the browser deliver a native paste event to onGridPaste.
|
|
186
|
+
// Accept either the positive branch or the inverted early-return guard
|
|
187
|
+
// (`if (!navigator.clipboard?.readText) return`) - both gate on the async API.
|
|
188
|
+
expect(source).toMatch(/if \(!?navigator\.clipboard\?\.readText\)/);
|
|
189
|
+
expect(source).toMatch(/function onGridPaste\(event: ClipboardEvent\)/);
|
|
190
|
+
expect(source).toMatch(/clipboardData\?\.getData\("text\/plain"\)/);
|
|
191
|
+
// The grid root wires the handler.
|
|
192
|
+
expect(source).toMatch(/onpaste=\{onGridPaste\}/);
|
|
135
193
|
});
|
|
136
194
|
});
|
|
@@ -7,7 +7,33 @@ describe('SvGrid wrapper', () => {
|
|
|
7
7
|
expect(SvGrid).toBeDefined();
|
|
8
8
|
});
|
|
9
9
|
it('contains full-wrapper controls and aria-activedescendant wiring', () => {
|
|
10
|
-
|
|
10
|
+
// The wrapper spans SvGrid.svelte plus its extracted sibling modules
|
|
11
|
+
// (types + pure helpers); read them together so these "contains X"
|
|
12
|
+
// assertions hold wherever the code physically lives.
|
|
13
|
+
const source = [
|
|
14
|
+
'./SvGrid.svelte',
|
|
15
|
+
'./SvGrid.controller.svelte.ts',
|
|
16
|
+
'./GridMenus.svelte',
|
|
17
|
+
'./GridFooter.svelte',
|
|
18
|
+
'./SvGrid.types.ts',
|
|
19
|
+
'./SvGrid.helpers.ts',
|
|
20
|
+
'./filter-operators.ts',
|
|
21
|
+
'./facet-buckets.ts',
|
|
22
|
+
'./cell-values.ts',
|
|
23
|
+
'./clipboard.ts',
|
|
24
|
+
'./build-api.ts',
|
|
25
|
+
'./columns.ts',
|
|
26
|
+
'./selection.ts',
|
|
27
|
+
'./editing.ts',
|
|
28
|
+
'./cell-render.ts',
|
|
29
|
+
'./menus.ts',
|
|
30
|
+
'./summaries.ts',
|
|
31
|
+
'./keyboard-handlers.ts',
|
|
32
|
+
'./scroll-sync.ts',
|
|
33
|
+
'./features.ts',
|
|
34
|
+
]
|
|
35
|
+
.map((p) => readFileSync(resolve(__dirname, p), 'utf-8'))
|
|
36
|
+
.join('\n');
|
|
11
37
|
expect(source).toContain('Filter all rows');
|
|
12
38
|
expect(source).toContain('Previous');
|
|
13
39
|
expect(source).toContain('role="status"');
|
package/dist/test-setup.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
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
|
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
5
|
-
|
|
11
|
+
;
|
|
6
12
|
globalThis.ResizeObserver = class {
|
|
7
13
|
observe() { }
|
|
8
14
|
unobserve() { }
|
|
@@ -10,7 +16,7 @@ if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
|
10
16
|
};
|
|
11
17
|
}
|
|
12
18
|
if (typeof globalThis.IntersectionObserver === 'undefined') {
|
|
13
|
-
|
|
19
|
+
;
|
|
14
20
|
globalThis.IntersectionObserver = class {
|
|
15
21
|
observe() { }
|
|
16
22
|
unobserve() { }
|
|
@@ -23,7 +29,7 @@ if (typeof globalThis.IntersectionObserver === 'undefined') {
|
|
|
23
29
|
// jsdom's HTMLElement.scrollIntoView is a no-op; some grid code calls it
|
|
24
30
|
// during the first effect. Make sure the method exists on every element.
|
|
25
31
|
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
|
|
26
|
-
|
|
32
|
+
;
|
|
27
33
|
Element.prototype.scrollIntoView = function () { };
|
|
28
34
|
}
|
|
29
35
|
export {};
|
|
@@ -15,6 +15,8 @@ export declare function createColumnVirtualizer(input: {
|
|
|
15
15
|
scrollToIndex(index: number): void;
|
|
16
16
|
getVirtualItems(): import("./types").VirtualItem[];
|
|
17
17
|
getTotalSize(): number;
|
|
18
|
+
getOffsetForIndex(index: number): number;
|
|
19
|
+
getSizeForIndex(index: number): number;
|
|
18
20
|
getState(): import("./types").VirtualizerState;
|
|
19
21
|
subscribe(listener: () => void): () => boolean;
|
|
20
22
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type RowScrollScaling = {
|
|
2
|
+
/** True when the true content height exceeds the cap and mapping applies. */
|
|
3
|
+
readonly active: boolean;
|
|
4
|
+
/** Height (px) the DOM scroll spacer should occupy - capped at maxDomHeight. */
|
|
5
|
+
readonly domTotal: number;
|
|
6
|
+
/** Map a DOM scrollTop into the virtualizer's logical scroll offset. */
|
|
7
|
+
domToLogical(domTop: number): number;
|
|
8
|
+
/** Map a virtualizer logical offset back into a DOM scrollTop. */
|
|
9
|
+
logicalToDom(logical: number): number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the usable max element height from the two raw DOM signals.
|
|
13
|
+
*
|
|
14
|
+
* `layoutCap` is a tall probe's clamped `offsetHeight` - the height layout
|
|
15
|
+
* assigns the element. `scrollCap` is the `scrollHeight` a real `overflow:auto`
|
|
16
|
+
* container exposes for that same probe - the height the user can actually
|
|
17
|
+
* scroll through. They differ on mobile / high-DPR engines, which report a
|
|
18
|
+
* generous `offsetHeight` but then expose a SMALLER scrollable range (the
|
|
19
|
+
* physical limit is in device px, so a 3x-DPR phone has ~1/3 the CSS-px scroll
|
|
20
|
+
* cap). Trusting `offsetHeight` alone is exactly what strands the last rows of
|
|
21
|
+
* a huge grid on a phone, so we take the smaller of the two.
|
|
22
|
+
*
|
|
23
|
+
* A junk reading (<= the sanity floor, or so large the browser clearly didn't
|
|
24
|
+
* clamp) is dropped; if neither signal survives, the caller's conservative
|
|
25
|
+
* `fallback` is returned unchanged.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveMaxDomHeight(layoutCap: number, scrollCap: number, fallback: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* Build the scaling mapping for one axis.
|
|
30
|
+
*
|
|
31
|
+
* @param trueTotal The real content height in logical px (count * rowH, or
|
|
32
|
+
* the cumulative offset total for variable rows).
|
|
33
|
+
* @param maxDomHeight The browser's max element height (detected at runtime).
|
|
34
|
+
* @param viewport The scroll viewport height in px.
|
|
35
|
+
*
|
|
36
|
+
* Invariants (verified in scroll-scaling.test.ts):
|
|
37
|
+
* - Inert when `trueTotal <= maxDomHeight`: both maps are the identity.
|
|
38
|
+
* - `domTotal <= maxDomHeight` always, so the spacer never exceeds the cap.
|
|
39
|
+
* - Endpoints line up: domTop 0 -> logical 0, and the DOM max
|
|
40
|
+
* (`domTotal - viewport`) -> the logical max (`trueTotal - viewport`), so
|
|
41
|
+
* the very last row is always reachable.
|
|
42
|
+
* - `logicalToDom` is the inverse of `domToLogical` across the range.
|
|
43
|
+
* - Both maps are monotonic non-decreasing and clamp out-of-range inputs.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createRowScrollScaling(trueTotal: number, maxDomHeight: number, viewport: number): RowScrollScaling;
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
function clamp01(value) {
|
|
18
|
+
if (value <= 0)
|
|
19
|
+
return 0;
|
|
20
|
+
if (value >= 1)
|
|
21
|
+
return 1;
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
/** Below this a reading is junk (0 from jsdom, a sub-cap layout quirk, etc). */
|
|
25
|
+
const MAX_DOM_HEIGHT_SANITY_FLOOR = 100000;
|
|
26
|
+
/** At/above this the browser didn't clamp (returned ~the 1e9 we asked for). */
|
|
27
|
+
const MAX_DOM_HEIGHT_SANITY_CEIL = 900000000;
|
|
28
|
+
/**
|
|
29
|
+
* Shave a hair off the detected cap so the spacer never sits at the exact
|
|
30
|
+
* physical edge, where sub-pixel rounding could leave the final row a touch
|
|
31
|
+
* out of reach. 0.5% is invisible at desktop caps (~33.5M -> ~33.3M) yet
|
|
32
|
+
* keeps a safety gap proportional to the cap.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_DOM_HEIGHT_SAFETY = 0.995;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the usable max element height from the two raw DOM signals.
|
|
37
|
+
*
|
|
38
|
+
* `layoutCap` is a tall probe's clamped `offsetHeight` - the height layout
|
|
39
|
+
* assigns the element. `scrollCap` is the `scrollHeight` a real `overflow:auto`
|
|
40
|
+
* container exposes for that same probe - the height the user can actually
|
|
41
|
+
* scroll through. They differ on mobile / high-DPR engines, which report a
|
|
42
|
+
* generous `offsetHeight` but then expose a SMALLER scrollable range (the
|
|
43
|
+
* physical limit is in device px, so a 3x-DPR phone has ~1/3 the CSS-px scroll
|
|
44
|
+
* cap). Trusting `offsetHeight` alone is exactly what strands the last rows of
|
|
45
|
+
* a huge grid on a phone, so we take the smaller of the two.
|
|
46
|
+
*
|
|
47
|
+
* A junk reading (<= the sanity floor, or so large the browser clearly didn't
|
|
48
|
+
* clamp) is dropped; if neither signal survives, the caller's conservative
|
|
49
|
+
* `fallback` is returned unchanged.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveMaxDomHeight(layoutCap, scrollCap, fallback) {
|
|
52
|
+
const usable = [layoutCap, scrollCap].filter((v) => Number.isFinite(v) &&
|
|
53
|
+
v > MAX_DOM_HEIGHT_SANITY_FLOOR &&
|
|
54
|
+
v < MAX_DOM_HEIGHT_SANITY_CEIL);
|
|
55
|
+
if (usable.length === 0)
|
|
56
|
+
return fallback;
|
|
57
|
+
return Math.floor(Math.min(...usable) * MAX_DOM_HEIGHT_SAFETY);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Build the scaling mapping for one axis.
|
|
61
|
+
*
|
|
62
|
+
* @param trueTotal The real content height in logical px (count * rowH, or
|
|
63
|
+
* the cumulative offset total for variable rows).
|
|
64
|
+
* @param maxDomHeight The browser's max element height (detected at runtime).
|
|
65
|
+
* @param viewport The scroll viewport height in px.
|
|
66
|
+
*
|
|
67
|
+
* Invariants (verified in scroll-scaling.test.ts):
|
|
68
|
+
* - Inert when `trueTotal <= maxDomHeight`: both maps are the identity.
|
|
69
|
+
* - `domTotal <= maxDomHeight` always, so the spacer never exceeds the cap.
|
|
70
|
+
* - Endpoints line up: domTop 0 -> logical 0, and the DOM max
|
|
71
|
+
* (`domTotal - viewport`) -> the logical max (`trueTotal - viewport`), so
|
|
72
|
+
* the very last row is always reachable.
|
|
73
|
+
* - `logicalToDom` is the inverse of `domToLogical` across the range.
|
|
74
|
+
* - Both maps are monotonic non-decreasing and clamp out-of-range inputs.
|
|
75
|
+
*/
|
|
76
|
+
export function createRowScrollScaling(trueTotal, maxDomHeight, viewport) {
|
|
77
|
+
const safeMax = Math.max(maxDomHeight, 1);
|
|
78
|
+
const domTotal = Math.min(Math.max(trueTotal, 0), safeMax);
|
|
79
|
+
const active = trueTotal > safeMax;
|
|
80
|
+
// Scrollable ranges (content minus one viewport). Guard against zero so the
|
|
81
|
+
// ratios below never divide by zero on tiny / empty grids.
|
|
82
|
+
const domRange = Math.max(domTotal - viewport, 1);
|
|
83
|
+
const logicalRange = Math.max(trueTotal - viewport, 0);
|
|
84
|
+
return {
|
|
85
|
+
active,
|
|
86
|
+
domTotal,
|
|
87
|
+
domToLogical(domTop) {
|
|
88
|
+
if (!active)
|
|
89
|
+
return domTop;
|
|
90
|
+
return clamp01(domTop / domRange) * logicalRange;
|
|
91
|
+
},
|
|
92
|
+
logicalToDom(logical) {
|
|
93
|
+
if (!active)
|
|
94
|
+
return logical;
|
|
95
|
+
const lr = Math.max(logicalRange, 1);
|
|
96
|
+
return clamp01(logical / lr) * Math.max(domTotal - viewport, 0);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|