@svgrid/grid 2.2.31 → 2.2.33
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/DockNodeView.svelte +1 -1
- package/dist/GridFooter.svelte +0 -40
- package/dist/GridMenus.svelte +0 -77
- package/dist/SvAvatar.svelte +1 -1
- package/dist/SvCheckBox.svelte +2 -1
- package/dist/SvCircularProgress.svelte +1 -1
- package/dist/SvDateRangeInput.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 +164 -35
- package/dist/SvGrid.types.d.ts +81 -0
- package/dist/SvGridChart.svelte +2 -10
- package/dist/SvGridDropdown.svelte +38 -6
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvGridSelect.svelte +12 -5
- package/dist/SvGridSelect.svelte.d.ts +6 -0
- package/dist/SvGroupCell.svelte +9 -2
- package/dist/SvKbd.svelte +1 -1
- package/dist/SvList.svelte +1 -1
- package/dist/SvMenubar.svelte +1 -1
- package/dist/SvMultiSelect.svelte +12 -4
- package/dist/SvMultiSelect.svelte.d.ts +6 -0
- package/dist/SvOtpInput.svelte +1 -1
- 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 +35 -16
- package/dist/SvRichText.svelte.d.ts +9 -0
- package/dist/SvSwitchButton.svelte +2 -1
- package/dist/SvToaster.svelte +1 -1
- package/dist/SvTour.svelte +4 -0
- package/dist/SvTree.svelte +9 -2
- package/dist/SvTree.svelte.d.ts +5 -0
- package/dist/SvTreeSelect.svelte +6 -4
- package/dist/build-api.js +16 -0
- package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-B7XhhqdN.js} +34 -36
- package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-BxmIvCJh.js} +35 -37
- package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
- package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-o94wfSUa.js} +3 -2
- package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Cr_90pPD.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-ICbxiUvK.js} +4 -4
- package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-Bx42PMvf.js} +2 -2
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CUOdm7YA.js} +93 -88
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
- package/dist/cdn/{disclose-version-DoD9AFD_.js → disclose-version-DljhZohK.js} +45 -45
- package/dist/cdn/{src-DYDCiC0D.js → src-erjBRYfX.js} +5952 -5615
- package/dist/cdn/{src-BLJfdyL0.js → src-ktbQVOk-.js} +4190 -3851
- package/dist/cdn/svgrid.js +9 -9
- 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/chart.d.ts +2 -0
- package/dist/chart.js +1 -4
- package/dist/core.d.ts +80 -9
- package/dist/core.js +89 -0
- package/dist/createMenu.svelte.js +0 -1
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.js +1 -1
- 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/export-format.js +3 -0
- 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/selection.js +0 -1
- 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 +3 -2
- package/src/DockNodeView.svelte +1 -1
- package/src/GridFooter.svelte +0 -40
- package/src/GridMenus.svelte +0 -77
- package/src/SvAccordion.test.ts +5 -5
- package/src/SvAvatar.svelte +1 -1
- package/src/SvButtonGroup.test.ts +6 -6
- package/src/SvCalendar.test.ts +1 -1
- package/src/SvCheckBox.svelte +2 -1
- package/src/SvCircularProgress.svelte +1 -1
- package/src/SvDateRangeInput.svelte +1 -1
- package/src/SvFileUpload.test.ts +1 -1
- package/src/SvGrid.controller.svelte.ts +367 -22
- package/src/SvGrid.css +103 -3
- package/src/SvGrid.svelte +164 -35
- package/src/SvGrid.types.ts +81 -1
- package/src/SvGridChart.svelte +2 -10
- package/src/SvGridDropdown.svelte +38 -6
- package/src/SvGridSelect.svelte +12 -5
- package/src/SvGroupCell.svelte +9 -2
- package/src/SvKbd.svelte +1 -1
- package/src/SvList.svelte +1 -1
- package/src/SvMenubar.svelte +1 -1
- package/src/SvMultiSelect.svelte +12 -4
- package/src/SvOtpInput.svelte +1 -1
- 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 +35 -16
- package/src/SvSwitchButton.svelte +2 -1
- package/src/SvToaster.svelte +1 -1
- package/src/SvTour.svelte +4 -0
- package/src/SvTree.svelte +9 -2
- package/src/SvTreeSelect.svelte +6 -4
- package/src/ai.test.ts +1 -1
- package/src/build-api.ts +15 -105
- package/src/builtin-editors.test.ts +3 -1
- package/src/cell-formatting.test.ts +1 -1
- package/src/cell-render.test.ts +27 -3
- package/src/cell-render.ts +122 -95
- package/src/cell-values.ts +4 -0
- package/src/chart-export.test.ts +2 -2
- package/src/chart.ts +1 -2
- package/src/clipboard.test.ts +0 -2
- package/src/clipboard.ts +0 -102
- package/src/columns.ts +0 -115
- package/src/core.ts +157 -8
- package/src/createMenu.svelte.ts +0 -1
- package/src/editing.test.ts +1 -1
- package/src/editing.ts +2 -105
- package/src/editor-contract.ts +12 -5
- package/src/editor-registry.grid.test.ts +48 -2
- package/src/editor-registry.test.ts +3 -1
- package/src/editor-registry.ts +23 -7
- package/src/export-format.ts +3 -0
- package/src/facet-buckets.test.ts +33 -33
- package/src/features.ts +0 -90
- 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.coverage.test.ts +1 -1
- package/src/keyboard-handlers.ts +17 -120
- package/src/list-power.test.ts +2 -2
- package/src/menus.test.ts +4 -5
- package/src/menus.ts +0 -112
- package/src/named-views.coverage.test.ts +3 -3
- package/src/new-features.test.ts +3 -1
- package/src/recurrence.ts +3 -2
- package/src/row-drag.test.ts +1 -1
- package/src/row-resize.test.ts +41 -42
- package/src/scheduler-model.ts +1 -1
- package/src/scroll-sync.test.ts +4 -7
- package/src/scroll-sync.ts +0 -122
- package/src/selection.ts +0 -109
- package/src/server-data-source.coverage.test.ts +1 -1
- package/src/server-group-model.test.ts +8 -8
- package/src/spreadsheet.test.ts +0 -1
- package/src/summaries.ts +0 -112
- 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.behavior.test.ts +0 -1
- package/src/svgrid.charting.test.ts +4 -4
- package/src/svgrid.column-reorder.test.ts +0 -8
- 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 +168 -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,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 [
|
|
@@ -106,14 +106,6 @@ function headerOrder(target: HTMLElement): string[] {
|
|
|
106
106
|
return Array.from(ths).map((th) => th.dataset.svgridHeaderCol ?? '')
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function header(target: HTMLElement, id: string): HTMLElement {
|
|
110
|
-
// jsdom doesn't ship CSS.escape; our ids are simple alphanumerics so
|
|
111
|
-
// a plain attribute selector is fine.
|
|
112
|
-
const el = target.querySelector<HTMLElement>(`th[data-svgrid-header-col="${id}"]`)
|
|
113
|
-
if (!el) throw new Error(`header not found: ${id}`)
|
|
114
|
-
return el
|
|
115
|
-
}
|
|
116
|
-
|
|
117
109
|
describe('SvGrid column reorder - default off', () => {
|
|
118
110
|
it('does NOT set draggable on headers when the prop is unset', async () => {
|
|
119
111
|
const { target, destroy } = await mountGrid()
|
|
@@ -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
|
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filtering depth, verified rather than assumed (#11, #12).
|
|
3
|
+
*
|
|
4
|
+
* Both of these were still listed as roadmap gaps. They are not: the filter row
|
|
5
|
+
* renders a per-column operator picker, and a column filter carries an optional
|
|
6
|
+
* second condition joined by AND/OR. These tests pin the behaviour so the claim
|
|
7
|
+
* is backed by something executable.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
|
+
import { mount, unmount } from 'svelte'
|
|
11
|
+
import SvGrid from './SvGrid.svelte'
|
|
12
|
+
import {
|
|
13
|
+
applyExcelFilter,
|
|
14
|
+
createCoreRowModel,
|
|
15
|
+
createFilteredRowModel,
|
|
16
|
+
createPaginatedRowModel,
|
|
17
|
+
createSortedRowModel,
|
|
18
|
+
sortFns,
|
|
19
|
+
tableFeatures,
|
|
20
|
+
rowSortingFeature,
|
|
21
|
+
columnFilteringFeature,
|
|
22
|
+
} from './index'
|
|
23
|
+
|
|
24
|
+
type Row = { id: number; name: string; amount: number }
|
|
25
|
+
|
|
26
|
+
const data: Row[] = [
|
|
27
|
+
{ id: 1, name: 'alpha', amount: 5 },
|
|
28
|
+
{ id: 2, name: 'beta', amount: 50 },
|
|
29
|
+
{ id: 3, name: 'gamma', amount: 500 },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const columns = [
|
|
33
|
+
{ field: 'name', header: 'Name', width: 160 },
|
|
34
|
+
{ field: 'amount', header: 'Amount', width: 120 },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
|
|
38
|
+
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
39
|
+
|
|
40
|
+
function mountGrid() {
|
|
41
|
+
const target = document.createElement('div')
|
|
42
|
+
document.body.appendChild(target)
|
|
43
|
+
let api: never = null as never
|
|
44
|
+
const app = mount(SvGrid, {
|
|
45
|
+
target,
|
|
46
|
+
props: {
|
|
47
|
+
data,
|
|
48
|
+
columns,
|
|
49
|
+
features,
|
|
50
|
+
filterable: true,
|
|
51
|
+
showFilterRow: true,
|
|
52
|
+
onApiReady: (a: never) => { api = a },
|
|
53
|
+
_rowModels: {
|
|
54
|
+
coreRowModel: createCoreRowModel(),
|
|
55
|
+
filteredRowModel: createFilteredRowModel(),
|
|
56
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
57
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
58
|
+
},
|
|
59
|
+
containerHeight: 400,
|
|
60
|
+
virtualization: false,
|
|
61
|
+
} as never,
|
|
62
|
+
})
|
|
63
|
+
return { target, getApi: () => api, destroy: () => { unmount(app); target.remove() } }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let cleanup: (() => void) | null = null
|
|
67
|
+
afterEach(() => { cleanup?.(); cleanup = null })
|
|
68
|
+
|
|
69
|
+
const bodyRows = (t: HTMLElement) =>
|
|
70
|
+
[...t.querySelectorAll('tbody tr.sv-grid-row')].filter(
|
|
71
|
+
(r) => !r.classList.contains('sv-grid-row-spacer'),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
describe('filter row operator picker (#11)', () => {
|
|
75
|
+
it('renders an operator button per column in the filter row', async () => {
|
|
76
|
+
const { target, destroy } = mountGrid()
|
|
77
|
+
cleanup = destroy
|
|
78
|
+
await tick()
|
|
79
|
+
const btns = target.querySelectorAll('.sv-grid-filter-operator-btn')
|
|
80
|
+
expect(btns.length).toBe(columns.length)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('exposes the active operator on the button for a screen reader', async () => {
|
|
84
|
+
const { target, destroy } = mountGrid()
|
|
85
|
+
cleanup = destroy
|
|
86
|
+
await tick()
|
|
87
|
+
const btn = target.querySelector('.sv-grid-filter-operator-btn')!
|
|
88
|
+
expect(btn.getAttribute('aria-label')).toMatch(/^Filter condition: /)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('opens a menu of the operators valid for that column', async () => {
|
|
92
|
+
const { target, destroy } = mountGrid()
|
|
93
|
+
cleanup = destroy
|
|
94
|
+
await tick()
|
|
95
|
+
target.querySelector<HTMLButtonElement>('.sv-grid-filter-operator-btn')!.click()
|
|
96
|
+
await tick()
|
|
97
|
+
expect(target.querySelector('.sv-grid-filter-operator-btn.is-open')).not.toBeNull()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('two-condition column filter (#12)', () => {
|
|
102
|
+
// The model is what decides rows; these assert the AND/OR semantics directly.
|
|
103
|
+
const between = (v: unknown, lo: number, hi: number) =>
|
|
104
|
+
applyExcelFilter(v, { id: 'amount', operator: 'greaterThan', value: lo }) &&
|
|
105
|
+
applyExcelFilter(v, { id: 'amount', operator: 'lessThan', value: hi })
|
|
106
|
+
|
|
107
|
+
it('AND narrows to the intersection', () => {
|
|
108
|
+
expect(between(50, 10, 100)).toBe(true)
|
|
109
|
+
expect(between(5, 10, 100)).toBe(false)
|
|
110
|
+
expect(between(500, 10, 100)).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('OR widens to the union', () => {
|
|
114
|
+
const or = (v: unknown) =>
|
|
115
|
+
applyExcelFilter(v, { id: 'name', operator: 'startsWith', value: 'al' }) ||
|
|
116
|
+
applyExcelFilter(v, { id: 'name', operator: 'endsWith', value: 'ma' })
|
|
117
|
+
expect(or('alpha')).toBe(true)
|
|
118
|
+
expect(or('gamma')).toBe(true)
|
|
119
|
+
expect(or('beta')).toBe(false)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('applies a two-condition AND filter through the api', async () => {
|
|
123
|
+
const g = mountGrid()
|
|
124
|
+
cleanup = g.destroy
|
|
125
|
+
await tick()
|
|
126
|
+
const api = g.getApi() as never as {
|
|
127
|
+
setFilter: (id: string, f: Record<string, unknown>) => void
|
|
128
|
+
}
|
|
129
|
+
// amount > 10 AND amount < 100 -> only beta (50)
|
|
130
|
+
api.setFilter('amount', {
|
|
131
|
+
operator: 'greaterThan',
|
|
132
|
+
value: '10',
|
|
133
|
+
operator2: 'lessThan',
|
|
134
|
+
value2: '100',
|
|
135
|
+
join: 'AND',
|
|
136
|
+
})
|
|
137
|
+
await tick()
|
|
138
|
+
expect(bodyRows(g.target)).toHaveLength(1)
|
|
139
|
+
expect(g.target.textContent).toContain('beta')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('applies a two-condition OR filter through the api', async () => {
|
|
143
|
+
const g = mountGrid()
|
|
144
|
+
cleanup = g.destroy
|
|
145
|
+
await tick()
|
|
146
|
+
const api = g.getApi() as never as {
|
|
147
|
+
setFilter: (id: string, f: Record<string, unknown>) => void
|
|
148
|
+
}
|
|
149
|
+
// name startsWith "al" OR name endsWith "ma" -> alpha + gamma
|
|
150
|
+
api.setFilter('name', {
|
|
151
|
+
operator: 'startsWith',
|
|
152
|
+
value: 'al',
|
|
153
|
+
operator2: 'endsWith',
|
|
154
|
+
value2: 'ma',
|
|
155
|
+
join: 'OR',
|
|
156
|
+
})
|
|
157
|
+
await tick()
|
|
158
|
+
expect(bodyRows(g.target)).toHaveLength(2)
|
|
159
|
+
expect(g.target.textContent).toContain('alpha')
|
|
160
|
+
expect(g.target.textContent).toContain('gamma')
|
|
161
|
+
expect(g.target.textContent).not.toContain('beta')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -82,8 +82,22 @@ function mountGrid(data: Row[]) {
|
|
|
82
82
|
async function openFilterMenu(target: HTMLElement) {
|
|
83
83
|
const btn = target.querySelector('.sv-grid-col-filter-btn') as HTMLButtonElement
|
|
84
84
|
btn.click()
|
|
85
|
-
// GridMenus is a lazy chunk
|
|
86
|
-
|
|
85
|
+
// GridMenus is a lazy chunk, so poll until the popover mounts AND its value
|
|
86
|
+
// checklist has rendered.
|
|
87
|
+
//
|
|
88
|
+
// Waiting only for the popover element returns while the listbox is still
|
|
89
|
+
// empty: the chunk arrives, the menu element appears, and the values are
|
|
90
|
+
// snapshotted and windowed in a later flush. Callers then read option counts
|
|
91
|
+
// from a list that has not populated yet. It survives locally because those
|
|
92
|
+
// flushes land in the same task, but under parallel load the gap widens - it
|
|
93
|
+
// failed once that way in a full-suite run. Every caller here opens a menu on
|
|
94
|
+
// a column that has values, so a populated list is the settled state to wait
|
|
95
|
+
// for, and each test's own assertions are unchanged.
|
|
96
|
+
await vi.waitFor(() => {
|
|
97
|
+
const menu = target.querySelector('.sv-grid-filter-menu')
|
|
98
|
+
expect(menu).not.toBeNull()
|
|
99
|
+
expect(menu!.querySelectorAll('[role="option"]').length).toBeGreaterThan(0)
|
|
100
|
+
})
|
|
87
101
|
return target.querySelector('.sv-grid-filter-menu') as HTMLElement
|
|
88
102
|
}
|
|
89
103
|
|
|
@@ -64,8 +64,16 @@ async function openFilterMenu(target: HTMLElement) {
|
|
|
64
64
|
const btn = target.querySelector('.sv-grid-col-filter-btn') as HTMLButtonElement
|
|
65
65
|
expect(btn).not.toBeNull()
|
|
66
66
|
btn.click()
|
|
67
|
-
// GridMenus is a lazy chunk
|
|
68
|
-
|
|
67
|
+
// GridMenus is a lazy chunk, so poll until the popover mounts AND its value
|
|
68
|
+
// checklist is in the DOM. Waiting only for the popover element returns while
|
|
69
|
+
// the menu body is still filling in, so a caller that reaches for
|
|
70
|
+
// `.sv-listbox` can get null - which is how this file flaked under
|
|
71
|
+
// full-suite parallel load.
|
|
72
|
+
await vi.waitFor(() => {
|
|
73
|
+
const menu = target.querySelector('.sv-grid-filter-menu')
|
|
74
|
+
expect(menu).not.toBeNull()
|
|
75
|
+
expect(menu!.querySelector('.sv-listbox')).not.toBeNull()
|
|
76
|
+
})
|
|
69
77
|
return target.querySelector('.sv-grid-filter-menu') as HTMLElement
|
|
70
78
|
}
|
|
71
79
|
|