@svgrid/grid 3.0.2 → 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-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.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-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
  import { mount, unmount, createRawSnippet, flushSync } from 'svelte'
3
3
  import SvGridChart from './SvGridChart.svelte'
4
4
  import type { ChartSpec } from './chart'
@@ -10,6 +10,9 @@ function render(spec: ChartSpec, extra: Record<string, unknown> = {}) {
10
10
  target = document.createElement('div')
11
11
  document.body.appendChild(target)
12
12
  app = mount(SvGridChart, { target, props: { spec, ...extra } as any })
13
+ // bind:this lands in an effect, so a handler that needs the element (the
14
+ // tooltip reads the wrapper's box) must see one flush first.
15
+ flushSync()
13
16
  return target
14
17
  }
15
18
 
@@ -150,6 +153,216 @@ describe('dense charts (more categories than pixels)', () => {
150
153
  expect(el.querySelector('.sv-grid-chart-sr-only caption')!.textContent).toContain('first 1000 of 5000')
151
154
  })
152
155
 
156
+ it('a waterfall tooltip and table read the drawn totals, not the zeros in the data', () => {
157
+ const el = render({
158
+ type: 'waterfall',
159
+ categories: ['Revenue', 'Cost', 'Gross'],
160
+ series: [{ label: 'FY', values: [4300, -1840, 0] }],
161
+ waterfallTotals: [true, false, true],
162
+ })
163
+ ;(el.querySelectorAll('.sv-grid-chart-cat-hit')[2] as SVGElement).dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
164
+ flushSync()
165
+ const rows = [...el.querySelectorAll('.sv-grid-chart-tooltip-row')].map((r) => r.textContent!.replace(/\s+/g, ' ').trim())
166
+ expect(rows).toHaveLength(1)
167
+ expect(rows[0]).toContain('2,460')
168
+ const cells = [...el.querySelectorAll('.sv-grid-chart-sr-only tbody td')].map((td) => td.textContent)
169
+ expect(cells).toEqual(['Revenue', '4,300', 'Cost', '-1,840', 'Gross', '2,460'])
170
+ })
171
+
172
+ it('a gauge writes a symbol unit against the number and a word unit after a space', () => {
173
+ const el = render({ type: 'gauge', categories: [], series: [], gaugeValue: 99.2, gaugeMin: 99, gaugeMax: 100, gaugeUnit: '%' })
174
+ expect(el.querySelector('.sv-grid-chart-gauge-value')!.textContent).toBe('99.2%')
175
+ unmount(app!); app = null
176
+ const ms = render({ type: 'gauge', categories: [], series: [], gaugeValue: 480, gaugeMin: 0, gaugeMax: 500, gaugeUnit: 'ms' })
177
+ expect(ms.querySelector('.sv-grid-chart-gauge-value')!.textContent).toBe('480 ms')
178
+ })
179
+
180
+ it('a calendar cell names its day in words', () => {
181
+ const el = render({ type: 'calendar', categories: [], series: [], calendarValues: [{ date: '2026-04-07', value: 193 }, { date: '2026-04-08', value: 12 }] })
182
+ const cell = [...el.querySelectorAll('.sv-grid-chart-calendar-cell')].find((c) => c.getAttribute('aria-label')?.startsWith('Apr 7, 2026')) as SVGElement
183
+ expect(cell).toBeTruthy()
184
+ cell.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
185
+ flushSync()
186
+ expect(el.querySelector('.sv-grid-chart-tooltip-title')!.textContent).toBe('Apr 7, 2026')
187
+ })
188
+
189
+ it('a heat map writes its cell values when the spec asks for data labels', () => {
190
+ const spec: ChartSpec = { type: 'heatmap', categories: ['00', '01'], series: [{ label: 'Mon', values: [4, 9] }], dataLabels: { show: true }, width: 400, height: 200 }
191
+ const el = render(spec)
192
+ expect([...el.querySelectorAll('.sv-grid-chart-heatlabel')].map((t) => t.textContent)).toEqual(['4', '9'])
193
+ unmount(app!); app = null
194
+ const off = render({ ...spec, dataLabels: { show: false } })
195
+ expect(off.querySelectorAll('.sv-grid-chart-heatlabel')).toHaveLength(0)
196
+ })
197
+
198
+ it('a series on the right axis reads in that axis format in the tooltip, the table and its labels', () => {
199
+ const el = render(
200
+ {
201
+ type: 'bar',
202
+ categories: ['Jan', 'Feb'],
203
+ series: [
204
+ { label: 'Revenue', values: [2.1, 2.3] },
205
+ { label: 'Margin', values: [0.29, 0.31], type: 'line', axis: 'right' },
206
+ ],
207
+ valueFormat: 'currency',
208
+ y2Axis: { format: 'percent' },
209
+ },
210
+ { dataLabels: true },
211
+ )
212
+ ;(el.querySelectorAll('.sv-grid-chart-cat-hit')[0] as SVGElement).dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
213
+ flushSync()
214
+ const rows = [...el.querySelectorAll('.sv-grid-chart-tooltip-row')].map((r) => r.textContent!.replace(/\s+/g, ' ').trim())
215
+ expect(rows.some((r) => r.includes('$2.1') || r.includes('$2'))).toBe(true)
216
+ expect(rows.some((r) => r.includes('29%'))).toBe(true)
217
+ expect(rows.some((r) => r.includes('$0.29'))).toBe(false)
218
+ const cells = [...el.querySelectorAll('.sv-grid-chart-sr-only tbody tr')][0]!.querySelectorAll('td')
219
+ expect(cells[2]!.textContent).toBe('29%')
220
+ const labels = [...el.querySelectorAll('.sv-grid-chart-datalabel')].map((l) => l.textContent)
221
+ expect(labels).toContain('29%')
222
+ expect(labels).not.toContain('$0.29')
223
+ })
224
+
225
+ it('a scatter point selects on click and on Enter, and the selection dims the rest', () => {
226
+ const changes: unknown[] = []
227
+ const picks: unknown[] = []
228
+ const el = render(
229
+ {
230
+ type: 'scatter',
231
+ categories: [],
232
+ series: [{ label: 'Stores', values: [], points: [{ x: 1, y: 10, label: 'A' }, { x: 2, y: 20, label: 'B' }, { x: 3, y: 30, label: 'C' }] }],
233
+ },
234
+ { selectable: true, onSelectionChange: (s: unknown) => changes.push(s), onSelect: (s: unknown) => picks.push(s) },
235
+ )
236
+ const dots = el.querySelectorAll('.sv-grid-chart-scatter')
237
+ expect(dots[1]!.getAttribute('role')).toBe('button')
238
+ ;(dots[1] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
239
+ flushSync()
240
+ expect(changes).toEqual([[{ category: 'B', series: 'Stores', index: 1 }]])
241
+ expect(picks).toEqual([{ category: 'B', series: 'Stores', value: 20, rowIds: undefined }])
242
+ expect(dots[1]!.classList.contains('is-selected')).toBe(true)
243
+ expect((dots[0] as SVGElement).style.opacity).toBe('0.35')
244
+ ;(dots[2] as SVGElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
245
+ flushSync()
246
+ expect(changes[1]).toEqual([{ category: 'C', series: 'Stores', index: 2 }])
247
+ expect(dots[1]!.classList.contains('is-selected')).toBe(false)
248
+ expect(dots[2]!.classList.contains('is-selected')).toBe(true)
249
+ })
250
+
251
+ it('a drilled chart shows its breadcrumb with the toolbar off', () => {
252
+ const tree = { name: 'All', children: [{ name: 'Blink', children: [{ name: 'Chrome', value: 6 }, { name: 'Edge', value: 2 }] }, { name: 'Gecko', value: 3 }] }
253
+ const el = render({ type: 'sunburst', categories: [], series: [], tree }, { drillable: true, toolbar: false })
254
+ expect(el.querySelector('.sv-grid-chart-toolbar')).toBeNull()
255
+ const blink = [...el.querySelectorAll('.sv-grid-chart-arc')].find((a) => a.getAttribute('aria-label')?.startsWith('Blink')) as SVGElement
256
+ blink.dispatchEvent(new MouseEvent('click', { bubbles: true }))
257
+ flushSync()
258
+ const crumbs = el.querySelector('.sv-grid-chart-crumbs')!
259
+ expect(crumbs).not.toBeNull()
260
+ expect(el.querySelector('.sv-grid-chart-toolbar.is-crumbs-only')).not.toBeNull()
261
+ expect(crumbs.textContent!.replace(/\s+/g, ' ').trim()).toBe('All / Blink')
262
+ ;(crumbs.querySelector('button') as HTMLButtonElement).click()
263
+ flushSync()
264
+ expect(el.querySelector('.sv-grid-chart-toolbar')).toBeNull()
265
+ })
266
+
267
+ it('a double-click isolates a legend chip and a second double-click clears it, leaving nothing hidden', () => {
268
+ const el = render({ type: 'line', categories: ['a', 'b'], series: [{ label: 'x', values: [1, 2] }, { label: 'y', values: [2, 3] }, { label: 'z', values: [3, 4] }] }, { legend: true })
269
+ const chips = () => [...el.querySelectorAll('.sv-grid-chart-legend-item')] as HTMLButtonElement[]
270
+ const dbl = (chip: HTMLElement) => {
271
+ chip.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))
272
+ chip.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 }))
273
+ chip.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, detail: 2 }))
274
+ flushSync()
275
+ }
276
+ const off = () => chips().map((c) => c.classList.contains('is-off'))
277
+ dbl(chips()[1]!)
278
+ expect(chips()[1]!.classList.contains('is-isolated')).toBe(true)
279
+ // Isolation dims the other chips without marking them off: nothing was hidden by hand.
280
+ expect(off()).toEqual([false, false, false])
281
+ expect(el.querySelectorAll('.sv-grid-chart-linepath')).toHaveLength(1)
282
+ dbl(chips()[1]!)
283
+ expect(chips().some((c) => c.classList.contains('is-isolated'))).toBe(false)
284
+ expect(off()).toEqual([false, false, false])
285
+ expect(el.querySelectorAll('.sv-grid-chart-linepath')).toHaveLength(3)
286
+ // Isolating one chip while another is isolated moves the isolation.
287
+ dbl(chips()[0]!)
288
+ dbl(chips()[2]!)
289
+ expect(chips()[2]!.classList.contains('is-isolated')).toBe(true)
290
+ expect(off()).toEqual([false, false, false])
291
+ expect(el.querySelectorAll('.sv-grid-chart-linepath')).toHaveLength(1)
292
+ })
293
+
294
+ it('a range area draws its band at a stronger fill with thin edges', () => {
295
+ const el = render({
296
+ type: 'range-area',
297
+ categories: ['a', 'b', 'c'],
298
+ series: [{ label: 'band', values: [10, 12, 11], lowValues: [4, 5, 3] }],
299
+ })
300
+ expect(el.querySelector('.sv-grid-chart-area')!.getAttribute('fill-opacity')).toBe('0.35')
301
+ expect(el.querySelector('.sv-grid-chart-linepath')!.getAttribute('stroke-width')).toBe('1')
302
+ unmount(app!); app = null
303
+ const plain = render({ type: 'area', categories: ['a', 'b', 'c'], series: [{ label: 's', values: [10, 12, 11] }] })
304
+ expect(plain.querySelector('.sv-grid-chart-area')!.getAttribute('fill-opacity')).toBe('0.15')
305
+ expect(plain.querySelector('.sv-grid-chart-linepath')!.getAttribute('stroke-width')).toBe('2')
306
+ })
307
+
308
+ it('writes a date category out in the tooltip and the crosshair pill', () => {
309
+ const el = render(
310
+ {
311
+ type: 'line',
312
+ xType: 'time',
313
+ categories: ['2025-05-01', '2025-06-01', '2025-07-01'],
314
+ series: [{ label: 'Free', values: [1, 2, 3] }],
315
+ },
316
+ { crosshairLabels: true },
317
+ )
318
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement
319
+ hit.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 10, clientY: 10 }))
320
+ flushSync()
321
+ expect(el.querySelector('.sv-grid-chart-tooltip-title')!.textContent).toBe('Jun 2025')
322
+ expect(el.querySelector('.sv-grid-chart-crosshair-label text')!.textContent).toBe('Jun 2025')
323
+ unmount(app!); app = null
324
+ // Daily data reads the full date; a plain category axis is left alone.
325
+ const daily = render({ type: 'line', xType: 'time', categories: ['2025-06-01', '2025-06-02'], series: [{ label: 's', values: [1, 2] }] })
326
+ ;(daily.querySelectorAll('.sv-grid-chart-cat-hit')[0] as SVGElement).dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
327
+ flushSync()
328
+ expect(daily.querySelector('.sv-grid-chart-tooltip-title')!.textContent).toBe('Jun 1, 2025')
329
+ unmount(app!); app = null
330
+ const plain = render({ type: 'line', categories: ['2025-06-01', '2025-06-02'], series: [{ label: 's', values: [1, 2] }] })
331
+ ;(plain.querySelectorAll('.sv-grid-chart-cat-hit')[0] as SVGElement).dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
332
+ flushSync()
333
+ expect(plain.querySelector('.sv-grid-chart-tooltip-title')!.textContent).toBe('2025-06-01')
334
+ })
335
+
336
+ it('reads a gap as an empty cell, never as "NaN" or as 0', () => {
337
+ const el = render({
338
+ type: 'line',
339
+ categories: ['Jan', 'Feb', 'Mar'],
340
+ series: [
341
+ { label: 'Actual', values: [10, Number.NaN, 12] },
342
+ { label: 'Forecast', values: [null as unknown as number, 11, Number.NaN] },
343
+ ],
344
+ })
345
+ const cells = [...el.querySelectorAll('.sv-grid-chart-sr-only tbody td')].map((td) => td.textContent)
346
+ expect(cells).toEqual(['Jan', '10', '', 'Feb', '', '11', 'Mar', '12', ''])
347
+ expect(el.querySelector('.sv-grid-chart-sr-only')!.textContent).not.toContain('NaN')
348
+ })
349
+
350
+ it('hides the screen-reader table behind a clipped wrapper, not by styling the table itself', () => {
351
+ // A table ignores width / height / overflow: hidden as a clip (they are
352
+ // minimums on a table box), so a 1000-row table styled visually-hidden
353
+ // still stood 17,000px tall and every scroll container around a chart
354
+ // grew by that much. The clip has to be on a block element around it.
355
+ const el = render(line(5000))
356
+ const table = el.querySelector('table')!
357
+ expect(table.classList.contains('sv-grid-chart-sr-only')).toBe(false)
358
+ const wrapper = table.parentElement!
359
+ expect(wrapper.tagName).toBe('DIV')
360
+ expect(wrapper.classList.contains('sv-grid-chart-sr-only')).toBe(true)
361
+ // The svg still points at the table by id.
362
+ const svg = el.querySelector('svg')!
363
+ expect(svg.getAttribute('aria-describedby')).toBe(table.id)
364
+ })
365
+
153
366
  it('leaves a small chart entirely alone', () => {
154
367
  const el = render(line(40))
155
368
  expect(el.querySelector('.sv-grid-chart-sr-only tbody')!.children.length).toBe(40)
@@ -308,4 +521,1209 @@ describe('interactive annotations', () => {
308
521
  expect(el.querySelectorAll('.sv-grid-chart-annotation-marker')).toHaveLength(1)
309
522
  expect(el.querySelector('.sv-grid-chart-annotation-label')!.textContent).toBe('deploy')
310
523
  })
524
+
525
+ it('a pin carries its initial in the head and the whole label beside it', () => {
526
+ // The head used to hold truncate(label, 2): "O…" for "Outage", and no
527
+ // label anywhere else, so a pinned event read as a letter and a dot.
528
+ const el = render({ ...spec, annotations: [{ at: { category: 'b' }, label: 'Outage', shape: 'pin', placement: 'bottom' }] })
529
+ expect(el.querySelector('.sv-grid-chart-annotation-flagtext')!.textContent).toBe('O')
530
+ expect(el.querySelector('.sv-grid-chart-annotation-label')!.textContent).toBe('Outage')
531
+ })
532
+ })
533
+
534
+ describe('SvGridChart wave 1: axes, frame, legend, tooltip, labels', () => {
535
+ const base: ChartSpec = {
536
+ type: 'line',
537
+ categories: ['Jan', 'Feb', 'Mar', 'Apr'],
538
+ series: [
539
+ { label: 'Sales', values: [40, 90, 60, 120] },
540
+ { label: 'Cost', values: [20, 30, 25, 50] },
541
+ ],
542
+ }
543
+ const remount = () => {
544
+ if (app) unmount(app)
545
+ app = null
546
+ }
547
+
548
+ it('draws the title, subtitle and caption from the spec', () => {
549
+ const el = render({ ...base, title: 'Revenue', subtitle: 'FY26', caption: 'Source: finance' })
550
+ expect(el.querySelector('.sv-grid-chart-title')!.textContent).toBe('Revenue')
551
+ expect(el.querySelector('.sv-grid-chart-subtitle')!.textContent).toBe('FY26')
552
+ expect(el.querySelector('.sv-grid-chart-caption')!.textContent).toBe('Source: finance')
553
+ })
554
+
555
+ it('draws no gridlines when the axis turns them off, and x gridlines when asked', () => {
556
+ const off = render({ ...base, yAxis: { gridLines: false } })
557
+ // Only the zero line survives (it is an axis, not a gridline).
558
+ expect(off.querySelectorAll('.sv-grid-chart-gridline:not(.is-zero)')).toHaveLength(0)
559
+ remount()
560
+ const on = render({ ...base, xAxis: { gridLines: true } })
561
+ // 4 x ticks + the y gridlines.
562
+ expect(on.querySelectorAll('.sv-grid-chart-gridline').length).toBeGreaterThan(4)
563
+ })
564
+
565
+ it('rotates the x labels by the configured angle', () => {
566
+ const el = render({ ...base, xAxis: { labelRotation: -90 } })
567
+ const t = el.querySelector('text.sv-grid-chart-axis[transform]') as SVGElement
568
+ expect(t).toBeTruthy()
569
+ expect(t.getAttribute('transform')!.startsWith('rotate(-90')).toBe(true)
570
+ })
571
+
572
+ it('paints a reference band beneath the marks', () => {
573
+ const el = render({ ...base, referenceBands: [{ from: 50, to: 100, label: 'target' }] })
574
+ const band = el.querySelector('.sv-grid-chart-refband') as SVGRectElement
575
+ expect(band).toBeTruthy()
576
+ const nodes = [...el.querySelectorAll('.sv-grid-chart-svg *')]
577
+ expect(nodes.indexOf(band)).toBeLessThan(nodes.findIndex((n) => n.classList.contains('sv-grid-chart-linepath')))
578
+ expect([...el.querySelectorAll('.sv-grid-chart-reflabel')].some((n) => n.textContent === 'target')).toBe(true)
579
+ })
580
+
581
+ it('draws non-circle markers as paths and honours per-point markers', () => {
582
+ const el = render({
583
+ ...base,
584
+ series: [{ label: 'a', values: [1, 2, 3, 4], marker: 'square', markers: [null, { shape: 'none' }, { shape: 'diamond' }, null] }],
585
+ })
586
+ const dots = el.querySelectorAll('.sv-grid-chart-dot')
587
+ expect(dots).toHaveLength(3)
588
+ expect([...dots].every((d) => d.tagName.toLowerCase() === 'path')).toBe(true)
589
+ })
590
+
591
+ it('applies stroke width, dash and gradient from the series', () => {
592
+ const el = render({
593
+ ...base,
594
+ type: 'area',
595
+ series: [{ label: 'a', values: [1, 2, 3, 4], strokeWidth: 4, dash: [4, 2], gradient: true }],
596
+ })
597
+ const line = el.querySelector('.sv-grid-chart-linepath')!
598
+ expect(line.getAttribute('stroke-width')).toBe('4')
599
+ expect(line.getAttribute('stroke-dasharray')).toBe('4 2')
600
+ expect(el.querySelector('linearGradient')).toBeTruthy()
601
+ expect(el.querySelector('.sv-grid-chart-area')!.getAttribute('fill')!.startsWith('url(#')).toBe(true)
602
+ })
603
+
604
+ it('places the legend on the side or on top through a wrapper class', () => {
605
+ const left = render(base, { legend: 'left' })
606
+ expect(left.querySelector('.sv-grid-chart')!.classList.contains('is-legend-left')).toBe(true)
607
+ expect(left.querySelector('.sv-grid-chart-legend')!.classList.contains('is-vertical')).toBe(true)
608
+ remount()
609
+ const top = render(base, { legend: 'top' })
610
+ expect(top.querySelector('.sv-grid-chart')!.classList.contains('is-legend-top')).toBe(true)
611
+ expect(top.querySelector('.sv-grid-chart-legend')!.classList.contains('is-vertical')).toBe(false)
612
+ })
613
+
614
+ it('renders a custom legend item through the snippet', () => {
615
+ const el = render(base, {
616
+ legendItem: createRawSnippet((get: () => { label: string }) => ({ render: () => `<i class="my-item">${get().label}</i>` })),
617
+ })
618
+ const items = el.querySelectorAll('.sv-grid-chart-legend-item .my-item')
619
+ expect(items).toHaveLength(2)
620
+ expect(items[0]!.textContent).toBe('Sales')
621
+ expect(el.querySelector('.sv-grid-chart-swatch')).toBeNull()
622
+ })
623
+
624
+ it('renders a custom tooltip through the snippet, with the hovered context', () => {
625
+ const el = render(base, {
626
+ tooltip: createRawSnippet((get: () => { category: string; rows: Array<{ label?: string }> }) => ({
627
+ render: () => `<b class="my-tip">${get().category}:${get().rows.length}</b>`,
628
+ })),
629
+ })
630
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement
631
+ // jsdom does not deliver delegated mousemove; focus takes the same path.
632
+ hit.dispatchEvent(new FocusEvent('focus'))
633
+ flushSync()
634
+ const tip = el.querySelector('.sv-grid-chart-tooltip')!
635
+ expect(tip.classList.contains('is-custom')).toBe(true)
636
+ expect(tip.querySelector('.my-tip')!.textContent).toBe('Feb:2')
637
+ })
638
+
639
+ it('tooltipFormat rewrites the title and rows of the default tooltip', () => {
640
+ const el = render(base, {
641
+ tooltipFormat: (ctx: { category: string }) => ({ title: `Month ${ctx.category}`, rows: [{ value: 'only this' }] }),
642
+ })
643
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[2] as SVGElement
644
+ // jsdom does not deliver delegated mousemove; focus takes the same path.
645
+ hit.dispatchEvent(new FocusEvent('focus'))
646
+ flushSync()
647
+ expect(el.querySelector('.sv-grid-chart-tooltip-title')!.textContent).toBe('Month Mar')
648
+ expect(el.querySelectorAll('.sv-grid-chart-tooltip-row')).toHaveLength(1)
649
+ expect(el.querySelector('.sv-grid-chart-tooltip-row-value')!.textContent).toBe('only this')
650
+ })
651
+
652
+ it('data labels take a config object: formatter and placement', () => {
653
+ const el = render({ ...base, type: 'bar', series: [{ label: 'a', values: [10, 20, 30, 40] }] }, {
654
+ dataLabels: { formatter: (v: number) => `${v}!`, placement: 'inside', hideOverlap: false },
655
+ })
656
+ const labels = [...el.querySelectorAll('.sv-grid-chart-datalabel')]
657
+ expect(labels).toHaveLength(4)
658
+ expect(labels[0]!.textContent).toBe('10!')
659
+ expect(labels.every((l) => l.classList.contains('on-bar'))).toBe(true)
660
+ })
661
+
662
+ it('hides overlapping data labels by default and keeps them all when told to', () => {
663
+ // 40 points across a narrow chart: the labels cannot all fit.
664
+ const dense: ChartSpec = {
665
+ type: 'line',
666
+ categories: Array.from({ length: 40 }, (_, i) => `c${i}`),
667
+ series: [{ label: 'a', values: Array.from({ length: 40 }, (_, i) => 100 + (i % 3)) }],
668
+ width: 300,
669
+ height: 200,
670
+ }
671
+ const thinned = render(dense, { dataLabels: true })
672
+ const kept = thinned.querySelectorAll('.sv-grid-chart-datalabel').length
673
+ expect(kept).toBeGreaterThan(0)
674
+ expect(kept).toBeLessThan(40)
675
+ remount()
676
+ const all = render(dense, { dataLabels: { hideOverlap: false } })
677
+ expect(all.querySelectorAll('.sv-grid-chart-datalabel')).toHaveLength(40)
678
+ })
679
+
680
+ it('wraps the marks in a clip group so a pinned axis cuts values off at the plot edge', () => {
681
+ const el = render({ ...base, yAxis: { max: 80 } })
682
+ const g = el.querySelector('.sv-grid-chart-marks') as SVGGElement
683
+ expect(g.getAttribute('clip-path')!.startsWith('url(#')).toBe(true)
684
+ expect(el.querySelector('clipPath rect')).toBeTruthy()
685
+ expect(g.querySelector('.sv-grid-chart-linepath')).toBeTruthy()
686
+ })
687
+ })
688
+
689
+ describe('SvGridChart wave 2: the new families render and respond', () => {
690
+ it('a sunburst draws one arc per node, focusable, with a tooltip and a key-only legend', () => {
691
+ const el = render({
692
+ type: 'sunburst',
693
+ categories: [],
694
+ series: [],
695
+ tree: { name: 'root', children: [{ name: 'A', children: [{ name: 'A1', value: 3 }, { name: 'A2', value: 1 }] }, { name: 'B', value: 4 }] },
696
+ }, { onSelect: () => {} })
697
+ const arcs = el.querySelectorAll('.sv-grid-chart-arc')
698
+ expect(arcs).toHaveLength(4)
699
+ expect(arcs[0]!.getAttribute('role')).toBe('button')
700
+ ;(arcs[1] as SVGElement).dispatchEvent(new FocusEvent('focus'))
701
+ flushSync()
702
+ expect(el.querySelector('.sv-grid-chart-tooltip')).toBeTruthy()
703
+ const chips = el.querySelectorAll('.sv-grid-chart-legend-item')
704
+ expect(chips).toHaveLength(2)
705
+ expect((chips[0] as HTMLButtonElement).disabled).toBe(true)
706
+ // The screen-reader table lists the node paths.
707
+ expect(el.querySelector('.sv-grid-chart-sr-only tbody')!.textContent).toContain('A / A1')
708
+ })
709
+
710
+ it('a radial bar lists categories in the legend and toggles one off', () => {
711
+ const el = render({ type: 'radial-bar', categories: ['a', 'b', 'c'], series: [{ label: 's', values: [1, 2, 3] }] })
712
+ expect(el.querySelectorAll('.sv-grid-chart-arc')).toHaveLength(3)
713
+ expect(el.querySelectorAll('.sv-grid-chart-arc-track')).toHaveLength(3)
714
+ const chips = el.querySelectorAll('.sv-grid-chart-legend-item')
715
+ expect([...chips].map((c) => c.textContent?.trim())).toEqual(['a', 'b', 'c'])
716
+ ;(chips[0] as HTMLButtonElement).click()
717
+ flushSync()
718
+ // A toggled-off category draws no arc (its value is zeroed).
719
+ expect(el.querySelectorAll('.sv-grid-chart-arc')).toHaveLength(2)
720
+ })
721
+
722
+ it('radial columns draw polar rings and category labels; a chord draws ribbons', () => {
723
+ const el = render({ type: 'radial-column', categories: ['a', 'b', 'c'], series: [{ label: 's', values: [1, 2, 3] }] })
724
+ expect(el.querySelectorAll('.sv-grid-chart-arc')).toHaveLength(3)
725
+ expect(el.querySelectorAll('circle.sv-grid-chart-gridline').length).toBeGreaterThan(0)
726
+ expect([...el.querySelectorAll('text.sv-grid-chart-axis')].map((t) => t.textContent)).toEqual(['a', 'b', 'c'])
727
+ unmount(app!); app = null
728
+ const chord = render({
729
+ type: 'chord', categories: [], series: [],
730
+ sankeyNodes: [{ id: 'x' }, { id: 'y' }], sankeyLinks: [{ source: 'x', target: 'y', value: 2 }],
731
+ })
732
+ expect(chord.querySelectorAll('.sv-grid-chart-ribbon')).toHaveLength(1)
733
+ expect(chord.querySelectorAll('.sv-grid-chart-arc')).toHaveLength(2)
734
+ })
735
+
736
+ it('lollipops and dumbbells draw stems with dots and no bars', () => {
737
+ const el = render({ type: 'lollipop', categories: ['a', 'b'], series: [{ label: 's', values: [3, 5] }] })
738
+ expect(el.querySelectorAll('.sv-grid-chart-stem')).toHaveLength(2)
739
+ expect(el.querySelectorAll('.sv-grid-chart-stem circle')).toHaveLength(2)
740
+ expect(el.querySelectorAll('.sv-grid-chart-bar')).toHaveLength(0)
741
+ unmount(app!); app = null
742
+ const db = render({ type: 'dumbbell', categories: ['a', 'b'], series: [{ label: 's', values: [3, 5], lowValues: [1, 2] }] })
743
+ expect(db.querySelectorAll('.sv-grid-chart-stem circle')).toHaveLength(4)
744
+ // The category tooltip reads the range.
745
+ ;(db.querySelectorAll('.sv-grid-chart-cat-hit')[0] as SVGElement).dispatchEvent(new FocusEvent('focus'))
746
+ flushSync()
747
+ expect(db.querySelector('.sv-grid-chart-tooltip-row-value')!.textContent).toBe('1 to 3')
748
+ })
749
+
750
+ it('a bullet draws ranges, the measure bar and the target tick per row', () => {
751
+ const el = render({
752
+ type: 'bullet', categories: ['Revenue', 'Profit'],
753
+ series: [{ label: 'Actual', values: [270, 23], targets: [250, 26] }],
754
+ bulletRanges: [{ from: 0, to: 150, color: '#eee' }, { from: 150, to: 300, color: '#ddd' }],
755
+ })
756
+ expect(el.querySelectorAll('.sv-grid-chart-bullet')).toHaveLength(2)
757
+ expect(el.querySelectorAll('.sv-grid-chart-bullet-target')).toHaveLength(2)
758
+ expect(el.querySelectorAll('.sv-grid-chart-bullet rect')).toHaveLength(6)
759
+ expect(el.querySelector('.sv-grid-chart-sr-only tbody')!.textContent).toContain('250')
760
+ })
761
+
762
+ it('hollow candles fill by the body direction', () => {
763
+ const ohlc = [{ o: 10, h: 12, l: 9, c: 11 }, { o: 11, h: 12, l: 9, c: 10 }]
764
+ const el = render({ type: 'candlestick', categories: ['a', 'b'], series: [{ label: 'p', values: [11, 10], ohlc }], candleStyle: 'hollow' })
765
+ const bodies = el.querySelectorAll('.sv-grid-chart-candle')
766
+ expect(bodies[0]!.getAttribute('fill')).toBe('none')
767
+ expect(bodies[1]!.getAttribute('fill')).not.toBe('none')
768
+ })
769
+
770
+ it('a histogram draws touching bars and a brush is refused for the polar types', () => {
771
+ const el = render({ type: 'histogram', categories: ['5', '15', '25'], series: [{ label: 'n', values: [2, 5, 1] }], binEdges: [0, 10, 20, 30], xType: 'number' })
772
+ const bars = [...el.querySelectorAll('.sv-grid-chart-bar')]
773
+ expect(bars).toHaveLength(3)
774
+ const x0 = Number(bars[0]!.getAttribute('x')) + Number(bars[0]!.getAttribute('width'))
775
+ expect(Math.abs(x0 - Number(bars[1]!.getAttribute('x')))).toBeLessThanOrEqual(1)
776
+ unmount(app!); app = null
777
+ const polar = render({ type: 'nightingale', categories: ['a', 'b'], series: [{ label: 's', values: [1, 2] }] }, { brush: true })
778
+ expect(polar.querySelector('.sv-grid-chart-brush')).toBeNull()
779
+ })
780
+ })
781
+
782
+ describe('SvGridChart wave 3: zoom API, sync, menu, keyboard, selection, drilldown, motion', () => {
783
+ const ts: ChartSpec = {
784
+ type: 'line',
785
+ categories: Array.from({ length: 30 }, (_, i) => new Date(Date.UTC(2026, 0, 1 + i)).toISOString().slice(0, 10)),
786
+ series: [
787
+ { label: 'a', values: Array.from({ length: 30 }, (_, i) => 10 + i) },
788
+ { label: 'b', values: Array.from({ length: 30 }, (_, i) => 40 - i) },
789
+ ],
790
+ xType: 'ordinal-time',
791
+ }
792
+
793
+ it('zoomTo / resetZoom narrow and restore the axis, and report through onZoom', () => {
794
+ const seen: unknown[] = []
795
+ target = document.createElement('div')
796
+ document.body.appendChild(target)
797
+ app = mount(SvGridChart, { target, props: { spec: ts, zoomable: true, onZoom: (w: unknown) => seen.push(w) } as any })
798
+ flushSync()
799
+ const hits = () => target!.querySelectorAll('.sv-grid-chart-cat-hit').length
800
+ const before = hits()
801
+ ;(app as unknown as { zoomTo: (w: unknown) => void }).zoomTo({ i0: 5, i1: 9 })
802
+ flushSync()
803
+ expect(hits()).toBe(5)
804
+ expect(seen).toEqual([{ i0: 5, i1: 9 }])
805
+ ;(app as unknown as { resetZoom: () => void }).resetZoom()
806
+ flushSync()
807
+ expect(hits()).toBe(before)
808
+ expect(seen[1]).toBeNull()
809
+ })
810
+
811
+ it('range presets show for a time axis and apply a window', () => {
812
+ const el = render(ts, { rangePresets: true, zoomable: true })
813
+ const btns = [...el.querySelectorAll('.sv-grid-chart-preset')].map((b) => b.textContent)
814
+ expect(btns).toEqual(['1W', '1M', '3M', '6M', 'YTD', '1Y', 'All'])
815
+ const hits = () => el.querySelectorAll('.sv-grid-chart-cat-hit').length
816
+ ;(el.querySelector('.sv-grid-chart-preset') as HTMLButtonElement).click()
817
+ flushSync()
818
+ expect(hits()).toBe(8)
819
+ ;([...el.querySelectorAll('.sv-grid-chart-preset')].find((b) => b.textContent === 'All') as HTMLButtonElement).click()
820
+ flushSync()
821
+ expect(hits()).toBe(30)
822
+ })
823
+
824
+ it('two charts in a syncGroup share the crosshair', () => {
825
+ const a = render(ts, { syncGroup: 'g1' })
826
+ const t2 = document.createElement('div')
827
+ document.body.appendChild(t2)
828
+ const app2 = mount(SvGridChart, { target: t2, props: { spec: ts, syncGroup: 'g1' } as any })
829
+ flushSync()
830
+ try {
831
+ ;(a.querySelectorAll('.sv-grid-chart-cat-hit')[3] as SVGElement).dispatchEvent(new FocusEvent('focus'))
832
+ flushSync()
833
+ expect(a.querySelector('.sv-grid-chart-crosshair')).toBeTruthy()
834
+ expect(t2.querySelector('.sv-grid-chart-crosshair')).toBeTruthy()
835
+ ;(a.querySelectorAll('.sv-grid-chart-cat-hit')[3] as SVGElement).dispatchEvent(new FocusEvent('blur'))
836
+ flushSync()
837
+ expect(t2.querySelector('.sv-grid-chart-crosshair')).toBeNull()
838
+ } finally {
839
+ unmount(app2)
840
+ t2.remove()
841
+ }
842
+ })
843
+
844
+ it('opens a context menu on right-click with the built-in and custom items', async () => {
845
+ const el = render(ts, { contextMenu: [{ label: 'Explain this', onSelect: () => {} }], zoomable: true })
846
+ el.querySelector('.sv-grid-chart-svg')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50, cancelable: true }))
847
+ await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-menu')).toBeTruthy() })
848
+ const labels = [...document.querySelectorAll('.sv-grid-chart-menu [role="menuitem"]')].map((n) => n.textContent?.trim())
849
+ expect(labels).toContain('Download PNG')
850
+ expect(labels).toContain('Download CSV')
851
+ expect(labels).toContain('Explain this')
852
+ expect(labels).toContain('Series')
853
+ document.querySelector('.sv-grid-chart-menu')?.remove()
854
+ })
855
+
856
+ it('Shift+F10 on a focused category opens the menu built for that point', async () => {
857
+ const seen: unknown[] = []
858
+ const el = render(ts, { contextMenu: (t: unknown) => { seen.push(t); return [{ label: 'Custom', onSelect: () => {} }] } })
859
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[4] as SVGElement
860
+ hit.dispatchEvent(new FocusEvent('focus'))
861
+ flushSync()
862
+ // Step onto series b so the target carries a series and a value.
863
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
864
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
865
+ flushSync()
866
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true, bubbles: true, cancelable: true }))
867
+ await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-menu')).toBeTruthy() })
868
+ expect(seen.at(-1)).toEqual({ category: '2026-01-05', index: 4, series: 'b', value: 36 })
869
+ document.querySelector('.sv-grid-chart-menu')?.remove()
870
+ })
871
+
872
+ it('arrow keys step series and the live region announces the point', () => {
873
+ const el = render(ts)
874
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[2] as SVGElement
875
+ hit.dispatchEvent(new FocusEvent('focus'))
876
+ flushSync()
877
+ const live = () => el.querySelector('.sv-grid-chart-live')!.textContent
878
+ // A date category is read out the way the tooltip writes it.
879
+ expect(live()).toContain('Jan 3, 2026')
880
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
881
+ flushSync()
882
+ expect(live()).toBe('a: 12 at Jan 3, 2026')
883
+ // The tooltip narrows to that series.
884
+ expect(el.querySelectorAll('.sv-grid-chart-tooltip-row')).toHaveLength(1)
885
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
886
+ flushSync()
887
+ expect(live()).toBe('b: 38 at Jan 3, 2026')
888
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
889
+ flushSync()
890
+ expect(el.querySelectorAll('.sv-grid-chart-tooltip-row')).toHaveLength(2)
891
+ })
892
+
893
+ it('selectable charts track clicked points, dim the rest and report changes', () => {
894
+ const changes: unknown[] = []
895
+ const el = render({ type: 'bar', categories: ['a', 'b', 'c'], series: [{ label: 's', values: [1, 2, 3] }] }, {
896
+ selectable: true, onSelectionChange: (s: unknown) => changes.push(s),
897
+ })
898
+ const bars = el.querySelectorAll('.sv-grid-chart-bar')
899
+ ;(bars[1] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
900
+ flushSync()
901
+ expect(changes).toEqual([[{ category: 'b', series: 's', index: 1 }]])
902
+ expect(bars[1]!.classList.contains('is-selected')).toBe(true)
903
+ expect((bars[0] as SVGElement).style.opacity).toBe('0.35')
904
+ // Ctrl-click adds; a plain click replaces.
905
+ ;(bars[2] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true }))
906
+ flushSync()
907
+ expect(changes[1]).toHaveLength(2)
908
+ ;(bars[0] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
909
+ flushSync()
910
+ expect(changes[2]).toEqual([{ category: 'a', series: 's', index: 0 }])
911
+ })
912
+
913
+ it('a grouped axis repeats its labels, and a click selects one bar, not every bar with that label', () => {
914
+ // Q1..Q4 under 2025 and again under 2026: the label names two bars, the
915
+ // index names one. A ref without an index (an older caller's `selected`)
916
+ // still matches by label.
917
+ const changes: unknown[] = []
918
+ const el = render(
919
+ {
920
+ type: 'bar',
921
+ categories: ['Q1', 'Q2', 'Q1', 'Q2'],
922
+ categoryGroups: [{ label: '2025', span: 2 }, { label: '2026', span: 2 }],
923
+ series: [{ label: 's', values: [1, 2, 3, 4] }],
924
+ },
925
+ { selectable: true, onSelectionChange: (s: unknown) => changes.push(s) },
926
+ )
927
+ const hits = el.querySelectorAll('.sv-grid-chart-cat-hit')
928
+ ;(hits[3] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
929
+ flushSync()
930
+ expect(changes).toEqual([[{ category: 'Q2', series: 's', index: 3 }]])
931
+ const bars = el.querySelectorAll('.sv-grid-chart-bar')
932
+ expect([...bars].map((b) => b.classList.contains('is-selected'))).toEqual([false, false, false, true])
933
+ expect((bars[1] as SVGElement).style.opacity).toBe('0.35')
934
+ unmount(app!); app = null
935
+ const legacy = render(
936
+ { type: 'bar', categories: ['Q1', 'Q2', 'Q1', 'Q2'], series: [{ label: 's', values: [1, 2, 3, 4] }] },
937
+ { selectable: true, selected: [{ category: 'Q2', series: 's' }] },
938
+ )
939
+ expect(legacy.querySelectorAll('.sv-grid-chart-bar.is-selected')).toHaveLength(2)
940
+ })
941
+
942
+ it('a click on the category hit zone selects a point, not a bare category', () => {
943
+ // In a browser the hit zones sit above the bars, so every "bar click"
944
+ // arrives here. A single-series chart resolves to its one series; with
945
+ // several, the series the keyboard stepped onto wins.
946
+ const changes: unknown[] = []
947
+ const el = render({ type: 'bar', categories: ['a', 'b'], series: [{ label: 'only', values: [1, 2] }] }, {
948
+ selectable: true, onSelectionChange: (sel: unknown) => changes.push(sel),
949
+ })
950
+ const hits = el.querySelectorAll('.sv-grid-chart-cat-hit')
951
+ ;(hits[1] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
952
+ flushSync()
953
+ expect(changes).toEqual([[{ category: 'b', series: 'only', index: 1 }]])
954
+ expect(el.querySelectorAll('.sv-grid-chart-bar.is-selected')).toHaveLength(1)
955
+ unmount(app!); app = null
956
+ const multi = render({ type: 'bar', categories: ['a', 'b'], series: [{ label: 's1', values: [1, 2] }, { label: 's2', values: [3, 4] }] }, {
957
+ selectable: true, onSelectionChange: (sel: unknown) => changes.push(sel),
958
+ })
959
+ const hit = multi.querySelectorAll('.sv-grid-chart-cat-hit')[0] as SVGElement
960
+ hit.dispatchEvent(new FocusEvent('focus'))
961
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
962
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
963
+ hit.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
964
+ flushSync()
965
+ expect(changes[1]).toEqual([{ category: 'a', series: 's2', index: 0 }])
966
+ })
967
+
968
+ it('a drillable sunburst descends on click and the breadcrumb climbs back', () => {
969
+ const paths: string[][] = []
970
+ const el = render({
971
+ type: 'sunburst', categories: [], series: [],
972
+ tree: { name: 'All', children: [{ name: 'A', children: [{ name: 'A1', value: 3 }, { name: 'A2', value: 1 }] }, { name: 'B', value: 4 }] },
973
+ }, { drillable: true, onSelect: () => paths.push(['leaf']) })
974
+ expect(el.querySelectorAll('.sv-grid-chart-arc')).toHaveLength(4)
975
+ const a = [...el.querySelectorAll('.sv-grid-chart-arc')].find((n) => n.getAttribute('aria-label')?.startsWith('A:'))! as SVGElement
976
+ expect(a.classList.contains('is-drillable')).toBe(true)
977
+ a.dispatchEvent(new MouseEvent('click', { bubbles: true }))
978
+ flushSync()
979
+ // Only A's children are drawn now, and the crumb reads All / A.
980
+ expect(el.querySelectorAll('.sv-grid-chart-arc')).toHaveLength(2)
981
+ expect(el.querySelector('.sv-grid-chart-crumbs')!.textContent).toContain('All')
982
+ expect(el.querySelector('.sv-grid-chart-crumb.is-current')!.textContent).toBe('A')
983
+ // A leaf click selects instead of drilling.
984
+ ;(el.querySelector('.sv-grid-chart-arc') as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
985
+ flushSync()
986
+ expect(paths).toEqual([['leaf']])
987
+ ;(el.querySelector('.sv-grid-chart-crumb') as HTMLButtonElement).click()
988
+ flushSync()
989
+ expect(el.querySelectorAll('.sv-grid-chart-arc')).toHaveLength(4)
990
+ })
991
+
992
+ it('a drillable pie draws the tree level by level and descends on a slice click', () => {
993
+ const selects: unknown[] = []
994
+ const el = render({
995
+ type: 'pie', categories: [], series: [],
996
+ tree: { name: 'All', children: [
997
+ { name: 'A', children: [{ name: 'A1', value: 3, color: '#123456' }, { name: 'A2', value: 1 }] },
998
+ { name: 'B', value: 4 },
999
+ { name: 'C', children: [{ name: 'C1', value: 2 }] },
1000
+ ] },
1001
+ }, { drillable: true, legend: true, onSelect: (sel: unknown) => selects.push(sel) })
1002
+ const slices = () => [...el.querySelectorAll('.sv-grid-chart-slice')] as SVGElement[]
1003
+ // Top level: A (4), B (4), C (2); A and C drill, B is a leaf.
1004
+ expect(slices()).toHaveLength(3)
1005
+ expect(slices().map((n) => n.classList.contains('is-drillable'))).toEqual([true, false, true])
1006
+ expect([...el.querySelectorAll('.sv-grid-chart-legend-item')].map((n) => n.textContent?.trim())).toEqual(['A', 'B', 'C'])
1007
+ expect(el.querySelector('.sv-grid-chart-sr-table, table')!.textContent).toContain('A')
1008
+ slices()[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
1009
+ flushSync()
1010
+ expect(slices()).toHaveLength(2)
1011
+ expect(el.querySelector('.sv-grid-chart-crumb.is-current')!.textContent).toBe('A')
1012
+ // A node colour from the tree reaches its slice.
1013
+ expect(slices()[0]!.getAttribute('fill')).toBe('#123456')
1014
+ // A leaf selects instead of drilling.
1015
+ slices()[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
1016
+ flushSync()
1017
+ expect(selects).toEqual([{ category: 'A2', series: 'A2', value: 1, rowIds: undefined }])
1018
+ expect(slices()).toHaveLength(2)
1019
+ ;(el.querySelector('.sv-grid-chart-crumb') as HTMLButtonElement).click()
1020
+ flushSync()
1021
+ expect(slices()).toHaveLength(3)
1022
+ })
1023
+
1024
+ it('the enter effect and the update tween respect the animate prop', () => {
1025
+ const grow = render({ type: 'bar', categories: ['a'], series: [{ label: 's', values: [1] }] }, { animate: { enter: 'grow' } })
1026
+ expect(grow.querySelector('.sv-grid-chart-marks')!.classList.contains('is-enter-grow')).toBe(true)
1027
+ unmount(app!); app = null
1028
+ const wipe = render({ type: 'bar', categories: ['a'], series: [{ label: 's', values: [1] }] }, { animate: { enter: 'wipe' } })
1029
+ expect(wipe.querySelector('clipPath rect')!.classList.contains('sv-grid-chart-wipe')).toBe(true)
1030
+ unmount(app!); app = null
1031
+ const none = render({ type: 'bar', categories: ['a'], series: [{ label: 's', values: [1] }] }, { animate: { enter: 'none' } })
1032
+ expect(none.querySelector('.sv-grid-chart-marks')!.classList.contains('is-enter-none')).toBe(true)
1033
+ })
1034
+ })
1035
+
1036
+ describe('SvGridChart wave 4: last price, flags, bands, drawings, panes', () => {
1037
+ const cats = ['2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04', '2026-01-05']
1038
+ const priced: ChartSpec = {
1039
+ type: 'line',
1040
+ categories: cats,
1041
+ series: [{ label: 'p', values: [10, 12, 11, 15, 13], overlay: 'bb:3:2' }],
1042
+ lastPriceLine: true,
1043
+ annotations: [{ at: { category: '2026-01-02' }, label: 'E', shape: 'flag', text: 'Earnings call' }],
1044
+ width: 600,
1045
+ height: 300,
1046
+ }
1047
+
1048
+ it('draws the last-price pill, the Bollinger band and a flag that tells its text on hover', () => {
1049
+ const el = render(priced)
1050
+ const pill = el.querySelector('.sv-grid-chart-refpill')!
1051
+ expect(pill).toBeTruthy()
1052
+ expect(pill.querySelector('text')!.textContent).toBe('13')
1053
+ expect(pill.querySelector('rect')!.getAttribute('fill')).toBe('#ef4444')
1054
+ expect(el.querySelector('.sv-grid-chart-overlay-band')).toBeTruthy()
1055
+ const flag = el.querySelector('.sv-grid-chart-annotation-marker.has-text')!
1056
+ expect(flag.getAttribute('role')).toBe('img')
1057
+ expect(flag.getAttribute('aria-label')).toBe('E: Earnings call')
1058
+ expect(flag.querySelector('.sv-grid-chart-annotation-flagtext')!.textContent).toBe('E')
1059
+ flag.dispatchEvent(new FocusEvent('focus'))
1060
+ flushSync()
1061
+ expect(el.querySelector('.sv-grid-chart-tooltip')!.textContent).toContain('Earnings call')
1062
+ flag.dispatchEvent(new FocusEvent('blur'))
1063
+ flushSync()
1064
+ expect(el.querySelector('.sv-grid-chart-tooltip')).toBeNull()
1065
+ })
1066
+
1067
+ it('renders the drawings from the spec: a trend line, a ray with its value, six fib levels, an arrowhead, a note', () => {
1068
+ const el = render({
1069
+ ...priced,
1070
+ annotations: [],
1071
+ drawings: [
1072
+ { id: 't', kind: 'trend', points: [{ x: '2026-01-01', y: 10 }, { x: '2026-01-05', y: 13 }] },
1073
+ { id: 'r', kind: 'hray', points: [{ x: '2026-01-03', y: 12 }] },
1074
+ { id: 'f', kind: 'fib', points: [{ x: '2026-01-01', y: 10 }, { x: '2026-01-04', y: 15 }] },
1075
+ { id: 'a', kind: 'arrow', points: [{ x: '2026-01-02', y: 12 }, { x: '2026-01-04', y: 15 }], color: '#f00' },
1076
+ { id: 'n', kind: 'text', points: [{ x: '2026-01-03', y: 14 }], text: 'breakout' },
1077
+ ],
1078
+ })
1079
+ expect(el.querySelectorAll('.sv-grid-chart-drawing')).toHaveLength(5)
1080
+ expect(el.querySelector('.sv-grid-chart-drawing.is-hray .sv-grid-chart-drawing-label')!.textContent).toBe('12')
1081
+ expect(el.querySelectorAll('.sv-grid-chart-drawing.is-fib .sv-grid-chart-drawing-label')).toHaveLength(6)
1082
+ expect(el.querySelector('.sv-grid-chart-drawing.is-arrow path')!.getAttribute('fill')).toBe('#f00')
1083
+ expect(el.querySelector('.sv-grid-chart-drawing-text')!.textContent).toBe('breakout')
1084
+ // Not drawable: nothing is focusable and there are no tools.
1085
+ expect(el.querySelector('.sv-grid-chart-drawing[tabindex]')).toBeNull()
1086
+ expect(el.querySelector('.sv-grid-chart-drawtools')).toBeNull()
1087
+ })
1088
+
1089
+ it('drawable: the toolbar offers the tools, a selected drawing grows handles and Delete removes it', () => {
1090
+ const changes: unknown[] = []
1091
+ const el = render({
1092
+ ...priced,
1093
+ annotations: [],
1094
+ drawings: [{ id: 't', kind: 'trend', points: [{ x: '2026-01-01', y: 10 }, { x: '2026-01-05', y: 13 }] }],
1095
+ }, { drawable: ['trend', 'hray'], onDrawingsChange: (d: unknown) => changes.push(d) })
1096
+ const tools = [...el.querySelectorAll('.sv-grid-chart-drawtool')].map((b) => b.textContent)
1097
+ expect(tools).toEqual(['Trend', 'Ray'])
1098
+ const drawing = el.querySelector('.sv-grid-chart-drawing') as SVGElement
1099
+ expect(drawing.getAttribute('role')).toBe('button')
1100
+ drawing.dispatchEvent(new MouseEvent('click', { bubbles: true }))
1101
+ flushSync()
1102
+ expect(drawing.classList.contains('is-selected')).toBe(true)
1103
+ expect(el.querySelectorAll('.sv-grid-chart-drawing-handle')).toHaveLength(2)
1104
+ drawing.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }))
1105
+ flushSync()
1106
+ expect(changes).toEqual([[]])
1107
+ // The Clear button empties the list too.
1108
+ ;([...el.querySelectorAll('.sv-grid-chart-tool')].find((b) => b.textContent === 'Clear') as HTMLButtonElement).click()
1109
+ expect(changes).toHaveLength(2)
1110
+ })
1111
+
1112
+ it('drawable: the text tool opens an inline box at the click, Enter keeps the note and Escape drops it', () => {
1113
+ const changes: unknown[][] = []
1114
+ const el = render({ ...priced, annotations: [] }, { drawable: true, onDrawingsChange: (d: unknown[]) => changes.push(d) })
1115
+ // jsdom has no layout; give the svg a box so a client point maps 1:1 to the 600x300 viewBox.
1116
+ const svg = el.querySelector('.sv-grid-chart-svg') as SVGSVGElement
1117
+ svg.getBoundingClientRect = () => ({ left: 0, top: 0, width: 600, height: 300, right: 600, bottom: 300, x: 0, y: 0, toJSON: () => ({}) })
1118
+ ;([...el.querySelectorAll('.sv-grid-chart-drawtool')].find((b) => b.textContent === 'Text') as HTMLButtonElement).click()
1119
+ flushSync()
1120
+ svg.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 300, clientY: 150 }))
1121
+ flushSync()
1122
+ const box = el.querySelector('.sv-grid-chart-textdraft') as HTMLInputElement
1123
+ expect(box).toBeTruthy()
1124
+ expect(document.activeElement).toBe(box)
1125
+ box.value = 'breakout'
1126
+ box.dispatchEvent(new Event('input', { bubbles: true }))
1127
+ box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
1128
+ flushSync()
1129
+ expect(el.querySelector('.sv-grid-chart-textdraft')).toBeNull()
1130
+ expect(changes).toHaveLength(1)
1131
+ const note = changes[0]![0] as { kind: string; text: string; points: Array<{ x: unknown; y: number }> }
1132
+ expect(note.kind).toBe('text')
1133
+ expect(note.text).toBe('breakout')
1134
+ // Anchored in data space: a category label and a value inside the axis.
1135
+ expect(cats).toContain(note.points[0]!.x)
1136
+ expect(note.points[0]!.y).toBeGreaterThan(9)
1137
+ expect(note.points[0]!.y).toBeLessThan(16)
1138
+ // Escape on an empty box drops it without a change.
1139
+ svg.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 200, clientY: 150 }))
1140
+ flushSync()
1141
+ const again = el.querySelector('.sv-grid-chart-textdraft') as HTMLInputElement
1142
+ again.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1143
+ flushSync()
1144
+ expect(el.querySelector('.sv-grid-chart-textdraft')).toBeNull()
1145
+ expect(changes).toHaveLength(1)
1146
+ })
1147
+
1148
+ it('drawable: picking a tool turns the plot crosshair and the category click stops selecting', () => {
1149
+ const selects: unknown[] = []
1150
+ const el = render({ ...priced, annotations: [] }, { drawable: true, onSelect: (s: unknown) => selects.push(s) })
1151
+ const btn = [...el.querySelectorAll('.sv-grid-chart-drawtool')].find((b) => b.textContent === 'Trend') as HTMLButtonElement
1152
+ btn.click()
1153
+ flushSync()
1154
+ expect(btn.getAttribute('aria-pressed')).toBe('true')
1155
+ expect(el.querySelector('.sv-grid-chart-svg')!.classList.contains('is-drawing')).toBe(true)
1156
+ ;(el.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
1157
+ flushSync()
1158
+ expect(selects).toEqual([])
1159
+ // Escape drops the tool.
1160
+ el.querySelector('.sv-grid-chart-svg')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1161
+ flushSync()
1162
+ expect(btn.getAttribute('aria-pressed')).toBe('false')
1163
+ })
1164
+ })
1165
+
1166
+ describe('SvGridChart wave 5: callouts, visible, crosshair pills', () => {
1167
+ const pie: ChartSpec = {
1168
+ type: 'pie',
1169
+ categories: ['Alpha', 'Beta', 'Gamma', 'Sliver'],
1170
+ series: [{ label: 'share', values: [50, 30, 19.5, 0.5] }],
1171
+ }
1172
+
1173
+ it('dataLabels placement outside draws a leader and a label per slice and drops the inside percent', () => {
1174
+ const el = render(pie, { dataLabels: { placement: 'outside' } })
1175
+ expect(el.querySelectorAll('.sv-grid-chart-callout')).toHaveLength(3)
1176
+ const texts = [...el.querySelectorAll('.sv-grid-chart-callout-label')].map((n) => n.textContent)
1177
+ expect(texts).toEqual(['Alpha 50%', 'Beta 30%', 'Gamma 20%'])
1178
+ expect(el.querySelector('.sv-grid-chart-datalabel')).toBeNull()
1179
+ const anchors = [...el.querySelectorAll('.sv-grid-chart-callout-label')].map((n) => n.getAttribute('text-anchor'))
1180
+ expect(anchors).toContain('start')
1181
+ expect(anchors).toContain('end')
1182
+ })
1183
+
1184
+ it('the spec carries its own dataLabels and the formatter writes the callout text', () => {
1185
+ const el = render(
1186
+ { ...pie, dataLabels: { placement: 'outside', formatter: (v: number, ctx: { category: string }) => `${ctx.category}=${v}` } },
1187
+ )
1188
+ expect([...el.querySelectorAll('.sv-grid-chart-callout-label')].map((n) => n.textContent)).toEqual(['Alpha=50', 'Beta=30', 'Gamma=19.5'])
1189
+ // The prop wins over the spec.
1190
+ unmount(app!); app = null
1191
+ const inside = render({ ...pie, dataLabels: { placement: 'outside' } }, { dataLabels: true })
1192
+ expect(inside.querySelector('.sv-grid-chart-callout')).toBeNull()
1193
+ expect(inside.querySelectorAll('.sv-grid-chart-datalabel').length).toBeGreaterThan(0)
1194
+ })
1195
+
1196
+ it('series.visible false starts the series hidden and the legend chip toggles it back', () => {
1197
+ const el = render({
1198
+ type: 'line',
1199
+ categories: ['a', 'b', 'c'],
1200
+ series: [
1201
+ { label: 'shown', values: [1, 2, 3] },
1202
+ { label: 'muted', values: [3, 2, 1], visible: false },
1203
+ ],
1204
+ }, { legend: true })
1205
+ expect(el.querySelectorAll('.sv-grid-chart-linepath')).toHaveLength(1)
1206
+ const chip = [...el.querySelectorAll('.sv-grid-chart-legend-item')].find((n) => n.textContent?.trim() === 'muted') as HTMLElement
1207
+ expect(chip.classList.contains('is-off')).toBe(true)
1208
+ chip.click()
1209
+ flushSync()
1210
+ expect(el.querySelectorAll('.sv-grid-chart-linepath')).toHaveLength(2)
1211
+ })
1212
+
1213
+ it('crosshairLabels pins the hovered category on the x axis and the value under the pointer on the y axis', () => {
1214
+ const spec: ChartSpec = { type: 'line', categories: ['Jan', 'Feb', 'Mar'], series: [{ label: 'v', values: [10, 20, 30] }] }
1215
+ const el = render(spec)
1216
+ const svg = el.querySelector('.sv-grid-chart-svg') as SVGSVGElement
1217
+ svg.getBoundingClientRect = () => ({ left: 0, top: 0, width: 600, height: 300, right: 600, bottom: 300, x: 0, y: 0, toJSON: () => ({}) })
1218
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement
1219
+ hit.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 300, clientY: 150 }))
1220
+ flushSync()
1221
+ const pills = [...el.querySelectorAll('.sv-grid-chart-crosshair-label text')].map((n) => n.textContent)
1222
+ expect(pills[0]).toBe('Feb')
1223
+ expect(pills).toHaveLength(2)
1224
+ expect(Number(pills[1])).toBeGreaterThan(0)
1225
+ expect(Number(pills[1])).toBeLessThanOrEqual(30)
1226
+ hit.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }))
1227
+ flushSync()
1228
+ expect(el.querySelector('.sv-grid-chart-crosshair-label')).toBeNull()
1229
+ // Off by prop: the crosshair line stays, the pills go.
1230
+ unmount(app!); app = null
1231
+ const bare = render(spec, { crosshairLabels: false })
1232
+ ;(bare.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement).dispatchEvent(new FocusEvent('focus'))
1233
+ flushSync()
1234
+ expect(bare.querySelector('.sv-grid-chart-crosshair')).toBeTruthy()
1235
+ expect(bare.querySelector('.sv-grid-chart-crosshair-label')).toBeNull()
1236
+ })
1237
+ })
1238
+
1239
+ describe('SvGridChart wave 5: series labels and responsive rules', () => {
1240
+ const spec: ChartSpec = {
1241
+ type: 'line',
1242
+ categories: ['a', 'b', 'c'],
1243
+ series: [{ label: 'North', values: [1, 2, 3] }, { label: 'South', values: [3, 2, 1] }],
1244
+ seriesLabels: true,
1245
+ }
1246
+
1247
+ it('draws one end label per line in the series colour and dims it with the series', () => {
1248
+ const el = render(spec, { legend: true })
1249
+ const labels = () => [...el.querySelectorAll('.sv-grid-chart-serieslabel')] as SVGTextElement[]
1250
+ expect(labels().map((n) => n.textContent)).toEqual(['North', 'South'])
1251
+ expect(labels()[0]!.getAttribute('fill')).toBe(el.querySelector('.sv-grid-chart-linepath')!.getAttribute('stroke'))
1252
+ // Hiding a series through the legend drops its label with the line.
1253
+ ;([...el.querySelectorAll('.sv-grid-chart-legend-item')].find((n) => n.textContent?.trim() === 'South') as HTMLElement).click()
1254
+ flushSync()
1255
+ expect(labels().map((n) => n.textContent)).toEqual(['North'])
1256
+ })
1257
+
1258
+ it('a responsive rule patches the drawn spec and moves or hides the legend by the rendered width', () => {
1259
+ const rules = [
1260
+ { maxWidth: 400, spec: { seriesLabels: false }, legend: false as const },
1261
+ { minWidth: 401, legend: 'right' as const },
1262
+ ]
1263
+ const wide = render({ ...spec, responsive: rules }, { legend: true, width: 700 })
1264
+ expect(wide.querySelectorAll('.sv-grid-chart-serieslabel')).toHaveLength(2)
1265
+ expect(wide.querySelector('.sv-grid-chart')!.classList.contains('is-legend-right')).toBe(true)
1266
+ unmount(app!); app = null
1267
+ const narrow = render({ ...spec, responsive: rules }, { legend: true, width: 320 })
1268
+ expect(narrow.querySelectorAll('.sv-grid-chart-serieslabel')).toHaveLength(0)
1269
+ expect(narrow.querySelector('.sv-grid-chart-legend')).toBeNull()
1270
+ })
1271
+ })
1272
+
1273
+ describe('SvGridChart accessibility: roving marks, keyboard zoom, brush, localeText', () => {
1274
+ const key = (el: Element, key: string, init: KeyboardEventInit = {}) => {
1275
+ el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }))
1276
+ flushSync()
1277
+ }
1278
+ const tabs = (el: HTMLElement, sel: string) => [...el.querySelectorAll(sel)].map((n) => n.getAttribute('tabindex'))
1279
+
1280
+ it('a pie is one Tab stop: the slices rove and the arrow keys walk them', () => {
1281
+ const el = render({ type: 'pie', categories: ['a', 'b', 'c', 'd'], series: [{ label: 's', values: [4, 3, 2, 1] }] })
1282
+ expect(tabs(el, '.sv-grid-chart-slice')).toEqual(['0', '-1', '-1', '-1'])
1283
+ const slices = () => [...el.querySelectorAll('.sv-grid-chart-slice')] as SVGElement[]
1284
+ expect(slices().map((n) => n.getAttribute('role'))).toEqual(['img', 'img', 'img', 'img'])
1285
+ slices()[0]!.focus()
1286
+ key(slices()[0]!, 'ArrowRight')
1287
+ expect(tabs(el, '.sv-grid-chart-slice')).toEqual(['-1', '0', '-1', '-1'])
1288
+ expect(document.activeElement).toBe(slices()[1])
1289
+ key(slices()[1]!, 'End')
1290
+ expect(tabs(el, '.sv-grid-chart-slice')).toEqual(['-1', '-1', '-1', '0'])
1291
+ key(slices()[3]!, 'ArrowRight')
1292
+ expect(tabs(el, '.sv-grid-chart-slice')).toEqual(['-1', '-1', '-1', '0'])
1293
+ key(slices()[3]!, 'Home')
1294
+ expect(document.activeElement).toBe(slices()[0])
1295
+ // The focused slice raises its tooltip, like a hovered one.
1296
+ expect(el.querySelector('.sv-grid-chart-tooltip')?.textContent).toContain('a')
1297
+ })
1298
+
1299
+ it('Enter on a roved slice selects when the chart is selectable, and the role says so', () => {
1300
+ const picked: unknown[] = []
1301
+ const el = render({ type: 'pie', categories: ['a', 'b'], series: [{ label: 's', values: [1, 2] }] }, { onSelect: (s: unknown) => picked.push(s) })
1302
+ const slices = [...el.querySelectorAll('.sv-grid-chart-slice')] as SVGElement[]
1303
+ expect(slices[0]!.getAttribute('role')).toBe('button')
1304
+ key(slices[1]!, 'Enter')
1305
+ expect(picked).toEqual([{ category: 'b', series: 'b', value: 2, rowIds: undefined }])
1306
+ })
1307
+
1308
+ it('a heat map and a calendar draw their ramp and no series legend', () => {
1309
+ const heat = render({ type: 'heatmap', categories: ['c1', 'c2'], series: [{ label: 'r1', values: [1, 2] }, { label: 'r2', values: [3, 4] }] }, { legend: true })
1310
+ expect(heat.querySelector('.sv-grid-chart-legend')).toBeNull()
1311
+ expect(heat.querySelectorAll('.sv-grid-chart-heatcell').length).toBe(4)
1312
+ unmount(app!); app = null
1313
+ const bars = render({ type: 'bar', categories: ['c1', 'c2'], series: [{ label: 'r1', values: [1, 2] }, { label: 'r2', values: [3, 4] }] }, { legend: true })
1314
+ expect(bars.querySelectorAll('.sv-grid-chart-legend-item')).toHaveLength(2)
1315
+ })
1316
+
1317
+ it('a heat map walks its cells in two dimensions: Right is the next column, Down the next row', () => {
1318
+ const el = render({
1319
+ type: 'heatmap',
1320
+ categories: ['c1', 'c2', 'c3'],
1321
+ series: [{ label: 'r1', values: [1, 2, 3] }, { label: 'r2', values: [4, 5, 6] }],
1322
+ })
1323
+ const cells = () => [...el.querySelectorAll('.sv-grid-chart-heatcell')] as SVGElement[]
1324
+ expect(cells()).toHaveLength(6)
1325
+ expect(tabs(el, '.sv-grid-chart-heatcell').filter((t) => t === '0')).toHaveLength(1)
1326
+ cells()[0]!.focus()
1327
+ key(cells()[0]!, 'ArrowRight')
1328
+ expect(document.activeElement).toBe(cells()[1])
1329
+ key(cells()[1]!, 'ArrowDown')
1330
+ expect(document.activeElement).toBe(cells()[4])
1331
+ expect(cells()[4]!.getAttribute('aria-label')).toBe('r2 c2: 5')
1332
+ key(cells()[4]!, 'ArrowUp')
1333
+ expect(document.activeElement).toBe(cells()[1])
1334
+ })
1335
+
1336
+ it('tree map cells, funnel segments, sankey nodes, scatter and radar dots are focusable and named', () => {
1337
+ const tree = render({ type: 'treemap', categories: [], series: [], tree: { name: 'r', children: [{ name: 'A', value: 3 }, { name: 'B', value: 1 }] } })
1338
+ expect(tabs(tree, '.sv-grid-chart-treemap-cell')).toEqual(['0', '-1'])
1339
+ unmount(app!); app = null
1340
+ const funnel = render({ type: 'funnel', categories: ['Visit', 'Sign up'], series: [{ label: 'n', values: [100, 40] }] })
1341
+ expect(tabs(funnel, '.sv-grid-chart-funnel-seg')).toEqual(['0', '-1'])
1342
+ expect(funnel.querySelector('.sv-grid-chart-funnel-seg')!.getAttribute('aria-label')).toBe('Visit: 100')
1343
+ unmount(app!); app = null
1344
+ const sankey = render({ type: 'sankey', categories: [], series: [], sankeyNodes: [{ id: 'a' }, { id: 'b' }], sankeyLinks: [{ source: 'a', target: 'b', value: 5 }] })
1345
+ expect(tabs(sankey, '.sv-grid-chart-sankey-node')).toEqual(['0', '-1'])
1346
+ expect(sankey.querySelector('.sv-grid-chart-sankey-node')!.getAttribute('aria-label')).toBe('a: in 0, out 5')
1347
+ unmount(app!); app = null
1348
+ const scatter = render({ type: 'scatter', categories: [], series: [{ label: 'pts', values: [], points: [{ x: 1, y: 2, label: 'p1' }, { x: 3, y: 4 }] }] })
1349
+ expect(tabs(scatter, '.sv-grid-chart-scatter')).toEqual(['0', '-1'])
1350
+ expect(scatter.querySelector('.sv-grid-chart-scatter')!.getAttribute('aria-label')).toBe('p1: 1, 2')
1351
+ unmount(app!); app = null
1352
+ const radar = render({ type: 'radar', categories: ['Speed', 'Range'], series: [{ label: 'A', values: [1, 2] }, { label: 'B', values: [2, 1] }] })
1353
+ expect(tabs(radar, '.sv-grid-chart-radar-dot')).toEqual(['0', '-1', '-1', '-1'])
1354
+ expect(radar.querySelector('.sv-grid-chart-radar-dot')!.getAttribute('aria-label')).toBe('A Speed: 1')
1355
+ })
1356
+
1357
+ it('+ / - / 0 zoom a focused category from the keyboard and Shift + arrows pan the window', async () => {
1358
+ const cats = Array.from({ length: 20 }, (_, i) => `c${i}`)
1359
+ const zooms: unknown[] = []
1360
+ const el = render(
1361
+ { type: 'line', categories: cats, series: [{ label: 'v', values: cats.map((_, i) => i) }] },
1362
+ { zoomable: true, onZoom: (z: unknown) => zooms.push(z) },
1363
+ )
1364
+ const hits = () => [...el.querySelectorAll('.sv-grid-chart-cat-hit')] as SVGElement[]
1365
+ expect(hits()).toHaveLength(20)
1366
+ key(hits()[10]!, '+')
1367
+ expect(hits().length).toBeLessThan(20)
1368
+ const first = zooms[0] as { i0: number; i1: number }
1369
+ expect(first.i0).toBeLessThanOrEqual(10)
1370
+ expect(first.i1).toBeGreaterThanOrEqual(10)
1371
+ await new Promise((r) => setTimeout(r, 0))
1372
+ // Focus followed the same category into the narrower window.
1373
+ expect(document.activeElement?.getAttribute('data-cat-index')).toBe(String(10 - first.i0))
1374
+ key(document.activeElement!, 'ArrowRight', { shiftKey: true })
1375
+ const panned = zooms[1] as { i0: number; i1: number }
1376
+ expect(panned.i0).toBeGreaterThan(first.i0)
1377
+ expect(panned.i1 - panned.i0).toBe(first.i1 - first.i0)
1378
+ await new Promise((r) => setTimeout(r, 0))
1379
+ key(document.activeElement!, '-')
1380
+ const wider = zooms[2] as { i0: number; i1: number } | null
1381
+ expect(wider === null || wider.i1 - wider.i0 > panned.i1 - panned.i0).toBe(true)
1382
+ key(document.activeElement!, '0')
1383
+ expect(zooms[zooms.length - 1]).toBeNull()
1384
+ expect(hits()).toHaveLength(20)
1385
+ })
1386
+
1387
+ it('the brush strip keeps its whole height for the mini-map: no title, labels or marks inherited from the spec', () => {
1388
+ // A title and subtitle alone reserved 45 of the strip's 88 pixels, and
1389
+ // the mini-map was a five-pixel smear under an empty band.
1390
+ const cats = Array.from({ length: 30 }, (_, i) => `d${i}`)
1391
+ const spec: ChartSpec = {
1392
+ type: 'line', categories: cats, width: 600,
1393
+ series: [{ label: 'v', values: cats.map((_, i) => 10 + (i % 7) * 5) }],
1394
+ title: 'A title', subtitle: 'and a subtitle', caption: 'and a caption',
1395
+ seriesLabels: true, dataLabels: { show: true },
1396
+ referenceBands: [{ from: 'd3', to: 'd5', axis: 'x', label: 'band' }],
1397
+ annotations: [{ at: { category: 'd10' }, label: 'note' }],
1398
+ }
1399
+ const el = render(spec, { zoomable: true, brush: true, brushHeight: 88 })
1400
+ const brush = el.querySelector('.sv-grid-chart-brush') as SVGElement
1401
+ const window = brush.querySelector('.sv-grid-chart-brush-window')!
1402
+ // The window rect spans the strip, not a sliver at its foot.
1403
+ expect(Number(window.getAttribute('height'))).toBeGreaterThan(50)
1404
+ expect(Number(window.getAttribute('y'))).toBeLessThan(20)
1405
+ // Nothing but the mini-map is drawn in it.
1406
+ expect(brush.querySelectorAll('text').length).toBe(0)
1407
+ expect(brush.querySelector('path')).toBeTruthy()
1408
+ })
1409
+
1410
+ it('the brush is a slider: arrows pan and resize the window, Home / End push it to the ends, 0 resets', () => {
1411
+ const cats = Array.from({ length: 30 }, (_, i) => `d${i}`)
1412
+ const el = render({ type: 'line', categories: cats, series: [{ label: 'v', values: cats.map((_, i) => i) }] }, { zoomable: true, brush: true })
1413
+ const brush = el.querySelector('.sv-grid-chart-brush') as SVGElement
1414
+ expect(brush.getAttribute('role')).toBe('slider')
1415
+ expect(brush.getAttribute('tabindex')).toBe('0')
1416
+ expect(brush.getAttribute('aria-valuemax')).toBe('29')
1417
+ expect(brush.getAttribute('aria-valuetext')).toBe('d0 to d29')
1418
+ const hits = () => el.querySelectorAll('.sv-grid-chart-cat-hit').length
1419
+ key(brush, 'ArrowUp')
1420
+ const narrowed = hits()
1421
+ expect(narrowed).toBeLessThan(30)
1422
+ expect(brush.getAttribute('aria-valuetext')).not.toBe('d0 to d29')
1423
+ key(brush, 'End')
1424
+ expect(brush.getAttribute('aria-valuetext')).toMatch(/ to d29$/)
1425
+ expect(hits()).toBe(narrowed)
1426
+ key(brush, 'ArrowLeft')
1427
+ expect(brush.getAttribute('aria-valuetext')).not.toMatch(/ to d29$/)
1428
+ key(brush, 'Home')
1429
+ expect(brush.getAttribute('aria-valuetext')).toMatch(/^d0 to /)
1430
+ key(brush, '0')
1431
+ expect(hits()).toBe(30)
1432
+ expect(brush.getAttribute('aria-valuenow')).toBe('0')
1433
+ })
1434
+
1435
+ it('localeText translates the toolbar, the legend hints, the image label and the data table', () => {
1436
+ const el = render(
1437
+ { type: 'bar', categories: ['a'], series: [{ label: 's', values: [1] }] },
1438
+ { toolbar: true, legend: true, localeText: { png: 'Bild', downloadPng: 'Als PNG speichern', chartLabel: 'Diagramm ({type})', dataTable: 'Daten: {type}', hideSeries: 'Ausblenden', isolateHint: 'Doppelklick isoliert' } },
1439
+ )
1440
+ const png = [...el.querySelectorAll('.sv-grid-chart-tool')].find((b) => b.textContent?.trim() === 'Bild') as HTMLButtonElement
1441
+ expect(png).toBeTruthy()
1442
+ expect(png.getAttribute('title')).toBe('Als PNG speichern')
1443
+ expect(el.querySelector('.sv-grid-chart-svg')!.getAttribute('aria-label')).toBe('Diagramm (bar)')
1444
+ expect(el.querySelector('caption')!.textContent!.trim()).toBe('Daten: bar')
1445
+ expect(el.querySelector('.sv-grid-chart-legend-item')!.getAttribute('title')).toBe('Ausblenden s \u00b7 Doppelklick isoliert')
1446
+ // Unset keys stay English.
1447
+ expect([...el.querySelectorAll('.sv-grid-chart-tool')].some((b) => b.textContent?.trim() === 'SVG')).toBe(true)
1448
+ expect(el.querySelector('.sv-grid-chart-toolbar')!.getAttribute('role')).toBe('toolbar')
1449
+ })
1450
+ })
1451
+
1452
+ describe('SvGridChart overlays in the tooltip', () => {
1453
+ it('lists the regression value at the category with its R-squared, and a moving average without one', () => {
1454
+ const el = render({
1455
+ type: 'line',
1456
+ categories: ['a', 'b', 'c', 'd', 'e', 'f'],
1457
+ series: [
1458
+ { label: 'sales', values: [1, 2, 3, 4, 5, 6], overlay: 'poly:2' },
1459
+ { label: 'cost', values: [2, 2, 2, 2, 2, 2], overlay: 'sma:2' },
1460
+ ],
1461
+ })
1462
+ ;(el.querySelectorAll('.sv-grid-chart-cat-hit')[3] as SVGElement).dispatchEvent(new FocusEvent('focus'))
1463
+ flushSync()
1464
+ const rows = [...el.querySelectorAll('.sv-grid-chart-tooltip-row')].map((r) => r.textContent?.replace(/\s+/g, ' ').trim())
1465
+ expect(rows.some((r) => /sales poly 2.*4.*R\u00b2 1\.00/.test(r ?? ''))).toBe(true)
1466
+ expect(rows.some((r) => /cost SMA 2.*2$/.test(r ?? '') && !/R\u00b2/.test(r ?? ''))).toBe(true)
1467
+ })
1468
+ })
1469
+
1470
+ describe('SvGridChart hover highlight and onHover', () => {
1471
+ const spec: ChartSpec = {
1472
+ type: 'line',
1473
+ categories: ['a', 'b', 'c'],
1474
+ series: [{ label: 'high', values: [90, 90, 90] }, { label: 'low', values: [10, 10, 10] }],
1475
+ width: 600,
1476
+ height: 300,
1477
+ }
1478
+ const mock = (el: HTMLElement) => {
1479
+ const svg = el.querySelector('.sv-grid-chart-svg') as SVGSVGElement
1480
+ svg.getBoundingClientRect = () => ({ left: 0, top: 0, width: 600, height: 300, right: 600, bottom: 300, x: 0, y: 0, toJSON: () => ({}) })
1481
+ return svg
1482
+ }
1483
+ const lineOpacity = (el: HTMLElement) =>
1484
+ [...el.querySelectorAll('.sv-grid-chart-linepath')].map((n) => (n.parentElement as HTMLElement).style.opacity)
1485
+ const move = (el: Element, clientX: number, clientY: number) => {
1486
+ el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX, clientY }))
1487
+ flushSync()
1488
+ }
1489
+ const yOfLine = (el: HTMLElement, label: string) => {
1490
+ // The line's first point y from its path, mapped to client space (1:1 here).
1491
+ const geoPath = [...el.querySelectorAll('.sv-grid-chart-linepath')].map((n) => n.getAttribute('d')!)
1492
+ const idx = label === 'high' ? 0 : 1
1493
+ return Number(/M[\d.]+,([\d.]+)/.exec(geoPath[idx]!)![1])
1494
+ }
1495
+
1496
+ it('hovering near a line dims the other series and reports the point once', () => {
1497
+ const points: unknown[] = []
1498
+ const el = render(spec, { onHover: (p: unknown) => points.push(p) })
1499
+ mock(el)
1500
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement
1501
+ move(hit, 300, yOfLine(el, 'high') + 4)
1502
+ expect(lineOpacity(el)).toEqual(['1', '0.18'])
1503
+ move(hit, 301, yOfLine(el, 'high') + 4)
1504
+ expect(points).toEqual([{ category: 'b', index: 1, series: undefined, value: undefined }].map((x) => ({ ...x, series: null, value: null })))
1505
+ hit.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }))
1506
+ flushSync()
1507
+ expect(lineOpacity(el)).toEqual(['1', '1'])
1508
+ expect(points[points.length - 1]).toBeNull()
1509
+ expect(points).toHaveLength(2)
1510
+ })
1511
+
1512
+ it('a pointer far from every mark dims nothing, and hoverHighlight false never dims but still reports', () => {
1513
+ const el = render(spec)
1514
+ mock(el)
1515
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement
1516
+ const mid = (yOfLine(el, 'high') + yOfLine(el, 'low')) / 2
1517
+ move(hit, 300, mid)
1518
+ expect(lineOpacity(el)).toEqual(['1', '1'])
1519
+ unmount(app!); app = null
1520
+ const points: unknown[] = []
1521
+ const off = render(spec, { hoverHighlight: false, onHover: (p: unknown) => points.push(p) })
1522
+ mock(off)
1523
+ const hit2 = off.querySelectorAll('.sv-grid-chart-cat-hit')[2] as SVGElement
1524
+ move(hit2, 500, yOfLine(off, 'low') + 2)
1525
+ expect(lineOpacity(off)).toEqual(['1', '1'])
1526
+ expect(points).toEqual([{ category: 'c', index: 2, series: null, value: null }])
1527
+ })
1528
+
1529
+ it('single tooltip mode names the nearest series in the report', () => {
1530
+ const points: Array<{ series: string | null; value: number | null }> = []
1531
+ const el = render(spec, { tooltipMode: 'single', onHover: (p: { series: string | null; value: number | null }) => points.push(p) })
1532
+ mock(el)
1533
+ const hit = el.querySelectorAll('.sv-grid-chart-cat-hit')[0] as SVGElement
1534
+ move(hit, 100, yOfLine(el, 'low') + 3)
1535
+ expect(points[0]).toEqual({ category: 'a', index: 0, series: 'low', value: 10 })
1536
+ expect(lineOpacity(el)).toEqual(['0.18', '1'])
1537
+ })
1538
+ })
1539
+
1540
+ describe('SvGridChart tooltip position and sticky tooltips', () => {
1541
+ const spec: ChartSpec = {
1542
+ type: 'bar',
1543
+ categories: ['a', 'b', 'c'],
1544
+ series: [{ label: 's', values: [1, 2, 3] }],
1545
+ width: 600,
1546
+ height: 300,
1547
+ }
1548
+ const mock = (el: HTMLElement) => {
1549
+ const box = { left: 0, top: 0, width: 600, height: 300, right: 600, bottom: 300, x: 0, y: 0, toJSON: () => ({}) }
1550
+ ;(el.querySelector('.sv-grid-chart-svg') as SVGSVGElement).getBoundingClientRect = () => box
1551
+ ;(el.querySelector('.sv-grid-chart') as HTMLElement).getBoundingClientRect = () => box
1552
+ }
1553
+ const hover = (el: HTMLElement, i: number, x = 300, y = 150) => {
1554
+ el.querySelectorAll('.sv-grid-chart-cat-hit')[i]!.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: x, clientY: y }))
1555
+ flushSync()
1556
+ }
1557
+ const tipEl = (el: HTMLElement) => el.querySelector('.sv-grid-chart-tooltip') as HTMLElement | null
1558
+
1559
+ it('a corner position pins the tooltip at the plot corner without the follow transform', () => {
1560
+ const el = render(spec, { tooltipPosition: 'top-right' })
1561
+ mock(el)
1562
+ hover(el, 1)
1563
+ const t = tipEl(el)!
1564
+ expect(t.classList.contains('is-fixed')).toBe(true)
1565
+ expect(t.classList.contains('is-right')).toBe(true)
1566
+ expect(t.classList.contains('is-below')).toBe(false)
1567
+ // At the plot's top-right corner (inset 8px), not at the pointer.
1568
+ const plotRight = Number(/x="([\d.]+)"/.exec(el.querySelectorAll('.sv-grid-chart-cat-hit')[2]!.outerHTML)![1]) + Number(el.querySelectorAll('.sv-grid-chart-cat-hit')[2]!.getAttribute('width'))
1569
+ expect(parseFloat(t.style.left)).toBeCloseTo(plotRight - 8, 0)
1570
+ expect(parseFloat(t.style.top)).toBeLessThan(60)
1571
+ hover(el, 0, 100, 250)
1572
+ expect(parseFloat(tipEl(el)!.style.left)).toBeCloseTo(plotRight - 8, 0)
1573
+ })
1574
+
1575
+ it('sticky pins on click, survives the pointer leaving, and releases on Escape or a second click', () => {
1576
+ const el = render(spec, { tooltipSticky: true })
1577
+ mock(el)
1578
+ const hits = el.querySelectorAll('.sv-grid-chart-cat-hit')
1579
+ hover(el, 1)
1580
+ hits[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 300, clientY: 150 }))
1581
+ flushSync()
1582
+ expect(tipEl(el)!.classList.contains('is-pinned')).toBe(true)
1583
+ const title = tipEl(el)!.querySelector('.sv-grid-chart-tooltip-title')!.textContent
1584
+ expect(title).toBe('b')
1585
+ hits[1]!.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }))
1586
+ flushSync()
1587
+ expect(tipEl(el)).toBeTruthy()
1588
+ // Another category does not move a pinned tooltip.
1589
+ hover(el, 2, 500, 150)
1590
+ expect(tipEl(el)!.querySelector('.sv-grid-chart-tooltip-title')!.textContent).toBe('b')
1591
+ el.querySelector('.sv-grid-chart-svg')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1592
+ flushSync()
1593
+ expect(tipEl(el)).toBeNull()
1594
+ // Pin again, then release with a second click on the same category.
1595
+ hover(el, 0, 100, 150)
1596
+ hits[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 150 }))
1597
+ flushSync()
1598
+ expect(tipEl(el)!.classList.contains('is-pinned')).toBe(true)
1599
+ hits[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 150 }))
1600
+ flushSync()
1601
+ expect(tipEl(el)).toBeNull()
1602
+ })
1603
+
1604
+ it('a pointer down outside the chart releases a pinned tooltip', () => {
1605
+ const el = render(spec, { tooltipSticky: true })
1606
+ mock(el)
1607
+ hover(el, 1)
1608
+ el.querySelectorAll('.sv-grid-chart-cat-hit')[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 300, clientY: 150 }))
1609
+ flushSync()
1610
+ expect(tipEl(el)!.classList.contains('is-pinned')).toBe(true)
1611
+ document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
1612
+ flushSync()
1613
+ expect(tipEl(el)).toBeNull()
1614
+ })
1615
+ })
1616
+
1617
+ describe('SvGridChart data-label leaders and rotation', () => {
1618
+ it('draws a leader per pushed label and rotates the text', () => {
1619
+ const el = render(
1620
+ {
1621
+ type: 'bar',
1622
+ categories: Array.from({ length: 12 }, (_, i) => `c${i}`),
1623
+ series: [{ label: 's', values: Array.from({ length: 12 }, (_, i) => 1000 + i) }],
1624
+ width: 260,
1625
+ height: 200,
1626
+ },
1627
+ { dataLabels: { connector: true, rotation: -30 } },
1628
+ )
1629
+ expect(el.querySelectorAll('.sv-grid-chart-datalabel-leader').length).toBeGreaterThan(0)
1630
+ const t = el.querySelector('.sv-grid-chart-datalabel') as SVGTextElement
1631
+ expect(t.getAttribute('transform')).toMatch(/^rotate\(-30 /)
1632
+ })
1633
+ })
1634
+
1635
+ describe('SvGridChart spec.style', () => {
1636
+ it('lands on the host as custom properties and a font, and stays off when unset', () => {
1637
+ const el = render({
1638
+ type: 'bar',
1639
+ categories: ['a'],
1640
+ series: [{ label: 's', values: [1] }],
1641
+ style: { background: '#101820', textColor: '#f4f4f4', fontSize: 14, fontFamily: 'Georgia, serif' },
1642
+ })
1643
+ const host = el.querySelector('.sv-grid-chart') as HTMLElement
1644
+ expect(host.style.getPropertyValue('--sg-chart-bg')).toBe('#101820')
1645
+ expect(host.style.getPropertyValue('--sg-fg')).toBe('#f4f4f4')
1646
+ expect(host.style.getPropertyValue('--sg-chart-font-scale')).toBe('1.167')
1647
+ expect(host.style.fontFamily).toBe('Georgia, serif')
1648
+ unmount(app!); app = null
1649
+ const plain = render({ type: 'bar', categories: ['a'], series: [{ label: 's', values: [1] }] })
1650
+ expect((plain.querySelector('.sv-grid-chart') as HTMLElement).getAttribute('style')).toBeNull()
1651
+ })
1652
+ })
1653
+
1654
+ describe('SvGridChart dev diagnostics', () => {
1655
+ it('warns once per spec object about what is off, and not at all for a clean spec', async () => {
1656
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
1657
+ try {
1658
+ const bad = { type: 'bar', categories: ['a', 'b'], series: [{ label: 's', values: [1] }], serie: [] } as unknown as ChartSpec
1659
+ const el = render(bad)
1660
+ await vi.waitFor(() => expect(warn).toHaveBeenCalled())
1661
+ const msgs = warn.mock.calls.map((c) => String(c[0]))
1662
+ expect(msgs.some((m) => m.includes('[SvChart] serie:') && m.includes('did you mean "series"'))).toBe(true)
1663
+ expect(msgs.some((m) => m.includes('series[0].values: 1 values for 2 categories'))).toBe(true)
1664
+ const count = warn.mock.calls.length
1665
+ // The same object again: nothing new.
1666
+ unmount(app!); app = null
1667
+ render(bad)
1668
+ await new Promise((r) => setTimeout(r, 20))
1669
+ expect(warn.mock.calls.length).toBe(count)
1670
+ unmount(app!); app = null
1671
+ warn.mockClear()
1672
+ render({ type: 'bar', categories: ['a', 'b'], series: [{ label: 's', values: [1, 2] }] })
1673
+ await new Promise((r) => setTimeout(r, 20))
1674
+ expect(warn).not.toHaveBeenCalled()
1675
+ void el
1676
+ } finally {
1677
+ warn.mockRestore()
1678
+ }
1679
+ })
1680
+ })
1681
+
1682
+ describe('SvGridChart describe', () => {
1683
+ const spec: ChartSpec = {
1684
+ type: 'line',
1685
+ categories: ['Jan', 'Feb', 'Mar'],
1686
+ series: [{ label: 'Revenue', values: [100, 120, 150] }],
1687
+ }
1688
+
1689
+ it('sets aria-description and the caption from the summary, and the menu copies it', async () => {
1690
+ const written: string[] = []
1691
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (t: string) => { written.push(t) } }, configurable: true })
1692
+ const el = render(spec, { contextMenu: true })
1693
+ const svg = el.querySelector('.sv-grid-chart-svg')!
1694
+ await vi.waitFor(() => expect(svg.getAttribute('aria-description')).toContain('Revenue rises 50%'))
1695
+ expect(el.querySelector('caption')!.textContent).toContain('Revenue rises 50% from 100 at Jan to 150 at Mar.')
1696
+ svg.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50, cancelable: true }))
1697
+ await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-menu')).toBeTruthy() })
1698
+ const item = [...document.querySelectorAll('.sv-grid-chart-menu [role="menuitem"]')].find((n) => n.textContent?.trim() === 'Describe chart') as HTMLElement
1699
+ expect(item).toBeTruthy()
1700
+ item.click()
1701
+ await vi.waitFor(() => expect(written).toEqual(['Revenue rises 50% from 100 at Jan to 150 at Mar.']))
1702
+ await vi.waitFor(() => expect(el.querySelector('.sv-grid-chart-live')!.textContent).toContain('Description copied'))
1703
+ document.querySelector('.sv-grid-chart-menu')?.remove()
1704
+ })
1705
+
1706
+ it('describe false leaves the description and the menu item out', async () => {
1707
+ const el = render(spec, { describe: false, contextMenu: true })
1708
+ await new Promise((r) => setTimeout(r, 30))
1709
+ expect(el.querySelector('.sv-grid-chart-svg')!.getAttribute('aria-description')).toBeNull()
1710
+ el.querySelector('.sv-grid-chart-svg')!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 50, clientY: 50, cancelable: true }))
1711
+ await vi.waitFor(() => { expect(document.querySelector('.sv-grid-chart-menu')).toBeTruthy() })
1712
+ const labels = [...document.querySelectorAll('.sv-grid-chart-menu [role="menuitem"]')].map((n) => n.textContent?.trim())
1713
+ expect(labels).not.toContain('Describe chart')
1714
+ document.querySelector('.sv-grid-chart-menu')?.remove()
1715
+ })
1716
+ })
1717
+
1718
+ describe('SvGridChart live', () => {
1719
+ it('skips the enter effect and the update tween while animate is on', () => {
1720
+ const spec: ChartSpec = { type: 'bar', categories: ['a'], series: [{ label: 's', values: [1] }] }
1721
+ const el = render(spec, { animate: { enter: 'grow' }, live: true })
1722
+ expect(el.querySelector('.sv-grid-chart-marks')!.classList.contains('is-enter-none')).toBe(true)
1723
+ expect(el.querySelector('.sv-grid-chart-marks')!.classList.contains('is-enter-grow')).toBe(false)
1724
+ unmount(app!); app = null
1725
+ // Without live the same props grow in.
1726
+ const plain = render(spec, { animate: { enter: 'grow' } })
1727
+ expect(plain.querySelector('.sv-grid-chart-marks')!.classList.contains('is-enter-grow')).toBe(true)
1728
+ })
311
1729
  })