@svgrid/grid 2.6.22 → 2.7.0

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 (331) hide show
  1. package/CHANGELOG.md +88 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/GridMenus.svelte +184 -6
  6. package/dist/SvAutoComplete.svelte +169 -165
  7. package/dist/SvAvatar.svelte +75 -75
  8. package/dist/SvCalendar.svelte +503 -503
  9. package/dist/SvCarousel.svelte +141 -141
  10. package/dist/SvCheckBox.svelte +102 -102
  11. package/dist/SvCircularProgress.svelte +109 -109
  12. package/dist/SvColorInput.svelte +181 -181
  13. package/dist/SvComboBox.svelte +279 -275
  14. package/dist/SvContextMenu.svelte +116 -116
  15. package/dist/SvCountryInput.svelte +163 -163
  16. package/dist/SvDateRangeInput.svelte +25 -3
  17. package/dist/SvDrawer.svelte +254 -254
  18. package/dist/SvDropDownList.svelte +378 -374
  19. package/dist/SvDurationInput.svelte +126 -126
  20. package/dist/SvField.svelte +293 -293
  21. package/dist/SvForm.svelte +437 -437
  22. package/dist/SvGrid.controller.svelte.d.ts +48 -1
  23. package/dist/SvGrid.controller.svelte.js +214 -52
  24. package/dist/SvGrid.css +221 -0
  25. package/dist/SvGrid.helpers.d.ts +55 -0
  26. package/dist/SvGrid.helpers.js +83 -0
  27. package/dist/SvGrid.svelte +145 -0
  28. package/dist/SvGrid.types.d.ts +105 -0
  29. package/dist/SvGridCellEditor.svelte +744 -662
  30. package/dist/SvGridChart.svelte +1716 -1716
  31. package/dist/SvGridChartPanel.svelte +485 -485
  32. package/dist/SvGridChartView.svelte +70 -70
  33. package/dist/SvGridDropdown.svelte +728 -728
  34. package/dist/SvGridSelect.svelte +270 -266
  35. package/dist/SvGridSelect.svelte.d.ts +1 -1
  36. package/dist/SvGroupCell.svelte +116 -116
  37. package/dist/SvListBox.svelte +334 -326
  38. package/dist/SvMaskedInput.svelte +122 -122
  39. package/dist/SvMenu.svelte +124 -124
  40. package/dist/SvMenuList.svelte +146 -146
  41. package/dist/SvMultiSelect.svelte +293 -289
  42. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  43. package/dist/SvNumberInput.svelte +172 -168
  44. package/dist/SvOtpInput.svelte +158 -158
  45. package/dist/SvPasswordInput.svelte +151 -151
  46. package/dist/SvPhoneInput.svelte +133 -133
  47. package/dist/SvPopover.svelte +197 -197
  48. package/dist/SvProgress.svelte +116 -116
  49. package/dist/SvRadioGroup.svelte +107 -107
  50. package/dist/SvRating.svelte +112 -112
  51. package/dist/SvResult.svelte +73 -73
  52. package/dist/SvRichCell.svelte +125 -0
  53. package/dist/SvRichCell.svelte.d.ts +15 -0
  54. package/dist/SvRichText.svelte +211 -211
  55. package/dist/SvRowGroupPanel.svelte +170 -170
  56. package/dist/SvScrollArea.svelte +61 -61
  57. package/dist/SvSlider.svelte +203 -203
  58. package/dist/SvSwitchButton.svelte +108 -108
  59. package/dist/SvTagsInput.svelte +115 -115
  60. package/dist/SvTextArea.svelte +5 -1
  61. package/dist/SvTextInput.svelte +147 -147
  62. package/dist/SvTimePicker.svelte +245 -245
  63. package/dist/SvToaster.svelte +159 -159
  64. package/dist/SvToggleButton.svelte +85 -85
  65. package/dist/SvTooltip.svelte +161 -161
  66. package/dist/SvTour.svelte +208 -208
  67. package/dist/SvTree.svelte +444 -444
  68. package/dist/SvTreeSelect.svelte +239 -235
  69. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  70. package/dist/builtin-editors.d.ts +2 -2
  71. package/dist/builtin-editors.js +19 -3
  72. package/dist/cdn/GridMenus-C4oLsJyj.js +600 -0
  73. package/dist/cdn/GridMenus-yEivjNwl.js +601 -0
  74. package/dist/cdn/SvDateRangeInput-Bh0A0JkF.js +199 -0
  75. package/dist/cdn/SvDateRangeInput-DflbiP7N.js +198 -0
  76. package/dist/cdn/SvDateTimePicker-BWwpfB_o.js +812 -0
  77. package/dist/cdn/SvDateTimePicker-Bivn8dAP.js +816 -0
  78. package/dist/cdn/SvGridCellEditor-AXL8cHGO.js +550 -0
  79. package/dist/cdn/SvGridCellEditor-Ba7rY3eu.js +549 -0
  80. package/dist/cdn/{SvGridChart-BwVos976.js → SvGridChart-Bs2GIR2Q.js} +1 -1
  81. package/dist/cdn/{SvGridChart-BEJmNNx9.js → SvGridChart-CwhFz7GV.js} +1 -1
  82. package/dist/cdn/{SvGridChartPanel-BOknOkrP.js → SvGridChartPanel-1WXuStzM.js} +1 -1
  83. package/dist/cdn/{SvGridChartPanel-D2PjII4O.js → SvGridChartPanel-e5_nqNP9.js} +1 -1
  84. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-SmPW10dI.js} +1 -1
  85. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-eSPuJE6g.js} +1 -1
  86. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-B1ZcLpgs.js} +1 -1
  87. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CtdbHcIS.js} +1 -1
  88. package/dist/cdn/date-format-CcP1tafP.js +1395 -0
  89. package/dist/cdn/date-format-DZT7T1wf.js +1393 -0
  90. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  91. package/dist/cdn/{src-C9Hihx1W.js → src-COC0vnpg.js} +7233 -6701
  92. package/dist/cdn/{src-D1lXwq1l.js → src-pYGV2rMR.js} +5747 -5215
  93. package/dist/cdn/svgrid.js +15 -13
  94. package/dist/cdn/svgrid.svelte-external.js +15 -13
  95. package/dist/cell-values.d.ts +16 -0
  96. package/dist/cell-values.js +28 -0
  97. package/dist/chart-export.js +8 -8
  98. package/dist/clipboard.d.ts +22 -0
  99. package/dist/clipboard.js +405 -27
  100. package/dist/editing.js +12 -7
  101. package/dist/editors/cell-editors.d.ts +11 -0
  102. package/dist/editors/cell-editors.js +18 -1
  103. package/dist/grid-messages.d.ts +15 -0
  104. package/dist/grid-messages.js +15 -0
  105. package/dist/index.d.ts +4 -0
  106. package/dist/index.js +4 -0
  107. package/dist/list-option.d.ts +6 -0
  108. package/dist/menus.js +40 -7
  109. package/dist/sanitize-html.d.ts +37 -0
  110. package/dist/sanitize-html.js +234 -0
  111. package/dist/selection-bar-view.svelte.d.ts +25 -0
  112. package/dist/selection-bar-view.svelte.js +13 -0
  113. package/dist/selection.d.ts +2 -1
  114. package/dist/selection.js +129 -13
  115. package/package.json +11 -11
  116. package/src/FlexRender.svelte +96 -96
  117. package/src/GridFooter.svelte +181 -181
  118. package/src/GridMenus.svelte +184 -6
  119. package/src/SvAutoComplete.svelte +169 -165
  120. package/src/SvAvatar.svelte +75 -75
  121. package/src/SvCalendar.svelte +503 -503
  122. package/src/SvCalendar.test.ts +226 -226
  123. package/src/SvCarousel.svelte +141 -141
  124. package/src/SvCheckBox.svelte +102 -102
  125. package/src/SvCircularProgress.svelte +109 -109
  126. package/src/SvColorInput.svelte +181 -181
  127. package/src/SvComboBox.svelte +279 -275
  128. package/src/SvContextMenu.svelte +116 -116
  129. package/src/SvCountryInput.svelte +163 -163
  130. package/src/SvDateRangeInput.svelte +25 -3
  131. package/src/SvDrawer.svelte +254 -254
  132. package/src/SvDropDownList.svelte +378 -374
  133. package/src/SvDurationInput.svelte +126 -126
  134. package/src/SvField.svelte +293 -293
  135. package/src/SvForm.svelte +437 -437
  136. package/src/SvForm.test.ts +411 -411
  137. package/src/SvGrid.controller.svelte.ts +251 -61
  138. package/src/SvGrid.css +221 -0
  139. package/src/SvGrid.helpers.ts +87 -0
  140. package/src/SvGrid.svelte +145 -0
  141. package/src/SvGrid.types.ts +2092 -1980
  142. package/src/SvGridCellEditor.svelte +744 -662
  143. package/src/SvGridChart.svelte +1716 -1716
  144. package/src/SvGridChartPanel.svelte +485 -485
  145. package/src/SvGridChartView.svelte +70 -70
  146. package/src/SvGridDropdown.svelte +728 -728
  147. package/src/SvGridSelect.svelte +270 -266
  148. package/src/SvGroupCell.svelte +116 -116
  149. package/src/SvListBox.svelte +334 -326
  150. package/src/SvMaskedInput.svelte +122 -122
  151. package/src/SvMenu.svelte +124 -124
  152. package/src/SvMenu.test.ts +97 -97
  153. package/src/SvMenuList.svelte +146 -146
  154. package/src/SvMultiSelect.svelte +293 -289
  155. package/src/SvNumberInput.svelte +172 -168
  156. package/src/SvOtpInput.svelte +158 -158
  157. package/src/SvPasswordInput.svelte +151 -151
  158. package/src/SvPhoneInput.svelte +133 -133
  159. package/src/SvPopover.svelte +197 -197
  160. package/src/SvProgress.svelte +116 -116
  161. package/src/SvRadioGroup.svelte +107 -107
  162. package/src/SvRating.svelte +112 -112
  163. package/src/SvResult.svelte +73 -73
  164. package/src/SvRichCell.svelte +125 -0
  165. package/src/SvRichText.svelte +211 -211
  166. package/src/SvRowGroupPanel.svelte +170 -170
  167. package/src/SvScrollArea.svelte +61 -61
  168. package/src/SvSlider.svelte +203 -203
  169. package/src/SvSwitchButton.svelte +108 -108
  170. package/src/SvTagsInput.svelte +115 -115
  171. package/src/SvTextArea.svelte +5 -1
  172. package/src/SvTextInput.svelte +147 -147
  173. package/src/SvTimePicker.svelte +245 -245
  174. package/src/SvToaster.svelte +159 -159
  175. package/src/SvToaster.test.ts +95 -95
  176. package/src/SvToggleButton.svelte +85 -85
  177. package/src/SvTooltip.svelte +161 -161
  178. package/src/SvTour.svelte +208 -208
  179. package/src/SvTree.svelte +444 -444
  180. package/src/SvTreeSelect.svelte +239 -235
  181. package/src/a11y/dismissable.test.ts +119 -119
  182. package/src/a11y/dismissable.ts +114 -114
  183. package/src/a11y.contract.test.ts +49 -49
  184. package/src/a11y.test.ts +59 -59
  185. package/src/a11y.ts +61 -61
  186. package/src/ai.test.ts +502 -502
  187. package/src/ai.ts +1419 -1419
  188. package/src/build-api.coverage.test.ts +633 -633
  189. package/src/build-api.ts +846 -846
  190. package/src/builtin-editors.grid.test.ts +83 -83
  191. package/src/builtin-editors.test.ts +18 -1
  192. package/src/builtin-editors.ts +19 -3
  193. package/src/cell-formatting.ts +171 -171
  194. package/src/cell-render.test.ts +513 -513
  195. package/src/cell-render.ts +496 -496
  196. package/src/cell-values.ts +148 -118
  197. package/src/chart-export.ts +202 -202
  198. package/src/chart-view.svelte.ts +36 -36
  199. package/src/chart.ts +2321 -2321
  200. package/src/clipboard.test.ts +93 -20
  201. package/src/clipboard.ts +433 -33
  202. package/src/collaboration.test.ts +104 -104
  203. package/src/collaboration.ts +167 -167
  204. package/src/column-groups.ts +78 -78
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1865 -1865
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCombobox.svelte.ts +191 -191
  209. package/src/createCountryInput.svelte.ts +157 -157
  210. package/src/createDropdownList.svelte.ts +168 -168
  211. package/src/createForm.svelte.ts +386 -386
  212. package/src/createGrid.svelte.ts +42 -42
  213. package/src/createGrid.test.ts +10 -10
  214. package/src/createGridState.svelte.ts +17 -17
  215. package/src/createListbox.svelte.ts +250 -250
  216. package/src/createMenu.svelte.ts +224 -224
  217. package/src/createPopoverSelect.svelte.ts +213 -213
  218. package/src/createSlider.svelte.ts +191 -191
  219. package/src/createTooltip.svelte.ts +144 -144
  220. package/src/createTree.svelte.ts +322 -322
  221. package/src/date-string-column.test.ts +87 -0
  222. package/src/datetime/date-core.ts +206 -206
  223. package/src/datetime/date-restrict.ts +61 -61
  224. package/src/datetime/timezone.ts +135 -135
  225. package/src/dock-manager-model.ts +596 -596
  226. package/src/dock-model.ts +374 -374
  227. package/src/editing.test.ts +974 -949
  228. package/src/editing.ts +609 -606
  229. package/src/editor-block-prop.test.ts +132 -0
  230. package/src/editor-contract.ts +171 -171
  231. package/src/editor-registry.grid.test.ts +144 -144
  232. package/src/editor-registry.ts +122 -122
  233. package/src/editors/cell-editors.ts +27 -1
  234. package/src/export-data-api.test.ts +126 -126
  235. package/src/export-format.test.ts +107 -107
  236. package/src/export-format.ts +601 -601
  237. package/src/filter-operators.ts +160 -160
  238. package/src/filtering/excel-filters.ts +325 -325
  239. package/src/flex-render.ts +3 -3
  240. package/src/form-field.ts +127 -127
  241. package/src/grid-messages.ts +34 -0
  242. package/src/group-display.test.ts +167 -167
  243. package/src/group-display.ts +200 -200
  244. package/src/headless.ts +87 -87
  245. package/src/index.ts +13 -0
  246. package/src/js-scroller.svelte.ts +173 -173
  247. package/src/keyboard-handlers.ts +270 -270
  248. package/src/keyboard.test.ts +59 -59
  249. package/src/keyboard.ts +97 -97
  250. package/src/list-nav.test.ts +49 -49
  251. package/src/list-nav.ts +29 -29
  252. package/src/list-option.test.ts +56 -56
  253. package/src/list-option.ts +179 -173
  254. package/src/menus.test.ts +43 -0
  255. package/src/menus.ts +597 -567
  256. package/src/merge-objects.ts +48 -48
  257. package/src/move-cells.test.ts +850 -0
  258. package/src/number-editor-literal.test.ts +84 -0
  259. package/src/number-editor.grid.test.ts +160 -0
  260. package/src/overlays.test.ts +90 -90
  261. package/src/positioning.ts +268 -268
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag-touch.ts +211 -211
  264. package/src/row-drag.test.ts +353 -353
  265. package/src/row-drag.ts +415 -415
  266. package/src/row-resize.test.ts +524 -524
  267. package/src/row-resize.ts +228 -228
  268. package/src/sanitize-html.test.ts +182 -0
  269. package/src/sanitize-html.ts +235 -0
  270. package/src/scheduler-ical.ts +181 -181
  271. package/src/scheduler-model.test.ts +562 -562
  272. package/src/scheduler-model.ts +873 -873
  273. package/src/selection-bar-view.svelte.ts +36 -0
  274. package/src/selection.test.ts +900 -743
  275. package/src/selection.ts +131 -13
  276. package/src/server-data-source.test.ts +383 -383
  277. package/src/server-data-source.ts +469 -469
  278. package/src/sparkline.test.ts +68 -68
  279. package/src/sparkline.ts +169 -169
  280. package/src/spreadsheet.test.ts +488 -488
  281. package/src/spreadsheet.ts +312 -312
  282. package/src/static-functions.ts +11 -11
  283. package/src/subscribe.ts +38 -38
  284. package/src/summaries.ts +113 -113
  285. package/src/svgrid-wrapper.types.ts +563 -563
  286. package/src/svgrid.async-editor-options.test.ts +273 -273
  287. package/src/svgrid.auto-row-height.test.ts +204 -204
  288. package/src/svgrid.behavior.test.ts +910 -910
  289. package/src/svgrid.charting.test.ts +534 -534
  290. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  291. package/src/svgrid.context-menu.test.ts +147 -147
  292. package/src/svgrid.features.test.ts +157 -157
  293. package/src/svgrid.filter-depth.test.ts +163 -163
  294. package/src/svgrid.filter-menu-listbox.svelte.test.ts +377 -331
  295. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  296. package/src/svgrid.grand-total.test.ts +188 -188
  297. package/src/svgrid.group-display-mode.test.ts +171 -171
  298. package/src/svgrid.group-footers.test.ts +121 -121
  299. package/src/svgrid.group-pagination.test.ts +153 -153
  300. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  301. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  302. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  303. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  304. package/src/svgrid.tree-data.test.ts +186 -186
  305. package/src/svgrid.upsell-license.test.ts +117 -0
  306. package/src/svgrid.wrapper.test.ts +63 -63
  307. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  308. package/src/test-setup.ts +62 -62
  309. package/src/themes/index.ts +288 -288
  310. package/src/toast-store.svelte.ts +250 -250
  311. package/src/toast-store.test.ts +147 -147
  312. package/src/tree-row-model.test.ts +168 -168
  313. package/src/ui-buttons.test.ts +144 -144
  314. package/src/ui-inputs.test.ts +118 -118
  315. package/src/ui-localization.test.ts +113 -113
  316. package/src/ui-range.test.ts +70 -70
  317. package/src/ui-selection.test.ts +155 -155
  318. package/src/ui-tier1.test.ts +142 -142
  319. package/src/virtual.test.ts +88 -88
  320. package/src/virtualization/column-virtualizer.test.ts +27 -27
  321. package/src/virtualization/column-virtualizer.ts +30 -30
  322. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  323. package/src/virtualization/types.ts +30 -30
  324. package/src/virtualization/virtualizer.test.ts +47 -47
  325. package/src/virtualization/virtualizer.ts +322 -322
  326. package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
  327. package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
  328. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  329. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  330. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  331. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
package/src/editing.ts CHANGED
@@ -1,606 +1,609 @@
1
- // editing handlers extracted from the controller. Imperative event handlers
2
- // reading/writing controller state via the `ctx` handle; the reactive core
3
- // ($state/$derived/$effect) stays in the controller.
4
- import {
5
- parseEditorValue,
6
- type CellContext,
7
- type CellEditorType,
8
- type Column,
9
- type Row,
10
- type RowData,
11
- type TableFeatures,
12
- } from "./index";
13
- import "./sv-grid-scrollbar";
14
- import { getNextActiveCell } from "./keyboard";
15
- import {
16
- getCellKey,
17
- toValueArray,
18
- } from "./SvGrid.helpers";
19
- import {
20
- getColumnBaseValue,
21
- isGroupRow,
22
- } from "./cell-values";
23
-
24
- type HistoryStep = {
25
- rowId: string;
26
- columnId: string;
27
- field: string;
28
- before: unknown;
29
- after: unknown;
30
- };
31
-
32
- export function createEditing<
33
- TFeatures extends TableFeatures = TableFeatures,
34
- TData extends RowData = RowData,
35
- >(ctx: any) {
36
- /**
37
- * Resolve `columnDef.editable` for one row × column pair.
38
- *
39
- * - `undefined` / `true` → editable
40
- * - `false` → not editable (column-level lockdown, fastest path)
41
- * - functioncall it with the cell's context and honour the
42
- * boolean it returns (cell-level lockdown - used for things like
43
- * "this field is owned by the server, this one isn't, by row")
44
- *
45
- * Called from every editing entry point: double-click, type-to-edit,
46
- * fill-handle drag, Delete-clear, clipboard paste. A central helper
47
- * keeps the cell-level callback signature consistent and saves a
48
- * lookup of CellContext at each site.
49
- */
50
- function isCellEditable(column: Column<TData>, row?: Row<TData>): boolean {
51
- const editable = column.columnDef.editable;
52
- if (editable === false) return false;
53
- if (typeof editable !== "function") return true;
54
- if (!row) return true;
55
- const cellCtx: CellContext<TData> = {
56
- cell: {
57
- id: `${row.id}_${column.id}`,
58
- row,
59
- column,
60
- getValue: () => getColumnBaseValue(row, column),
61
- getContext: () => cellCtx,
62
- },
63
- row,
64
- column,
65
- table: ctx.grid,
66
- getValue: () => getColumnBaseValue(row, column),
67
- };
68
- try {
69
- return editable(cellCtx) !== false;
70
- } catch {
71
- // A throwing predicate shouldn't crash the grid - log and treat
72
- // as not-editable so any error biases toward safety.
73
- return false;
74
- }
75
- }
76
-
77
- /** Variant that takes raw row + column indices for the rare callers
78
- * (fill-handle, paste, Delete) that already work in index-space. */
79
- function isCellEditableAt(rowIndex: number, colIndex: number): boolean {
80
- const row = ctx.allRows[rowIndex];
81
- const column = ctx.allColumns[colIndex];
82
- if (!column) return false;
83
- return isCellEditable(column, row);
84
- }
85
-
86
- function getRowColumnValue(row: Row<TData>, columnId: string) {
87
- const column = ctx.allColumns.find((entry: any) => entry.id === columnId);
88
- return column
89
- ? getColumnBaseValue(row, column)
90
- : row.getCellValueByColumnId(columnId);
91
- }
92
-
93
- function getCellDisplayValue(
94
- rowId: string,
95
- columnId: string,
96
- baseValue: unknown,
97
- ) {
98
- const key = getCellKey(rowId, columnId);
99
- return key in ctx.editedCellValues ? ctx.editedCellValues[key] : baseValue;
100
- }
101
-
102
- /** Start editing seeded with a typed character (Excel-style type-to-edit). */
103
- function startEditingWithChar(
104
- rowIndex: number,
105
- colIndex: number,
106
- char: string,
107
- ): boolean {
108
- if (!ctx.editingEnabled) return false;
109
- const row = ctx.allRows[rowIndex];
110
- const column = ctx.allColumns[colIndex];
111
- if (!row || !column || isGroupRow(row)) return false;
112
- if (!isCellEditable(column, row)) return false;
113
- const editorType = (column.columnDef.editorType ??
114
- "text") as CellEditorType;
115
- if (
116
- editorType === "checkbox" ||
117
- editorType === "date" ||
118
- editorType === "datetime" ||
119
- editorType === "list" ||
120
- editorType === "chips"
121
- ) {
122
- return false;
123
- }
124
- if (editorType === "number" && !/[0-9.+-]/.test(char)) return false;
125
- ctx.editorSelectAll = false;
126
- ctx.editingCell = {
127
- rowId: row.id,
128
- columnId: column.id,
129
- editorType,
130
- value: char,
131
- rowRef: row.original,
132
- };
133
- ctx.setActiveCell(rowIndex, colIndex);
134
- ctx.setSelection(rowIndex, colIndex);
135
- return true;
136
- }
137
-
138
- function saveEditingCell() {
139
- if (!ctx.editingCell) return;
140
- const editing = ctx.editingCell;
141
- // Resolve the row this edit belongs to. `allRows` is only the current page,
142
- // so fall back to the full filtered model, then to the data object captured
143
- // when the edit started. Without that last hop an edit is silently dropped
144
- // when a filter typed mid-edit hides the row from both models (#49).
145
- const row =
146
- ctx.allRows.find((entry: any) => entry.id === editing.rowId) ??
147
- ctx.allRowsBeforePagination.find((entry: any) => entry.id === editing.rowId);
148
- const rowData = (row?.original ?? editing.rowRef) as
149
- | Record<string, unknown>
150
- | undefined;
151
- const column = ctx.allColumns.find(
152
- (entry: any) => entry.id === ctx.editingCell?.columnId,
153
- );
154
- const parsedValue = parseEditorValue(
155
- ctx.editingCell.editorType,
156
- ctx.editingCell.value,
157
- {
158
- multiple: column?.columnDef.editorMultiple === true,
159
- },
160
- );
161
- let oldValue: unknown = undefined;
162
- let finalValue: unknown = parsedValue;
163
- if (rowData && column?.columnDef.field) {
164
- oldValue = rowData[column.columnDef.field];
165
- // Per-column valueParser refines the type-coerced value before storing.
166
- const parser = (
167
- column.columnDef as { valueParser?: (p: unknown) => unknown }
168
- ).valueParser;
169
- if (parser) {
170
- finalValue = parser({
171
- newValue: parsedValue,
172
- oldValue,
173
- rawInput: ctx.editingCell.value,
174
- data: rowData,
175
- columnId: ctx.editingCell.columnId,
176
- });
177
- }
178
- rowData[column.columnDef.field] = finalValue;
179
- }
180
- const key = getCellKey(ctx.editingCell.rowId, ctx.editingCell.columnId);
181
- ctx.editedCellValues = {
182
- ...ctx.editedCellValues,
183
- [key]: finalValue,
184
- };
185
- ctx.grid.store.setState((prev: any) => ({ ...prev }));
186
- // Record into the history at the current pointer. Any forward
187
- // history (steps the user could have redone) is truncated - this
188
- // is the standard "edit invalidates redo" rule.
189
- if (oldValue !== finalValue && rowData && column?.columnDef.field) {
190
- const step: HistoryStep = {
191
- rowId: ctx.editingCell.rowId,
192
- columnId: ctx.editingCell.columnId,
193
- field: column.columnDef.field as string,
194
- before: oldValue,
195
- after: finalValue,
196
- }
197
- const truncated = ctx.history.slice(0, ctx.historyPtr + 1)
198
- truncated.push(step)
199
- // Cap the buffer at UNDO_LIMIT; drop the OLDEST entries so the
200
- // pointer stays valid relative to the newest steps.
201
- if (truncated.length > ctx.UNDO_LIMIT) {
202
- const drop = truncated.length - ctx.UNDO_LIMIT
203
- ctx.history = truncated.slice(drop)
204
- ctx.historyPtr = ctx.history.length - 1
205
- } else {
206
- ctx.history = truncated
207
- ctx.historyPtr = ctx.history.length - 1
208
- }
209
- ctx.historyVersion += 1
210
- }
211
- // Notify the consumer AFTER the row has been updated so any callback-
212
- // driven recompute (cascade totals, server save, undo stack) sees the
213
- // post-write state. `rowIndex` matches the position in `props.data`.
214
- if (ctx.props.onCellValueChange && rowData && column) {
215
- const rowIndex = ctx.internalData.indexOf(rowData as TData);
216
- ctx.props.onCellValueChange({
217
- rowIndex,
218
- columnId: column.id,
219
- oldValue,
220
- newValue: finalValue,
221
- row: rowData as TData,
222
- });
223
- }
224
- ctx.editingCell = null;
225
- }
226
-
227
- /** Apply an undo / redo step directly to the underlying row, bypassing
228
- * the editor pipeline so we don't accidentally re-push to the stack. */
229
- function applyHistoryStep(step: HistoryStep, direction: 'undo' | 'redo') {
230
- const row = ctx.allRows.find((r: any) => r.id === step.rowId)
231
- const col = ctx.allColumns.find((c: any) => c.id === step.columnId)
232
- if (!row?.original || !col) return
233
- const value = direction === 'undo' ? step.before : step.after
234
- ;(row.original as Record<string, unknown>)[step.field] = value
235
- const key = getCellKey(step.rowId, step.columnId)
236
- ctx.editedCellValues = { ...ctx.editedCellValues, [key]: value }
237
- ctx.grid.store.setState((prev: any) => ({ ...prev }))
238
- if (ctx.props.onCellValueChange) {
239
- const rowIndex = ctx.internalData.indexOf(row.original as TData)
240
- ctx.props.onCellValueChange({
241
- rowIndex,
242
- columnId: step.columnId,
243
- oldValue: direction === 'undo' ? step.after : step.before,
244
- newValue: value,
245
- row: row.original as TData,
246
- })
247
- }
248
- }
249
-
250
- function updateEditingCellValue(value: unknown) {
251
- ctx.editingCell = ctx.editingCell ? { ...ctx.editingCell, value } : ctx.editingCell;
252
- }
253
-
254
- /**
255
- * Commit the open editor and move the active cell one column along, wrapping
256
- * at row boundaries the way Excel does. Editors `stopPropagation()` every key
257
- * so the grid's own Tab handler never sees this one - without the explicit
258
- * move, Tab committed the edit and then let the browser walk focus out of the
259
- * grid entirely (#48). Shared by every editor type, including the textarea.
260
- */
261
- function commitAndMoveByTab(shiftKey: boolean) {
262
- const active = ctx.activeCell;
263
- saveEditingCell();
264
- ctx.gridRootEl?.focus({ preventScroll: true });
265
- if (!active) return;
266
- const next = getNextActiveCell(active, shiftKey ? "tabPrev" : "tabNext", {
267
- maxRow: ctx.allRows.length - 1,
268
- maxCol: ctx.allColumns.length - 1,
269
- });
270
- ctx.setActiveCell(next.rowIndex, next.colIndex);
271
- ctx.setSelection(next.rowIndex, next.colIndex);
272
- ctx.scrollActiveCellIntoView(next.rowIndex, next.colIndex);
273
- }
274
-
275
- function onEditorKeyDown(event: KeyboardEvent) {
276
- event.stopPropagation();
277
- if (event.key === "Enter") {
278
- event.preventDefault();
279
- saveEditingCell();
280
- ctx.gridRootEl?.focus({ preventScroll: true });
281
- } else if (event.key === "Tab") {
282
- event.preventDefault();
283
- commitAndMoveByTab(event.shiftKey);
284
- } else if (event.key === "Escape") {
285
- event.preventDefault();
286
- ctx.editingCell = null;
287
- ctx.gridRootEl?.focus({ preventScroll: true });
288
- }
289
- }
290
-
291
- function focusOnMount(node: HTMLInputElement | HTMLTextAreaElement) {
292
- const selectAll = ctx.editorSelectAll;
293
- requestAnimationFrame(() => {
294
- node.focus({ preventScroll: true });
295
- try {
296
- if (selectAll) {
297
- node.select();
298
- } else {
299
- const end = node.value.length;
300
- node.setSelectionRange(end, end);
301
- }
302
- } catch {
303
- /* date/number inputs may not support text selection */
304
- }
305
- });
306
- }
307
-
308
- function onCellDoubleClick(rowIndex: number, colIndex: number) {
309
- if (!ctx.editingEnabled) return;
310
- const row = ctx.allRows[rowIndex];
311
- const column = ctx.allColumns[colIndex];
312
- if (!row || !column) return;
313
- if (isGroupRow(row)) return;
314
- // Full-row editing: put the whole row into edit instead of one cell.
315
- if (ctx.props.fullRowEditing) {
316
- if (ctx.fullRowEdit?.rowId === row.id) return;
317
- startFullRowEdit(rowIndex);
318
- return;
319
- }
320
- if (!isCellEditable(column, row)) return;
321
- if (ctx.editingCell?.rowId === row.id && ctx.editingCell?.columnId === column.id)
322
- return;
323
- const editorType = (column.columnDef.editorType ??
324
- "text") as CellEditorType;
325
- ctx.editorSelectAll = true;
326
- let initialValue = getCellDisplayValue(
327
- row.id,
328
- column.id,
329
- row.getCellValueByColumnId(column.id),
330
- );
331
- if (editorType === "list" || editorType === "chips") {
332
- if (column.columnDef.editorMultiple) {
333
- // Seed with an array so the editor can mutate slot-by-slot.
334
- initialValue = toValueArray(initialValue);
335
- } else if (Array.isArray(initialValue)) {
336
- initialValue = initialValue[0] ?? "";
337
- }
338
- }
339
- ctx.editingCell = {
340
- rowId: row.id,
341
- columnId: column.id,
342
- editorType,
343
- value: initialValue,
344
- rowRef: row.original,
345
- };
346
- ctx.setActiveCell(rowIndex, colIndex);
347
- ctx.setSelection(rowIndex, colIndex);
348
- }
349
-
350
- async function pasteFromClipboard() {
351
- // Preferred path: the async Clipboard API. Requires a secure context
352
- // (HTTPS or localhost) AND read permission; it also works on Firefox,
353
- // where the native `paste` event does NOT fire on a non-editable element.
354
- // On plain HTTP (a XAMPP/Apache LAN host) `navigator.clipboard` is
355
- // undefined - there the Ctrl+V keydown handler skips preventDefault and
356
- // lets the browser deliver a native `paste` event to `onGridPaste`.
357
- if (!navigator.clipboard?.readText) return;
358
- let text: string;
359
- try {
360
- text = await navigator.clipboard.readText();
361
- } catch {
362
- return;
363
- }
364
- applyPastedText(text);
365
- }
366
-
367
- /**
368
- * Native `paste` ClipboardEvent fallback (insecure-context path). A real
369
- * Ctrl/Cmd+V on the focused grid root fires this with `clipboardData`
370
- * readable even over plain HTTP and with no permission prompt. Wired on the
371
- * grid root in SvGrid.svelte; the keydown handler lets the gesture through
372
- * (no preventDefault) whenever the async API is unavailable.
373
- */
374
- function onGridPaste(event: ClipboardEvent) {
375
- // A cell is being edited: the focused editor input handles its own paste
376
- // natively. Never hijack it for a range paste - doing so (on Firefox /
377
- // insecure contexts, where the async Clipboard API is unavailable) would
378
- // preventDefault the editor's paste and write to the data cell instead,
379
- // so the edit "wouldn't update".
380
- if (ctx.editingCell) return;
381
- // When the async API is available the keydown handler already pasted via
382
- // pasteFromClipboard(); ignore the (suppressed) native event so we don't
383
- // paste twice.
384
- if (typeof navigator.clipboard?.readText === "function") return;
385
- const text = event.clipboardData?.getData("text/plain") ?? "";
386
- if (!text) return;
387
- event.preventDefault();
388
- applyPastedText(text);
389
- }
390
-
391
- function applyPastedText(text: string) {
392
- const anchor = ctx.selectionRange.anchor ?? ctx.grid.getState().activeCell;
393
- if (!anchor) return;
394
- const focus = ctx.selectionRange.focus ?? anchor;
395
- const startRow = Math.min(anchor.rowIndex, focus.rowIndex);
396
- const startCol = Math.min(anchor.colIndex, focus.colIndex);
397
- const endRow = Math.max(anchor.rowIndex, focus.rowIndex);
398
- const endCol = Math.max(anchor.colIndex, focus.colIndex);
399
- const lines = text.replace(/\r\n/g, "\n").split("\n");
400
- if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
401
- if (!lines.length) return;
402
-
403
- // Excel/Sheets behaviour: when the clipboard holds a single cell and
404
- // the selection is a range, fill the entire range with that value.
405
- // For larger clipboards (multi-row/col TSV), paste at the top-left
406
- // corner and let the clipboard determine the span (the original
407
- // behaviour).
408
- const clipboardIsSingleCell =
409
- lines.length === 1 && (lines[0]?.split("\t").length ?? 0) === 1;
410
- const selectionIsRange = startRow !== endRow || startCol !== endCol;
411
- const fillRange = clipboardIsSingleCell && selectionIsRange;
412
-
413
- const rowSpan = fillRange ? endRow - startRow + 1 : lines.length;
414
- const colSpan = fillRange
415
- ? endCol - startCol + 1
416
- : Math.max(...lines.map((l) => l.split("\t").length));
417
-
418
- const next = ctx.internalData.slice() as Array<TData>;
419
- for (let i = 0; i < rowSpan; i += 1) {
420
- const targetRowIndex = startRow + i;
421
- const row = ctx.allRows[targetRowIndex];
422
- if (!row || isGroupRow(row)) continue;
423
- // Map the visible row back to its index in the data array via row.id (default = String(index)).
424
- const dataIndex = Number(row.id);
425
- if (
426
- !Number.isInteger(dataIndex) ||
427
- dataIndex < 0 ||
428
- dataIndex >= next.length
429
- )
430
- continue;
431
- const originalRow = next[dataIndex];
432
- if (!originalRow) continue;
433
- const sourceCells = fillRange
434
- ? null
435
- : lines[i]?.split("\t") ?? [];
436
- const updated: Record<string, unknown> = {
437
- ...(originalRow as Record<string, unknown>),
438
- };
439
- for (let j = 0; j < colSpan; j += 1) {
440
- const column = ctx.allColumns[startCol + j];
441
- if (!column?.columnDef.field) continue;
442
- if (!isCellEditableAt(targetRowIndex, startCol + j)) continue;
443
- const editorType = (column.columnDef.editorType ??
444
- "text") as CellEditorType;
445
- const raw = fillRange ? lines[0]! : sourceCells?.[j] ?? "";
446
- updated[column.columnDef.field] = parseEditorValue(editorType, raw);
447
- }
448
- next[dataIndex] = updated as TData;
449
- }
450
- ctx.internalData = next;
451
- ctx.grid.store.setState((prev: any) => ({ ...prev }));
452
- }
453
-
454
- /** Programmatically begin editing a cell (mirrors a double-click). */
455
- function startEditing(rowIndex: number, columnId: string): boolean {
456
- if (!ctx.editingEnabled) return false;
457
- const row = ctx.allRows[rowIndex];
458
- if (!row || isGroupRow(row)) return false;
459
- const colIndex = ctx.allColumns.findIndex((c: any) => c.id === columnId);
460
- if (colIndex < 0) return false;
461
- const column = ctx.allColumns[colIndex];
462
- if (!isCellEditable(column, row)) return false;
463
- if (
464
- ctx.editingCell?.rowId === row.id &&
465
- ctx.editingCell?.columnId === columnId
466
- )
467
- return true;
468
- onCellDoubleClick(rowIndex, colIndex);
469
- return (
470
- ctx.editingCell?.rowId === row.id &&
471
- ctx.editingCell?.columnId === columnId
472
- );
473
- }
474
-
475
- /** Commit (default) or cancel the active edit, if any. */
476
- function stopEditing(cancel = false): boolean {
477
- if (!ctx.editingCell) return false;
478
- if (cancel) {
479
- ctx.editingCell = null;
480
- } else {
481
- saveEditingCell();
482
- }
483
- return true;
484
- }
485
-
486
- // ---- Full-row editing -------------------------------------------------
487
- /** Put the whole row into edit mode, seeding a draft per editable column. */
488
- function startFullRowEdit(rowIndex: number): boolean {
489
- if (!ctx.editingEnabled || !ctx.props.fullRowEditing) return false;
490
- const row = ctx.allRows[rowIndex];
491
- if (!row || isGroupRow(row)) return false;
492
- if (ctx.fullRowEdit && ctx.fullRowEdit.rowId !== row.id) commitFullRowEdit();
493
- ctx.editingCell = null; // never both at once
494
- const draft: Record<string, unknown> = {};
495
- for (const column of ctx.allColumns) {
496
- if (!column.columnDef.field) continue;
497
- if (!isCellEditable(column, row)) continue;
498
- draft[column.id] = getCellDisplayValue(
499
- row.id,
500
- column.id,
501
- row.getCellValueByColumnId(column.id),
502
- );
503
- }
504
- ctx.fullRowEdit = { rowId: row.id, draft };
505
- ctx.setActiveCell(rowIndex, Math.max(0, ctx.allColumns.findIndex((c: any) => c.id in draft)));
506
- return true;
507
- }
508
-
509
- /** Stage one column's draft value during full-row editing. */
510
- function setFullRowDraft(columnId: string, value: unknown): void {
511
- const fr = ctx.fullRowEdit;
512
- if (!fr) return;
513
- ctx.fullRowEdit = { rowId: fr.rowId, draft: { ...fr.draft, [columnId]: value } };
514
- }
515
-
516
- /** Commit every drafted column of the full-row edit in one data update. */
517
- function commitFullRowEdit(): void {
518
- const fr = ctx.fullRowEdit;
519
- if (!fr) return;
520
- const row = ctx.allRows.find((r: any) => r.id === fr.rowId);
521
- if (!row?.original) {
522
- ctx.fullRowEdit = null;
523
- return;
524
- }
525
- const changed: Array<{ columnId: string; field: string; before: unknown; after: unknown }> = [];
526
- for (const column of ctx.allColumns) {
527
- if (!(column.id in fr.draft)) continue;
528
- const field = column.columnDef.field as string | undefined;
529
- if (!field) continue;
530
- const editorType = (column.columnDef.editorType ?? "text") as CellEditorType;
531
- const parsed = parseEditorValue(editorType, fr.draft[column.id], {
532
- multiple: column.columnDef.editorMultiple === true,
533
- });
534
- const oldValue = (row.original as Record<string, unknown>)[field];
535
- const parser = (column.columnDef as { valueParser?: (p: unknown) => unknown }).valueParser;
536
- const finalValue = parser
537
- ? parser({
538
- newValue: parsed,
539
- oldValue,
540
- rawInput: String(fr.draft[column.id] ?? ""),
541
- data: row.original,
542
- columnId: column.id,
543
- })
544
- : parsed;
545
- if (oldValue !== finalValue) {
546
- (row.original as Record<string, unknown>)[field] = finalValue;
547
- const key = getCellKey(fr.rowId, column.id);
548
- ctx.editedCellValues = { ...ctx.editedCellValues, [key]: finalValue };
549
- changed.push({ columnId: column.id, field, before: oldValue, after: finalValue });
550
- }
551
- }
552
- // Record the whole-row change as consecutive history steps.
553
- if (changed.length) {
554
- let hist = ctx.history.slice(0, ctx.historyPtr + 1);
555
- for (const c of changed) {
556
- hist.push({ rowId: fr.rowId, columnId: c.columnId, field: c.field, before: c.before, after: c.after });
557
- }
558
- if (hist.length > ctx.UNDO_LIMIT) hist = hist.slice(hist.length - ctx.UNDO_LIMIT);
559
- ctx.history = hist;
560
- ctx.historyPtr = ctx.history.length - 1;
561
- ctx.historyVersion += 1;
562
- }
563
- ctx.grid.store.setState((prev: any) => ({ ...prev }));
564
- if (ctx.props.onCellValueChange && changed.length) {
565
- const rowIndex = ctx.internalData.indexOf(row.original as TData);
566
- for (const c of changed) {
567
- ctx.props.onCellValueChange({
568
- rowIndex,
569
- columnId: c.columnId,
570
- oldValue: c.before,
571
- newValue: c.after,
572
- row: row.original as TData,
573
- });
574
- }
575
- }
576
- ctx.fullRowEdit = null;
577
- }
578
-
579
- /** Discard the full-row edit. */
580
- function cancelFullRowEdit(): void {
581
- ctx.fullRowEdit = null;
582
- }
583
-
584
- return {
585
- isCellEditable,
586
- isCellEditableAt,
587
- getRowColumnValue,
588
- getCellDisplayValue,
589
- startEditingWithChar,
590
- startEditing,
591
- stopEditing,
592
- startFullRowEdit,
593
- setFullRowDraft,
594
- commitFullRowEdit,
595
- cancelFullRowEdit,
596
- saveEditingCell,
597
- applyHistoryStep,
598
- updateEditingCellValue,
599
- onEditorKeyDown,
600
- commitAndMoveByTab,
601
- focusOnMount,
602
- onCellDoubleClick,
603
- pasteFromClipboard,
604
- onGridPaste,
605
- };
606
- }
1
+ // editing handlers extracted from the controller. Imperative event handlers
2
+ // reading/writing controller state via the `ctx` handle; the reactive core
3
+ // ($state/$derived/$effect) stays in the controller.
4
+ import {
5
+ parseEditorValue,
6
+ type CellContext,
7
+ type CellEditorType,
8
+ type Column,
9
+ type Row,
10
+ type RowData,
11
+ type TableFeatures,
12
+ } from "./index";
13
+ import "./sv-grid-scrollbar";
14
+ import { getNextActiveCell } from "./keyboard";
15
+ import {
16
+ getCellKey,
17
+ toValueArray,
18
+ } from "./SvGrid.helpers";
19
+ import {
20
+ createDataIndexLookup,
21
+ getColumnBaseValue,
22
+ isGroupRow,
23
+ } from "./cell-values";
24
+
25
+ type HistoryStep = {
26
+ rowId: string;
27
+ columnId: string;
28
+ field: string;
29
+ before: unknown;
30
+ after: unknown;
31
+ };
32
+
33
+ export function createEditing<
34
+ TFeatures extends TableFeatures = TableFeatures,
35
+ TData extends RowData = RowData,
36
+ >(ctx: any) {
37
+ /**
38
+ * Resolve `columnDef.editable` for one row × column pair.
39
+ *
40
+ * - `undefined` / `true` → editable
41
+ * - `false`not editable (column-level lockdown, fastest path)
42
+ * - function → call it with the cell's context and honour the
43
+ * boolean it returns (cell-level lockdown - used for things like
44
+ * "this field is owned by the server, this one isn't, by row")
45
+ *
46
+ * Called from every editing entry point: double-click, type-to-edit,
47
+ * fill-handle drag, Delete-clear, clipboard paste. A central helper
48
+ * keeps the cell-level callback signature consistent and saves a
49
+ * lookup of CellContext at each site.
50
+ */
51
+ function isCellEditable(column: Column<TData>, row?: Row<TData>): boolean {
52
+ const editable = column.columnDef.editable;
53
+ if (editable === false) return false;
54
+ if (typeof editable !== "function") return true;
55
+ if (!row) return true;
56
+ const cellCtx: CellContext<TData> = {
57
+ cell: {
58
+ id: `${row.id}_${column.id}`,
59
+ row,
60
+ column,
61
+ getValue: () => getColumnBaseValue(row, column),
62
+ getContext: () => cellCtx,
63
+ },
64
+ row,
65
+ column,
66
+ table: ctx.grid,
67
+ getValue: () => getColumnBaseValue(row, column),
68
+ };
69
+ try {
70
+ return editable(cellCtx) !== false;
71
+ } catch {
72
+ // A throwing predicate shouldn't crash the grid - log and treat
73
+ // as not-editable so any error biases toward safety.
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /** Variant that takes raw row + column indices for the rare callers
79
+ * (fill-handle, paste, Delete) that already work in index-space. */
80
+ function isCellEditableAt(rowIndex: number, colIndex: number): boolean {
81
+ const row = ctx.allRows[rowIndex];
82
+ const column = ctx.allColumns[colIndex];
83
+ if (!column) return false;
84
+ return isCellEditable(column, row);
85
+ }
86
+
87
+ function getRowColumnValue(row: Row<TData>, columnId: string) {
88
+ const column = ctx.allColumns.find((entry: any) => entry.id === columnId);
89
+ return column
90
+ ? getColumnBaseValue(row, column)
91
+ : row.getCellValueByColumnId(columnId);
92
+ }
93
+
94
+ function getCellDisplayValue(
95
+ rowId: string,
96
+ columnId: string,
97
+ baseValue: unknown,
98
+ ) {
99
+ const key = getCellKey(rowId, columnId);
100
+ return key in ctx.editedCellValues ? ctx.editedCellValues[key] : baseValue;
101
+ }
102
+
103
+ /** Start editing seeded with a typed character (Excel-style type-to-edit). */
104
+ function startEditingWithChar(
105
+ rowIndex: number,
106
+ colIndex: number,
107
+ char: string,
108
+ ): boolean {
109
+ if (!ctx.editingEnabled) return false;
110
+ const row = ctx.allRows[rowIndex];
111
+ const column = ctx.allColumns[colIndex];
112
+ if (!row || !column || isGroupRow(row)) return false;
113
+ if (!isCellEditable(column, row)) return false;
114
+ const editorType = (column.columnDef.editorType ??
115
+ "text") as CellEditorType;
116
+ if (
117
+ editorType === "checkbox" ||
118
+ editorType === "date" ||
119
+ editorType === "datetime" ||
120
+ editorType === "list" ||
121
+ editorType === "chips"
122
+ ) {
123
+ return false;
124
+ }
125
+ if (editorType === "number" && !/[0-9.+-]/.test(char)) return false;
126
+ ctx.editorSelectAll = false;
127
+ ctx.editingCell = {
128
+ rowId: row.id,
129
+ columnId: column.id,
130
+ editorType,
131
+ value: char,
132
+ rowRef: row.original,
133
+ };
134
+ ctx.setActiveCell(rowIndex, colIndex);
135
+ ctx.setSelection(rowIndex, colIndex);
136
+ return true;
137
+ }
138
+
139
+ function saveEditingCell() {
140
+ if (!ctx.editingCell) return;
141
+ const editing = ctx.editingCell;
142
+ // Resolve the row this edit belongs to. `allRows` is only the current page,
143
+ // so fall back to the full filtered model, then to the data object captured
144
+ // when the edit started. Without that last hop an edit is silently dropped
145
+ // when a filter typed mid-edit hides the row from both models (#49).
146
+ const row =
147
+ ctx.allRows.find((entry: any) => entry.id === editing.rowId) ??
148
+ ctx.allRowsBeforePagination.find((entry: any) => entry.id === editing.rowId);
149
+ const rowData = (row?.original ?? editing.rowRef) as
150
+ | Record<string, unknown>
151
+ | undefined;
152
+ const column = ctx.allColumns.find(
153
+ (entry: any) => entry.id === ctx.editingCell?.columnId,
154
+ );
155
+ const parsedValue = parseEditorValue(
156
+ ctx.editingCell.editorType,
157
+ ctx.editingCell.value,
158
+ {
159
+ multiple: column?.columnDef.editorMultiple === true,
160
+ // A dateString column holds 'YYYY-MM-DD', not a timestamp.
161
+ dateOnly: column?.columnDef.cellDataType === "dateString",
162
+ },
163
+ );
164
+ let oldValue: unknown = undefined;
165
+ let finalValue: unknown = parsedValue;
166
+ if (rowData && column?.columnDef.field) {
167
+ oldValue = rowData[column.columnDef.field];
168
+ // Per-column valueParser refines the type-coerced value before storing.
169
+ const parser = (
170
+ column.columnDef as { valueParser?: (p: unknown) => unknown }
171
+ ).valueParser;
172
+ if (parser) {
173
+ finalValue = parser({
174
+ newValue: parsedValue,
175
+ oldValue,
176
+ rawInput: ctx.editingCell.value,
177
+ data: rowData,
178
+ columnId: ctx.editingCell.columnId,
179
+ });
180
+ }
181
+ rowData[column.columnDef.field] = finalValue;
182
+ }
183
+ const key = getCellKey(ctx.editingCell.rowId, ctx.editingCell.columnId);
184
+ ctx.editedCellValues = {
185
+ ...ctx.editedCellValues,
186
+ [key]: finalValue,
187
+ };
188
+ ctx.grid.store.setState((prev: any) => ({ ...prev }));
189
+ // Record into the history at the current pointer. Any forward
190
+ // history (steps the user could have redone) is truncated - this
191
+ // is the standard "edit invalidates redo" rule.
192
+ if (oldValue !== finalValue && rowData && column?.columnDef.field) {
193
+ const step: HistoryStep = {
194
+ rowId: ctx.editingCell.rowId,
195
+ columnId: ctx.editingCell.columnId,
196
+ field: column.columnDef.field as string,
197
+ before: oldValue,
198
+ after: finalValue,
199
+ }
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
213
+ }
214
+ // Notify the consumer AFTER the row has been updated so any callback-
215
+ // driven recompute (cascade totals, server save, undo stack) sees the
216
+ // post-write state. `rowIndex` matches the position in `props.data`.
217
+ if (ctx.props.onCellValueChange && rowData && column) {
218
+ const rowIndex = ctx.internalData.indexOf(rowData as TData);
219
+ ctx.props.onCellValueChange({
220
+ rowIndex,
221
+ columnId: column.id,
222
+ oldValue,
223
+ newValue: finalValue,
224
+ row: rowData as TData,
225
+ });
226
+ }
227
+ ctx.editingCell = null;
228
+ }
229
+
230
+ /** Apply an undo / redo step directly to the underlying row, bypassing
231
+ * the editor pipeline so we don't accidentally re-push to the stack. */
232
+ function applyHistoryStep(step: HistoryStep, direction: 'undo' | 'redo') {
233
+ const row = ctx.allRows.find((r: any) => r.id === step.rowId)
234
+ const col = ctx.allColumns.find((c: any) => c.id === step.columnId)
235
+ if (!row?.original || !col) return
236
+ const value = direction === 'undo' ? step.before : step.after
237
+ ;(row.original as Record<string, unknown>)[step.field] = value
238
+ const key = getCellKey(step.rowId, step.columnId)
239
+ ctx.editedCellValues = { ...ctx.editedCellValues, [key]: value }
240
+ ctx.grid.store.setState((prev: any) => ({ ...prev }))
241
+ if (ctx.props.onCellValueChange) {
242
+ const rowIndex = ctx.internalData.indexOf(row.original as TData)
243
+ ctx.props.onCellValueChange({
244
+ rowIndex,
245
+ columnId: step.columnId,
246
+ oldValue: direction === 'undo' ? step.after : step.before,
247
+ newValue: value,
248
+ row: row.original as TData,
249
+ })
250
+ }
251
+ }
252
+
253
+ function updateEditingCellValue(value: unknown) {
254
+ ctx.editingCell = ctx.editingCell ? { ...ctx.editingCell, value } : ctx.editingCell;
255
+ }
256
+
257
+ /**
258
+ * Commit the open editor and move the active cell one column along, wrapping
259
+ * at row boundaries the way Excel does. Editors `stopPropagation()` every key
260
+ * so the grid's own Tab handler never sees this one - without the explicit
261
+ * move, Tab committed the edit and then let the browser walk focus out of the
262
+ * grid entirely (#48). Shared by every editor type, including the textarea.
263
+ */
264
+ function commitAndMoveByTab(shiftKey: boolean) {
265
+ const active = ctx.activeCell;
266
+ saveEditingCell();
267
+ ctx.gridRootEl?.focus({ preventScroll: true });
268
+ if (!active) return;
269
+ const next = getNextActiveCell(active, shiftKey ? "tabPrev" : "tabNext", {
270
+ maxRow: ctx.allRows.length - 1,
271
+ maxCol: ctx.allColumns.length - 1,
272
+ });
273
+ ctx.setActiveCell(next.rowIndex, next.colIndex);
274
+ ctx.setSelection(next.rowIndex, next.colIndex);
275
+ ctx.scrollActiveCellIntoView(next.rowIndex, next.colIndex);
276
+ }
277
+
278
+ function onEditorKeyDown(event: KeyboardEvent) {
279
+ event.stopPropagation();
280
+ if (event.key === "Enter") {
281
+ event.preventDefault();
282
+ saveEditingCell();
283
+ ctx.gridRootEl?.focus({ preventScroll: true });
284
+ } else if (event.key === "Tab") {
285
+ event.preventDefault();
286
+ commitAndMoveByTab(event.shiftKey);
287
+ } else if (event.key === "Escape") {
288
+ event.preventDefault();
289
+ ctx.editingCell = null;
290
+ ctx.gridRootEl?.focus({ preventScroll: true });
291
+ }
292
+ }
293
+
294
+ function focusOnMount(node: HTMLInputElement | HTMLTextAreaElement) {
295
+ const selectAll = ctx.editorSelectAll;
296
+ requestAnimationFrame(() => {
297
+ node.focus({ preventScroll: true });
298
+ try {
299
+ if (selectAll) {
300
+ node.select();
301
+ } else {
302
+ const end = node.value.length;
303
+ node.setSelectionRange(end, end);
304
+ }
305
+ } catch {
306
+ /* date/number inputs may not support text selection */
307
+ }
308
+ });
309
+ }
310
+
311
+ function onCellDoubleClick(rowIndex: number, colIndex: number) {
312
+ if (!ctx.editingEnabled) return;
313
+ const row = ctx.allRows[rowIndex];
314
+ const column = ctx.allColumns[colIndex];
315
+ if (!row || !column) return;
316
+ if (isGroupRow(row)) return;
317
+ // Full-row editing: put the whole row into edit instead of one cell.
318
+ if (ctx.props.fullRowEditing) {
319
+ if (ctx.fullRowEdit?.rowId === row.id) return;
320
+ startFullRowEdit(rowIndex);
321
+ return;
322
+ }
323
+ if (!isCellEditable(column, row)) return;
324
+ if (ctx.editingCell?.rowId === row.id && ctx.editingCell?.columnId === column.id)
325
+ return;
326
+ const editorType = (column.columnDef.editorType ??
327
+ "text") as CellEditorType;
328
+ ctx.editorSelectAll = true;
329
+ let initialValue = getCellDisplayValue(
330
+ row.id,
331
+ column.id,
332
+ row.getCellValueByColumnId(column.id),
333
+ );
334
+ if (editorType === "list" || editorType === "chips") {
335
+ if (column.columnDef.editorMultiple) {
336
+ // Seed with an array so the editor can mutate slot-by-slot.
337
+ initialValue = toValueArray(initialValue);
338
+ } else if (Array.isArray(initialValue)) {
339
+ initialValue = initialValue[0] ?? "";
340
+ }
341
+ }
342
+ ctx.editingCell = {
343
+ rowId: row.id,
344
+ columnId: column.id,
345
+ editorType,
346
+ value: initialValue,
347
+ rowRef: row.original,
348
+ };
349
+ ctx.setActiveCell(rowIndex, colIndex);
350
+ ctx.setSelection(rowIndex, colIndex);
351
+ }
352
+
353
+ async function pasteFromClipboard() {
354
+ // Preferred path: the async Clipboard API. Requires a secure context
355
+ // (HTTPS or localhost) AND read permission; it also works on Firefox,
356
+ // where the native `paste` event does NOT fire on a non-editable element.
357
+ // On plain HTTP (a XAMPP/Apache LAN host) `navigator.clipboard` is
358
+ // undefined - there the Ctrl+V keydown handler skips preventDefault and
359
+ // lets the browser deliver a native `paste` event to `onGridPaste`.
360
+ if (!navigator.clipboard?.readText) return;
361
+ let text: string;
362
+ try {
363
+ text = await navigator.clipboard.readText();
364
+ } catch {
365
+ return;
366
+ }
367
+ applyPastedText(text);
368
+ }
369
+
370
+ /**
371
+ * Native `paste` ClipboardEvent fallback (insecure-context path). A real
372
+ * Ctrl/Cmd+V on the focused grid root fires this with `clipboardData`
373
+ * readable even over plain HTTP and with no permission prompt. Wired on the
374
+ * grid root in SvGrid.svelte; the keydown handler lets the gesture through
375
+ * (no preventDefault) whenever the async API is unavailable.
376
+ */
377
+ function onGridPaste(event: ClipboardEvent) {
378
+ // A cell is being edited: the focused editor input handles its own paste
379
+ // natively. Never hijack it for a range paste - doing so (on Firefox /
380
+ // insecure contexts, where the async Clipboard API is unavailable) would
381
+ // preventDefault the editor's paste and write to the data cell instead,
382
+ // so the edit "wouldn't update".
383
+ if (ctx.editingCell) return;
384
+ // When the async API is available the keydown handler already pasted via
385
+ // pasteFromClipboard(); ignore the (suppressed) native event so we don't
386
+ // paste twice.
387
+ if (typeof navigator.clipboard?.readText === "function") return;
388
+ const text = event.clipboardData?.getData("text/plain") ?? "";
389
+ if (!text) return;
390
+ event.preventDefault();
391
+ applyPastedText(text);
392
+ }
393
+
394
+ function applyPastedText(text: string) {
395
+ const anchor = ctx.selectionRange.anchor ?? ctx.grid.getState().activeCell;
396
+ if (!anchor) return;
397
+ const focus = ctx.selectionRange.focus ?? anchor;
398
+ const startRow = Math.min(anchor.rowIndex, focus.rowIndex);
399
+ const startCol = Math.min(anchor.colIndex, focus.colIndex);
400
+ const endRow = Math.max(anchor.rowIndex, focus.rowIndex);
401
+ const endCol = Math.max(anchor.colIndex, focus.colIndex);
402
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
403
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
404
+ if (!lines.length) return;
405
+
406
+ // Excel/Sheets behaviour: when the clipboard holds a single cell and
407
+ // the selection is a range, fill the entire range with that value.
408
+ // For larger clipboards (multi-row/col TSV), paste at the top-left
409
+ // corner and let the clipboard determine the span (the original
410
+ // behaviour).
411
+ const clipboardIsSingleCell =
412
+ lines.length === 1 && (lines[0]?.split("\t").length ?? 0) === 1;
413
+ const selectionIsRange = startRow !== endRow || startCol !== endCol;
414
+ const fillRange = clipboardIsSingleCell && selectionIsRange;
415
+
416
+ const rowSpan = fillRange ? endRow - startRow + 1 : lines.length;
417
+ const colSpan = fillRange
418
+ ? endCol - startCol + 1
419
+ : Math.max(...lines.map((l) => l.split("\t").length));
420
+
421
+ const next = ctx.internalData.slice() as Array<TData>;
422
+ const dataIndexOf = createDataIndexLookup(next);
423
+ for (let i = 0; i < rowSpan; i += 1) {
424
+ const targetRowIndex = startRow + i;
425
+ const row = ctx.allRows[targetRowIndex];
426
+ if (!row || isGroupRow(row)) continue;
427
+ // Map the visible row back to its slot in the data array by identity -
428
+ // `row.id` is only the index under the default getRowId.
429
+ const dataIndex = dataIndexOf(row);
430
+ if (dataIndex < 0) continue;
431
+ const originalRow = next[dataIndex];
432
+ if (!originalRow) continue;
433
+ const sourceCells = fillRange
434
+ ? null
435
+ : lines[i]?.split("\t") ?? [];
436
+ const updated: Record<string, unknown> = {
437
+ ...(originalRow as Record<string, unknown>),
438
+ };
439
+ for (let j = 0; j < colSpan; j += 1) {
440
+ const column = ctx.allColumns[startCol + j];
441
+ if (!column?.columnDef.field) continue;
442
+ if (!isCellEditableAt(targetRowIndex, startCol + j)) continue;
443
+ const editorType = (column.columnDef.editorType ??
444
+ "text") as CellEditorType;
445
+ const raw = fillRange ? lines[0]! : sourceCells?.[j] ?? "";
446
+ updated[column.columnDef.field] = parseEditorValue(editorType, raw, {
447
+ dateOnly: column.columnDef.cellDataType === "dateString",
448
+ });
449
+ }
450
+ next[dataIndex] = updated as TData;
451
+ }
452
+ ctx.internalData = next;
453
+ ctx.grid.store.setState((prev: any) => ({ ...prev }));
454
+ }
455
+
456
+ /** Programmatically begin editing a cell (mirrors a double-click). */
457
+ function startEditing(rowIndex: number, columnId: string): boolean {
458
+ if (!ctx.editingEnabled) return false;
459
+ const row = ctx.allRows[rowIndex];
460
+ if (!row || isGroupRow(row)) return false;
461
+ const colIndex = ctx.allColumns.findIndex((c: any) => c.id === columnId);
462
+ if (colIndex < 0) return false;
463
+ const column = ctx.allColumns[colIndex];
464
+ if (!isCellEditable(column, row)) return false;
465
+ if (
466
+ ctx.editingCell?.rowId === row.id &&
467
+ ctx.editingCell?.columnId === columnId
468
+ )
469
+ return true;
470
+ onCellDoubleClick(rowIndex, colIndex);
471
+ return (
472
+ ctx.editingCell?.rowId === row.id &&
473
+ ctx.editingCell?.columnId === columnId
474
+ );
475
+ }
476
+
477
+ /** Commit (default) or cancel the active edit, if any. */
478
+ function stopEditing(cancel = false): boolean {
479
+ if (!ctx.editingCell) return false;
480
+ if (cancel) {
481
+ ctx.editingCell = null;
482
+ } else {
483
+ saveEditingCell();
484
+ }
485
+ return true;
486
+ }
487
+
488
+ // ---- Full-row editing -------------------------------------------------
489
+ /** Put the whole row into edit mode, seeding a draft per editable column. */
490
+ function startFullRowEdit(rowIndex: number): boolean {
491
+ if (!ctx.editingEnabled || !ctx.props.fullRowEditing) return false;
492
+ const row = ctx.allRows[rowIndex];
493
+ if (!row || isGroupRow(row)) return false;
494
+ if (ctx.fullRowEdit && ctx.fullRowEdit.rowId !== row.id) commitFullRowEdit();
495
+ ctx.editingCell = null; // never both at once
496
+ const draft: Record<string, unknown> = {};
497
+ for (const column of ctx.allColumns) {
498
+ if (!column.columnDef.field) continue;
499
+ if (!isCellEditable(column, row)) continue;
500
+ draft[column.id] = getCellDisplayValue(
501
+ row.id,
502
+ column.id,
503
+ row.getCellValueByColumnId(column.id),
504
+ );
505
+ }
506
+ ctx.fullRowEdit = { rowId: row.id, draft };
507
+ ctx.setActiveCell(rowIndex, Math.max(0, ctx.allColumns.findIndex((c: any) => c.id in draft)));
508
+ return true;
509
+ }
510
+
511
+ /** Stage one column's draft value during full-row editing. */
512
+ function setFullRowDraft(columnId: string, value: unknown): void {
513
+ const fr = ctx.fullRowEdit;
514
+ if (!fr) return;
515
+ ctx.fullRowEdit = { rowId: fr.rowId, draft: { ...fr.draft, [columnId]: value } };
516
+ }
517
+
518
+ /** Commit every drafted column of the full-row edit in one data update. */
519
+ function commitFullRowEdit(): void {
520
+ const fr = ctx.fullRowEdit;
521
+ if (!fr) return;
522
+ const row = ctx.allRows.find((r: any) => r.id === fr.rowId);
523
+ if (!row?.original) {
524
+ ctx.fullRowEdit = null;
525
+ return;
526
+ }
527
+ const changed: Array<{ columnId: string; field: string; before: unknown; after: unknown }> = [];
528
+ for (const column of ctx.allColumns) {
529
+ if (!(column.id in fr.draft)) continue;
530
+ const field = column.columnDef.field as string | undefined;
531
+ if (!field) continue;
532
+ const editorType = (column.columnDef.editorType ?? "text") as CellEditorType;
533
+ const parsed = parseEditorValue(editorType, fr.draft[column.id], {
534
+ multiple: column.columnDef.editorMultiple === true,
535
+ dateOnly: column.columnDef.cellDataType === "dateString",
536
+ });
537
+ const oldValue = (row.original as Record<string, unknown>)[field];
538
+ const parser = (column.columnDef as { valueParser?: (p: unknown) => unknown }).valueParser;
539
+ const finalValue = parser
540
+ ? parser({
541
+ newValue: parsed,
542
+ oldValue,
543
+ rawInput: String(fr.draft[column.id] ?? ""),
544
+ data: row.original,
545
+ columnId: column.id,
546
+ })
547
+ : parsed;
548
+ if (oldValue !== finalValue) {
549
+ (row.original as Record<string, unknown>)[field] = finalValue;
550
+ const key = getCellKey(fr.rowId, column.id);
551
+ ctx.editedCellValues = { ...ctx.editedCellValues, [key]: finalValue };
552
+ changed.push({ columnId: column.id, field, before: oldValue, after: finalValue });
553
+ }
554
+ }
555
+ // Record the whole-row change as consecutive history steps.
556
+ if (changed.length) {
557
+ let hist = ctx.history.slice(0, ctx.historyPtr + 1);
558
+ for (const c of changed) {
559
+ hist.push({ rowId: fr.rowId, columnId: c.columnId, field: c.field, before: c.before, after: c.after });
560
+ }
561
+ if (hist.length > ctx.UNDO_LIMIT) hist = hist.slice(hist.length - ctx.UNDO_LIMIT);
562
+ ctx.history = hist;
563
+ ctx.historyPtr = ctx.history.length - 1;
564
+ ctx.historyVersion += 1;
565
+ }
566
+ ctx.grid.store.setState((prev: any) => ({ ...prev }));
567
+ if (ctx.props.onCellValueChange && changed.length) {
568
+ const rowIndex = ctx.internalData.indexOf(row.original as TData);
569
+ for (const c of changed) {
570
+ ctx.props.onCellValueChange({
571
+ rowIndex,
572
+ columnId: c.columnId,
573
+ oldValue: c.before,
574
+ newValue: c.after,
575
+ row: row.original as TData,
576
+ });
577
+ }
578
+ }
579
+ ctx.fullRowEdit = null;
580
+ }
581
+
582
+ /** Discard the full-row edit. */
583
+ function cancelFullRowEdit(): void {
584
+ ctx.fullRowEdit = null;
585
+ }
586
+
587
+ return {
588
+ isCellEditable,
589
+ isCellEditableAt,
590
+ getRowColumnValue,
591
+ getCellDisplayValue,
592
+ startEditingWithChar,
593
+ startEditing,
594
+ stopEditing,
595
+ startFullRowEdit,
596
+ setFullRowDraft,
597
+ commitFullRowEdit,
598
+ cancelFullRowEdit,
599
+ saveEditingCell,
600
+ applyHistoryStep,
601
+ updateEditingCellValue,
602
+ onEditorKeyDown,
603
+ commitAndMoveByTab,
604
+ focusOnMount,
605
+ onCellDoubleClick,
606
+ pasteFromClipboard,
607
+ onGridPaste,
608
+ };
609
+ }