@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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Builds the `GridCommandContext` handed to registered keyboard commands.
3
+ *
4
+ * Deliberately a getter object over the controller `ctx` rather than a
5
+ * snapshot: it is constructed on every keystroke that reaches a registered
6
+ * handler, so copying row and column arrays into it would put an allocation
7
+ * proportional to the grid in the hot path. Getters make construction O(1) and
8
+ * charge only for what a handler actually reads.
9
+ *
10
+ * Everything here is display-indexed, matching `SelectionPoint` and the rest of
11
+ * the selection model, so indices follow sort, filter and pagination. That is
12
+ * why `getCellValue` / `setCellValue` exist here rather than being left to
13
+ * `api.getCellValue`: the api pair indexes the DATA array, so handing a handler
14
+ * a selection index and an api that reads a different index space would give
15
+ * the wrong cell the moment the grid is sorted or filtered. `api.refresh()` has
16
+ * no index in it, so that one is left to the api.
17
+ */
18
+ import type { GridCommandContext } from "./shortcut-registry";
19
+ import { pushHistory, runHistoryGroup } from "./history";
20
+
21
+ export function buildCommandContext(ctx: any, editing: boolean): GridCommandContext {
22
+ function columnIdAt(colIndex: number): string | null {
23
+ return ctx.allColumns[colIndex]?.id ?? null;
24
+ }
25
+
26
+ return {
27
+ get api() {
28
+ return ctx.buildApi();
29
+ },
30
+ editing,
31
+ get editor() {
32
+ const root: HTMLElement | null = ctx.gridRootEl ?? null;
33
+ const el = root?.querySelector<HTMLInputElement | HTMLTextAreaElement>(".sv-grid-cell-editor") ?? null;
34
+ return el;
35
+ },
36
+ get activeCell() {
37
+ const active = ctx.grid.getState().activeCell;
38
+ if (!active) return null;
39
+ return {
40
+ rowIndex: active.rowIndex,
41
+ colIndex: active.colIndex,
42
+ columnId: columnIdAt(active.colIndex),
43
+ };
44
+ },
45
+ get selectionFocus() {
46
+ const focus = ctx.selectionRange?.focus;
47
+ return focus ? { rowIndex: focus.rowIndex, colIndex: focus.colIndex } : null;
48
+ },
49
+ get rowCount() {
50
+ return ctx.allRows.length;
51
+ },
52
+ get colCount() {
53
+ return ctx.allColumns.length;
54
+ },
55
+ get ranges() {
56
+ // getSelectionRects() already normalises anchor/focus into rectangles
57
+ // and puts the active range last, which is the order a handler wants
58
+ // when it has to pick one.
59
+ return ctx.getSelectionRects().map(
60
+ (r: { minRow: number; minCol: number; maxRow: number; maxCol: number }) =>
61
+ [r.minRow, r.minCol, r.maxRow, r.maxCol] as const,
62
+ );
63
+ },
64
+ columnIdAt,
65
+ getCellValue(rowIndex: number, colIndex: number) {
66
+ const columnId = columnIdAt(colIndex);
67
+ return columnId == null ? undefined : ctx.readCellRaw(rowIndex, columnId);
68
+ },
69
+ setCellValue(rowIndex: number, colIndex: number, value: unknown) {
70
+ const columnId = columnIdAt(colIndex);
71
+ if (columnId == null) return;
72
+ // Record the write so Ctrl+Z walks it back. `writeCellRaw` is the raw
73
+ // writer and deliberately keeps no history of its own - the fill handle
74
+ // and paste call it in loops and push their own entries (or, today, none
75
+ // at all). A command has to push here or every multi-cell command it
76
+ // runs would be silently un-undoable.
77
+ //
78
+ // Both reads happen BEFORE the write: writeCellRaw swaps a fresh row
79
+ // object into `internalData`, so afterwards the id lookup would miss.
80
+ const field = ctx.allColumns[colIndex]?.columnDef?.field;
81
+ const rowId = ctx.allRows[rowIndex]?.id;
82
+ const before = ctx.readCellRaw(rowIndex, columnId);
83
+ ctx.writeCellRaw(rowIndex, columnId, value);
84
+ // Only when something actually changed: writeCellRaw no-ops on an equal
85
+ // value, and a step whose before and after match would make Ctrl+Z look
86
+ // like it had swallowed a press.
87
+ if (field != null && rowId != null && before !== value) {
88
+ pushHistory(ctx, [{ rowId, columnId, field, before, after: value }]);
89
+ }
90
+ },
91
+ setActiveCell(rowIndex: number, colIndex: number) {
92
+ ctx.setActiveCell(rowIndex, colIndex);
93
+ },
94
+ setSelection(rowIndex: number, colIndex: number) {
95
+ ctx.setSelection(rowIndex, colIndex);
96
+ },
97
+ extendSelection(rowIndex: number, colIndex: number) {
98
+ ctx.extendSelection(rowIndex, colIndex);
99
+ },
100
+ scrollIntoView(rowIndex: number, colIndex: number) {
101
+ ctx.scrollActiveCellIntoView(rowIndex, colIndex);
102
+ },
103
+ startEditing(rowIndex: number, colIndex: number, seed?: string) {
104
+ if (seed !== undefined) {
105
+ return ctx.startEditingWithChar(rowIndex, colIndex, seed) === true;
106
+ }
107
+ ctx.onCellDoubleClick(rowIndex, colIndex);
108
+ return true;
109
+ },
110
+ batch<T>(fn: () => T): T {
111
+ return runHistoryGroup(ctx, fn);
112
+ },
113
+ recordUndo(undo: () => void, redo: () => void) {
114
+ pushHistory(ctx, [
115
+ { rowId: "", columnId: "", field: "", before: undefined, after: undefined, custom: { undo, redo } },
116
+ ]);
117
+ },
118
+ focus() {
119
+ // preventScroll: the grid keeps its own scroll position, and a focus
120
+ // that yanked the viewport to the root's top edge would undo whatever
121
+ // scrollIntoView the command just asked for.
122
+ ctx.gridRootEl?.focus({ preventScroll: true });
123
+ },
124
+ async paste() {
125
+ await ctx.pasteFromClipboard?.();
126
+ },
127
+ copy() {
128
+ ctx.copySelectionToClipboard?.();
129
+ },
130
+ async cut() {
131
+ await ctx.cutSelectionToClipboard?.();
132
+ },
133
+ canEdit(rowIndex: number, colIndex: number) {
134
+ return ctx.isCellEditableAt?.(rowIndex, colIndex) ?? true;
135
+ },
136
+ };
137
+ }
@@ -119,3 +119,54 @@ describe('row-model cache: selection', () => {
119
119
  expect(target.getIsSelected()).toBe(false)
120
120
  })
121
121
  })
122
+
123
+ describe('base rows: reuse across a data swap', () => {
124
+ // The core reads `options.data` and `options.columns` off the object it was
125
+ // given (the component hands it a reactive one), so a swap is a write there.
126
+ const grid = () => {
127
+ const options = {
128
+ _features: tableFeatures({ rowSelectionFeature }),
129
+ _rowModels: { coreRowModel: createCoreRowModel<Row>() },
130
+ columns: COLUMNS as unknown as Array<ColumnDef<ReturnType<typeof tableFeatures>, Row>>,
131
+ data: makeRows(50),
132
+ getRowId: (r: Row) => String(r.id),
133
+ }
134
+ return { g: createSvGridCore<ReturnType<typeof tableFeatures>, Row>(options), options }
135
+ }
136
+
137
+ it('keeps the row object for a data object that stayed at its index, and rebuilds the rest', () => {
138
+ const { g, options } = grid()
139
+ const before = g.getRowModel().rows
140
+ const data = options.data.slice()
141
+ // A block landing: forty of fifty entries are the same objects, ten are new.
142
+ for (let i = 20; i < 30; i += 1) data[i] = { ...data[i]!, name: 'fresh' }
143
+ options.data = data
144
+ const after = g.getRowModel().rows
145
+ expect(after).toHaveLength(50)
146
+ for (let i = 0; i < 50; i += 1) {
147
+ if (i >= 20 && i < 30) expect(after[i]).not.toBe(before[i])
148
+ else expect(after[i]).toBe(before[i])
149
+ }
150
+ expect(after[25]!.getCellValueByColumnId('name')).toBe('fresh')
151
+ })
152
+
153
+ it('does not serve a memoised value after the object was changed in place', () => {
154
+ const { g, options } = grid()
155
+ const rows = g.getRowModel().rows
156
+ expect(rows[3]!.getCellValueByColumnId('score')).toBe((3 * 31) % 1000)
157
+ options.data[3]!.score = 4242
158
+ options.data = options.data.slice()
159
+ const again = g.getRowModel().rows
160
+ expect(again[3]).toBe(rows[3])
161
+ expect(again[3]!.getCellValueByColumnId('score')).toBe(4242)
162
+ })
163
+
164
+ it('rebuilds every row when the columns change', () => {
165
+ const { g, options } = grid()
166
+ const before = g.getRowModel().rows
167
+ options.columns = [...COLUMNS, { field: 'extra' }] as unknown as typeof options.columns
168
+ const after = g.getRowModel().rows
169
+ expect(after[0]).not.toBe(before[0])
170
+ expect(after[0]!.getAllCells()).toHaveLength(4)
171
+ })
172
+ })
package/src/core.ts CHANGED
@@ -468,6 +468,13 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
468
468
  | ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)
469
469
  /** When true, list/chips allow multiple selections. Cell value becomes an array. */
470
470
  editorMultiple?: boolean
471
+ /**
472
+ * The text editor accepts line breaks: Alt+Enter inserts one and the
473
+ * editor grows a line per break, while Enter still commits. What a
474
+ * spreadsheet cell with wrapped text wants; `editorType: 'textarea'` is
475
+ * the other shape, where Enter itself is the line break.
476
+ */
477
+ editorMultiline?: boolean
471
478
  /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
472
479
  editorSeparator?: string
473
480
  format?: CellFormatConfig
@@ -1538,6 +1545,7 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1538
1545
  let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
1539
1546
  let cachedBaseRowsColumns: Array<Column<TData>> | null = null
1540
1547
  let cachedBaseRows: Array<Row<TData>> = []
1548
+ let cachedRowCtx: BaseRowCtx<TData> | null = null
1541
1549
  let cachedRowModel: RowModel<TData> | null = null
1542
1550
  let cachedRowModelBaseRows: Array<Row<TData>> | null = null
1543
1551
  let cachedPipeline = options._rowModels
@@ -1747,22 +1755,35 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1747
1755
  getRowModel() {
1748
1756
  const columns = grid.getAllColumns()
1749
1757
  if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
1758
+ // Same columns as last time: the shared context still describes them,
1759
+ // and a row whose data object sits at the same index can keep its
1760
+ // row object. A row model that streams blocks hands the grid a new
1761
+ // array of 60k entries per block where 100 changed; building 60k
1762
+ // fresh row objects each time was most of the cost of a block
1763
+ // landing. The reused row drops its memoised values and cells, since
1764
+ // an app may have changed the object in place before passing a new
1765
+ // array - that is the case the old rebuild covered by accident.
1766
+ const previous = cachedBaseRowsColumns === columns && cachedRowCtx ? cachedBaseRows : null
1750
1767
  cachedBaseRowsInput = options.data
1751
1768
  cachedBaseRowsColumns = columns
1752
1769
  // O(1) column-id → index lookup so getCellValueByColumnId doesn't do
1753
1770
  // a linear `findIndex` on every cell read (was O(rows × cells × cols)).
1754
- const columnIndexById = new Map<string, number>()
1755
- for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
1756
- const columnCount = columns.length
1757
-
1758
- // One shared context for every row in this table, so a row carries a
1759
- // pointer rather than a closure scope. See BASE_ROW_METHODS.
1760
- const rowCtx: BaseRowCtx<TData> = {
1761
- grid: grid as SvGrid<TData>,
1762
- store,
1763
- columns,
1764
- columnCount,
1765
- columnIndexById,
1771
+ let rowCtx: BaseRowCtx<TData>
1772
+ if (previous && cachedRowCtx) {
1773
+ rowCtx = cachedRowCtx
1774
+ } else {
1775
+ const columnIndexById = new Map<string, number>()
1776
+ for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
1777
+ // One shared context for every row in this table, so a row carries a
1778
+ // pointer rather than a closure scope. See BASE_ROW_METHODS.
1779
+ rowCtx = {
1780
+ grid: grid as SvGrid<TData>,
1781
+ store,
1782
+ columns,
1783
+ columnCount: columns.length,
1784
+ columnIndexById,
1785
+ }
1786
+ cachedRowCtx = rowCtx
1766
1787
  }
1767
1788
 
1768
1789
  cachedBaseRows = new Array(options.data.length)
@@ -1778,6 +1799,13 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1778
1799
  }
1779
1800
  for (let index = 0; index < options.data.length; index++) {
1780
1801
  const original = options.data[index]!
1802
+ const kept = previous?.[index] as BaseRowState<TData> | undefined
1803
+ if (kept && kept.original === original) {
1804
+ kept[ROW_VALUES] = null
1805
+ kept[ROW_CELLS] = null
1806
+ cachedBaseRows[index] = kept
1807
+ continue
1808
+ }
1781
1809
  // `_values` and `_cells` stay null until something reads them - a
1782
1810
  // 100k-row grid showing twenty rows must not materialise every row's
1783
1811
  // values or cell objects to paint.
@@ -77,6 +77,11 @@ function makeCtx(opts: FakeOptions = {}) {
77
77
  editorSelectAll: false,
78
78
  editedCellValues: {},
79
79
  selectionRange: { anchor: null, focus: null },
80
+ tabRunOrigin: null,
81
+ activeRangeRect: () => null,
82
+ isRowCollapsed: () => false,
83
+ collapsedColumns: {},
84
+ isCellInSelectedRange: () => false,
80
85
  gridRootEl: null,
81
86
  props: {},
82
87
  history: [] as any[],
@@ -551,9 +556,10 @@ describe('updateEditingCellValue', () => {
551
556
  })
552
557
 
553
558
  describe('onEditorKeyDown', () => {
554
- function fakeEvent(key: string) {
559
+ function fakeEvent(key: string, init: Partial<KeyboardEvent> = {}) {
555
560
  return {
556
561
  key,
562
+ ...init,
557
563
  stopPropagation: vi.fn(),
558
564
  preventDefault: vi.fn(),
559
565
  } as unknown as KeyboardEvent
@@ -576,6 +582,74 @@ describe('onEditorKeyDown', () => {
576
582
  expect(focus).toHaveBeenCalledWith({ preventScroll: true })
577
583
  })
578
584
 
585
+ it('Enter saves and moves the active cell one row down, Shift+Enter one up', () => {
586
+ // Typing a column of values: Enter after each one lands on the next row,
587
+ // as in every spreadsheet, rather than leaving the cursor on the cell
588
+ // just committed. The last row is a wall, not a wrap.
589
+ const { ctx, ed } = editingFor({
590
+ columns: [{ id: 'a', field: 'a', editorType: 'text' }],
591
+ data: [{ a: 'a0' }, { a: 'a1' }, { a: 'a2' }],
592
+ })
593
+ ctx.gridRootEl = { focus: vi.fn() }
594
+ ed.onCellDoubleClick(0, 0)
595
+ ctx.editingCell = { ...ctx.editingCell, value: 'new' }
596
+ ed.onEditorKeyDown(fakeEvent('Enter'))
597
+ expect(ctx.internalData[0].a).toBe('new')
598
+ expect(ctx.activeCalls.at(-1)).toEqual([1, 0])
599
+
600
+ ed.onCellDoubleClick(1, 0)
601
+ const up = { ...fakeEvent('Enter'), shiftKey: true } as unknown as KeyboardEvent
602
+ ;(up as any).preventDefault = vi.fn()
603
+ ;(up as any).stopPropagation = vi.fn()
604
+ ed.onEditorKeyDown(up)
605
+ expect(ctx.activeCalls.at(-1)).toEqual([0, 0])
606
+
607
+ ed.onCellDoubleClick(2, 0)
608
+ ed.onEditorKeyDown(fakeEvent('Enter'))
609
+ expect(ctx.activeCalls.at(-1)).toEqual([2, 0])
610
+ })
611
+
612
+ it('Alt+Enter puts a line break into a multiline editor instead of committing', () => {
613
+ const { ctx, ed } = editingFor({
614
+ columns: [{ id: 'a', field: 'a', editorType: 'text' }],
615
+ data: [{ a: 'old' }],
616
+ })
617
+ ctx.gridRootEl = { focus: vi.fn() }
618
+ ctx.editingCell = { rowId: '0', columnId: 'a', editorType: 'text', value: 'ab' }
619
+ const area = document.createElement('textarea')
620
+ area.value = 'ab'
621
+ area.selectionStart = area.selectionEnd = 1
622
+ const ev = {
623
+ key: 'Enter', altKey: true, currentTarget: area,
624
+ stopPropagation: vi.fn(), preventDefault: vi.fn(),
625
+ } as unknown as KeyboardEvent
626
+ ed.onEditorKeyDown(ev)
627
+ expect(ev.preventDefault).toHaveBeenCalled()
628
+ expect(area.value).toBe('a\nb')
629
+ expect(area.selectionStart).toBe(2)
630
+ expect(ctx.editingCell?.value).toBe('a\nb')
631
+ // Still editing: nothing was saved.
632
+ expect(ctx.internalData[0].a).toBe('old')
633
+ })
634
+
635
+ it('Alt+Enter in a single-line input is just Enter', () => {
636
+ const { ctx, ed } = editingFor({
637
+ columns: [{ id: 'a', field: 'a', editorType: 'text' }],
638
+ data: [{ a: 'old' }, { a: 'next' }],
639
+ })
640
+ ctx.gridRootEl = { focus: vi.fn() }
641
+ ed.onCellDoubleClick(0, 0)
642
+ ctx.editingCell = { ...ctx.editingCell, value: 'new' }
643
+ const input = document.createElement('input')
644
+ const ev = {
645
+ key: 'Enter', altKey: true, currentTarget: input,
646
+ stopPropagation: vi.fn(), preventDefault: vi.fn(),
647
+ } as unknown as KeyboardEvent
648
+ ed.onEditorKeyDown(ev)
649
+ expect(ctx.internalData[0].a).toBe('new')
650
+ expect(ctx.editingCell).toBeNull()
651
+ })
652
+
579
653
  it('Escape cancels without saving and refocuses the grid root', () => {
580
654
  const { ctx, ed } = editingFor({
581
655
  columns: [{ id: 'a', field: 'a', editorType: 'text' }],
@@ -630,6 +704,89 @@ describe('onEditorKeyDown', () => {
630
704
  expect(ctx.activeCalls.at(-1)).toEqual([0, 1])
631
705
  })
632
706
 
707
+ it('Enter after a run of Tab commits returns to the column the run began in', () => {
708
+ const { ctx, ed } = editingFor({
709
+ columns: [
710
+ { id: 'a', field: 'a', editorType: 'text' },
711
+ { id: 'b', field: 'b', editorType: 'text' },
712
+ { id: 'c', field: 'c', editorType: 'text' },
713
+ ],
714
+ data: [{ a: '', b: '', c: '' }, { a: '', b: '', c: '' }],
715
+ })
716
+ ctx.gridRootEl = { focus: vi.fn() }
717
+ // The real setActiveCell forgets the run on a move to another cell;
718
+ // the fake must too, or the test would pass with the origin never
719
+ // being written back.
720
+ const plain = ctx.setActiveCell
721
+ ctx.setActiveCell = (r: number, c: number) => {
722
+ const prev = ctx.activeCell
723
+ if (!prev || prev.rowIndex !== r || prev.colIndex !== c) ctx.tabRunOrigin = null
724
+ plain(r, c)
725
+ }
726
+ ed.onCellDoubleClick(0, 0)
727
+ ctx.editingCell = { ...ctx.editingCell, value: 'one' }
728
+ ed.onEditorKeyDown(fakeEvent('Tab'))
729
+ ed.onCellDoubleClick(0, 1)
730
+ ctx.editingCell = { ...ctx.editingCell, value: 'two' }
731
+ ed.onEditorKeyDown(fakeEvent('Tab'))
732
+ expect(ctx.activeCalls.at(-1)).toEqual([0, 2])
733
+ expect(ctx.tabRunOrigin).toBe(0)
734
+ ed.onCellDoubleClick(0, 2)
735
+ ctx.editingCell = { ...ctx.editingCell, value: 'three' }
736
+ ed.onEditorKeyDown(fakeEvent('Enter'))
737
+ expect(ctx.internalData[0]).toMatchObject({ a: 'one', b: 'two', c: 'three' })
738
+ expect(ctx.activeCalls.at(-1)).toEqual([1, 0])
739
+ expect(ctx.tabRunOrigin).toBe(null)
740
+ expect(ctx.selectionCalls.at(-1)).toEqual([1, 0])
741
+ })
742
+
743
+ it('Ctrl+Enter writes the entry into every cell of the block, as one undo, and stays put', () => {
744
+ const { ctx, ed } = editingFor({
745
+ columns: [
746
+ { id: 'a', field: 'a', editorType: 'text' },
747
+ { id: 'b', field: 'b', editorType: 'text' },
748
+ ],
749
+ data: [{ a: '', b: '' }, { a: '', b: '' }, { a: 'keep', b: 'keep' }],
750
+ })
751
+ ctx.gridRootEl = { focus: vi.fn() }
752
+ ctx.activeRangeRect = () => ({ minRow: 0, maxRow: 1, minCol: 0, maxCol: 1 })
753
+ ctx.isCellInSelectedRange = () => true
754
+ ed.onCellDoubleClick(0, 0)
755
+ ctx.editingCell = { ...ctx.editingCell, value: 'x' }
756
+ const ev = fakeEvent('Enter', { ctrlKey: true })
757
+ ed.onEditorKeyDown(ev)
758
+ expect(ev.preventDefault).toHaveBeenCalled()
759
+ expect(ctx.internalData.slice(0, 2)).toEqual([{ a: 'x', b: 'x' }, { a: 'x', b: 'x' }])
760
+ expect(ctx.internalData[2]).toEqual({ a: 'keep', b: 'keep' })
761
+ // The cursor did not move.
762
+ expect(ctx.activeCalls.at(-1)).toEqual([0, 0])
763
+ expect(ctx.editingCell).toBeNull()
764
+ // Four steps, one group.
765
+ const groups = new Set(ctx.history.map((s: any) => s.groupId))
766
+ expect(ctx.history.length).toBe(4)
767
+ expect(groups.size).toBe(1)
768
+ expect([...groups][0]).toBeTruthy()
769
+ })
770
+
771
+ it('a commit inside a selected block keeps the block and moves within it', () => {
772
+ const { ctx, ed } = editingFor({
773
+ columns: [
774
+ { id: 'a', field: 'a', editorType: 'text' },
775
+ { id: 'b', field: 'b', editorType: 'text' },
776
+ ],
777
+ data: [{ a: '', b: '' }, { a: '', b: '' }],
778
+ })
779
+ ctx.gridRootEl = { focus: vi.fn() }
780
+ ctx.activeRangeRect = () => ({ minRow: 0, maxRow: 1, minCol: 0, maxCol: 1 })
781
+ ctx.isCellInSelectedRange = () => true
782
+ ed.onCellDoubleClick(1, 0)
783
+ ctx.editingCell = { ...ctx.editingCell, value: 'x' }
784
+ ed.onEditorKeyDown(fakeEvent('Enter'))
785
+ // Past the block's last row: the top of the next column.
786
+ expect(ctx.activeCalls.at(-1)).toEqual([0, 1])
787
+ expect(ctx.selectionCalls).toEqual([])
788
+ })
789
+
633
790
  it('Shift+Tab moves back a column and wraps to the previous row (#48)', () => {
634
791
  const { ctx, ed } = editingFor({
635
792
  columns: [
@@ -841,10 +998,131 @@ describe('onCellDoubleClick', () => {
841
998
  })
842
999
  })
843
1000
 
1001
+ describe('applyPastedText reports and records what it wrote', () => {
1002
+ function pasteReady(text: string) {
1003
+ Object.defineProperty(navigator, 'clipboard', {
1004
+ value: { readText: vi.fn().mockResolvedValue(text) },
1005
+ configurable: true,
1006
+ })
1007
+ const { ctx, ed } = editingFor({
1008
+ columns: [
1009
+ { id: 'a', field: 'a', editable: true, editorType: 'text' },
1010
+ { id: 'b', field: 'b', editable: true, editorType: 'text' },
1011
+ ],
1012
+ data: [{ a: 'a0', b: 'b0' }, { a: 'a1', b: 'b1' }, { a: 'a2', b: 'b2' }],
1013
+ })
1014
+ ctx.setActiveCell(1, 0)
1015
+ ctx.selectionRange = { anchor: { rowIndex: 1, colIndex: 0 }, focus: { rowIndex: 1, colIndex: 0 } }
1016
+ return { ctx, ed }
1017
+ }
1018
+
1019
+ it('fires onCellValueChange for every cell a block paste changed', async () => {
1020
+ // A formula engine behind the grid only sees a value through this hook;
1021
+ // a paste that skipped it left the engine holding the old cells.
1022
+ const { ctx, ed } = pasteReady('x\ty\nz\tb2')
1023
+ const onCellValueChange = vi.fn()
1024
+ ctx.props.onCellValueChange = onCellValueChange
1025
+ await ed.pasteFromClipboard()
1026
+ expect(ctx.internalData[1]).toEqual({ a: 'x', b: 'y' })
1027
+ expect(ctx.internalData[2]).toEqual({ a: 'z', b: 'b2' })
1028
+ // b2 was already b2: no event for a cell that did not change.
1029
+ expect(onCellValueChange).toHaveBeenCalledTimes(3)
1030
+ expect(onCellValueChange).toHaveBeenCalledWith(
1031
+ expect.objectContaining({ rowIndex: 1, columnId: 'a', oldValue: 'a1', newValue: 'x' }),
1032
+ )
1033
+ expect(onCellValueChange).toHaveBeenCalledWith(
1034
+ expect.objectContaining({ rowIndex: 2, columnId: 'a', oldValue: 'a2', newValue: 'z' }),
1035
+ )
1036
+ })
1037
+
1038
+ it('records the paste as one grouped history entry', async () => {
1039
+ const { ctx, ed } = pasteReady('x\ty\nz\tw')
1040
+ await ed.pasteFromClipboard()
1041
+ expect(ctx.history).toHaveLength(4)
1042
+ const groups = new Set(ctx.history.map((s: { groupId?: string }) => s.groupId))
1043
+ expect(groups.size).toBe(1)
1044
+ expect([...groups][0]).toBeDefined()
1045
+ expect(ctx.history[0]).toMatchObject({ rowId: '1', columnId: 'a', field: 'a', before: 'a1', after: 'x' })
1046
+ })
1047
+
1048
+ it('writes nothing, records nothing, when the paste changes nothing', async () => {
1049
+ const { ctx, ed } = pasteReady('a1\tb1')
1050
+ const onCellValueChange = vi.fn()
1051
+ ctx.props.onCellValueChange = onCellValueChange
1052
+ const before = ctx.internalData
1053
+ await ed.pasteFromClipboard()
1054
+ expect(ctx.internalData).toBe(before)
1055
+ expect(ctx.history).toHaveLength(0)
1056
+ expect(onCellValueChange).not.toHaveBeenCalled()
1057
+ })
1058
+ })
1059
+
1060
+ describe('onPasteClipboard', () => {
1061
+ const grid = () => editingFor({
1062
+ columns: [
1063
+ { id: 'a', field: 'a', editorType: 'text', editable: true },
1064
+ { id: 'b', field: 'b', editorType: 'text', editable: true },
1065
+ ],
1066
+ data: [{ a: '', b: '' }, { a: '', b: '' }],
1067
+ })
1068
+
1069
+ it('hands the native paste event\'s text and HTML to the handler and applies nothing itself when it returns true', () => {
1070
+ Object.defineProperty(navigator, 'clipboard', { value: { readText: vi.fn() }, configurable: true })
1071
+ const { ctx, ed } = grid()
1072
+ const seen: unknown[] = []
1073
+ ctx.props.onPasteClipboard = (payload: unknown) => { seen.push(payload); return true }
1074
+ const ev = {
1075
+ clipboardData: { getData: (type: string) => (type === 'text/html' ? '<table><tr><td x:fmla="=A1">1</td></tr></table>' : '1') },
1076
+ preventDefault: vi.fn(),
1077
+ } as unknown as ClipboardEvent
1078
+ ed.onGridPaste(ev)
1079
+ expect(ev.preventDefault).toHaveBeenCalled()
1080
+ expect(seen).toEqual([{ text: '1', html: '<table><tr><td x:fmla="=A1">1</td></tr></table>', source: 'event' }])
1081
+ expect(ctx.internalData[0]).toMatchObject({ a: '', b: '' })
1082
+ })
1083
+
1084
+ it('falls through to the plain paste when the handler declines', () => {
1085
+ Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
1086
+ const { ctx, ed } = grid()
1087
+ ctx.props.onPasteClipboard = () => undefined
1088
+ ctx.selectionRange = { anchor: { rowIndex: 0, colIndex: 0 }, focus: { rowIndex: 0, colIndex: 0 } }
1089
+ const ev = {
1090
+ clipboardData: { getData: (type: string) => (type === 'text/plain' ? 'X\tY' : '') },
1091
+ preventDefault: vi.fn(),
1092
+ } as unknown as ClipboardEvent
1093
+ ed.onGridPaste(ev)
1094
+ expect(ctx.internalData[0]).toMatchObject({ a: 'X', b: 'Y' })
1095
+ })
1096
+
1097
+ it('reads both types through navigator.clipboard.read on the async path', async () => {
1098
+ const blob = (text: string) => ({ text: async () => text })
1099
+ const read = vi.fn().mockResolvedValue([
1100
+ { types: ['text/plain', 'text/html'], getType: async (t: string) => blob(t === 'text/html' ? '<b>h</b>' : 'p') },
1101
+ ])
1102
+ Object.defineProperty(navigator, 'clipboard', { value: { read, readText: vi.fn() }, configurable: true })
1103
+ const { ctx, ed } = grid()
1104
+ const seen: unknown[] = []
1105
+ ctx.props.onPasteClipboard = (payload: unknown) => { seen.push(payload); return true }
1106
+ await ed.pasteFromClipboard()
1107
+ expect(seen).toEqual([{ text: 'p', html: '<b>h</b>', source: 'async' }])
1108
+ })
1109
+
1110
+ it('the paste is one history group even when the handler writes several cells', () => {
1111
+ Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
1112
+ const { ctx, ed } = grid()
1113
+ let groupSeen: unknown = 'unset'
1114
+ ctx.props.onPasteClipboard = () => { groupSeen = ctx.historyGroupId; return true }
1115
+ ed.onGridPaste({ clipboardData: { getData: () => 'x' }, preventDefault: vi.fn() } as unknown as ClipboardEvent)
1116
+ // The handler ran inside an ambient group, and the group closed after it.
1117
+ expect(groupSeen).toBeTruthy()
1118
+ expect(ctx.historyGroupId).toBeUndefined()
1119
+ })
1120
+ })
1121
+
844
1122
  describe('clipboard fallbacks (non-paste branches only)', () => {
845
1123
  // onGridPaste and pasteFromClipboard guard on navigator.clipboard.readText.
846
- // The actual paste transformation is covered by clipboard.test.ts; here we
847
- // only assert the early-return guards so we do not duplicate that coverage.
1124
+ // The transformation itself is covered just above; here we only assert
1125
+ // the early-return guards.
848
1126
 
849
1127
  it('pasteFromClipboard returns early when the async API is unavailable', async () => {
850
1128
  Object.defineProperty(navigator, 'clipboard', {