@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
package/src/editing.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type TableFeatures,
12
12
  } from "./index";
13
13
  import "./sv-grid-scrollbar";
14
- import { getNextActiveCell } from "./keyboard";
14
+ import { getEntryStep } from "./keyboard";
15
15
  import {
16
16
  getCellKey,
17
17
  toValueArray,
@@ -21,14 +21,10 @@ import {
21
21
  getColumnBaseValue,
22
22
  isGroupRow,
23
23
  } from "./cell-values";
24
+ import { pushHistory, nextGroupId, runHistoryGroup, type HistoryStep } from "./history";
25
+ import { hasGridShortcuts, runGridShortcuts } from "./shortcut-registry";
26
+ import { buildCommandContext } from "./command-context";
24
27
 
25
- type HistoryStep = {
26
- rowId: string;
27
- columnId: string;
28
- field: string;
29
- before: unknown;
30
- after: unknown;
31
- };
32
28
 
33
29
  export function createEditing<
34
30
  TFeatures extends TableFeatures = TableFeatures,
@@ -132,11 +128,29 @@ export function createEditing<
132
128
  rowRef: row.original,
133
129
  };
134
130
  ctx.setActiveCell(rowIndex, colIndex);
135
- ctx.setSelection(rowIndex, colIndex);
131
+ selectForEdit(rowIndex, colIndex);
136
132
  return true;
137
133
  }
138
134
 
135
+ /**
136
+ * The selection an edit starts with. A cell already inside the selection
137
+ * leaves it standing: typing into the active cell of a selected block
138
+ * used to collapse the block to that one cell, so Enter could never walk
139
+ * on inside it. A cell outside the selection becomes the selection.
140
+ */
141
+ function selectForEdit(rowIndex: number, colIndex: number) {
142
+ if (ctx.isCellInSelectedRange(rowIndex, colIndex)) return;
143
+ ctx.setSelection(rowIndex, colIndex);
144
+ }
145
+
139
146
  function saveEditingCell() {
147
+ // One group: a consumer's onCellValueChange may record a step of its own
148
+ // (a spreadsheet gives "12%" a percent format as it lands), and that
149
+ // belongs to the same Ctrl+Z as the value.
150
+ runHistoryGroup(ctx, () => commitEditingCell());
151
+ }
152
+
153
+ function commitEditingCell() {
140
154
  if (!ctx.editingCell) return;
141
155
  const editing = ctx.editingCell;
142
156
  // Resolve the row this edit belongs to. `allRows` is only the current page,
@@ -197,19 +211,7 @@ export function createEditing<
197
211
  before: oldValue,
198
212
  after: finalValue,
199
213
  }
200
- const truncated = ctx.history.slice(0, ctx.historyPtr + 1)
201
- truncated.push(step)
202
- // Cap the buffer at UNDO_LIMIT; drop the OLDEST entries so the
203
- // pointer stays valid relative to the newest steps.
204
- if (truncated.length > ctx.UNDO_LIMIT) {
205
- const drop = truncated.length - ctx.UNDO_LIMIT
206
- ctx.history = truncated.slice(drop)
207
- ctx.historyPtr = ctx.history.length - 1
208
- } else {
209
- ctx.history = truncated
210
- ctx.historyPtr = ctx.history.length - 1
211
- }
212
- ctx.historyVersion += 1
214
+ pushHistory(ctx, [step])
213
215
  }
214
216
  // Notify the consumer AFTER the row has been updated so any callback-
215
217
  // driven recompute (cascade totals, server save, undo stack) sees the
@@ -230,6 +232,11 @@ export function createEditing<
230
232
  /** Apply an undo / redo step directly to the underlying row, bypassing
231
233
  * the editor pipeline so we don't accidentally re-push to the stack. */
232
234
  function applyHistoryStep(step: HistoryStep, direction: 'undo' | 'redo') {
235
+ if (step.custom) {
236
+ if (direction === 'undo') step.custom.undo()
237
+ else step.custom.redo()
238
+ return
239
+ }
233
240
  const row = ctx.allRows.find((r: any) => r.id === step.rowId)
234
241
  const col = ctx.allColumns.find((c: any) => c.id === step.columnId)
235
242
  if (!row?.original || !col) return
@@ -261,26 +268,111 @@ export function createEditing<
261
268
  * move, Tab committed the edit and then let the browser walk focus out of the
262
269
  * grid entirely (#48). Shared by every editor type, including the textarea.
263
270
  */
264
- function commitAndMoveByTab(shiftKey: boolean) {
271
+ /**
272
+ * Commit the edit and step the cursor the way data entry expects: Tab a
273
+ * column to the right (wrapping at the row's end), Enter a row down, Shift
274
+ * reversing either. Enter used to commit and stay, so a column of numbers
275
+ * took a click or an arrow between every value; Excel and every other
276
+ * sheet move down, and the accessibility page has always said this grid
277
+ * does too. At the last row or column the cursor stays put.
278
+ */
279
+ function commitAndMove(intent: "tabNext" | "tabPrev" | "moveDown" | "moveUp") {
265
280
  const active = ctx.activeCell;
266
281
  saveEditingCell();
267
282
  ctx.gridRootEl?.focus({ preventScroll: true });
268
283
  if (!active) return;
269
- const next = getNextActiveCell(active, shiftKey ? "tabPrev" : "tabNext", {
284
+ // The same stepping as Enter and Tab on the grid root: a Tab run's Enter
285
+ // goes back to the run's first column, and a selected block keeps the
286
+ // cursor inside it.
287
+ const step = getEntryStep(active, intent, {
270
288
  maxRow: ctx.allRows.length - 1,
271
289
  maxCol: ctx.allColumns.length - 1,
290
+ tabOrigin: ctx.tabRunOrigin,
291
+ range: ctx.activeRangeRect(),
292
+ collapsed: {
293
+ isRowCollapsed: (i: number) => ctx.isRowCollapsed(i) as boolean,
294
+ isColumnCollapsed: (i: number) => !!ctx.collapsedColumns[ctx.allColumns[i]?.id],
295
+ },
272
296
  });
297
+ const next = step.cell;
273
298
  ctx.setActiveCell(next.rowIndex, next.colIndex);
274
- ctx.setSelection(next.rowIndex, next.colIndex);
299
+ ctx.tabRunOrigin = step.tabOrigin;
300
+ if (!step.withinRange) ctx.setSelection(next.rowIndex, next.colIndex);
275
301
  ctx.scrollActiveCellIntoView(next.rowIndex, next.colIndex);
276
302
  }
277
303
 
304
+ function commitAndMoveByTab(shiftKey: boolean) {
305
+ commitAndMove(shiftKey ? "tabPrev" : "tabNext");
306
+ }
307
+
308
+ /**
309
+ * Ctrl+Enter: the entry goes into every cell of the selected block and the
310
+ * cursor stays where it is, as in Excel, where it is how a block is filled
311
+ * with one value in one go. Each cell takes the same raw entry through the
312
+ * ordinary commit, so its column's parser, the history and
313
+ * `onCellValueChange` all see it, and the whole block is one undo. A cell
314
+ * that cannot be edited is left alone. With nothing but the active cell
315
+ * selected it is a commit that does not move.
316
+ */
317
+ function commitToSelection() {
318
+ const editing = ctx.editingCell;
319
+ if (!editing) return;
320
+ const raw = editing.value;
321
+ const active = ctx.activeCell;
322
+ const range = ctx.activeRangeRect();
323
+ runHistoryGroup(ctx, () => {
324
+ commitEditingCell();
325
+ if (!active || !range) return;
326
+ for (let r = range.minRow; r <= range.maxRow; r += 1) {
327
+ for (let c = range.minCol; c <= range.maxCol; c += 1) {
328
+ if (r === active.rowIndex && c === active.colIndex) continue;
329
+ const row = ctx.allRows[r];
330
+ const column = ctx.allColumns[c];
331
+ if (!row || !column || isGroupRow(row) || !isCellEditable(column, row)) continue;
332
+ ctx.editingCell = {
333
+ rowId: row.id,
334
+ columnId: column.id,
335
+ editorType: (column.columnDef.editorType ?? "text") as CellEditorType,
336
+ value: raw,
337
+ rowRef: row.original,
338
+ };
339
+ commitEditingCell();
340
+ }
341
+ }
342
+ });
343
+ ctx.gridRootEl?.focus({ preventScroll: true });
344
+ }
345
+
278
346
  function onEditorKeyDown(event: KeyboardEvent) {
279
347
  event.stopPropagation();
280
- if (event.key === "Enter") {
348
+ // Registered commands get the key first here too, with `editing: true`, so
349
+ // a handler can claim a combination that only means something mid-edit
350
+ // (Alt+Enter for a newline, F4 to cycle a reference between relative and
351
+ // absolute) before the editor's own Enter / Tab / Escape handling runs.
352
+ if (hasGridShortcuts() && runGridShortcuts(event, buildCommandContext(ctx, true))) {
353
+ return;
354
+ }
355
+ if (event.key === "Enter" && event.altKey) {
356
+ // A line break, in an editor that can hold one (`editorMultiline`).
357
+ // In a single-line editor Alt+Enter is just Enter.
358
+ const el = event.currentTarget;
359
+ if (el instanceof HTMLTextAreaElement) {
360
+ event.preventDefault();
361
+ const start = el.selectionStart ?? el.value.length;
362
+ const end = el.selectionEnd ?? start;
363
+ el.value = el.value.slice(0, start) + "\n" + el.value.slice(end);
364
+ el.selectionStart = el.selectionEnd = start + 1;
365
+ updateEditingCellValue(el.value);
366
+ el.dispatchEvent(new Event("input", { bubbles: true }));
367
+ return;
368
+ }
369
+ }
370
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
281
371
  event.preventDefault();
282
- saveEditingCell();
283
- ctx.gridRootEl?.focus({ preventScroll: true });
372
+ commitToSelection();
373
+ } else if (event.key === "Enter") {
374
+ event.preventDefault();
375
+ commitAndMove(event.shiftKey ? "moveUp" : "moveDown");
284
376
  } else if (event.key === "Tab") {
285
377
  event.preventDefault();
286
378
  commitAndMoveByTab(event.shiftKey);
@@ -356,7 +448,7 @@ export function createEditing<
356
448
  rowRef: row.original,
357
449
  };
358
450
  ctx.setActiveCell(rowIndex, colIndex);
359
- ctx.setSelection(rowIndex, colIndex);
451
+ selectForEdit(rowIndex, colIndex);
360
452
  }
361
453
 
362
454
  async function pasteFromClipboard() {
@@ -366,6 +458,28 @@ export function createEditing<
366
458
  // On plain HTTP (a XAMPP/Apache LAN host) `navigator.clipboard` is
367
459
  // undefined - there the Ctrl+V keydown handler skips preventDefault and
368
460
  // lets the browser deliver a native `paste` event to `onGridPaste`.
461
+ if (ctx.props.onPasteClipboard && navigator.clipboard?.read) {
462
+ // Both types, for a handler that reads the HTML.
463
+ try {
464
+ const items = await navigator.clipboard.read();
465
+ let text = "";
466
+ let html: string | null = null;
467
+ for (const item of items) {
468
+ if (item.types.includes("text/html")) html = await (await item.getType("text/html")).text();
469
+ if (item.types.includes("text/plain")) text = await (await item.getType("text/plain")).text();
470
+ }
471
+ if (text || html) applyPastedPayload({ text, html, source: "async" });
472
+ } catch {
473
+ // No permission or nothing readable: the text-only read below may still work.
474
+ if (!navigator.clipboard.readText) return;
475
+ try {
476
+ applyPastedPayload({ text: await navigator.clipboard.readText(), html: null, source: "async" });
477
+ } catch {
478
+ return;
479
+ }
480
+ }
481
+ return;
482
+ }
369
483
  if (!navigator.clipboard?.readText) return;
370
484
  let text: string;
371
485
  try {
@@ -376,6 +490,26 @@ export function createEditing<
376
490
  applyPastedText(text);
377
491
  }
378
492
 
493
+ /**
494
+ * With `onPasteClipboard` set, Ctrl+V leaves the key to the browser so the
495
+ * `paste` event arrives with the HTML. A browser that does not deliver
496
+ * that event to the grid root (Firefox, a non-editable element) would
497
+ * paste nothing, so the async read is armed to run shortly after unless
498
+ * the event lands first and disarms it.
499
+ */
500
+ let pasteFallback: ReturnType<typeof setTimeout> | null = null;
501
+ function armPasteFallback() {
502
+ if (pasteFallback) clearTimeout(pasteFallback);
503
+ pasteFallback = setTimeout(() => {
504
+ pasteFallback = null;
505
+ void pasteFromClipboard();
506
+ }, 80);
507
+ }
508
+ function disarmPasteFallback() {
509
+ if (pasteFallback) clearTimeout(pasteFallback);
510
+ pasteFallback = null;
511
+ }
512
+
379
513
  /**
380
514
  * Native `paste` ClipboardEvent fallback (insecure-context path). A real
381
515
  * Ctrl/Cmd+V on the focused grid root fires this with `clipboardData`
@@ -390,6 +524,17 @@ export function createEditing<
390
524
  // preventDefault the editor's paste and write to the data cell instead,
391
525
  // so the edit "wouldn't update".
392
526
  if (ctx.editingCell) return;
527
+ if (ctx.props.onPasteClipboard) {
528
+ // The event is the preferred path for a handler: it carries the HTML
529
+ // as the source wrote it, needs no permission, and works over HTTP.
530
+ disarmPasteFallback();
531
+ const text = event.clipboardData?.getData("text/plain") ?? "";
532
+ const html = event.clipboardData?.getData("text/html") || null;
533
+ if (!text && !html) return;
534
+ event.preventDefault();
535
+ applyPastedPayload({ text, html, source: "event" });
536
+ return;
537
+ }
393
538
  // When the async API is available the keydown handler already pasted via
394
539
  // pasteFromClipboard(); ignore the (suppressed) native event so we don't
395
540
  // paste twice.
@@ -401,6 +546,22 @@ export function createEditing<
401
546
  }
402
547
 
403
548
  function applyPastedText(text: string) {
549
+ // One history group for the whole paste, so whatever a consumer's
550
+ // processCellFromClipboard records alongside the values (a format, via
551
+ // recordUndo) comes back with them on a single Ctrl+Z.
552
+ runHistoryGroup(ctx, () => applyPastedCells(text));
553
+ }
554
+
555
+ /** A paste with both types: the handler first, the plain text otherwise. */
556
+ function applyPastedPayload(payload: { text: string; html: string | null; source: "event" | "async" }) {
557
+ runHistoryGroup(ctx, () => {
558
+ const handled = ctx.props.onPasteClipboard?.(payload);
559
+ if (handled === true) return;
560
+ if (payload.text) applyPastedCells(payload.text);
561
+ });
562
+ }
563
+
564
+ function applyPastedCells(text: string) {
404
565
  const anchor = ctx.selectionRange.anchor ?? ctx.grid.getState().activeCell;
405
566
  if (!anchor) return;
406
567
  const focus = ctx.selectionRange.focus ?? anchor;
@@ -429,6 +590,15 @@ export function createEditing<
429
590
 
430
591
  const next = ctx.internalData.slice() as Array<TData>;
431
592
  const dataIndexOf = createDataIndexLookup(next);
593
+ // Every cell the paste changes: one grouped history entry for the lot,
594
+ // so Ctrl+Z takes the whole paste back, and one onCellValueChange each,
595
+ // so a consumer keeping its own model behind the grid (a formula engine)
596
+ // learns the values. The loop used to swap the rows in and stop, which
597
+ // left the undo stack blind to a paste and the consumer never told.
598
+ const changed: Array<{
599
+ row: TData; rowId: string; rowIndex: number; columnId: string; field: string;
600
+ before: unknown; after: unknown;
601
+ }> = [];
432
602
  for (let i = 0; i < rowSpan; i += 1) {
433
603
  const targetRowIndex = startRow + i;
434
604
  const row = ctx.allRows[targetRowIndex];
@@ -445,21 +615,67 @@ export function createEditing<
445
615
  const updated: Record<string, unknown> = {
446
616
  ...(originalRow as Record<string, unknown>),
447
617
  };
618
+ const rowChanges: typeof changed = [];
619
+ const record = (column: { id: string; columnDef: { field?: string } }, before: unknown, after: unknown) => {
620
+ if (before === after) return;
621
+ rowChanges.push({
622
+ row: updated as TData, rowId: row.id, rowIndex: dataIndex, columnId: column.id,
623
+ field: column.columnDef.field as string, before, after,
624
+ });
625
+ };
448
626
  for (let j = 0; j < colSpan; j += 1) {
449
627
  const column = ctx.allColumns[startCol + j];
450
628
  if (!column?.columnDef.field) continue;
451
629
  if (!isCellEditableAt(targetRowIndex, startCol + j)) continue;
630
+ const before = updated[column.columnDef.field];
452
631
  const editorType = (column.columnDef.editorType ??
453
632
  "text") as CellEditorType;
454
633
  const raw = fillRange ? lines[0]! : sourceCells?.[j] ?? "";
455
- updated[column.columnDef.field] = parseEditorValue(editorType, raw, {
634
+ const parsedValue = parseEditorValue(editorType, raw, {
456
635
  dateOnly: column.columnDef.cellDataType === "dateString",
457
636
  });
637
+ // The inbound half of the clipboard pair. A consumer (or a feature
638
+ // pack doing paste-special) gets the raw text AND what the grid would
639
+ // have written, and can return either. `undefined` leaves the cell
640
+ // alone, which is how "paste values only" skips a formula column.
641
+ const hook = ctx.props.processCellFromClipboard;
642
+ if (hook) {
643
+ const decided = hook({
644
+ text: raw,
645
+ parsedValue,
646
+ row: originalRow as TData,
647
+ rowIndex: targetRowIndex,
648
+ columnId: column.id as string,
649
+ });
650
+ if (decided === undefined) continue;
651
+ updated[column.columnDef.field] = decided;
652
+ record(column, before, decided);
653
+ continue;
654
+ }
655
+ updated[column.columnDef.field] = parsedValue;
656
+ record(column, before, parsedValue);
458
657
  }
658
+ if (!rowChanges.length) continue;
459
659
  next[dataIndex] = updated as TData;
660
+ changed.push(...rowChanges);
460
661
  }
662
+ if (!changed.length) return;
461
663
  ctx.internalData = next;
462
664
  ctx.grid.store.setState((prev: any) => ({ ...prev }));
665
+ pushHistory(
666
+ ctx,
667
+ changed.map((c) => ({
668
+ rowId: c.rowId, columnId: c.columnId, field: c.field, before: c.before, after: c.after,
669
+ })),
670
+ nextGroupId(),
671
+ );
672
+ if (ctx.props.onCellValueChange) {
673
+ for (const c of changed) {
674
+ ctx.props.onCellValueChange({
675
+ rowIndex: c.rowIndex, columnId: c.columnId, oldValue: c.before, newValue: c.after, row: c.row,
676
+ });
677
+ }
678
+ }
463
679
  }
464
680
 
465
681
  /** Programmatically begin editing a cell (mirrors a double-click). */
@@ -563,14 +779,15 @@ export function createEditing<
563
779
  }
564
780
  // Record the whole-row change as consecutive history steps.
565
781
  if (changed.length) {
566
- let hist = ctx.history.slice(0, ctx.historyPtr + 1);
567
- for (const c of changed) {
568
- hist.push({ rowId: fr.rowId, columnId: c.columnId, field: c.field, before: c.before, after: c.after });
569
- }
570
- if (hist.length > ctx.UNDO_LIMIT) hist = hist.slice(hist.length - ctx.UNDO_LIMIT);
571
- ctx.history = hist;
572
- ctx.historyPtr = ctx.history.length - 1;
573
- ctx.historyVersion += 1;
782
+ // One full-row commit is ONE undo, not one per changed column.
783
+ pushHistory(
784
+ ctx,
785
+ changed.map((c) => ({
786
+ rowId: fr.rowId, columnId: c.columnId, field: c.field,
787
+ before: c.before, after: c.after,
788
+ })),
789
+ nextGroupId(),
790
+ );
574
791
  }
575
792
  ctx.grid.store.setState((prev: any) => ({ ...prev }));
576
793
  if (ctx.props.onCellValueChange && changed.length) {
@@ -614,5 +831,6 @@ export function createEditing<
614
831
  onCellDoubleClick,
615
832
  pasteFromClipboard,
616
833
  onGridPaste,
834
+ armPasteFallback,
617
835
  };
618
836
  }
@@ -8,6 +8,14 @@ describe('buildFillPattern - numeric', () => {
8
8
  expect(buildFillPattern([100, 90, 80], 3)).toEqual([70, 60, 50])
9
9
  })
10
10
 
11
+ it('continues an uneven run of numbers along its linear trend, as Excel does', () => {
12
+ // 1, 2, 4: slope 1.5, intercept -2/3, so the next three are 5.33, 6.83, 8.33.
13
+ const next = buildFillPattern([1, 2, 4], 3) as number[]
14
+ expect(next.map((v) => Number(v.toFixed(4)))).toEqual([5.3333, 6.8333, 8.3333])
15
+ expect((buildFillPattern([10, 20, 40], 2) as number[]).map((v) => Number(v.toFixed(4)))).toEqual([53.3333, 68.3333])
16
+ expect(buildFillPattern([18200, 4200, 1200], 2)).toEqual([-9133.33333333333, -17633.3333333333])
17
+ })
18
+
11
19
  it('repeats a single numeric value (Excel default)', () => {
12
20
  expect(buildFillPattern([42], 4)).toEqual([42, 42, 42, 42])
13
21
  })
@@ -87,7 +95,34 @@ describe('buildFillPattern - fallback cycle', () => {
87
95
  ])
88
96
  })
89
97
 
98
+ it('repeats a single number held as text, the way a numeric one repeats', () => {
99
+ // A sheet stores what was typed, so 5 arrives as '5'. It is a number,
100
+ // not 'Item 5' with an empty prefix: one copy, no 6, 7, 8.
101
+ expect(buildFillPattern(['5'], 3)).toEqual(['5', '5', '5'])
102
+ expect(buildFillPattern(['Item 5'], 2)).toEqual(['Item 6', 'Item 7'])
103
+ })
104
+
90
105
  it('repeats a single non-numeric string', () => {
91
106
  expect(buildFillPattern(['hello'], 3)).toEqual(['hello', 'hello', 'hello'])
92
107
  })
108
+
109
+ it('steps an ISO date by a day, and by the gap two dates set', () => {
110
+ // Used to read 2024-01-15 as the number 2024 with a suffix and fill
111
+ // 2025-01-15, 2026-01-15.
112
+ expect(buildFillPattern(['2024-01-15'], 3)).toEqual(['2024-01-16', '2024-01-17', '2024-01-18'])
113
+ expect(buildFillPattern(['2024-01-01', '2024-01-08'], 2)).toEqual(['2024-01-15', '2024-01-22'])
114
+ expect(buildFillPattern(['2024-02-28'], 2)).toEqual(['2024-02-29', '2024-03-01'])
115
+ expect(buildFillPattern(['2024-03-03', '2024-03-02'], 2)).toEqual(['2024-03-01', '2024-02-29'])
116
+ })
117
+
118
+ it('steps Date objects by the day and keeps them Dates', () => {
119
+ const out = buildFillPattern([new Date(Date.UTC(2024, 0, 31))], 2)
120
+ expect(out.every((d) => d instanceof Date)).toBe(true)
121
+ expect((out as Date[]).map((d) => d.toISOString().slice(0, 10))).toEqual(['2024-02-01', '2024-02-02'])
122
+ })
123
+
124
+ it('still counts a prefixed number that happens to hold digits and dashes', () => {
125
+ expect(buildFillPattern(['Run-001'], 2)).toEqual(['Run-002', 'Run-003'])
126
+ expect(buildFillPattern(['2024-1-5'], 1)).not.toEqual(['2024-01-06'])
127
+ })
93
128
  })
@@ -164,13 +164,54 @@ function arithmeticStep(nums: ReadonlyArray<number>): number | null {
164
164
  * `sourceValues` already in the selection. See module header for the
165
165
  * pattern-detection rules.
166
166
  */
167
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
168
+
169
+ /**
170
+ * The sources as day stamps when every one is a date: `Date` objects, or
171
+ * `YYYY-MM-DD` text (read as UTC so a fill never crosses a day boundary
172
+ * with the time zone). Anything else means "not a date series".
173
+ */
174
+ function datesOf(values: ReadonlyArray<unknown>): { stamps: number[]; iso: boolean } | null {
175
+ if (values.every((v) => v instanceof Date && Number.isFinite(v.getTime()))) {
176
+ return { stamps: values.map((v) => (v as Date).getTime()), iso: false }
177
+ }
178
+ if (values.every((v) => typeof v === 'string' && ISO_DATE.test(v))) {
179
+ const stamps = values.map((v) => Date.parse(`${v}T00:00:00Z`))
180
+ return stamps.every((t) => Number.isFinite(t)) ? { stamps, iso: true } : null
181
+ }
182
+ return null
183
+ }
184
+
167
185
  export function buildFillPattern(
168
186
  sourceValues: ReadonlyArray<unknown>,
169
187
  targetCount: number,
170
188
  ): unknown[] {
171
189
  if (sourceValues.length === 0 || targetCount <= 0) return []
172
190
 
173
- // -------- 1) Numeric arithmetic progression (2+ values) -----------
191
+ // -------- 0) Dates step by the day ---------------------------------
192
+ // Before the numeric rule, since a Date coerces to a finite number of
193
+ // milliseconds, and before the prefix rule, which read 2024-01-15 as the
194
+ // number 2024 with a suffix and filled 2025-01-15. Two dates set the step
195
+ // (a week apart fills weekly); one date steps by a day, as Excel's does.
196
+ const dates = datesOf(sourceValues)
197
+ if (dates) {
198
+ const DAY = 86_400_000
199
+ const days = dates.stamps.map((t) => Math.round(t / DAY))
200
+ const step = days.length >= 2 ? arithmeticStep(days) : 1
201
+ if (step !== null) {
202
+ const last = days[days.length - 1]!
203
+ return Array.from({ length: targetCount }, (_, i) => {
204
+ const stamp = (last + step * (i + 1)) * DAY
205
+ return dates.iso ? new Date(stamp).toISOString().slice(0, 10) : new Date(stamp)
206
+ })
207
+ }
208
+ }
209
+
210
+ // -------- 1) Numbers: the linear trend (2+ values) -----------------
211
+ // Excel's AutoFill of two or more numbers continues their least-squares
212
+ // line: 1, 2, 3 goes on 4, 5, 6, and 1, 2, 4 goes on 5.33, 6.83, ...
213
+ // rather than repeating. An even step is the same line, so the common
214
+ // case is unchanged.
174
215
  const nums = sourceValues.map((v) => Number(v))
175
216
  const allNumericLike =
176
217
  sourceValues.every((v) => v !== null && v !== '' && v !== undefined) &&
@@ -181,6 +222,17 @@ export function buildFillPattern(
181
222
  const last = nums[nums.length - 1]!
182
223
  return Array.from({ length: targetCount }, (_, i) => last + step * (i + 1))
183
224
  }
225
+ const n = nums.length
226
+ const meanX = (n - 1) / 2
227
+ const meanY = nums.reduce((a, b) => a + b, 0) / n
228
+ let sxy = 0
229
+ let sxx = 0
230
+ for (let i = 0; i < n; i += 1) { sxy += (i - meanX) * (nums[i]! - meanY); sxx += (i - meanX) ** 2 }
231
+ const slope = sxx === 0 ? 0 : sxy / sxx
232
+ const intercept = meanY - slope * meanX
233
+ // Rounded to the precision floating point gives 5.333333333333333, so
234
+ // 10, 20, 40 fills 50, 60 and not 50.00000000000001.
235
+ return Array.from({ length: targetCount }, (_, i) => Number((intercept + slope * (n + i)).toPrecision(15)))
184
236
  }
185
237
 
186
238
  // -------- 2) Known string sequence (days / months / quarters) -----
@@ -200,7 +252,9 @@ export function buildFillPattern(
200
252
  if (allStrings && sourceValues.length >= 1) {
201
253
  const stringSources = sourceValues as string[]
202
254
  const split = findPrefixNumberSuffix(stringSources)
203
- if (split) {
255
+ // A bare number as text ('5') is a number, not 'Item 5': one of them
256
+ // copies, as a numeric 5 does, and two or more were a progression above.
257
+ if (split && (split.prefix !== '' || split.suffix !== '')) {
204
258
  const step = split.numbers.length >= 2
205
259
  ? arithmeticStep(split.numbers) ?? 1
206
260
  : 1
@@ -7,11 +7,24 @@
7
7
  *
8
8
  * This mirrors the editor kit's `resolveMessages` pattern (editor-contract.ts) so
9
9
  * chrome and editors share one localization idiom.
10
+ *
11
+ * The chart panel's strings are the other half of `GridMessages`
12
+ * (`GridChartPanelMessages` in chart-panel-messages.ts): same map, same
13
+ * `localization.text`, but their English defaults load with the panel, not
14
+ * with every grid.
10
15
  */
11
16
  import { resolveMessages } from './editor-contract'
17
+ import type { GridChartPanelMessages } from './chart-panel-messages'
18
+
19
+ /**
20
+ * Every string `localization.text` accepts: the grid chrome's own
21
+ * ({@link GridChromeMessages}) and the chart panel's
22
+ * ({@link GridChartPanelMessages}).
23
+ */
24
+ export type GridMessages = GridChromeMessages & GridChartPanelMessages
12
25
 
13
26
  /** Every localizable chrome string, grouped by area in the comments. */
14
- export type GridMessages = {
27
+ export type GridChromeMessages = {
15
28
  // Empty / loading state
16
29
  noRows: string
17
30
  loading: string
@@ -102,7 +115,7 @@ export type GridMessages = {
102
115
  }
103
116
 
104
117
  /** English defaults - the literal strings the grid shipped before localization. */
105
- export const defaultGridMessages: GridMessages = {
118
+ export const defaultGridMessages: GridChromeMessages = {
106
119
  noRows: 'No rows to display.',
107
120
  loading: 'Loading grid data...',
108
121
  columns: 'Columns',
@@ -184,6 +197,6 @@ export const defaultGridMessages: GridMessages = {
184
197
  * values fall back to the default (via `resolveMessages`), so a partial map only
185
198
  * replaces the keys it sets.
186
199
  */
187
- export function resolveGridMessages(overrides?: Partial<GridMessages> | null): GridMessages {
200
+ export function resolveGridMessages(overrides?: Partial<GridMessages> | null): GridChromeMessages {
188
201
  return resolveMessages(defaultGridMessages, overrides)
189
202
  }