@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.
- package/dist/GridMenus.svelte +3 -2
- package/dist/SvGrid.controller.svelte.d.ts +60 -8
- package/dist/SvGrid.controller.svelte.js +475 -110
- package/dist/SvGrid.css +186 -3
- package/dist/SvGrid.svelte +281 -163
- package/dist/SvGrid.types.d.ts +24 -7
- package/dist/cdn/{GridMenus-D15bhpX5.js → GridMenus-B1sipj6y.js} +165 -165
- package/dist/cdn/{GridMenus-B8-Gbfq2.js → GridMenus-DlAzfHLk.js} +201 -201
- package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
- package/dist/cdn/{src-Dz625Fhx.js → src-BYsa66I0.js} +9738 -10174
- package/dist/cdn/{src-CleI0g9l.js → src-pA4RroLV.js} +10702 -11138
- package/dist/cdn/svgrid.js +27 -27
- package/dist/cdn/svgrid.svelte-external.js +27 -27
- package/dist/cdn/{validate-GEs4ZPSW.js → validate-XuMe4_KR.js} +2 -2
- 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 -52
- package/dist/filtering/excel-filters.js +41 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/selection.d.ts +2 -2
- package/dist/selection.js +32 -8
- 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/validate.js +3 -11
- 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 +469 -114
- package/src/SvGrid.css +186 -3
- package/src/SvGrid.svelte +281 -163
- 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 -52
- package/src/counting-cell.test.svelte +11 -0
- package/src/filtering/excel-filters.ts +39 -6
- package/src/headless.reactivity.svelte.test.ts +1 -1
- package/src/index.ts +6 -0
- package/src/new-features.test.ts +24 -0
- package/src/selection.test.ts +14 -0
- package/src/selection.ts +31 -8
- 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/svgrid.behavior.test.ts +29 -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 +4 -4
- package/src/validate.ts +3 -14
- 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/src/selection.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
36
|
-
if (!
|
|
37
|
-
model.toggle(rowId,
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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,
|
package/src/spreadsheet.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -197,10 +197,10 @@ describe('gantt versus treeData', () => {
|
|
|
197
197
|
})
|
|
198
198
|
|
|
199
199
|
describe('pinning versus column virtualization', () => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
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([])
|
|
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
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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
|