@svgrid/grid 3.0.7 → 3.0.9
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/README.md +1 -1
- package/dist/GridMenus.svelte +3 -2
- package/dist/SvGrid.controller.svelte.d.ts +60 -8
- package/dist/SvGrid.controller.svelte.js +490 -114
- package/dist/SvGrid.css +186 -3
- package/dist/SvGrid.svelte +285 -164
- package/dist/SvGrid.types.d.ts +24 -7
- package/dist/cdn/{GridMenus-CcwU8I4t.js → GridMenus-B1sipj6y.js} +165 -165
- package/dist/cdn/{GridMenus-BgUS-W3o.js → GridMenus-DlAzfHLk.js} +201 -201
- package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
- package/dist/cdn/{src-kE9B98r6.js → src-BYsa66I0.js} +9747 -10178
- package/dist/cdn/{src-25JmJ_7g.js → src-pA4RroLV.js} +10709 -11140
- package/dist/cdn/svgrid.js +27 -27
- package/dist/cdn/svgrid.svelte-external.js +27 -27
- package/dist/cdn/validate-XuMe4_KR.js +80 -0
- package/dist/cell-render.js +25 -3
- package/dist/columns.js +16 -1
- package/dist/conditional-formatting.js +9 -2
- package/dist/core.d.ts +93 -0
- package/dist/core.js +313 -47
- package/dist/filtering/excel-filters.js +41 -6
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/keyboard-handlers.js +5 -1
- package/dist/selection.d.ts +2 -2
- package/dist/selection.js +46 -12
- package/dist/server-block-cache.d.ts +7 -32
- package/dist/server-block-cache.js +21 -12
- package/dist/server-data-source.js +3 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/spreadsheet.js +7 -0
- package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
- package/dist/validate.js +13 -14
- package/dist/virtualization/column-virtualizer.d.ts +4 -0
- package/dist/virtualization/column-virtualizer.js +2 -0
- package/dist/virtualization/types.d.ts +20 -0
- package/dist/virtualization/virtualizer.js +90 -24
- package/dist/windowed-brand.d.ts +22 -0
- package/dist/windowed-brand.js +60 -0
- package/dist/windowed-data.d.ts +6 -0
- package/dist/windowed-data.js +34 -0
- package/dist/windowed-row-model.d.ts +1 -0
- package/dist/windowed-row-model.js +97 -0
- package/package.json +1 -1
- package/src/GridMenus.svelte +3 -2
- package/src/SvGrid.controller.svelte.ts +483 -122
- package/src/SvGrid.css +186 -3
- package/src/SvGrid.svelte +285 -164
- package/src/SvGrid.types.ts +24 -7
- package/src/cell-render.ts +24 -2
- package/src/columns.ts +16 -1
- package/src/conditional-formatting.ts +6 -4
- package/src/core.sort.test.ts +61 -0
- package/src/core.ts +309 -49
- package/src/counting-cell.test.svelte +11 -0
- package/src/filtering/excel-filters.ts +39 -6
- package/src/headless.reactivity.svelte.test.ts +89 -0
- package/src/headless.ts +1 -1
- package/src/index.ts +7 -1
- package/src/keyboard-handlers.ts +5 -1
- package/src/new-features.test.ts +24 -0
- package/src/selection.test.ts +51 -0
- package/src/selection.ts +47 -14
- package/src/server-block-cache.ts +27 -10
- package/src/server-data-source.ts +3 -1
- package/src/server.ts +6 -0
- package/src/spreadsheet.ts +6 -0
- package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
- package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
- package/src/svgrid.behavior.test.ts +58 -0
- package/src/svgrid.column-cell-recycling.test.ts +120 -0
- package/src/svgrid.interaction.test.ts +4 -1
- package/src/svgrid.new-features.wrapper.test.ts +5 -4
- package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
- package/src/validate.test.ts +20 -4
- package/src/validate.ts +13 -16
- package/src/virtualization/column-virtualizer.ts +6 -0
- package/src/virtualization/types.ts +20 -0
- package/src/virtualization/virtualizer.test.ts +152 -0
- package/src/virtualization/virtualizer.ts +93 -27
- package/src/windowed-brand.ts +67 -0
- package/src/windowed-data.test.ts +105 -0
- package/src/windowed-data.ts +36 -0
- package/src/windowed-row-model.ts +113 -0
- package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
- package/dist/cdn/validate-AZoD8BoR.js +0 -80
- /package/dist/{subscribe.d.ts → subscribe.svelte.d.ts} +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM: body cells are recycled across the column window.
|
|
3
|
+
*
|
|
4
|
+
* With column virtualization on, the body cells' {#each} is keyed by a slot,
|
|
5
|
+
* not by the column id. A column keeps its <td> while it stays in the window;
|
|
6
|
+
* a column entering the window takes the <td> of one that left. A thumb drag
|
|
7
|
+
* that jumps far along a wide grid therefore updates the cells in place
|
|
8
|
+
* instead of destroying the window's cells and creating new ones.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
11
|
+
import { flushSync, mount, unmount } from 'svelte'
|
|
12
|
+
import SvGrid from './SvGrid.svelte'
|
|
13
|
+
import { renderComponent } from './render-component'
|
|
14
|
+
import CountingCell from './counting-cell.test.svelte'
|
|
15
|
+
|
|
16
|
+
const COLS = 400
|
|
17
|
+
const ROWS = 10
|
|
18
|
+
|
|
19
|
+
type Row = Record<string, number>
|
|
20
|
+
|
|
21
|
+
function makeData(): Row[] {
|
|
22
|
+
return Array.from({ length: ROWS }, (_, r) => {
|
|
23
|
+
const row: Row = { id: r }
|
|
24
|
+
for (let c = 0; c < COLS; c += 1) row[`c${c}`] = r * 1000 + c
|
|
25
|
+
return row
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let cleanup: (() => void) | null = null
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup?.()
|
|
32
|
+
cleanup = null
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
async function mountGrid(columns: unknown[]) {
|
|
36
|
+
const target = document.createElement('div')
|
|
37
|
+
document.body.appendChild(target)
|
|
38
|
+
const app = mount(SvGrid, {
|
|
39
|
+
target,
|
|
40
|
+
props: {
|
|
41
|
+
data: makeData(),
|
|
42
|
+
columns,
|
|
43
|
+
getRowId: (r: Row) => String(r.id),
|
|
44
|
+
containerHeight: 400,
|
|
45
|
+
containerWidth: 1000,
|
|
46
|
+
} as never,
|
|
47
|
+
})
|
|
48
|
+
flushSync()
|
|
49
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
50
|
+
flushSync()
|
|
51
|
+
cleanup = () => {
|
|
52
|
+
unmount(app)
|
|
53
|
+
target.remove()
|
|
54
|
+
}
|
|
55
|
+
return target
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function scrollTo(root: HTMLElement, left: number) {
|
|
59
|
+
const container = root.querySelector<HTMLElement>('.sv-grid-container')!
|
|
60
|
+
container.scrollLeft = left
|
|
61
|
+
container.dispatchEvent(new Event('scroll'))
|
|
62
|
+
await new Promise((r) => setTimeout(r, 40))
|
|
63
|
+
flushSync()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const firstRowCells = (root: HTMLElement) =>
|
|
67
|
+
[...root.querySelector('tbody tr')!.querySelectorAll<HTMLElement>(':scope > td[data-col-id]')]
|
|
68
|
+
|
|
69
|
+
const plainColumns = () => Array.from({ length: COLS }, (_, c) => ({ field: `c${c}`, header: `C${c}`, width: 100 }))
|
|
70
|
+
|
|
71
|
+
describe('body cells recycled across the column window', { timeout: 30_000 }, () => {
|
|
72
|
+
it('reuses the <td>s on a far jump and every cell shows its own column', async () => {
|
|
73
|
+
const root = await mountGrid(plainColumns())
|
|
74
|
+
const before = firstRowCells(root)
|
|
75
|
+
expect(before[0]!.dataset.colId).toBe('c0')
|
|
76
|
+
|
|
77
|
+
await scrollTo(root, 200 * 100)
|
|
78
|
+
const after = firstRowCells(root)
|
|
79
|
+
const ids = after.map((td) => td.dataset.colId!)
|
|
80
|
+
// The window moved: none of the first columns are rendered any more.
|
|
81
|
+
expect(ids).not.toContain('c0')
|
|
82
|
+
expect(ids.some((id) => Number(id.slice(1)) >= 195)).toBe(true)
|
|
83
|
+
// ...and the cells are the same elements, now showing other columns.
|
|
84
|
+
const reused = after.filter((td) => before.includes(td)).length
|
|
85
|
+
expect(reused).toBeGreaterThanOrEqual(Math.min(before.length, after.length) - 2)
|
|
86
|
+
// Every cell's text is its own column's value (row 0: value = column index).
|
|
87
|
+
for (const td of after) expect(td.textContent!.trim()).toBe(td.dataset.colId!.slice(1))
|
|
88
|
+
// Ids stay unique.
|
|
89
|
+
expect(new Set(after.map((td) => td.id)).size).toBe(after.length)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('keeps a column on the same <td> while it stays in the window', async () => {
|
|
93
|
+
const root = await mountGrid(plainColumns())
|
|
94
|
+
const byColumn = () => new Map(firstRowCells(root).map((td) => [td.dataset.colId!, td]))
|
|
95
|
+
const before = byColumn()
|
|
96
|
+
await scrollTo(root, 200)
|
|
97
|
+
const after = byColumn()
|
|
98
|
+
const kept = [...before.keys()].filter((id) => after.has(id))
|
|
99
|
+
expect(kept.length).toBeGreaterThan(0)
|
|
100
|
+
for (const id of kept) expect(after.get(id)).toBe(before.get(id))
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('re-creates an app cell component per column instead of re-propping it', async () => {
|
|
104
|
+
const columns = Array.from({ length: COLS }, (_, c) => ({
|
|
105
|
+
field: `c${c}`,
|
|
106
|
+
header: `C${c}`,
|
|
107
|
+
width: 100,
|
|
108
|
+
cell: (ctx: { getValue: () => unknown; column: { id: string } }) =>
|
|
109
|
+
renderComponent(CountingCell, { columnId: ctx.column.id, value: ctx.getValue() }),
|
|
110
|
+
}))
|
|
111
|
+
const root = await mountGrid(columns)
|
|
112
|
+
await scrollTo(root, 200 * 100)
|
|
113
|
+
// Each mounted instance records the column it was created for; a cell
|
|
114
|
+
// showing a column must be an instance created for that column.
|
|
115
|
+
for (const td of firstRowCells(root)) {
|
|
116
|
+
const el = td.querySelector<HTMLElement>('[data-counting-cell]')!
|
|
117
|
+
expect(el.dataset.createdFor).toBe(td.dataset.colId)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -320,7 +320,10 @@ describe('SvGrid interactions - cell pointer events', () => {
|
|
|
320
320
|
if (!a || !c) return -1 // jsdom didn't lay the cells out; caller skips
|
|
321
321
|
a.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, button: 0, pointerType }))
|
|
322
322
|
await tick()
|
|
323
|
-
|
|
323
|
+
// What a browser fires as the pointer moves into the cell: `pointerover`
|
|
324
|
+
// (bubbles, from the cell the pointer left), then `pointerenter`.
|
|
325
|
+
c.dispatchEvent(new PointerEvent('pointerover', { bubbles: true, pointerId: 1, pointerType, relatedTarget: a }))
|
|
326
|
+
c.dispatchEvent(new PointerEvent('pointerenter', { bubbles: false, pointerId: 1, pointerType, relatedTarget: a }))
|
|
324
327
|
await tick()
|
|
325
328
|
return target.querySelectorAll('[data-selected-range="true"]').length
|
|
326
329
|
} finally {
|
|
@@ -77,8 +77,9 @@ describe('SvGrid wrapper - cellClass + rowClass', () => {
|
|
|
77
77
|
expect(source).toMatch(/function computeCellClass/)
|
|
78
78
|
// The td that draws a merge shows the origin cell, so the class comes
|
|
79
79
|
// from the cell that draws (cellRow / cellColumn): the row's own outside a merge.
|
|
80
|
-
|
|
81
|
-
expect(source).toMatch(/
|
|
80
|
+
// Computed once per cell in `bodyCellInfo`, which the td reads as `c`.
|
|
81
|
+
expect(source).toMatch(/userCellClass: computeCellClass\(\s*cellRow,\s*cellColumn,?\s*\)/)
|
|
82
|
+
expect(source).toMatch(/class=\{`sv-grid-cell \$\{c\.userCellClass\}`\}/)
|
|
82
83
|
})
|
|
83
84
|
})
|
|
84
85
|
|
|
@@ -99,12 +100,12 @@ describe('SvGrid wrapper - declarative cell validation (validate hook)', () => {
|
|
|
99
100
|
// The virtualized window, the plain body and the frozen rows all render
|
|
100
101
|
// `bodyRow`, so the class appears exactly once: the two copies the
|
|
101
102
|
// render paths used to carry had drifted apart.
|
|
102
|
-
const hits = source.match(/class:sv-grid-cell-invalid=\{cellValidity\.invalid\}/g) ?? []
|
|
103
|
+
const hits = source.match(/class:sv-grid-cell-invalid=\{c\.cellValidity\.invalid\}/g) ?? []
|
|
103
104
|
expect(hits.length).toBe(1)
|
|
104
105
|
expect(source).toMatch(/\{#snippet bodyRow\(/)
|
|
105
106
|
expect((source.match(/\{@render bodyRow\(/g) ?? []).length).toBe(3)
|
|
106
107
|
// Message wins over the plain column tooltip when the cell is invalid.
|
|
107
|
-
expect(source).toMatch(/cellValidity\.invalid && cellValidity\.message/)
|
|
108
|
+
expect(source).toMatch(/c\.cellValidity\.invalid && c\.cellValidity\.message/)
|
|
108
109
|
})
|
|
109
110
|
|
|
110
111
|
it('forwards computeCellValidity through the controller so the wrapper can read it', () => {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM: pinned columns with column virtualization on (the default).
|
|
3
|
+
*
|
|
4
|
+
* Pinned cells are position: sticky, so they must stay in the DOM. The grid
|
|
5
|
+
* used to keep them there by rendering one contiguous run from column 0 (a
|
|
6
|
+
* left pin) to the last column (a right pin), so pinning either edge of a
|
|
7
|
+
* 1,000-column grid rendered every column. It now renders three runs - the
|
|
8
|
+
* pinned-left columns, the virtual window, the pinned-right columns - with a
|
|
9
|
+
* spacer cell for the columns between two runs.
|
|
10
|
+
*
|
|
11
|
+
* The table uses `table-layout: fixed`, so every row must give each column the
|
|
12
|
+
* same slot. The checks compare each row's total declared width (cells plus
|
|
13
|
+
* spacers) against the leaf header row.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
16
|
+
import { flushSync, mount, unmount } from 'svelte'
|
|
17
|
+
import SvGrid from './SvGrid.svelte'
|
|
18
|
+
|
|
19
|
+
const COLS = 120
|
|
20
|
+
const ROWS = 20
|
|
21
|
+
|
|
22
|
+
type Row = Record<string, number>
|
|
23
|
+
|
|
24
|
+
function leafColumns() {
|
|
25
|
+
return Array.from({ length: COLS }, (_, c) => ({ field: `c${c}`, header: `C${c}`, width: 100 }))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeData(): Row[] {
|
|
29
|
+
return Array.from({ length: ROWS }, (_, r) => {
|
|
30
|
+
const row: Row = { id: r }
|
|
31
|
+
for (let c = 0; c < COLS; c += 1) row[`c${c}`] = r * COLS + c
|
|
32
|
+
return row
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let cleanup: (() => void) | null = null
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
cleanup?.()
|
|
39
|
+
cleanup = null
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
async function mountGrid(props: Record<string, unknown>) {
|
|
43
|
+
const target = document.createElement('div')
|
|
44
|
+
document.body.appendChild(target)
|
|
45
|
+
const app = mount(SvGrid, {
|
|
46
|
+
target,
|
|
47
|
+
props: {
|
|
48
|
+
data: makeData(),
|
|
49
|
+
columns: leafColumns(),
|
|
50
|
+
getRowId: (r: Row) => String(r.id),
|
|
51
|
+
containerHeight: 400,
|
|
52
|
+
containerWidth: 1000,
|
|
53
|
+
...props,
|
|
54
|
+
} as never,
|
|
55
|
+
})
|
|
56
|
+
flushSync()
|
|
57
|
+
// The column virtualizer learns its count and viewport in effects.
|
|
58
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
59
|
+
flushSync()
|
|
60
|
+
cleanup = () => {
|
|
61
|
+
unmount(app)
|
|
62
|
+
target.remove()
|
|
63
|
+
}
|
|
64
|
+
return target
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const px = (el: Element) => Number.parseFloat((el as HTMLElement).style.width) || 0
|
|
68
|
+
const rowWidth = (tr: Element) => [...tr.children].reduce((sum, cell) => sum + px(cell), 0)
|
|
69
|
+
const dataCells = (tr: Element) => [...tr.querySelectorAll<HTMLElement>('td[data-col-id]')]
|
|
70
|
+
const firstBodyRow = (root: HTMLElement) => root.querySelector('tbody tr')!
|
|
71
|
+
const leafHeaderRow = (root: HTMLElement) => [...root.querySelectorAll('thead tr')].pop()!
|
|
72
|
+
|
|
73
|
+
describe('pinned columns with column virtualization', { timeout: 30_000 }, () => {
|
|
74
|
+
it('renders only the window when nothing is pinned (baseline)', async () => {
|
|
75
|
+
const root = await mountGrid({})
|
|
76
|
+
const ids = dataCells(firstBodyRow(root)).map((td) => td.dataset.colId)
|
|
77
|
+
expect(ids.length).toBeLessThan(40)
|
|
78
|
+
expect(ids).not.toContain(`c${COLS - 1}`)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('renders the window plus the pinned columns, not every column', async () => {
|
|
82
|
+
const root = await mountGrid({ initialColumnPinning: { left: ['c0'], right: [`c${COLS - 1}`] } })
|
|
83
|
+
const ids = dataCells(firstBodyRow(root)).map((td) => td.dataset.colId)
|
|
84
|
+
// Before: pinning both ends rendered all 120 columns in every row.
|
|
85
|
+
expect(ids.length).toBeLessThan(40)
|
|
86
|
+
expect(ids[0]).toBe('c0')
|
|
87
|
+
expect(ids[ids.length - 1]).toBe(`c${COLS - 1}`)
|
|
88
|
+
// The columns between the window and the right pin are a spacer, not cells.
|
|
89
|
+
const spacers = firstBodyRow(root).querySelectorAll('td.sv-grid-cell-spacer')
|
|
90
|
+
expect(spacers.length).toBeGreaterThan(0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('gives every row the same slots as the leaf header row', async () => {
|
|
94
|
+
const root = await mountGrid({ initialColumnPinning: { left: ['c0'], right: [`c${COLS - 1}`] } })
|
|
95
|
+
const header = leafHeaderRow(root)
|
|
96
|
+
const body = firstBodyRow(root)
|
|
97
|
+
expect(slots(body)).toBe(slots(header))
|
|
98
|
+
// Fixed layout: cells plus spacers cover every column (plus the system
|
|
99
|
+
// columns, such as the row checkbox, that sit before them).
|
|
100
|
+
expect(rowWidth(header)).toBeGreaterThanOrEqual(COLS * 100)
|
|
101
|
+
expect(rowWidth(body)).toBeCloseTo(rowWidth(header), 0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('splits a column group that crosses a seam into one cell per run', async () => {
|
|
105
|
+
const columns = [
|
|
106
|
+
{ id: 'all', header: 'Everything', columns: leafColumns() },
|
|
107
|
+
]
|
|
108
|
+
const root = await mountGrid({ columns, initialColumnPinning: { left: ['c0'], right: [`c${COLS - 1}`] } })
|
|
109
|
+
const groupCells = [...root.querySelectorAll<HTMLElement>('th.sv-grid-group-header-cell')]
|
|
110
|
+
// One piece over the left pin + window, one over the right pin; distinct
|
|
111
|
+
// keys, or Svelte would have thrown on a duplicate key in the each block.
|
|
112
|
+
expect(groupCells.length).toBe(2)
|
|
113
|
+
expect(groupCells.every((th) => th.textContent?.includes('Everything'))).toBe(true)
|
|
114
|
+
const groupRow = groupCells[0]!.closest('tr')!
|
|
115
|
+
const header = leafHeaderRow(root)
|
|
116
|
+
expect(slots(groupRow)).toBe(slots(header))
|
|
117
|
+
expect(rowWidth(groupRow)).toBeCloseTo(rowWidth(header), 0)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('clamps a merged cell to the run it starts in', async () => {
|
|
121
|
+
const root = await mountGrid({
|
|
122
|
+
initialColumnPinning: { left: ['c0'], right: [`c${COLS - 1}`] },
|
|
123
|
+
mergedCells: [{ rowIndex: 0, colIndex: 0, rowSpan: 1, colSpan: COLS }],
|
|
124
|
+
})
|
|
125
|
+
const header = leafHeaderRow(root)
|
|
126
|
+
const body = firstBodyRow(root)
|
|
127
|
+
// The merge covers every column, but no td may span across a spacer: the
|
|
128
|
+
// row keeps the header's slots, so each piece stays inside its run.
|
|
129
|
+
expect(slots(body)).toBe(slots(header))
|
|
130
|
+
expect(rowWidth(body)).toBeCloseTo(rowWidth(header), 0)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('the first render of a wide grid', { timeout: 30_000 }, () => {
|
|
135
|
+
it('draws a window of columns, not every column, before anything is measured', async () => {
|
|
136
|
+
const wide = 2000
|
|
137
|
+
const target = document.createElement('div')
|
|
138
|
+
document.body.appendChild(target)
|
|
139
|
+
const columns = Array.from({ length: wide }, (_, c) => ({ field: `c${c}`, header: `C${c}`, width: 100 }))
|
|
140
|
+
const data = [Object.fromEntries([['id', 0], ...columns.map((c, i) => [c.field, i])])]
|
|
141
|
+
const app = mount(SvGrid, {
|
|
142
|
+
target,
|
|
143
|
+
props: { data, columns, getRowId: (r: Row) => String(r.id), containerHeight: 300 } as never,
|
|
144
|
+
})
|
|
145
|
+
flushSync()
|
|
146
|
+
cleanup = () => {
|
|
147
|
+
unmount(app)
|
|
148
|
+
target.remove()
|
|
149
|
+
}
|
|
150
|
+
// jsdom never lays out, so this is the pre-measure window: the columns
|
|
151
|
+
// that fit an estimated viewport, plus the overscan.
|
|
152
|
+
const cells = dataCells(firstBodyRow(target))
|
|
153
|
+
expect(cells.length).toBeGreaterThan(0)
|
|
154
|
+
expect(cells.length).toBeLessThan(40)
|
|
155
|
+
expect(target.querySelectorAll('td').length).toBeLessThan(200)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
/** Table slots a row occupies: each cell counts its colspan. */
|
|
160
|
+
function slots(tr: Element): number {
|
|
161
|
+
return [...tr.children].reduce((n, cell) => n + ((cell as HTMLTableCellElement).colSpan || 1), 0)
|
|
162
|
+
}
|
|
@@ -138,14 +138,26 @@ describe('the rowModel prop', () => {
|
|
|
138
138
|
await tick()
|
|
139
139
|
|
|
140
140
|
// The model was told; the grid did NOT reorder locally, because a model
|
|
141
|
-
// with `setSort` implies `externalSort`.
|
|
142
|
-
//
|
|
143
|
-
|
|
141
|
+
// with `setSort` implies `externalSort`. Mounting with no sort tells the
|
|
142
|
+
// model nothing - it used to get an empty sort, which made a server
|
|
143
|
+
// source refetch page 0 for no reason.
|
|
144
|
+
expect(source.sorts).toEqual([[{ id: 'name', desc: false }]])
|
|
144
145
|
const firstRow = target.querySelector('tbody .sv-grid-row')!
|
|
145
146
|
expect(firstRow.textContent).toContain('Zoe')
|
|
146
147
|
destroy()
|
|
147
148
|
})
|
|
148
149
|
|
|
150
|
+
it('hands initialSorting to the model once, since the model has no initial sort of its own', async () => {
|
|
151
|
+
const source = handWritten([
|
|
152
|
+
{ id: 1, name: 'Zoe' },
|
|
153
|
+
{ id: 2, name: 'Ada' },
|
|
154
|
+
])
|
|
155
|
+
const { destroy } = await mountGrid({ rowModel: source.model, sortable: true, initialSorting: [{ id: 'name', desc: true }] })
|
|
156
|
+
await tick()
|
|
157
|
+
expect(source.sorts).toEqual([[{ id: 'name', desc: true }]])
|
|
158
|
+
destroy()
|
|
159
|
+
})
|
|
160
|
+
|
|
149
161
|
it('lets an explicit prop win over the model', async () => {
|
|
150
162
|
const source = handWritten([{ id: 1, name: 'Ada' }])
|
|
151
163
|
const { target, destroy } = await mountGrid({
|
package/src/validate.test.ts
CHANGED
|
@@ -58,6 +58,22 @@ describe('unknown field', () => {
|
|
|
58
58
|
expect(run({ data: [...loading, ...data], columns: [{ field: 'naem' }] })).toHaveLength(1)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
+
it('does not walk a million placeholders looking for the sample', () => {
|
|
62
|
+
// A row model scrolled to its middle: placeholders up to the loaded block.
|
|
63
|
+
const placeholder = createRowPlaceholder('loading', { index: 0 })
|
|
64
|
+
const big = new Array(1_000_000).fill(placeholder)
|
|
65
|
+
big.push({ name: 'x' })
|
|
66
|
+
let reads = 0
|
|
67
|
+
const counted = new Proxy(big, {
|
|
68
|
+
get(target, key, receiver) {
|
|
69
|
+
if (typeof key === 'string' && /^\d+$/.test(key)) reads += 1
|
|
70
|
+
return Reflect.get(target, key, receiver)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
expect(run({ data: counted, columns: [{ field: 'naem' }] })).toEqual([])
|
|
74
|
+
expect(reads).toBeLessThanOrEqual(1000)
|
|
75
|
+
})
|
|
76
|
+
|
|
61
77
|
it('looks through group columns', () => {
|
|
62
78
|
const msgs = run({ columns: [{ header: 'Group', columns: [{ field: 'nope' }] }] })
|
|
63
79
|
expect(msgs[0]).toContain('"nope" does not exist')
|
|
@@ -181,10 +197,10 @@ describe('gantt versus treeData', () => {
|
|
|
181
197
|
})
|
|
182
198
|
|
|
183
199
|
describe('pinning versus column virtualization', () => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
expect(
|
|
200
|
+
// Pinned columns render as their own runs beside the virtual window, so
|
|
201
|
+
// pinning with column virtualization on (the default) is supported.
|
|
202
|
+
it('is silent for pinning with column virtualization on by default', () => {
|
|
203
|
+
expect(run({ initialColumnPinning: { left: ['name'], right: ['name'] } })).toEqual([])
|
|
188
204
|
})
|
|
189
205
|
|
|
190
206
|
it('is silent once column virtualization is off', () => {
|
package/src/validate.ts
CHANGED
|
@@ -24,6 +24,8 @@ import { rowPlaceholderState } from './server-block-cache'
|
|
|
24
24
|
|
|
25
25
|
/** How many rows to sample when deciding whether a `field` exists. */
|
|
26
26
|
const FIELD_SAMPLE_ROWS = 10
|
|
27
|
+
/** How many rows to look through for that sample, placeholders included. */
|
|
28
|
+
const FIELD_SCAN_ROWS = 1000
|
|
27
29
|
|
|
28
30
|
const DOCS = 'https://svgrid.com/docs/getting-started/3-data-and-columns/'
|
|
29
31
|
|
|
@@ -79,9 +81,15 @@ export function validateGridConfig<
|
|
|
79
81
|
// no data keys at all and say nothing about the columns.
|
|
80
82
|
// Stops at the sample size: this runs on every data change, and a filter
|
|
81
83
|
// over all the rows first was O(n) per tick on a live feed.
|
|
84
|
+
// The scan is bounded too: a row model scrolled to the middle of a million
|
|
85
|
+
// rows, or just purged by a sort, hands over a list that is placeholders
|
|
86
|
+
// up to the loaded block, and walking all of them for ten real rows was
|
|
87
|
+
// O(n) per block landing. Finding none means nothing to judge by.
|
|
82
88
|
const sample: TData[] = []
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
const data = input.data ?? []
|
|
90
|
+
const scan = Math.min(data.length, FIELD_SCAN_ROWS)
|
|
91
|
+
for (let i = 0; i < scan && sample.length < FIELD_SAMPLE_ROWS; i += 1) {
|
|
92
|
+
const row = data[i]!
|
|
85
93
|
if (!rowPlaceholderState(row)) sample.push(row)
|
|
86
94
|
}
|
|
87
95
|
// Under server-side grouping the first rows are group rows, which carry
|
|
@@ -220,20 +228,9 @@ export function validateGridConfig<
|
|
|
220
228
|
)
|
|
221
229
|
}
|
|
222
230
|
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
// which means the natural way to write this silently does nothing.
|
|
227
|
-
const pinned =
|
|
228
|
-
(input.initialColumnPinning?.left?.length ?? 0) +
|
|
229
|
-
(input.initialColumnPinning?.right?.length ?? 0)
|
|
230
|
-
if (pinned > 0 && input.columnVirtualization !== false) {
|
|
231
|
-
messages.push(
|
|
232
|
-
'[svgrid] `initialColumnPinning` is set while column virtualization is on ' +
|
|
233
|
-
'(its default), so the pinned columns will not stick - the virtualizer ' +
|
|
234
|
-
'recycles column nodes. Add `columnVirtualization={false}`.',
|
|
235
|
-
)
|
|
236
|
-
}
|
|
231
|
+
// (7. was "pinning versus column virtualization". Pinned columns now render
|
|
232
|
+
// as their own runs beside the virtual window, so the pair is supported and
|
|
233
|
+
// there is nothing to warn about.)
|
|
237
234
|
|
|
238
235
|
// ---- 8. Server-mode contracts left half-wired -----------------------------
|
|
239
236
|
// Each of these makes the grid hand control to the consumer. Miss the other
|
|
@@ -7,6 +7,10 @@ export function createColumnVirtualizer(input: {
|
|
|
7
7
|
viewportWidth: number
|
|
8
8
|
scrollOffset?: number
|
|
9
9
|
overscan?: number
|
|
10
|
+
/** Columns kept behind the scroll direction; defaults to `overscan`. */
|
|
11
|
+
overscanBehind?: number
|
|
12
|
+
/** Fewest columns left ahead of a scroll before the window moves. */
|
|
13
|
+
overscanMin?: number
|
|
10
14
|
/** Either a uniform size (number) or a per-column size function. */
|
|
11
15
|
estimateSize?: number | ColumnSizeEstimator
|
|
12
16
|
}) {
|
|
@@ -16,6 +20,8 @@ export function createColumnVirtualizer(input: {
|
|
|
16
20
|
viewportHeight: input.viewportWidth,
|
|
17
21
|
scrollOffset: input.scrollOffset ?? 0,
|
|
18
22
|
overscan: input.overscan ?? 4,
|
|
23
|
+
overscanBehind: input.overscanBehind,
|
|
24
|
+
overscanMin: input.overscanMin,
|
|
19
25
|
})
|
|
20
26
|
|
|
21
27
|
return {
|
|
@@ -16,8 +16,28 @@ export type VirtualizerOptions = {
|
|
|
16
16
|
*/
|
|
17
17
|
estimateSize: number | ((index: number) => number)
|
|
18
18
|
overscan?: number
|
|
19
|
+
/**
|
|
20
|
+
* Items kept on the side the scroll is moving away from (`overscan` is kept
|
|
21
|
+
* ahead of it). Defaults to `overscan`, a symmetric window. Before the
|
|
22
|
+
* first scroll there is no direction and both sides get `overscan`.
|
|
23
|
+
*/
|
|
24
|
+
overscanBehind?: number
|
|
25
|
+
/**
|
|
26
|
+
* Keep the rendered window while a scroll leaves at least this many items
|
|
27
|
+
* rendered ahead of it, and move the window only when the scroll runs past
|
|
28
|
+
* that: the window is then rebuilt with the full `overscan` ahead, so it
|
|
29
|
+
* moves `overscan - overscanMin + 1` items at a time instead of one. Each
|
|
30
|
+
* move costs about the same whether one item enters or several, so fewer,
|
|
31
|
+
* larger moves are less work, and the scroll frames in between change
|
|
32
|
+
* nothing. A window built at rest, or after a jump to items outside the
|
|
33
|
+
* rendered window, carries `overscanMin` instead of `overscan`. Undefined
|
|
34
|
+
* moves the window on every item boundary, always with `overscan`.
|
|
35
|
+
*/
|
|
36
|
+
overscanMin?: number
|
|
19
37
|
viewportHeight: number
|
|
20
38
|
scrollOffset?: number
|
|
39
|
+
/** Internal: the sign of the last scroll movement (-1, 0, 1). */
|
|
40
|
+
scrollDirection?: number
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
export type VirtualizerState = {
|
|
@@ -89,3 +89,155 @@ describe('virtualizer', () => {
|
|
|
89
89
|
expect(calls).toBe(afterBuild)
|
|
90
90
|
})
|
|
91
91
|
})
|
|
92
|
+
|
|
93
|
+
describe('virtualizer: overscanBehind', () => {
|
|
94
|
+
// 1,000 items of 10px in a 100px viewport: items 0-9 visible at offset 0.
|
|
95
|
+
const make = () =>
|
|
96
|
+
createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3, overscanBehind: 1 })
|
|
97
|
+
|
|
98
|
+
it('keeps the full overscan on both sides before any scroll', () => {
|
|
99
|
+
// Created at offset 500: no movement yet, so no direction.
|
|
100
|
+
const fresh = createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3, overscanBehind: 1, scrollOffset: 500 })
|
|
101
|
+
const items = fresh.getVirtualItems()
|
|
102
|
+
expect(items[0]!.index).toBe(47)
|
|
103
|
+
expect(items.at(-1)!.index).toBe(50 + 10 + 3)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('puts the overscan ahead of a forward scroll and one item behind it', () => {
|
|
107
|
+
const v = make()
|
|
108
|
+
v.setScrollOffset(500)
|
|
109
|
+
const items = v.getVirtualItems()
|
|
110
|
+
expect(items[0]!.index).toBe(50 - 1)
|
|
111
|
+
expect(items.at(-1)!.index).toBe(60 + 3)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('flips when the scroll turns around', () => {
|
|
115
|
+
const v = make()
|
|
116
|
+
v.setScrollOffset(500)
|
|
117
|
+
v.setScrollOffset(400)
|
|
118
|
+
const items = v.getVirtualItems()
|
|
119
|
+
expect(items[0]!.index).toBe(40 - 3)
|
|
120
|
+
expect(items.at(-1)!.index).toBe(50 + 1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('defaults to a symmetric window', () => {
|
|
124
|
+
const v = createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3 })
|
|
125
|
+
v.setScrollOffset(500)
|
|
126
|
+
const items = v.getVirtualItems()
|
|
127
|
+
expect(items[0]!.index).toBe(47)
|
|
128
|
+
expect(items.at(-1)!.index).toBe(63)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('virtualizer: overscanMin', () => {
|
|
133
|
+
// 1,000 items of 10px in a 100px viewport; at offset o the visible items
|
|
134
|
+
// are o/10 .. o/10 + 10.
|
|
135
|
+
const make = (estimateSize: number | ((i: number) => number) = 10) =>
|
|
136
|
+
createVirtualizer({ count: 1000, estimateSize, viewportHeight: 100, overscan: 3, overscanBehind: 1, overscanMin: 1 })
|
|
137
|
+
|
|
138
|
+
const range = (v: ReturnType<typeof make>) => {
|
|
139
|
+
const items = v.getVirtualItems()
|
|
140
|
+
return [items[0]!.index, items.at(-1)!.index]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it('renders overscanMin around the visible items at rest and after a jump', () => {
|
|
144
|
+
// At rest: one item on each side of visible 0..10.
|
|
145
|
+
const v = make()
|
|
146
|
+
expect(range(v)).toEqual([0, 11])
|
|
147
|
+
// Visible 50..60 shares nothing with the window: a jump, so one item
|
|
148
|
+
// ahead and one behind, as a thumb drag lands.
|
|
149
|
+
v.setScrollOffset(500)
|
|
150
|
+
expect(range(v)).toEqual([49, 61])
|
|
151
|
+
const fresh = createVirtualizer({ count: 1000, estimateSize: 10, viewportHeight: 100, overscan: 3, overscanBehind: 1, overscanMin: 1, scrollOffset: 500 })
|
|
152
|
+
expect(range(fresh)).toEqual([49, 61])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('keeps the window while one item is left ahead, then moves it three at a time', () => {
|
|
156
|
+
const v = make()
|
|
157
|
+
let emits = 0
|
|
158
|
+
v.subscribe(() => (emits += 1))
|
|
159
|
+
// Visible end 11 leaves nothing ahead of [0, 11]: the window moves on
|
|
160
|
+
// with the full overscan ahead.
|
|
161
|
+
v.setScrollOffset(10)
|
|
162
|
+
const moved = v.getVirtualItems()
|
|
163
|
+
expect(range(v)).toEqual([0, 14])
|
|
164
|
+
expect(emits).toBe(1)
|
|
165
|
+
|
|
166
|
+
// Visible ends 12 and 13: item 14 is still rendered ahead of them.
|
|
167
|
+
v.setScrollOffset(20)
|
|
168
|
+
v.setScrollOffset(30)
|
|
169
|
+
expect(v.getVirtualItems()).toBe(moved)
|
|
170
|
+
expect(emits).toBe(1)
|
|
171
|
+
|
|
172
|
+
// Visible end 14 leaves nothing ahead: the window moves by three.
|
|
173
|
+
v.setScrollOffset(40)
|
|
174
|
+
expect(range(v)).toEqual([3, 17])
|
|
175
|
+
expect(emits).toBe(2)
|
|
176
|
+
v.setScrollOffset(50)
|
|
177
|
+
v.setScrollOffset(60)
|
|
178
|
+
expect(emits).toBe(2)
|
|
179
|
+
v.setScrollOffset(70)
|
|
180
|
+
expect(range(v)).toEqual([6, 20])
|
|
181
|
+
expect(emits).toBe(3)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('works the same with per-item sizes', () => {
|
|
185
|
+
// The per-item path counts visible 0..9 at offset 0, not 0..10: an item
|
|
186
|
+
// starting exactly at the viewport's end is not in it.
|
|
187
|
+
const v = make(() => 10)
|
|
188
|
+
expect(range(v)).toEqual([0, 10])
|
|
189
|
+
v.setScrollOffset(10)
|
|
190
|
+
const moved = v.getVirtualItems()
|
|
191
|
+
expect(range(v)).toEqual([0, 13])
|
|
192
|
+
v.setScrollOffset(30)
|
|
193
|
+
expect(v.getVirtualItems()).toBe(moved)
|
|
194
|
+
v.setScrollOffset(40)
|
|
195
|
+
expect(range(v)).toEqual([3, 16])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('keeps the window through a turn while it still covers the way back', () => {
|
|
199
|
+
const v = make()
|
|
200
|
+
v.setScrollOffset(500)
|
|
201
|
+
v.setScrollOffset(510)
|
|
202
|
+
const moved = v.getVirtualItems()
|
|
203
|
+
expect(range(v)).toEqual([50, 64])
|
|
204
|
+
v.setScrollOffset(530)
|
|
205
|
+
// Back up to visible 52..62 and 51..61: items 51 and 50 are one ahead.
|
|
206
|
+
v.setScrollOffset(520)
|
|
207
|
+
v.setScrollOffset(510)
|
|
208
|
+
expect(v.getVirtualItems()).toBe(moved)
|
|
209
|
+
// Visible start 50 with item 49 missing: the window moves with the full
|
|
210
|
+
// overscan before it and one item after.
|
|
211
|
+
v.setScrollOffset(500)
|
|
212
|
+
expect(range(v)).toEqual([47, 61])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('always renders every visible item', () => {
|
|
216
|
+
const v = make((i) => 5 + (i % 7) * 3)
|
|
217
|
+
let seed = 7
|
|
218
|
+
const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647)
|
|
219
|
+
let offset = 0
|
|
220
|
+
for (let step = 0; step < 2000; step += 1) {
|
|
221
|
+
// Mostly small steps either way, now and then a jump.
|
|
222
|
+
offset = random() < 0.05 ? Math.floor(random() * 12_000) : Math.max(0, offset + Math.floor((random() - 0.4) * 60))
|
|
223
|
+
v.setScrollOffset(offset)
|
|
224
|
+
const state = v.getState()
|
|
225
|
+
const items = v.getVirtualItems()
|
|
226
|
+
const top = Math.min(offset, Math.max(state.totalSize - 100, 0))
|
|
227
|
+
const visible = items.filter((it) => it.end > top && it.start < top + 100)
|
|
228
|
+
expect(visible.length).toBeGreaterThan(0)
|
|
229
|
+
expect(items[0]!.start).toBeLessThanOrEqual(top)
|
|
230
|
+
expect(items.at(-1)!.end).toBeGreaterThanOrEqual(Math.min(top + 100, state.totalSize))
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('re-windows at once when the viewport or the sizes change', () => {
|
|
235
|
+
const v = make()
|
|
236
|
+
v.setScrollOffset(500)
|
|
237
|
+
v.setScrollOffset(510)
|
|
238
|
+
v.setViewportHeight(200)
|
|
239
|
+
expect(range(v)).toEqual([50, 51 + 20 + 1])
|
|
240
|
+
v.setOptions({ estimateSize: 20 })
|
|
241
|
+
expect(v.getVirtualItems()[0]!.start).toBe(v.getVirtualItems()[0]!.index * 20)
|
|
242
|
+
})
|
|
243
|
+
})
|