@svgrid/grid 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/CHANGELOG.md +116 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/SvAutoComplete.svelte +169 -169
  6. package/dist/SvAvatar.svelte +75 -75
  7. package/dist/SvCalendar.svelte +503 -503
  8. package/dist/SvCarousel.svelte +141 -141
  9. package/dist/SvCheckBox.svelte +102 -102
  10. package/dist/SvCircularProgress.svelte +109 -109
  11. package/dist/SvColorInput.svelte +181 -181
  12. package/dist/SvComboBox.svelte +279 -279
  13. package/dist/SvContextMenu.svelte +116 -116
  14. package/dist/SvCountryInput.svelte +163 -163
  15. package/dist/SvDrawer.svelte +254 -254
  16. package/dist/SvDropDownList.svelte +378 -378
  17. package/dist/SvDurationInput.svelte +126 -126
  18. package/dist/SvField.svelte +293 -293
  19. package/dist/SvForm.svelte +437 -437
  20. package/dist/SvGridCellEditor.svelte +748 -744
  21. package/dist/SvGridChart.svelte +1716 -1716
  22. package/dist/SvGridChartPanel.svelte +485 -485
  23. package/dist/SvGridChartView.svelte +70 -70
  24. package/dist/SvGridDropdown.svelte +728 -728
  25. package/dist/SvGridSelect.svelte +270 -270
  26. package/dist/SvGroupCell.svelte +116 -116
  27. package/dist/SvListBox.svelte +334 -334
  28. package/dist/SvMaskedInput.svelte +122 -122
  29. package/dist/SvMenu.svelte +124 -124
  30. package/dist/SvMenuList.svelte +146 -146
  31. package/dist/SvMultiSelect.svelte +293 -293
  32. package/dist/SvNumberInput.svelte +172 -172
  33. package/dist/SvOtpInput.svelte +158 -158
  34. package/dist/SvPasswordInput.svelte +151 -151
  35. package/dist/SvPhoneInput.svelte +133 -133
  36. package/dist/SvPopover.svelte +197 -197
  37. package/dist/SvProgress.svelte +116 -116
  38. package/dist/SvRadioGroup.svelte +107 -107
  39. package/dist/SvRating.svelte +112 -112
  40. package/dist/SvResult.svelte +73 -73
  41. package/dist/SvRichText.svelte +211 -211
  42. package/dist/SvRowGroupPanel.svelte +170 -170
  43. package/dist/SvScrollArea.svelte +61 -61
  44. package/dist/SvSlider.svelte +203 -203
  45. package/dist/SvSwitchButton.svelte +108 -108
  46. package/dist/SvTagsInput.svelte +115 -115
  47. package/dist/SvTextInput.svelte +147 -147
  48. package/dist/SvTimePicker.svelte +245 -245
  49. package/dist/SvToaster.svelte +159 -159
  50. package/dist/SvToggleButton.svelte +85 -85
  51. package/dist/SvTooltip.svelte +161 -161
  52. package/dist/SvTour.svelte +208 -208
  53. package/dist/SvTree.svelte +444 -444
  54. package/dist/SvTreeSelect.svelte +239 -239
  55. package/dist/cdn/{GridMenus-DMihUtma.js → GridMenus-CYzOvmNQ.js} +3 -3
  56. package/dist/cdn/{GridMenus-nmNDj1a3.js → GridMenus-DFB6iwF8.js} +3 -3
  57. package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
  58. package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
  59. package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
  60. package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
  61. package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
  62. package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
  63. package/dist/cdn/{SvGridChart-CwhFz7GV.js → SvGridChart-BEJmNNx9.js} +1 -1
  64. package/dist/cdn/{SvGridChart-Bs2GIR2Q.js → SvGridChart-BwVos976.js} +1 -1
  65. package/dist/cdn/{SvGridChartPanel-1WXuStzM.js → SvGridChartPanel-BOknOkrP.js} +1 -1
  66. package/dist/cdn/{SvGridChartPanel-e5_nqNP9.js → SvGridChartPanel-D2PjII4O.js} +1 -1
  67. package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-00LPUHL1.js} +1 -1
  68. package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-BhUEJ5ki.js} +1 -1
  69. package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
  70. package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
  71. package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
  72. package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
  73. package/dist/cdn/{src-B_YS5AOc.js → src-C8QjSaq-.js} +42 -44
  74. package/dist/cdn/{src-DYXXpuSk.js → src-D0hO2eYN.js} +41 -43
  75. package/dist/cdn/svgrid.js +7 -7
  76. package/dist/cdn/svgrid.svelte-external.js +7 -7
  77. package/dist/chart-export.js +8 -8
  78. package/dist/editing.js +22 -13
  79. package/package.json +11 -11
  80. package/src/FlexRender.svelte +96 -96
  81. package/src/GridFooter.svelte +181 -181
  82. package/src/SvAutoComplete.svelte +169 -169
  83. package/src/SvAvatar.svelte +75 -75
  84. package/src/SvCalendar.svelte +503 -503
  85. package/src/SvCalendar.test.ts +226 -226
  86. package/src/SvCarousel.svelte +141 -141
  87. package/src/SvCheckBox.svelte +102 -102
  88. package/src/SvCircularProgress.svelte +109 -109
  89. package/src/SvColorInput.svelte +181 -181
  90. package/src/SvComboBox.svelte +279 -279
  91. package/src/SvContextMenu.svelte +116 -116
  92. package/src/SvCountryInput.svelte +163 -163
  93. package/src/SvDrawer.svelte +254 -254
  94. package/src/SvDropDownList.svelte +378 -378
  95. package/src/SvDurationInput.svelte +126 -126
  96. package/src/SvField.svelte +293 -293
  97. package/src/SvForm.svelte +437 -437
  98. package/src/SvForm.test.ts +411 -411
  99. package/src/SvGrid.types.ts +2092 -2092
  100. package/src/SvGridCellEditor.svelte +748 -744
  101. package/src/SvGridChart.svelte +1716 -1716
  102. package/src/SvGridChartPanel.svelte +485 -485
  103. package/src/SvGridChartView.svelte +70 -70
  104. package/src/SvGridDropdown.svelte +728 -728
  105. package/src/SvGridSelect.svelte +270 -270
  106. package/src/SvGroupCell.svelte +116 -116
  107. package/src/SvListBox.svelte +334 -334
  108. package/src/SvMaskedInput.svelte +122 -122
  109. package/src/SvMenu.svelte +124 -124
  110. package/src/SvMenu.test.ts +97 -97
  111. package/src/SvMenuList.svelte +146 -146
  112. package/src/SvMultiSelect.svelte +293 -293
  113. package/src/SvNumberInput.svelte +172 -172
  114. package/src/SvOtpInput.svelte +158 -158
  115. package/src/SvPasswordInput.svelte +151 -151
  116. package/src/SvPhoneInput.svelte +133 -133
  117. package/src/SvPopover.svelte +197 -197
  118. package/src/SvProgress.svelte +116 -116
  119. package/src/SvRadioGroup.svelte +107 -107
  120. package/src/SvRating.svelte +112 -112
  121. package/src/SvResult.svelte +73 -73
  122. package/src/SvRichText.svelte +211 -211
  123. package/src/SvRowGroupPanel.svelte +170 -170
  124. package/src/SvScrollArea.svelte +61 -61
  125. package/src/SvSlider.svelte +203 -203
  126. package/src/SvSwitchButton.svelte +108 -108
  127. package/src/SvTagsInput.svelte +115 -115
  128. package/src/SvTextInput.svelte +147 -147
  129. package/src/SvTimePicker.svelte +245 -245
  130. package/src/SvToaster.svelte +159 -159
  131. package/src/SvToaster.test.ts +95 -95
  132. package/src/SvToggleButton.svelte +85 -85
  133. package/src/SvTooltip.svelte +161 -161
  134. package/src/SvTour.svelte +208 -208
  135. package/src/SvTree.svelte +444 -444
  136. package/src/SvTreeSelect.svelte +239 -239
  137. package/src/a11y/dismissable.test.ts +119 -119
  138. package/src/a11y/dismissable.ts +114 -114
  139. package/src/a11y.contract.test.ts +49 -49
  140. package/src/a11y.test.ts +59 -59
  141. package/src/a11y.ts +61 -61
  142. package/src/ai.test.ts +502 -502
  143. package/src/ai.ts +1419 -1419
  144. package/src/build-api.coverage.test.ts +633 -633
  145. package/src/build-api.ts +846 -846
  146. package/src/builtin-editors.grid.test.ts +83 -83
  147. package/src/cell-formatting.ts +171 -171
  148. package/src/cell-render.test.ts +513 -513
  149. package/src/cell-render.ts +496 -496
  150. package/src/cell-values.ts +148 -148
  151. package/src/chart-export.ts +202 -202
  152. package/src/chart-view.svelte.ts +36 -36
  153. package/src/chart.ts +2321 -2321
  154. package/src/collaboration.test.ts +104 -104
  155. package/src/collaboration.ts +167 -167
  156. package/src/column-groups.ts +78 -78
  157. package/src/core.performance.test.ts +30 -30
  158. package/src/core.ts +1865 -1865
  159. package/src/createAutocomplete.svelte.ts +132 -132
  160. package/src/createCombobox.svelte.ts +191 -191
  161. package/src/createCountryInput.svelte.ts +157 -157
  162. package/src/createDropdownList.svelte.ts +168 -168
  163. package/src/createForm.svelte.ts +386 -386
  164. package/src/createGrid.svelte.ts +42 -42
  165. package/src/createGrid.test.ts +10 -10
  166. package/src/createGridState.svelte.ts +17 -17
  167. package/src/createListbox.svelte.ts +250 -250
  168. package/src/createMenu.svelte.ts +224 -224
  169. package/src/createPopoverSelect.svelte.ts +213 -213
  170. package/src/createSlider.svelte.ts +191 -191
  171. package/src/createTooltip.svelte.ts +144 -144
  172. package/src/createTree.svelte.ts +322 -322
  173. package/src/datetime/date-core.ts +206 -206
  174. package/src/datetime/date-restrict.ts +61 -61
  175. package/src/datetime/timezone.ts +135 -135
  176. package/src/dock-manager-model.ts +596 -596
  177. package/src/dock-model.ts +374 -374
  178. package/src/editing.test.ts +974 -974
  179. package/src/editing.ts +618 -609
  180. package/src/editor-contract.ts +171 -171
  181. package/src/editor-registry.grid.test.ts +144 -144
  182. package/src/editor-registry.ts +122 -122
  183. package/src/export-data-api.test.ts +126 -126
  184. package/src/export-format.test.ts +107 -107
  185. package/src/export-format.ts +601 -601
  186. package/src/filter-operators.ts +160 -160
  187. package/src/filtering/excel-filters.ts +325 -325
  188. package/src/flex-render.ts +3 -3
  189. package/src/form-field.ts +127 -127
  190. package/src/group-display.test.ts +167 -167
  191. package/src/group-display.ts +200 -200
  192. package/src/headless.ts +87 -87
  193. package/src/js-scroller.svelte.ts +173 -173
  194. package/src/keyboard-handlers.ts +270 -270
  195. package/src/keyboard.test.ts +59 -59
  196. package/src/keyboard.ts +97 -97
  197. package/src/list-nav.test.ts +49 -49
  198. package/src/list-nav.ts +29 -29
  199. package/src/list-option.test.ts +56 -56
  200. package/src/list-option.ts +179 -179
  201. package/src/menus.ts +597 -597
  202. package/src/merge-objects.ts +48 -48
  203. package/src/overlays.test.ts +90 -90
  204. package/src/positioning.ts +268 -268
  205. package/src/render-component.ts +28 -28
  206. package/src/row-drag.test.ts +401 -401
  207. package/src/row-drag.ts +419 -419
  208. package/src/row-resize.test.ts +524 -524
  209. package/src/row-resize.ts +228 -228
  210. package/src/scheduler-ical.ts +181 -181
  211. package/src/scheduler-model.test.ts +562 -562
  212. package/src/scheduler-model.ts +873 -873
  213. package/src/selection.test.ts +885 -885
  214. package/src/server-data-source.test.ts +383 -383
  215. package/src/server-data-source.ts +469 -469
  216. package/src/sparkline.test.ts +68 -68
  217. package/src/sparkline.ts +169 -169
  218. package/src/spreadsheet.test.ts +488 -488
  219. package/src/spreadsheet.ts +312 -312
  220. package/src/static-functions.ts +11 -11
  221. package/src/subscribe.ts +38 -38
  222. package/src/summaries.ts +113 -113
  223. package/src/svgrid-wrapper.types.ts +563 -563
  224. package/src/svgrid.async-editor-options.test.ts +273 -273
  225. package/src/svgrid.auto-row-height.test.ts +204 -204
  226. package/src/svgrid.behavior.test.ts +910 -910
  227. package/src/svgrid.charting.test.ts +534 -534
  228. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  229. package/src/svgrid.context-menu.test.ts +147 -147
  230. package/src/svgrid.features.test.ts +157 -157
  231. package/src/svgrid.filter-depth.test.ts +163 -163
  232. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  233. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  234. package/src/svgrid.grand-total.test.ts +188 -188
  235. package/src/svgrid.group-display-mode.test.ts +171 -171
  236. package/src/svgrid.group-footers.test.ts +121 -121
  237. package/src/svgrid.group-pagination.test.ts +153 -153
  238. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  239. package/src/svgrid.tree-data.test.ts +186 -186
  240. package/src/svgrid.wrapper.test.ts +63 -63
  241. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  242. package/src/test-setup.ts +62 -62
  243. package/src/themes/index.ts +288 -288
  244. package/src/toast-store.svelte.ts +250 -250
  245. package/src/toast-store.test.ts +147 -147
  246. package/src/tree-row-model.test.ts +168 -168
  247. package/src/ui-buttons.test.ts +144 -144
  248. package/src/ui-inputs.test.ts +118 -118
  249. package/src/ui-localization.test.ts +113 -113
  250. package/src/ui-range.test.ts +70 -70
  251. package/src/ui-selection.test.ts +155 -155
  252. package/src/ui-tier1.test.ts +142 -142
  253. package/src/virtual.test.ts +88 -88
  254. package/src/virtualization/column-virtualizer.test.ts +27 -27
  255. package/src/virtualization/column-virtualizer.ts +30 -30
  256. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  257. package/src/virtualization/types.ts +30 -30
  258. package/src/virtualization/virtualizer.test.ts +47 -47
  259. package/src/virtualization/virtualizer.ts +322 -322
@@ -1,534 +1,534 @@
1
- /**
2
- * Built-in integrated charting (`charting` prop on <SvGrid>): the docked chart
3
- * panel, its live derivation from displayed rows + selection, and the
4
- * chart-click -> grid cross-filter loop.
5
- */
6
- import { describe, expect, it } from 'vitest'
7
- import { mount, unmount } from 'svelte'
8
- import SvGrid from './SvGrid.svelte'
9
- import {
10
- columnFilteringFeature,
11
- createCoreRowModel,
12
- createFilteredRowModel,
13
- createSortedRowModel,
14
- rowSelectionFeature,
15
- rowSortingFeature,
16
- sortFns,
17
- tableFeatures,
18
- chartSpecToCsv,
19
- formatChartValue,
20
- buildChart,
21
- } from './index'
22
- import type { ChartAggregateRequest, ColumnDef, SvGridApi } from './index'
23
-
24
- type Person = { id: number; name: string; team: string; age: number; salary: number }
25
-
26
- const features = tableFeatures({ columnFilteringFeature, rowSelectionFeature, rowSortingFeature })
27
-
28
- const people: Person[] = [
29
- { id: 1, name: 'Ada', team: 'Research', age: 38, salary: 120 },
30
- { id: 2, name: 'Grace', team: 'Compilers', age: 42, salary: 150 },
31
- { id: 3, name: 'Alan', team: 'Research', age: 41, salary: 130 },
32
- { id: 4, name: 'Margaret', team: 'Apollo', age: 35, salary: 160 },
33
- { id: 5, name: 'Linus', team: 'Kernel', age: 54, salary: 175 },
34
- { id: 6, name: 'Donald', team: 'Research', age: 86, salary: 110 },
35
- ]
36
-
37
- const columns: ColumnDef<typeof features, Person>[] = [
38
- { field: 'name', header: 'Name', width: 160 },
39
- { field: 'team', header: 'Team', width: 160 },
40
- { field: 'age', header: 'Age', width: 100, cellDataType: 'number' },
41
- { field: 'salary', header: 'Salary', width: 100, cellDataType: 'number' },
42
- ]
43
-
44
- type MountResult = { api: SvGridApi<typeof features, Person>; target: HTMLElement; destroy: () => void }
45
-
46
- function mountGrid(overrides: Record<string, unknown> = {}): Promise<MountResult> {
47
- return new Promise((resolve, reject) => {
48
- const target = document.createElement('div')
49
- target.style.width = '1000px'
50
- target.style.height = '600px'
51
- document.body.appendChild(target)
52
- const app = mount(SvGrid, {
53
- target,
54
- props: {
55
- data: people,
56
- columns,
57
- features,
58
- _rowModels: {
59
- coreRowModel: createCoreRowModel(),
60
- filteredRowModel: createFilteredRowModel(),
61
- sortedRowModel: createSortedRowModel(sortFns),
62
- },
63
- rowHeight: 36,
64
- containerHeight: 480,
65
- virtualization: false,
66
- onApiReady(api: SvGridApi<typeof features, Person>) {
67
- resolve({ api, target, destroy: () => { unmount(app); target.remove() } })
68
- },
69
- ...overrides,
70
- } as any,
71
- })
72
- queueMicrotask(() => reject(new Error('onApiReady never fired')))
73
- })
74
- }
75
-
76
- // A macrotask boundary: drains all pending microtasks (Svelte effects, the chart
77
- // panel's lazy import() + its .then, and the follow-up flush) so the docked panel
78
- // is mounted before assertions. The panel is loaded lazily as its own chunk.
79
- const tick = () => new Promise((r) => setTimeout(r))
80
-
81
- describe('SvGrid built-in charting', () => {
82
- it('derives a chart spec from all displayed rows (default dimension + measure)', async () => {
83
- const { api, destroy } = await mountGrid({ charting: true })
84
- await tick() // the chart engine loads lazily; ready a tick after mount
85
- try {
86
- const spec = api.getChartSpec()
87
- expect(spec).not.toBeNull()
88
- expect(spec!.type).toBe('bar')
89
- // Default dimension is the first text column (name), one category per row.
90
- expect(spec!.categories.length).toBe(people.length)
91
- } finally {
92
- destroy()
93
- }
94
- })
95
-
96
- it('re-derives the chart when the grid is filtered (auto-refresh)', async () => {
97
- const { api, destroy } = await mountGrid({ charting: true })
98
- try {
99
- // Filter to the Research team (3 people) via the same facet-filter state
100
- // the chart's own cross-filter writes.
101
- api.setFacetFilter('team', ['Research'])
102
- await tick()
103
- const spec = api.getChartSpec()
104
- expect(spec!.categories.length).toBe(3)
105
- } finally {
106
- destroy()
107
- }
108
- })
109
-
110
- it('scopes the chart to the selected cell range', async () => {
111
- const { api, destroy } = await mountGrid({ charting: true })
112
- try {
113
- // Select the first two rows across all columns.
114
- api.selectCells([[0, 0, 1, 2]])
115
- await tick()
116
- const spec = api.getChartSpec()
117
- expect(spec!.categories.length).toBe(2)
118
- } finally {
119
- destroy()
120
- }
121
- })
122
-
123
- it('renders the docked panel with an <svg> when open', async () => {
124
- const { target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
125
- try {
126
- await tick()
127
- const panel = target.querySelector('.sv-grid-chart-panel')
128
- expect(panel).toBeTruthy()
129
- expect(panel!.querySelector('svg')).toBeTruthy()
130
- } finally {
131
- destroy()
132
- }
133
- })
134
-
135
- it('pops the docked panel out into a floating window, maximizes, and docks back', async () => {
136
- const { target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
137
- try {
138
- await tick()
139
- const panel = () => target.querySelector('.sv-grid-chart-panel')!
140
- const click = (label: string) => {
141
- const btn = target.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)
142
- expect(btn).toBeTruthy()
143
- btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
144
- }
145
- // Starts docked.
146
- expect(panel().classList.contains('is-floating')).toBe(false)
147
- // Pop out -> floating window.
148
- click('Pop out chart')
149
- await tick()
150
- expect(panel().classList.contains('is-floating')).toBe(true)
151
- // Maximize -> fills the grid.
152
- click('Maximize')
153
- await tick()
154
- expect(panel().classList.contains('is-maximized')).toBe(true)
155
- // Dock back -> re-docked, not floating, not maximized.
156
- click('Dock chart')
157
- await tick()
158
- expect(panel().classList.contains('is-floating')).toBe(false)
159
- expect(panel().classList.contains('is-maximized')).toBe(false)
160
- } finally {
161
- destroy()
162
- }
163
- })
164
-
165
- it('adds a second chart, shows a tab strip, and persists both charts in view state', async () => {
166
- const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
167
- try {
168
- await tick()
169
- // One chart, no tab strip yet.
170
- expect(target.querySelectorAll('.sv-grid-chart-tab').length).toBe(0)
171
- // Add a chart.
172
- const add = target.querySelector<HTMLButtonElement>('[aria-label="Add chart"]')
173
- expect(add).toBeTruthy()
174
- add!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
175
- await tick()
176
- // Now two tabs; the second is active.
177
- const tabs = target.querySelectorAll('.sv-grid-chart-tab')
178
- expect(tabs.length).toBe(2)
179
- expect(tabs[1]!.classList.contains('is-active')).toBe(true)
180
- // View state carries both charts + the active index.
181
- const state = api.getState() as any
182
- expect(state.charts.length).toBe(2)
183
- expect(state.chartActive).toBe(1)
184
- // Removing a chart drops back to a single chart (tab strip disappears).
185
- target.querySelector<HTMLButtonElement>('.sv-grid-chart-tab.is-active .sv-grid-chart-tab-x')!
186
- .dispatchEvent(new MouseEvent('click', { bubbles: true }))
187
- await tick()
188
- expect(target.querySelectorAll('.sv-grid-chart-tab').length).toBe(0)
189
- expect((api.getState() as any).charts.length).toBe(1)
190
- } finally {
191
- destroy()
192
- }
193
- })
194
-
195
- it('keeps each chart\'s own type when switching tabs', async () => {
196
- const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
197
- try {
198
- await tick()
199
- // Chart 1 -> line.
200
- const typeSel = () => target.querySelector<HTMLSelectElement>('.sv-grid-chart-controls select')!
201
- typeSel().value = 'line'
202
- typeSel().dispatchEvent(new Event('change', { bubbles: true }))
203
- await tick()
204
- expect(api.getChartSpec()!.type).toBe('line')
205
- // Add Chart 2 (defaults to bar).
206
- target.querySelector<HTMLButtonElement>('[aria-label="Add chart"]')!
207
- .dispatchEvent(new MouseEvent('click', { bubbles: true }))
208
- await tick()
209
- expect(api.getChartSpec()!.type).toBe('bar')
210
- // Switch back to Chart 1 -> still line.
211
- target.querySelectorAll<HTMLButtonElement>('.sv-grid-chart-tab-label')[0]!
212
- .dispatchEvent(new MouseEvent('click', { bubbles: true }))
213
- await tick()
214
- expect(api.getChartSpec()!.type).toBe('line')
215
- } finally {
216
- destroy()
217
- }
218
- })
219
-
220
- it('configureChart applies a config to the active chart (resolving field names)', async () => {
221
- const { api, destroy } = await mountGrid({ charting: { defaultOpen: true } })
222
- try {
223
- api.configureChart({ type: 'line', dimension: 'team', measure: 'salary', reduce: 'avg' })
224
- await tick()
225
- const spec = api.getChartSpec()
226
- expect(spec!.type).toBe('line')
227
- // salary aggregated by team -> one category per distinct team.
228
- expect(spec!.categories.length).toBeGreaterThan(0)
229
- } finally {
230
- destroy()
231
- }
232
- })
233
-
234
- it('shows an AI button only when a chart-AI handler is registered, and applies its result', async () => {
235
- const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
236
- try {
237
- await tick()
238
- // No handler -> no AI button.
239
- expect(target.querySelector('[aria-label="Chart with AI"]')).toBeFalsy()
240
- // Register a deterministic handler (stands in for enterprise's aiChart).
241
- api.setChartAiHandler(async () => ({
242
- type: 'line',
243
- dimension: 'team',
244
- series: null,
245
- measure: 'salary',
246
- reduce: 'avg',
247
- stacked: false,
248
- rationale: 'line of avg salary by team',
249
- }))
250
- await tick()
251
- const aiBtn = target.querySelector<HTMLButtonElement>('[aria-label="Chart with AI"]')
252
- expect(aiBtn).toBeTruthy()
253
- // Open the prompt row, type a query, submit.
254
- aiBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
255
- await tick()
256
- const input = target.querySelector<HTMLInputElement>('.sv-grid-chart-ai-input')!
257
- input.value = 'average salary by team as a line'
258
- input.dispatchEvent(new Event('input', { bubbles: true }))
259
- const form = target.querySelector<HTMLFormElement>('.sv-grid-chart-ai-row')!
260
- form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
261
- // Let the async handler resolve.
262
- await new Promise((r) => setTimeout(r, 0))
263
- await tick()
264
- expect(api.getChartSpec()!.type).toBe('line')
265
- } finally {
266
- destroy()
267
- }
268
- })
269
-
270
- it('toggles a logarithmic value axis (configureChart + panel toggle present)', async () => {
271
- const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
272
- try {
273
- api.configureChart({ dimension: 'team', measure: 'salary' })
274
- await tick()
275
- expect(api.getChartSpec()!.yScale).not.toBe('log')
276
- api.configureChart({ logScale: true })
277
- await tick()
278
- expect(api.getChartSpec()!.yScale).toBe('log')
279
- const labels = [...target.querySelectorAll('.sv-grid-chart-toggle')].map((l) => l.textContent?.trim())
280
- expect(labels).toContain('Log scale')
281
- } finally {
282
- destroy()
283
- }
284
- })
285
-
286
- it('offers a date axis only when the group-by column is date-like', async () => {
287
- // Non-date dimension -> no Date axis toggle.
288
- const a = await mountGrid({ charting: { defaultOpen: true } })
289
- try {
290
- await tick()
291
- const labels = [...a.target.querySelectorAll('.sv-grid-chart-toggle')].map((l) => l.textContent?.trim())
292
- expect(labels).not.toContain('Date axis')
293
- } finally {
294
- a.destroy()
295
- }
296
- // Date dimension -> Date axis toggle appears and drives xType: 'time'.
297
- const dateColumns = [
298
- { field: 'day', header: 'Day', width: 120, cellDataType: 'date' },
299
- { field: 'channel', header: 'Channel', width: 120 },
300
- { field: 'signups', header: 'Signups', width: 100, cellDataType: 'number' },
301
- ]
302
- const dateData = [
303
- { id: 1, day: '2026-01-01', channel: 'Organic', signups: 10 },
304
- { id: 2, day: '2026-01-02', channel: 'Organic', signups: 20 },
305
- { id: 3, day: '2026-01-03', channel: 'Paid', signups: 30 },
306
- ]
307
- const b = await mountGrid({
308
- data: dateData,
309
- columns: dateColumns,
310
- charting: { defaultOpen: true, dimension: 'day', measures: 'signups' },
311
- })
312
- try {
313
- await tick()
314
- const labels = [...b.target.querySelectorAll('.sv-grid-chart-toggle')].map((l) => l.textContent?.trim())
315
- expect(labels).toContain('Date axis')
316
- b.api.configureChart({ timeAxis: true })
317
- await tick()
318
- expect(b.api.getChartSpec()!.xType).toBe('time')
319
- } finally {
320
- b.destroy()
321
- }
322
- })
323
-
324
- it('serializes chart data to CSV and shows an Export menu (PNG/SVG/CSV/Copy)', async () => {
325
- const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
326
- try {
327
- api.configureChart({ dimension: 'team', series: 'name', measure: 'salary' })
328
- await tick()
329
- const csv = chartSpecToCsv(api.getChartSpec()!)
330
- const [header, ...rows] = csv.split('\n')
331
- expect(header!.startsWith('Category,')).toBe(true)
332
- expect(rows.length).toBeGreaterThan(0)
333
- // Export button + menu.
334
- const btn = target.querySelector<HTMLButtonElement>('[aria-label="Export chart"]')
335
- expect(btn).toBeTruthy()
336
- btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
337
- await tick()
338
- const items = [...target.querySelectorAll('.sv-grid-chart-export-menu button')].map((b) => b.textContent)
339
- expect(items).toEqual(['PNG image', 'SVG vector', 'CSV data', 'Copy to clipboard'])
340
- } finally {
341
- destroy()
342
- }
343
- })
344
-
345
- it('buildChart emits a grouped category-axis tier from categoryGroups', () => {
346
- const geo = buildChart({
347
- type: 'bar',
348
- categories: ['USA', 'Canada', 'Japan', 'India'],
349
- series: [{ label: 'Rev', values: [10, 20, 30, 40] }],
350
- categoryGroups: [{ label: 'Americas', span: 2 }, { label: 'APAC', span: 2 }],
351
- })
352
- expect(geo.categoryGroupTicks.map((g) => g.label)).toEqual(['Americas', 'APAC'])
353
- // Groups tile the axis edge-to-edge (first group's right = second's left).
354
- expect(geo.categoryGroupTicks[0]!.x1).toBe(geo.categoryGroupTicks[1]!.x0)
355
- // Ignored when the spans don't cover every leaf.
356
- const bad = buildChart({
357
- type: 'bar',
358
- categories: ['A', 'B', 'C'],
359
- series: [{ label: 'x', values: [1, 2, 3] }],
360
- categoryGroups: [{ label: 'G', span: 2 }],
361
- })
362
- expect(bad.categoryGroupTicks.length).toBe(0)
363
- })
364
-
365
- it('formatChartValue renders currency / percent / compact', () => {
366
- expect(formatChartValue(1500, 'currency')).toBe('$1.5k')
367
- expect(formatChartValue(-2_000_000, 'currency')).toBe('-$2M')
368
- expect(formatChartValue(0.25, 'percent')).toBe('25%')
369
- expect(formatChartValue(1500)).toBe('1.5k')
370
- expect(formatChartValue(1500, 'number')).toBe('1.5k')
371
- })
372
-
373
- it('applies a value format and an auto y-axis title to the spec', async () => {
374
- const { api, destroy } = await mountGrid({ charting: { defaultOpen: true } })
375
- try {
376
- api.configureChart({ dimension: 'team', measure: 'salary', reduce: 'sum', valueFormat: 'currency' })
377
- await tick()
378
- const spec = api.getChartSpec()!
379
- expect(spec.valueFormat).toBe('currency')
380
- expect(spec.yAxisTitle).toBe('Sum of Salary')
381
- } finally {
382
- destroy()
383
- }
384
- })
385
-
386
- it('defaults the value format from the measure column format', async () => {
387
- const cols = [
388
- { field: 'team', header: 'Team', width: 120 },
389
- { field: 'salary', header: 'Salary', width: 120, cellDataType: 'number', format: { type: 'currency', currency: 'USD' } },
390
- ]
391
- const { api, destroy } = await mountGrid({
392
- columns: cols,
393
- charting: { defaultOpen: true, dimension: 'team', measures: 'salary' },
394
- })
395
- try {
396
- await tick()
397
- expect(api.getChartSpec()!.valueFormat).toBe('currency')
398
- } finally {
399
- destroy()
400
- }
401
- })
402
-
403
- it('splits into one series per distinct value of the `series` field', async () => {
404
- const { api, destroy } = await mountGrid({ charting: { dimension: 'team', series: 'name', measures: 'age' } })
405
- await tick() // the chart engine loads lazily; ready a tick after mount
406
- try {
407
- const spec = api.getChartSpec()
408
- // categories = distinct teams (4); one series per distinct name (6).
409
- expect(spec!.series.length).toBe(6)
410
- } finally {
411
- destroy()
412
- }
413
- })
414
-
415
- it('plots one series per measure with `measures[]`', async () => {
416
- const { api, destroy } = await mountGrid({ charting: { dimension: 'team', measures: ['age', 'salary'] } })
417
- await tick() // the chart engine loads lazily; ready a tick after mount
418
- try {
419
- const spec = api.getChartSpec()
420
- expect(spec!.series.length).toBe(2)
421
- } finally {
422
- destroy()
423
- }
424
- })
425
-
426
- it('marks the spec stacked when config.stacked is set', async () => {
427
- const { api, destroy } = await mountGrid({ charting: { dimension: 'team', series: 'name', measures: 'age', stacked: true } })
428
- await tick() // the chart engine loads lazily; ready a tick after mount
429
- try {
430
- expect(api.getChartSpec()!.stacked).toBe(true)
431
- } finally {
432
- destroy()
433
- }
434
- })
435
-
436
- it('renders a custom `buildSpec` chart that stays live with the grid', async () => {
437
- const { api, destroy } = await mountGrid({
438
- charting: { defaultOpen: true, buildSpec: (rows: unknown[]) => ({ type: 'bar', categories: ['All'], series: [{ label: 'Count', values: [rows.length] }] }) },
439
- })
440
- try {
441
- await tick()
442
- // The custom spec reflects all displayed rows...
443
- expect(api.getChartSpec()!.series[0]!.values[0]).toBe(people.length)
444
- // ...and re-derives when the grid is filtered (auto-refresh through buildSpec).
445
- api.setFacetFilter('team', ['Research'])
446
- await tick()
447
- expect(api.getChartSpec()!.series[0]!.values[0]).toBe(3)
448
- } finally {
449
- destroy()
450
- }
451
- })
452
-
453
- it('maps a multi-column cell selection to dimension + split-by + measures (column-exact)', async () => {
454
- const { api, destroy } = await mountGrid({ charting: true })
455
- try {
456
- // Select columns 1..3 (team, age, salary) across the first two rows:
457
- // team -> dimension, no 2nd text col, age+salary -> measures.
458
- api.selectCells([[0, 1, 1, 3]])
459
- await tick()
460
- const spec = api.getChartSpec()
461
- // Two numeric columns in the span -> two series.
462
- expect(spec!.series.length).toBe(2)
463
- } finally {
464
- destroy()
465
- }
466
- })
467
-
468
- it('round-trips the chart config through getState / setState', async () => {
469
- const a = await mountGrid({ charting: true })
470
- try {
471
- a.api.openChart()
472
- a.api.setState({ ...a.api.getState(), chart: { open: true, type: 'line', dimension: 'team', series: null, measure: 'salary', reduce: 'avg', stacked: false } } as any)
473
- await tick()
474
- const state = a.api.getState() as any
475
- expect(state.chart).toBeTruthy()
476
- expect(state.chart.type).toBe('line')
477
- expect(state.chart.reduce).toBe('avg')
478
- const spec = a.api.getChartSpec()
479
- expect(spec!.type).toBe('line')
480
- } finally {
481
- a.destroy()
482
- }
483
- })
484
-
485
- it('charts server-side buckets from getAggregate and refetches on filter change', async () => {
486
- let calls = 0
487
- let lastFilter: any = null
488
- const { api, destroy } = await mountGrid({
489
- charting: {
490
- getAggregate: async (req: ChartAggregateRequest) => {
491
- calls += 1
492
- lastFilter = req.filterModel
493
- return [
494
- { category: 'A', value: 10 },
495
- { category: 'B', value: 20 },
496
- ]
497
- },
498
- },
499
- })
500
- try {
501
- await new Promise((r) => setTimeout(r, 15))
502
- const spec = api.getChartSpec()
503
- expect(spec).not.toBeNull()
504
- expect(spec!.categories).toEqual(['A', 'B'])
505
- expect(spec!.series[0]!.values).toEqual([10, 20])
506
- expect(calls).toBeGreaterThan(0)
507
- // A filter change re-runs the server aggregation with the new filter.
508
- const before = calls
509
- api.setFacetFilter('team', ['Research'])
510
- await new Promise((r) => setTimeout(r, 15))
511
- expect(calls).toBeGreaterThan(before)
512
- expect(lastFilter.facets.team).toEqual(['Research'])
513
- } finally {
514
- destroy()
515
- }
516
- })
517
-
518
- it('cross-filters the grid when a chart category is clicked', async () => {
519
- const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
520
- try {
521
- await tick()
522
- expect(api.getDisplayedRows().length).toBe(people.length)
523
- // The full-height hit rect for the first category carries data-cat-index=0.
524
- const hit = target.querySelector<SVGElement>('.sv-grid-chart-panel svg [data-cat-index="0"]')
525
- expect(hit).toBeTruthy()
526
- hit!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
527
- await tick()
528
- // Clicking one category (a distinct name) filters the grid to that row.
529
- expect(api.getDisplayedRows().length).toBe(1)
530
- } finally {
531
- destroy()
532
- }
533
- })
534
- })
1
+ /**
2
+ * Built-in integrated charting (`charting` prop on <SvGrid>): the docked chart
3
+ * panel, its live derivation from displayed rows + selection, and the
4
+ * chart-click -> grid cross-filter loop.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount } from 'svelte'
8
+ import SvGrid from './SvGrid.svelte'
9
+ import {
10
+ columnFilteringFeature,
11
+ createCoreRowModel,
12
+ createFilteredRowModel,
13
+ createSortedRowModel,
14
+ rowSelectionFeature,
15
+ rowSortingFeature,
16
+ sortFns,
17
+ tableFeatures,
18
+ chartSpecToCsv,
19
+ formatChartValue,
20
+ buildChart,
21
+ } from './index'
22
+ import type { ChartAggregateRequest, ColumnDef, SvGridApi } from './index'
23
+
24
+ type Person = { id: number; name: string; team: string; age: number; salary: number }
25
+
26
+ const features = tableFeatures({ columnFilteringFeature, rowSelectionFeature, rowSortingFeature })
27
+
28
+ const people: Person[] = [
29
+ { id: 1, name: 'Ada', team: 'Research', age: 38, salary: 120 },
30
+ { id: 2, name: 'Grace', team: 'Compilers', age: 42, salary: 150 },
31
+ { id: 3, name: 'Alan', team: 'Research', age: 41, salary: 130 },
32
+ { id: 4, name: 'Margaret', team: 'Apollo', age: 35, salary: 160 },
33
+ { id: 5, name: 'Linus', team: 'Kernel', age: 54, salary: 175 },
34
+ { id: 6, name: 'Donald', team: 'Research', age: 86, salary: 110 },
35
+ ]
36
+
37
+ const columns: ColumnDef<typeof features, Person>[] = [
38
+ { field: 'name', header: 'Name', width: 160 },
39
+ { field: 'team', header: 'Team', width: 160 },
40
+ { field: 'age', header: 'Age', width: 100, cellDataType: 'number' },
41
+ { field: 'salary', header: 'Salary', width: 100, cellDataType: 'number' },
42
+ ]
43
+
44
+ type MountResult = { api: SvGridApi<typeof features, Person>; target: HTMLElement; destroy: () => void }
45
+
46
+ function mountGrid(overrides: Record<string, unknown> = {}): Promise<MountResult> {
47
+ return new Promise((resolve, reject) => {
48
+ const target = document.createElement('div')
49
+ target.style.width = '1000px'
50
+ target.style.height = '600px'
51
+ document.body.appendChild(target)
52
+ const app = mount(SvGrid, {
53
+ target,
54
+ props: {
55
+ data: people,
56
+ columns,
57
+ features,
58
+ _rowModels: {
59
+ coreRowModel: createCoreRowModel(),
60
+ filteredRowModel: createFilteredRowModel(),
61
+ sortedRowModel: createSortedRowModel(sortFns),
62
+ },
63
+ rowHeight: 36,
64
+ containerHeight: 480,
65
+ virtualization: false,
66
+ onApiReady(api: SvGridApi<typeof features, Person>) {
67
+ resolve({ api, target, destroy: () => { unmount(app); target.remove() } })
68
+ },
69
+ ...overrides,
70
+ } as any,
71
+ })
72
+ queueMicrotask(() => reject(new Error('onApiReady never fired')))
73
+ })
74
+ }
75
+
76
+ // A macrotask boundary: drains all pending microtasks (Svelte effects, the chart
77
+ // panel's lazy import() + its .then, and the follow-up flush) so the docked panel
78
+ // is mounted before assertions. The panel is loaded lazily as its own chunk.
79
+ const tick = () => new Promise((r) => setTimeout(r))
80
+
81
+ describe('SvGrid built-in charting', () => {
82
+ it('derives a chart spec from all displayed rows (default dimension + measure)', async () => {
83
+ const { api, destroy } = await mountGrid({ charting: true })
84
+ await tick() // the chart engine loads lazily; ready a tick after mount
85
+ try {
86
+ const spec = api.getChartSpec()
87
+ expect(spec).not.toBeNull()
88
+ expect(spec!.type).toBe('bar')
89
+ // Default dimension is the first text column (name), one category per row.
90
+ expect(spec!.categories.length).toBe(people.length)
91
+ } finally {
92
+ destroy()
93
+ }
94
+ })
95
+
96
+ it('re-derives the chart when the grid is filtered (auto-refresh)', async () => {
97
+ const { api, destroy } = await mountGrid({ charting: true })
98
+ try {
99
+ // Filter to the Research team (3 people) via the same facet-filter state
100
+ // the chart's own cross-filter writes.
101
+ api.setFacetFilter('team', ['Research'])
102
+ await tick()
103
+ const spec = api.getChartSpec()
104
+ expect(spec!.categories.length).toBe(3)
105
+ } finally {
106
+ destroy()
107
+ }
108
+ })
109
+
110
+ it('scopes the chart to the selected cell range', async () => {
111
+ const { api, destroy } = await mountGrid({ charting: true })
112
+ try {
113
+ // Select the first two rows across all columns.
114
+ api.selectCells([[0, 0, 1, 2]])
115
+ await tick()
116
+ const spec = api.getChartSpec()
117
+ expect(spec!.categories.length).toBe(2)
118
+ } finally {
119
+ destroy()
120
+ }
121
+ })
122
+
123
+ it('renders the docked panel with an <svg> when open', async () => {
124
+ const { target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
125
+ try {
126
+ await tick()
127
+ const panel = target.querySelector('.sv-grid-chart-panel')
128
+ expect(panel).toBeTruthy()
129
+ expect(panel!.querySelector('svg')).toBeTruthy()
130
+ } finally {
131
+ destroy()
132
+ }
133
+ })
134
+
135
+ it('pops the docked panel out into a floating window, maximizes, and docks back', async () => {
136
+ const { target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
137
+ try {
138
+ await tick()
139
+ const panel = () => target.querySelector('.sv-grid-chart-panel')!
140
+ const click = (label: string) => {
141
+ const btn = target.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)
142
+ expect(btn).toBeTruthy()
143
+ btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
144
+ }
145
+ // Starts docked.
146
+ expect(panel().classList.contains('is-floating')).toBe(false)
147
+ // Pop out -> floating window.
148
+ click('Pop out chart')
149
+ await tick()
150
+ expect(panel().classList.contains('is-floating')).toBe(true)
151
+ // Maximize -> fills the grid.
152
+ click('Maximize')
153
+ await tick()
154
+ expect(panel().classList.contains('is-maximized')).toBe(true)
155
+ // Dock back -> re-docked, not floating, not maximized.
156
+ click('Dock chart')
157
+ await tick()
158
+ expect(panel().classList.contains('is-floating')).toBe(false)
159
+ expect(panel().classList.contains('is-maximized')).toBe(false)
160
+ } finally {
161
+ destroy()
162
+ }
163
+ })
164
+
165
+ it('adds a second chart, shows a tab strip, and persists both charts in view state', async () => {
166
+ const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
167
+ try {
168
+ await tick()
169
+ // One chart, no tab strip yet.
170
+ expect(target.querySelectorAll('.sv-grid-chart-tab').length).toBe(0)
171
+ // Add a chart.
172
+ const add = target.querySelector<HTMLButtonElement>('[aria-label="Add chart"]')
173
+ expect(add).toBeTruthy()
174
+ add!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
175
+ await tick()
176
+ // Now two tabs; the second is active.
177
+ const tabs = target.querySelectorAll('.sv-grid-chart-tab')
178
+ expect(tabs.length).toBe(2)
179
+ expect(tabs[1]!.classList.contains('is-active')).toBe(true)
180
+ // View state carries both charts + the active index.
181
+ const state = api.getState() as any
182
+ expect(state.charts.length).toBe(2)
183
+ expect(state.chartActive).toBe(1)
184
+ // Removing a chart drops back to a single chart (tab strip disappears).
185
+ target.querySelector<HTMLButtonElement>('.sv-grid-chart-tab.is-active .sv-grid-chart-tab-x')!
186
+ .dispatchEvent(new MouseEvent('click', { bubbles: true }))
187
+ await tick()
188
+ expect(target.querySelectorAll('.sv-grid-chart-tab').length).toBe(0)
189
+ expect((api.getState() as any).charts.length).toBe(1)
190
+ } finally {
191
+ destroy()
192
+ }
193
+ })
194
+
195
+ it('keeps each chart\'s own type when switching tabs', async () => {
196
+ const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
197
+ try {
198
+ await tick()
199
+ // Chart 1 -> line.
200
+ const typeSel = () => target.querySelector<HTMLSelectElement>('.sv-grid-chart-controls select')!
201
+ typeSel().value = 'line'
202
+ typeSel().dispatchEvent(new Event('change', { bubbles: true }))
203
+ await tick()
204
+ expect(api.getChartSpec()!.type).toBe('line')
205
+ // Add Chart 2 (defaults to bar).
206
+ target.querySelector<HTMLButtonElement>('[aria-label="Add chart"]')!
207
+ .dispatchEvent(new MouseEvent('click', { bubbles: true }))
208
+ await tick()
209
+ expect(api.getChartSpec()!.type).toBe('bar')
210
+ // Switch back to Chart 1 -> still line.
211
+ target.querySelectorAll<HTMLButtonElement>('.sv-grid-chart-tab-label')[0]!
212
+ .dispatchEvent(new MouseEvent('click', { bubbles: true }))
213
+ await tick()
214
+ expect(api.getChartSpec()!.type).toBe('line')
215
+ } finally {
216
+ destroy()
217
+ }
218
+ })
219
+
220
+ it('configureChart applies a config to the active chart (resolving field names)', async () => {
221
+ const { api, destroy } = await mountGrid({ charting: { defaultOpen: true } })
222
+ try {
223
+ api.configureChart({ type: 'line', dimension: 'team', measure: 'salary', reduce: 'avg' })
224
+ await tick()
225
+ const spec = api.getChartSpec()
226
+ expect(spec!.type).toBe('line')
227
+ // salary aggregated by team -> one category per distinct team.
228
+ expect(spec!.categories.length).toBeGreaterThan(0)
229
+ } finally {
230
+ destroy()
231
+ }
232
+ })
233
+
234
+ it('shows an AI button only when a chart-AI handler is registered, and applies its result', async () => {
235
+ const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
236
+ try {
237
+ await tick()
238
+ // No handler -> no AI button.
239
+ expect(target.querySelector('[aria-label="Chart with AI"]')).toBeFalsy()
240
+ // Register a deterministic handler (stands in for enterprise's aiChart).
241
+ api.setChartAiHandler(async () => ({
242
+ type: 'line',
243
+ dimension: 'team',
244
+ series: null,
245
+ measure: 'salary',
246
+ reduce: 'avg',
247
+ stacked: false,
248
+ rationale: 'line of avg salary by team',
249
+ }))
250
+ await tick()
251
+ const aiBtn = target.querySelector<HTMLButtonElement>('[aria-label="Chart with AI"]')
252
+ expect(aiBtn).toBeTruthy()
253
+ // Open the prompt row, type a query, submit.
254
+ aiBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
255
+ await tick()
256
+ const input = target.querySelector<HTMLInputElement>('.sv-grid-chart-ai-input')!
257
+ input.value = 'average salary by team as a line'
258
+ input.dispatchEvent(new Event('input', { bubbles: true }))
259
+ const form = target.querySelector<HTMLFormElement>('.sv-grid-chart-ai-row')!
260
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
261
+ // Let the async handler resolve.
262
+ await new Promise((r) => setTimeout(r, 0))
263
+ await tick()
264
+ expect(api.getChartSpec()!.type).toBe('line')
265
+ } finally {
266
+ destroy()
267
+ }
268
+ })
269
+
270
+ it('toggles a logarithmic value axis (configureChart + panel toggle present)', async () => {
271
+ const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
272
+ try {
273
+ api.configureChart({ dimension: 'team', measure: 'salary' })
274
+ await tick()
275
+ expect(api.getChartSpec()!.yScale).not.toBe('log')
276
+ api.configureChart({ logScale: true })
277
+ await tick()
278
+ expect(api.getChartSpec()!.yScale).toBe('log')
279
+ const labels = [...target.querySelectorAll('.sv-grid-chart-toggle')].map((l) => l.textContent?.trim())
280
+ expect(labels).toContain('Log scale')
281
+ } finally {
282
+ destroy()
283
+ }
284
+ })
285
+
286
+ it('offers a date axis only when the group-by column is date-like', async () => {
287
+ // Non-date dimension -> no Date axis toggle.
288
+ const a = await mountGrid({ charting: { defaultOpen: true } })
289
+ try {
290
+ await tick()
291
+ const labels = [...a.target.querySelectorAll('.sv-grid-chart-toggle')].map((l) => l.textContent?.trim())
292
+ expect(labels).not.toContain('Date axis')
293
+ } finally {
294
+ a.destroy()
295
+ }
296
+ // Date dimension -> Date axis toggle appears and drives xType: 'time'.
297
+ const dateColumns = [
298
+ { field: 'day', header: 'Day', width: 120, cellDataType: 'date' },
299
+ { field: 'channel', header: 'Channel', width: 120 },
300
+ { field: 'signups', header: 'Signups', width: 100, cellDataType: 'number' },
301
+ ]
302
+ const dateData = [
303
+ { id: 1, day: '2026-01-01', channel: 'Organic', signups: 10 },
304
+ { id: 2, day: '2026-01-02', channel: 'Organic', signups: 20 },
305
+ { id: 3, day: '2026-01-03', channel: 'Paid', signups: 30 },
306
+ ]
307
+ const b = await mountGrid({
308
+ data: dateData,
309
+ columns: dateColumns,
310
+ charting: { defaultOpen: true, dimension: 'day', measures: 'signups' },
311
+ })
312
+ try {
313
+ await tick()
314
+ const labels = [...b.target.querySelectorAll('.sv-grid-chart-toggle')].map((l) => l.textContent?.trim())
315
+ expect(labels).toContain('Date axis')
316
+ b.api.configureChart({ timeAxis: true })
317
+ await tick()
318
+ expect(b.api.getChartSpec()!.xType).toBe('time')
319
+ } finally {
320
+ b.destroy()
321
+ }
322
+ })
323
+
324
+ it('serializes chart data to CSV and shows an Export menu (PNG/SVG/CSV/Copy)', async () => {
325
+ const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
326
+ try {
327
+ api.configureChart({ dimension: 'team', series: 'name', measure: 'salary' })
328
+ await tick()
329
+ const csv = chartSpecToCsv(api.getChartSpec()!)
330
+ const [header, ...rows] = csv.split('\n')
331
+ expect(header!.startsWith('Category,')).toBe(true)
332
+ expect(rows.length).toBeGreaterThan(0)
333
+ // Export button + menu.
334
+ const btn = target.querySelector<HTMLButtonElement>('[aria-label="Export chart"]')
335
+ expect(btn).toBeTruthy()
336
+ btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
337
+ await tick()
338
+ const items = [...target.querySelectorAll('.sv-grid-chart-export-menu button')].map((b) => b.textContent)
339
+ expect(items).toEqual(['PNG image', 'SVG vector', 'CSV data', 'Copy to clipboard'])
340
+ } finally {
341
+ destroy()
342
+ }
343
+ })
344
+
345
+ it('buildChart emits a grouped category-axis tier from categoryGroups', () => {
346
+ const geo = buildChart({
347
+ type: 'bar',
348
+ categories: ['USA', 'Canada', 'Japan', 'India'],
349
+ series: [{ label: 'Rev', values: [10, 20, 30, 40] }],
350
+ categoryGroups: [{ label: 'Americas', span: 2 }, { label: 'APAC', span: 2 }],
351
+ })
352
+ expect(geo.categoryGroupTicks.map((g) => g.label)).toEqual(['Americas', 'APAC'])
353
+ // Groups tile the axis edge-to-edge (first group's right = second's left).
354
+ expect(geo.categoryGroupTicks[0]!.x1).toBe(geo.categoryGroupTicks[1]!.x0)
355
+ // Ignored when the spans don't cover every leaf.
356
+ const bad = buildChart({
357
+ type: 'bar',
358
+ categories: ['A', 'B', 'C'],
359
+ series: [{ label: 'x', values: [1, 2, 3] }],
360
+ categoryGroups: [{ label: 'G', span: 2 }],
361
+ })
362
+ expect(bad.categoryGroupTicks.length).toBe(0)
363
+ })
364
+
365
+ it('formatChartValue renders currency / percent / compact', () => {
366
+ expect(formatChartValue(1500, 'currency')).toBe('$1.5k')
367
+ expect(formatChartValue(-2_000_000, 'currency')).toBe('-$2M')
368
+ expect(formatChartValue(0.25, 'percent')).toBe('25%')
369
+ expect(formatChartValue(1500)).toBe('1.5k')
370
+ expect(formatChartValue(1500, 'number')).toBe('1.5k')
371
+ })
372
+
373
+ it('applies a value format and an auto y-axis title to the spec', async () => {
374
+ const { api, destroy } = await mountGrid({ charting: { defaultOpen: true } })
375
+ try {
376
+ api.configureChart({ dimension: 'team', measure: 'salary', reduce: 'sum', valueFormat: 'currency' })
377
+ await tick()
378
+ const spec = api.getChartSpec()!
379
+ expect(spec.valueFormat).toBe('currency')
380
+ expect(spec.yAxisTitle).toBe('Sum of Salary')
381
+ } finally {
382
+ destroy()
383
+ }
384
+ })
385
+
386
+ it('defaults the value format from the measure column format', async () => {
387
+ const cols = [
388
+ { field: 'team', header: 'Team', width: 120 },
389
+ { field: 'salary', header: 'Salary', width: 120, cellDataType: 'number', format: { type: 'currency', currency: 'USD' } },
390
+ ]
391
+ const { api, destroy } = await mountGrid({
392
+ columns: cols,
393
+ charting: { defaultOpen: true, dimension: 'team', measures: 'salary' },
394
+ })
395
+ try {
396
+ await tick()
397
+ expect(api.getChartSpec()!.valueFormat).toBe('currency')
398
+ } finally {
399
+ destroy()
400
+ }
401
+ })
402
+
403
+ it('splits into one series per distinct value of the `series` field', async () => {
404
+ const { api, destroy } = await mountGrid({ charting: { dimension: 'team', series: 'name', measures: 'age' } })
405
+ await tick() // the chart engine loads lazily; ready a tick after mount
406
+ try {
407
+ const spec = api.getChartSpec()
408
+ // categories = distinct teams (4); one series per distinct name (6).
409
+ expect(spec!.series.length).toBe(6)
410
+ } finally {
411
+ destroy()
412
+ }
413
+ })
414
+
415
+ it('plots one series per measure with `measures[]`', async () => {
416
+ const { api, destroy } = await mountGrid({ charting: { dimension: 'team', measures: ['age', 'salary'] } })
417
+ await tick() // the chart engine loads lazily; ready a tick after mount
418
+ try {
419
+ const spec = api.getChartSpec()
420
+ expect(spec!.series.length).toBe(2)
421
+ } finally {
422
+ destroy()
423
+ }
424
+ })
425
+
426
+ it('marks the spec stacked when config.stacked is set', async () => {
427
+ const { api, destroy } = await mountGrid({ charting: { dimension: 'team', series: 'name', measures: 'age', stacked: true } })
428
+ await tick() // the chart engine loads lazily; ready a tick after mount
429
+ try {
430
+ expect(api.getChartSpec()!.stacked).toBe(true)
431
+ } finally {
432
+ destroy()
433
+ }
434
+ })
435
+
436
+ it('renders a custom `buildSpec` chart that stays live with the grid', async () => {
437
+ const { api, destroy } = await mountGrid({
438
+ charting: { defaultOpen: true, buildSpec: (rows: unknown[]) => ({ type: 'bar', categories: ['All'], series: [{ label: 'Count', values: [rows.length] }] }) },
439
+ })
440
+ try {
441
+ await tick()
442
+ // The custom spec reflects all displayed rows...
443
+ expect(api.getChartSpec()!.series[0]!.values[0]).toBe(people.length)
444
+ // ...and re-derives when the grid is filtered (auto-refresh through buildSpec).
445
+ api.setFacetFilter('team', ['Research'])
446
+ await tick()
447
+ expect(api.getChartSpec()!.series[0]!.values[0]).toBe(3)
448
+ } finally {
449
+ destroy()
450
+ }
451
+ })
452
+
453
+ it('maps a multi-column cell selection to dimension + split-by + measures (column-exact)', async () => {
454
+ const { api, destroy } = await mountGrid({ charting: true })
455
+ try {
456
+ // Select columns 1..3 (team, age, salary) across the first two rows:
457
+ // team -> dimension, no 2nd text col, age+salary -> measures.
458
+ api.selectCells([[0, 1, 1, 3]])
459
+ await tick()
460
+ const spec = api.getChartSpec()
461
+ // Two numeric columns in the span -> two series.
462
+ expect(spec!.series.length).toBe(2)
463
+ } finally {
464
+ destroy()
465
+ }
466
+ })
467
+
468
+ it('round-trips the chart config through getState / setState', async () => {
469
+ const a = await mountGrid({ charting: true })
470
+ try {
471
+ a.api.openChart()
472
+ a.api.setState({ ...a.api.getState(), chart: { open: true, type: 'line', dimension: 'team', series: null, measure: 'salary', reduce: 'avg', stacked: false } } as any)
473
+ await tick()
474
+ const state = a.api.getState() as any
475
+ expect(state.chart).toBeTruthy()
476
+ expect(state.chart.type).toBe('line')
477
+ expect(state.chart.reduce).toBe('avg')
478
+ const spec = a.api.getChartSpec()
479
+ expect(spec!.type).toBe('line')
480
+ } finally {
481
+ a.destroy()
482
+ }
483
+ })
484
+
485
+ it('charts server-side buckets from getAggregate and refetches on filter change', async () => {
486
+ let calls = 0
487
+ let lastFilter: any = null
488
+ const { api, destroy } = await mountGrid({
489
+ charting: {
490
+ getAggregate: async (req: ChartAggregateRequest) => {
491
+ calls += 1
492
+ lastFilter = req.filterModel
493
+ return [
494
+ { category: 'A', value: 10 },
495
+ { category: 'B', value: 20 },
496
+ ]
497
+ },
498
+ },
499
+ })
500
+ try {
501
+ await new Promise((r) => setTimeout(r, 15))
502
+ const spec = api.getChartSpec()
503
+ expect(spec).not.toBeNull()
504
+ expect(spec!.categories).toEqual(['A', 'B'])
505
+ expect(spec!.series[0]!.values).toEqual([10, 20])
506
+ expect(calls).toBeGreaterThan(0)
507
+ // A filter change re-runs the server aggregation with the new filter.
508
+ const before = calls
509
+ api.setFacetFilter('team', ['Research'])
510
+ await new Promise((r) => setTimeout(r, 15))
511
+ expect(calls).toBeGreaterThan(before)
512
+ expect(lastFilter.facets.team).toEqual(['Research'])
513
+ } finally {
514
+ destroy()
515
+ }
516
+ })
517
+
518
+ it('cross-filters the grid when a chart category is clicked', async () => {
519
+ const { api, target, destroy } = await mountGrid({ charting: { defaultOpen: true } })
520
+ try {
521
+ await tick()
522
+ expect(api.getDisplayedRows().length).toBe(people.length)
523
+ // The full-height hit rect for the first category carries data-cat-index=0.
524
+ const hit = target.querySelector<SVGElement>('.sv-grid-chart-panel svg [data-cat-index="0"]')
525
+ expect(hit).toBeTruthy()
526
+ hit!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
527
+ await tick()
528
+ // Clicking one category (a distinct name) filters the grid to that row.
529
+ expect(api.getDisplayedRows().length).toBe(1)
530
+ } finally {
531
+ destroy()
532
+ }
533
+ })
534
+ })