@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
@@ -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
+ }
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
@@ -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', {