@svgrid/grid 3.0.3 → 3.0.4
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 +2 -1
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/{src-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-Bugf5XlI.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +16 -0
- package/dist/selection.js +108 -16
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +6 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +107 -18
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +30 -2
- package/src/selection.ts +111 -19
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
- package/dist/cdn/GridMenus-DhdPF700.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
- package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
- package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
- package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
- package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
- package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
- package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* panel, its live derivation from displayed rows + selection, and the
|
|
4
4
|
* chart-click -> grid cross-filter loop.
|
|
5
5
|
*/
|
|
6
|
-
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
7
|
import { mount, unmount } from 'svelte'
|
|
8
8
|
import SvGrid from './SvGrid.svelte'
|
|
9
9
|
import {
|
|
@@ -231,6 +231,41 @@ describe('SvGrid built-in charting', () => {
|
|
|
231
231
|
}
|
|
232
232
|
})
|
|
233
233
|
|
|
234
|
+
it('shows an Explain button beside Chart it when an explain handler is registered, and Describe copies the summary', async () => {
|
|
235
|
+
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
236
|
+
try {
|
|
237
|
+
await tick()
|
|
238
|
+
api.setChartAiHandler(async () => null)
|
|
239
|
+
await tick()
|
|
240
|
+
// No explain handler yet: the AI row has no Explain button.
|
|
241
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Chart with AI"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
242
|
+
await tick()
|
|
243
|
+
expect(target.querySelector('.sv-grid-chart-explain-btn')).toBeFalsy()
|
|
244
|
+
api.setChartExplainHandler(async () => ({ summary: 'Salary rises across teams.', insights: ['Engineering leads.', 'Sales trails.'] }))
|
|
245
|
+
await tick()
|
|
246
|
+
const btn = target.querySelector<HTMLButtonElement>('.sv-grid-chart-explain-btn')
|
|
247
|
+
expect(btn).toBeTruthy()
|
|
248
|
+
btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
249
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
250
|
+
await tick()
|
|
251
|
+
expect(target.querySelector('.sv-grid-chart-ai-msg')!.textContent).toBe('Salary rises across teams. Engineering leads. Sales trails.')
|
|
252
|
+
// The export menu's Describe item copies chartSummary of the panel's spec.
|
|
253
|
+
const written: string[] = []
|
|
254
|
+
Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (t: string) => { written.push(t) } }, configurable: true })
|
|
255
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Export chart"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
256
|
+
await tick()
|
|
257
|
+
const item = [...target.querySelectorAll('.sv-grid-chart-export-menu [role="menuitem"]')].find((n) => n.textContent === 'Describe chart') as HTMLButtonElement
|
|
258
|
+
item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
259
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
260
|
+
await tick()
|
|
261
|
+
expect(written).toHaveLength(1)
|
|
262
|
+
expect(written[0]).toMatch(/rises|falls|holds/)
|
|
263
|
+
expect(target.querySelector('.sv-grid-chart-ai-msg')!.textContent).toBe(written[0])
|
|
264
|
+
} finally {
|
|
265
|
+
destroy()
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
234
269
|
it('shows an AI button only when a chart-AI handler is registered, and applies its result', async () => {
|
|
235
270
|
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
236
271
|
try {
|
|
@@ -321,7 +356,59 @@ describe('SvGrid built-in charting', () => {
|
|
|
321
356
|
}
|
|
322
357
|
})
|
|
323
358
|
|
|
324
|
-
it('
|
|
359
|
+
it('configureChart takes the new reducers and a calendar bucket, and the panel offers both', async () => {
|
|
360
|
+
const dateColumns = [
|
|
361
|
+
{ field: 'day', header: 'Day', width: 120, cellDataType: 'date' },
|
|
362
|
+
{ field: 'signups', header: 'Signups', width: 100, cellDataType: 'number' },
|
|
363
|
+
]
|
|
364
|
+
const dateData = [
|
|
365
|
+
{ id: 1, day: '2026-01-05', signups: 10 },
|
|
366
|
+
{ id: 2, day: '2026-01-20', signups: 30 },
|
|
367
|
+
{ id: 3, day: '2026-02-03', signups: 7 },
|
|
368
|
+
{ id: 4, day: '2026-02-17', signups: 9 },
|
|
369
|
+
]
|
|
370
|
+
const { api, target, destroy } = await mountGrid({
|
|
371
|
+
data: dateData,
|
|
372
|
+
columns: dateColumns,
|
|
373
|
+
charting: { defaultOpen: true, dimension: 'day', measures: 'signups' },
|
|
374
|
+
})
|
|
375
|
+
try {
|
|
376
|
+
await tick()
|
|
377
|
+
// The Aggregate select lists the extended reducers and a Bucket select
|
|
378
|
+
// appears for a date dimension.
|
|
379
|
+
const selects = [...target.querySelectorAll('.sv-grid-chart-ctl select')]
|
|
380
|
+
const options = (label: string) => {
|
|
381
|
+
const ctl = [...target.querySelectorAll('.sv-grid-chart-ctl')].find((c) => c.querySelector('.sv-grid-chart-ctl-lbl')?.textContent === label)
|
|
382
|
+
return [...(ctl?.querySelectorAll('option') ?? [])].map((o) => o.textContent)
|
|
383
|
+
}
|
|
384
|
+
expect(selects.length).toBeGreaterThan(0)
|
|
385
|
+
expect(options('Aggregate')).toContain('Median')
|
|
386
|
+
expect(options('Aggregate')).toContain('Distinct count')
|
|
387
|
+
expect(options('Bucket')).toEqual(['Exact', 'Day', 'Week', 'Month', 'Quarter', 'Year'])
|
|
388
|
+
|
|
389
|
+
api.configureChart({ reduce: 'median', bucket: 'month' })
|
|
390
|
+
await tick()
|
|
391
|
+
const spec = api.getChartSpec()!
|
|
392
|
+
expect(spec.categories).toEqual(['2026-01-01', '2026-02-01'])
|
|
393
|
+
expect(spec.series[0]!.values).toEqual([20, 8])
|
|
394
|
+
expect(spec.xType).toBe('ordinal-time')
|
|
395
|
+
expect(spec.yAxisTitle).toBe('Median of Signups')
|
|
396
|
+
|
|
397
|
+
// The bucket round-trips through the saved grid state.
|
|
398
|
+
const state = api.getState() as { charts?: Array<{ bucket?: string }> }
|
|
399
|
+
expect(state.charts?.[0]?.bucket).toBe('month')
|
|
400
|
+
api.configureChart({ bucket: null, reduce: 'max' })
|
|
401
|
+
await tick()
|
|
402
|
+
expect(api.getChartSpec()!.categories).toHaveLength(4)
|
|
403
|
+
api.setState(state as never)
|
|
404
|
+
await tick()
|
|
405
|
+
expect(api.getChartSpec()!.categories).toEqual(['2026-01-01', '2026-02-01'])
|
|
406
|
+
} finally {
|
|
407
|
+
destroy()
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('serializes chart data to CSV and shows an Export menu (PNG/SVG/PDF/CSV/Copy/Print/Describe)', async () => {
|
|
325
412
|
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
326
413
|
try {
|
|
327
414
|
api.configureChart({ dimension: 'team', series: 'name', measure: 'salary' })
|
|
@@ -336,7 +423,7 @@ describe('SvGrid built-in charting', () => {
|
|
|
336
423
|
btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
337
424
|
await tick()
|
|
338
425
|
const items = [...target.querySelectorAll('.sv-grid-chart-export-menu button')].map((b) => b.textContent)
|
|
339
|
-
expect(items).toEqual(['PNG image', 'SVG vector', 'CSV data', 'Copy to clipboard'])
|
|
426
|
+
expect(items).toEqual(['PNG image', 'SVG vector', 'PDF document', 'CSV data', 'Copy to clipboard', 'Print', 'Describe chart'])
|
|
340
427
|
} finally {
|
|
341
428
|
destroy()
|
|
342
429
|
}
|
|
@@ -434,6 +521,37 @@ describe('SvGrid built-in charting', () => {
|
|
|
434
521
|
}
|
|
435
522
|
})
|
|
436
523
|
|
|
524
|
+
it('applies valueFormat, logScale and timeAxis to the row-reading types too', async () => {
|
|
525
|
+
// These three return a spec built directly from the rows, on a path that
|
|
526
|
+
// used to skip the block applying these settings. So `valueFormat` on a
|
|
527
|
+
// gauge silently did nothing while the same setting on a bar chart worked,
|
|
528
|
+
// which reads as the setting being broken rather than type-specific.
|
|
529
|
+
const { api, destroy } = await mountGrid({ charting: true })
|
|
530
|
+
try {
|
|
531
|
+
api.configureChart({
|
|
532
|
+
type: 'gauge', measure: 'salary', reduce: 'avg', valueFormat: 'currency',
|
|
533
|
+
})
|
|
534
|
+
await tick()
|
|
535
|
+
expect(api.getChartSpec()!.valueFormat).toBe('currency')
|
|
536
|
+
|
|
537
|
+
api.configureChart({
|
|
538
|
+
type: 'boxplot', dimension: 'team', measure: 'salary', logScale: true,
|
|
539
|
+
})
|
|
540
|
+
await tick()
|
|
541
|
+
expect(api.getChartSpec()!.yScale).toBe('log')
|
|
542
|
+
|
|
543
|
+
// And the aggregated path still gets them, which is the half that
|
|
544
|
+
// always worked.
|
|
545
|
+
api.configureChart({
|
|
546
|
+
type: 'bar', dimension: 'team', measure: 'salary', valueFormat: 'currency',
|
|
547
|
+
})
|
|
548
|
+
await tick()
|
|
549
|
+
expect(api.getChartSpec()!.valueFormat).toBe('currency')
|
|
550
|
+
} finally {
|
|
551
|
+
destroy()
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
|
|
437
555
|
it('builds the row-reading types through the panel: scatter, gauge, box plot', async () => {
|
|
438
556
|
// These three do not go through `rowsToChartSpec`, so their dispatch is the
|
|
439
557
|
// one bit of chart wiring a bar-chart test never touches. It lives in the
|
|
@@ -585,6 +703,398 @@ describe('SvGrid built-in charting', () => {
|
|
|
585
703
|
}
|
|
586
704
|
})
|
|
587
705
|
|
|
706
|
+
|
|
707
|
+
it('passes zoom, presets, sync and menu config through to the chart, and the window survives tabs and state', async () => {
|
|
708
|
+
const dateColumns = [
|
|
709
|
+
{ field: 'day', header: 'Day', width: 120, cellDataType: 'date' },
|
|
710
|
+
{ field: 'signups', header: 'Signups', width: 100, cellDataType: 'number' },
|
|
711
|
+
]
|
|
712
|
+
const dateData = Array.from({ length: 40 }, (_, i) => ({
|
|
713
|
+
id: i + 1,
|
|
714
|
+
day: new Date(Date.UTC(2026, 0, 1 + i)).toISOString().slice(0, 10),
|
|
715
|
+
signups: 10 + (i % 7),
|
|
716
|
+
}))
|
|
717
|
+
const { api, target, destroy } = await mountGrid({
|
|
718
|
+
data: dateData,
|
|
719
|
+
columns: dateColumns,
|
|
720
|
+
charting: {
|
|
721
|
+
defaultOpen: true, dimension: 'day', measures: 'signups', defaultType: 'line', timeAxis: true,
|
|
722
|
+
zoom: { wheel: true }, rangePresets: true, syncTabs: true, contextMenu: [{ label: 'Explain', onSelect: () => {} }],
|
|
723
|
+
},
|
|
724
|
+
})
|
|
725
|
+
try {
|
|
726
|
+
await tick()
|
|
727
|
+
const hits = () => target.querySelectorAll('.sv-grid-chart-cat-hit').length
|
|
728
|
+
expect(hits()).toBe(40)
|
|
729
|
+
// The presets came through and the pointer-zoom class with them.
|
|
730
|
+
const presets = [...target.querySelectorAll<HTMLButtonElement>('.sv-grid-chart-preset')]
|
|
731
|
+
expect(presets.map((b) => b.textContent)).toContain('1W')
|
|
732
|
+
presets.find((b) => b.textContent === '1W')!.click()
|
|
733
|
+
await tick()
|
|
734
|
+
expect(hits()).toBe(8)
|
|
735
|
+
// The window is on the tab, so it is part of the saved state.
|
|
736
|
+
const state = api.getState() as { charts: Array<{ zoom?: { i0: number; i1: number } | null }> }
|
|
737
|
+
expect(state.charts[0]!.zoom).toEqual({ i0: 32, i1: 39 })
|
|
738
|
+
// A second tab joins the same sync group and opens at that window.
|
|
739
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Add chart"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
740
|
+
await tick()
|
|
741
|
+
await tick()
|
|
742
|
+
expect(target.querySelectorAll('.sv-grid-chart-tab.is-active').length).toBe(1)
|
|
743
|
+
expect(hits()).toBe(8)
|
|
744
|
+
// Restoring the saved state restores the window; a bad one is dropped.
|
|
745
|
+
api.setState({ ...state, charts: [{ ...state.charts[0], zoom: { i0: 'x' } }] } as never)
|
|
746
|
+
await tick()
|
|
747
|
+
expect(hits()).toBe(40)
|
|
748
|
+
api.setState(state as never)
|
|
749
|
+
await tick()
|
|
750
|
+
expect(hits()).toBe(8)
|
|
751
|
+
} finally {
|
|
752
|
+
destroy()
|
|
753
|
+
}
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
it('the builder opens from the panel with a live thumbnail per type, and a card switches the chart', { timeout: 20_000 }, async () => {
|
|
758
|
+
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
759
|
+
try {
|
|
760
|
+
await tick()
|
|
761
|
+
const build = target.querySelector<HTMLButtonElement>('.sv-grid-chart-build-btn')
|
|
762
|
+
expect(build).toBeTruthy()
|
|
763
|
+
build!.click()
|
|
764
|
+
// The builder is a lazy chunk; wait for the modal.
|
|
765
|
+
await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-builder')).toBeTruthy() })
|
|
766
|
+
await tick()
|
|
767
|
+
const cards = document.querySelectorAll('.sv-grid-chart-builder-card')
|
|
768
|
+
expect(cards.length).toBeGreaterThan(20)
|
|
769
|
+
// Every card holds a rendered chart svg, not an empty frame.
|
|
770
|
+
expect(document.querySelectorAll('.sv-grid-chart-builder-card .sv-grid-chart-svg').length).toBe(cards.length)
|
|
771
|
+
const line = [...cards].find((c) => c.querySelector('.sv-grid-chart-builder-card-label')?.textContent === 'Line') as HTMLButtonElement
|
|
772
|
+
line.click()
|
|
773
|
+
await tick()
|
|
774
|
+
expect(api.getChartSpec()?.type).toBe('line')
|
|
775
|
+
expect(line.getAttribute('aria-pressed')).toBe('true')
|
|
776
|
+
// Escape closes the modal.
|
|
777
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
778
|
+
await tick()
|
|
779
|
+
} finally {
|
|
780
|
+
document.querySelector('.sv-modal__backdrop')?.remove()
|
|
781
|
+
destroy()
|
|
782
|
+
}
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
it('the Format tab writes a title through the format state and it survives a data change', async () => {
|
|
786
|
+
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
787
|
+
try {
|
|
788
|
+
await tick()
|
|
789
|
+
api.configureChart({ format: { title: 'Head count', yAxis: { max: 50 }, legend: 'right' } })
|
|
790
|
+
await tick()
|
|
791
|
+
expect(api.getChartSpec()?.title).toBe('Head count')
|
|
792
|
+
expect(api.getChartSpec()?.yAxis?.max).toBe(50)
|
|
793
|
+
expect(target.querySelector('.sv-grid-chart.is-legend-right')).toBeTruthy()
|
|
794
|
+
// The format is part of the saved view and keeps applying to new rows.
|
|
795
|
+
const state = api.getState() as { charts?: Array<{ format?: { title?: string } }> }
|
|
796
|
+
expect(state.charts?.[0]?.format?.title).toBe('Head count')
|
|
797
|
+
api.addRow({ id: 99, name: 'Zed', team: 'Ops', age: 30, salary: 10 } as Person)
|
|
798
|
+
await tick()
|
|
799
|
+
expect(api.getChartSpec()?.title).toBe('Head count')
|
|
800
|
+
api.configureChart({ format: null })
|
|
801
|
+
await tick()
|
|
802
|
+
expect(api.getChartSpec()?.title).toBeUndefined()
|
|
803
|
+
} finally {
|
|
804
|
+
destroy()
|
|
805
|
+
}
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it('the Format tab writes series labels, crosshair pills, a compact rule, a stack group and a style through configureChart', { timeout: 20_000 }, async () => {
|
|
809
|
+
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
810
|
+
try {
|
|
811
|
+
await tick()
|
|
812
|
+
api.configureChart({ series: 'team', format: { seriesLabels: true, crosshairLabels: false, compactBelow: 300, series: { Research: { stack: 'g' } }, style: { fontSize: 14 } } })
|
|
813
|
+
await tick()
|
|
814
|
+
const spec = api.getChartSpec()!
|
|
815
|
+
expect(spec.seriesLabels).toBe(true)
|
|
816
|
+
expect(spec.responsive?.at(-1)).toMatchObject({ maxWidth: 300, legend: false })
|
|
817
|
+
expect(spec.series.find((s) => s.label === 'Research')?.stack).toBe('g')
|
|
818
|
+
expect(spec.style).toEqual({ fontSize: 14 })
|
|
819
|
+
// The style lands on the host and the crosshair pills are off.
|
|
820
|
+
const host = target.querySelector<HTMLElement>('.sv-grid-chart')!
|
|
821
|
+
expect(host.getAttribute('style')).toContain('--sg-chart-font-scale')
|
|
822
|
+
;(target.querySelector('.sv-grid-chart-cat-hit') as SVGElement).dispatchEvent(new FocusEvent('focus'))
|
|
823
|
+
await tick()
|
|
824
|
+
expect(target.querySelector('.sv-grid-chart-crosshair')).toBeTruthy()
|
|
825
|
+
expect(target.querySelector('.sv-grid-chart-crosshair-label')).toBeNull()
|
|
826
|
+
api.configureChart({ format: { crosshairLabels: true } })
|
|
827
|
+
await tick()
|
|
828
|
+
;(target.querySelector('.sv-grid-chart-cat-hit') as SVGElement).dispatchEvent(new FocusEvent('focus'))
|
|
829
|
+
await tick()
|
|
830
|
+
expect(target.querySelector('.sv-grid-chart-crosshair-label')).toBeTruthy()
|
|
831
|
+
api.configureChart({ format: { seriesLabels: true, crosshairLabels: false, compactBelow: 300, series: { Research: { stack: 'g' } }, style: { fontSize: 14 } } })
|
|
832
|
+
await tick()
|
|
833
|
+
// The builder's Format tab shows the same fields.
|
|
834
|
+
target.querySelector<HTMLButtonElement>('.sv-grid-chart-build-btn')!.click()
|
|
835
|
+
await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-builder')).toBeTruthy() })
|
|
836
|
+
const formatTab = [...document.querySelectorAll<HTMLButtonElement>('.sv-grid-chart-builder [role="tab"]')].find((b) => b.textContent?.trim() === 'Format')!
|
|
837
|
+
formatTab.click()
|
|
838
|
+
await tick()
|
|
839
|
+
const rows = [...document.querySelectorAll('.sv-grid-chart-builder-row')].map((r) => r.querySelector('span')?.textContent)
|
|
840
|
+
expect(rows).toEqual(expect.arrayContaining(['Series labels', 'Crosshair labels', 'Compact under', 'Font size']))
|
|
841
|
+
const stackInput = document.querySelector<HTMLInputElement>('.sv-grid-chart-builder-series[data-series="Research"] input[type="text"]')
|
|
842
|
+
expect(stackInput?.value).toBe('g')
|
|
843
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
844
|
+
await tick()
|
|
845
|
+
} finally {
|
|
846
|
+
document.querySelector('.sv-modal__backdrop')?.remove()
|
|
847
|
+
destroy()
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('the builder Data tab renders the panel pickers and both write the same tab', { timeout: 20_000 }, async () => {
|
|
852
|
+
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
853
|
+
try {
|
|
854
|
+
await tick()
|
|
855
|
+
api.configureChart({ dimension: 'team', measure: 'salary' })
|
|
856
|
+
await tick()
|
|
857
|
+
const research = () => { const s = api.getChartSpec()!; return s.series[0]!.values[s.categories.indexOf('Research')] }
|
|
858
|
+
expect(research()).toBe(360)
|
|
859
|
+
target.querySelector<HTMLButtonElement>('.sv-grid-chart-build-btn')!.click()
|
|
860
|
+
await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-builder')).toBeTruthy() })
|
|
861
|
+
const dataTab = [...document.querySelectorAll<HTMLButtonElement>('.sv-grid-chart-builder [role="tab"]')].find((b) => b.textContent?.trim() === 'Data')!
|
|
862
|
+
dataTab.click()
|
|
863
|
+
await tick()
|
|
864
|
+
const form = document.querySelector('.sv-grid-chart-controls[data-scope="builder"]')!
|
|
865
|
+
expect(form).toBeTruthy()
|
|
866
|
+
expect(form.classList.contains('is-form')).toBe(true)
|
|
867
|
+
const lbl = (root: Element, label: string) => [...root.querySelectorAll('.sv-grid-chart-ctl')].find((c) => c.querySelector('.sv-grid-chart-ctl-lbl')?.textContent === label)
|
|
868
|
+
// No Type select in the builder (the gallery picks it), the rest is there.
|
|
869
|
+
expect(lbl(form, 'Type')).toBeUndefined()
|
|
870
|
+
expect(lbl(form, 'Group by')).toBeTruthy()
|
|
871
|
+
const reduce = lbl(form, 'Aggregate')!.querySelector('select')!
|
|
872
|
+
reduce.value = 'avg'
|
|
873
|
+
reduce.dispatchEvent(new Event('change', { bubbles: true }))
|
|
874
|
+
await tick()
|
|
875
|
+
expect(research()).toBe(120) // the average of 120 / 130 / 110
|
|
876
|
+
const panel = target.querySelector('.sv-grid-chart-controls[data-scope="panel"]')!
|
|
877
|
+
expect(lbl(panel, 'Aggregate')!.querySelector('select')!.value).toBe('avg')
|
|
878
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
879
|
+
await tick()
|
|
880
|
+
} finally {
|
|
881
|
+
document.querySelector('.sv-modal__backdrop')?.remove()
|
|
882
|
+
destroy()
|
|
883
|
+
}
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it('localization.text relabels the panel and the builder; unset keys stay English', { timeout: 20_000 }, async () => {
|
|
887
|
+
const { target, destroy } = await mountGrid({
|
|
888
|
+
charting: { defaultOpen: true },
|
|
889
|
+
localization: { text: { chartPanelTitle: 'Diagramm', chartGroupBy: 'Gruppieren nach', chartTypeBar: 'Balken', chartAdd: 'Diagramm hinzufügen', chartBuilderTitle: 'Diagramm-Editor', chartBuilderTabData: 'Daten', noRows: 'Keine Zeilen' } },
|
|
890
|
+
})
|
|
891
|
+
try {
|
|
892
|
+
await tick()
|
|
893
|
+
expect(target.querySelector('.sv-grid-chart-title')!.textContent).toBe('Diagramm')
|
|
894
|
+
expect(target.querySelector('[aria-label="Diagramm hinzufügen"]')).toBeTruthy()
|
|
895
|
+
const labels = [...target.querySelectorAll('.sv-grid-chart-ctl-lbl')].map((l) => l.textContent)
|
|
896
|
+
expect(labels).toContain('Gruppieren nach')
|
|
897
|
+
expect(labels).toContain('Aggregate')
|
|
898
|
+
const typeSel = target.querySelector<HTMLSelectElement>('.sv-grid-chart-controls select')!
|
|
899
|
+
const options = [...typeSel.options].map((o) => o.textContent)
|
|
900
|
+
expect(options).toContain('Balken')
|
|
901
|
+
expect(options).toContain('Line')
|
|
902
|
+
target.querySelector<HTMLButtonElement>('.sv-grid-chart-build-btn')!.click()
|
|
903
|
+
await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-builder')).toBeTruthy() })
|
|
904
|
+
const tabs = [...document.querySelectorAll('.sv-grid-chart-builder [role="tab"]')].map((b) => b.textContent?.trim())
|
|
905
|
+
expect(tabs).toEqual(['Type', 'Daten', 'Format'])
|
|
906
|
+
expect(document.querySelector('.sv-grid-chart-builder-card-label')!.textContent).toBe('Balken')
|
|
907
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
908
|
+
await tick()
|
|
909
|
+
} finally {
|
|
910
|
+
document.querySelector('.sv-modal__backdrop')?.remove()
|
|
911
|
+
destroy()
|
|
912
|
+
}
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('saves a chart under a name, applies it to another tab, and round-trips it through the view state and the popover', async () => {
|
|
916
|
+
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
917
|
+
try {
|
|
918
|
+
await tick()
|
|
919
|
+
api.configureChart({ type: 'line', reduce: 'avg', logScale: true, format: { title: 'Average pay' } })
|
|
920
|
+
api.saveChart('Avg pay')
|
|
921
|
+
expect(api.getSavedCharts().map((s) => s.name)).toEqual(['Avg pay'])
|
|
922
|
+
// A second tab starts as a bar; the saved chart makes it the line, title kept.
|
|
923
|
+
api.configureChart({ type: 'bar', reduce: 'sum', logScale: false, format: null })
|
|
924
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Add chart"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
925
|
+
await tick()
|
|
926
|
+
expect(api.getChartSpec()!.type).toBe('bar')
|
|
927
|
+
expect(api.applySavedChart('Avg pay')).toBe(true)
|
|
928
|
+
expect(api.applySavedChart('nope')).toBe(false)
|
|
929
|
+
await tick()
|
|
930
|
+
expect(api.getChartSpec()!.type).toBe('line')
|
|
931
|
+
expect(api.getChartSpec()!.title).toBe('Average pay')
|
|
932
|
+
expect(api.getChartSpec()!.yScale).toBe('log')
|
|
933
|
+
expect(target.querySelector('.sv-grid-chart-tab.is-active .sv-grid-chart-tab-label')!.textContent).toBe('Chart 2')
|
|
934
|
+
// The saved list travels with the state, and only when there is one.
|
|
935
|
+
const state = api.getState() as { savedCharts?: Array<{ name: string; tab: { type: string } }> }
|
|
936
|
+
expect(state.savedCharts?.[0]).toMatchObject({ name: 'Avg pay', tab: { type: 'line', reduce: 'avg' } })
|
|
937
|
+
api.removeSavedChart('Avg pay')
|
|
938
|
+
expect((api.getState() as { savedCharts?: unknown }).savedCharts).toBeUndefined()
|
|
939
|
+
api.setState({ ...state } as never)
|
|
940
|
+
await tick()
|
|
941
|
+
expect(api.getSavedCharts().map((s) => s.name)).toEqual(['Avg pay'])
|
|
942
|
+
// configureChart({ saved }) applies it first, then the other keys on top.
|
|
943
|
+
api.configureChart({ saved: 'Avg pay', type: 'area' })
|
|
944
|
+
await tick()
|
|
945
|
+
expect(api.getChartSpec()!.type).toBe('area')
|
|
946
|
+
expect(api.getChartSpec()!.title).toBe('Average pay')
|
|
947
|
+
// The popover: lists, saves, applies and removes.
|
|
948
|
+
target.querySelector<HTMLButtonElement>('.sv-grid-chart-saved-btn')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
949
|
+
await tick()
|
|
950
|
+
expect([...target.querySelectorAll('.sv-grid-chart-saved-apply')].map((b) => b.textContent)).toEqual(['Avg pay'])
|
|
951
|
+
const name = target.querySelector<HTMLInputElement>('.sv-grid-chart-saved-name')!
|
|
952
|
+
name.value = 'Area'
|
|
953
|
+
name.dispatchEvent(new Event('input', { bubbles: true }))
|
|
954
|
+
await tick()
|
|
955
|
+
target.querySelector<HTMLFormElement>('.sv-grid-chart-saved-row')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
956
|
+
await tick()
|
|
957
|
+
expect(api.getSavedCharts().map((s) => s.name)).toEqual(['Avg pay', 'Area'])
|
|
958
|
+
target.querySelector<HTMLButtonElement>('.sv-grid-chart-saved-x')!.click()
|
|
959
|
+
await tick()
|
|
960
|
+
expect(api.getSavedCharts().map((s) => s.name)).toEqual(['Area'])
|
|
961
|
+
target.querySelector<HTMLButtonElement>('.sv-grid-chart-saved-apply')!.click()
|
|
962
|
+
await tick()
|
|
963
|
+
expect(target.querySelector('.sv-grid-chart-saved-menu')).toBeNull()
|
|
964
|
+
expect(api.getChartSpec()!.type).toBe('area')
|
|
965
|
+
// Escape closes the popover and hands focus back to its button; the
|
|
966
|
+
// export menu closes the same way.
|
|
967
|
+
const savedBtn = target.querySelector<HTMLButtonElement>('.sv-grid-chart-saved-btn')!
|
|
968
|
+
savedBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
969
|
+
await tick()
|
|
970
|
+
expect(target.querySelector('.sv-grid-chart-saved-menu')).not.toBeNull()
|
|
971
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
972
|
+
await tick()
|
|
973
|
+
expect(target.querySelector('.sv-grid-chart-saved-menu')).toBeNull()
|
|
974
|
+
expect(document.activeElement).toBe(savedBtn)
|
|
975
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Export chart"]')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
976
|
+
await tick()
|
|
977
|
+
expect(target.querySelector('.sv-grid-chart-export-menu')).not.toBeNull()
|
|
978
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
979
|
+
await tick()
|
|
980
|
+
expect(target.querySelector('.sv-grid-chart-export-menu')).toBeNull()
|
|
981
|
+
} finally {
|
|
982
|
+
destroy()
|
|
983
|
+
}
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('an unlinked chart keeps its spec across a data change and relinks on demand', async () => {
|
|
987
|
+
const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
|
|
988
|
+
try {
|
|
989
|
+
await tick()
|
|
990
|
+
const before = api.getChartSpec()!
|
|
991
|
+
const link = target.querySelector<HTMLButtonElement>('.sv-grid-chart-link-btn')!
|
|
992
|
+
expect(link.getAttribute('aria-pressed')).toBe('false')
|
|
993
|
+
link.click()
|
|
994
|
+
await tick()
|
|
995
|
+
expect(link.getAttribute('aria-pressed')).toBe('true')
|
|
996
|
+
api.setFacetFilter('team', [people[0]!.team])
|
|
997
|
+
await tick()
|
|
998
|
+
expect(api.getChartSpec()!.categories).toEqual(before.categories)
|
|
999
|
+
// The freeze round-trips through the saved state.
|
|
1000
|
+
const state = api.getState() as { charts?: Array<{ frozen?: { spec: unknown } | null }> }
|
|
1001
|
+
expect(state.charts?.[0]?.frozen?.spec).toBeTruthy()
|
|
1002
|
+
link.click()
|
|
1003
|
+
await tick()
|
|
1004
|
+
expect(api.getChartSpec()!.categories.length).toBeLessThan(before.categories.length)
|
|
1005
|
+
} finally {
|
|
1006
|
+
destroy()
|
|
1007
|
+
}
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
it('fires onChartCreated for the first chart and each added tab, and onChartChanged once per burst', async () => {
|
|
1011
|
+
vi.useFakeTimers()
|
|
1012
|
+
const created: unknown[] = []
|
|
1013
|
+
const changed: unknown[] = []
|
|
1014
|
+
try {
|
|
1015
|
+
const { api, target, destroy } = await mountGrid({
|
|
1016
|
+
charting: { defaultOpen: true, onChartCreated: (i: unknown) => created.push(i), onChartChanged: (i: unknown) => changed.push(i) },
|
|
1017
|
+
})
|
|
1018
|
+
try {
|
|
1019
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
1020
|
+
expect(created).toHaveLength(1)
|
|
1021
|
+
expect(created[0]).toMatchObject({ index: 0, title: 'Chart 1', type: 'bar' })
|
|
1022
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
1023
|
+
const n = changed.length
|
|
1024
|
+
expect(n).toBeGreaterThanOrEqual(1)
|
|
1025
|
+
// Three quick picker changes collapse into one change event.
|
|
1026
|
+
api.configureChart({ type: 'line' })
|
|
1027
|
+
api.configureChart({ type: 'area' })
|
|
1028
|
+
api.configureChart({ reduce: 'avg' })
|
|
1029
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
1030
|
+
expect(changed).toHaveLength(n)
|
|
1031
|
+
await vi.advanceTimersByTimeAsync(200)
|
|
1032
|
+
expect(changed).toHaveLength(n + 1)
|
|
1033
|
+
expect(changed[n]).toMatchObject({ index: 0, type: 'area' })
|
|
1034
|
+
expect((changed[n] as { spec: { type: string } }).spec.type).toBe('area')
|
|
1035
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Add chart"]')!.click()
|
|
1036
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
1037
|
+
expect(created).toHaveLength(2)
|
|
1038
|
+
expect(created[1]).toMatchObject({ index: 1, title: 'Chart 2' })
|
|
1039
|
+
} finally {
|
|
1040
|
+
destroy()
|
|
1041
|
+
}
|
|
1042
|
+
} finally {
|
|
1043
|
+
vi.useRealTimers()
|
|
1044
|
+
}
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
it('a candlestick chart from the panel guesses the price columns, rolls up by week, and stacks the indicator panes', async () => {
|
|
1048
|
+
const priceColumns = [
|
|
1049
|
+
{ field: 'day', header: 'Day', width: 120, cellDataType: 'date' },
|
|
1050
|
+
{ field: 'open', header: 'Open', width: 80, cellDataType: 'number' },
|
|
1051
|
+
{ field: 'high', header: 'High', width: 80, cellDataType: 'number' },
|
|
1052
|
+
{ field: 'low', header: 'Low', width: 80, cellDataType: 'number' },
|
|
1053
|
+
{ field: 'close', header: 'Close', width: 80, cellDataType: 'number' },
|
|
1054
|
+
{ field: 'volume', header: 'Volume', width: 80, cellDataType: 'number' },
|
|
1055
|
+
]
|
|
1056
|
+
const priceData = Array.from({ length: 30 }, (_, i) => {
|
|
1057
|
+
const c = 100 + Math.sin(i / 4) * 10 + i
|
|
1058
|
+
return { id: i + 1, day: new Date(Date.UTC(2026, 0, 5 + i)).toISOString().slice(0, 10), open: c - 1, high: c + 2, low: c - 3, close: c, volume: 1000 + i * 10 }
|
|
1059
|
+
})
|
|
1060
|
+
const { api, target, destroy } = await mountGrid({
|
|
1061
|
+
data: priceData, columns: priceColumns,
|
|
1062
|
+
charting: { defaultOpen: true, defaultType: 'candlestick' },
|
|
1063
|
+
})
|
|
1064
|
+
try {
|
|
1065
|
+
await tick()
|
|
1066
|
+
const spec = api.getChartSpec()!
|
|
1067
|
+
expect(spec.type).toBe('candlestick')
|
|
1068
|
+
expect(spec.series[0]!.ohlc).toHaveLength(30)
|
|
1069
|
+
expect(spec.series[0]!.volumes).toHaveLength(30)
|
|
1070
|
+
expect(spec.lastPriceLine).toBe(true)
|
|
1071
|
+
// The pickers show the guessed columns and the indicator chips.
|
|
1072
|
+
const labels = [...target.querySelectorAll('.sv-grid-chart-ctl-lbl')].map((l) => l.textContent)
|
|
1073
|
+
expect(labels).toEqual(expect.arrayContaining(['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Indicators']))
|
|
1074
|
+
expect(labels).not.toContain('Aggregate')
|
|
1075
|
+
const chip = (name: string) => [...target.querySelectorAll<HTMLButtonElement>('.sv-grid-chart-chip')].find((b) => b.textContent === name)!
|
|
1076
|
+
chip('Volume').click()
|
|
1077
|
+
chip('RSI').click()
|
|
1078
|
+
chip('Bollinger').click()
|
|
1079
|
+
await tick()
|
|
1080
|
+
expect(target.querySelectorAll('.sv-chart-pane.is-indicator')).toHaveLength(2)
|
|
1081
|
+
expect(api.getChartSpec()!.series[0]!.overlay).toBe('bb:20:2')
|
|
1082
|
+
// Weekly roll-up through the bucket picker.
|
|
1083
|
+
api.configureChart({ bucket: 'week' })
|
|
1084
|
+
await tick()
|
|
1085
|
+
expect(api.getChartSpec()!.categories.length).toBeLessThan(10)
|
|
1086
|
+
// It all round-trips.
|
|
1087
|
+
const state = api.getState() as { charts?: Array<{ indicators?: string[]; ohlc?: unknown }> }
|
|
1088
|
+
expect(state.charts?.[0]?.indicators).toEqual(['volume', 'rsi', 'bb'])
|
|
1089
|
+
api.configureChart({ indicators: [], ohlc: { close: 'open' }, bucket: null })
|
|
1090
|
+
await tick()
|
|
1091
|
+
expect(target.querySelectorAll('.sv-chart-pane.is-indicator')).toHaveLength(0)
|
|
1092
|
+
expect(api.getChartSpec()!.series[0]!.ohlc![0]!.c).toBe(api.getChartSpec()!.series[0]!.ohlc![0]!.o)
|
|
1093
|
+
} finally {
|
|
1094
|
+
destroy()
|
|
1095
|
+
}
|
|
1096
|
+
})
|
|
1097
|
+
|
|
588
1098
|
it('round-trips the chart config through getState / setState', async () => {
|
|
589
1099
|
const a = await mountGrid({ charting: true })
|
|
590
1100
|
try {
|
|
@@ -94,6 +94,37 @@ async function openContextMenu(target: HTMLElement) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
describe('SvGrid context menu', () => {
|
|
97
|
+
it('pulls a menu taller than the guessed 280px back inside the viewport', async () => {
|
|
98
|
+
// Thirteen entries, the spreadsheet shell's menu: 456px tall in a browser.
|
|
99
|
+
const items: ContextMenuItem<Row>[] = Array.from({ length: 13 }, (_, i) => ({ key: `k${i}`, label: `Entry ${i}`, action: () => {} }))
|
|
100
|
+
const { target, destroy } = await mountGrid(items)
|
|
101
|
+
await tick()
|
|
102
|
+
const proto = HTMLElement.prototype.getBoundingClientRect
|
|
103
|
+
const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
104
|
+
const box = proto.call(this)
|
|
105
|
+
if (!this.classList.contains('sv-grid-context-menu')) return box
|
|
106
|
+
return { ...box, width: 200, height: 456, right: box.left + 200, bottom: box.top + 456, toJSON: () => ({}) } as DOMRect
|
|
107
|
+
})
|
|
108
|
+
const innerHeight = window.innerHeight
|
|
109
|
+
Object.defineProperty(window, 'innerHeight', { value: 900, configurable: true })
|
|
110
|
+
try {
|
|
111
|
+
const cell = target.querySelector('.sv-grid-cell[data-svgrid-row]') as HTMLElement
|
|
112
|
+
// A click at y=600: the 280px guess fits (600 + 280 < 900), so no flip,
|
|
113
|
+
// but the real 456px would run to 1056.
|
|
114
|
+
cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 100, clientY: 600 }))
|
|
115
|
+
await vi.waitFor(() => {
|
|
116
|
+
const menu = target.querySelector('.sv-grid-context-menu') as HTMLElement
|
|
117
|
+
expect(menu).not.toBeNull()
|
|
118
|
+
expect(parseFloat(menu.style.top)).toBe(900 - 456 - 8)
|
|
119
|
+
expect(parseFloat(menu.style.left)).toBe(100)
|
|
120
|
+
})
|
|
121
|
+
} finally {
|
|
122
|
+
spy.mockRestore()
|
|
123
|
+
Object.defineProperty(window, 'innerHeight', { value: innerHeight, configurable: true })
|
|
124
|
+
destroy()
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
97
128
|
it('opens the default menu on right-click with the built-in items', async () => {
|
|
98
129
|
const { target, destroy } = await mountGrid(true)
|
|
99
130
|
await tick()
|
|
@@ -75,7 +75,9 @@ describe('SvGrid wrapper - cellClass + rowClass', () => {
|
|
|
75
75
|
|
|
76
76
|
it('computes the cell-level class per column and threads it onto the <td>', () => {
|
|
77
77
|
expect(source).toMatch(/function computeCellClass/)
|
|
78
|
-
|
|
78
|
+
// The td that draws a merge shows the origin cell, so the class comes
|
|
79
|
+
// from the cell that draws (cellRow / cellColumn): the row's own outside a merge.
|
|
80
|
+
expect(source).toMatch(/userCellClass = computeCellClass\(\s*cellRow,\s*cellColumn,?\s*\)/)
|
|
79
81
|
expect(source).toMatch(/class=\{`sv-grid-cell \$\{userCellClass\}`\}/)
|
|
80
82
|
})
|
|
81
83
|
})
|
|
@@ -93,9 +95,14 @@ describe('SvGrid wrapper - declarative cell validation (validate hook)', () => {
|
|
|
93
95
|
expect(source).toMatch(/if \(out == null \|\| out === true\)/)
|
|
94
96
|
})
|
|
95
97
|
|
|
96
|
-
it('threads the invalid class + message tooltip onto
|
|
98
|
+
it('threads the invalid class + message tooltip onto the one body-row snippet every path renders', () => {
|
|
99
|
+
// The virtualized window, the plain body and the frozen rows all render
|
|
100
|
+
// `bodyRow`, so the class appears exactly once: the two copies the
|
|
101
|
+
// render paths used to carry had drifted apart.
|
|
97
102
|
const hits = source.match(/class:sv-grid-cell-invalid=\{cellValidity\.invalid\}/g) ?? []
|
|
98
|
-
expect(hits.length).
|
|
103
|
+
expect(hits.length).toBe(1)
|
|
104
|
+
expect(source).toMatch(/\{#snippet bodyRow\(/)
|
|
105
|
+
expect((source.match(/\{@render bodyRow\(/g) ?? []).length).toBe(3)
|
|
99
106
|
// Message wins over the plain column tooltip when the cell is invalid.
|
|
100
107
|
expect(source).toMatch(/cellValidity\.invalid && cellValidity\.message/)
|
|
101
108
|
})
|