@svgrid/grid 2.2.31 → 2.2.32
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/SvAvatar.svelte +1 -1
- package/dist/SvCheckBox.svelte +2 -1
- package/dist/SvCircularProgress.svelte +1 -1
- package/dist/SvGrid.controller.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.js +358 -18
- package/dist/SvGrid.css +103 -3
- package/dist/SvGrid.svelte +162 -10
- package/dist/SvGrid.types.d.ts +81 -0
- package/dist/SvGridDropdown.svelte +38 -6
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvGridSelect.svelte +12 -3
- package/dist/SvGridSelect.svelte.d.ts +6 -0
- package/dist/SvGroupCell.svelte +9 -2
- package/dist/SvMultiSelect.svelte +12 -3
- package/dist/SvMultiSelect.svelte.d.ts +6 -0
- package/dist/SvPasswordInput.svelte +1 -1
- package/dist/SvProgress.svelte +1 -1
- package/dist/SvRating.svelte +1 -1
- package/dist/SvResult.svelte +1 -1
- package/dist/SvRichText.svelte +31 -16
- package/dist/SvRichText.svelte.d.ts +9 -0
- package/dist/SvSwitchButton.svelte +2 -1
- package/dist/SvToaster.svelte +1 -1
- package/dist/SvTree.svelte +9 -2
- package/dist/SvTree.svelte.d.ts +5 -0
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/build-api.js +16 -0
- package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-C6ixSGWW.js} +33 -33
- package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-Cyk3qpJ7.js} +33 -33
- package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-CdqaP61H.js} +2 -1
- package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-Dh-2ej8j.js} +92 -87
- package/dist/cdn/{src-BLJfdyL0.js → src-BgHjWF0Q.js} +4193 -3837
- package/dist/cdn/{src-DYDCiC0D.js → src-CgD-wiuS.js} +5956 -5602
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/cell-render.d.ts +1 -0
- package/dist/cell-render.js +115 -1
- package/dist/cell-values.js +5 -0
- package/dist/core.d.ts +80 -9
- package/dist/core.js +89 -0
- package/dist/editor-contract.d.ts +12 -5
- package/dist/editor-registry.d.ts +18 -7
- package/dist/editor-registry.js +10 -1
- package/dist/group-display.d.ts +39 -0
- package/dist/group-display.js +52 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/keyboard-handlers.js +16 -0
- package/dist/recurrence.js +3 -2
- package/dist/svgrid-wrapper.types.d.ts +8 -2
- package/dist/themes/index.d.ts +10 -0
- package/dist/themes/index.js +26 -0
- package/package.json +1 -1
- package/src/SvAccordion.test.ts +5 -5
- package/src/SvAvatar.svelte +1 -1
- package/src/SvButtonGroup.test.ts +6 -6
- package/src/SvCheckBox.svelte +2 -1
- package/src/SvCircularProgress.svelte +1 -1
- package/src/SvFileUpload.test.ts +1 -1
- package/src/SvGrid.controller.svelte.ts +367 -17
- package/src/SvGrid.css +103 -3
- package/src/SvGrid.svelte +162 -10
- package/src/SvGrid.types.ts +81 -0
- package/src/SvGridDropdown.svelte +38 -6
- package/src/SvGridSelect.svelte +12 -3
- package/src/SvGroupCell.svelte +9 -2
- package/src/SvMultiSelect.svelte +12 -3
- package/src/SvPasswordInput.svelte +1 -1
- package/src/SvProgress.svelte +1 -1
- package/src/SvRating.svelte +1 -1
- package/src/SvResult.svelte +1 -1
- package/src/SvRichText.svelte +31 -16
- package/src/SvSwitchButton.svelte +2 -1
- package/src/SvToaster.svelte +1 -1
- package/src/SvTree.svelte +9 -2
- package/src/SvTreeSelect.svelte +6 -2
- package/src/build-api.ts +15 -0
- package/src/builtin-editors.test.ts +3 -1
- package/src/cell-render.test.ts +27 -3
- package/src/cell-render.ts +122 -1
- package/src/cell-values.ts +4 -0
- package/src/chart-export.test.ts +2 -2
- package/src/clipboard.test.ts +0 -2
- package/src/core.ts +157 -8
- package/src/editing.test.ts +1 -1
- package/src/editor-contract.ts +12 -5
- package/src/editor-registry.grid.test.ts +48 -0
- package/src/editor-registry.test.ts +3 -1
- package/src/editor-registry.ts +23 -7
- package/src/facet-buckets.test.ts +33 -33
- package/src/group-display.test.ts +91 -1
- package/src/group-display.ts +83 -0
- package/src/index.ts +9 -0
- package/src/keyboard-handlers.ts +17 -0
- package/src/list-power.test.ts +2 -2
- package/src/menus.test.ts +4 -4
- package/src/named-views.coverage.test.ts +3 -3
- package/src/recurrence.ts +3 -2
- package/src/row-resize.test.ts +40 -40
- package/src/scheduler-model.ts +1 -1
- package/src/server-data-source.coverage.test.ts +1 -1
- package/src/server-group-model.test.ts +6 -6
- package/src/spreadsheet.test.ts +0 -1
- package/src/svgrid-wrapper.types.ts +11 -9
- package/src/svgrid.async-editor-options.test.ts +271 -0
- package/src/svgrid.auto-row-height.test.ts +204 -0
- package/src/svgrid.charting.test.ts +4 -4
- package/src/svgrid.comments-autocomplete.test.ts +8 -2
- package/src/svgrid.context-menu.test.ts +25 -9
- package/src/svgrid.filter-depth.test.ts +163 -0
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
- package/src/svgrid.filter-menu-scroll.test.ts +10 -2
- package/src/svgrid.grand-total.test.ts +188 -0
- package/src/svgrid.group-display-mode.test.ts +171 -0
- package/src/svgrid.group-footers.test.ts +121 -0
- package/src/svgrid.group-pagination.test.ts +153 -0
- package/src/svgrid.tree-data.test.ts +186 -0
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
- package/src/themes/index.ts +42 -0
- package/src/tree-edit.test.ts +4 -4
- package/src/tree-row-model.test.ts +169 -0
- package/src/ui-localization.test.ts +113 -0
- package/themes/ag-alpine.css +22 -0
- package/themes/antd.css +22 -0
- package/themes/atlassian.css +22 -0
- package/themes/bootstrap.css +22 -0
- package/themes/carbon.css +22 -0
- package/themes/catppuccin.css +22 -0
- package/themes/dracula.css +22 -0
- package/themes/ember.css +22 -0
- package/themes/excel.css +22 -0
- package/themes/fluent.css +22 -0
- package/themes/github.css +22 -0
- package/themes/linear.css +22 -0
- package/themes/material.css +22 -0
- package/themes/nord.css +22 -0
- package/themes/notion.css +22 -0
- package/themes/salesforce.css +22 -0
- package/themes/sap.css +22 -0
- package/themes/shadcn.css +22 -0
- package/themes/tailwind.css +22 -0
- package/themes/vercel.css +22 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async `editorOptions` (#18): an option list that arrives from the server.
|
|
3
|
+
*
|
|
4
|
+
* Render paths call the option resolver synchronously and often, so an async
|
|
5
|
+
* source cannot block. The first call starts the fetch and returns an empty
|
|
6
|
+
* list; the resolved value lands in reactive state and re-renders the open
|
|
7
|
+
* editor. In-flight keys are tracked so repeated renders do not refire the
|
|
8
|
+
* request, and so the dropdown can say "Loading…" rather than "No options",
|
|
9
|
+
* which would read as "nothing to pick".
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { mount, unmount } from 'svelte'
|
|
13
|
+
import SvGrid from './SvGrid.svelte'
|
|
14
|
+
import {
|
|
15
|
+
createCoreRowModel,
|
|
16
|
+
createFilteredRowModel,
|
|
17
|
+
createPaginatedRowModel,
|
|
18
|
+
createSortedRowModel,
|
|
19
|
+
sortFns,
|
|
20
|
+
tableFeatures,
|
|
21
|
+
rowSortingFeature,
|
|
22
|
+
} from './index'
|
|
23
|
+
|
|
24
|
+
type Row = { id: number; country: string; city: string }
|
|
25
|
+
|
|
26
|
+
const data: Row[] = [
|
|
27
|
+
{ id: 1, country: 'FR', city: 'Paris' },
|
|
28
|
+
{ id: 2, country: 'JP', city: 'Osaka' },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
32
|
+
// The list editors lazy-load `SvGridDropdown` with a dynamic import, so
|
|
33
|
+
// microtasks alone never get it mounted - the settle has to yield the macrotask
|
|
34
|
+
// queue too, and then again for the resolved options to re-render.
|
|
35
|
+
const settle = async () => {
|
|
36
|
+
for (let i = 0; i < 4; i += 1) {
|
|
37
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
38
|
+
await tick()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mountGrid(columns: unknown[]) {
|
|
43
|
+
const target = document.createElement('div')
|
|
44
|
+
document.body.appendChild(target)
|
|
45
|
+
let api: never = null as never
|
|
46
|
+
const app = mount(SvGrid, {
|
|
47
|
+
target,
|
|
48
|
+
props: {
|
|
49
|
+
data,
|
|
50
|
+
columns,
|
|
51
|
+
features: tableFeatures({ rowSortingFeature }),
|
|
52
|
+
editable: true,
|
|
53
|
+
onApiReady: (a: never) => { api = a },
|
|
54
|
+
_rowModels: {
|
|
55
|
+
coreRowModel: createCoreRowModel(),
|
|
56
|
+
filteredRowModel: createFilteredRowModel(),
|
|
57
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
58
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
59
|
+
},
|
|
60
|
+
containerHeight: 400,
|
|
61
|
+
virtualization: false,
|
|
62
|
+
} as never,
|
|
63
|
+
})
|
|
64
|
+
return { target, getApi: () => api, destroy: () => { unmount(app); target.remove() } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let cleanup: (() => void) | null = null
|
|
68
|
+
afterEach(() => { cleanup?.(); cleanup = null })
|
|
69
|
+
|
|
70
|
+
/** Open the editor for row 0 of `columnId` through the public api. */
|
|
71
|
+
async function openEditor(api: never, columnId: string) {
|
|
72
|
+
;(api as never as { startEditing: (r: number, c: string) => boolean }).startEditing(0, columnId)
|
|
73
|
+
await settle()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('async editorOptions (#18)', () => {
|
|
77
|
+
it('resolves a promised option list and renders it', async () => {
|
|
78
|
+
const columns = [
|
|
79
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
80
|
+
{
|
|
81
|
+
field: 'city',
|
|
82
|
+
header: 'City',
|
|
83
|
+
width: 160,
|
|
84
|
+
editorType: 'select',
|
|
85
|
+
editorOptions: Promise.resolve(['Paris', 'Lyon', 'Osaka']),
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
const g = mountGrid(columns)
|
|
89
|
+
cleanup = g.destroy
|
|
90
|
+
await settle()
|
|
91
|
+
await openEditor(g.getApi(), 'city')
|
|
92
|
+
expect(document.body.textContent).toContain('Lyon')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('fetches once no matter how many times render asks', async () => {
|
|
96
|
+
const source = vi.fn(async () => ['a', 'b'])
|
|
97
|
+
const columns = [
|
|
98
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
99
|
+
{
|
|
100
|
+
field: 'city',
|
|
101
|
+
header: 'City',
|
|
102
|
+
width: 160,
|
|
103
|
+
editorType: 'select',
|
|
104
|
+
editorOptions: source(),
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
const g = mountGrid(columns)
|
|
108
|
+
cleanup = g.destroy
|
|
109
|
+
await settle()
|
|
110
|
+
await openEditor(g.getApi(), 'city')
|
|
111
|
+
await settle()
|
|
112
|
+
// The promise was created once; the resolver must not re-await per render.
|
|
113
|
+
expect(source).toHaveBeenCalledTimes(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('calls a per-row async source ONCE per row, not once per render', async () => {
|
|
117
|
+
// The resolver runs inside the template, so calling an async source on
|
|
118
|
+
// every render fires a real request per render - and any state the source
|
|
119
|
+
// touches gets mutated mid-render, which Svelte rejects outright. Reported
|
|
120
|
+
// from the demo as `state_unsafe_mutation`.
|
|
121
|
+
const calls: number[] = []
|
|
122
|
+
const columns = [
|
|
123
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
124
|
+
{
|
|
125
|
+
field: 'city',
|
|
126
|
+
header: 'City',
|
|
127
|
+
width: 160,
|
|
128
|
+
editorType: 'select',
|
|
129
|
+
editorOptions: (row: Row) => {
|
|
130
|
+
calls.push(row.id)
|
|
131
|
+
return Promise.resolve(['Paris', 'Lyon'])
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
const g = mountGrid(columns)
|
|
136
|
+
cleanup = g.destroy
|
|
137
|
+
await settle()
|
|
138
|
+
await openEditor(g.getApi(), 'city')
|
|
139
|
+
await settle()
|
|
140
|
+
const afterOpen = calls.length
|
|
141
|
+
expect(afterOpen).toBeGreaterThan(0)
|
|
142
|
+
// Force real re-render passes while the editor is open. Each one used to
|
|
143
|
+
// re-invoke the source and start another request, which in a browser
|
|
144
|
+
// becomes an endless fetch loop as every resolution re-renders.
|
|
145
|
+
const api = g.getApi() as never as {
|
|
146
|
+
setOption: (k: string, v: unknown) => void
|
|
147
|
+
}
|
|
148
|
+
for (const zebra of [true, false, true]) {
|
|
149
|
+
api.setOption('zebraRows', zebra)
|
|
150
|
+
await settle()
|
|
151
|
+
}
|
|
152
|
+
expect(calls.length).toBe(afterOpen)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('refetches a cascade when the row it depends on changes', async () => {
|
|
156
|
+
// A cascade is a function of the row's OTHER cells. Keying the cache by row
|
|
157
|
+
// id alone made the first result permanent - changing Country left the old
|
|
158
|
+
// city list in place forever.
|
|
159
|
+
const asked: string[] = []
|
|
160
|
+
const columns = [
|
|
161
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
162
|
+
{
|
|
163
|
+
field: 'city',
|
|
164
|
+
header: 'City',
|
|
165
|
+
width: 160,
|
|
166
|
+
editorType: 'select',
|
|
167
|
+
editorOptions: (row: Row) => {
|
|
168
|
+
asked.push(row.country)
|
|
169
|
+
return Promise.resolve(row.country === 'FR' ? ['Paris', 'Lyon'] : ['Osaka', 'Kyoto'])
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
]
|
|
173
|
+
const g = mountGrid(columns)
|
|
174
|
+
cleanup = g.destroy
|
|
175
|
+
await settle()
|
|
176
|
+
const api = g.getApi() as never as {
|
|
177
|
+
startEditing: (r: number, c: string) => boolean
|
|
178
|
+
stopEditing: (cancel?: boolean) => boolean
|
|
179
|
+
setCellValue: (r: number, c: string, v: unknown) => void
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await openEditor(api as never, 'city')
|
|
183
|
+
expect(document.body.textContent).toContain('Lyon')
|
|
184
|
+
api.stopEditing(true)
|
|
185
|
+
await settle()
|
|
186
|
+
|
|
187
|
+
// Row 0 moves from FR to JP.
|
|
188
|
+
api.setCellValue(0, 'country', 'JP')
|
|
189
|
+
await settle()
|
|
190
|
+
|
|
191
|
+
await openEditor(api as never, 'city')
|
|
192
|
+
expect(asked).toContain('JP')
|
|
193
|
+
expect(document.body.textContent).toContain('Kyoto')
|
|
194
|
+
expect(document.body.textContent).not.toContain('Lyon')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('resolves a per-row (cascading) async source', async () => {
|
|
198
|
+
const columns = [
|
|
199
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
200
|
+
{
|
|
201
|
+
field: 'city',
|
|
202
|
+
header: 'City',
|
|
203
|
+
width: 160,
|
|
204
|
+
editorType: 'select',
|
|
205
|
+
editorOptions: (row: Row) =>
|
|
206
|
+
Promise.resolve(row.country === 'FR' ? ['Paris', 'Lyon'] : ['Osaka', 'Kyoto']),
|
|
207
|
+
},
|
|
208
|
+
]
|
|
209
|
+
const g = mountGrid(columns)
|
|
210
|
+
cleanup = g.destroy
|
|
211
|
+
await settle()
|
|
212
|
+
await openEditor(g.getApi(), 'city')
|
|
213
|
+
// Row 1 is FR - it must not be offered the JP list.
|
|
214
|
+
expect(document.body.textContent).toContain('Lyon')
|
|
215
|
+
expect(document.body.textContent).not.toContain('Kyoto')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('falls back to an empty list when the source rejects', async () => {
|
|
219
|
+
const rejected = Promise.reject(new Error('offline'))
|
|
220
|
+
rejected.catch(() => {}) // keep the reporter clean; the grid attaches its own handler
|
|
221
|
+
const columns = [
|
|
222
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
223
|
+
{
|
|
224
|
+
field: 'city',
|
|
225
|
+
header: 'City',
|
|
226
|
+
width: 160,
|
|
227
|
+
editorType: 'select',
|
|
228
|
+
editorOptions: rejected,
|
|
229
|
+
},
|
|
230
|
+
]
|
|
231
|
+
const g = mountGrid(columns)
|
|
232
|
+
cleanup = g.destroy
|
|
233
|
+
await settle()
|
|
234
|
+
await openEditor(g.getApi(), 'city')
|
|
235
|
+
await settle()
|
|
236
|
+
// The editor must settle on "No options", never spin forever.
|
|
237
|
+
expect(document.body.textContent).toContain('No options')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('exposes refreshEditorOptions on the api', async () => {
|
|
241
|
+
const columns = [
|
|
242
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
243
|
+
{ field: 'city', header: 'City', width: 160 },
|
|
244
|
+
]
|
|
245
|
+
const g = mountGrid(columns)
|
|
246
|
+
cleanup = g.destroy
|
|
247
|
+
await settle()
|
|
248
|
+
const api = g.getApi() as never as { refreshEditorOptions: (c?: string) => void }
|
|
249
|
+
expect(typeof api.refreshEditorOptions).toBe('function')
|
|
250
|
+
expect(() => api.refreshEditorOptions('city')).not.toThrow()
|
|
251
|
+
expect(() => api.refreshEditorOptions()).not.toThrow()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('leaves a plain array source synchronous', async () => {
|
|
255
|
+
const columns = [
|
|
256
|
+
{ field: 'country', header: 'Country', width: 120 },
|
|
257
|
+
{
|
|
258
|
+
field: 'city',
|
|
259
|
+
header: 'City',
|
|
260
|
+
width: 160,
|
|
261
|
+
editorType: 'select',
|
|
262
|
+
editorOptions: ['Paris', 'Lyon'],
|
|
263
|
+
},
|
|
264
|
+
]
|
|
265
|
+
const g = mountGrid(columns)
|
|
266
|
+
cleanup = g.destroy
|
|
267
|
+
await settle()
|
|
268
|
+
await openEditor(g.getApi(), 'city')
|
|
269
|
+
expect(document.body.textContent).toContain('Lyon')
|
|
270
|
+
})
|
|
271
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM: `autoRowHeight` sizes each row to its own content.
|
|
3
|
+
*
|
|
4
|
+
* jsdom does no layout - getBoundingClientRect is 0 and the global
|
|
5
|
+
* ResizeObserver stub never fires - so this file installs a controllable
|
|
6
|
+
* observer and fakes element heights. That is enough to prove the wiring that
|
|
7
|
+
* actually matters: rows are measured, the measurement reaches the virtualizer,
|
|
8
|
+
* and a measured row is sized by its content instead of the fixed rowHeight.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
11
|
+
import { mount, unmount } from 'svelte'
|
|
12
|
+
import SvGrid from './SvGrid.svelte'
|
|
13
|
+
import {
|
|
14
|
+
createCoreRowModel,
|
|
15
|
+
createFilteredRowModel,
|
|
16
|
+
createSortedRowModel,
|
|
17
|
+
sortFns,
|
|
18
|
+
tableFeatures,
|
|
19
|
+
rowSortingFeature,
|
|
20
|
+
} from './index'
|
|
21
|
+
|
|
22
|
+
type Row = { id: number; name: string; notes: string }
|
|
23
|
+
|
|
24
|
+
const data: Row[] = [
|
|
25
|
+
{ id: 1, name: 'Ada', notes: 'short' },
|
|
26
|
+
{ id: 2, name: 'Linus', notes: 'a much longer note that would wrap onto several lines in a narrow column' },
|
|
27
|
+
{ id: 3, name: 'Grace', notes: 'medium length note' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const columns = [
|
|
31
|
+
{ field: 'name', header: 'Name', width: 120 },
|
|
32
|
+
{ field: 'notes', header: 'Notes', width: 160 },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const features = tableFeatures({ rowSortingFeature })
|
|
36
|
+
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
37
|
+
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()))
|
|
38
|
+
|
|
39
|
+
/** A ResizeObserver whose callbacks this test can fire on demand. */
|
|
40
|
+
class ControllableRO {
|
|
41
|
+
static instances: ControllableRO[] = []
|
|
42
|
+
cb: () => void
|
|
43
|
+
targets: Element[] = []
|
|
44
|
+
constructor(cb: () => void) {
|
|
45
|
+
this.cb = cb
|
|
46
|
+
ControllableRO.instances.push(this)
|
|
47
|
+
}
|
|
48
|
+
observe(el: Element) { this.targets.push(el) }
|
|
49
|
+
unobserve() {}
|
|
50
|
+
disconnect() { this.targets = [] }
|
|
51
|
+
static fireAll() { for (const i of ControllableRO.instances) i.cb() }
|
|
52
|
+
static reset() { ControllableRO.instances = [] }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let originalRO: typeof globalThis.ResizeObserver
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
originalRO = globalThis.ResizeObserver
|
|
58
|
+
ControllableRO.reset()
|
|
59
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ControllableRO
|
|
60
|
+
})
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = originalRO
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function mountGrid(props: Record<string, unknown>) {
|
|
66
|
+
const target = document.createElement('div')
|
|
67
|
+
document.body.appendChild(target)
|
|
68
|
+
const app = mount(SvGrid, {
|
|
69
|
+
target,
|
|
70
|
+
props: {
|
|
71
|
+
data,
|
|
72
|
+
columns,
|
|
73
|
+
features,
|
|
74
|
+
rowHeight: 30,
|
|
75
|
+
_rowModels: {
|
|
76
|
+
coreRowModel: createCoreRowModel(),
|
|
77
|
+
filteredRowModel: createFilteredRowModel(),
|
|
78
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
79
|
+
},
|
|
80
|
+
containerHeight: 400,
|
|
81
|
+
virtualization: false,
|
|
82
|
+
...props,
|
|
83
|
+
} as never,
|
|
84
|
+
})
|
|
85
|
+
return { target, destroy: () => { unmount(app); target.remove() } }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let cleanup: (() => void) | null = null
|
|
89
|
+
afterEach(() => { cleanup?.(); cleanup = null })
|
|
90
|
+
|
|
91
|
+
const dataRows = (t: HTMLElement) =>
|
|
92
|
+
[...t.querySelectorAll<HTMLElement>('tbody tr.sv-grid-row')].filter((r) => !r.classList.contains('sv-grid-group-row'))
|
|
93
|
+
|
|
94
|
+
describe('autoRowHeight', () => {
|
|
95
|
+
it('is off by default: rows get a fixed height and no wrap class', async () => {
|
|
96
|
+
const g = mountGrid({})
|
|
97
|
+
cleanup = g.destroy
|
|
98
|
+
await tick()
|
|
99
|
+
const row = dataRows(g.target)[0]!
|
|
100
|
+
expect(row.classList.contains('sv-grid-row-auto-height')).toBe(false)
|
|
101
|
+
expect(row.getAttribute('style')).toContain('height: 30px')
|
|
102
|
+
expect(row.getAttribute('style')).not.toContain('min-height')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('switches rows to min-height + the wrap class when on', async () => {
|
|
106
|
+
const g = mountGrid({ autoRowHeight: true })
|
|
107
|
+
cleanup = g.destroy
|
|
108
|
+
await tick()
|
|
109
|
+
for (const row of dataRows(g.target)) {
|
|
110
|
+
expect(row.classList.contains('sv-grid-row-auto-height')).toBe(true)
|
|
111
|
+
// min-height, so content can grow the row instead of being clipped.
|
|
112
|
+
expect(row.getAttribute('style')).toContain('min-height: 30px')
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('feeds a measured height back into the row size', async () => {
|
|
117
|
+
const g = mountGrid({ autoRowHeight: true })
|
|
118
|
+
cleanup = g.destroy
|
|
119
|
+
await tick()
|
|
120
|
+
|
|
121
|
+
const rows = dataRows(g.target)
|
|
122
|
+
expect(rows.length).toBe(3)
|
|
123
|
+
// Fake layout: row 1 (the long note) wraps to 3 lines.
|
|
124
|
+
const heights = [30, 90, 30]
|
|
125
|
+
rows.forEach((row, i) => {
|
|
126
|
+
row.getBoundingClientRect = () => ({ height: heights[i], width: 280, top: 0, left: 0, right: 280, bottom: heights[i]!, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
ControllableRO.fireAll()
|
|
130
|
+
await raf()
|
|
131
|
+
await tick()
|
|
132
|
+
|
|
133
|
+
const after = dataRows(g.target)
|
|
134
|
+
expect(after[0]?.getAttribute('style')).toContain('min-height: 30px')
|
|
135
|
+
// The measured tall row is now sized by its content, not by rowHeight.
|
|
136
|
+
expect(after[1]?.getAttribute('style')).toContain('min-height: 90px')
|
|
137
|
+
expect(after[2]?.getAttribute('style')).toContain('min-height: 30px')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('ignores autoRowHeight when rowHeight is a function (caller owns heights)', async () => {
|
|
141
|
+
const g = mountGrid({ autoRowHeight: true, rowHeight: (i: number) => 40 + i * 10 })
|
|
142
|
+
cleanup = g.destroy
|
|
143
|
+
await tick()
|
|
144
|
+
const rows = dataRows(g.target)
|
|
145
|
+
expect(rows[0]?.classList.contains('sv-grid-row-auto-height')).toBe(false)
|
|
146
|
+
expect(rows[0]?.getAttribute('style')).toContain('height: 40px')
|
|
147
|
+
expect(rows[1]?.getAttribute('style')).toContain('height: 50px')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('uses a numeric rowHeight as the pre-measure estimate', async () => {
|
|
151
|
+
const g = mountGrid({ autoRowHeight: true, rowHeight: 48 })
|
|
152
|
+
cleanup = g.destroy
|
|
153
|
+
await tick()
|
|
154
|
+
// Nothing measured yet, so every row sits at the estimate.
|
|
155
|
+
for (const row of dataRows(g.target)) {
|
|
156
|
+
expect(row.getAttribute('style')).toContain('min-height: 48px')
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('works on the virtualized body too (the variable-size path)', async () => {
|
|
161
|
+
const g = mountGrid({ autoRowHeight: true, virtualization: true })
|
|
162
|
+
cleanup = g.destroy
|
|
163
|
+
await tick()
|
|
164
|
+
const rows = dataRows(g.target)
|
|
165
|
+
expect(rows.length).toBeGreaterThan(0)
|
|
166
|
+
expect(rows[0]?.classList.contains('sv-grid-row-auto-height')).toBe(true)
|
|
167
|
+
|
|
168
|
+
rows.forEach((row, i) => {
|
|
169
|
+
const h = i === 1 ? 96 : 30
|
|
170
|
+
row.getBoundingClientRect = () => ({ height: h, width: 280, top: 0, left: 0, right: 280, bottom: h, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
|
|
171
|
+
})
|
|
172
|
+
ControllableRO.fireAll()
|
|
173
|
+
await raf()
|
|
174
|
+
await tick()
|
|
175
|
+
|
|
176
|
+
const after = dataRows(g.target)
|
|
177
|
+
// The virtualizer now sizes row 1 from its measurement, so the row element
|
|
178
|
+
// reflects the measured height rather than the 30px estimate.
|
|
179
|
+
expect(after[1]?.getAttribute('style')).toContain('96px')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does not re-report a sub-pixel change (no measure/layout loop)', async () => {
|
|
183
|
+
const g = mountGrid({ autoRowHeight: true })
|
|
184
|
+
cleanup = g.destroy
|
|
185
|
+
await tick()
|
|
186
|
+
const rows = dataRows(g.target)
|
|
187
|
+
rows.forEach((row) => {
|
|
188
|
+
row.getBoundingClientRect = () => ({ height: 70, width: 280, top: 0, left: 0, right: 280, bottom: 70, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
|
|
189
|
+
})
|
|
190
|
+
ControllableRO.fireAll()
|
|
191
|
+
await raf()
|
|
192
|
+
await tick()
|
|
193
|
+
expect(dataRows(g.target)[0]?.getAttribute('style')).toContain('min-height: 70px')
|
|
194
|
+
|
|
195
|
+
// A 0.4px drift must be ignored - otherwise fractional layout ping-pongs.
|
|
196
|
+
dataRows(g.target).forEach((row) => {
|
|
197
|
+
row.getBoundingClientRect = () => ({ height: 70.4, width: 280, top: 0, left: 0, right: 280, bottom: 70.4, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
|
|
198
|
+
})
|
|
199
|
+
ControllableRO.fireAll()
|
|
200
|
+
await raf()
|
|
201
|
+
await tick()
|
|
202
|
+
expect(dataRows(g.target)[0]?.getAttribute('style')).toContain('min-height: 70px')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
formatChartValue,
|
|
20
20
|
buildChart,
|
|
21
21
|
} from './index'
|
|
22
|
-
import type { ColumnDef, SvGridApi } from './index'
|
|
22
|
+
import type { ChartAggregateRequest, ColumnDef, SvGridApi } from './index'
|
|
23
23
|
|
|
24
24
|
type Person = { id: number; name: string; team: string; age: number; salary: number }
|
|
25
25
|
|
|
@@ -328,7 +328,7 @@ describe('SvGrid built-in charting', () => {
|
|
|
328
328
|
await tick()
|
|
329
329
|
const csv = chartSpecToCsv(api.getChartSpec()!)
|
|
330
330
|
const [header, ...rows] = csv.split('\n')
|
|
331
|
-
expect(header
|
|
331
|
+
expect(header!.startsWith('Category,')).toBe(true)
|
|
332
332
|
expect(rows.length).toBeGreaterThan(0)
|
|
333
333
|
// Export button + menu.
|
|
334
334
|
const btn = target.querySelector<HTMLButtonElement>('[aria-label="Export chart"]')
|
|
@@ -435,7 +435,7 @@ describe('SvGrid built-in charting', () => {
|
|
|
435
435
|
|
|
436
436
|
it('renders a custom `buildSpec` chart that stays live with the grid', async () => {
|
|
437
437
|
const { api, destroy } = await mountGrid({
|
|
438
|
-
charting: { defaultOpen: true, buildSpec: (rows) => ({ type: 'bar', categories: ['All'], series: [{ label: 'Count', values: [rows.length] }] }) },
|
|
438
|
+
charting: { defaultOpen: true, buildSpec: (rows: unknown[]) => ({ type: 'bar', categories: ['All'], series: [{ label: 'Count', values: [rows.length] }] }) },
|
|
439
439
|
})
|
|
440
440
|
try {
|
|
441
441
|
await tick()
|
|
@@ -487,7 +487,7 @@ describe('SvGrid built-in charting', () => {
|
|
|
487
487
|
let lastFilter: any = null
|
|
488
488
|
const { api, destroy } = await mountGrid({
|
|
489
489
|
charting: {
|
|
490
|
-
getAggregate: async (req) => {
|
|
490
|
+
getAggregate: async (req: ChartAggregateRequest) => {
|
|
491
491
|
calls += 1
|
|
492
492
|
lastFilter = req.filterModel
|
|
493
493
|
return [
|
|
@@ -94,8 +94,14 @@ describe('editable comments', () => {
|
|
|
94
94
|
const cell = dataCell(target, 0)
|
|
95
95
|
cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
|
|
96
96
|
// First menu-open triggers GridMenus' lazy import; poll until it mounts.
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// Wait for the menu's ITEMS, not just the overlay element: GridMenus is a
|
|
98
|
+
// lazy chunk and the overlay mounts before its items render, so a fixed
|
|
99
|
+
// tick afterwards is a race rather than a guarantee.
|
|
100
|
+
await vi.waitFor(() => {
|
|
101
|
+
const menu = target.querySelector('.sv-grid-context-menu')
|
|
102
|
+
expect(menu).not.toBeNull()
|
|
103
|
+
expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
|
|
104
|
+
})
|
|
99
105
|
const editItem = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find(
|
|
100
106
|
(b) => b.textContent?.trim() === 'Edit comment',
|
|
101
107
|
) as HTMLElement
|
|
@@ -74,17 +74,31 @@ function rightClickFirstCell(target: HTMLElement) {
|
|
|
74
74
|
return cell
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Right-click and wait until the menu's ITEMS are in the DOM.
|
|
79
|
+
*
|
|
80
|
+
* GridMenus is a lazy chunk, and the overlay element appears before its items
|
|
81
|
+
* render. Waiting for the element alone leaves callers reading an empty item
|
|
82
|
+
* list; waiting a fixed tick makes them depend on an earlier test having
|
|
83
|
+
* already warmed the chunk. Only for cases that expect the menu to open - the
|
|
84
|
+
* "stays closed" case must not use this.
|
|
85
|
+
*/
|
|
86
|
+
async function openContextMenu(target: HTMLElement) {
|
|
87
|
+
rightClickFirstCell(target)
|
|
88
|
+
await vi.waitFor(() => {
|
|
89
|
+
const menu = target.querySelector('.sv-grid-context-menu')
|
|
90
|
+
expect(menu).not.toBeNull()
|
|
91
|
+
expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
|
|
92
|
+
})
|
|
93
|
+
return target.querySelector('.sv-grid-context-menu') as HTMLElement
|
|
94
|
+
}
|
|
95
|
+
|
|
77
96
|
describe('SvGrid context menu', () => {
|
|
78
97
|
it('opens the default menu on right-click with the built-in items', async () => {
|
|
79
98
|
const { target, destroy } = await mountGrid(true)
|
|
80
99
|
await tick()
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// first load can exceed a single tick); poll until the overlay mounts.
|
|
84
|
-
await vi.waitFor(() => expect(target.querySelector('.sv-grid-context-menu')).not.toBeNull())
|
|
85
|
-
const menu = target.querySelector('.sv-grid-context-menu')
|
|
86
|
-
expect(menu).not.toBeNull()
|
|
87
|
-
const labels = [...menu!.querySelectorAll('.sv-grid-menu-item')].map((b) => b.textContent?.trim())
|
|
100
|
+
const menu = await openContextMenu(target)
|
|
101
|
+
const labels = [...menu.querySelectorAll('.sv-grid-menu-item')].map((b) => b.textContent?.trim())
|
|
88
102
|
expect(labels).toContain('Copy')
|
|
89
103
|
expect(labels).toContain('Paste')
|
|
90
104
|
expect(labels).toContain('Remove row')
|
|
@@ -113,8 +127,10 @@ describe('SvGrid context menu', () => {
|
|
|
113
127
|
]
|
|
114
128
|
const { target, destroy } = await mountGrid(items)
|
|
115
129
|
await tick()
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
// Was a bare `await tick()`, which only worked because the case above had
|
|
131
|
+
// already loaded the lazy chunk - running this one alone would have read an
|
|
132
|
+
// empty menu.
|
|
133
|
+
await openContextMenu(target)
|
|
118
134
|
const labels = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].map((b) =>
|
|
119
135
|
b.textContent?.trim(),
|
|
120
136
|
)
|