@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
@@ -77,6 +77,11 @@ function makeCtx(opts: FakeOptions = {}) {
77
77
  _rows: buildRows(),
78
78
  selectionRange: { anchor: null, focus: null },
79
79
  selectionRanges: [] as Array<{ anchor: any; focus: any }>,
80
+ history: [] as any[],
81
+ historyPtr: -1,
82
+ historyVersion: 0,
83
+ historyGroupId: undefined as string | undefined,
84
+ UNDO_LIMIT: 200,
80
85
  // Mirrors the real controller's getSelectionRects: committed ranges + active.
81
86
  getSelectionRects(): Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }> {
82
87
  const rects: Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }> = []
@@ -436,6 +441,77 @@ describe('onFillPointerUp / applyFillPattern', () => {
436
441
  expect(ctx.selectionRange.focus).toEqual({ rowIndex: 3, colIndex: 0 })
437
442
  })
438
443
 
444
+ it('records the drag as one grouped history entry, so Ctrl+Z takes it all back', () => {
445
+ // The fill used to write through writeCellRaw and push nothing: a drag
446
+ // over twenty rows was invisible to undo.
447
+ const ctx = makeCtx({
448
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
449
+ data: [{ a: 1 }, { a: 2 }, { a: 0 }, { a: 0 }],
450
+ })
451
+ ctx.fillDrag = { sourceMinRow: 0, sourceMaxRow: 1, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 3, targetCol: 0 }
452
+ createClipboard(ctx).onFillPointerUp()
453
+ expect(ctx.history).toHaveLength(2)
454
+ expect(ctx.history[0]).toMatchObject({ rowId: '2', columnId: 'a', field: 'a', before: 0, after: 3 })
455
+ expect(ctx.history[1]).toMatchObject({ rowId: '3', columnId: 'a', field: 'a', before: 0, after: 4 })
456
+ expect(ctx.history[0].groupId).toBeDefined()
457
+ expect(ctx.history[0].groupId).toBe(ctx.history[1].groupId)
458
+ })
459
+
460
+ it('asks processCellForFill first and falls back to the pattern when it declines', () => {
461
+ // A spreadsheet moves a formula's references by the distance filled;
462
+ // the pattern rules would read "=A1+B1" as "Item 1" and write "=A1+B2".
463
+ const seen: Array<{ value: unknown; delta: { rows: number; cols: number } }> = []
464
+ const ctx = makeCtx({
465
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
466
+ data: [{ a: '=A1+B1' }, { a: '' }, { a: '' }],
467
+ })
468
+ ctx.props.processCellForFill = ({ value, delta }: { value: unknown; delta: { rows: number; cols: number } }) => {
469
+ seen.push({ value, delta })
470
+ return typeof value === 'string' && value.startsWith('=') ? `${value}@${delta.rows}` : undefined
471
+ }
472
+ ctx.fillDrag = { sourceMinRow: 0, sourceMaxRow: 0, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 2, targetCol: 0 }
473
+ createClipboard(ctx).onFillPointerUp()
474
+ expect(ctx.internalData[1].a).toBe('=A1+B1@1')
475
+ expect(ctx.internalData[2].a).toBe('=A1+B1@2')
476
+ expect(seen.map((s) => s.delta.rows)).toEqual([1, 2])
477
+
478
+ // Declining leaves the series to the pattern.
479
+ const numeric = makeCtx({
480
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
481
+ data: [{ a: 1 }, { a: 2 }, { a: 0 }, { a: 0 }],
482
+ })
483
+ numeric.props.processCellForFill = () => undefined
484
+ numeric.fillDrag = { sourceMinRow: 0, sourceMaxRow: 1, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 3, targetCol: 0 }
485
+ createClipboard(numeric).onFillPointerUp()
486
+ expect(numeric.internalData[2].a).toBe(3)
487
+ expect(numeric.internalData[3].a).toBe(4)
488
+ })
489
+
490
+ it('double-click fills down as far as the neighbouring column has data', () => {
491
+ const ctx = makeCtx({
492
+ columns: [
493
+ { id: 'a', field: 'a', editable: true, editorType: 'text' },
494
+ { id: 'b', field: 'b', editable: true, editorType: 'number' },
495
+ ],
496
+ data: [{ a: 'x', b: 1 }, { a: 'y', b: 0 }, { a: 'z', b: 0 }, { a: '', b: 0 }],
497
+ })
498
+ ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 1 }, focus: { rowIndex: 0, colIndex: 1 } }
499
+ createClipboard(ctx).fillDownToNeighbour()
500
+ // Column a runs to row 3 (index 2); the fill stops there.
501
+ expect(ctx.internalData.map((r: any) => r.b)).toEqual([1, 1, 1, 0])
502
+ expect(ctx.selectionRange.focus).toEqual({ rowIndex: 2, colIndex: 1 })
503
+ })
504
+
505
+ it('double-click with nothing beside the selection fills nothing', () => {
506
+ const ctx = makeCtx({
507
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
508
+ data: [{ a: 1 }, { a: 0 }, { a: 0 }],
509
+ })
510
+ ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } }
511
+ createClipboard(ctx).fillDownToNeighbour()
512
+ expect(ctx.internalData.map((r: any) => r.a)).toEqual([1, 0, 0])
513
+ })
514
+
439
515
  it('fills upward, reverse-extrapolating the pattern', () => {
440
516
  const ctx = makeCtx({
441
517
  columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
@@ -617,6 +693,59 @@ describe('copySelectionToClipboard', () => {
617
693
  expect(writeText).toHaveBeenCalledWith('a\tb\na0\tb0\na1\tb1')
618
694
  })
619
695
 
696
+ it('writes text and HTML through the copy event when clipboardHtml supplies markup', () => {
697
+ // The copy event is the first path: execCommand('copy') fires it and the
698
+ // one-shot listener puts both types on it, attributes intact.
699
+ const ctx = makeCtx({ data: [{ a: 'a0', b: 'b0' }] })
700
+ ctx.props.clipboardHtml = ({ text }: { text: string }) => `<table data-x="1"><tr><td>${text}</td></tr></table>`
701
+ ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 1 } }
702
+ const set = vi.fn()
703
+ // jsdom has no execCommand; this one fires the copy event the way a browser's does.
704
+ ;(document as unknown as { execCommand: (name: string) => boolean }).execCommand = () => {
705
+ const ev = new Event('copy', { cancelable: true }) as ClipboardEvent
706
+ Object.defineProperty(ev, 'clipboardData', { value: { setData: set } })
707
+ document.dispatchEvent(ev)
708
+ return true
709
+ }
710
+ try {
711
+ createClipboard(ctx).copySelectionToClipboard()
712
+ expect(set).toHaveBeenCalledWith('text/plain', 'a0\tb0')
713
+ expect(set).toHaveBeenCalledWith('text/html', '<table data-x="1"><tr><td>a0\tb0</td></tr></table>')
714
+ expect(writeText).not.toHaveBeenCalled()
715
+ } finally {
716
+ delete (document as unknown as { execCommand?: unknown }).execCommand
717
+ }
718
+ })
719
+
720
+ it('falls back to the async write with both types when the copy event is refused', () => {
721
+ const ctx = makeCtx({ data: [{ a: 'a0', b: 'b0' }] })
722
+ ctx.props.clipboardHtml = () => '<b>x</b>'
723
+ ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } }
724
+ const write = vi.fn().mockResolvedValue(undefined)
725
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText, write }, configurable: true })
726
+ ;(document as unknown as { execCommand: (name: string) => boolean }).execCommand = () => false
727
+ class FakeItem { constructor(public parts: Record<string, Blob>) {} }
728
+ ;(globalThis as Record<string, unknown>).ClipboardItem = FakeItem
729
+ try {
730
+ createClipboard(ctx).copySelectionToClipboard()
731
+ expect(write).toHaveBeenCalledTimes(1)
732
+ const item = write.mock.calls[0]![0][0] as FakeItem
733
+ expect(Object.keys(item.parts)).toEqual(['text/plain', 'text/html'])
734
+ expect(writeText).not.toHaveBeenCalled()
735
+ } finally {
736
+ delete (document as unknown as { execCommand?: unknown }).execCommand
737
+ delete (globalThis as Record<string, unknown>).ClipboardItem
738
+ }
739
+ })
740
+
741
+ it('writes text alone when clipboardHtml returns nothing', () => {
742
+ const ctx = makeCtx({ data: [{ a: 'a0', b: 'b0' }] })
743
+ ctx.props.clipboardHtml = () => null
744
+ ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } }
745
+ createClipboard(ctx).copySelectionToClipboard()
746
+ expect(writeText).toHaveBeenCalledWith('a0')
747
+ })
748
+
620
749
  it('runs each value through processCellForClipboard', () => {
621
750
  const ctx = makeCtx({
622
751
  data: [{ a: 'a0', b: 'b0' }],
@@ -738,6 +867,28 @@ describe('clearSelectedCells', () => {
738
867
  )
739
868
  })
740
869
 
870
+ it('records the clear in the history as one grouped step per cell', () => {
871
+ // Delete over a block used to leave no trace: the values were gone and
872
+ // Ctrl+Z skipped past them to whatever edit came before.
873
+ const ctx = makeCtx({
874
+ columns: [
875
+ { id: 'a', field: 'a', editable: true, editorType: 'text' },
876
+ { id: 'b', field: 'b', editable: true, editorType: 'text' },
877
+ ],
878
+ data: [{ a: 'x', b: 'y' }],
879
+ })
880
+ ctx.selectionRange = {
881
+ anchor: { rowIndex: 0, colIndex: 0 },
882
+ focus: { rowIndex: 0, colIndex: 1 },
883
+ }
884
+ createClipboard(ctx).clearSelectedCells()
885
+ expect(ctx.history).toHaveLength(2)
886
+ expect(ctx.history[0]).toMatchObject({ rowId: '0', columnId: 'a', field: 'a', before: 'x', after: '' })
887
+ expect(ctx.history[0].groupId).toBeDefined()
888
+ expect(ctx.history[1].groupId).toBe(ctx.history[0].groupId)
889
+ expect(ctx.historyPtr).toBe(1)
890
+ })
891
+
741
892
  it('clears a number cell via parseEditorValue coercion', () => {
742
893
  const ctx = makeCtx({
743
894
  columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
package/src/clipboard.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  isGroupRow,
19
19
  toolPanelHeaderLabel,
20
20
  } from "./cell-values";
21
+ import { pushHistory, nextGroupId, runHistoryGroup, type HistoryStep } from "./history";
21
22
 
22
23
  export function createClipboard<
23
24
  TFeatures extends TableFeatures = TableFeatures,
@@ -84,7 +85,91 @@ export function createClipboard<
84
85
  /** Apply the pattern fill on pointerup. Each NEW row (or column) is
85
86
  * filled from a pattern derived from the matching column (or row) of
86
87
  * the source. Handles all four drag directions. */
88
+ /**
89
+ * Write one filled cell and remember it, so the drag undoes as a unit.
90
+ * `writeCellRaw` keeps no history of its own; the fill used to call it in
91
+ * a loop and push nothing, which left Ctrl+Z blind to a whole drag.
92
+ */
93
+ function writeFilled(steps: HistoryStep[], rowIndex: number, columnId: string, value: unknown) {
94
+ const row = ctx.allRows[rowIndex];
95
+ const column = ctx.findColumnById(columnId);
96
+ const field = column?.columnDef.field;
97
+ if (!row || field == null) return;
98
+ const before = readCellRaw(rowIndex, columnId);
99
+ writeCellRaw(rowIndex, columnId, value);
100
+ if (before !== value) steps.push({ rowId: row.id, columnId, field, before, after: value });
101
+ }
102
+
87
103
  function applyFillPattern() {
104
+ const steps: HistoryStep[] = [];
105
+ runHistoryGroup(ctx, () => applyFillCells(steps));
106
+ if (steps.length) pushHistory(ctx, steps, nextGroupId());
107
+ }
108
+
109
+ /**
110
+ * The value for the i-th filled cell: the consumer's, when its hook has
111
+ * one for the source cell the target cycles back to, else the pattern's.
112
+ * `sourceStart` is the row (or column) of the first source value and
113
+ * `targetStart` that of the first target; `step` is -1 filling upward.
114
+ */
115
+ function filledValue(
116
+ sources: unknown[],
117
+ pattern: unknown[],
118
+ i: number,
119
+ sourceStart: number,
120
+ targetStart: number,
121
+ step: 1 | -1,
122
+ axis: "rows" | "cols",
123
+ rowIndex: number,
124
+ columnId: string,
125
+ ): unknown {
126
+ const hook = ctx.props.processCellForFill;
127
+ if (hook && sources.length) {
128
+ const k = i % sources.length;
129
+ const distance = (targetStart + step * i) - (sourceStart + step * k);
130
+ const delta = axis === "rows" ? { rows: distance, cols: 0 } : { rows: 0, cols: distance };
131
+ const decided = hook({ value: sources[k], delta, rowIndex, columnId });
132
+ if (decided !== undefined) return decided;
133
+ }
134
+ return pattern[i];
135
+ }
136
+
137
+ /**
138
+ * Double-click on the fill handle: fill down as far as the column beside
139
+ * the selection has data, the way Excel's does. The column to the left
140
+ * decides; when it is blank below the selection (or there is none) the
141
+ * one to the right does. Nothing to follow, nothing filled.
142
+ */
143
+ function fillDownToNeighbour() {
144
+ const anchor = ctx.selectionRange.anchor ?? ctx.grid.getState().activeCell;
145
+ if (!anchor) return;
146
+ const focus = ctx.selectionRange.focus ?? anchor;
147
+ const minRow = Math.min(anchor.rowIndex, focus.rowIndex);
148
+ const maxRow = Math.max(anchor.rowIndex, focus.rowIndex);
149
+ const minCol = Math.min(anchor.colIndex, focus.colIndex);
150
+ const maxCol = Math.max(anchor.colIndex, focus.colIndex);
151
+ const filled = (r: number, c: number) => {
152
+ const col = ctx.allColumns[c];
153
+ if (!col) return false;
154
+ const v = readCellRaw(r, col.id);
155
+ return v != null && v !== "";
156
+ };
157
+ const extent = (c: number): number => {
158
+ let r = maxRow;
159
+ while (r + 1 < ctx.allRows.length && filled(r + 1, c)) r += 1;
160
+ return r;
161
+ };
162
+ let target = minCol > 0 ? extent(minCol - 1) : maxRow;
163
+ if (target === maxRow) target = extent(maxCol + 1);
164
+ if (target <= maxRow) return;
165
+ ctx.fillDrag = {
166
+ sourceMinRow: minRow, sourceMaxRow: maxRow, sourceMinCol: minCol, sourceMaxCol: maxCol,
167
+ targetRow: target, targetCol: maxCol,
168
+ };
169
+ applyFillPattern();
170
+ }
171
+
172
+ function applyFillCells(steps: HistoryStep[]) {
88
173
  const d = ctx.fillDrag;
89
174
  if (!d) return;
90
175
  // Clear fillDrag FIRST so a thrown error doesn't leave the grid
@@ -118,7 +203,8 @@ export function createClipboard<
118
203
  for (let i = 0; i < targetRows; i += 1) {
119
204
  const targetRow = d.sourceMaxRow + 1 + i;
120
205
  if (ctx.isCellEditableAt(targetRow, c))
121
- writeCellRaw(targetRow, column.id, fills[i]);
206
+ writeFilled(steps, targetRow, column.id,
207
+ filledValue(sourceColValues, fills, i, d.sourceMinRow, d.sourceMaxRow + 1, 1, "rows", targetRow, column.id));
122
208
  }
123
209
  }
124
210
  if (newMinRow < d.sourceMinRow) {
@@ -129,7 +215,8 @@ export function createClipboard<
129
215
  for (let i = 0; i < targetRows; i += 1) {
130
216
  const targetRow = d.sourceMinRow - 1 - i;
131
217
  if (ctx.isCellEditableAt(targetRow, c))
132
- writeCellRaw(targetRow, column.id, fills[i]);
218
+ writeFilled(steps, targetRow, column.id,
219
+ filledValue(reversed, fills, i, d.sourceMaxRow, d.sourceMinRow - 1, -1, "rows", targetRow, column.id));
133
220
  }
134
221
  }
135
222
  }
@@ -150,7 +237,8 @@ export function createClipboard<
150
237
  const targetCol = d.sourceMaxCol + 1 + i;
151
238
  const col = ctx.allColumns[targetCol];
152
239
  if (col && ctx.isCellEditableAt(r, targetCol))
153
- writeCellRaw(r, col.id, fills[i]);
240
+ writeFilled(steps, r, col.id,
241
+ filledValue(sourceRowValues, fills, i, d.sourceMinCol, d.sourceMaxCol + 1, 1, "cols", r, col.id));
154
242
  }
155
243
  }
156
244
  if (newMinCol < d.sourceMinCol) {
@@ -161,7 +249,8 @@ export function createClipboard<
161
249
  const targetCol = d.sourceMinCol - 1 - i;
162
250
  const col = ctx.allColumns[targetCol];
163
251
  if (col && ctx.isCellEditableAt(r, targetCol))
164
- writeCellRaw(r, col.id, fills[i]);
252
+ writeFilled(steps, r, col.id,
253
+ filledValue(reversed, fills, i, d.sourceMaxCol, d.sourceMinCol - 1, -1, "cols", r, col.id));
165
254
  }
166
255
  }
167
256
  }
@@ -618,17 +707,10 @@ export function createClipboard<
618
707
  }
619
708
  }
620
709
 
621
- // One undo entry per changed cell, appended together so a single Ctrl+Z
622
- // sequence walks the whole move back. Same shape and truncation rule as
623
- // a whole-row commit.
624
- if (steps.length) {
625
- let hist = ctx.history.slice(0, ctx.historyPtr + 1);
626
- for (const step of steps) hist.push(step);
627
- if (hist.length > ctx.UNDO_LIMIT) hist = hist.slice(hist.length - ctx.UNDO_LIMIT);
628
- ctx.history = hist;
629
- ctx.historyPtr = ctx.history.length - 1;
630
- ctx.historyVersion += 1;
631
- }
710
+ // The whole move is ONE undo entry. It used to be one step per changed
711
+ // cell, so walking a 15x20 move back took 300 presses of Ctrl+Z and blew
712
+ // past UNDO_LIMIT, evicting every step behind it.
713
+ pushHistory(ctx, steps, nextGroupId());
632
714
 
633
715
  // Follow the values: the range stays selected where it landed, so a
634
716
  // second drag can chain off the first the way it does in a spreadsheet.
@@ -679,6 +761,40 @@ export function createClipboard<
679
761
  legacyCopyText(text);
680
762
  }
681
763
 
764
+ /**
765
+ * Text and HTML together. The `copy` event is the first path: a listener
766
+ * armed for this one call puts both types on the event's clipboardData
767
+ * while the legacy textarea copy runs, which works in a secure and an
768
+ * insecure context alike and keeps every attribute of the HTML. Only when
769
+ * the browser refuses that does the async `ClipboardItem` write run, and
770
+ * text alone after that.
771
+ */
772
+ function writeClipboard(text: string, html: string) {
773
+ if (typeof document !== "undefined") {
774
+ let armed = true;
775
+ const onCopy = (event: ClipboardEvent) => {
776
+ if (!armed || !event.clipboardData) return;
777
+ armed = false;
778
+ event.clipboardData.setData("text/plain", text);
779
+ event.clipboardData.setData("text/html", html);
780
+ event.preventDefault();
781
+ };
782
+ document.addEventListener("copy", onCopy, { once: true, capture: true });
783
+ const ok = legacyCopyText(text);
784
+ document.removeEventListener("copy", onCopy, { capture: true });
785
+ if (ok && !armed) return;
786
+ }
787
+ if (navigator.clipboard?.write && typeof ClipboardItem !== "undefined") {
788
+ const item = new ClipboardItem({
789
+ "text/plain": new Blob([text], { type: "text/plain" }),
790
+ "text/html": new Blob([html], { type: "text/html" }),
791
+ });
792
+ void navigator.clipboard.write([item]).catch(() => writeClipboardText(text));
793
+ return;
794
+ }
795
+ writeClipboardText(text);
796
+ }
797
+
682
798
  function legacyCopyText(text: string): boolean {
683
799
  if (typeof document === "undefined") return false;
684
800
  const active = document.activeElement as HTMLElement | null;
@@ -759,7 +875,13 @@ export function createClipboard<
759
875
  }
760
876
  blocks.push(lines.join("\n"));
761
877
  }
762
- writeClipboardText(blocks.join("\n\n"));
878
+ const text = blocks.join("\n\n");
879
+ const toHtml = ctx.props.clipboardHtml as
880
+ | ((params: { rects: typeof rects; text: string }) => string | null | undefined)
881
+ | undefined;
882
+ const html = toHtml?.({ rects, text });
883
+ if (html) writeClipboard(text, html);
884
+ else writeClipboardText(text);
763
885
  }
764
886
 
765
887
  /**
@@ -786,6 +908,10 @@ export function createClipboard<
786
908
  newValue: unknown
787
909
  row: TData
788
910
  }> = [];
911
+ // One history step per cleared cell, grouped, so Delete over a block is
912
+ // one Ctrl+Z. Clearing used to record nothing at all: the values were
913
+ // gone and undo skipped straight past them to whatever came before.
914
+ const steps: HistoryStep[] = [];
789
915
  const dataIndexOf = createDataIndexLookup(next);
790
916
  for (let r = startRow; r <= endRow; r += 1) {
791
917
  const row = ctx.allRows[r];
@@ -813,6 +939,7 @@ export function createClipboard<
813
939
  const cleared = parseEditorValue(editorType, "");
814
940
  updated[field] = cleared;
815
941
  rowChanged = true;
942
+ steps.push({ rowId: row.id, columnId: column.id, field, before: oldValue, after: cleared });
816
943
  changes.push({
817
944
  rowIndex: dataIndex,
818
945
  columnId: column.id,
@@ -829,6 +956,7 @@ export function createClipboard<
829
956
  if (mutated) {
830
957
  ctx.internalData = next;
831
958
  ctx.grid.store.setState((prev: any) => ({ ...prev }));
959
+ pushHistory(ctx, steps, nextGroupId());
832
960
  // Fire onCellValueChange per cleared cell - same as paste's writeCellRaw -
833
961
  // so consumers (formula engines, autosave) recompute. Clear IS a value
834
962
  // change; without this, a HyperFormula-backed grid wouldn't re-evaluate.
@@ -857,6 +985,7 @@ export function createClipboard<
857
985
  startFillDrag,
858
986
  onFillPointerMove,
859
987
  onFillPointerUp,
988
+ fillDownToNeighbour,
860
989
  isOnMoveGrabStrip,
861
990
  startMoveDrag,
862
991
  onMovePointerMove,
@@ -27,6 +27,9 @@ export type ColumnResizeOptions = {
27
27
  getWidth: (columnId: string) => number
28
28
  /** Called on every animation frame of the drag, and once at the end. */
29
29
  onResize: (columnId: string, width: number) => void
30
+ /** Called once when a drag ends or an arrow key has resized, with the
31
+ * width the gesture started from: what an undo puts back. */
32
+ onResizeEnd?: (columnId: string, width: number, startWidth: number) => void
30
33
  /** Accessible name for the handle. Defaults to the column id. */
31
34
  label?: (columnId: string) => string
32
35
  /**
@@ -90,6 +93,7 @@ export function columnResize(node: HTMLElement, opts: ColumnResizeOptions) {
90
93
  // Commit the final width even if the last frame was cancelled mid-flight.
91
94
  pendingWidth = Math.max(minWidth(), drag.startWidth + (e.clientX - drag.startX))
92
95
  commit()
96
+ current.onResizeEnd?.(drag.columnId, pendingWidth, drag.startWidth)
93
97
  drag.handle.classList.remove('is-resizing')
94
98
  syncAria(drag.handle, drag.columnId)
95
99
  try {
@@ -144,8 +148,10 @@ export function columnResize(node: HTMLElement, opts: ColumnResizeOptions) {
144
148
  if (!columnId || current.canResize?.(columnId) === false) return
145
149
  e.preventDefault()
146
150
  e.stopPropagation()
147
- const next = Math.max(minWidth(), Math.round(current.getWidth(columnId) + delta))
151
+ const start = current.getWidth(columnId)
152
+ const next = Math.max(minWidth(), Math.round(start + delta))
148
153
  current.onResize(columnId, next)
154
+ current.onResizeEnd?.(columnId, next, start)
149
155
  syncAria(t, columnId)
150
156
  }
151
157
 
package/src/columns.ts CHANGED
@@ -161,6 +161,8 @@ export function createColumns<
161
161
  }
162
162
 
163
163
  function getColumnWidth(columnId: string) {
164
+ // Collapsed: no room at all, whatever width it keeps for later.
165
+ if (ctx.collapsedColumns?.[columnId]) return 0;
164
166
  const fitted = ctx.fittedColumnWidths?.[columnId];
165
167
  if (fitted !== undefined && ctx.columnWidths[columnId] === undefined)
166
168
  return fitted;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The command context handed to registered keyboard shortcuts.
3
+ *
4
+ * The interesting part is `setCellValue`: it is the only writer a command
5
+ * touches, so if it does not record history then every sheet command ships
6
+ * un-undoable no matter how carefully `cmd.batch` groups. These tests hold
7
+ * that behaviour down, along with the index-space contract (display indices
8
+ * in, `readCellRaw` / `writeCellRaw` out) that the getters exist for.
9
+ */
10
+ import { describe, expect, it, vi } from 'vitest'
11
+ import { buildCommandContext } from './command-context'
12
+ import { undoHistory, type HistoryStep } from './history'
13
+
14
+ function makeCtx(values: Record<string, unknown> = {}) {
15
+ const store: Record<string, unknown> = { ...values }
16
+ const key = (r: number, columnId: string) => `${r}:${columnId}`
17
+ const ctx: any = {
18
+ allRows: [{ id: 'r1' }, { id: 'r2' }, { id: 'r3' }],
19
+ allColumns: [
20
+ { id: 'a', columnDef: { field: 'a' } },
21
+ { id: 'b', columnDef: { field: 'b' } },
22
+ ],
23
+ history: [] as HistoryStep[],
24
+ historyPtr: -1,
25
+ historyVersion: 0,
26
+ historyGroupId: undefined as string | undefined,
27
+ UNDO_LIMIT: 200,
28
+ grid: { getState: () => ({ activeCell: { rowIndex: 0, colIndex: 0 } }) },
29
+ getSelectionRects: () => [],
30
+ readCellRaw: (r: number, columnId: string) => store[key(r, columnId)],
31
+ writeCellRaw: vi.fn((r: number, columnId: string, value: unknown) => {
32
+ if (store[key(r, columnId)] === value) return
33
+ store[key(r, columnId)] = value
34
+ }),
35
+ applyHistoryStep: vi.fn((s: HistoryStep, direction: 'undo' | 'redo') => {
36
+ store[key(Number(s.rowId.slice(1)) - 1, s.columnId)] =
37
+ direction === 'undo' ? s.before : s.after
38
+ }),
39
+ setActiveCell: vi.fn(),
40
+ setSelection: vi.fn(),
41
+ extendSelection: vi.fn(),
42
+ scrollActiveCellIntoView: vi.fn(),
43
+ gridRootEl: { focus: vi.fn() },
44
+ pasteFromClipboard: vi.fn(async () => {}),
45
+ copySelectionToClipboard: vi.fn(),
46
+ cutSelectionToClipboard: vi.fn(async () => {}),
47
+ buildApi: () => ({}),
48
+ }
49
+ return { ctx, store, key }
50
+ }
51
+
52
+ describe('setCellValue records history', () => {
53
+ it('pushes one step per changed cell', () => {
54
+ const { ctx } = makeCtx({ '0:a': 1 })
55
+ const cmd = buildCommandContext(ctx, false)
56
+ cmd.setCellValue(0, 0, 9)
57
+ expect(ctx.history).toEqual([
58
+ { rowId: 'r1', columnId: 'a', field: 'a', before: 1, after: 9 },
59
+ ])
60
+ expect(ctx.historyPtr).toBe(0)
61
+ })
62
+
63
+ it('pushes nothing when the value is unchanged', () => {
64
+ const { ctx } = makeCtx({ '0:a': 1 })
65
+ const cmd = buildCommandContext(ctx, false)
66
+ cmd.setCellValue(0, 0, 1)
67
+ expect(ctx.history).toEqual([])
68
+ expect(ctx.historyPtr).toBe(-1)
69
+ })
70
+
71
+ it('pushes nothing for a column index that does not exist', () => {
72
+ const { ctx } = makeCtx()
73
+ const cmd = buildCommandContext(ctx, false)
74
+ cmd.setCellValue(0, 99, 'x')
75
+ expect(ctx.writeCellRaw).not.toHaveBeenCalled()
76
+ expect(ctx.history).toEqual([])
77
+ })
78
+
79
+ it('reads `before` from the cell rather than from the new value', () => {
80
+ const { ctx } = makeCtx({ '1:b': 'old' })
81
+ const cmd = buildCommandContext(ctx, false)
82
+ cmd.setCellValue(1, 1, 'new')
83
+ expect(ctx.history[0]).toMatchObject({ rowId: 'r2', before: 'old', after: 'new' })
84
+ })
85
+ })
86
+
87
+ describe('cmd.canEdit', () => {
88
+ it('asks the editing layer, in display index space', () => {
89
+ const { ctx } = makeCtx()
90
+ ctx.isCellEditableAt = vi.fn((r: number, c: number) => !(r === 1 && c === 0))
91
+ const cmd = buildCommandContext(ctx, false)
92
+ expect(cmd.canEdit!(0, 0)).toBe(true)
93
+ expect(cmd.canEdit!(1, 0)).toBe(false)
94
+ expect(ctx.isCellEditableAt).toHaveBeenCalledWith(1, 0)
95
+ })
96
+
97
+ it('says yes when the controller has no editing layer', () => {
98
+ const { ctx } = makeCtx()
99
+ expect(buildCommandContext(ctx, false).canEdit!(2, 1)).toBe(true)
100
+ })
101
+ })
102
+
103
+ describe('cmd.focus', () => {
104
+ it('focuses the grid root without scrolling it', () => {
105
+ const { ctx } = makeCtx()
106
+ buildCommandContext(ctx, false).focus()
107
+ expect(ctx.gridRootEl.focus).toHaveBeenCalledWith({ preventScroll: true })
108
+ })
109
+
110
+ it('is a no-op before the grid has mounted', () => {
111
+ const { ctx } = makeCtx()
112
+ ctx.gridRootEl = null
113
+ expect(() => buildCommandContext(ctx, false).focus()).not.toThrow()
114
+ })
115
+ })
116
+
117
+ describe('cmd.recordUndo', () => {
118
+ it('puts a non-cell step in the history that undo and redo call back', () => {
119
+ const { ctx } = makeCtx()
120
+ const cmd = buildCommandContext(ctx, false)
121
+ const undo = vi.fn()
122
+ const redo = vi.fn()
123
+ cmd.recordUndo(undo, redo)
124
+ expect(ctx.history).toHaveLength(1)
125
+ expect(ctx.history[0].custom).toEqual({ undo, redo })
126
+ undoHistory(ctx)
127
+ expect(ctx.applyHistoryStep).toHaveBeenCalledWith(expect.objectContaining({ custom: { undo, redo } }), 'undo')
128
+ })
129
+
130
+ it('joins the batch it is recorded inside, so cells and formats undo together', () => {
131
+ const { ctx } = makeCtx({ '0:a': 1 })
132
+ const cmd = buildCommandContext(ctx, false)
133
+ cmd.batch(() => {
134
+ cmd.setCellValue(0, 0, 2)
135
+ cmd.recordUndo(() => {}, () => {})
136
+ })
137
+ expect(ctx.history).toHaveLength(2)
138
+ expect(ctx.history[0].groupId).toBeDefined()
139
+ expect(ctx.history[1].groupId).toBe(ctx.history[0].groupId)
140
+ })
141
+ })
142
+
143
+ describe('cmd.editor', () => {
144
+ it('is the open inline editor element, and null when nothing is being edited', () => {
145
+ const { ctx } = makeCtx()
146
+ const root = document.createElement('div')
147
+ ctx.gridRootEl = root
148
+ expect(buildCommandContext(ctx, false).editor).toBeNull()
149
+ const input = document.createElement('input')
150
+ input.className = 'sv-grid-cell-editor'
151
+ root.appendChild(input)
152
+ expect(buildCommandContext(ctx, true).editor).toBe(input)
153
+ })
154
+ })
155
+
156
+ describe('cmd.paste', () => {
157
+ it('routes to the grid clipboard paste, the same path Ctrl+V takes', async () => {
158
+ const { ctx } = makeCtx()
159
+ await buildCommandContext(ctx, false).paste()
160
+ expect(ctx.pasteFromClipboard).toHaveBeenCalledTimes(1)
161
+ })
162
+ })
163
+
164
+ describe('cmd.copy and cmd.cut', () => {
165
+ it('route to the selection copy and cut, the paths Ctrl+C and Ctrl+X take', async () => {
166
+ const { ctx } = makeCtx()
167
+ const cmd = buildCommandContext(ctx, false)
168
+ cmd.copy()
169
+ expect(ctx.copySelectionToClipboard).toHaveBeenCalledTimes(1)
170
+ await cmd.cut()
171
+ expect(ctx.cutSelectionToClipboard).toHaveBeenCalledTimes(1)
172
+ })
173
+ })
174
+
175
+ describe('cmd.batch groups the steps it writes', () => {
176
+ it('makes a multi-cell command ONE undo', () => {
177
+ const { ctx, store } = makeCtx({ '0:a': 1, '1:a': 2, '2:a': 3 })
178
+ const cmd = buildCommandContext(ctx, false)
179
+ cmd.batch(() => {
180
+ cmd.setCellValue(0, 0, 'x')
181
+ cmd.setCellValue(1, 0, 'x')
182
+ cmd.setCellValue(2, 0, 'x')
183
+ })
184
+ const ids = ctx.history.map((s: HistoryStep) => s.groupId)
185
+ expect(ids).toHaveLength(3)
186
+ expect(new Set(ids).size).toBe(1)
187
+ expect(ids[0]).toBeTruthy()
188
+
189
+ expect(undoHistory(ctx)).toBe(true)
190
+ expect([store['0:a'], store['1:a'], store['2:a']]).toEqual([1, 2, 3])
191
+ expect(ctx.historyPtr).toBe(-1)
192
+ })
193
+
194
+ it('leaves a lone write outside a batch ungrouped', () => {
195
+ const { ctx } = makeCtx({ '0:a': 1 })
196
+ const cmd = buildCommandContext(ctx, false)
197
+ cmd.setCellValue(0, 0, 'x')
198
+ expect(ctx.history[0].groupId).toBeUndefined()
199
+ })
200
+ })