@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.
Files changed (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-Bugf5XlI.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
  273. package/dist/cdn/GridMenus-DhdPF700.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
  276. package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
  277. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
  279. package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
  280. package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
  281. 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('serializes chart data to CSV and shows an Export menu (PNG/SVG/CSV/Copy)', async () => {
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
- expect(source).toMatch(/userCellClass = computeCellClass\(\s*row,\s*rendered\.column,?\s*\)/)
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 both <td> render paths', () => {
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).toBeGreaterThanOrEqual(2)
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
  })