@svgrid/grid 3.0.0 → 3.0.2

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 (304) hide show
  1. package/CHANGELOG.md +116 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +190 -181
  5. package/dist/GridFooter.svelte.d.ts +8 -0
  6. package/dist/GridMenus.svelte +18 -10
  7. package/dist/GridMenus.svelte.d.ts +2 -1
  8. package/dist/SvAutoComplete.svelte +169 -169
  9. package/dist/SvAvatar.svelte +75 -75
  10. package/dist/SvCalendar.svelte +503 -503
  11. package/dist/SvCarousel.svelte +141 -141
  12. package/dist/SvCheckBox.svelte +102 -102
  13. package/dist/SvCircularProgress.svelte +109 -109
  14. package/dist/SvColorInput.svelte +181 -181
  15. package/dist/SvComboBox.svelte +279 -279
  16. package/dist/SvContextMenu.svelte +116 -116
  17. package/dist/SvCountryInput.svelte +163 -163
  18. package/dist/SvDrawer.svelte +254 -254
  19. package/dist/SvDropDownList.svelte +378 -378
  20. package/dist/SvDurationInput.svelte +126 -126
  21. package/dist/SvField.svelte +293 -293
  22. package/dist/SvForm.svelte +437 -437
  23. package/dist/SvGrid.controller.svelte.d.ts +17 -0
  24. package/dist/SvGrid.controller.svelte.js +128 -14
  25. package/dist/SvGrid.css +79 -10
  26. package/dist/SvGrid.svelte +191 -186
  27. package/dist/SvGrid.types.d.ts +42 -1
  28. package/dist/SvGridCellEditor.svelte +748 -744
  29. package/dist/SvGridChart.svelte +2246 -1716
  30. package/dist/SvGridChart.svelte.d.ts +63 -1
  31. package/dist/SvGridChartPanel.svelte +633 -485
  32. package/dist/SvGridChartView.svelte +79 -70
  33. package/dist/SvGridChartView.svelte.d.ts +7 -0
  34. package/dist/SvGridDropdown.svelte +728 -728
  35. package/dist/SvGridSelect.svelte +270 -270
  36. package/dist/SvGroupCell.svelte +141 -116
  37. package/dist/SvGroupCell.svelte.d.ts +8 -0
  38. package/dist/SvListBox.svelte +334 -334
  39. package/dist/SvMaskedInput.svelte +122 -122
  40. package/dist/SvMenu.svelte +124 -124
  41. package/dist/SvMenuList.svelte +146 -146
  42. package/dist/SvMultiSelect.svelte +293 -293
  43. package/dist/SvNumberInput.svelte +172 -172
  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/SvRichText.svelte +211 -211
  53. package/dist/SvRowGroupPanel.svelte +186 -170
  54. package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
  55. package/dist/SvScrollArea.svelte +61 -61
  56. package/dist/SvSlider.svelte +203 -203
  57. package/dist/SvSwitchButton.svelte +108 -108
  58. package/dist/SvTagsInput.svelte +115 -115
  59. package/dist/SvTextInput.svelte +147 -147
  60. package/dist/SvTimePicker.svelte +245 -245
  61. package/dist/SvToaster.svelte +159 -159
  62. package/dist/SvToggleButton.svelte +85 -85
  63. package/dist/SvTooltip.svelte +161 -161
  64. package/dist/SvTour.svelte +208 -208
  65. package/dist/SvTree.svelte +444 -444
  66. package/dist/SvTreeSelect.svelte +239 -239
  67. package/dist/ai.d.ts +11 -2
  68. package/dist/ai.js +51 -7
  69. package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
  70. package/dist/cdn/GridMenus-M2XP9t0_.js +603 -0
  71. package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
  72. package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
  73. package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
  74. package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
  75. package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
  76. package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
  77. package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
  78. package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
  79. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
  80. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
  81. package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-CxfakxR0.js} +5 -3
  82. package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-Dm_YEx9L.js} +5 -3
  83. package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
  84. package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
  85. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  86. package/dist/cdn/chart-CKJRCPOk.js +1652 -0
  87. package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
  88. package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
  89. package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
  90. package/dist/cdn/{src-B_YS5AOc.js → src-BJ2opd0j.js} +3642 -3450
  91. package/dist/cdn/{src-DYXXpuSk.js → src-DNbz94Ct.js} +8649 -8457
  92. package/dist/cdn/svgrid.js +16 -15
  93. package/dist/cdn/svgrid.svelte-external.js +16 -15
  94. package/dist/cell-formatting.d.ts +7 -0
  95. package/dist/cell-formatting.js +7 -1
  96. package/dist/chart-export.d.ts +23 -2
  97. package/dist/chart-export.js +88 -14
  98. package/dist/chart.d.ts +410 -18
  99. package/dist/chart.js +824 -24
  100. package/dist/editing.js +22 -13
  101. package/dist/grid-icons.d.ts +49 -0
  102. package/dist/grid-icons.js +86 -0
  103. package/dist/index.d.ts +3 -2
  104. package/dist/index.js +3 -2
  105. package/package.json +1 -1
  106. package/src/FlexRender.svelte +96 -96
  107. package/src/GridFooter.svelte +190 -181
  108. package/src/GridMenus.svelte +18 -10
  109. package/src/SvAutoComplete.svelte +169 -169
  110. package/src/SvAvatar.svelte +75 -75
  111. package/src/SvCalendar.svelte +503 -503
  112. package/src/SvCalendar.test.ts +226 -226
  113. package/src/SvCarousel.svelte +141 -141
  114. package/src/SvCheckBox.svelte +102 -102
  115. package/src/SvCircularProgress.svelte +109 -109
  116. package/src/SvColorInput.svelte +181 -181
  117. package/src/SvComboBox.svelte +279 -279
  118. package/src/SvContextMenu.svelte +116 -116
  119. package/src/SvCountryInput.svelte +163 -163
  120. package/src/SvDrawer.svelte +254 -254
  121. package/src/SvDropDownList.svelte +378 -378
  122. package/src/SvDurationInput.svelte +126 -126
  123. package/src/SvField.svelte +293 -293
  124. package/src/SvForm.svelte +437 -437
  125. package/src/SvForm.test.ts +411 -411
  126. package/src/SvGrid.controller.svelte.ts +146 -13
  127. package/src/SvGrid.css +79 -10
  128. package/src/SvGrid.svelte +191 -186
  129. package/src/SvGrid.types.ts +2133 -2092
  130. package/src/SvGridCellEditor.svelte +748 -744
  131. package/src/SvGridChart.svelte +2246 -1716
  132. package/src/SvGridChart.test.ts +243 -1
  133. package/src/SvGridChartPanel.svelte +633 -485
  134. package/src/SvGridChartView.svelte +79 -70
  135. package/src/SvGridDropdown.svelte +728 -728
  136. package/src/SvGridSelect.svelte +270 -270
  137. package/src/SvGroupCell.svelte +141 -116
  138. package/src/SvListBox.svelte +334 -334
  139. package/src/SvMaskedInput.svelte +122 -122
  140. package/src/SvMenu.svelte +124 -124
  141. package/src/SvMenu.test.ts +97 -97
  142. package/src/SvMenuList.svelte +146 -146
  143. package/src/SvMultiSelect.svelte +293 -293
  144. package/src/SvNumberInput.svelte +172 -172
  145. package/src/SvOtpInput.svelte +158 -158
  146. package/src/SvPasswordInput.svelte +151 -151
  147. package/src/SvPhoneInput.svelte +133 -133
  148. package/src/SvPopover.svelte +197 -197
  149. package/src/SvProgress.svelte +116 -116
  150. package/src/SvRadioGroup.svelte +107 -107
  151. package/src/SvRating.svelte +112 -112
  152. package/src/SvResult.svelte +73 -73
  153. package/src/SvRichText.svelte +211 -211
  154. package/src/SvRowGroupPanel.svelte +186 -170
  155. package/src/SvScrollArea.svelte +61 -61
  156. package/src/SvSlider.svelte +203 -203
  157. package/src/SvSwitchButton.svelte +108 -108
  158. package/src/SvTagsInput.svelte +115 -115
  159. package/src/SvTextInput.svelte +147 -147
  160. package/src/SvTimePicker.svelte +245 -245
  161. package/src/SvToaster.svelte +159 -159
  162. package/src/SvToaster.test.ts +95 -95
  163. package/src/SvToggleButton.svelte +85 -85
  164. package/src/SvTooltip.svelte +161 -161
  165. package/src/SvTour.svelte +208 -208
  166. package/src/SvTree.svelte +444 -444
  167. package/src/SvTreeSelect.svelte +239 -239
  168. package/src/a11y/dismissable.test.ts +119 -119
  169. package/src/a11y/dismissable.ts +114 -114
  170. package/src/a11y.contract.test.ts +49 -49
  171. package/src/a11y.test.ts +59 -59
  172. package/src/a11y.ts +61 -61
  173. package/src/ai.test.ts +561 -502
  174. package/src/ai.ts +1472 -1419
  175. package/src/build-api.coverage.test.ts +633 -633
  176. package/src/build-api.ts +846 -846
  177. package/src/builtin-editors.grid.test.ts +83 -83
  178. package/src/cell-formatting.ts +177 -171
  179. package/src/cell-render.test.ts +513 -513
  180. package/src/cell-render.ts +496 -496
  181. package/src/cell-values.ts +148 -148
  182. package/src/chart-export.test.ts +112 -0
  183. package/src/chart-export.ts +270 -202
  184. package/src/chart-view.svelte.ts +36 -36
  185. package/src/chart.test.ts +830 -1
  186. package/src/chart.ts +3342 -2321
  187. package/src/collaboration.test.ts +104 -104
  188. package/src/collaboration.ts +167 -167
  189. package/src/column-groups.ts +78 -78
  190. package/src/core.performance.test.ts +30 -30
  191. package/src/core.ts +1865 -1865
  192. package/src/createAutocomplete.svelte.ts +132 -132
  193. package/src/createCombobox.svelte.ts +191 -191
  194. package/src/createCountryInput.svelte.ts +157 -157
  195. package/src/createDropdownList.svelte.ts +168 -168
  196. package/src/createForm.svelte.ts +386 -386
  197. package/src/createGrid.svelte.ts +42 -42
  198. package/src/createGrid.test.ts +10 -10
  199. package/src/createGridState.svelte.ts +17 -17
  200. package/src/createListbox.svelte.ts +250 -250
  201. package/src/createMenu.svelte.ts +224 -224
  202. package/src/createPopoverSelect.svelte.ts +213 -213
  203. package/src/createSlider.svelte.ts +191 -191
  204. package/src/createTooltip.svelte.ts +144 -144
  205. package/src/createTree.svelte.ts +322 -322
  206. package/src/datetime/date-core.ts +206 -206
  207. package/src/datetime/date-restrict.ts +61 -61
  208. package/src/datetime/timezone.ts +135 -135
  209. package/src/dock-manager-model.ts +596 -596
  210. package/src/dock-model.ts +374 -374
  211. package/src/editing.test.ts +974 -974
  212. package/src/editing.ts +618 -609
  213. package/src/editor-contract.ts +171 -171
  214. package/src/editor-registry.grid.test.ts +144 -144
  215. package/src/editor-registry.ts +122 -122
  216. package/src/export-data-api.test.ts +126 -126
  217. package/src/export-format.test.ts +107 -107
  218. package/src/export-format.ts +601 -601
  219. package/src/filter-operators.ts +160 -160
  220. package/src/filtering/excel-filters.ts +325 -325
  221. package/src/flex-render.ts +3 -3
  222. package/src/form-field.ts +127 -127
  223. package/src/grid-icons.test.ts +46 -0
  224. package/src/grid-icons.ts +184 -0
  225. package/src/group-display.test.ts +167 -167
  226. package/src/group-display.ts +200 -200
  227. package/src/headless.ts +87 -87
  228. package/src/icon-overrides.grid.test.ts +188 -0
  229. package/src/icon-seam.test.ts +100 -0
  230. package/src/index.ts +19 -0
  231. package/src/js-scroller.svelte.ts +173 -173
  232. package/src/keyboard-handlers.ts +270 -270
  233. package/src/keyboard.test.ts +59 -59
  234. package/src/keyboard.ts +97 -97
  235. package/src/list-nav.test.ts +49 -49
  236. package/src/list-nav.ts +29 -29
  237. package/src/list-option.test.ts +56 -56
  238. package/src/list-option.ts +179 -179
  239. package/src/menus.ts +597 -597
  240. package/src/merge-objects.ts +48 -48
  241. package/src/overlays.test.ts +90 -90
  242. package/src/positioning.ts +268 -268
  243. package/src/render-component.ts +28 -28
  244. package/src/row-drag.test.ts +401 -401
  245. package/src/row-drag.ts +419 -419
  246. package/src/row-resize.test.ts +524 -524
  247. package/src/row-resize.ts +228 -228
  248. package/src/scheduler-ical.ts +181 -181
  249. package/src/scheduler-model.test.ts +562 -562
  250. package/src/scheduler-model.ts +873 -873
  251. package/src/selection.test.ts +885 -885
  252. package/src/server-data-source.test.ts +383 -383
  253. package/src/server-data-source.ts +469 -469
  254. package/src/sparkline.test.ts +68 -68
  255. package/src/sparkline.ts +169 -169
  256. package/src/spreadsheet.test.ts +488 -488
  257. package/src/spreadsheet.ts +312 -312
  258. package/src/static-functions.ts +11 -11
  259. package/src/subscribe.ts +38 -38
  260. package/src/summaries.ts +113 -113
  261. package/src/svgrid-wrapper.types.ts +563 -563
  262. package/src/svgrid.async-editor-options.test.ts +273 -273
  263. package/src/svgrid.auto-row-height.test.ts +204 -204
  264. package/src/svgrid.behavior.test.ts +910 -910
  265. package/src/svgrid.charting.test.ts +654 -534
  266. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  267. package/src/svgrid.context-menu.test.ts +147 -147
  268. package/src/svgrid.features.test.ts +157 -157
  269. package/src/svgrid.filter-depth.test.ts +163 -163
  270. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  271. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  272. package/src/svgrid.grand-total.test.ts +188 -188
  273. package/src/svgrid.group-display-mode.test.ts +171 -171
  274. package/src/svgrid.group-footers.test.ts +121 -121
  275. package/src/svgrid.group-pagination.test.ts +153 -153
  276. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  277. package/src/svgrid.tree-data.test.ts +186 -186
  278. package/src/svgrid.wrapper.test.ts +63 -63
  279. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  280. package/src/test-setup.ts +62 -62
  281. package/src/themes/index.ts +288 -288
  282. package/src/toast-store.svelte.ts +250 -250
  283. package/src/toast-store.test.ts +147 -147
  284. package/src/tree-row-model.test.ts +168 -168
  285. package/src/ui-buttons.test.ts +144 -144
  286. package/src/ui-inputs.test.ts +118 -118
  287. package/src/ui-localization.test.ts +113 -113
  288. package/src/ui-range.test.ts +70 -70
  289. package/src/ui-selection.test.ts +155 -155
  290. package/src/ui-tier1.test.ts +142 -142
  291. package/src/virtual.test.ts +88 -88
  292. package/src/virtualization/column-virtualizer.test.ts +27 -27
  293. package/src/virtualization/column-virtualizer.ts +30 -30
  294. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  295. package/src/virtualization/types.ts +30 -30
  296. package/src/virtualization/virtualizer.test.ts +47 -47
  297. package/src/virtualization/virtualizer.ts +322 -322
  298. package/dist/cdn/GridMenus-DMihUtma.js +0 -600
  299. package/dist/cdn/GridMenus-nmNDj1a3.js +0 -601
  300. package/dist/cdn/SvGridChart-Bs2GIR2Q.js +0 -1202
  301. package/dist/cdn/SvGridChart-CwhFz7GV.js +0 -1203
  302. package/dist/cdn/SvGridChartPanel-1WXuStzM.js +0 -561
  303. package/dist/cdn/SvGridChartPanel-e5_nqNP9.js +0 -585
  304. package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/src/editing.ts CHANGED
@@ -1,609 +1,618 @@
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
- }
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
+ /**
295
+ * Focus the editor synchronously, as soon as the action runs.
296
+ *
297
+ * This used to be deferred by a `requestAnimationFrame`, which left a window
298
+ * where the cell already carried `sv-grid-cell-editing` but nothing inside it
299
+ * held focus. `onGridKeyDown` returns early while `editingCell` is set, so a
300
+ * keystroke landing in that window reached neither the grid nor the editor
301
+ * and was simply dropped: double-click, type immediately, lose the first
302
+ * characters. The action only runs once the node is in the document, so there
303
+ * is nothing to wait a frame for.
304
+ */
305
+ function focusOnMount(node: HTMLInputElement | HTMLTextAreaElement) {
306
+ const selectAll = ctx.editorSelectAll;
307
+ node.focus({ preventScroll: true });
308
+ try {
309
+ if (selectAll) {
310
+ node.select();
311
+ } else {
312
+ const end = node.value.length;
313
+ node.setSelectionRange(end, end);
314
+ }
315
+ } catch {
316
+ /* date/number inputs may not support text selection */
317
+ }
318
+ }
319
+
320
+ function onCellDoubleClick(rowIndex: number, colIndex: number) {
321
+ if (!ctx.editingEnabled) return;
322
+ const row = ctx.allRows[rowIndex];
323
+ const column = ctx.allColumns[colIndex];
324
+ if (!row || !column) return;
325
+ if (isGroupRow(row)) return;
326
+ // Full-row editing: put the whole row into edit instead of one cell.
327
+ if (ctx.props.fullRowEditing) {
328
+ if (ctx.fullRowEdit?.rowId === row.id) return;
329
+ startFullRowEdit(rowIndex);
330
+ return;
331
+ }
332
+ if (!isCellEditable(column, row)) return;
333
+ if (ctx.editingCell?.rowId === row.id && ctx.editingCell?.columnId === column.id)
334
+ return;
335
+ const editorType = (column.columnDef.editorType ??
336
+ "text") as CellEditorType;
337
+ ctx.editorSelectAll = true;
338
+ let initialValue = getCellDisplayValue(
339
+ row.id,
340
+ column.id,
341
+ row.getCellValueByColumnId(column.id),
342
+ );
343
+ if (editorType === "list" || editorType === "chips") {
344
+ if (column.columnDef.editorMultiple) {
345
+ // Seed with an array so the editor can mutate slot-by-slot.
346
+ initialValue = toValueArray(initialValue);
347
+ } else if (Array.isArray(initialValue)) {
348
+ initialValue = initialValue[0] ?? "";
349
+ }
350
+ }
351
+ ctx.editingCell = {
352
+ rowId: row.id,
353
+ columnId: column.id,
354
+ editorType,
355
+ value: initialValue,
356
+ rowRef: row.original,
357
+ };
358
+ ctx.setActiveCell(rowIndex, colIndex);
359
+ ctx.setSelection(rowIndex, colIndex);
360
+ }
361
+
362
+ async function pasteFromClipboard() {
363
+ // Preferred path: the async Clipboard API. Requires a secure context
364
+ // (HTTPS or localhost) AND read permission; it also works on Firefox,
365
+ // where the native `paste` event does NOT fire on a non-editable element.
366
+ // On plain HTTP (a XAMPP/Apache LAN host) `navigator.clipboard` is
367
+ // undefined - there the Ctrl+V keydown handler skips preventDefault and
368
+ // lets the browser deliver a native `paste` event to `onGridPaste`.
369
+ if (!navigator.clipboard?.readText) return;
370
+ let text: string;
371
+ try {
372
+ text = await navigator.clipboard.readText();
373
+ } catch {
374
+ return;
375
+ }
376
+ applyPastedText(text);
377
+ }
378
+
379
+ /**
380
+ * Native `paste` ClipboardEvent fallback (insecure-context path). A real
381
+ * Ctrl/Cmd+V on the focused grid root fires this with `clipboardData`
382
+ * readable even over plain HTTP and with no permission prompt. Wired on the
383
+ * grid root in SvGrid.svelte; the keydown handler lets the gesture through
384
+ * (no preventDefault) whenever the async API is unavailable.
385
+ */
386
+ function onGridPaste(event: ClipboardEvent) {
387
+ // A cell is being edited: the focused editor input handles its own paste
388
+ // natively. Never hijack it for a range paste - doing so (on Firefox /
389
+ // insecure contexts, where the async Clipboard API is unavailable) would
390
+ // preventDefault the editor's paste and write to the data cell instead,
391
+ // so the edit "wouldn't update".
392
+ if (ctx.editingCell) return;
393
+ // When the async API is available the keydown handler already pasted via
394
+ // pasteFromClipboard(); ignore the (suppressed) native event so we don't
395
+ // paste twice.
396
+ if (typeof navigator.clipboard?.readText === "function") return;
397
+ const text = event.clipboardData?.getData("text/plain") ?? "";
398
+ if (!text) return;
399
+ event.preventDefault();
400
+ applyPastedText(text);
401
+ }
402
+
403
+ function applyPastedText(text: string) {
404
+ const anchor = ctx.selectionRange.anchor ?? ctx.grid.getState().activeCell;
405
+ if (!anchor) return;
406
+ const focus = ctx.selectionRange.focus ?? anchor;
407
+ const startRow = Math.min(anchor.rowIndex, focus.rowIndex);
408
+ const startCol = Math.min(anchor.colIndex, focus.colIndex);
409
+ const endRow = Math.max(anchor.rowIndex, focus.rowIndex);
410
+ const endCol = Math.max(anchor.colIndex, focus.colIndex);
411
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
412
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
413
+ if (!lines.length) return;
414
+
415
+ // Excel/Sheets behaviour: when the clipboard holds a single cell and
416
+ // the selection is a range, fill the entire range with that value.
417
+ // For larger clipboards (multi-row/col TSV), paste at the top-left
418
+ // corner and let the clipboard determine the span (the original
419
+ // behaviour).
420
+ const clipboardIsSingleCell =
421
+ lines.length === 1 && (lines[0]?.split("\t").length ?? 0) === 1;
422
+ const selectionIsRange = startRow !== endRow || startCol !== endCol;
423
+ const fillRange = clipboardIsSingleCell && selectionIsRange;
424
+
425
+ const rowSpan = fillRange ? endRow - startRow + 1 : lines.length;
426
+ const colSpan = fillRange
427
+ ? endCol - startCol + 1
428
+ : Math.max(...lines.map((l) => l.split("\t").length));
429
+
430
+ const next = ctx.internalData.slice() as Array<TData>;
431
+ const dataIndexOf = createDataIndexLookup(next);
432
+ for (let i = 0; i < rowSpan; i += 1) {
433
+ const targetRowIndex = startRow + i;
434
+ const row = ctx.allRows[targetRowIndex];
435
+ if (!row || isGroupRow(row)) continue;
436
+ // Map the visible row back to its slot in the data array by identity -
437
+ // `row.id` is only the index under the default getRowId.
438
+ const dataIndex = dataIndexOf(row);
439
+ if (dataIndex < 0) continue;
440
+ const originalRow = next[dataIndex];
441
+ if (!originalRow) continue;
442
+ const sourceCells = fillRange
443
+ ? null
444
+ : lines[i]?.split("\t") ?? [];
445
+ const updated: Record<string, unknown> = {
446
+ ...(originalRow as Record<string, unknown>),
447
+ };
448
+ for (let j = 0; j < colSpan; j += 1) {
449
+ const column = ctx.allColumns[startCol + j];
450
+ if (!column?.columnDef.field) continue;
451
+ if (!isCellEditableAt(targetRowIndex, startCol + j)) continue;
452
+ const editorType = (column.columnDef.editorType ??
453
+ "text") as CellEditorType;
454
+ const raw = fillRange ? lines[0]! : sourceCells?.[j] ?? "";
455
+ updated[column.columnDef.field] = parseEditorValue(editorType, raw, {
456
+ dateOnly: column.columnDef.cellDataType === "dateString",
457
+ });
458
+ }
459
+ next[dataIndex] = updated as TData;
460
+ }
461
+ ctx.internalData = next;
462
+ ctx.grid.store.setState((prev: any) => ({ ...prev }));
463
+ }
464
+
465
+ /** Programmatically begin editing a cell (mirrors a double-click). */
466
+ function startEditing(rowIndex: number, columnId: string): boolean {
467
+ if (!ctx.editingEnabled) return false;
468
+ const row = ctx.allRows[rowIndex];
469
+ if (!row || isGroupRow(row)) return false;
470
+ const colIndex = ctx.allColumns.findIndex((c: any) => c.id === columnId);
471
+ if (colIndex < 0) return false;
472
+ const column = ctx.allColumns[colIndex];
473
+ if (!isCellEditable(column, row)) return false;
474
+ if (
475
+ ctx.editingCell?.rowId === row.id &&
476
+ ctx.editingCell?.columnId === columnId
477
+ )
478
+ return true;
479
+ onCellDoubleClick(rowIndex, colIndex);
480
+ return (
481
+ ctx.editingCell?.rowId === row.id &&
482
+ ctx.editingCell?.columnId === columnId
483
+ );
484
+ }
485
+
486
+ /** Commit (default) or cancel the active edit, if any. */
487
+ function stopEditing(cancel = false): boolean {
488
+ if (!ctx.editingCell) return false;
489
+ if (cancel) {
490
+ ctx.editingCell = null;
491
+ } else {
492
+ saveEditingCell();
493
+ }
494
+ return true;
495
+ }
496
+
497
+ // ---- Full-row editing -------------------------------------------------
498
+ /** Put the whole row into edit mode, seeding a draft per editable column. */
499
+ function startFullRowEdit(rowIndex: number): boolean {
500
+ if (!ctx.editingEnabled || !ctx.props.fullRowEditing) return false;
501
+ const row = ctx.allRows[rowIndex];
502
+ if (!row || isGroupRow(row)) return false;
503
+ if (ctx.fullRowEdit && ctx.fullRowEdit.rowId !== row.id) commitFullRowEdit();
504
+ ctx.editingCell = null; // never both at once
505
+ const draft: Record<string, unknown> = {};
506
+ for (const column of ctx.allColumns) {
507
+ if (!column.columnDef.field) continue;
508
+ if (!isCellEditable(column, row)) continue;
509
+ draft[column.id] = getCellDisplayValue(
510
+ row.id,
511
+ column.id,
512
+ row.getCellValueByColumnId(column.id),
513
+ );
514
+ }
515
+ ctx.fullRowEdit = { rowId: row.id, draft };
516
+ ctx.setActiveCell(rowIndex, Math.max(0, ctx.allColumns.findIndex((c: any) => c.id in draft)));
517
+ return true;
518
+ }
519
+
520
+ /** Stage one column's draft value during full-row editing. */
521
+ function setFullRowDraft(columnId: string, value: unknown): void {
522
+ const fr = ctx.fullRowEdit;
523
+ if (!fr) return;
524
+ ctx.fullRowEdit = { rowId: fr.rowId, draft: { ...fr.draft, [columnId]: value } };
525
+ }
526
+
527
+ /** Commit every drafted column of the full-row edit in one data update. */
528
+ function commitFullRowEdit(): void {
529
+ const fr = ctx.fullRowEdit;
530
+ if (!fr) return;
531
+ const row = ctx.allRows.find((r: any) => r.id === fr.rowId);
532
+ if (!row?.original) {
533
+ ctx.fullRowEdit = null;
534
+ return;
535
+ }
536
+ const changed: Array<{ columnId: string; field: string; before: unknown; after: unknown }> = [];
537
+ for (const column of ctx.allColumns) {
538
+ if (!(column.id in fr.draft)) continue;
539
+ const field = column.columnDef.field as string | undefined;
540
+ if (!field) continue;
541
+ const editorType = (column.columnDef.editorType ?? "text") as CellEditorType;
542
+ const parsed = parseEditorValue(editorType, fr.draft[column.id], {
543
+ multiple: column.columnDef.editorMultiple === true,
544
+ dateOnly: column.columnDef.cellDataType === "dateString",
545
+ });
546
+ const oldValue = (row.original as Record<string, unknown>)[field];
547
+ const parser = (column.columnDef as { valueParser?: (p: unknown) => unknown }).valueParser;
548
+ const finalValue = parser
549
+ ? parser({
550
+ newValue: parsed,
551
+ oldValue,
552
+ rawInput: String(fr.draft[column.id] ?? ""),
553
+ data: row.original,
554
+ columnId: column.id,
555
+ })
556
+ : parsed;
557
+ if (oldValue !== finalValue) {
558
+ (row.original as Record<string, unknown>)[field] = finalValue;
559
+ const key = getCellKey(fr.rowId, column.id);
560
+ ctx.editedCellValues = { ...ctx.editedCellValues, [key]: finalValue };
561
+ changed.push({ columnId: column.id, field, before: oldValue, after: finalValue });
562
+ }
563
+ }
564
+ // Record the whole-row change as consecutive history steps.
565
+ 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;
574
+ }
575
+ ctx.grid.store.setState((prev: any) => ({ ...prev }));
576
+ if (ctx.props.onCellValueChange && changed.length) {
577
+ const rowIndex = ctx.internalData.indexOf(row.original as TData);
578
+ for (const c of changed) {
579
+ ctx.props.onCellValueChange({
580
+ rowIndex,
581
+ columnId: c.columnId,
582
+ oldValue: c.before,
583
+ newValue: c.after,
584
+ row: row.original as TData,
585
+ });
586
+ }
587
+ }
588
+ ctx.fullRowEdit = null;
589
+ }
590
+
591
+ /** Discard the full-row edit. */
592
+ function cancelFullRowEdit(): void {
593
+ ctx.fullRowEdit = null;
594
+ }
595
+
596
+ return {
597
+ isCellEditable,
598
+ isCellEditableAt,
599
+ getRowColumnValue,
600
+ getCellDisplayValue,
601
+ startEditingWithChar,
602
+ startEditing,
603
+ stopEditing,
604
+ startFullRowEdit,
605
+ setFullRowDraft,
606
+ commitFullRowEdit,
607
+ cancelFullRowEdit,
608
+ saveEditingCell,
609
+ applyHistoryStep,
610
+ updateEditingCellValue,
611
+ onEditorKeyDown,
612
+ commitAndMoveByTab,
613
+ focusOnMount,
614
+ onCellDoubleClick,
615
+ pasteFromClipboard,
616
+ onGridPaste,
617
+ };
618
+ }