@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.
Files changed (91) hide show
  1. package/README.md +1 -1
  2. package/dist/GridMenus.svelte +3 -2
  3. package/dist/SvGrid.controller.svelte.d.ts +60 -8
  4. package/dist/SvGrid.controller.svelte.js +490 -114
  5. package/dist/SvGrid.css +186 -3
  6. package/dist/SvGrid.svelte +285 -164
  7. package/dist/SvGrid.types.d.ts +24 -7
  8. package/dist/cdn/{GridMenus-CcwU8I4t.js → GridMenus-B1sipj6y.js} +165 -165
  9. package/dist/cdn/{GridMenus-BgUS-W3o.js → GridMenus-DlAzfHLk.js} +201 -201
  10. package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
  11. package/dist/cdn/{src-kE9B98r6.js → src-BYsa66I0.js} +9747 -10178
  12. package/dist/cdn/{src-25JmJ_7g.js → src-pA4RroLV.js} +10709 -11140
  13. package/dist/cdn/svgrid.js +27 -27
  14. package/dist/cdn/svgrid.svelte-external.js +27 -27
  15. package/dist/cdn/validate-XuMe4_KR.js +80 -0
  16. package/dist/cell-render.js +25 -3
  17. package/dist/columns.js +16 -1
  18. package/dist/conditional-formatting.js +9 -2
  19. package/dist/core.d.ts +93 -0
  20. package/dist/core.js +313 -47
  21. package/dist/filtering/excel-filters.js +41 -6
  22. package/dist/headless.d.ts +1 -1
  23. package/dist/headless.js +1 -1
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +2 -1
  26. package/dist/keyboard-handlers.js +5 -1
  27. package/dist/selection.d.ts +2 -2
  28. package/dist/selection.js +46 -12
  29. package/dist/server-block-cache.d.ts +7 -32
  30. package/dist/server-block-cache.js +21 -12
  31. package/dist/server-data-source.js +3 -1
  32. package/dist/server.d.ts +1 -0
  33. package/dist/server.js +1 -0
  34. package/dist/spreadsheet.js +7 -0
  35. package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
  36. package/dist/validate.js +13 -14
  37. package/dist/virtualization/column-virtualizer.d.ts +4 -0
  38. package/dist/virtualization/column-virtualizer.js +2 -0
  39. package/dist/virtualization/types.d.ts +20 -0
  40. package/dist/virtualization/virtualizer.js +90 -24
  41. package/dist/windowed-brand.d.ts +22 -0
  42. package/dist/windowed-brand.js +60 -0
  43. package/dist/windowed-data.d.ts +6 -0
  44. package/dist/windowed-data.js +34 -0
  45. package/dist/windowed-row-model.d.ts +1 -0
  46. package/dist/windowed-row-model.js +97 -0
  47. package/package.json +1 -1
  48. package/src/GridMenus.svelte +3 -2
  49. package/src/SvGrid.controller.svelte.ts +483 -122
  50. package/src/SvGrid.css +186 -3
  51. package/src/SvGrid.svelte +285 -164
  52. package/src/SvGrid.types.ts +24 -7
  53. package/src/cell-render.ts +24 -2
  54. package/src/columns.ts +16 -1
  55. package/src/conditional-formatting.ts +6 -4
  56. package/src/core.sort.test.ts +61 -0
  57. package/src/core.ts +309 -49
  58. package/src/counting-cell.test.svelte +11 -0
  59. package/src/filtering/excel-filters.ts +39 -6
  60. package/src/headless.reactivity.svelte.test.ts +89 -0
  61. package/src/headless.ts +1 -1
  62. package/src/index.ts +7 -1
  63. package/src/keyboard-handlers.ts +5 -1
  64. package/src/new-features.test.ts +24 -0
  65. package/src/selection.test.ts +51 -0
  66. package/src/selection.ts +47 -14
  67. package/src/server-block-cache.ts +27 -10
  68. package/src/server-data-source.ts +3 -1
  69. package/src/server.ts +6 -0
  70. package/src/spreadsheet.ts +6 -0
  71. package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
  72. package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
  73. package/src/svgrid.behavior.test.ts +58 -0
  74. package/src/svgrid.column-cell-recycling.test.ts +120 -0
  75. package/src/svgrid.interaction.test.ts +4 -1
  76. package/src/svgrid.new-features.wrapper.test.ts +5 -4
  77. package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
  78. package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
  79. package/src/validate.test.ts +20 -4
  80. package/src/validate.ts +13 -16
  81. package/src/virtualization/column-virtualizer.ts +6 -0
  82. package/src/virtualization/types.ts +20 -0
  83. package/src/virtualization/virtualizer.test.ts +152 -0
  84. package/src/virtualization/virtualizer.ts +93 -27
  85. package/src/windowed-brand.ts +67 -0
  86. package/src/windowed-data.test.ts +105 -0
  87. package/src/windowed-data.ts +36 -0
  88. package/src/windowed-row-model.ts +113 -0
  89. package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
  90. package/dist/cdn/validate-AZoD8BoR.js +0 -80
  91. /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
- c.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true, pointerId: 1, pointerType }))
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
- expect(source).toMatch(/userCellClass = computeCellClass\(\s*cellRow,\s*cellColumn,?\s*\)/)
81
- expect(source).toMatch(/class=\{`sv-grid-cell \$\{userCellClass\}`\}/)
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`. (The grid also reports the
142
- // empty initial sort on mount, as it does for any onSortingChange.)
143
- expect(source.sorts.at(-1)).toEqual([{ id: 'name', desc: false }])
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({
@@ -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
- it('flags pinning while column virtualization is on by default', () => {
185
- const msg = run({ initialColumnPinning: { left: ['name'] } })[0]!
186
- expect(msg).toContain('will not stick')
187
- expect(msg).toContain('columnVirtualization={false}')
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
- for (const row of input.data ?? []) {
84
- if (sample.length >= FIELD_SAMPLE_ROWS) break
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
- // ---- 7. Pinning that column virtualization will hide ----------------------
224
- // Documented incompatibility: the virtualizer recycles column DOM nodes, so
225
- // sticky pinning cannot survive it. `columnVirtualization` defaults to ON,
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
+ })