@svgrid/grid 3.0.3 → 3.0.5

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 (325) hide show
  1. package/README.md +6 -5
  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 +122 -41
  8. package/dist/SvGrid.controller.svelte.js +846 -207
  9. package/dist/SvGrid.css +178 -2
  10. package/dist/SvGrid.svelte +717 -720
  11. package/dist/SvGrid.types.d.ts +566 -12
  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 +119 -28
  29. package/dist/cdn/GridMenus-CID62rlI.js +636 -0
  30. package/dist/cdn/GridMenus-DLL-qZeu.js +635 -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/server-block-cache-CGoWz-87.js +237 -0
  67. package/dist/cdn/{src-D6XvA1Ij.js → src-Bklofsad.js} +10615 -10155
  68. package/dist/cdn/{src-Bugf5XlI.js → src-jcFMkBnN.js} +7111 -6651
  69. package/dist/cdn/svgrid.js +27 -15
  70. package/dist/cdn/svgrid.svelte-external.js +27 -15
  71. package/dist/cdn/validate-C7Rn_Bns.js +76 -0
  72. package/dist/chart-axes.d.ts +53 -0
  73. package/dist/chart-axes.js +351 -0
  74. package/dist/chart-cartesian.d.ts +88 -0
  75. package/dist/chart-cartesian.js +1862 -0
  76. package/dist/chart-decimate.d.ts +51 -0
  77. package/dist/chart-decimate.js +199 -0
  78. package/dist/chart-export-pdf.d.ts +47 -0
  79. package/dist/chart-export-pdf.js +187 -0
  80. package/dist/chart-export.d.ts +2 -1
  81. package/dist/chart-export.js +18 -3
  82. package/dist/chart-financial.d.ts +91 -0
  83. package/dist/chart-financial.js +175 -0
  84. package/dist/chart-flow.d.ts +10 -0
  85. package/dist/chart-flow.js +298 -0
  86. package/dist/chart-format.d.ts +18 -0
  87. package/dist/chart-format.js +65 -0
  88. package/dist/chart-grid.d.ts +8 -0
  89. package/dist/chart-grid.js +190 -0
  90. package/dist/chart-hierarchy.d.ts +17 -0
  91. package/dist/chart-hierarchy.js +146 -0
  92. package/dist/chart-indicators.d.ts +115 -0
  93. package/dist/chart-indicators.js +389 -0
  94. package/dist/chart-messages.d.ts +95 -0
  95. package/dist/chart-messages.js +92 -0
  96. package/dist/chart-motion.d.ts +16 -0
  97. package/dist/chart-motion.js +141 -0
  98. package/dist/chart-panel-messages.d.ts +265 -0
  99. package/dist/chart-panel-messages.js +285 -0
  100. package/dist/chart-pivot.d.ts +87 -0
  101. package/dist/chart-pivot.js +156 -0
  102. package/dist/chart-polar.d.ts +25 -0
  103. package/dist/chart-polar.js +700 -0
  104. package/dist/chart-samples.d.ts +16 -0
  105. package/dist/chart-samples.js +153 -0
  106. package/dist/chart-scale.d.ts +113 -0
  107. package/dist/chart-scale.js +405 -0
  108. package/dist/chart-stats.d.ts +111 -0
  109. package/dist/chart-stats.js +419 -0
  110. package/dist/chart-stream.d.ts +45 -0
  111. package/dist/chart-stream.js +91 -0
  112. package/dist/chart-summary.d.ts +27 -0
  113. package/dist/chart-summary.js +205 -0
  114. package/dist/chart-sync.svelte.d.ts +32 -0
  115. package/dist/chart-sync.svelte.js +23 -0
  116. package/dist/chart-table.d.ts +35 -0
  117. package/dist/chart-table.js +165 -0
  118. package/dist/chart-trend.d.ts +19 -0
  119. package/dist/chart-trend.js +61 -0
  120. package/dist/chart-types.d.ts +1456 -0
  121. package/dist/chart-types.js +6 -0
  122. package/dist/chart-validate.d.ts +43 -0
  123. package/dist/chart-validate.js +272 -0
  124. package/dist/chart-zoom.d.ts +40 -0
  125. package/dist/chart-zoom.js +145 -0
  126. package/dist/chart.d.ts +186 -865
  127. package/dist/chart.js +460 -2204
  128. package/dist/clipboard.d.ts +1 -0
  129. package/dist/clipboard.js +137 -18
  130. package/dist/column-resize.d.ts +3 -0
  131. package/dist/column-resize.js +4 -1
  132. package/dist/columns.js +3 -0
  133. package/dist/command-context.d.ts +19 -0
  134. package/dist/command-context.js +116 -0
  135. package/dist/core.d.ts +7 -0
  136. package/dist/core.js +36 -13
  137. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  138. package/dist/editing.d.ts +2 -8
  139. package/dist/editing.js +272 -33
  140. package/dist/fill-patterns.d.ts +0 -5
  141. package/dist/fill-patterns.js +56 -2
  142. package/dist/gantt-view.svelte.d.ts +24 -0
  143. package/dist/gantt-view.svelte.js +13 -0
  144. package/dist/grid-icons.d.ts +2 -2
  145. package/dist/grid-icons.js +2 -2
  146. package/dist/grid-messages.d.ts +16 -3
  147. package/dist/grid-messages.js +10 -0
  148. package/dist/history.d.ts +83 -0
  149. package/dist/history.js +133 -0
  150. package/dist/index.d.ts +29 -10
  151. package/dist/index.js +23 -5
  152. package/dist/keyboard-handlers.js +88 -31
  153. package/dist/keyboard.d.ts +55 -0
  154. package/dist/keyboard.js +132 -0
  155. package/dist/menus.d.ts +3 -1
  156. package/dist/menus.js +7 -2
  157. package/dist/merges.d.ts +97 -0
  158. package/dist/merges.js +147 -0
  159. package/dist/row-model.d.ts +133 -0
  160. package/dist/row-model.js +16 -0
  161. package/dist/row-resize.d.ts +3 -0
  162. package/dist/row-resize.js +14 -0
  163. package/dist/scroll-sync.d.ts +1 -0
  164. package/dist/scroll-sync.js +24 -0
  165. package/dist/selection.d.ts +18 -1
  166. package/dist/selection.js +138 -18
  167. package/dist/server-block-cache.d.ts +214 -0
  168. package/dist/server-block-cache.js +531 -0
  169. package/dist/server-data-source.d.ts +240 -6
  170. package/dist/server-data-source.js +220 -20
  171. package/dist/server.d.ts +13 -0
  172. package/dist/server.js +13 -0
  173. package/dist/shortcut-registry.d.ts +145 -0
  174. package/dist/shortcut-registry.js +44 -0
  175. package/dist/svgrid-wrapper.types.d.ts +213 -22
  176. package/dist/validate.d.ts +4 -0
  177. package/dist/validate.js +21 -3
  178. package/dist/virtualization/virtualizer.js +38 -17
  179. package/package.json +11 -1
  180. package/src/GridMenus.svelte +44 -2
  181. package/src/SvChartMenu.svelte +69 -0
  182. package/src/SvChartPanes.svelte +113 -0
  183. package/src/SvChartPanes.test.ts +66 -0
  184. package/src/SvGrid.controller.svelte.ts +819 -179
  185. package/src/SvGrid.css +178 -2
  186. package/src/SvGrid.svelte +717 -720
  187. package/src/SvGrid.types.ts +564 -11
  188. package/src/SvGridCellEditor.svelte +17 -0
  189. package/src/SvGridChart.svelte +2257 -329
  190. package/src/SvGridChart.test.ts +1419 -1
  191. package/src/SvGridChart.types.ts +336 -0
  192. package/src/SvGridChartBuilder.svelte +323 -0
  193. package/src/SvGridChartPanel.svelte +221 -275
  194. package/src/SvGridChartPickers.svelte +417 -0
  195. package/src/SvGridChartView.svelte +13 -14
  196. package/src/SvModal.svelte +7 -1
  197. package/src/SvModal.test.ts +9 -0
  198. package/src/ai.test.ts +57 -2
  199. package/src/ai.ts +166 -22
  200. package/src/aria.d.ts +11 -0
  201. package/src/build-api.coverage.test.ts +184 -0
  202. package/src/build-api.ts +118 -22
  203. package/src/chart-axes.test.ts +573 -0
  204. package/src/chart-axes.ts +342 -0
  205. package/src/chart-cartesian.ts +1863 -0
  206. package/src/chart-decimate.test.ts +249 -0
  207. package/src/chart-decimate.ts +186 -0
  208. package/src/chart-export-pdf.test.ts +91 -0
  209. package/src/chart-export-pdf.ts +207 -0
  210. package/src/chart-export.test.ts +14 -0
  211. package/src/chart-export.ts +13 -3
  212. package/src/chart-financial.ts +208 -0
  213. package/src/chart-flow.ts +286 -0
  214. package/src/chart-format.ts +67 -0
  215. package/src/chart-grid.ts +179 -0
  216. package/src/chart-hierarchy.ts +135 -0
  217. package/src/chart-indicators.test.ts +443 -0
  218. package/src/chart-indicators.ts +384 -0
  219. package/src/chart-messages.test.ts +21 -0
  220. package/src/chart-messages.ts +184 -0
  221. package/src/chart-motion.test.ts +71 -0
  222. package/src/chart-motion.ts +148 -0
  223. package/src/chart-panel-messages.test.ts +29 -0
  224. package/src/chart-panel-messages.ts +550 -0
  225. package/src/chart-pivot.test.ts +54 -0
  226. package/src/chart-pivot.ts +225 -0
  227. package/src/chart-polar.ts +674 -0
  228. package/src/chart-samples.ts +106 -0
  229. package/src/chart-scale.ts +394 -0
  230. package/src/chart-series-types.test.ts +714 -0
  231. package/src/chart-stats.ts +409 -0
  232. package/src/chart-stream.test.ts +75 -0
  233. package/src/chart-stream.ts +114 -0
  234. package/src/chart-summary.test.ts +90 -0
  235. package/src/chart-summary.ts +197 -0
  236. package/src/chart-sync.svelte.ts +41 -0
  237. package/src/chart-table.test.ts +76 -0
  238. package/src/chart-table.ts +178 -0
  239. package/src/chart-trend.ts +47 -0
  240. package/src/chart-types.ts +1309 -0
  241. package/src/chart-validate.test.ts +100 -0
  242. package/src/chart-validate.ts +247 -0
  243. package/src/chart-zoom.test.ts +137 -0
  244. package/src/chart-zoom.ts +124 -0
  245. package/src/chart.coverage.test.ts +81 -0
  246. package/src/chart.test.ts +56 -3
  247. package/src/chart.ts +557 -2784
  248. package/src/clipboard.test.ts +151 -0
  249. package/src/clipboard.ts +145 -16
  250. package/src/column-resize.ts +7 -1
  251. package/src/columns.ts +2 -0
  252. package/src/command-context.test.ts +200 -0
  253. package/src/command-context.ts +137 -0
  254. package/src/core.rowmodel-cache.test.ts +51 -0
  255. package/src/core.ts +40 -12
  256. package/src/editing.test.ts +281 -3
  257. package/src/editing.ts +264 -38
  258. package/src/fill-patterns.test.ts +35 -0
  259. package/src/fill-patterns.ts +56 -2
  260. package/src/gantt-stub.test.svelte +38 -0
  261. package/src/gantt-view.svelte.ts +35 -0
  262. package/src/grid-icons.ts +2 -2
  263. package/src/grid-messages.ts +28 -3
  264. package/src/history.test.ts +196 -0
  265. package/src/history.ts +162 -0
  266. package/src/icon-seam.test.ts +5 -6
  267. package/src/index.ts +186 -12
  268. package/src/keyboard-handlers.coverage.test.ts +126 -6
  269. package/src/keyboard-handlers.ts +91 -30
  270. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  271. package/src/keyboard.test.ts +124 -1
  272. package/src/keyboard.ts +142 -0
  273. package/src/menus.test.ts +26 -0
  274. package/src/menus.ts +10 -3
  275. package/src/merges.test.ts +112 -0
  276. package/src/merges.ts +168 -0
  277. package/src/pivot.grid.test.ts +40 -5
  278. package/src/row-model.ts +146 -0
  279. package/src/row-resize.test.ts +44 -0
  280. package/src/row-resize.ts +17 -0
  281. package/src/scroll-sync.test.ts +42 -1
  282. package/src/scroll-sync.ts +25 -0
  283. package/src/selection.multi-range.test.ts +34 -0
  284. package/src/selection.test.ts +33 -2
  285. package/src/selection.ts +143 -21
  286. package/src/server-block-cache.test.ts +645 -0
  287. package/src/server-block-cache.ts +677 -0
  288. package/src/server-data-source.infinite.test.ts +343 -0
  289. package/src/server-data-source.ts +450 -27
  290. package/src/server.ts +47 -0
  291. package/src/shortcut-registry.test.ts +97 -0
  292. package/src/shortcut-registry.ts +181 -0
  293. package/src/svgrid-wrapper.types.ts +184 -13
  294. package/src/svgrid.behavior.test.ts +135 -0
  295. package/src/svgrid.charting.test.ts +513 -3
  296. package/src/svgrid.context-menu.test.ts +31 -0
  297. package/src/svgrid.gantt-seam.test.ts +199 -0
  298. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  299. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  300. package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
  301. package/src/svgrid.upsell-license.test.ts +7 -4
  302. package/src/validate.test.ts +29 -0
  303. package/src/validate.ts +28 -3
  304. package/src/virtualization/virtualizer.ts +39 -17
  305. package/dist/SvGroupCell.svelte +0 -141
  306. package/dist/SvGroupCell.svelte.d.ts +0 -49
  307. package/dist/SvRowGroupPanel.svelte +0 -186
  308. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  309. package/dist/cdn/GridMenus-CHOMnW-c.js +0 -602
  310. package/dist/cdn/GridMenus-DhdPF700.js +0 -603
  311. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  312. package/dist/cdn/SvGridChart-1PMSp2LZ.js +0 -1481
  313. package/dist/cdn/SvGridChart-D02avhyr.js +0 -1480
  314. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +0 -834
  315. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +0 -810
  316. package/dist/cdn/SvGridChartView-C--HhdUo.js +0 -56
  317. package/dist/cdn/SvGridChartView-KyyFG-wY.js +0 -55
  318. package/dist/cdn/chart-DLg3_zTQ.js +0 -1652
  319. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  320. package/dist/server-group-model.d.ts +0 -98
  321. package/dist/server-group-model.js +0 -263
  322. package/src/SvGroupCell.svelte +0 -141
  323. package/src/SvRowGroupPanel.svelte +0 -186
  324. package/src/server-group-model.test.ts +0 -294
  325. package/src/server-group-model.ts +0 -370
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
  })
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `GridRowModel` - one prop instead of eleven.
3
+ *
4
+ * A grid backed by a server needs the same wiring every time: hand it the
5
+ * rows, tell it not to sort or filter locally, forward the sort and the filter
6
+ * to the backend, report which rows are on screen, say which rows have not
7
+ * loaded, and delegate selection. Written out by hand that is a dozen props
8
+ * and a filter-shape conversion that every app gets slightly differently:
9
+ *
10
+ * ```svelte
11
+ * <SvGrid data={s.rows} externalSort externalFilter loading={s.loading}
12
+ * onSortingChange={(x) => ctl.setSort(x)}
13
+ * onFiltersChange={(f) => ctl.setFilter({ global: f.global,
14
+ * columns: Object.fromEntries(f.columns.map((c) => [c.id, { ... }])) })}
15
+ * onVisibleRangeChange={(r) => ctl.setViewport(r.startIndex, r.endIndex)}
16
+ * ... />
17
+ * ```
18
+ *
19
+ * An object that implements this interface carries all of it, so the same grid
20
+ * becomes:
21
+ *
22
+ * ```svelte
23
+ * <SvGrid rowModel={ctl} {columns} />
24
+ * ```
25
+ *
26
+ * `createServerDataSource` returns one, and so does the Enterprise server row
27
+ * model - which is the point of the interface rather than a concrete type: the
28
+ * grid depends on the SHAPE, never on either implementation, and an app with
29
+ * its own fetching can satisfy it too.
30
+ *
31
+ * Every member except `subscribe` / `getRows` / `isLoading` is optional, and a
32
+ * prop written explicitly on `<SvGrid>` always wins over the model's answer -
33
+ * so adopting this is never all-or-nothing.
34
+ */
35
+ import type { FilterOperator } from './SvGrid.types'
36
+
37
+ /** The filter state as the grid reports it, straight from `onFiltersChange`. */
38
+ export type GridFilterState = {
39
+ global: string
40
+ columns: Array<{
41
+ id: string
42
+ operator: FilterOperator
43
+ value: string
44
+ valueTo?: string
45
+ selectedValues?: Array<string>
46
+ }>
47
+ }
48
+
49
+ /** The sort the grid hands a row model: column id and direction, in priority order. */
50
+ export type GridRowModelSort = Array<{ id: string; desc: boolean }>
51
+
52
+ /**
53
+ * What `<SvGrid rowModel>` consumes: rows, a loading flag, a change
54
+ * subscription, and whichever optional parts the model implements - sort,
55
+ * filter, viewport, placeholders, group accessors, selection, filter
56
+ * values, paging, pinned rows and pivot columns. `createServerDataSource`
57
+ * and the Enterprise `createServerRowModel` both satisfy it.
58
+ */
59
+ export type GridRowModel<TData> = {
60
+ /**
61
+ * Register a callback for "something changed, re-read me". Returns an
62
+ * unsubscribe function, which the grid calls on unmount.
63
+ */
64
+ subscribe(onChange: () => void): () => void
65
+ /** The rows to render, including any placeholders for unloaded ones. */
66
+ getRows(): ReadonlyArray<TData>
67
+ /** True while a fetch is in flight, for the loading overlay. */
68
+ isLoading(): boolean
69
+ /** Stable row id. Required once selection or editing is involved. */
70
+ getRowId?(row: TData, index: number): string
71
+ /** Sort on the server. Present means "do not sort locally". */
72
+ setSort?(sort: GridRowModelSort): void
73
+ /** Filter on the server. Present means "do not filter locally". */
74
+ setFilter?(filters: GridFilterState): void
75
+ /** Which rows are on screen, so a block-loading model knows what to fetch. */
76
+ setViewport?(startIndex: number, endIndex: number): void
77
+ /** Why a row has no data yet, or null when it does. */
78
+ rowPlaceholder?(row: TData, rowIndex: number): 'loading' | 'failed' | null
79
+ /** Retry the failed block a placeholder row belongs to. */
80
+ retryRow?(row: TData, rowIndex: number): void
81
+ /** Group / tree accessors, for the grid's treegrid keyboard and ARIA. */
82
+ group?: {
83
+ isGroup: (row: TData) => boolean
84
+ level: (row: TData) => number
85
+ expanded?: (row: TData) => boolean
86
+ onToggle: (row: TData) => void
87
+ }
88
+ /** Selection the grid does not own - see the `rowSelectionModel` prop. */
89
+ selection?: {
90
+ isSelected: (rowId: string, row: TData) => boolean
91
+ headerState: () => 'none' | 'some' | 'all'
92
+ toggle: (rowId: string, row: TData, next: boolean) => void
93
+ toggleAll: (next: boolean) => void
94
+ selectedCount?: () => number | null
95
+ bulkUpdate?: (patch: Record<string, unknown>) => Promise<number>
96
+ }
97
+ /** Distinct values for a column's set filter, fetched from the server. */
98
+ filterValues?(columnId: string): Promise<Array<string>>
99
+ /** Server-side paging. Omit it for infinite scrolling. */
100
+ pagination?: {
101
+ pageIndex: number
102
+ pageSize: number
103
+ rowCount: number
104
+ setPage(pageIndex: number): void
105
+ setPageSize(pageSize: number): void
106
+ /** Choices for the footer's page-size selector. */
107
+ pageSizes?: number[]
108
+ /**
109
+ * Ask the grid for as many rows as its body fits: it measures and calls
110
+ * `setPageSize` whenever the fit changes.
111
+ */
112
+ autoPageSize?: boolean
113
+ }
114
+ /** Rows to pin above / below the scrolling body - a grand total, say. */
115
+ readonly pinnedTopRows?: ReadonlyArray<TData>
116
+ readonly pinnedBottomRows?: ReadonlyArray<TData>
117
+ /**
118
+ * Columns that replace the grid's `columns` while the model is in pivot
119
+ * mode, or null. Read after every change notification.
120
+ */
121
+ readonly pivotResultColumns?: ReadonlyArray<unknown> | null
122
+ }
123
+
124
+ /**
125
+ * Turn the grid's filter payload into the column-keyed map a `ServerRequest`
126
+ * carries. Exported because a hand-written `GridRowModel` needs exactly this
127
+ * conversion, and copying it is how the two shapes drift apart.
128
+ */
129
+ export function toServerFilterColumns(
130
+ filters: GridFilterState,
131
+ ): Record<
132
+ string,
133
+ { operator: string; value: string; valueTo?: string; selectedValues?: Array<string> }
134
+ > {
135
+ return Object.fromEntries(
136
+ filters.columns.map((c) => [
137
+ c.id,
138
+ {
139
+ operator: c.operator,
140
+ value: c.value,
141
+ ...(c.valueTo !== undefined ? { valueTo: c.valueTo } : {}),
142
+ ...(c.selectedValues !== undefined ? { selectedValues: c.selectedValues } : {}),
143
+ },
144
+ ]),
145
+ )
146
+ }
@@ -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,
@@ -203,6 +203,47 @@ describe('scheduleScrollSync / flushScheduledScrollSync', () => {
203
203
  })
204
204
  })
205
205
 
206
+ describe('syncScrollNow', () => {
207
+ let cancelled: number[] = []
208
+ beforeEach(() => {
209
+ cancelled = []
210
+ vi.stubGlobal('requestAnimationFrame', () => 42)
211
+ vi.stubGlobal('cancelAnimationFrame', (h: number) => {
212
+ cancelled.push(h)
213
+ })
214
+ })
215
+ afterEach(() => {
216
+ vi.unstubAllGlobals()
217
+ })
218
+
219
+ it('flushes the virtualizers synchronously, without waiting for a frame', () => {
220
+ const { ctx, ss } = syncFor({
221
+ rowVirtualizationEnabled: true,
222
+ columnVirtualizationEnabled: true,
223
+ domToLogicalRowOffset: (n: number) => n * 2,
224
+ })
225
+ ss.syncScrollNow(30, 88)
226
+ expect(ctx.pendingScrollTop).toBe(30)
227
+ expect(ctx.pendingScrollLeft).toBe(88)
228
+ expect(ctx.scrollVersion).toBe(1)
229
+ expect(ctx.virtualizer.setScrollOffset).toHaveBeenCalledWith(60)
230
+ expect(ctx.columnVirtualizer.setHorizontalOffset).toHaveBeenCalledWith(88)
231
+ expect(ctx.scrollSyncRaf).toBeNull()
232
+ expect(cancelled).toEqual([])
233
+ })
234
+
235
+ it('cancels a frame that a scroll event already scheduled, so the position is not applied twice', () => {
236
+ const { ctx, ss } = syncFor({ rowVirtualizationEnabled: true })
237
+ ss.scheduleScrollSync(10, 0)
238
+ expect(ctx.scrollSyncRaf).toBe(42)
239
+ ss.syncScrollNow(64, 0)
240
+ expect(cancelled).toEqual([42])
241
+ expect(ctx.scrollSyncRaf).toBeNull()
242
+ expect(ctx.virtualizer.setScrollOffset).toHaveBeenCalledTimes(1)
243
+ expect(ctx.virtualizer.setScrollOffset).toHaveBeenCalledWith(64)
244
+ })
245
+ })
246
+
206
247
  describe('onBodyScroll', () => {
207
248
  beforeEach(() => {
208
249
  // These tests assert what happens BEFORE the frame runs, so the stub just
@@ -321,7 +362,7 @@ describe('createScrollSync wiring', () => {
321
362
  it('exposes the documented handler surface', () => {
322
363
  const { ss } = syncFor()
323
364
  expect(Object.keys(ss).sort()).toEqual(
324
- ['flushScheduledScrollSync', 'hideTooltip', 'onBodyScroll', 'scheduleScrollSync', 'showTooltipFor'],
365
+ ['flushScheduledScrollSync', 'hideTooltip', 'onBodyScroll', 'scheduleScrollSync', 'showTooltipFor', 'syncScrollNow'],
325
366
  )
326
367
  })
327
368
  })
@@ -66,6 +66,30 @@ export function createScrollSync<
66
66
  ctx.scrollSyncRaf = requestAnimationFrame(flushScheduledScrollSync);
67
67
  }
68
68
 
69
+ /**
70
+ * Sync the virtualizers to a scroll position NOW instead of on the next
71
+ * animation frame.
72
+ *
73
+ * For a scroll the grid made itself - keyboard navigation bringing the
74
+ * active cell into view - the position is already known, and going through
75
+ * the browser's `scroll` event and then the rAF above put the row-window
76
+ * update one frame behind the key press: the keydown's own flush painted
77
+ * the active cell on the OLD rows, and the rows only caught up on the next
78
+ * frame. Two painted frames per arrow key. Syncing inline folds the window
79
+ * update into the same flush as the active-cell change, so the key press
80
+ * paints once. The `scroll` event still fires afterwards and re-syncs to
81
+ * the same offset, which the virtualizers treat as a no-op.
82
+ */
83
+ function syncScrollNow(scrollTop: number, scrollLeft: number) {
84
+ if (ctx.scrollSyncRaf !== null) {
85
+ cancelAnimationFrame(ctx.scrollSyncRaf);
86
+ ctx.scrollSyncRaf = null;
87
+ }
88
+ ctx.pendingScrollTop = scrollTop;
89
+ ctx.pendingScrollLeft = scrollLeft;
90
+ flushScheduledScrollSync();
91
+ }
92
+
69
93
  function onBodyScroll(event: Event) {
70
94
  const container = event.currentTarget as HTMLDivElement | null;
71
95
  if (!container) return;
@@ -91,6 +115,7 @@ export function createScrollSync<
91
115
  hideTooltip,
92
116
  flushScheduledScrollSync,
93
117
  scheduleScrollSync,
118
+ syncScrollNow,
94
119
  onBodyScroll,
95
120
  };
96
121
  }
@@ -58,4 +58,38 @@ describe('multiple range selection', () => {
58
58
  // outside every range
59
59
  expect(s.getCellRangeEdges(2, 2)).toBeNull()
60
60
  })
61
+
62
+ it('cellSelectionState folds active cell, range edges and fill handle into one value', () => {
63
+ const ctx = makeCtx()
64
+ ctx.activeCell = { rowIndex: 1, colIndex: 1, cellId: null }
65
+ ctx.fillHandleCell = { rowIndex: 1, colIndex: 1 }
66
+ const s = createSelection(ctx)
67
+ s.setSelection(0, 0)
68
+ s.extendSelection(1, 1)
69
+
70
+ // The focus corner: active, on the bottom-right edges, carries the handle.
71
+ expect(s.cellSelectionState(1, 1)).toEqual({
72
+ active: true,
73
+ edges: { top: false, bottom: true, left: false, right: true },
74
+ fillHandle: true,
75
+ })
76
+ // Another cell of the range: in the range, not active, no handle.
77
+ expect(s.cellSelectionState(0, 1)).toEqual({
78
+ active: false,
79
+ edges: { top: true, bottom: false, left: false, right: true },
80
+ fillHandle: false,
81
+ })
82
+ // A plain cell is `null`, not an all-false object, so the view can tell
83
+ // "nothing changed here" by identity.
84
+ expect(s.cellSelectionState(5, 5)).toBeNull()
85
+ })
86
+
87
+ it('cellSelectionState reports the active cell even with no range anchored', () => {
88
+ const ctx = makeCtx()
89
+ ctx.activeCell = { rowIndex: 2, colIndex: 3, cellId: null }
90
+ ctx.fillHandleCell = null
91
+ const s = createSelection(ctx)
92
+ expect(s.cellSelectionState(2, 3)).toEqual({ active: true, edges: null, fillHandle: false })
93
+ expect(s.cellSelectionState(2, 4)).toBeNull()
94
+ })
61
95
  })