@svgrid/grid 3.0.8 → 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 (80) hide show
  1. package/dist/GridMenus.svelte +3 -2
  2. package/dist/SvGrid.controller.svelte.d.ts +60 -8
  3. package/dist/SvGrid.controller.svelte.js +475 -110
  4. package/dist/SvGrid.css +186 -3
  5. package/dist/SvGrid.svelte +281 -163
  6. package/dist/SvGrid.types.d.ts +24 -7
  7. package/dist/cdn/{GridMenus-D15bhpX5.js → GridMenus-B1sipj6y.js} +165 -165
  8. package/dist/cdn/{GridMenus-B8-Gbfq2.js → GridMenus-DlAzfHLk.js} +201 -201
  9. package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
  10. package/dist/cdn/{src-Dz625Fhx.js → src-BYsa66I0.js} +9738 -10174
  11. package/dist/cdn/{src-CleI0g9l.js → src-pA4RroLV.js} +10702 -11138
  12. package/dist/cdn/svgrid.js +27 -27
  13. package/dist/cdn/svgrid.svelte-external.js +27 -27
  14. package/dist/cdn/{validate-GEs4ZPSW.js → validate-XuMe4_KR.js} +2 -2
  15. package/dist/cell-render.js +25 -3
  16. package/dist/columns.js +16 -1
  17. package/dist/conditional-formatting.js +9 -2
  18. package/dist/core.d.ts +93 -0
  19. package/dist/core.js +313 -52
  20. package/dist/filtering/excel-filters.js +41 -6
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/selection.d.ts +2 -2
  24. package/dist/selection.js +32 -8
  25. package/dist/server-block-cache.d.ts +7 -32
  26. package/dist/server-block-cache.js +21 -12
  27. package/dist/server-data-source.js +3 -1
  28. package/dist/server.d.ts +1 -0
  29. package/dist/server.js +1 -0
  30. package/dist/spreadsheet.js +7 -0
  31. package/dist/validate.js +3 -11
  32. package/dist/virtualization/column-virtualizer.d.ts +4 -0
  33. package/dist/virtualization/column-virtualizer.js +2 -0
  34. package/dist/virtualization/types.d.ts +20 -0
  35. package/dist/virtualization/virtualizer.js +90 -24
  36. package/dist/windowed-brand.d.ts +22 -0
  37. package/dist/windowed-brand.js +60 -0
  38. package/dist/windowed-data.d.ts +6 -0
  39. package/dist/windowed-data.js +34 -0
  40. package/dist/windowed-row-model.d.ts +1 -0
  41. package/dist/windowed-row-model.js +97 -0
  42. package/package.json +1 -1
  43. package/src/GridMenus.svelte +3 -2
  44. package/src/SvGrid.controller.svelte.ts +469 -114
  45. package/src/SvGrid.css +186 -3
  46. package/src/SvGrid.svelte +281 -163
  47. package/src/SvGrid.types.ts +24 -7
  48. package/src/cell-render.ts +24 -2
  49. package/src/columns.ts +16 -1
  50. package/src/conditional-formatting.ts +6 -4
  51. package/src/core.sort.test.ts +61 -0
  52. package/src/core.ts +309 -52
  53. package/src/counting-cell.test.svelte +11 -0
  54. package/src/filtering/excel-filters.ts +39 -6
  55. package/src/headless.reactivity.svelte.test.ts +1 -1
  56. package/src/index.ts +6 -0
  57. package/src/new-features.test.ts +24 -0
  58. package/src/selection.test.ts +14 -0
  59. package/src/selection.ts +31 -8
  60. package/src/server-block-cache.ts +27 -10
  61. package/src/server-data-source.ts +3 -1
  62. package/src/server.ts +6 -0
  63. package/src/spreadsheet.ts +6 -0
  64. package/src/svgrid.behavior.test.ts +29 -0
  65. package/src/svgrid.column-cell-recycling.test.ts +120 -0
  66. package/src/svgrid.interaction.test.ts +4 -1
  67. package/src/svgrid.new-features.wrapper.test.ts +5 -4
  68. package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
  69. package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
  70. package/src/validate.test.ts +4 -4
  71. package/src/validate.ts +3 -14
  72. package/src/virtualization/column-virtualizer.ts +6 -0
  73. package/src/virtualization/types.ts +20 -0
  74. package/src/virtualization/virtualizer.test.ts +152 -0
  75. package/src/virtualization/virtualizer.ts +93 -27
  76. package/src/windowed-brand.ts +67 -0
  77. package/src/windowed-data.test.ts +105 -0
  78. package/src/windowed-data.ts +36 -0
  79. package/src/windowed-row-model.ts +113 -0
  80. package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
@@ -100,6 +100,20 @@ describe('createSelection - row selection', () => {
100
100
  expect(sel.isRowSelected('missing')).toBe(false)
101
101
  })
102
102
 
103
+ it('with a selection model, a drawn row is answered from its own data, not a scan of every row', () => {
104
+ const find = vi.fn(() => makeDataRow('r1', { id: 'r1' }))
105
+ const model = { isSelected: vi.fn((id: string, data: any) => data?.id === id), toggle: vi.fn() }
106
+ const ctx = makeCtx({ allRows: { find }, props: { rowSelectionModel: model } })
107
+ const sel = createSelection(ctx)
108
+ expect(sel.isRowSelected('r1', { id: 'r1' })).toBe(true)
109
+ sel.toggleRowSelectionById('r1', { id: 'r1' })
110
+ expect(model.toggle).toHaveBeenCalledWith('r1', { id: 'r1' }, false)
111
+ expect(find).not.toHaveBeenCalled()
112
+ // Without the data (an API caller) it still finds the row by id.
113
+ expect(sel.isRowSelected('r1')).toBe(true)
114
+ expect(find).toHaveBeenCalledTimes(1)
115
+ })
116
+
103
117
  it('toggleRowSelectionById flips the single row', () => {
104
118
  const ctx = makeCtx({ rowSelectionState: { r1: false } })
105
119
  const sel = createSelection(ctx)
package/src/selection.ts CHANGED
@@ -20,21 +20,33 @@ export function createSelection<
20
20
  TFeatures extends TableFeatures = TableFeatures,
21
21
  TData extends RowData = RowData,
22
22
  >(ctx: any) {
23
- function isRowSelected(rowId: string) {
23
+ /**
24
+ * A selection model needs the row's data. The body passes the row it is
25
+ * drawing as `original`; without it the row is looked up by id, a scan of
26
+ * every row - on a server model's windowed million rows, once per drawn
27
+ * row, that scan held the page for seconds after a sort.
28
+ */
29
+ function rowOriginal(rowId: string): { found: boolean; original?: unknown } {
30
+ const row = ctx.allRows.find((r: any) => r.id === rowId);
31
+ return row ? { found: true, original: row.original } : { found: false };
32
+ }
33
+
34
+ function isRowSelected(rowId: string, original?: unknown) {
24
35
  const model = ctx.props.rowSelectionModel;
25
36
  if (model) {
26
- const row = ctx.allRows.find((r: any) => r.id === rowId);
27
- return row ? model.isSelected(rowId, row.original) : false;
37
+ if (original !== undefined) return model.isSelected(rowId, original);
38
+ const hit = rowOriginal(rowId);
39
+ return hit.found ? model.isSelected(rowId, hit.original) : false;
28
40
  }
29
41
  return Boolean(ctx.rowSelectionState[rowId]);
30
42
  }
31
43
 
32
- function toggleRowSelectionById(rowId: string) {
44
+ function toggleRowSelectionById(rowId: string, original?: unknown) {
33
45
  const model = ctx.props.rowSelectionModel;
34
46
  if (model) {
35
- const row = ctx.allRows.find((r: any) => r.id === rowId);
36
- if (!row) return;
37
- model.toggle(rowId, row.original, !model.isSelected(rowId, row.original));
47
+ const hit = original !== undefined ? { found: true, original } : rowOriginal(rowId);
48
+ if (!hit.found) return;
49
+ model.toggle(rowId, hit.original, !model.isSelected(rowId, hit.original));
38
50
  return;
39
51
  }
40
52
  ctx.grid.setRowSelection((prev: any) => ({ ...prev, [rowId]: !prev[rowId] }));
@@ -472,8 +484,19 @@ export function createSelection<
472
484
 
473
485
  /** Look up a column by id without depending on `buildApi`'s private
474
486
  * closure (those helpers don't exist at this scope). */
487
+ // id -> column, rebuilt when the column list changes. Called per header
488
+ // cell as columns scroll into view, so a linear `find` made every scroll
489
+ // frame cost more the more columns the grid has.
490
+ let columnsById: Map<string, any> = new Map();
491
+ let columnsByIdFor: unknown = null;
475
492
  function findColumnById(columnId: string) {
476
- return ctx.allColumns.find((c: any) => c.id === columnId);
493
+ const all = ctx.allColumns;
494
+ if (all !== columnsByIdFor) {
495
+ columnsById = new Map();
496
+ for (const c of all as any[]) columnsById.set(c.id, c);
497
+ columnsByIdFor = all;
498
+ }
499
+ return columnsById.get(columnId);
477
500
  }
478
501
 
479
502
  function onCellPointerDown(
@@ -30,6 +30,7 @@
30
30
  * Enterprise's server row model runs one of these per group level; the free
31
31
  * flat controller runs exactly one.
32
32
  */
33
+ import { createWindowedData } from './windowed-data'
33
34
 
34
35
  /**
35
36
  * The mark that says "this row is not data yet".
@@ -171,6 +172,13 @@ export type BlockCache<TData> = {
171
172
  setViewport(startIndex: number, endIndex: number): void
172
173
  /** The dense row array to hand the grid. Placeholders fill unloaded slots. */
173
174
  rows(): ReadonlyArray<TData>
175
+ /**
176
+ * The same rows as windowed data (see windowed-data.ts): `length` is the
177
+ * row count and each entry is read through `getRow` when the grid asks for
178
+ * it, so handing it over costs O(1) whatever the count. A new array per
179
+ * change, like `rows()`.
180
+ */
181
+ windowedRows(): ReadonlyArray<TData>
174
182
  /** One row, without building the array. Returns a placeholder when unloaded. */
175
183
  getRow(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW
176
184
  rowCount(): number | null
@@ -267,6 +275,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
267
275
 
268
276
  let emitScheduled = false
269
277
  let rowsCache: TData[] | null = null
278
+ let windowedCache: TData[] | null = null
270
279
 
271
280
  const blockOf = (rowIndex: number): number => Math.floor(rowIndex / blockSize)
272
281
  const startOf = (blockIndex: number): number => blockIndex * blockSize
@@ -321,7 +330,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
321
330
  * mutations notifies once. Every mutating path ends here.
322
331
  */
323
332
  function emit(): void {
324
- rowsCache = null // rebuilt lazily, on the next read
333
+ rowsCache = windowedCache = null // rebuilt lazily, on the next read
325
334
  if (disposed || emitScheduled) return
326
335
  emitScheduled = true
327
336
  schedule(() => {
@@ -439,6 +448,14 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
439
448
  }
440
449
  }
441
450
 
451
+ function rowAt(index: number): TData | typeof LOADING_ROW | typeof FAILED_ROW {
452
+ const block = blocks.get(blockOf(index))
453
+ if (!block) return LOADING_ROW
454
+ if (block.status === 'failed') return FAILED_ROW
455
+ const row = block.rows[index - startOf(blockOf(index))]
456
+ return row ?? LOADING_ROW
457
+ }
458
+
442
459
  function abortAll(): void {
443
460
  for (const block of blocks.values()) block.controller?.abort()
444
461
  inFlight = 0
@@ -485,14 +502,14 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
485
502
  return out
486
503
  },
487
504
 
488
- getRow(index) {
489
- const block = blocks.get(blockOf(index))
490
- if (!block) return LOADING_ROW
491
- if (block.status === 'failed') return FAILED_ROW
492
- const row = block.rows[index - startOf(blockOf(index))]
493
- return row ?? LOADING_ROW
505
+ windowedRows() {
506
+ if (windowedCache) return windowedCache
507
+ windowedCache = createWindowedData<TData>(currentLength(), (i) => rowAt(i) as TData)
508
+ return windowedCache
494
509
  },
495
510
 
511
+ getRow: rowAt,
512
+
496
513
  rowCount: () => (countKnown ? count : null),
497
514
  lastRowKnown: () => countKnown,
498
515
 
@@ -531,7 +548,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
531
548
  blocks.clear()
532
549
  count = null
533
550
  countKnown = false
534
- rowsCache = null
551
+ rowsCache = windowedCache = null
535
552
  fetchViewport()
536
553
  emit()
537
554
  },
@@ -675,7 +692,7 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
675
692
  controller: null,
676
693
  })
677
694
  }
678
- rowsCache = null
695
+ rowsCache = windowedCache = null
679
696
  }
680
697
 
681
698
  /** Forget every block from `fromBlock` on, aborting any in flight. */
@@ -685,6 +702,6 @@ export function createBlockCache<TData>(options: BlockCacheOptions<TData>): Bloc
685
702
  block.controller?.abort()
686
703
  blocks.delete(index)
687
704
  }
688
- rowsCache = null
705
+ rowsCache = windowedCache = null
689
706
  }
690
707
  }
@@ -581,7 +581,9 @@ export function createServerDataSource<TData>(
581
581
  */
582
582
  function emitFromCache(): void {
583
583
  if (!cache || disposed) return
584
- const rows = cache.rows()
584
+ // Windowed: the grid reads only the rows it shows, so a block landing
585
+ // costs the same at 100M rows as at 10K.
586
+ const rows = cache.windowedRows()
585
587
  state.rows = rows
586
588
  state.rowCount = cache.rowCount()
587
589
  state.lastRowKnown = cache.lastRowKnown()
package/src/server.ts CHANGED
@@ -41,6 +41,12 @@ export {
41
41
  type BlockFetchResult,
42
42
  type BlockState,
43
43
  } from './server-block-cache'
44
+ export {
45
+ createWindowedData,
46
+ windowedSourceOf,
47
+ isWindowedData,
48
+ type WindowedSource,
49
+ } from './windowed-data'
44
50
  export {
45
51
  toServerFilterColumns,
46
52
  type GridRowModel,
@@ -156,6 +156,12 @@ const OVERLAY_CLASS = 'sv-cell-border-overlay'
156
156
  * every relevant DOM mutation inside the grid, and whenever the action
157
157
  * options update. */
158
158
  function apply(root: HTMLElement, opts: SpreadsheetActionOptions): void {
159
+ // Merges here are colspan / rowspan on the rendered cells, which only the
160
+ // table algorithm honours: keep this grid's rows in table layout (the
161
+ // flex-row rules in SvGrid.css skip a table marked this way).
162
+ for (const table of root.querySelectorAll<HTMLTableElement>('table.sv-grid-table')) {
163
+ if (table.getAttribute('data-row-layout') !== 'table') table.setAttribute('data-row-layout', 'table')
164
+ }
159
165
  // ---- 1. Clean up anything the LAST run added -----------------------
160
166
  for (const td of root.querySelectorAll<HTMLTableCellElement>(`td[${MARK}]`)) {
161
167
  td.removeAttribute('colspan')
@@ -580,6 +580,35 @@ describe('SvGrid - external sort / filter', () => {
580
580
  }
581
581
  })
582
582
 
583
+ it('mounting is not a change: no sort, filter or selection callback fires on mount', async () => {
584
+ // These used to fire once on mount with the starting state. A consumer
585
+ // that keeps the sort in the URL and resets the page on a new sort then
586
+ // navigated from ?page=2 back to page 1 on every load.
587
+ const calls: string[] = []
588
+ const { api, destroy } = await mountGrid({
589
+ externalSort: true,
590
+ externalFilter: true,
591
+ initialSorting: [{ id: 'age', desc: true }],
592
+ onSortingChange: () => calls.push('sort'),
593
+ onFiltersChange: () => calls.push('filters'),
594
+ onRowSelectionChange: () => calls.push('rows'),
595
+ onCellSelectionChange: () => calls.push('cells'),
596
+ })
597
+ try {
598
+ await tick()
599
+ await tick()
600
+ expect(calls).toEqual([])
601
+ // ...and a real change still reports, once each.
602
+ api.setSort('age', 'asc')
603
+ api.setFilter('team', { operator: 'contains', value: 'Research' })
604
+ await tick()
605
+ expect(calls.filter((c) => c === 'sort')).toHaveLength(1)
606
+ expect(calls.filter((c) => c === 'filters')).toHaveLength(1)
607
+ } finally {
608
+ destroy()
609
+ }
610
+ })
611
+
583
612
  it('externalFilter + onFiltersChange fires when a filter is set', async () => {
584
613
  let calls = 0
585
614
  const { api, destroy } = await mountGrid({
@@ -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({
@@ -197,10 +197,10 @@ describe('gantt versus treeData', () => {
197
197
  })
198
198
 
199
199
  describe('pinning versus column virtualization', () => {
200
- it('flags pinning while column virtualization is on by default', () => {
201
- const msg = run({ initialColumnPinning: { left: ['name'] } })[0]!
202
- expect(msg).toContain('will not stick')
203
- 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([])
204
204
  })
205
205
 
206
206
  it('is silent once column virtualization is off', () => {
package/src/validate.ts CHANGED
@@ -228,20 +228,9 @@ export function validateGridConfig<
228
228
  )
229
229
  }
230
230
 
231
- // ---- 7. Pinning that column virtualization will hide ----------------------
232
- // Documented incompatibility: the virtualizer recycles column DOM nodes, so
233
- // sticky pinning cannot survive it. `columnVirtualization` defaults to ON,
234
- // which means the natural way to write this silently does nothing.
235
- const pinned =
236
- (input.initialColumnPinning?.left?.length ?? 0) +
237
- (input.initialColumnPinning?.right?.length ?? 0)
238
- if (pinned > 0 && input.columnVirtualization !== false) {
239
- messages.push(
240
- '[svgrid] `initialColumnPinning` is set while column virtualization is on ' +
241
- '(its default), so the pinned columns will not stick - the virtualizer ' +
242
- 'recycles column nodes. Add `columnVirtualization={false}`.',
243
- )
244
- }
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.)
245
234
 
246
235
  // ---- 8. Server-mode contracts left half-wired -----------------------------
247
236
  // Each of these makes the grid hand control to the consumer. Miss the other