@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.
- package/README.md +2 -1
- package/dist/GridMenus.svelte +44 -2
- package/dist/SvChartMenu.svelte +69 -0
- package/dist/SvChartMenu.svelte.d.ts +11 -0
- package/dist/SvChartPanes.svelte +113 -0
- package/dist/SvChartPanes.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.d.ts +110 -39
- package/dist/SvGrid.controller.svelte.js +655 -200
- package/dist/SvGrid.css +105 -2
- package/dist/SvGrid.svelte +386 -514
- package/dist/SvGrid.types.d.ts +210 -9
- package/dist/SvGridCellEditor.svelte +17 -0
- package/dist/SvGridChart.svelte +2257 -329
- package/dist/SvGridChart.svelte.d.ts +6 -106
- package/dist/SvGridChart.types.d.ts +338 -0
- package/dist/SvGridChart.types.js +1 -0
- package/dist/SvGridChartBuilder.svelte +323 -0
- package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
- package/dist/SvGridChartPanel.svelte +221 -275
- package/dist/SvGridChartPickers.svelte +417 -0
- package/dist/SvGridChartPickers.svelte.d.ts +70 -0
- package/dist/SvGridChartView.svelte +13 -14
- package/dist/SvModal.svelte +7 -1
- package/dist/SvModal.svelte.d.ts +5 -0
- package/dist/ai.d.ts +40 -8
- package/dist/ai.js +135 -24
- package/dist/aria.d.ts +11 -0
- package/dist/build-api.js +108 -22
- package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
- package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
- package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
- package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
- package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
- package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
- package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
- package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
- package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
- package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
- package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
- package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
- package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
- package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
- package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
- package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
- package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
- package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
- package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
- package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
- package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
- package/dist/cdn/SvModal-CIZWCcad.js +397 -0
- package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
- package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
- package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
- package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
- package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
- package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
- package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
- package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
- package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
- package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
- package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
- package/dist/cdn/dismissable-DAHetSNk.js +44 -0
- package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
- package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
- package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
- package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
- package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
- package/dist/cdn/svgrid.js +26 -15
- package/dist/cdn/svgrid.svelte-external.js +26 -15
- package/dist/chart-axes.d.ts +53 -0
- package/dist/chart-axes.js +351 -0
- package/dist/chart-cartesian.d.ts +88 -0
- package/dist/chart-cartesian.js +1862 -0
- package/dist/chart-decimate.d.ts +51 -0
- package/dist/chart-decimate.js +199 -0
- package/dist/chart-export-pdf.d.ts +47 -0
- package/dist/chart-export-pdf.js +187 -0
- package/dist/chart-export.d.ts +2 -1
- package/dist/chart-export.js +18 -3
- package/dist/chart-financial.d.ts +91 -0
- package/dist/chart-financial.js +175 -0
- package/dist/chart-flow.d.ts +10 -0
- package/dist/chart-flow.js +298 -0
- package/dist/chart-format.d.ts +18 -0
- package/dist/chart-format.js +65 -0
- package/dist/chart-grid.d.ts +8 -0
- package/dist/chart-grid.js +190 -0
- package/dist/chart-hierarchy.d.ts +17 -0
- package/dist/chart-hierarchy.js +146 -0
- package/dist/chart-indicators.d.ts +115 -0
- package/dist/chart-indicators.js +389 -0
- package/dist/chart-messages.d.ts +95 -0
- package/dist/chart-messages.js +92 -0
- package/dist/chart-motion.d.ts +16 -0
- package/dist/chart-motion.js +141 -0
- package/dist/chart-panel-messages.d.ts +265 -0
- package/dist/chart-panel-messages.js +285 -0
- package/dist/chart-pivot.d.ts +87 -0
- package/dist/chart-pivot.js +156 -0
- package/dist/chart-polar.d.ts +25 -0
- package/dist/chart-polar.js +700 -0
- package/dist/chart-samples.d.ts +16 -0
- package/dist/chart-samples.js +153 -0
- package/dist/chart-scale.d.ts +113 -0
- package/dist/chart-scale.js +405 -0
- package/dist/chart-stats.d.ts +111 -0
- package/dist/chart-stats.js +419 -0
- package/dist/chart-stream.d.ts +45 -0
- package/dist/chart-stream.js +91 -0
- package/dist/chart-summary.d.ts +27 -0
- package/dist/chart-summary.js +205 -0
- package/dist/chart-sync.svelte.d.ts +32 -0
- package/dist/chart-sync.svelte.js +23 -0
- package/dist/chart-table.d.ts +35 -0
- package/dist/chart-table.js +165 -0
- package/dist/chart-trend.d.ts +19 -0
- package/dist/chart-trend.js +61 -0
- package/dist/chart-types.d.ts +1456 -0
- package/dist/chart-types.js +6 -0
- package/dist/chart-validate.d.ts +43 -0
- package/dist/chart-validate.js +272 -0
- package/dist/chart-zoom.d.ts +40 -0
- package/dist/chart-zoom.js +145 -0
- package/dist/chart.d.ts +186 -865
- package/dist/chart.js +460 -2204
- package/dist/clipboard.d.ts +1 -0
- package/dist/clipboard.js +137 -18
- package/dist/column-resize.d.ts +3 -0
- package/dist/column-resize.js +4 -1
- package/dist/columns.js +3 -0
- package/dist/command-context.d.ts +19 -0
- package/dist/command-context.js +116 -0
- package/dist/core.d.ts +7 -0
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.d.ts +2 -8
- package/dist/editing.js +262 -33
- package/dist/fill-patterns.d.ts +0 -5
- package/dist/fill-patterns.js +56 -2
- package/dist/grid-messages.d.ts +10 -3
- package/dist/grid-messages.js +5 -0
- package/dist/history.d.ts +83 -0
- package/dist/history.js +133 -0
- package/dist/index.d.ts +24 -6
- package/dist/index.js +20 -2
- package/dist/keyboard-handlers.js +88 -31
- package/dist/keyboard.d.ts +55 -0
- package/dist/keyboard.js +132 -0
- package/dist/menus.d.ts +3 -1
- package/dist/menus.js +7 -2
- package/dist/merges.d.ts +97 -0
- package/dist/merges.js +147 -0
- package/dist/row-resize.d.ts +3 -0
- package/dist/row-resize.js +14 -0
- package/dist/scroll-sync.d.ts +1 -0
- package/dist/scroll-sync.js +24 -0
- package/dist/selection.d.ts +16 -0
- package/dist/selection.js +108 -16
- package/dist/shortcut-registry.d.ts +145 -0
- package/dist/shortcut-registry.js +44 -0
- package/dist/svgrid-wrapper.types.d.ts +213 -22
- package/dist/virtualization/virtualizer.js +38 -17
- package/package.json +6 -1
- package/src/GridMenus.svelte +44 -2
- package/src/SvChartMenu.svelte +69 -0
- package/src/SvChartPanes.svelte +113 -0
- package/src/SvChartPanes.test.ts +66 -0
- package/src/SvGrid.controller.svelte.ts +629 -172
- package/src/SvGrid.css +105 -2
- package/src/SvGrid.svelte +386 -514
- package/src/SvGrid.types.ts +188 -8
- package/src/SvGridCellEditor.svelte +17 -0
- package/src/SvGridChart.svelte +2257 -329
- package/src/SvGridChart.test.ts +1419 -1
- package/src/SvGridChart.types.ts +336 -0
- package/src/SvGridChartBuilder.svelte +323 -0
- package/src/SvGridChartPanel.svelte +221 -275
- package/src/SvGridChartPickers.svelte +417 -0
- package/src/SvGridChartView.svelte +13 -14
- package/src/SvModal.svelte +7 -1
- package/src/SvModal.test.ts +9 -0
- package/src/ai.test.ts +57 -2
- package/src/ai.ts +166 -22
- package/src/aria.d.ts +11 -0
- package/src/build-api.coverage.test.ts +184 -0
- package/src/build-api.ts +107 -18
- package/src/chart-axes.test.ts +573 -0
- package/src/chart-axes.ts +342 -0
- package/src/chart-cartesian.ts +1863 -0
- package/src/chart-decimate.test.ts +249 -0
- package/src/chart-decimate.ts +186 -0
- package/src/chart-export-pdf.test.ts +91 -0
- package/src/chart-export-pdf.ts +207 -0
- package/src/chart-export.test.ts +14 -0
- package/src/chart-export.ts +13 -3
- package/src/chart-financial.ts +208 -0
- package/src/chart-flow.ts +286 -0
- package/src/chart-format.ts +67 -0
- package/src/chart-grid.ts +179 -0
- package/src/chart-hierarchy.ts +135 -0
- package/src/chart-indicators.test.ts +443 -0
- package/src/chart-indicators.ts +384 -0
- package/src/chart-messages.test.ts +21 -0
- package/src/chart-messages.ts +184 -0
- package/src/chart-motion.test.ts +71 -0
- package/src/chart-motion.ts +148 -0
- package/src/chart-panel-messages.test.ts +29 -0
- package/src/chart-panel-messages.ts +550 -0
- package/src/chart-pivot.test.ts +54 -0
- package/src/chart-pivot.ts +225 -0
- package/src/chart-polar.ts +674 -0
- package/src/chart-samples.ts +106 -0
- package/src/chart-scale.ts +394 -0
- package/src/chart-series-types.test.ts +714 -0
- package/src/chart-stats.ts +409 -0
- package/src/chart-stream.test.ts +75 -0
- package/src/chart-stream.ts +114 -0
- package/src/chart-summary.test.ts +90 -0
- package/src/chart-summary.ts +197 -0
- package/src/chart-sync.svelte.ts +41 -0
- package/src/chart-table.test.ts +76 -0
- package/src/chart-table.ts +178 -0
- package/src/chart-trend.ts +47 -0
- package/src/chart-types.ts +1309 -0
- package/src/chart-validate.test.ts +100 -0
- package/src/chart-validate.ts +247 -0
- package/src/chart-zoom.test.ts +137 -0
- package/src/chart-zoom.ts +124 -0
- package/src/chart.coverage.test.ts +81 -0
- package/src/chart.test.ts +56 -3
- package/src/chart.ts +557 -2784
- package/src/clipboard.test.ts +151 -0
- package/src/clipboard.ts +145 -16
- package/src/column-resize.ts +7 -1
- package/src/columns.ts +2 -0
- package/src/command-context.test.ts +200 -0
- package/src/command-context.ts +137 -0
- package/src/core.ts +7 -0
- package/src/editing.test.ts +281 -3
- package/src/editing.ts +256 -38
- package/src/fill-patterns.test.ts +35 -0
- package/src/fill-patterns.ts +56 -2
- package/src/grid-messages.ts +16 -3
- package/src/history.test.ts +196 -0
- package/src/history.ts +162 -0
- package/src/icon-seam.test.ts +3 -2
- package/src/index.ts +150 -2
- package/src/keyboard-handlers.coverage.test.ts +126 -6
- package/src/keyboard-handlers.ts +91 -30
- package/src/keyboard-shortcuts.seam.test.ts +200 -0
- package/src/keyboard.test.ts +124 -1
- package/src/keyboard.ts +142 -0
- package/src/menus.test.ts +26 -0
- package/src/menus.ts +10 -3
- package/src/merges.test.ts +112 -0
- package/src/merges.ts +168 -0
- package/src/pivot.grid.test.ts +40 -5
- package/src/row-resize.test.ts +44 -0
- package/src/row-resize.ts +17 -0
- package/src/scroll-sync.test.ts +42 -1
- package/src/scroll-sync.ts +25 -0
- package/src/selection.multi-range.test.ts +34 -0
- package/src/selection.test.ts +30 -2
- package/src/selection.ts +111 -19
- package/src/shortcut-registry.test.ts +97 -0
- package/src/shortcut-registry.ts +181 -0
- package/src/svgrid-wrapper.types.ts +184 -13
- package/src/svgrid.behavior.test.ts +135 -0
- package/src/svgrid.charting.test.ts +513 -3
- package/src/svgrid.context-menu.test.ts +31 -0
- package/src/svgrid.new-features.wrapper.test.ts +10 -3
- package/src/virtualization/virtualizer.ts +39 -17
- package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
- package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
- package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
- package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
- package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
- package/dist/cdn/chart-CKJRCPOk.js +0 -1652
package/src/SvGridChart.test.ts
CHANGED
|
@@ -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
|
})
|