@svgrid/grid 3.0.7 → 3.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/GridMenus.svelte +3 -2
- package/dist/SvGrid.controller.svelte.d.ts +60 -8
- package/dist/SvGrid.controller.svelte.js +490 -114
- package/dist/SvGrid.css +186 -3
- package/dist/SvGrid.svelte +285 -164
- package/dist/SvGrid.types.d.ts +24 -7
- package/dist/cdn/{GridMenus-CcwU8I4t.js → GridMenus-B1sipj6y.js} +165 -165
- package/dist/cdn/{GridMenus-BgUS-W3o.js → GridMenus-DlAzfHLk.js} +201 -201
- package/dist/cdn/server-block-cache-Dj-KPqEQ.js +1289 -0
- package/dist/cdn/{src-kE9B98r6.js → src-BYsa66I0.js} +9747 -10178
- package/dist/cdn/{src-25JmJ_7g.js → src-pA4RroLV.js} +10709 -11140
- package/dist/cdn/svgrid.js +27 -27
- package/dist/cdn/svgrid.svelte-external.js +27 -27
- package/dist/cdn/validate-XuMe4_KR.js +80 -0
- package/dist/cell-render.js +25 -3
- package/dist/columns.js +16 -1
- package/dist/conditional-formatting.js +9 -2
- package/dist/core.d.ts +93 -0
- package/dist/core.js +313 -47
- package/dist/filtering/excel-filters.js +41 -6
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/keyboard-handlers.js +5 -1
- package/dist/selection.d.ts +2 -2
- package/dist/selection.js +46 -12
- package/dist/server-block-cache.d.ts +7 -32
- package/dist/server-block-cache.js +21 -12
- package/dist/server-data-source.js +3 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/spreadsheet.js +7 -0
- package/dist/{subscribe.js → subscribe.svelte.js} +5 -1
- package/dist/validate.js +13 -14
- package/dist/virtualization/column-virtualizer.d.ts +4 -0
- package/dist/virtualization/column-virtualizer.js +2 -0
- package/dist/virtualization/types.d.ts +20 -0
- package/dist/virtualization/virtualizer.js +90 -24
- package/dist/windowed-brand.d.ts +22 -0
- package/dist/windowed-brand.js +60 -0
- package/dist/windowed-data.d.ts +6 -0
- package/dist/windowed-data.js +34 -0
- package/dist/windowed-row-model.d.ts +1 -0
- package/dist/windowed-row-model.js +97 -0
- package/package.json +1 -1
- package/src/GridMenus.svelte +3 -2
- package/src/SvGrid.controller.svelte.ts +483 -122
- package/src/SvGrid.css +186 -3
- package/src/SvGrid.svelte +285 -164
- package/src/SvGrid.types.ts +24 -7
- package/src/cell-render.ts +24 -2
- package/src/columns.ts +16 -1
- package/src/conditional-formatting.ts +6 -4
- package/src/core.sort.test.ts +61 -0
- package/src/core.ts +309 -49
- package/src/counting-cell.test.svelte +11 -0
- package/src/filtering/excel-filters.ts +39 -6
- package/src/headless.reactivity.svelte.test.ts +89 -0
- package/src/headless.ts +1 -1
- package/src/index.ts +7 -1
- package/src/keyboard-handlers.ts +5 -1
- package/src/new-features.test.ts +24 -0
- package/src/selection.test.ts +51 -0
- package/src/selection.ts +47 -14
- package/src/server-block-cache.ts +27 -10
- package/src/server-data-source.ts +3 -1
- package/src/server.ts +6 -0
- package/src/spreadsheet.ts +6 -0
- package/src/{subscribe.test.ts → subscribe.svelte.test.ts} +1 -1
- package/src/{subscribe.ts → subscribe.svelte.ts} +5 -1
- package/src/svgrid.behavior.test.ts +58 -0
- package/src/svgrid.column-cell-recycling.test.ts +120 -0
- package/src/svgrid.interaction.test.ts +4 -1
- package/src/svgrid.new-features.wrapper.test.ts +5 -4
- package/src/svgrid.pinned-column-virtualization.test.ts +162 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +15 -3
- package/src/validate.test.ts +20 -4
- package/src/validate.ts +13 -16
- package/src/virtualization/column-virtualizer.ts +6 -0
- package/src/virtualization/types.ts +20 -0
- package/src/virtualization/virtualizer.test.ts +152 -0
- package/src/virtualization/virtualizer.ts +93 -27
- package/src/windowed-brand.ts +67 -0
- package/src/windowed-data.test.ts +105 -0
- package/src/windowed-data.ts +36 -0
- package/src/windowed-row-model.ts +113 -0
- package/dist/cdn/server-block-cache-DIqb3VZn.js +0 -241
- package/dist/cdn/validate-AZoD8BoR.js +0 -80
- /package/dist/{subscribe.d.ts → subscribe.svelte.d.ts} +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless docs tell you to write:
|
|
3
|
+
*
|
|
4
|
+
* const table = createSvGrid({ ..., state: { sorting }, onSortingChange })
|
|
5
|
+
* const rows = $derived(table.getRowModel().rows)
|
|
6
|
+
*
|
|
7
|
+
* `createStore` (core.ts) is a plain closure + listener set, NOT runes, and
|
|
8
|
+
* `options.state` is spread into the initial store once at creation. So that
|
|
9
|
+
* `$derived` has no reactive dependency on the engine's state: clicking a
|
|
10
|
+
* header updates the store and the component's own `sorting` rune, the sort
|
|
11
|
+
* indicator moves, and the ROWS never recompute.
|
|
12
|
+
*
|
|
13
|
+
* Caught while recording the headless video: the arrow cleared and the order
|
|
14
|
+
* did not change. Lives in a `.svelte.test.ts` so `$state` is available.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import { flushSync } from 'svelte'
|
|
18
|
+
import {
|
|
19
|
+
createSvGrid,
|
|
20
|
+
createCoreRowModel,
|
|
21
|
+
createSortedRowModel,
|
|
22
|
+
tableFeatures,
|
|
23
|
+
rowSortingFeature,
|
|
24
|
+
} from './index'
|
|
25
|
+
|
|
26
|
+
type Repo = { name: string; stars: number }
|
|
27
|
+
|
|
28
|
+
// Deliberately NOT in stars order: clearing the sort has to produce a
|
|
29
|
+
// visibly different list, or the assertions below prove nothing.
|
|
30
|
+
const data: Repo[] = [
|
|
31
|
+
{ name: 'sv-grid', stars: 172 },
|
|
32
|
+
{ name: 'svelte', stars: 78000 },
|
|
33
|
+
{ name: 'vite', stars: 68000 },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const features = tableFeatures({ rowSortingFeature })
|
|
37
|
+
const columns = [
|
|
38
|
+
{ field: 'name', header: 'Repo' },
|
|
39
|
+
{ field: 'stars', header: 'Stars' },
|
|
40
|
+
] as never
|
|
41
|
+
|
|
42
|
+
function build(onSortingChange: (next: unknown) => void, seed: Array<{ id: string; desc: boolean }>) {
|
|
43
|
+
return createSvGrid({
|
|
44
|
+
_features: features,
|
|
45
|
+
_rowModels: {
|
|
46
|
+
coreRowModel: createCoreRowModel<Repo>(),
|
|
47
|
+
sortedRowModel: createSortedRowModel<Repo>(),
|
|
48
|
+
},
|
|
49
|
+
data,
|
|
50
|
+
columns,
|
|
51
|
+
state: { sorting: seed },
|
|
52
|
+
onSortingChange,
|
|
53
|
+
} as never)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('headless state reactivity', () => {
|
|
57
|
+
it('seeds the sort from options.state', () => {
|
|
58
|
+
const table = build(() => {}, [{ id: 'stars', desc: true }])
|
|
59
|
+
const names = table.getRowModel().rows.map((r) => (r.original as Repo).name)
|
|
60
|
+
expect(names).toEqual(['svelte', 'vite', 'sv-grid'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('re-sorts when the toggle handler runs', () => {
|
|
64
|
+
let sorting = $state([{ id: 'stars', desc: true }])
|
|
65
|
+
const table = build((next) => (sorting = next as typeof sorting), sorting)
|
|
66
|
+
|
|
67
|
+
// The pattern the headless docs now teach: touch the state rune the
|
|
68
|
+
// component owns so the derived re-runs. A plain
|
|
69
|
+
// `$derived(table.getRowModel().rows)` reads only the framework-free
|
|
70
|
+
// engine and never recomputes, which is the bug this guards.
|
|
71
|
+
const rows = $derived.by(() => {
|
|
72
|
+
void sorting
|
|
73
|
+
return table.getRowModel().rows
|
|
74
|
+
})
|
|
75
|
+
expect(rows.map((r) => (r.original as Repo).name)).toEqual(['svelte', 'vite', 'sv-grid'])
|
|
76
|
+
|
|
77
|
+
const stars = table.getAllColumns().find((c) => c.id === 'stars')!
|
|
78
|
+
stars.getToggleSortingHandler()()
|
|
79
|
+
flushSync()
|
|
80
|
+
|
|
81
|
+
// The engine's own state really did change...
|
|
82
|
+
expect(table.getState().sorting).toEqual([])
|
|
83
|
+
// ...and the component's rune did too, which is why the arrow clears.
|
|
84
|
+
expect(sorting).toEqual([])
|
|
85
|
+
|
|
86
|
+
// The rows must follow the cleared sort, i.e. fall back to data order.
|
|
87
|
+
expect(rows.map((r) => (r.original as Repo).name)).toEqual(['sv-grid', 'svelte', 'vite'])
|
|
88
|
+
})
|
|
89
|
+
})
|
package/src/headless.ts
CHANGED
|
@@ -84,7 +84,7 @@ export {
|
|
|
84
84
|
|
|
85
85
|
export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
|
|
86
86
|
export { createGridState, createSvGridState } from './createGridState.svelte'
|
|
87
|
-
export { subscribeGrid, subscribeSvGrid } from './subscribe'
|
|
87
|
+
export { subscribeGrid, subscribeSvGrid } from './subscribe.svelte'
|
|
88
88
|
|
|
89
89
|
// The virtualizers are pure window arithmetic (scroll offset in, visible
|
|
90
90
|
// range out) with no DOM of their own, and a custom renderer over 50k rows
|
package/src/index.ts
CHANGED
|
@@ -73,7 +73,7 @@ export {
|
|
|
73
73
|
|
|
74
74
|
export { createGrid, createSvGrid, type SvelteGrid } from './createGrid.svelte'
|
|
75
75
|
export { createGridState, createSvGridState } from './createGridState.svelte'
|
|
76
|
-
export { subscribeGrid, subscribeSvGrid } from './subscribe'
|
|
76
|
+
export { subscribeGrid, subscribeSvGrid } from './subscribe.svelte'
|
|
77
77
|
export { default as SvGrid } from './SvGrid.svelte'
|
|
78
78
|
// Kanban board view. The `board` prop + its config types live in the free grid,
|
|
79
79
|
// but the RENDERER (SvGridBoard) ships in @svgrid/enterprise and registers itself
|
|
@@ -801,6 +801,12 @@ export {
|
|
|
801
801
|
type BlockFetchResult,
|
|
802
802
|
type BlockState,
|
|
803
803
|
} from './server-block-cache'
|
|
804
|
+
export {
|
|
805
|
+
createWindowedData,
|
|
806
|
+
windowedSourceOf,
|
|
807
|
+
isWindowedData,
|
|
808
|
+
type WindowedSource,
|
|
809
|
+
} from './windowed-data'
|
|
804
810
|
export {
|
|
805
811
|
toServerFilterColumns,
|
|
806
812
|
type GridRowModel,
|
package/src/keyboard-handlers.ts
CHANGED
|
@@ -216,7 +216,11 @@ export function createKeyboard<
|
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
218
|
if (column?.columnDef.editorType === "checkbox") {
|
|
219
|
-
|
|
219
|
+
// Same rule as a click: `editable: false` (or an editable callback
|
|
220
|
+
// saying no) keeps the checkbox read-only.
|
|
221
|
+
if (ctx.isCellEditableAt?.(current.rowIndex, current.colIndex) ?? true) {
|
|
222
|
+
ctx.toggleBooleanCell(current.rowIndex, current.colIndex);
|
|
223
|
+
}
|
|
220
224
|
return;
|
|
221
225
|
}
|
|
222
226
|
ctx.onCellDoubleClick(current.rowIndex, current.colIndex);
|
package/src/new-features.test.ts
CHANGED
|
@@ -70,6 +70,8 @@ describe('createSvGrid - getRowId', () => {
|
|
|
70
70
|
},
|
|
71
71
|
}).getRowModel().rows
|
|
72
72
|
expect(rows).toHaveLength(data.length)
|
|
73
|
+
// Rows are made when read (a first build is lazy), so read each one.
|
|
74
|
+
expect(rows.map((r) => r.id)).toEqual(['ada', 'linus', 'grace'])
|
|
73
75
|
expect(seen).toEqual([
|
|
74
76
|
{ id: 'ada', index: 0 },
|
|
75
77
|
{ id: 'linus', index: 1 },
|
|
@@ -77,6 +79,28 @@ describe('createSvGrid - getRowId', () => {
|
|
|
77
79
|
])
|
|
78
80
|
})
|
|
79
81
|
|
|
82
|
+
it('makes a row, and asks getRowId for it, only when the row is read', () => {
|
|
83
|
+
// A first build is lazy: a grid that only shows its first screen must not
|
|
84
|
+
// build a row object (or call getRowId) for every row of a large dataset.
|
|
85
|
+
const seen: number[] = []
|
|
86
|
+
const rows = createSvGrid<typeof features, Row>({
|
|
87
|
+
_features: features,
|
|
88
|
+
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
|
|
89
|
+
columns,
|
|
90
|
+
data,
|
|
91
|
+
getRowId: (row, index) => {
|
|
92
|
+
seen.push(index)
|
|
93
|
+
return row.id
|
|
94
|
+
},
|
|
95
|
+
}).getRowModel().rows
|
|
96
|
+
expect(seen).toEqual([])
|
|
97
|
+
expect(rows[2]!.id).toBe('grace')
|
|
98
|
+
expect(seen).toEqual([2])
|
|
99
|
+
// The same object every time it is read.
|
|
100
|
+
expect(rows[2]).toBe(rows[2])
|
|
101
|
+
expect(seen).toEqual([2])
|
|
102
|
+
})
|
|
103
|
+
|
|
80
104
|
it('preserves the same id across `getRowModel()` invocations (stable identity)', () => {
|
|
81
105
|
const grid = createSvGrid<typeof features, Row>({
|
|
82
106
|
_features: features,
|
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)
|
|
@@ -657,6 +671,43 @@ describe('createSelection - onCellClick', () => {
|
|
|
657
671
|
expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 0, colIndex: 0 })
|
|
658
672
|
})
|
|
659
673
|
|
|
674
|
+
describe('boolean cells that are not declared checkboxes', () => {
|
|
675
|
+
const boolCtx = (over: any = {}, columnDef: any = {}) =>
|
|
676
|
+
clickCtx({
|
|
677
|
+
allRows: [makeDataRow('r0', { done: true })],
|
|
678
|
+
allColumns: [makeColumn('done', { field: 'done', ...columnDef })],
|
|
679
|
+
...over,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('do not toggle (or write the row) while inline editing is off', () => {
|
|
683
|
+
const ctx = boolCtx({ editingEnabled: false })
|
|
684
|
+
createSelection(ctx).onCellClick(0, 0)
|
|
685
|
+
expect(ctx.toggleBooleanCell).not.toHaveBeenCalled()
|
|
686
|
+
expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 0, colIndex: 0 })
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('toggle when inline editing is on and the cell is editable', () => {
|
|
690
|
+
const ctx = boolCtx({ editingEnabled: true, isCellEditableAt: () => true })
|
|
691
|
+
createSelection(ctx).onCellClick(0, 0)
|
|
692
|
+
expect(ctx.toggleBooleanCell).toHaveBeenCalledWith(0, 0)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
it('do not toggle when the column has its own cell renderer', () => {
|
|
696
|
+
const ctx = boolCtx({ editingEnabled: true, isCellEditableAt: () => true }, { cell: () => 'flag' })
|
|
697
|
+
createSelection(ctx).onCellClick(0, 0)
|
|
698
|
+
expect(ctx.toggleBooleanCell).not.toHaveBeenCalled()
|
|
699
|
+
})
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('a declared checkbox that is not editable stays put', () => {
|
|
703
|
+
const ctx = clickCtx({
|
|
704
|
+
allColumns: [makeColumn('c0', { editorType: 'checkbox', editable: false })],
|
|
705
|
+
isCellEditableAt: () => false,
|
|
706
|
+
})
|
|
707
|
+
createSelection(ctx).onCellClick(0, 0)
|
|
708
|
+
expect(ctx.toggleBooleanCell).not.toHaveBeenCalled()
|
|
709
|
+
})
|
|
710
|
+
|
|
660
711
|
it('enters edit mode when clicking the previously-active editable cell', () => {
|
|
661
712
|
const ctx = clickCtx({
|
|
662
713
|
editingEnabled: true,
|
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(
|
|
@@ -655,12 +678,22 @@ export function createSelection<
|
|
|
655
678
|
row: row.original as TData,
|
|
656
679
|
});
|
|
657
680
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
681
|
+
// A declared checkbox editor toggles on click unless the cell is not
|
|
682
|
+
// editable. A value that only HAPPENS to be boolean is drawn as a read-only
|
|
683
|
+
// checkbox, so it toggles only when inline editing is on - it used to flip
|
|
684
|
+
// and write the row on any click, in grids with editing switched off. A
|
|
685
|
+
// column with its own `cell` renderer draws (and handles) the value itself.
|
|
686
|
+
const declaredCheckbox = column.columnDef.editorType === "checkbox";
|
|
687
|
+
const inferredCheckbox =
|
|
688
|
+
!declaredCheckbox &&
|
|
689
|
+
typeof cellValue === "boolean" &&
|
|
690
|
+
column.columnDef.cell == null;
|
|
691
|
+
|
|
692
|
+
if (declaredCheckbox || inferredCheckbox) {
|
|
693
|
+
const editable = ctx.isCellEditableAt?.(rowIndex, colIndex) ?? true;
|
|
694
|
+
if (editable && (declaredCheckbox || ctx.editingEnabled)) {
|
|
695
|
+
ctx.toggleBooleanCell(rowIndex, colIndex);
|
|
696
|
+
}
|
|
664
697
|
setActiveCell(rowIndex, colIndex);
|
|
665
698
|
setSelection(rowIndex, colIndex);
|
|
666
699
|
ctx.editingCell = null;
|
|
@@ -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')
|
|
@@ -23,7 +23,11 @@ export function subscribeGrid<
|
|
|
23
23
|
grid: SvGrid<TData>,
|
|
24
24
|
selector: (state: Record<string, any>) => TSelected,
|
|
25
25
|
) {
|
|
26
|
-
|
|
26
|
+
// $state, not a plain variable: core.ts keeps a deliberately framework-free
|
|
27
|
+
// store, so this Svelte adapter is where reactivity belongs. Without it a
|
|
28
|
+
// `$derived` reading .current never re-runs and the docs' headless examples
|
|
29
|
+
// render their seeded state forever.
|
|
30
|
+
let current = $state(selector(grid.store.state))
|
|
27
31
|
grid.store.subscribe(() => {
|
|
28
32
|
const next = selector(grid.store.state)
|
|
29
33
|
if (!shallowCompare(current, next)) current = next
|
|
@@ -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({
|
|
@@ -620,6 +649,35 @@ describe('SvGrid - custom snippet cell', () => {
|
|
|
620
649
|
destroy()
|
|
621
650
|
}
|
|
622
651
|
})
|
|
652
|
+
|
|
653
|
+
it('a boolean column with its own cell renderer draws the renderer, not the built-in checkbox', async () => {
|
|
654
|
+
// The checkbox branch used to run before the `cell` branch, so any boolean
|
|
655
|
+
// value came out as a read-only checkbox and the renderer was never called.
|
|
656
|
+
const flagCols: ColumnDef<typeof fullFeatures, Person>[] = personColumns.map((c) =>
|
|
657
|
+
c.field === 'active'
|
|
658
|
+
? { ...c, cell: (ctx: any) => ((ctx.row.original as Person).active ? 'ON' : 'OFF') }
|
|
659
|
+
: c,
|
|
660
|
+
)
|
|
661
|
+
const { target, destroy } = await mountGrid({ columns: flagCols, showRowSelection: false })
|
|
662
|
+
try {
|
|
663
|
+
await tick()
|
|
664
|
+
expect(target.querySelectorAll('.sv-grid-checkbox-readonly').length).toBe(0)
|
|
665
|
+
expect(target.textContent).toContain('ON')
|
|
666
|
+
expect(target.textContent).toContain('OFF')
|
|
667
|
+
} finally {
|
|
668
|
+
destroy()
|
|
669
|
+
}
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('a boolean column without a renderer still draws the read-only checkbox', async () => {
|
|
673
|
+
const { target, destroy } = await mountGrid({ showRowSelection: false })
|
|
674
|
+
try {
|
|
675
|
+
await tick()
|
|
676
|
+
expect(target.querySelectorAll('.sv-grid-checkbox-readonly').length).toBeGreaterThan(0)
|
|
677
|
+
} finally {
|
|
678
|
+
destroy()
|
|
679
|
+
}
|
|
680
|
+
})
|
|
623
681
|
})
|
|
624
682
|
|
|
625
683
|
describe('SvGrid - filterMode shortcuts', () => {
|