@svgrid/grid 3.0.3 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-D6XvA1Ij.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-Bugf5XlI.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
  273. package/dist/cdn/GridMenus-DhdPF700.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
  276. package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
  277. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
  279. package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
  280. package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
  281. package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
package/src/keyboard.ts CHANGED
@@ -22,6 +22,20 @@ export function getKeyboardIntent(event: KeyboardEvent): GridKeyboardIntent {
22
22
  if (event.ctrlKey && event.key === 'Home') return 'gridStart'
23
23
  if (event.ctrlKey && event.key === 'End') return 'gridEnd'
24
24
 
25
+ // Ctrl/Cmd + arrow is Excel's "jump to the edge of the data region". The
26
+ // grid does not implement that itself - it is a paid command registered
27
+ // through `registerGridShortcuts`, which runs before this function is ever
28
+ // called. If we get here the key was NOT claimed, and the right answer is to
29
+ // do nothing: moving one cell is a silently wrong response to a shortcut
30
+ // every spreadsheet user presses. Alt is excluded so a browser or OS
31
+ // combination still falls through untouched.
32
+ if ((event.ctrlKey || event.metaKey) && !event.altKey) {
33
+ if (
34
+ event.key === 'ArrowLeft' || event.key === 'ArrowRight' ||
35
+ event.key === 'ArrowUp' || event.key === 'ArrowDown'
36
+ ) return 'noop'
37
+ }
38
+
25
39
  if (event.key === 'ArrowLeft') return 'moveLeft'
26
40
  if (event.key === 'ArrowRight') return 'moveRight'
27
41
  if (event.key === 'ArrowUp') return 'moveUp'
@@ -95,3 +109,131 @@ export function getNextActiveCell(
95
109
  cellId: current.cellId,
96
110
  }
97
111
  }
112
+
113
+ export type Collapsed = {
114
+ isRowCollapsed: (rowIndex: number) => boolean
115
+ isColumnCollapsed: (colIndex: number) => boolean
116
+ }
117
+
118
+ /**
119
+ * Where a move lands once collapsed rows and columns are stepped over.
120
+ *
121
+ * A sheet's arrow keys never rest on a hidden row or column: the cursor
122
+ * goes on in the direction of travel to the next one that shows, and when
123
+ * nothing shows in that direction it comes back to the nearest one before
124
+ * the landing, which for End on a sheet whose last columns are hidden is the
125
+ * last visible column. An axis the move did not touch is left alone, so a
126
+ * cursor sitting on a row the user has just hidden still moves sideways.
127
+ * With every cell in the direction of travel hidden the cursor stays put.
128
+ */
129
+ export function pastCollapsed(
130
+ from: ActiveCellState,
131
+ next: ActiveCellState,
132
+ bounds: { maxRow: number; maxCol: number },
133
+ collapsed: Collapsed,
134
+ ): ActiveCellState {
135
+ const axis = (fromIdx: number, nextIdx: number, max: number, isCollapsed: (i: number) => boolean) => {
136
+ if (nextIdx === fromIdx) return nextIdx
137
+ const dir = nextIdx > fromIdx ? 1 : -1
138
+ let i = nextIdx
139
+ while (i >= 0 && i <= max && isCollapsed(i)) i += dir
140
+ if (i >= 0 && i <= max) return i
141
+ i = nextIdx - dir
142
+ while (i !== fromIdx && isCollapsed(i)) i -= dir
143
+ return i
144
+ }
145
+ const rowIndex = axis(from.rowIndex, next.rowIndex, bounds.maxRow, collapsed.isRowCollapsed)
146
+ const colIndex = axis(from.colIndex, next.colIndex, bounds.maxCol, collapsed.isColumnCollapsed)
147
+ return rowIndex === next.rowIndex && colIndex === next.colIndex ? next : { rowIndex, colIndex, cellId: next.cellId }
148
+ }
149
+
150
+ export type EntryIntent = 'moveDown' | 'moveUp' | 'tabNext' | 'tabPrev'
151
+
152
+ export type EntryRect = { minRow: number; maxRow: number; minCol: number; maxCol: number }
153
+
154
+ export type EntryStep = {
155
+ cell: ActiveCellState
156
+ /** The move stayed inside a multi-cell selection, which is kept. */
157
+ withinRange: boolean
158
+ /** The column a run of Tabs began in, for the Enter that ends it. */
159
+ tabOrigin: number | null
160
+ }
161
+
162
+ /**
163
+ * Where Enter and Tab put the cursor, the way a sheet's data entry expects.
164
+ *
165
+ * Two rules on top of `getNextActiveCell`. A run of Tabs remembers the
166
+ * column it started in, and the Enter that ends the run goes down from
167
+ * THAT column, not from the last cell: type across A1, B1, C1 with Tab and
168
+ * Enter lands on A2, ready for the next record. Any other move clears the
169
+ * memory, which the callers do by resetting `tabOrigin` on every plain
170
+ * `setActiveCell`. And inside a selection of more than one cell the cursor
171
+ * stays inside it: Enter walks down a column and wraps to the top of the
172
+ * next, Tab walks along a row and wraps to the start of the next, both
173
+ * back to the first cell after the last, and the selection is left
174
+ * standing, so a block can be filled without ever reaching for the mouse.
175
+ * Arrow keys are not entry keys and never come here.
176
+ */
177
+ export function getEntryStep(
178
+ current: ActiveCellState,
179
+ intent: EntryIntent,
180
+ opts: { maxRow: number; maxCol: number; tabOrigin: number | null; range: EntryRect | null; collapsed?: Collapsed },
181
+ ): EntryStep {
182
+ const step = entryStep(current, intent, opts)
183
+ const collapsed = opts.collapsed
184
+ if (!collapsed) return step
185
+ const hidden = (c: ActiveCellState) => collapsed.isRowCollapsed(c.rowIndex) || collapsed.isColumnCollapsed(c.colIndex)
186
+ if (!hidden(step.cell)) return step
187
+ if (!step.withinRange) return { ...step, cell: pastCollapsed(current, step.cell, opts, collapsed) }
188
+ // Walking a block: the same step again from the hidden cell, until one
189
+ // shows. A block with nothing showing leaves the cursor where it is.
190
+ let cell = step.cell
191
+ for (let guard = (opts.range!.maxRow - opts.range!.minRow + 1) * (opts.range!.maxCol - opts.range!.minCol + 1); guard > 0 && hidden(cell); guard -= 1) {
192
+ cell = entryStep(cell, intent, opts).cell
193
+ }
194
+ return { ...step, cell: hidden(cell) ? current : cell }
195
+ }
196
+
197
+ function entryStep(
198
+ current: ActiveCellState,
199
+ intent: EntryIntent,
200
+ opts: { maxRow: number; maxCol: number; tabOrigin: number | null; range: EntryRect | null },
201
+ ): EntryStep {
202
+ const range = opts.range
203
+ const inRange =
204
+ range !== null &&
205
+ (range.maxRow > range.minRow || range.maxCol > range.minCol) &&
206
+ current.rowIndex >= range.minRow && current.rowIndex <= range.maxRow &&
207
+ current.colIndex >= range.minCol && current.colIndex <= range.maxCol
208
+ if (inRange) {
209
+ let { rowIndex, colIndex } = current
210
+ if (intent === 'moveDown') {
211
+ rowIndex += 1
212
+ if (rowIndex > range.maxRow) { rowIndex = range.minRow; colIndex += 1 }
213
+ if (colIndex > range.maxCol) colIndex = range.minCol
214
+ } else if (intent === 'moveUp') {
215
+ rowIndex -= 1
216
+ if (rowIndex < range.minRow) { rowIndex = range.maxRow; colIndex -= 1 }
217
+ if (colIndex < range.minCol) colIndex = range.maxCol
218
+ } else if (intent === 'tabNext') {
219
+ colIndex += 1
220
+ if (colIndex > range.maxCol) { colIndex = range.minCol; rowIndex += 1 }
221
+ if (rowIndex > range.maxRow) rowIndex = range.minRow
222
+ } else {
223
+ colIndex -= 1
224
+ if (colIndex < range.minCol) { colIndex = range.maxCol; rowIndex -= 1 }
225
+ if (rowIndex < range.minRow) rowIndex = range.maxRow
226
+ }
227
+ return { cell: { rowIndex, colIndex, cellId: current.cellId }, withinRange: true, tabOrigin: null }
228
+ }
229
+ const bounds = { maxRow: opts.maxRow, maxCol: opts.maxCol }
230
+ if (intent === 'tabNext' || intent === 'tabPrev') {
231
+ return {
232
+ cell: getNextActiveCell(current, intent, bounds),
233
+ withinRange: false,
234
+ tabOrigin: opts.tabOrigin ?? current.colIndex,
235
+ }
236
+ }
237
+ const from = opts.tabOrigin === null ? current : { ...current, colIndex: opts.tabOrigin }
238
+ return { cell: getNextActiveCell(from, intent, bounds), withinRange: false, tabOrigin: null }
239
+ }
package/src/menus.test.ts CHANGED
@@ -621,6 +621,32 @@ describe('createMenus - contextMenuItems', () => {
621
621
  expect(ctx.clearSelectedCells).toHaveBeenCalled()
622
622
  })
623
623
 
624
+ it('an object on a built-in key with no action keeps the built-in, and carries the icon', () => {
625
+ const icon = { paths: [{ d: 'M2 2h12v12H2z' }] }
626
+ const ctx = makeCtx({
627
+ props: { contextMenu: [{ key: 'cut', icon }, { key: 'copy', label: 'Copy cells', icon: 'columns' }] },
628
+ contextMenuFor: { rowIndex: 0, colIndex: 0, columnId: 'name', rowId: 'r0', row: { id: 0 } },
629
+ })
630
+ const items = createMenus(ctx).contextMenuItems()
631
+ const cut = items.find((i) => i.key === 'cut')!
632
+ expect(cut.label).toBe('Cut')
633
+ expect(cut.icon).toBe(icon)
634
+ cut.run!()
635
+ expect(ctx.cutSelectionToClipboard).toHaveBeenCalled()
636
+ const copy = items.find((i) => i.key === 'copy')!
637
+ expect(copy.label).toBe('Copy cells')
638
+ expect(copy.icon).toBe('columns')
639
+ })
640
+
641
+ it('an unknown key with no action resolves to an inert item rather than throwing', () => {
642
+ const ctx = makeCtx({
643
+ props: { contextMenu: [{ key: 'nothing', label: 'Nothing' }] },
644
+ contextMenuFor: { rowIndex: 0, colIndex: 0, columnId: 'name', rowId: 'r0', row: { id: 0 } },
645
+ })
646
+ const item = createMenus(ctx).contextMenuItems()[0]!
647
+ expect(item.label).toBe('Nothing')
648
+ expect(item.run).toBeUndefined()
649
+ })
624
650
  it('row_above / row_below / remove_row mutate internalData', () => {
625
651
  const rowA = { id: 1 }
626
652
  const rowB = { id: 2 }
package/src/menus.ts CHANGED
@@ -8,8 +8,10 @@ import {
8
8
  import "./sv-grid-scrollbar";
9
9
  import { untrack } from "svelte";
10
10
  import type {
11
+ ContextMenuIcon,
11
12
  FilterOperator,
12
13
  } from "./SvGrid.types";
14
+ import type { GridIconName } from "./grid-icons";
13
15
  import {
14
16
  clampMenuX,
15
17
  cssEscape,
@@ -414,7 +416,7 @@ export function createMenus<
414
416
 
415
417
  // ---- Right-click context menu --------------------------------------
416
418
  type CtxTarget = { rowIndex: number; colIndex: number; columnId: string; rowId: string; row: TData | null };
417
- type ResolvedItem = { key: string; label: string; separator?: boolean; disabled?: boolean; run?: () => void };
419
+ type ResolvedItem = { key: string; label: string; icon?: GridIconName | ContextMenuIcon; separator?: boolean; disabled?: boolean; run?: () => void };
418
420
 
419
421
  function insertRowAt(t: CtxTarget, offset: number) {
420
422
  const data = ctx.internalData.slice() as TData[];
@@ -543,11 +545,16 @@ export function createMenus<
543
545
  continue;
544
546
  }
545
547
  if (entry.hidden?.(target)) continue;
548
+ // An object on a built-in's key with no action of its own is that
549
+ // built-in with an icon (or a label) of the consumer's choosing.
550
+ const b = builtins[entry.key];
551
+ const builtinLabel = b ? ((b.msg && messages?.[b.msg]) || b.label) : entry.key;
546
552
  out.push({
547
553
  key: entry.key,
548
- label: entry.label,
554
+ label: entry.label ?? builtinLabel,
555
+ icon: entry.icon,
549
556
  disabled: entry.disabled?.(target) ?? false,
550
- run: () => entry.action(target),
557
+ run: entry.action ? () => entry.action(target) : b ? () => b.run(target) : undefined,
551
558
  });
552
559
  }
553
560
  // Drop leading/trailing/double separators.
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildMergeIndex, mergeAt, isCovered, originOf, endOf, expandRectToMerges, stepPastMerge, mergeDrawAt,
4
+ } from './merges'
5
+
6
+ // A1:C1 (three across), B3:B5 (three down), E5:F6 (two by two).
7
+ const index = buildMergeIndex([
8
+ { rowIndex: 0, colIndex: 0, rowSpan: 1, colSpan: 3 },
9
+ { rowIndex: 2, colIndex: 1, rowSpan: 3, colSpan: 1 },
10
+ { rowIndex: 4, colIndex: 4, rowSpan: 2, colSpan: 2 },
11
+ ])
12
+ const bounds = { maxRow: 9, maxCol: 9 }
13
+
14
+ describe('buildMergeIndex', () => {
15
+ it('is null for nothing, and drops 1x1 merges', () => {
16
+ expect(buildMergeIndex([])).toBeNull()
17
+ expect(buildMergeIndex(undefined)).toBeNull()
18
+ expect(buildMergeIndex([{ rowIndex: 0, colIndex: 0, rowSpan: 1, colSpan: 1 }])).toBeNull()
19
+ })
20
+
21
+ it('indexes every covered cell to its merge', () => {
22
+ expect(mergeAt(index, 0, 2)?.colSpan).toBe(3)
23
+ expect(mergeAt(index, 4, 1)?.rowSpan).toBe(3)
24
+ expect(mergeAt(index, 5, 5)).toMatchObject({ rowIndex: 4, colIndex: 4 })
25
+ expect(mergeAt(index, 1, 1)).toBeUndefined()
26
+ expect(isCovered(index, 0, 0)).toBe(false)
27
+ expect(isCovered(index, 0, 1)).toBe(true)
28
+ expect(originOf(index, 5, 5)).toEqual({ rowIndex: 4, colIndex: 4 })
29
+ expect(originOf(index, 7, 7)).toEqual({ rowIndex: 7, colIndex: 7 })
30
+ expect(endOf(index, 2, 1)).toEqual({ rowIndex: 4, colIndex: 1 })
31
+ expect(endOf(index, 7, 7)).toEqual({ rowIndex: 7, colIndex: 7 })
32
+ })
33
+ })
34
+
35
+ describe('expandRectToMerges', () => {
36
+ it('grows a rectangle to hold every merge it touches, to a fixed point', () => {
37
+ expect(expandRectToMerges(index, { minRow: 0, maxRow: 0, minCol: 1, maxCol: 1 })).toEqual({ minRow: 0, maxRow: 0, minCol: 0, maxCol: 2 })
38
+ // B4 alone pulls in B3:B5; a rectangle reaching E5 pulls in E5:F6.
39
+ expect(expandRectToMerges(index, { minRow: 3, maxRow: 3, minCol: 1, maxCol: 1 })).toEqual({ minRow: 2, maxRow: 4, minCol: 1, maxCol: 1 })
40
+ expect(expandRectToMerges(index, { minRow: 4, maxRow: 4, minCol: 1, maxCol: 4 })).toEqual({ minRow: 2, maxRow: 5, minCol: 1, maxCol: 5 })
41
+ })
42
+
43
+ it('hands the same object back when nothing grows', () => {
44
+ const rect = { minRow: 7, maxRow: 8, minCol: 7, maxCol: 8 }
45
+ expect(expandRectToMerges(index, rect)).toBe(rect)
46
+ expect(expandRectToMerges(null, rect)).toBe(rect)
47
+ })
48
+ })
49
+
50
+ describe('stepPastMerge', () => {
51
+ it('a step into a merge lands on its origin', () => {
52
+ expect(stepPastMerge(index, { rowIndex: 1, colIndex: 1 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 0 })
53
+ expect(stepPastMerge(index, { rowIndex: 5, colIndex: 3 }, { rowIndex: 5, colIndex: 4 }, bounds)).toEqual({ rowIndex: 4, colIndex: 4 })
54
+ })
55
+
56
+ it('a step inside the merge the move began in carries on past its far edge', () => {
57
+ // Right from A1 (A1:C1) reaches D1, not B1.
58
+ expect(stepPastMerge(index, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 3 })
59
+ // Down from B3 (B3:B5) reaches B6.
60
+ expect(stepPastMerge(index, { rowIndex: 2, colIndex: 1 }, { rowIndex: 3, colIndex: 1 }, bounds)).toEqual({ rowIndex: 5, colIndex: 1 })
61
+ // Up from E5 (E5:F6) reaches E4; left reaches D5.
62
+ expect(stepPastMerge(index, { rowIndex: 4, colIndex: 4 }, { rowIndex: 3, colIndex: 4 }, bounds)).toEqual({ rowIndex: 3, colIndex: 4 })
63
+ expect(stepPastMerge(index, { rowIndex: 4, colIndex: 4 }, { rowIndex: 4, colIndex: 3 }, bounds)).toEqual({ rowIndex: 4, colIndex: 3 })
64
+ })
65
+
66
+ it('stays on the merge at the sheet edge, and takes the origin of a merge it lands in', () => {
67
+ expect(stepPastMerge(index, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, { maxRow: 9, maxCol: 2 })).toEqual({ rowIndex: 0, colIndex: 0 })
68
+ const chain = buildMergeIndex([
69
+ { rowIndex: 0, colIndex: 0, rowSpan: 1, colSpan: 2 },
70
+ { rowIndex: 0, colIndex: 2, rowSpan: 1, colSpan: 2 },
71
+ ])
72
+ expect(stepPastMerge(chain, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 2 })
73
+ })
74
+
75
+ it('is a pass-through without merges or outside them', () => {
76
+ expect(stepPastMerge(null, { rowIndex: 0, colIndex: 0 }, { rowIndex: 0, colIndex: 1 }, bounds)).toEqual({ rowIndex: 0, colIndex: 1 })
77
+ expect(stepPastMerge(index, { rowIndex: 7, colIndex: 7 }, { rowIndex: 7, colIndex: 8 }, bounds)).toEqual({ rowIndex: 7, colIndex: 8 })
78
+ })
79
+ })
80
+
81
+ describe('mergeDrawAt', () => {
82
+ const all = { firstRow: 0, lastRow: 9, firstCol: 0, lastCol: 9 }
83
+
84
+ it('the origin draws with its spans and the covered cells are skipped', () => {
85
+ expect(mergeDrawAt(index, 0, 0, all)).toEqual({ kind: 'origin', merge: index!.list[0], rowSpan: 1, colSpan: 3 })
86
+ expect(mergeDrawAt(index, 0, 1, all)).toEqual({ kind: 'skip' })
87
+ expect(mergeDrawAt(index, 4, 5, all)).toEqual({ kind: 'skip' })
88
+ expect(mergeDrawAt(index, 1, 1, all)).toBeNull()
89
+ })
90
+
91
+ it('a merge whose origin is above the window continues from its first drawn row, spans clamped', () => {
92
+ const window = { firstRow: 3, lastRow: 9, firstCol: 0, lastCol: 9 }
93
+ // B3:B5 with rows 3.. shown: B4 draws the rest (two rows), B5 is skipped.
94
+ expect(mergeDrawAt(index, 3, 1, window)).toEqual({ kind: 'continuation', merge: index!.list[1], rowSpan: 2, colSpan: 1 })
95
+ expect(mergeDrawAt(index, 4, 1, window)).toEqual({ kind: 'skip' })
96
+ })
97
+
98
+ it('clamps to the window\'s last row and column', () => {
99
+ const window = { firstRow: 0, lastRow: 4, firstCol: 0, lastCol: 4 }
100
+ expect(mergeDrawAt(index, 2, 1, window)).toEqual({ kind: 'origin', merge: index!.list[1], rowSpan: 3, colSpan: 1 })
101
+ expect(mergeDrawAt(index, 4, 4, window)).toEqual({ kind: 'origin', merge: index!.list[2], rowSpan: 1, colSpan: 1 })
102
+ })
103
+
104
+ it('a merge starting in the frozen band draws its remainder in the body as a continuation', () => {
105
+ const frozen = { firstRow: 0, lastRow: 0, firstCol: 0, lastCol: 9 }
106
+ const body = { firstRow: 1, lastRow: 9, firstCol: 0, lastCol: 9 }
107
+ const tall = buildMergeIndex([{ rowIndex: 0, colIndex: 0, rowSpan: 3, colSpan: 1 }])
108
+ expect(mergeDrawAt(tall, 0, 0, frozen)).toEqual({ kind: 'origin', merge: tall!.list[0], rowSpan: 1, colSpan: 1 })
109
+ expect(mergeDrawAt(tall, 1, 0, body)).toEqual({ kind: 'continuation', merge: tall!.list[0], rowSpan: 2, colSpan: 1 })
110
+ expect(mergeDrawAt(tall, 2, 0, body)).toEqual({ kind: 'skip' })
111
+ })
112
+ })
package/src/merges.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Merged cells: a rectangle of cells drawn as one, the way a spreadsheet's
3
+ * Merge & Center does. The grid takes them as `mergedCells`, a list of
4
+ * origins with their spans in display indices, and this module is the
5
+ * pure part: an index over them, the questions the renderer, the
6
+ * selection and the keyboard ask, and nothing that touches the DOM.
7
+ *
8
+ * A merge is drawn by its origin td, which takes `rowspan` and `colspan`;
9
+ * the cells it covers are not emitted. Selection rectangles grow to whole
10
+ * merges (a merge is selected as one), the active cell inside a merge is
11
+ * its origin, and an arrow key moving into a merge lands on the origin
12
+ * and moving out of one steps past its far edge.
13
+ */
14
+
15
+ export type MergedCell = {
16
+ /** Top-left cell of the merge, display indices. */
17
+ rowIndex: number
18
+ colIndex: number
19
+ /** How many rows and columns it covers, 1 or more. */
20
+ rowSpan: number
21
+ colSpan: number
22
+ }
23
+
24
+ export type MergeIndex = {
25
+ readonly list: ReadonlyArray<MergedCell>
26
+ /** Every cell a merge covers (origin included) -> its merge. */
27
+ readonly byCell: ReadonlyMap<string, MergedCell>
28
+ }
29
+
30
+ export type MergeRect = { minRow: number; maxRow: number; minCol: number; maxCol: number }
31
+
32
+ const key = (r: number, c: number) => `${r}:${c}`
33
+
34
+ /** An index over the merges, or null when there are none (the common case). */
35
+ export function buildMergeIndex(merges: ReadonlyArray<MergedCell> | undefined | null): MergeIndex | null {
36
+ if (!merges || merges.length === 0) return null
37
+ const byCell = new Map<string, MergedCell>()
38
+ const list: MergedCell[] = []
39
+ for (const raw of merges) {
40
+ const rowSpan = Math.max(1, Math.floor(raw.rowSpan))
41
+ const colSpan = Math.max(1, Math.floor(raw.colSpan))
42
+ if (rowSpan === 1 && colSpan === 1) continue
43
+ const merge = { rowIndex: raw.rowIndex, colIndex: raw.colIndex, rowSpan, colSpan }
44
+ list.push(merge)
45
+ for (let r = merge.rowIndex; r < merge.rowIndex + rowSpan; r += 1) {
46
+ for (let c = merge.colIndex; c < merge.colIndex + colSpan; c += 1) byCell.set(key(r, c), merge)
47
+ }
48
+ }
49
+ return list.length ? { list, byCell } : null
50
+ }
51
+
52
+ /** The merge covering (r, c), origin included, or undefined. */
53
+ export function mergeAt(index: MergeIndex | null, r: number, c: number): MergedCell | undefined {
54
+ return index?.byCell.get(key(r, c))
55
+ }
56
+
57
+ /** Whether (r, c) is inside a merge without being its origin. */
58
+ export function isCovered(index: MergeIndex | null, r: number, c: number): boolean {
59
+ const m = mergeAt(index, r, c)
60
+ return !!m && (m.rowIndex !== r || m.colIndex !== c)
61
+ }
62
+
63
+ /** The origin of the merge (r, c) sits in, or (r, c) itself. */
64
+ export function originOf(index: MergeIndex | null, r: number, c: number): { rowIndex: number; colIndex: number } {
65
+ const m = mergeAt(index, r, c)
66
+ return m ? { rowIndex: m.rowIndex, colIndex: m.colIndex } : { rowIndex: r, colIndex: c }
67
+ }
68
+
69
+ /** The far corner of the merge (r, c) sits in, or (r, c) itself. */
70
+ export function endOf(index: MergeIndex | null, r: number, c: number): { rowIndex: number; colIndex: number } {
71
+ const m = mergeAt(index, r, c)
72
+ return m
73
+ ? { rowIndex: m.rowIndex + m.rowSpan - 1, colIndex: m.colIndex + m.colSpan - 1 }
74
+ : { rowIndex: r, colIndex: c }
75
+ }
76
+
77
+ /**
78
+ * The rectangle grown to hold every merge it touches, to a fixed point: a
79
+ * merge that pokes out of the rectangle pulls the edge out, which can
80
+ * reach another merge, and so on, as a spreadsheet's selection does.
81
+ */
82
+ export function expandRectToMerges(index: MergeIndex | null, rect: MergeRect): MergeRect {
83
+ if (!index) return rect
84
+ let { minRow, maxRow, minCol, maxCol } = rect
85
+ for (let guard = 0; guard < 64; guard += 1) {
86
+ let grew = false
87
+ for (const m of index.list) {
88
+ const r2 = m.rowIndex + m.rowSpan - 1
89
+ const c2 = m.colIndex + m.colSpan - 1
90
+ const touches = m.rowIndex <= maxRow && r2 >= minRow && m.colIndex <= maxCol && c2 >= minCol
91
+ if (!touches) continue
92
+ if (m.rowIndex < minRow) { minRow = m.rowIndex; grew = true }
93
+ if (r2 > maxRow) { maxRow = r2; grew = true }
94
+ if (m.colIndex < minCol) { minCol = m.colIndex; grew = true }
95
+ if (c2 > maxCol) { maxCol = c2; grew = true }
96
+ }
97
+ if (!grew) break
98
+ }
99
+ return minRow === rect.minRow && maxRow === rect.maxRow && minCol === rect.minCol && maxCol === rect.maxCol
100
+ ? rect
101
+ : { minRow, maxRow, minCol, maxCol }
102
+ }
103
+
104
+ /**
105
+ * Where a move from `from` to `next` really lands. A step into a merge
106
+ * lands on its origin; a step that stays inside the merge the move started
107
+ * in carries on past its far edge (right from a three-wide merge reaches
108
+ * the column after it), clamped to the bounds; a step that then lands in
109
+ * another merge takes that one's origin.
110
+ */
111
+ export function stepPastMerge(
112
+ index: MergeIndex | null,
113
+ from: { rowIndex: number; colIndex: number },
114
+ next: { rowIndex: number; colIndex: number },
115
+ bounds: { maxRow: number; maxCol: number },
116
+ ): { rowIndex: number; colIndex: number } {
117
+ if (!index) return next
118
+ const target = mergeAt(index, next.rowIndex, next.colIndex)
119
+ if (!target) return next
120
+ const source = mergeAt(index, from.rowIndex, from.colIndex)
121
+ if (source !== target) return { rowIndex: target.rowIndex, colIndex: target.colIndex }
122
+ // Still inside the merge the move began in: keep going in that direction.
123
+ const dr = Math.sign(next.rowIndex - from.rowIndex)
124
+ const dc = Math.sign(next.colIndex - from.colIndex)
125
+ if (dr === 0 && dc === 0) return { rowIndex: target.rowIndex, colIndex: target.colIndex }
126
+ let rowIndex = next.rowIndex
127
+ let colIndex = next.colIndex
128
+ if (dr > 0) rowIndex = target.rowIndex + target.rowSpan
129
+ else if (dr < 0) rowIndex = target.rowIndex - 1
130
+ if (dc > 0) colIndex = target.colIndex + target.colSpan
131
+ else if (dc < 0) colIndex = target.colIndex - 1
132
+ if (rowIndex < 0 || rowIndex > bounds.maxRow || colIndex < 0 || colIndex > bounds.maxCol) {
133
+ // Nowhere further to go: the merge itself stays current.
134
+ return { rowIndex: target.rowIndex, colIndex: target.colIndex }
135
+ }
136
+ const landed = mergeAt(index, rowIndex, colIndex)
137
+ return landed ? { rowIndex: landed.rowIndex, colIndex: landed.colIndex } : { rowIndex, colIndex }
138
+ }
139
+
140
+ /**
141
+ * How a td at (r, c) draws inside a rendered window of rows and columns.
142
+ * A merge is drawn once per window, by the top-left cell of the part of it
143
+ * the window holds: the origin when the origin is in the window (`origin`),
144
+ * else the first covered cell the window shows (`continuation`, which
145
+ * draws the origin's content itself), with the spans clamped to the window
146
+ * so a table never spans into rows it has not drawn. Every other cell of
147
+ * the merge is `skip`: no td, the drawn one covers its slot.
148
+ */
149
+ export type MergeDraw =
150
+ | { kind: 'skip' }
151
+ | { kind: 'origin' | 'continuation'; merge: MergedCell; rowSpan: number; colSpan: number }
152
+
153
+ export function mergeDrawAt(
154
+ index: MergeIndex | null,
155
+ r: number,
156
+ c: number,
157
+ window: { firstRow: number; lastRow: number; firstCol: number; lastCol: number },
158
+ ): MergeDraw | null {
159
+ const m = mergeAt(index, r, c)
160
+ if (!m) return null
161
+ const anchorRow = Math.max(m.rowIndex, window.firstRow)
162
+ const anchorCol = Math.max(m.colIndex, window.firstCol)
163
+ if (r !== anchorRow || c !== anchorCol) return { kind: 'skip' }
164
+ const isOrigin = anchorRow === m.rowIndex && anchorCol === m.colIndex
165
+ const rowSpan = Math.max(1, Math.min(m.rowIndex + m.rowSpan - r, window.lastRow - r + 1))
166
+ const colSpan = Math.max(1, Math.min(m.colIndex + m.colSpan - c, window.lastCol - c + 1))
167
+ return { kind: isOrigin ? 'origin' : 'continuation', merge: m, rowSpan, colSpan }
168
+ }
@@ -35,27 +35,32 @@ const data: Row[] = [
35
35
  const engine = (rows: ReadonlyArray<Record<string, unknown>>) => {
36
36
  const byRegion = new Map<string, number>()
37
37
  for (const r of rows) byRegion.set(String(r.region), (byRegion.get(String(r.region)) ?? 0) + Number(r.sales))
38
- const pivotRows: GridPivotRow[] = [...byRegion].map(([region, total], i) => ({
39
- __pivotId: `p${i}`, __pivotDepth: 0, __pivotLabel: region, __pivotParentId: null, __pivotExpandable: false,
40
- region, total,
38
+ const pivotRows: Array<GridPivotRow & { __pivotKind: string }> = [...byRegion].map(([region, total], i) => ({
39
+ __pivotId: `p${i}`, __pivotDepth: 0, __pivotLabel: region, __pivotParentId: null, __pivotExpandable: false, __pivotKind: 'leaf',
40
+ region, total, pv_grand__total: total,
41
41
  }))
42
+ const all = [...byRegion.values()].reduce((a, b) => a + b, 0)
43
+ pivotRows.push({ __pivotId: 'grand', __pivotDepth: 0, __pivotLabel: 'Total', __pivotParentId: null, __pivotExpandable: false, __pivotKind: 'grandTotal', region: 'Total', total: all, pv_grand__total: all })
42
44
  return {
43
45
  rows: pivotRows,
44
46
  columns: [
45
- { id: 'region', header: 'Region', field: 'region', width: 140 },
47
+ { id: '__pivotRowHeader', header: 'Region', field: 'region', width: 140 },
46
48
  { id: 'total', header: 'Total sales', field: 'total', width: 140 },
49
+ { id: 'pv_group__grand', header: 'Grand total', columns: [{ id: 'pv_grand__total', header: 'Total sales', field: 'pv_grand__total', width: 140 }] },
47
50
  ] as never,
48
51
  }
49
52
  }
50
53
 
51
54
  const tick = () => new Promise<void>((r) => queueMicrotask(r))
52
55
 
53
- function mountGrid() {
56
+ function mountGrid(extra: Record<string, unknown> = {}) {
54
57
  const target = document.createElement('div')
58
+ target.style.width = '1000px'
55
59
  document.body.appendChild(target)
56
60
  const app = mount(SvGrid, {
57
61
  target,
58
62
  props: {
63
+ ...extra,
59
64
  data,
60
65
  columns: cols,
61
66
  features,
@@ -99,4 +104,34 @@ describe('in-grid pivot mode', () => {
99
104
  expect(target.textContent).toContain('Total sales')
100
105
  expect(target.textContent).toContain('140') // EU 100 + 40
101
106
  })
107
+
108
+ it('the Chart panel charts the pivot on screen, not the source rows', async () => {
109
+ registerPivotEngine(engine as never)
110
+ const { target, destroy } = mountGrid({ charting: { defaultOpen: true, position: 'right' } })
111
+ cleanup = destroy
112
+ // The panel and the chart engine are lazy chunks: a few macrotasks.
113
+ for (let i = 0; i < 6; i += 1) await new Promise((r) => setTimeout(r))
114
+ const panel = target.querySelector('.sv-grid-chart-panel')!
115
+ expect(panel).toBeTruthy()
116
+ // The categories are the pivot's leaf rows and the series its value column;
117
+ // the grand total is not a bar. The source rows would have drawn three bars
118
+ // (one per row) or asked for a Group by.
119
+ const table = panel.querySelector('.sv-grid-chart-sr-only tbody')!
120
+ const cells = [...table.querySelectorAll('td')].map((td) => td.textContent)
121
+ expect(cells).toEqual(['EU', '140', 'US', '70'])
122
+ expect(panel.querySelector('.sv-grid-chart-sr-only thead')!.textContent).toContain('Total sales')
123
+ // The data pickers are gone and the note says why; Type stays.
124
+ const labels = [...panel.querySelectorAll('.sv-grid-chart-ctl-lbl')].map((l) => l.textContent)
125
+ expect(labels).not.toContain('Group by')
126
+ expect(labels).not.toContain('Value')
127
+ expect(labels).not.toContain('Aggregate')
128
+ expect(labels).toContain('Type')
129
+ expect(panel.querySelector('.sv-grid-chart-pivot-hint')!.textContent).toContain('Charting the pivot')
130
+ // The type list keeps the shapes a pivot can take.
131
+ const options = [...panel.querySelectorAll('.sv-grid-chart-ctl select option')].map((o) => (o as HTMLOptionElement).value)
132
+ expect(options).toContain('line')
133
+ expect(options).toContain('radar')
134
+ expect(options).not.toContain('scatter')
135
+ expect(options).not.toContain('candlestick')
136
+ })
102
137
  })
@@ -209,6 +209,50 @@ describe('rowResize - strip injection', () => {
209
209
  // Drag lifecycle
210
210
  // ---------------------------------------------------------------------------
211
211
 
212
+ describe('rowResize - double-click', () => {
213
+ it('asks for an autosize of the row and keeps the click from the row itself', () => {
214
+ const { host, rows } = buildGrid([{ rowIndex: 0 }, { rowIndex: 1 }])
215
+ track(host)
216
+ const onAutosize = vi.fn()
217
+ const rowSaw = vi.fn()
218
+ rows[1]!.addEventListener('dblclick', rowSaw)
219
+ const onResize = vi.fn()
220
+ const action = rowResize(host, { onResize, onAutosize })
221
+ const strip = stripOf(rows[1]!)!
222
+ // Two presses on one strip inside 400ms are a double-click.
223
+ strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
224
+ window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
225
+ strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
226
+ expect(onAutosize).toHaveBeenCalledWith(1)
227
+ // No drag started, so nothing to release.
228
+ expect(strip.classList.contains('is-resizing')).toBe(false)
229
+ window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
230
+ // The first press's release reported the unchanged height; the second
231
+ // press started no drag.
232
+ expect(onResize).toHaveBeenCalledTimes(1)
233
+ expect(rowSaw).not.toHaveBeenCalled()
234
+ action.destroy()
235
+ })
236
+
237
+ it('does nothing without an onAutosize handler', () => {
238
+ const { host, rows } = buildGrid([{ rowIndex: 0 }])
239
+ track(host)
240
+ const rowSaw = vi.fn()
241
+ rows[0]!.addEventListener('dblclick', rowSaw)
242
+ const onResize = vi.fn()
243
+ const action = rowResize(host, { onResize })
244
+ const strip = stripOf(rows[0]!)!
245
+ strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
246
+ window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
247
+ strip.dispatchEvent(pointer('pointerdown', { clientY: 100 }))
248
+ // Without a handler the second press is an ordinary drag start.
249
+ expect(strip.classList.contains('is-resizing')).toBe(true)
250
+ window.dispatchEvent(pointer('pointerup', { clientY: 100 }))
251
+ expect(rowSaw).not.toHaveBeenCalled()
252
+ action.destroy()
253
+ })
254
+ })
255
+
212
256
  describe('rowResize - drag', () => {
213
257
  it('reports the new height on pointerup and fires move callbacks', () => {
214
258
  const { host, rows } = buildGrid([{ rowIndex: 0 }])
package/src/row-resize.ts CHANGED
@@ -33,6 +33,9 @@ export type RowResizeOptions = {
33
33
  onResize: (rowIndex: number, height: number) => void
34
34
  /** Optional callback fired during the drag (every pointermove). */
35
35
  onResizeMove?: (rowIndex: number, height: number) => void
36
+ /** Double-click on the strip: size the row to its content, the
37
+ * spreadsheet gesture. Without it a double-click does nothing. */
38
+ onAutosize?: (rowIndex: number) => void
36
39
  /** Min row height in px. Default 20. */
37
40
  min?: number
38
41
  /** Max row height in px. Default 320. */
@@ -63,6 +66,8 @@ type ActiveDrag = {
63
66
  export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
64
67
  let current = opts
65
68
  let drag: ActiveDrag | null = null
69
+ /** The last press on a strip, for telling a double-click from two drags. */
70
+ let lastPress: { rowIndex: number; at: number } | null = null
66
71
 
67
72
  function rowIndexOf(tr: HTMLTableRowElement): number {
68
73
  const dataRow = tr.querySelector<HTMLElement>('[data-svgrid-row]')
@@ -133,6 +138,18 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
133
138
  if (!Number.isFinite(rowIndex)) return
134
139
  e.preventDefault()
135
140
  e.stopPropagation()
141
+ // The second press of a double-click: size the row to its content
142
+ // instead of starting another drag. Timed here rather than read from
143
+ // a dblclick event, whose target is the row once the first press's
144
+ // resize has re-rendered the strip; pointerdown carries no count.
145
+ const now = e.timeStamp
146
+ const again = lastPress !== null && lastPress.rowIndex === rowIndex && now - lastPress.at < 400
147
+ lastPress = { rowIndex, at: now }
148
+ if (again && current.onAutosize) {
149
+ lastPress = null
150
+ current.onAutosize(rowIndex)
151
+ return
152
+ }
136
153
  t.classList.add('is-resizing')
137
154
  drag = {
138
155
  rowIndex,