@svgrid/grid 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +2 -1
  2. package/dist/GridMenus.svelte +44 -2
  3. package/dist/SvChartMenu.svelte +69 -0
  4. package/dist/SvChartMenu.svelte.d.ts +11 -0
  5. package/dist/SvChartPanes.svelte +113 -0
  6. package/dist/SvChartPanes.svelte.d.ts +20 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +110 -39
  8. package/dist/SvGrid.controller.svelte.js +655 -200
  9. package/dist/SvGrid.css +105 -2
  10. package/dist/SvGrid.svelte +386 -514
  11. package/dist/SvGrid.types.d.ts +210 -9
  12. package/dist/SvGridCellEditor.svelte +17 -0
  13. package/dist/SvGridChart.svelte +2257 -329
  14. package/dist/SvGridChart.svelte.d.ts +6 -106
  15. package/dist/SvGridChart.types.d.ts +338 -0
  16. package/dist/SvGridChart.types.js +1 -0
  17. package/dist/SvGridChartBuilder.svelte +323 -0
  18. package/dist/SvGridChartBuilder.svelte.d.ts +34 -0
  19. package/dist/SvGridChartPanel.svelte +221 -275
  20. package/dist/SvGridChartPickers.svelte +417 -0
  21. package/dist/SvGridChartPickers.svelte.d.ts +70 -0
  22. package/dist/SvGridChartView.svelte +13 -14
  23. package/dist/SvModal.svelte +7 -1
  24. package/dist/SvModal.svelte.d.ts +5 -0
  25. package/dist/ai.d.ts +40 -8
  26. package/dist/ai.js +135 -24
  27. package/dist/aria.d.ts +11 -0
  28. package/dist/build-api.js +108 -22
  29. package/dist/cdn/GridMenus-7kbpnnBW.js +635 -0
  30. package/dist/cdn/GridMenus-BL7ZgQvU.js +636 -0
  31. package/dist/cdn/SvChartMenu-Bl6PBbkT.js +58 -0
  32. package/dist/cdn/SvChartMenu-FBSMINA6.js +59 -0
  33. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-BeU_TkbQ.js} +15 -14
  34. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-yX8vzleW.js} +38 -37
  35. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-DQwt4UAs.js} +139 -180
  36. package/dist/cdn/SvDateTimePicker-vNU6bZ-q.js +775 -0
  37. package/dist/cdn/{SvGridCellEditor-CjEJMJKc.js → SvGridCellEditor-B1p-vCK5.js} +188 -178
  38. package/dist/cdn/{SvGridCellEditor-BWnTi2N7.js → SvGridCellEditor-D_0q4xAu.js} +98 -88
  39. package/dist/cdn/SvGridChart-C3EWAZaM.js +3574 -0
  40. package/dist/cdn/SvGridChart-DzLkSwCH.js +3572 -0
  41. package/dist/cdn/SvGridChartBuilder-BE2T1ykB.js +783 -0
  42. package/dist/cdn/SvGridChartBuilder-CfII62sZ.js +784 -0
  43. package/dist/cdn/SvGridChartPanel-CCX5_Wgd.js +675 -0
  44. package/dist/cdn/SvGridChartPanel-xf4sWVTo.js +699 -0
  45. package/dist/cdn/SvGridChartView-CfuXmY5I.js +48 -0
  46. package/dist/cdn/SvGridChartView-DYwabQWj.js +47 -0
  47. package/dist/cdn/SvMenuList-CLN8OuIK.js +452 -0
  48. package/dist/cdn/SvMenuList-DkGhNKjf.js +451 -0
  49. package/dist/cdn/SvModal-BAE-pjZX.js +396 -0
  50. package/dist/cdn/SvModal-CIZWCcad.js +397 -0
  51. package/dist/cdn/chart-Dz7SqMXH.js +4341 -0
  52. package/dist/cdn/chart-export-BXqueeWJ.js +135 -0
  53. package/dist/cdn/chart-export-pdf-DONFUnNY.js +110 -0
  54. package/dist/cdn/chart-panel-messages-CUbf2R4i.js +1052 -0
  55. package/dist/cdn/chart-panel-messages-CmNrMdsr.js +1053 -0
  56. package/dist/cdn/chart-summary-BJW_tg_X.js +138 -0
  57. package/dist/cdn/chart-trend-CaN9mDEV.js +50 -0
  58. package/dist/cdn/chart-validate-HcOVFAfJ.js +188 -0
  59. package/dist/cdn/{column-resize-DsfNXMom.js → column-resize-DpmOLfRp.js} +3 -3
  60. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CtquV-p3.js} +583 -603
  61. package/dist/cdn/{date-format-BNii4zeD.js → date-format-D6KFzU_W.js} +347 -367
  62. package/dist/cdn/dismissable-DAHetSNk.js +44 -0
  63. package/dist/cdn/editor-contract-LXgQJAgd.js +22 -0
  64. package/dist/cdn/focus-trap-BBOQYBma.js +65 -0
  65. package/dist/cdn/{row-resize-BRcimkUT.js → row-resize-niQCp040.js} +44 -36
  66. package/dist/cdn/{src-DNbz94Ct.js → src-BKhZ6eXd.js} +10227 -9787
  67. package/dist/cdn/{src-BJ2opd0j.js → src-V1uu8iE9.js} +6568 -6128
  68. package/dist/cdn/svgrid.js +26 -15
  69. package/dist/cdn/svgrid.svelte-external.js +26 -15
  70. package/dist/chart-axes.d.ts +53 -0
  71. package/dist/chart-axes.js +351 -0
  72. package/dist/chart-cartesian.d.ts +88 -0
  73. package/dist/chart-cartesian.js +1862 -0
  74. package/dist/chart-decimate.d.ts +51 -0
  75. package/dist/chart-decimate.js +199 -0
  76. package/dist/chart-export-pdf.d.ts +47 -0
  77. package/dist/chart-export-pdf.js +187 -0
  78. package/dist/chart-export.d.ts +2 -1
  79. package/dist/chart-export.js +18 -3
  80. package/dist/chart-financial.d.ts +91 -0
  81. package/dist/chart-financial.js +175 -0
  82. package/dist/chart-flow.d.ts +10 -0
  83. package/dist/chart-flow.js +298 -0
  84. package/dist/chart-format.d.ts +18 -0
  85. package/dist/chart-format.js +65 -0
  86. package/dist/chart-grid.d.ts +8 -0
  87. package/dist/chart-grid.js +190 -0
  88. package/dist/chart-hierarchy.d.ts +17 -0
  89. package/dist/chart-hierarchy.js +146 -0
  90. package/dist/chart-indicators.d.ts +115 -0
  91. package/dist/chart-indicators.js +389 -0
  92. package/dist/chart-messages.d.ts +95 -0
  93. package/dist/chart-messages.js +92 -0
  94. package/dist/chart-motion.d.ts +16 -0
  95. package/dist/chart-motion.js +141 -0
  96. package/dist/chart-panel-messages.d.ts +265 -0
  97. package/dist/chart-panel-messages.js +285 -0
  98. package/dist/chart-pivot.d.ts +87 -0
  99. package/dist/chart-pivot.js +156 -0
  100. package/dist/chart-polar.d.ts +25 -0
  101. package/dist/chart-polar.js +700 -0
  102. package/dist/chart-samples.d.ts +16 -0
  103. package/dist/chart-samples.js +153 -0
  104. package/dist/chart-scale.d.ts +113 -0
  105. package/dist/chart-scale.js +405 -0
  106. package/dist/chart-stats.d.ts +111 -0
  107. package/dist/chart-stats.js +419 -0
  108. package/dist/chart-stream.d.ts +45 -0
  109. package/dist/chart-stream.js +91 -0
  110. package/dist/chart-summary.d.ts +27 -0
  111. package/dist/chart-summary.js +205 -0
  112. package/dist/chart-sync.svelte.d.ts +32 -0
  113. package/dist/chart-sync.svelte.js +23 -0
  114. package/dist/chart-table.d.ts +35 -0
  115. package/dist/chart-table.js +165 -0
  116. package/dist/chart-trend.d.ts +19 -0
  117. package/dist/chart-trend.js +61 -0
  118. package/dist/chart-types.d.ts +1456 -0
  119. package/dist/chart-types.js +6 -0
  120. package/dist/chart-validate.d.ts +43 -0
  121. package/dist/chart-validate.js +272 -0
  122. package/dist/chart-zoom.d.ts +40 -0
  123. package/dist/chart-zoom.js +145 -0
  124. package/dist/chart.d.ts +186 -865
  125. package/dist/chart.js +460 -2204
  126. package/dist/clipboard.d.ts +1 -0
  127. package/dist/clipboard.js +137 -18
  128. package/dist/column-resize.d.ts +3 -0
  129. package/dist/column-resize.js +4 -1
  130. package/dist/columns.js +3 -0
  131. package/dist/command-context.d.ts +19 -0
  132. package/dist/command-context.js +116 -0
  133. package/dist/core.d.ts +7 -0
  134. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  135. package/dist/editing.d.ts +2 -8
  136. package/dist/editing.js +262 -33
  137. package/dist/fill-patterns.d.ts +0 -5
  138. package/dist/fill-patterns.js +56 -2
  139. package/dist/grid-messages.d.ts +10 -3
  140. package/dist/grid-messages.js +5 -0
  141. package/dist/history.d.ts +83 -0
  142. package/dist/history.js +133 -0
  143. package/dist/index.d.ts +24 -6
  144. package/dist/index.js +20 -2
  145. package/dist/keyboard-handlers.js +88 -31
  146. package/dist/keyboard.d.ts +55 -0
  147. package/dist/keyboard.js +132 -0
  148. package/dist/menus.d.ts +3 -1
  149. package/dist/menus.js +7 -2
  150. package/dist/merges.d.ts +97 -0
  151. package/dist/merges.js +147 -0
  152. package/dist/row-resize.d.ts +3 -0
  153. package/dist/row-resize.js +14 -0
  154. package/dist/scroll-sync.d.ts +1 -0
  155. package/dist/scroll-sync.js +24 -0
  156. package/dist/selection.d.ts +16 -0
  157. package/dist/selection.js +108 -16
  158. package/dist/shortcut-registry.d.ts +145 -0
  159. package/dist/shortcut-registry.js +44 -0
  160. package/dist/svgrid-wrapper.types.d.ts +213 -22
  161. package/dist/virtualization/virtualizer.js +38 -17
  162. package/package.json +6 -1
  163. package/src/GridMenus.svelte +44 -2
  164. package/src/SvChartMenu.svelte +69 -0
  165. package/src/SvChartPanes.svelte +113 -0
  166. package/src/SvChartPanes.test.ts +66 -0
  167. package/src/SvGrid.controller.svelte.ts +629 -172
  168. package/src/SvGrid.css +105 -2
  169. package/src/SvGrid.svelte +386 -514
  170. package/src/SvGrid.types.ts +188 -8
  171. package/src/SvGridCellEditor.svelte +17 -0
  172. package/src/SvGridChart.svelte +2257 -329
  173. package/src/SvGridChart.test.ts +1419 -1
  174. package/src/SvGridChart.types.ts +336 -0
  175. package/src/SvGridChartBuilder.svelte +323 -0
  176. package/src/SvGridChartPanel.svelte +221 -275
  177. package/src/SvGridChartPickers.svelte +417 -0
  178. package/src/SvGridChartView.svelte +13 -14
  179. package/src/SvModal.svelte +7 -1
  180. package/src/SvModal.test.ts +9 -0
  181. package/src/ai.test.ts +57 -2
  182. package/src/ai.ts +166 -22
  183. package/src/aria.d.ts +11 -0
  184. package/src/build-api.coverage.test.ts +184 -0
  185. package/src/build-api.ts +107 -18
  186. package/src/chart-axes.test.ts +573 -0
  187. package/src/chart-axes.ts +342 -0
  188. package/src/chart-cartesian.ts +1863 -0
  189. package/src/chart-decimate.test.ts +249 -0
  190. package/src/chart-decimate.ts +186 -0
  191. package/src/chart-export-pdf.test.ts +91 -0
  192. package/src/chart-export-pdf.ts +207 -0
  193. package/src/chart-export.test.ts +14 -0
  194. package/src/chart-export.ts +13 -3
  195. package/src/chart-financial.ts +208 -0
  196. package/src/chart-flow.ts +286 -0
  197. package/src/chart-format.ts +67 -0
  198. package/src/chart-grid.ts +179 -0
  199. package/src/chart-hierarchy.ts +135 -0
  200. package/src/chart-indicators.test.ts +443 -0
  201. package/src/chart-indicators.ts +384 -0
  202. package/src/chart-messages.test.ts +21 -0
  203. package/src/chart-messages.ts +184 -0
  204. package/src/chart-motion.test.ts +71 -0
  205. package/src/chart-motion.ts +148 -0
  206. package/src/chart-panel-messages.test.ts +29 -0
  207. package/src/chart-panel-messages.ts +550 -0
  208. package/src/chart-pivot.test.ts +54 -0
  209. package/src/chart-pivot.ts +225 -0
  210. package/src/chart-polar.ts +674 -0
  211. package/src/chart-samples.ts +106 -0
  212. package/src/chart-scale.ts +394 -0
  213. package/src/chart-series-types.test.ts +714 -0
  214. package/src/chart-stats.ts +409 -0
  215. package/src/chart-stream.test.ts +75 -0
  216. package/src/chart-stream.ts +114 -0
  217. package/src/chart-summary.test.ts +90 -0
  218. package/src/chart-summary.ts +197 -0
  219. package/src/chart-sync.svelte.ts +41 -0
  220. package/src/chart-table.test.ts +76 -0
  221. package/src/chart-table.ts +178 -0
  222. package/src/chart-trend.ts +47 -0
  223. package/src/chart-types.ts +1309 -0
  224. package/src/chart-validate.test.ts +100 -0
  225. package/src/chart-validate.ts +247 -0
  226. package/src/chart-zoom.test.ts +137 -0
  227. package/src/chart-zoom.ts +124 -0
  228. package/src/chart.coverage.test.ts +81 -0
  229. package/src/chart.test.ts +56 -3
  230. package/src/chart.ts +557 -2784
  231. package/src/clipboard.test.ts +151 -0
  232. package/src/clipboard.ts +145 -16
  233. package/src/column-resize.ts +7 -1
  234. package/src/columns.ts +2 -0
  235. package/src/command-context.test.ts +200 -0
  236. package/src/command-context.ts +137 -0
  237. package/src/core.ts +7 -0
  238. package/src/editing.test.ts +281 -3
  239. package/src/editing.ts +256 -38
  240. package/src/fill-patterns.test.ts +35 -0
  241. package/src/fill-patterns.ts +56 -2
  242. package/src/grid-messages.ts +16 -3
  243. package/src/history.test.ts +196 -0
  244. package/src/history.ts +162 -0
  245. package/src/icon-seam.test.ts +3 -2
  246. package/src/index.ts +150 -2
  247. package/src/keyboard-handlers.coverage.test.ts +126 -6
  248. package/src/keyboard-handlers.ts +91 -30
  249. package/src/keyboard-shortcuts.seam.test.ts +200 -0
  250. package/src/keyboard.test.ts +124 -1
  251. package/src/keyboard.ts +142 -0
  252. package/src/menus.test.ts +26 -0
  253. package/src/menus.ts +10 -3
  254. package/src/merges.test.ts +112 -0
  255. package/src/merges.ts +168 -0
  256. package/src/pivot.grid.test.ts +40 -5
  257. package/src/row-resize.test.ts +44 -0
  258. package/src/row-resize.ts +17 -0
  259. package/src/scroll-sync.test.ts +42 -1
  260. package/src/scroll-sync.ts +25 -0
  261. package/src/selection.multi-range.test.ts +34 -0
  262. package/src/selection.test.ts +30 -2
  263. package/src/selection.ts +111 -19
  264. package/src/shortcut-registry.test.ts +97 -0
  265. package/src/shortcut-registry.ts +181 -0
  266. package/src/svgrid-wrapper.types.ts +184 -13
  267. package/src/svgrid.behavior.test.ts +135 -0
  268. package/src/svgrid.charting.test.ts +513 -3
  269. package/src/svgrid.context-menu.test.ts +31 -0
  270. package/src/svgrid.new-features.wrapper.test.ts +10 -3
  271. package/src/virtualization/virtualizer.ts +39 -17
  272. package/dist/cdn/GridMenus-DyFA-QxA.js +0 -602
  273. package/dist/cdn/GridMenus-M2XP9t0_.js +0 -603
  274. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +0 -816
  275. package/dist/cdn/SvGridChart-DAb9RbRs.js +0 -1480
  276. package/dist/cdn/SvGridChart-Yl-IXZFO.js +0 -1481
  277. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +0 -834
  278. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +0 -810
  279. package/dist/cdn/SvGridChartView-CxfakxR0.js +0 -56
  280. package/dist/cdn/SvGridChartView-Dm_YEx9L.js +0 -55
  281. package/dist/cdn/chart-CKJRCPOk.js +0 -1652
@@ -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
  })
@@ -75,6 +75,8 @@ function makeCtx(overrides: any = {}) {
75
75
  // for plain drag-select. Its own behaviour is covered in move-cells.test.ts.
76
76
  trackEdgeScroll: vi.fn(),
77
77
  stopEdgeScroll: vi.fn(),
78
+ // Inline virtualizer sync after a programmatic scroll (scroll-sync.ts).
79
+ syncScrollNow: vi.fn(),
78
80
  grid: {
79
81
  setRowSelection,
80
82
  setActiveCell,
@@ -367,6 +369,19 @@ describe('createSelection - onCellPointerDown', () => {
367
369
  expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 0, colIndex: 0 })
368
370
  expect(ctx.selectionRange.focus).toEqual({ rowIndex: 1, colIndex: 1 })
369
371
  expect(ctx.activeAtPointerDown).toEqual({ rowIndex: 0, colIndex: 0 })
372
+ // The active cell stays at the anchor, as in Excel.
373
+ expect(ctx.grid.setActiveCell).not.toHaveBeenCalled()
374
+ })
375
+
376
+ it('Shift+click on a grid never clicked makes the cell active', () => {
377
+ const ctx = makeCtx({
378
+ allRows: [makeDataRow('r0'), makeDataRow('r1')],
379
+ allColumns: [makeColumn('c0'), makeColumn('c1')],
380
+ activeCell: null,
381
+ })
382
+ const sel = createSelection(ctx)
383
+ sel.onCellPointerDown(1, 1, { button: 0, shiftKey: true } as any)
384
+ expect(ctx.grid.setActiveCell).toHaveBeenCalled()
370
385
  })
371
386
  })
372
387
 
@@ -378,7 +393,7 @@ describe('createSelection - onCellPointerEnter / drag', () => {
378
393
  expect(ctx.grid.setActiveCell).not.toHaveBeenCalled()
379
394
  })
380
395
 
381
- it('extends selection and moves active cell while dragging', () => {
396
+ it('extends the selection while dragging and leaves the active cell where the drag began', () => {
382
397
  const ctx = makeCtx({
383
398
  isDraggingSelection: true,
384
399
  selectionRange: { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } },
@@ -388,7 +403,7 @@ describe('createSelection - onCellPointerEnter / drag', () => {
388
403
  const sel = createSelection(ctx)
389
404
  sel.onCellPointerEnter(1, 1)
390
405
  expect(ctx.selectionRange.focus).toEqual({ rowIndex: 1, colIndex: 1 })
391
- expect(ctx.grid.setActiveCell).toHaveBeenCalled()
406
+ expect(ctx.grid.setActiveCell).not.toHaveBeenCalled()
392
407
  })
393
408
 
394
409
  it('ignores group rows while dragging', () => {
@@ -649,6 +664,19 @@ describe('createSelection - onCellClick', () => {
649
664
  expect(ctx.onCellDoubleClick).toHaveBeenCalledWith(0, 0)
650
665
  })
651
666
 
667
+ it('leaves the cell alone on a second click when editOnSecondClick is off', () => {
668
+ // A sheet: clicking the active cell again is a click, not an edit.
669
+ const ctx = clickCtx({
670
+ editingEnabled: true,
671
+ activeAtPointerDown: { rowIndex: 0, colIndex: 0 },
672
+ props: { editOnSecondClick: false },
673
+ })
674
+ const sel = createSelection(ctx)
675
+ sel.onCellClick(0, 0)
676
+ expect(ctx.onCellDoubleClick).not.toHaveBeenCalled()
677
+ expect(ctx.editingCell).toBeNull()
678
+ })
679
+
652
680
  it('clears editing when not re-clicking the active cell', () => {
653
681
  const ctx = clickCtx({
654
682
  editingEnabled: true,
package/src/selection.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  getColumnBaseValue,
15
15
  isGroupRow,
16
16
  } from "./cell-values";
17
+ import { expandRectToMerges, originOf, endOf } from "./merges";
17
18
 
18
19
  export function createSelection<
19
20
  TFeatures extends TableFeatures = TableFeatures,
@@ -41,7 +42,18 @@ export function createSelection<
41
42
  }
42
43
 
43
44
  function setActiveCell(rowIndex: number, colIndex: number) {
45
+ // The active cell inside a merge is its origin: the one that draws, the
46
+ // one the formula bar shows, the one an edit writes.
47
+ ({ rowIndex, colIndex } = originOf(ctx.mergeIndex, rowIndex, colIndex));
44
48
  ctx.userHasActivatedCell = true;
49
+ // A click, an arrow, a jump: the Tab run is over. The Tab and Enter
50
+ // paths put the origin back after this call when it should live on.
51
+ // Activating the cell the cursor is already on (typing into it, F2, a
52
+ // double-click) is not a move and keeps the run.
53
+ const prev = ctx.grid.getState().activeCell;
54
+ if (!prev || prev.rowIndex !== rowIndex || prev.colIndex !== colIndex) {
55
+ ctx.tabRunOrigin = null;
56
+ }
45
57
  ctx.grid.setActiveCell({
46
58
  rowIndex,
47
59
  colIndex,
@@ -64,8 +76,16 @@ export function createSelection<
64
76
  if (!ctx.scrollContainer) return;
65
77
  if (rowIndex < 0 || rowIndex >= ctx.allRows.length) return;
66
78
  if (colIndex < 0 || colIndex >= ctx.allColumns.length) return;
67
-
68
- if (ctx.rowVirtualizationEnabled) {
79
+ const container = ctx.scrollContainer as HTMLElement;
80
+ const topBefore = container.scrollTop;
81
+ const leftBefore = container.scrollLeft;
82
+ // A frozen row is always in view; only the column may need to move.
83
+ const frozen: number = ctx.frozenRowCount ?? 0;
84
+ const frozenBand: number = ctx.frozenBandHeight ?? 0;
85
+
86
+ if (rowIndex < frozen) {
87
+ // Always in view; only the column below may need to move.
88
+ } else if (ctx.rowVirtualizationEnabled) {
69
89
  // Prefer the browser's native `scrollIntoView({ block: 'nearest' })`
70
90
  // when the target row is already mounted. It does Excel-style
71
91
  // minimum-scroll, respects the sticky thead via the
@@ -116,8 +136,10 @@ export function createSelection<
116
136
  const rowHeight = ctx.virtualizer.getSizeForIndex(rowIndex)
117
137
  const rowBottom = rowTopScroll + rowHeight
118
138
  let nextTop = currentTop
119
- if (rowTopScroll < currentTop + headerHeight) {
120
- nextTop = rowTopScroll - headerHeight
139
+ // The frozen band sits under the header, so a row is hidden until
140
+ // it clears both.
141
+ if (rowTopScroll < currentTop + headerHeight + frozenBand) {
142
+ nextTop = rowTopScroll - headerHeight - frozenBand
121
143
  } else if (rowBottom > currentTop + clientHeight) {
122
144
  nextTop = rowBottom - clientHeight
123
145
  }
@@ -170,33 +192,66 @@ export function createSelection<
170
192
  } else if (cellEnd > viewEnd) {
171
193
  ctx.scrollContainer.scrollLeft = cellEnd - ctx.scrollContainer.clientWidth;
172
194
  }
173
- // No inline scrollVersion bump - the `scroll` event triggers
174
- // onBodyScroll which flushes via rAF, doing one batched update.
195
+ // The scroll position is known right here, so hand it to the
196
+ // virtualizers now rather than waiting for the `scroll` event + rAF:
197
+ // that put the new row window one painted frame behind the active-cell
198
+ // move (see `syncScrollNow`). Reading the position back is cheap - the
199
+ // scrollIntoView / scrollTop writes above already laid the tree out.
200
+ const topAfter = container.scrollTop;
201
+ const leftAfter = container.scrollLeft;
202
+ if (topAfter !== topBefore || leftAfter !== leftBefore) {
203
+ ctx.syncScrollNow(topAfter, leftAfter);
204
+ }
175
205
  }
176
206
 
177
- // Normalize a range to a rectangle, or null when incomplete.
207
+ // Normalize a range to a rectangle, or null when incomplete. A merge
208
+ // the rectangle touches is in it whole, as a spreadsheet's selection
209
+ // has it: a merged cell is one cell.
178
210
  function rangeRect(range: SelectionRange | null) {
179
211
  const a = range?.anchor;
180
212
  const f = range?.focus;
181
213
  if (!a || !f) return null;
182
- return {
214
+ return expandRectToMerges(ctx.mergeIndex, {
183
215
  minRow: Math.min(a.rowIndex, f.rowIndex),
184
216
  maxRow: Math.max(a.rowIndex, f.rowIndex),
185
217
  minCol: Math.min(a.colIndex, f.colIndex),
186
218
  maxCol: Math.max(a.colIndex, f.colIndex),
187
- };
219
+ });
188
220
  }
189
221
 
222
+ type SelectionRect = { minRow: number; maxRow: number; minCol: number; maxCol: number };
223
+
224
+ /** The active range as a rectangle, or null while nothing is anchored. */
225
+ function activeRangeRect(): SelectionRect | null {
226
+ return rangeRect(ctx.selectionRange as SelectionRange);
227
+ }
228
+ // Memo for `getSelectionRects`, keyed on the identity of its two inputs.
229
+ // Both are replaced wholesale on every change and never mutated in place,
230
+ // so identity is a complete change check. Without it, every rendered
231
+ // cell's outline derived rebuilt this array on every arrow key - ~300
232
+ // arrays plus their rect objects per key press, for a result that is the
233
+ // same until the selection moves.
234
+ let rectsForRange: SelectionRange | null = null;
235
+ let rectsForRanges: SelectionRange[] | null = null;
236
+ let rectsMemo: SelectionRect[] = [];
237
+
190
238
  // Every selected rectangle: the committed extra ranges plus the active one.
191
239
  // Order matters for copy (added-order); the active range comes last.
192
- function getSelectionRects() {
193
- const rects = [] as Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }>;
194
- for (const r of (ctx.selectionRanges as SelectionRange[]) ?? []) {
240
+ // Callers must treat the result as read-only.
241
+ function getSelectionRects(): SelectionRect[] {
242
+ const range = ctx.selectionRange as SelectionRange;
243
+ const ranges = (ctx.selectionRanges as SelectionRange[]) ?? [];
244
+ if (range === rectsForRange && ranges === rectsForRanges) return rectsMemo;
245
+ const rects: SelectionRect[] = [];
246
+ for (const r of ranges) {
195
247
  const rect = rangeRect(r);
196
248
  if (rect) rects.push(rect);
197
249
  }
198
- const active = rangeRect(ctx.selectionRange);
250
+ const active = rangeRect(range);
199
251
  if (active) rects.push(active);
252
+ rectsForRange = range;
253
+ rectsForRanges = ranges;
254
+ rectsMemo = rects;
200
255
  return rects;
201
256
  }
202
257
 
@@ -259,6 +314,9 @@ export function createSelection<
259
314
  // that work to return null every time.
260
315
  if (!hasAnySelectionRect()) return null;
261
316
 
317
+ // A td inside a merge draws to the merge's far corner, so its bottom and
318
+ // right edges are the merge's, not the cell's.
319
+ const end = endOf(ctx.mergeIndex, rowIndex, colIndex);
262
320
  for (const rect of getSelectionRects()) {
263
321
  if (
264
322
  rowIndex < rect.minRow ||
@@ -270,14 +328,40 @@ export function createSelection<
270
328
  }
271
329
  return {
272
330
  top: rowIndex === rect.minRow,
273
- bottom: rowIndex === rect.maxRow,
331
+ bottom: end.rowIndex === rect.maxRow,
274
332
  left: colIndex === rect.minCol,
275
- right: colIndex === rect.maxCol,
333
+ right: end.colIndex === rect.maxCol,
276
334
  };
277
335
  }
278
336
  return null;
279
337
  }
280
338
 
339
+ /**
340
+ * Everything a cell's outline needs from the selection model, in ONE value:
341
+ * whether it is the active cell, which range edges it sits on (`edges` is
342
+ * null when it is in no range) and whether it carries the fill handle.
343
+ * `null` for a cell that is none of those - nearly every cell.
344
+ *
345
+ * One value on purpose. The view reads it through a single `{@const}`, so
346
+ * an active-cell move re-marks one derived per rendered cell instead of
347
+ * three (active flag, range edges, fill handle) plus the fill handle's
348
+ * `{#if}`. With ~300 cells rendered, that marking was the largest single
349
+ * cost of an arrow key that does not scroll. A plain cell returns the same
350
+ * `null` it returned before the move, so its attribute effect stays put.
351
+ */
352
+ function cellSelectionState(rowIndex: number, colIndex: number) {
353
+ const a = ctx.activeCell;
354
+ const active = !!a && a.rowIndex === rowIndex && a.colIndex === colIndex;
355
+ const edges = getCellRangeEdges(rowIndex, colIndex);
356
+ const fh = ctx.fillHandleCell;
357
+ // The handle sits on the range's bottom-right cell; when that is inside
358
+ // a merge, the td that draws the merge carries it.
359
+ const end = fh ? endOf(ctx.mergeIndex, rowIndex, colIndex) : null;
360
+ const fillHandle = !!fh && !!end && fh.rowIndex === end.rowIndex && fh.colIndex === end.colIndex;
361
+ if (!active && !edges && !fillHandle) return null;
362
+ return { active, edges, fillHandle };
363
+ }
364
+
281
365
  /**
282
366
  * The rectangle a drag is currently promising, or null when no drag is in
283
367
  * flight. A fill grows the source rectangle out to the pointer; a move
@@ -395,8 +479,12 @@ export function createSelection<
395
479
  ? { rowIndex: active.rowIndex, colIndex: active.colIndex }
396
480
  : null;
397
481
  if (event.shiftKey) {
482
+ // The range grows to the clicked cell and the active cell stays where
483
+ // it is, as in Excel: it is the cell the formula bar shows and the
484
+ // one the next keystroke edits. Used to jump to the clicked corner,
485
+ // so a Shift+click to select B2:D5 then typing wrote into D5.
398
486
  extendSelection(rowIndex, colIndex);
399
- setActiveCell(rowIndex, colIndex);
487
+ if (!active) setActiveCell(rowIndex, colIndex);
400
488
  } else {
401
489
  setActiveCell(rowIndex, colIndex);
402
490
  // Ctrl/Cmd starts an ADDITIONAL range, keeping prior ranges highlighted.
@@ -414,8 +502,9 @@ export function createSelection<
414
502
  if (!ctx.isDraggingSelection) return;
415
503
  const row = ctx.allRows[rowIndex];
416
504
  if (!row || isGroupRow(row)) return;
505
+ // The active cell is where the drag began; only the range's far corner
506
+ // follows the pointer.
417
507
  extendSelection(rowIndex, colIndex);
418
- setActiveCell(rowIndex, colIndex);
419
508
  }
420
509
 
421
510
  /** `event` is the window `pointerup` when this comes from the real gesture,
@@ -550,12 +639,13 @@ export function createSelection<
550
639
  }
551
640
 
552
641
  // onCellPointerDown already set active+selection for data cells. Only
553
- // decide whether to enter edit mode (click on the previously-active cell).
642
+ // decide whether to enter edit mode (click on the previously-active cell,
643
+ // unless the grid was told a second click is just a click).
554
644
  const wasActive =
555
645
  ctx.activeAtPointerDown !== null &&
556
646
  ctx.activeAtPointerDown.rowIndex === rowIndex &&
557
647
  ctx.activeAtPointerDown.colIndex === colIndex;
558
- if (wasActive && ctx.editingEnabled) {
648
+ if (wasActive && ctx.editingEnabled && ctx.props.editOnSecondClick !== false) {
559
649
  ctx.onCellDoubleClick(rowIndex, colIndex);
560
650
  return;
561
651
  }
@@ -603,6 +693,8 @@ export function createSelection<
603
693
  extendSelection,
604
694
  isCellInSelectedRange,
605
695
  getCellRangeEdges,
696
+ activeRangeRect,
697
+ cellSelectionState,
606
698
  fillMarqueeEdges,
607
699
  getSelectionRects,
608
700
  isInFillPreview,
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it, beforeEach, vi } from 'vitest'
2
+ import {
3
+ registerGridShortcuts,
4
+ runGridShortcuts,
5
+ hasGridShortcuts,
6
+ clearGridShortcuts,
7
+ type GridCommandContext,
8
+ } from './shortcut-registry'
9
+
10
+ // The context is a structural type over controller state; nothing in the
11
+ // registry reads it, so a cast of an empty object is enough here. The real
12
+ // shape is covered by command-context.test.ts.
13
+ const cmd = {} as GridCommandContext
14
+ const key = (init: KeyboardEventInit = {}) => new KeyboardEvent('keydown', init)
15
+
16
+ beforeEach(() => clearGridShortcuts())
17
+
18
+ describe('shortcut registry', () => {
19
+ it('reports nothing registered on a fresh grid', () => {
20
+ expect(hasGridShortcuts()).toBe(false)
21
+ expect(runGridShortcuts(key(), cmd)).toBe(false)
22
+ })
23
+
24
+ it('runs a registered handler and reports the claim', () => {
25
+ const handler = vi.fn(() => true)
26
+ registerGridShortcuts(handler)
27
+ expect(hasGridShortcuts()).toBe(true)
28
+ expect(runGridShortcuts(key({ key: 'd' }), cmd)).toBe(true)
29
+ expect(handler).toHaveBeenCalledOnce()
30
+ })
31
+
32
+ it('falls through when the handler declines', () => {
33
+ registerGridShortcuts(() => false)
34
+ expect(runGridShortcuts(key(), cmd)).toBe(false)
35
+ })
36
+
37
+ it('stops at the first handler that claims', () => {
38
+ const order: string[] = []
39
+ registerGridShortcuts(() => { order.push('a'); return true })
40
+ registerGridShortcuts(() => { order.push('b'); return true })
41
+ runGridShortcuts(key(), cmd)
42
+ expect(order).toEqual(['a'])
43
+ })
44
+
45
+ it('runs higher priority first regardless of registration order', () => {
46
+ const order: string[] = []
47
+ registerGridShortcuts(() => { order.push('low'); return false }, { priority: 1 })
48
+ registerGridShortcuts(() => { order.push('high'); return false }, { priority: 50 })
49
+ runGridShortcuts(key(), cmd)
50
+ expect(order).toEqual(['high', 'low'])
51
+ })
52
+
53
+ it('keeps registration order for equal priorities', () => {
54
+ const order: string[] = []
55
+ registerGridShortcuts(() => { order.push('first'); return false })
56
+ registerGridShortcuts(() => { order.push('second'); return false })
57
+ runGridShortcuts(key(), cmd)
58
+ expect(order).toEqual(['first', 'second'])
59
+ })
60
+
61
+ it('replaces rather than stacks when the same id registers twice', () => {
62
+ const first = vi.fn(() => false)
63
+ const second = vi.fn(() => false)
64
+ registerGridShortcuts(first, { id: 'sheet' })
65
+ registerGridShortcuts(second, { id: 'sheet' })
66
+ runGridShortcuts(key(), cmd)
67
+ expect(first).not.toHaveBeenCalled()
68
+ expect(second).toHaveBeenCalledOnce()
69
+ })
70
+
71
+ it('unregisters through the returned disposer', () => {
72
+ const handler = vi.fn(() => true)
73
+ const off = registerGridShortcuts(handler)
74
+ off()
75
+ expect(hasGridShortcuts()).toBe(false)
76
+ expect(runGridShortcuts(key(), cmd)).toBe(false)
77
+ expect(handler).not.toHaveBeenCalled()
78
+ })
79
+
80
+ it('leaves siblings alone when one disposer runs', () => {
81
+ const kept = vi.fn(() => false)
82
+ const off = registerGridShortcuts(() => false, { id: 'gone' })
83
+ registerGridShortcuts(kept, { id: 'kept' })
84
+ off()
85
+ runGridShortcuts(key(), cmd)
86
+ expect(hasGridShortcuts()).toBe(true)
87
+ expect(kept).toHaveBeenCalledOnce()
88
+ })
89
+
90
+ it('hands the handler the event and the context it was called with', () => {
91
+ const handler = vi.fn(() => true)
92
+ registerGridShortcuts(handler)
93
+ const event = key({ key: 'ArrowDown', ctrlKey: true })
94
+ runGridShortcuts(event, cmd)
95
+ expect(handler).toHaveBeenCalledWith(event, cmd)
96
+ })
97
+ })