@svgrid/grid 2.7.0 → 3.0.1

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 (264) 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 +181 -181
  5. package/dist/SvAutoComplete.svelte +169 -169
  6. package/dist/SvAvatar.svelte +75 -75
  7. package/dist/SvCalendar.svelte +503 -503
  8. package/dist/SvCarousel.svelte +141 -141
  9. package/dist/SvCheckBox.svelte +102 -102
  10. package/dist/SvCircularProgress.svelte +109 -109
  11. package/dist/SvColorInput.svelte +181 -181
  12. package/dist/SvComboBox.svelte +279 -279
  13. package/dist/SvContextMenu.svelte +116 -116
  14. package/dist/SvCountryInput.svelte +163 -163
  15. package/dist/SvDrawer.svelte +254 -254
  16. package/dist/SvDropDownList.svelte +378 -378
  17. package/dist/SvDurationInput.svelte +126 -126
  18. package/dist/SvField.svelte +293 -293
  19. package/dist/SvForm.svelte +437 -437
  20. package/dist/SvGridCellEditor.svelte +748 -744
  21. package/dist/SvGridChart.svelte +1716 -1716
  22. package/dist/SvGridChartPanel.svelte +485 -485
  23. package/dist/SvGridChartView.svelte +70 -70
  24. package/dist/SvGridDropdown.svelte +728 -728
  25. package/dist/SvGridSelect.svelte +270 -270
  26. package/dist/SvGroupCell.svelte +116 -116
  27. package/dist/SvListBox.svelte +334 -334
  28. package/dist/SvMaskedInput.svelte +122 -122
  29. package/dist/SvMenu.svelte +124 -124
  30. package/dist/SvMenuList.svelte +146 -146
  31. package/dist/SvMultiSelect.svelte +293 -293
  32. package/dist/SvNumberInput.svelte +172 -172
  33. package/dist/SvOtpInput.svelte +158 -158
  34. package/dist/SvPasswordInput.svelte +151 -151
  35. package/dist/SvPhoneInput.svelte +133 -133
  36. package/dist/SvPopover.svelte +197 -197
  37. package/dist/SvProgress.svelte +116 -116
  38. package/dist/SvRadioGroup.svelte +107 -107
  39. package/dist/SvRating.svelte +112 -112
  40. package/dist/SvResult.svelte +73 -73
  41. package/dist/SvRichText.svelte +211 -211
  42. package/dist/SvRowGroupPanel.svelte +170 -170
  43. package/dist/SvScrollArea.svelte +61 -61
  44. package/dist/SvSlider.svelte +203 -203
  45. package/dist/SvSwitchButton.svelte +108 -108
  46. package/dist/SvTagsInput.svelte +115 -115
  47. package/dist/SvTextInput.svelte +147 -147
  48. package/dist/SvTimePicker.svelte +245 -245
  49. package/dist/SvToaster.svelte +159 -159
  50. package/dist/SvToggleButton.svelte +85 -85
  51. package/dist/SvTooltip.svelte +161 -161
  52. package/dist/SvTour.svelte +208 -208
  53. package/dist/SvTree.svelte +444 -444
  54. package/dist/SvTreeSelect.svelte +239 -239
  55. package/dist/cdn/{GridMenus-C4oLsJyj.js → GridMenus-CYzOvmNQ.js} +3 -3
  56. package/dist/cdn/{GridMenus-yEivjNwl.js → GridMenus-DFB6iwF8.js} +3 -3
  57. package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
  58. package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
  59. package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
  60. package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
  61. package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
  62. package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
  63. package/dist/cdn/{SvGridChart-CwhFz7GV.js → SvGridChart-BEJmNNx9.js} +1 -1
  64. package/dist/cdn/{SvGridChart-Bs2GIR2Q.js → SvGridChart-BwVos976.js} +1 -1
  65. package/dist/cdn/{SvGridChartPanel-1WXuStzM.js → SvGridChartPanel-BOknOkrP.js} +1 -1
  66. package/dist/cdn/{SvGridChartPanel-e5_nqNP9.js → SvGridChartPanel-D2PjII4O.js} +1 -1
  67. package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-00LPUHL1.js} +1 -1
  68. package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-BhUEJ5ki.js} +1 -1
  69. package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
  70. package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
  71. package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
  72. package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
  73. package/dist/cdn/{row-drag-touch-Ddaa-QeB.js → row-drag-touch-CycRR65n.js} +12 -10
  74. package/dist/cdn/{src-COC0vnpg.js → src-C8QjSaq-.js} +53 -55
  75. package/dist/cdn/{src-pYGV2rMR.js → src-D0hO2eYN.js} +52 -54
  76. package/dist/cdn/svgrid.js +7 -7
  77. package/dist/cdn/svgrid.svelte-external.js +7 -7
  78. package/dist/chart-export.js +8 -8
  79. package/dist/editing.js +22 -13
  80. package/dist/row-drag-touch.d.ts +5 -1
  81. package/dist/row-drag-touch.js +15 -4
  82. package/dist/row-drag.js +7 -3
  83. package/package.json +11 -11
  84. package/src/FlexRender.svelte +96 -96
  85. package/src/GridFooter.svelte +181 -181
  86. package/src/SvAutoComplete.svelte +169 -169
  87. package/src/SvAvatar.svelte +75 -75
  88. package/src/SvCalendar.svelte +503 -503
  89. package/src/SvCalendar.test.ts +226 -226
  90. package/src/SvCarousel.svelte +141 -141
  91. package/src/SvCheckBox.svelte +102 -102
  92. package/src/SvCircularProgress.svelte +109 -109
  93. package/src/SvColorInput.svelte +181 -181
  94. package/src/SvComboBox.svelte +279 -279
  95. package/src/SvContextMenu.svelte +116 -116
  96. package/src/SvCountryInput.svelte +163 -163
  97. package/src/SvDrawer.svelte +254 -254
  98. package/src/SvDropDownList.svelte +378 -378
  99. package/src/SvDurationInput.svelte +126 -126
  100. package/src/SvField.svelte +293 -293
  101. package/src/SvForm.svelte +437 -437
  102. package/src/SvForm.test.ts +411 -411
  103. package/src/SvGrid.types.ts +2092 -2092
  104. package/src/SvGridCellEditor.svelte +748 -744
  105. package/src/SvGridChart.svelte +1716 -1716
  106. package/src/SvGridChartPanel.svelte +485 -485
  107. package/src/SvGridChartView.svelte +70 -70
  108. package/src/SvGridDropdown.svelte +728 -728
  109. package/src/SvGridSelect.svelte +270 -270
  110. package/src/SvGroupCell.svelte +116 -116
  111. package/src/SvListBox.svelte +334 -334
  112. package/src/SvMaskedInput.svelte +122 -122
  113. package/src/SvMenu.svelte +124 -124
  114. package/src/SvMenu.test.ts +97 -97
  115. package/src/SvMenuList.svelte +146 -146
  116. package/src/SvMultiSelect.svelte +293 -293
  117. package/src/SvNumberInput.svelte +172 -172
  118. package/src/SvOtpInput.svelte +158 -158
  119. package/src/SvPasswordInput.svelte +151 -151
  120. package/src/SvPhoneInput.svelte +133 -133
  121. package/src/SvPopover.svelte +197 -197
  122. package/src/SvProgress.svelte +116 -116
  123. package/src/SvRadioGroup.svelte +107 -107
  124. package/src/SvRating.svelte +112 -112
  125. package/src/SvResult.svelte +73 -73
  126. package/src/SvRichText.svelte +211 -211
  127. package/src/SvRowGroupPanel.svelte +170 -170
  128. package/src/SvScrollArea.svelte +61 -61
  129. package/src/SvSlider.svelte +203 -203
  130. package/src/SvSwitchButton.svelte +108 -108
  131. package/src/SvTagsInput.svelte +115 -115
  132. package/src/SvTextInput.svelte +147 -147
  133. package/src/SvTimePicker.svelte +245 -245
  134. package/src/SvToaster.svelte +159 -159
  135. package/src/SvToaster.test.ts +95 -95
  136. package/src/SvToggleButton.svelte +85 -85
  137. package/src/SvTooltip.svelte +161 -161
  138. package/src/SvTour.svelte +208 -208
  139. package/src/SvTree.svelte +444 -444
  140. package/src/SvTreeSelect.svelte +239 -239
  141. package/src/a11y/dismissable.test.ts +119 -119
  142. package/src/a11y/dismissable.ts +114 -114
  143. package/src/a11y.contract.test.ts +49 -49
  144. package/src/a11y.test.ts +59 -59
  145. package/src/a11y.ts +61 -61
  146. package/src/ai.test.ts +502 -502
  147. package/src/ai.ts +1419 -1419
  148. package/src/build-api.coverage.test.ts +633 -633
  149. package/src/build-api.ts +846 -846
  150. package/src/builtin-editors.grid.test.ts +83 -83
  151. package/src/cell-formatting.ts +171 -171
  152. package/src/cell-render.test.ts +513 -513
  153. package/src/cell-render.ts +496 -496
  154. package/src/cell-values.ts +148 -148
  155. package/src/chart-export.ts +202 -202
  156. package/src/chart-view.svelte.ts +36 -36
  157. package/src/chart.ts +2321 -2321
  158. package/src/collaboration.test.ts +104 -104
  159. package/src/collaboration.ts +167 -167
  160. package/src/column-groups.ts +78 -78
  161. package/src/core.performance.test.ts +30 -30
  162. package/src/core.ts +1865 -1865
  163. package/src/createAutocomplete.svelte.ts +132 -132
  164. package/src/createCombobox.svelte.ts +191 -191
  165. package/src/createCountryInput.svelte.ts +157 -157
  166. package/src/createDropdownList.svelte.ts +168 -168
  167. package/src/createForm.svelte.ts +386 -386
  168. package/src/createGrid.svelte.ts +42 -42
  169. package/src/createGrid.test.ts +10 -10
  170. package/src/createGridState.svelte.ts +17 -17
  171. package/src/createListbox.svelte.ts +250 -250
  172. package/src/createMenu.svelte.ts +224 -224
  173. package/src/createPopoverSelect.svelte.ts +213 -213
  174. package/src/createSlider.svelte.ts +191 -191
  175. package/src/createTooltip.svelte.ts +144 -144
  176. package/src/createTree.svelte.ts +322 -322
  177. package/src/datetime/date-core.ts +206 -206
  178. package/src/datetime/date-restrict.ts +61 -61
  179. package/src/datetime/timezone.ts +135 -135
  180. package/src/dock-manager-model.ts +596 -596
  181. package/src/dock-model.ts +374 -374
  182. package/src/editing.test.ts +974 -974
  183. package/src/editing.ts +618 -609
  184. package/src/editor-contract.ts +171 -171
  185. package/src/editor-registry.grid.test.ts +144 -144
  186. package/src/editor-registry.ts +122 -122
  187. package/src/export-data-api.test.ts +126 -126
  188. package/src/export-format.test.ts +107 -107
  189. package/src/export-format.ts +601 -601
  190. package/src/filter-operators.ts +160 -160
  191. package/src/filtering/excel-filters.ts +325 -325
  192. package/src/flex-render.ts +3 -3
  193. package/src/form-field.ts +127 -127
  194. package/src/group-display.test.ts +167 -167
  195. package/src/group-display.ts +200 -200
  196. package/src/headless.ts +87 -87
  197. package/src/js-scroller.svelte.ts +173 -173
  198. package/src/keyboard-handlers.ts +270 -270
  199. package/src/keyboard.test.ts +59 -59
  200. package/src/keyboard.ts +97 -97
  201. package/src/list-nav.test.ts +49 -49
  202. package/src/list-nav.ts +29 -29
  203. package/src/list-option.test.ts +56 -56
  204. package/src/list-option.ts +179 -179
  205. package/src/menus.ts +597 -597
  206. package/src/merge-objects.ts +48 -48
  207. package/src/overlays.test.ts +90 -90
  208. package/src/positioning.ts +268 -268
  209. package/src/render-component.ts +28 -28
  210. package/src/row-drag-touch.ts +226 -211
  211. package/src/row-drag.test.ts +401 -353
  212. package/src/row-drag.ts +419 -415
  213. package/src/row-resize.test.ts +524 -524
  214. package/src/row-resize.ts +228 -228
  215. package/src/scheduler-ical.ts +181 -181
  216. package/src/scheduler-model.test.ts +562 -562
  217. package/src/scheduler-model.ts +873 -873
  218. package/src/selection.test.ts +885 -885
  219. package/src/server-data-source.test.ts +383 -383
  220. package/src/server-data-source.ts +469 -469
  221. package/src/sparkline.test.ts +68 -68
  222. package/src/sparkline.ts +169 -169
  223. package/src/spreadsheet.test.ts +488 -488
  224. package/src/spreadsheet.ts +312 -312
  225. package/src/static-functions.ts +11 -11
  226. package/src/subscribe.ts +38 -38
  227. package/src/summaries.ts +113 -113
  228. package/src/svgrid-wrapper.types.ts +563 -563
  229. package/src/svgrid.async-editor-options.test.ts +273 -273
  230. package/src/svgrid.auto-row-height.test.ts +204 -204
  231. package/src/svgrid.behavior.test.ts +910 -910
  232. package/src/svgrid.charting.test.ts +534 -534
  233. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  234. package/src/svgrid.context-menu.test.ts +147 -147
  235. package/src/svgrid.features.test.ts +157 -157
  236. package/src/svgrid.filter-depth.test.ts +163 -163
  237. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  238. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  239. package/src/svgrid.grand-total.test.ts +188 -188
  240. package/src/svgrid.group-display-mode.test.ts +171 -171
  241. package/src/svgrid.group-footers.test.ts +121 -121
  242. package/src/svgrid.group-pagination.test.ts +153 -153
  243. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  244. package/src/svgrid.tree-data.test.ts +186 -186
  245. package/src/svgrid.wrapper.test.ts +63 -63
  246. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  247. package/src/test-setup.ts +62 -62
  248. package/src/themes/index.ts +288 -288
  249. package/src/toast-store.svelte.ts +250 -250
  250. package/src/toast-store.test.ts +147 -147
  251. package/src/tree-row-model.test.ts +168 -168
  252. package/src/ui-buttons.test.ts +144 -144
  253. package/src/ui-inputs.test.ts +118 -118
  254. package/src/ui-localization.test.ts +113 -113
  255. package/src/ui-range.test.ts +70 -70
  256. package/src/ui-selection.test.ts +155 -155
  257. package/src/ui-tier1.test.ts +142 -142
  258. package/src/virtual.test.ts +88 -88
  259. package/src/virtualization/column-virtualizer.test.ts +27 -27
  260. package/src/virtualization/column-virtualizer.ts +30 -30
  261. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  262. package/src/virtualization/types.ts +30 -30
  263. package/src/virtualization/virtualizer.test.ts +47 -47
  264. package/src/virtualization/virtualizer.ts +322 -322
@@ -1,744 +1,748 @@
1
- <script lang="ts" generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData">
2
- /**
3
- * The in-cell and full-row editor UI, lifted out of SvGrid.svelte.
4
- *
5
- * Editing is off by default (`editable` / `enableInlineEditing` both default
6
- * to false) and this markup only ever renders while a cell is actually being
7
- * edited, so it has no business sitting in the base bundle. SvGrid.svelte
8
- * loads it with `import()` the moment editing is enabled - well before any
9
- * double-click can happen - so behaviour is unchanged either way.
10
- *
11
- * Keep `SvGridDropdown` and `SvDateTimePicker` DYNAMIC below. Importing
12
- * either statically would drag ~22 KB into this chunk for every text edit,
13
- * and a static import from anywhere reachable by SvGrid.svelte has silently
14
- * defeated a lazy boundary in this codebase before.
15
- */
16
- import {
17
- type CellEditorOption,
18
- type Column,
19
- type EditorContext,
20
- type Row,
21
- type RowData,
22
- type TableFeatures,
23
- } from "./index";
24
- import {
25
- dateToTimeString,
26
- getEditableInputValue,
27
- getEditorClass,
28
- getEditorInputType,
29
- getCellEditorInputType,
30
- isNumericEditorInput,
31
- timeStringToDate,
32
- toValueArray,
33
- } from "./SvGrid.helpers";
34
- import {
35
- getCellEditor,
36
- hasCellEditor,
37
- resolveEditorProps,
38
- type CellEditorContext,
39
- } from "./editor-registry";
40
- import type { SvGridController } from "./SvGrid.controller.svelte";
41
-
42
- let {
43
- ctrl,
44
- column,
45
- row,
46
- fullRow = false,
47
- }: {
48
- ctrl: SvGridController<TFeatures, TData>;
49
- column: Column<TData>;
50
- row: Row<TData>;
51
- /** Render the full-row editor for this cell instead of the single-cell one. */
52
- fullRow?: boolean;
53
- } = $props();
54
-
55
- // Heavy editors stay behind their own `import()` so a text edit never pays
56
- // for the dropdown or the date picker.
57
- let DropdownEditor = $state<typeof import("./SvGridDropdown.svelte").default | null>(null);
58
- let DateEditor = $state<typeof import("./SvDateTimePicker.svelte").default | null>(null);
59
- $effect(() => {
60
- const t = ctrl.editingCell?.editorType;
61
- if (
62
- (t === "list" || t === "chips" || t === "select" || t === "rich-select") &&
63
- !DropdownEditor
64
- )
65
- import("./SvGridDropdown.svelte").then((m) => (DropdownEditor = m.default));
66
- if ((t === "date" || t === "datetime" || t === "time") && !DateEditor)
67
- import("./SvDateTimePicker.svelte").then((m) => (DateEditor = m.default));
68
- });
69
-
70
- const grid = $derived(ctrl.grid);
71
- const fullRowEdit = $derived(ctrl.fullRowEdit);
72
- const getColumnEditorOptions = $derived(ctrl.getColumnEditorOptions);
73
- const areEditorOptionsLoading = $derived(ctrl.areEditorOptionsLoading);
74
- const saveEditingCell = $derived(ctrl.saveEditingCell);
75
- const updateEditingCellValue = $derived(ctrl.updateEditingCellValue);
76
- const onEditorKeyDown = $derived(ctrl.onEditorKeyDown);
77
- const focusOnMount = $derived(ctrl.focusOnMount);
78
- const toggleCheckboxWithKeyboard = $derived(ctrl.toggleCheckboxWithKeyboard);
79
-
80
- /**
81
- * Focus a button-based editor on mount. `focusOnMount` is for text controls -
82
- * it also places the caret - and the checkbox and rating editors, being
83
- * <button>s, had no equivalent and so never took focus at all. Their own key
84
- * handlers therefore never fired: neither could be operated from the
85
- * keyboard, and Escape never reached them, so the cell stayed stuck in edit
86
- * mode until the user clicked somewhere else.
87
- *
88
- * `enabled` lets a group of buttons (the rating stars) pick which one holds
89
- * focus, since an action cannot be applied conditionally.
90
- */
91
- function focusControlOnMount(node: HTMLElement, enabled: boolean = true) {
92
- if (!enabled) return;
93
- requestAnimationFrame(() => node.focus({ preventScroll: true }));
94
- }
95
-
96
- function fullRowKeydown(e: KeyboardEvent) {
97
- if (e.key === "Enter") {
98
- e.preventDefault();
99
- ctrl.commitFullRowEdit();
100
- ctrl.gridRootEl?.focus({ preventScroll: true });
101
- } else if (e.key === "Escape") {
102
- e.preventDefault();
103
- ctrl.cancelFullRowEdit();
104
- ctrl.gridRootEl?.focus({ preventScroll: true });
105
- }
106
- }
107
- function optionValueOf(o: unknown): string {
108
- return typeof o === "object" && o !== null && "value" in o
109
- ? String((o as { value: unknown }).value)
110
- : String(o);
111
- }
112
- function optionLabelOf(o: unknown): string {
113
- return typeof o === "object" && o !== null && "label" in o
114
- ? String((o as { label: unknown }).label)
115
- : String(o);
116
- }
117
-
118
- // Build the interaction context handed to a CUSTOM cell editor registered via
119
- // `registerCellEditor`. Maps the grid's editing lifecycle onto the uniform
120
- // change / commit / cancel contract every registered editor speaks.
121
- function buildRegisteredEditorContext(): CellEditorContext {
122
- const cell = ctrl.editingCell;
123
- return {
124
- value: cell?.value,
125
- rowId: cell?.rowId ?? "",
126
- columnId: cell?.columnId ?? "",
127
- onChange: (v: unknown) => updateEditingCellValue(v),
128
- onCommit: (v?: unknown) => {
129
- if (v !== undefined) updateEditingCellValue(v);
130
- saveEditingCell();
131
- },
132
- onCancel: () => {
133
- ctrl.editingCell = null;
134
- ctrl.gridRootEl?.focus({ preventScroll: true });
135
- },
136
- // Tab out of an editor: commit, then step to the adjacent cell. Without
137
- // this a registered editor's Tab walks focus out of the grid (#48) - the
138
- // built-in editors already route through the same helper.
139
- onCommitAndMove: (v: unknown, direction: 1 | -1) => {
140
- if (v !== undefined) updateEditingCellValue(v);
141
- ctrl.commitAndMoveByTab(direction === -1);
142
- },
143
- // Close a popover editor without committing. Same effect as cancel today;
144
- // named separately so an editor can distinguish "dismiss my panel" from
145
- // "abandon the edit" if that ever diverges.
146
- onRequestClose: () => {
147
- ctrl.editingCell = null;
148
- ctrl.gridRootEl?.focus({ preventScroll: true });
149
- },
150
- inCell: true,
151
- };
152
- }
153
- </script>
154
-
155
- {#snippet chipsEditor(
156
- opts: CellEditorOption[],
157
- multi: boolean,
158
- selectedArr: Array<string | number>,
159
- optsLoading: boolean,
160
- )}
161
- {#if opts.length > 0}
162
- <!-- Options-driven chips editor: defer to the custom dropdown,
163
- which renders the selected values as chips in its trigger and
164
- pops out a styled listbox. Identical UX to the list editor
165
- with renderChipsInTrigger flipped on. -->
166
- {#if DropdownEditor}
167
- <DropdownEditor
168
- options={opts}
169
- loading={optsLoading}
170
- value={ctrl.editingCell?.value}
171
- multiple={multi}
172
- placeholder="Pick…"
173
- renderChipsInTrigger={true}
174
- onChange={(next) => {
175
- ctrl.editingCell = ctrl.editingCell
176
- ? { ...ctrl.editingCell, value: next }
177
- : ctrl.editingCell;
178
- }}
179
- onCommit={() => saveEditingCell()}
180
- onCancel={() => {
181
- ctrl.editingCell = null;
182
- ctrl.gridRootEl?.focus({ preventScroll: true });
183
- }}
184
- />
185
- {/if}
186
- {:else}
187
- <!-- Free-form chips: typed tags. Enter / comma commits a chip,
188
- Backspace on empty input removes the last chip, blur saves. -->
189
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
190
- <div
191
- class="sv-grid-cell-editor sv-grid-cell-editor-chips"
192
- role="group"
193
- tabindex={-1}
194
- >
195
- <div class="sv-grid-chips-row">
196
- {#each selectedArr as v, idx (String(v) + "_" + idx)}
197
- <span class="sv-grid-chip sv-grid-chip-removable">
198
- {String(v)}
199
- <button
200
- type="button"
201
- class="sv-grid-chip-remove"
202
- aria-label="Remove {String(v)}"
203
- onmousedown={(event) => event.preventDefault()}
204
- onclick={() => {
205
- const next = selectedArr.filter((_, i) => i !== idx);
206
- ctrl.editingCell = ctrl.editingCell
207
- ? {
208
- ...ctrl.editingCell,
209
- value: multi ? next : (next[0] ?? null),
210
- }
211
- : ctrl.editingCell;
212
- }}>×</button
213
- >
214
- </span>
215
- {/each}
216
- <input
217
- use:focusOnMount
218
- class="sv-grid-chip-input"
219
- type="text"
220
- placeholder={multi ? "Type, Enter to add" : "Type a value"}
221
- onkeydown={(event) => {
222
- if (event.key === "Enter" || (multi && event.key === ",")) {
223
- event.preventDefault();
224
- event.stopPropagation();
225
- const input = event.currentTarget as HTMLInputElement;
226
- const raw = input.value.trim();
227
- if (raw) {
228
- const next = multi ? [...selectedArr, raw] : [raw];
229
- ctrl.editingCell = ctrl.editingCell
230
- ? { ...ctrl.editingCell, value: multi ? next : raw }
231
- : ctrl.editingCell;
232
- input.value = "";
233
- if (!multi) saveEditingCell();
234
- }
235
- } else if (event.key === "Escape") {
236
- onEditorKeyDown(event);
237
- } else if (event.key === "Backspace") {
238
- const input = event.currentTarget as HTMLInputElement;
239
- if (input.value === "" && selectedArr.length > 0) {
240
- event.preventDefault();
241
- const next = selectedArr.slice(0, -1);
242
- ctrl.editingCell = ctrl.editingCell
243
- ? {
244
- ...ctrl.editingCell,
245
- value: multi ? next : (next[0] ?? null),
246
- }
247
- : ctrl.editingCell;
248
- }
249
- }
250
- }}
251
- onblur={() => saveEditingCell()}
252
- />
253
- {#if multi}
254
- <button
255
- type="button"
256
- class="sv-grid-chip-commit"
257
- onmousedown={(event) => event.preventDefault()}
258
- onclick={() => saveEditingCell()}
259
- aria-label="Commit chip selection">Done</button
260
- >
261
- {/if}
262
- </div>
263
- </div>
264
- {/if}
265
- {/snippet}
266
-
267
- {#if fullRow}
268
- {@const et = column.columnDef.editorType ?? "text"}
269
- {@const val = fullRowEdit?.draft[column.id]}
270
- {#if et === "checkbox"}
271
- <input
272
- type="checkbox"
273
- class="sv-grid-fr-editor sv-grid-fr-checkbox"
274
- checked={Boolean(val)}
275
- onchange={(e) =>
276
- ctrl.setFullRowDraft(column.id, e.currentTarget.checked)}
277
- onkeydown={fullRowKeydown}
278
- onpointerdown={(e) => e.stopPropagation()}
279
- onclick={(e) => e.stopPropagation()}
280
- />
281
- {:else if et === "list" || et === "select" || et === "rich-select"}
282
- {@const opts = getColumnEditorOptions(column, row)}
283
- <select
284
- class="sv-grid-cell-editor sv-grid-fr-editor"
285
- value={String(val ?? "")}
286
- onchange={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
287
- onkeydown={fullRowKeydown}
288
- onpointerdown={(e) => e.stopPropagation()}
289
- onclick={(e) => e.stopPropagation()}
290
- >
291
- {#each opts as o (optionValueOf(o))}
292
- <option value={optionValueOf(o)}>{optionLabelOf(o)}</option>
293
- {/each}
294
- </select>
295
- {:else}
296
- {@const inputType =
297
- et === "number"
298
- ? "number"
299
- : et === "date"
300
- ? "date"
301
- : et === "datetime"
302
- ? "datetime-local"
303
- : et === "time"
304
- ? "time"
305
- : et === "password"
306
- ? "password"
307
- : "text"}
308
- <input
309
- type={inputType}
310
- class="sv-grid-cell-editor sv-grid-fr-editor"
311
- value={String(val ?? "")}
312
- oninput={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
313
- onkeydown={fullRowKeydown}
314
- onpointerdown={(e) => e.stopPropagation()}
315
- onclick={(e) => e.stopPropagation()}
316
- />
317
- {/if}
318
- {:else}
319
- {#if column.columnDef.cellEditor}
320
- <!-- Custom editor slot. The columnDef provides a snippet that
321
- receives the editor context (value + commit + cancel) so the
322
- consumer fully owns the in-cell UI. -->
323
- {@const customEditor = column.columnDef
324
- .cellEditor as unknown as import("svelte").Snippet<
325
- [EditorContext<TData>]
326
- >}
327
- {@render customEditor({
328
- cell: row.getAllCells().find((c) => c.column.id === column.id)!,
329
- row,
330
- column,
331
- table: grid,
332
- getValue: () => ctrl.editingCell?.value,
333
- value: ctrl.editingCell?.value,
334
- update: (next: unknown) => {
335
- // Stage the draft without closing. Live-preview controls
336
- // (sliders, color pickers) call this on every input tick.
337
- ctrl.editingCell = ctrl.editingCell
338
- ? { ...ctrl.editingCell, value: next }
339
- : ctrl.editingCell;
340
- },
341
- commit: (next?: unknown) => {
342
- // Write + close. If the caller passed a value, stage it
343
- // first; otherwise save whatever update() last wrote.
344
- if (next !== undefined) {
345
- ctrl.editingCell = ctrl.editingCell
346
- ? { ...ctrl.editingCell, value: next }
347
- : ctrl.editingCell;
348
- }
349
- saveEditingCell();
350
- },
351
- cancel: () => {
352
- ctrl.editingCell = null;
353
- ctrl.gridRootEl?.focus({ preventScroll: true });
354
- },
355
- })}
356
- {:else if ctrl.editingCell?.editorType === "checkbox"}
357
- <button
358
- use:focusControlOnMount
359
- type="button"
360
- class="sv-grid-checkbox"
361
- role="checkbox"
362
- aria-checked={Boolean(ctrl.editingCell.value)}
363
- aria-label="Edit checkbox value"
364
- onclick={(event) => {
365
- event.stopPropagation();
366
- const nextValue = !ctrl.editingCell?.value;
367
- ctrl.editingCell = ctrl.editingCell
368
- ? { ...ctrl.editingCell, value: nextValue }
369
- : ctrl.editingCell;
370
- saveEditingCell();
371
- }}
372
- onkeydown={(event) => {
373
- // Enter / Space are the checkbox's own activation keys. Everything
374
- // else - Escape to cancel, Tab to commit and move on - goes to the
375
- // shared editor handler, which this editor used to bypass entirely.
376
- if (event.key === "Enter" || event.key === " ") {
377
- toggleCheckboxWithKeyboard(event, () => {
378
- event.stopPropagation();
379
- const nextValue = !ctrl.editingCell?.value;
380
- ctrl.editingCell = ctrl.editingCell
381
- ? { ...ctrl.editingCell, value: nextValue }
382
- : ctrl.editingCell;
383
- saveEditingCell();
384
- });
385
- return;
386
- }
387
- onEditorKeyDown(event);
388
- }}
389
- onblur={() => saveEditingCell()}
390
- ></button>
391
- {:else if ctrl.editingCell?.editorType === "list"}
392
- {@const opts = getColumnEditorOptions(column, row)}
393
- {@const optsLoading = areEditorOptionsLoading(column, row)}
394
- {@const multi = column.columnDef.editorMultiple === true}
395
- {#if DropdownEditor}
396
- <DropdownEditor
397
- options={opts}
398
- loading={optsLoading}
399
- value={ctrl.editingCell?.value}
400
- multiple={multi}
401
- placeholder="Select…"
402
- onChange={(next) => {
403
- ctrl.editingCell = ctrl.editingCell
404
- ? { ...ctrl.editingCell, value: next }
405
- : ctrl.editingCell;
406
- }}
407
- onCommit={() => saveEditingCell()}
408
- onCancel={() => {
409
- ctrl.editingCell = null;
410
- ctrl.gridRootEl?.focus({ preventScroll: true });
411
- }}
412
- />
413
- {/if}
414
- {:else if ctrl.editingCell?.editorType === "chips"}
415
- {@const opts = getColumnEditorOptions(column, row)}
416
- {@const optsLoading = areEditorOptionsLoading(column, row)}
417
- {@const multi = column.columnDef.editorMultiple === true}
418
- {@const selectedArr = toValueArray(ctrl.editingCell?.value)}
419
- {@render chipsEditor(opts, multi, selectedArr, optsLoading)}
420
- {:else if ctrl.editingCell?.editorType === "rating"}
421
- {@const ratingVal = Math.max(
422
- 0,
423
- Math.min(5, Math.round(Number(ctrl.editingCell?.value) || 0)),
424
- )}
425
- <!-- Focus the star for the current rating (the first one when unrated),
426
- the radio a radiogroup is expected to enter on. Without it nothing
427
- in this editor held focus, so `onEditorKeyDown` below never ran and
428
- Escape could not close the cell. -->
429
- {@const focusStar = Math.min(5, Math.max(1, ratingVal))}
430
- <span class="sv-grid-rating-editor" role="radiogroup" aria-label="Rating">
431
- {#each [1, 2, 3, 4, 5] as n (n)}
432
- <button
433
- use:focusControlOnMount={n === focusStar}
434
- type="button"
435
- role="radio"
436
- aria-checked={ratingVal >= n}
437
- aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
438
- class={`sv-grid-rating-star ${ratingVal >= n ? "sv-grid-rating-star-on" : ""}`}
439
- onmousedown={(event) => event.preventDefault()}
440
- onclick={(event) => {
441
- event.stopPropagation();
442
- ctrl.editingCell = ctrl.editingCell
443
- ? { ...ctrl.editingCell, value: n }
444
- : ctrl.editingCell;
445
- saveEditingCell();
446
- }}
447
- onkeydown={(event) => {
448
- // Arrows move between the stars, the way a radiogroup is
449
- // expected to work - without them the editor takes focus (see
450
- // focusControlOnMount) but Space can only re-pick the star that
451
- // already holds the value. Enter / Escape / Tab still fall
452
- // through to the shared handler.
453
- const key = event.key;
454
- if (
455
- key !== "ArrowLeft" &&
456
- key !== "ArrowRight" &&
457
- key !== "Home" &&
458
- key !== "End"
459
- ) {
460
- onEditorKeyDown(event);
461
- return;
462
- }
463
- event.preventDefault();
464
- event.stopPropagation();
465
- const current = Math.min(5, Math.max(1, ratingVal));
466
- const next =
467
- key === "Home"
468
- ? 1
469
- : key === "End"
470
- ? 5
471
- : key === "ArrowLeft"
472
- ? Math.max(1, current - 1)
473
- : Math.min(5, current + 1);
474
- ctrl.editingCell = ctrl.editingCell
475
- ? { ...ctrl.editingCell, value: next }
476
- : ctrl.editingCell;
477
- const stars = (
478
- event.currentTarget as HTMLElement
479
- ).parentElement?.querySelectorAll<HTMLElement>(
480
- ".sv-grid-rating-star",
481
- );
482
- stars?.[next - 1]?.focus({ preventScroll: true });
483
- }}>★</button
484
- >
485
- {/each}
486
- <button
487
- type="button"
488
- aria-label="Clear rating"
489
- class="sv-grid-rating-clear"
490
- onmousedown={(event) => event.preventDefault()}
491
- onclick={(event) => {
492
- event.stopPropagation();
493
- ctrl.editingCell = ctrl.editingCell
494
- ? { ...ctrl.editingCell, value: 0 }
495
- : ctrl.editingCell;
496
- saveEditingCell();
497
- }}>×</button
498
- >
499
- </span>
500
- {:else if ctrl.editingCell?.editorType === "select"}
501
- <!-- Custom dropdown: opens a themed popover identical in feel to
502
- the existing 'list' editor (single-select, no typeahead). -->
503
- {@const selectOpts = getColumnEditorOptions(column, row)}
504
- {@const optsLoading = areEditorOptionsLoading(column, row)}
505
- {#if DropdownEditor}
506
- <DropdownEditor
507
- options={selectOpts}
508
- loading={optsLoading}
509
- value={ctrl.editingCell?.value}
510
- multiple={false}
511
- placeholder="Select…"
512
- onChange={(next) => {
513
- ctrl.editingCell = ctrl.editingCell
514
- ? { ...ctrl.editingCell, value: next }
515
- : ctrl.editingCell;
516
- }}
517
- onCommit={() => saveEditingCell()}
518
- onCancel={() => {
519
- ctrl.editingCell = null;
520
- ctrl.gridRootEl?.focus({ preventScroll: true });
521
- }}
522
- />
523
- {/if}
524
- {:else if ctrl.editingCell?.editorType === "rich-select"}
525
- <!-- Searchable combobox: same popover as 'select' with a
526
- typeahead filter input baked in at the top. -->
527
- {@const richOpts = getColumnEditorOptions(column, row)}
528
- {@const optsLoading = areEditorOptionsLoading(column, row)}
529
- {#if DropdownEditor}
530
- <DropdownEditor
531
- options={richOpts}
532
- loading={optsLoading}
533
- value={ctrl.editingCell?.value}
534
- multiple={false}
535
- searchable={true}
536
- placeholder="Search…"
537
- onChange={(next) => {
538
- ctrl.editingCell = ctrl.editingCell
539
- ? { ...ctrl.editingCell, value: next }
540
- : ctrl.editingCell;
541
- }}
542
- onCommit={() => saveEditingCell()}
543
- onCancel={() => {
544
- ctrl.editingCell = null;
545
- ctrl.gridRootEl?.focus({ preventScroll: true });
546
- }}
547
- />
548
- {/if}
549
- {:else if ctrl.editingCell?.editorType === "textarea"}
550
- <!-- Multi-line editor. Commits on Tab, Ctrl/Cmd+Enter, or blur.
551
- Plain Enter inserts a newline (the whole point of textarea).
552
- Esc cancels. -->
553
- <textarea
554
- use:focusOnMount
555
- class="sv-grid-cell-editor sv-grid-cell-editor-textarea"
556
- rows="4"
557
- value={String(ctrl.editingCell?.value ?? "")}
558
- onpointerdown={(event) => event.stopPropagation()}
559
- oninput={(event) =>
560
- updateEditingCellValue(
561
- (event.currentTarget as HTMLTextAreaElement).value,
562
- )}
563
- onkeydown={(event) => {
564
- event.stopPropagation();
565
- if (event.key === "Escape") {
566
- event.preventDefault();
567
- ctrl.editingCell = null;
568
- ctrl.gridRootEl?.focus({ preventScroll: true });
569
- return;
570
- }
571
- // Tab and Ctrl/Cmd+Enter both commit. Plain Enter inserts a newline.
572
- // Tab goes through the shared helper so it advances the active cell
573
- // like every other editor does (#48).
574
- if (event.key === "Tab") {
575
- event.preventDefault();
576
- ctrl.commitAndMoveByTab(event.shiftKey);
577
- return;
578
- }
579
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
580
- event.preventDefault();
581
- saveEditingCell();
582
- ctrl.gridRootEl?.focus({ preventScroll: true });
583
- }
584
- }}
585
- onblur={() => saveEditingCell()}
586
- ></textarea>
587
- {:else if ctrl.editingCell?.editorType === "autocomplete"}
588
- <!-- Free-text autocomplete: a text input with a live-filtered
589
- suggestion list. Typing edits the value freely; clicking a
590
- suggestion (or blur) commits. Accepts values not in the list. -->
591
- {@const acOpts = getColumnEditorOptions(column, row)}
592
- {@const acText = String(ctrl.editingCell?.value ?? "")}
593
- {@const acFiltered = acText.trim()
594
- ? acOpts.filter((o) =>
595
- String(o.label).toLowerCase().includes(acText.toLowerCase()),
596
- )
597
- : acOpts}
598
- <div class="sv-grid-autocomplete">
599
- <input
600
- use:focusOnMount
601
- class="sv-grid-cell-editor sv-grid-cell-editor-autocomplete"
602
- type="text"
603
- value={acText}
604
- onpointerdown={(event) => event.stopPropagation()}
605
- oninput={(event) =>
606
- updateEditingCellValue(
607
- (event.currentTarget as HTMLInputElement).value,
608
- )}
609
- onblur={() => saveEditingCell()}
610
- onkeydown={onEditorKeyDown}
611
- />
612
- {#if acFiltered.length > 0}
613
- <div class="sv-grid-autocomplete-list" role="listbox">
614
- {#each acFiltered.slice(0, 50) as opt (opt.value)}
615
- <button
616
- type="button"
617
- class="sv-grid-autocomplete-option"
618
- role="option"
619
- aria-selected={String(opt.value) === acText}
620
- onmousedown={(event) => {
621
- event.preventDefault();
622
- ctrl.editingCell = ctrl.editingCell
623
- ? { ...ctrl.editingCell, value: opt.value }
624
- : ctrl.editingCell;
625
- saveEditingCell();
626
- }}>{opt.label}</button
627
- >
628
- {/each}
629
- </div>
630
- {/if}
631
- </div>
632
- {:else if ctrl.editingCell?.editorType === "date"}
633
- <!-- Rich date editor: SvCalendar popover over a formatted input. Opts
634
- out to the native <input type="date"> via editorType 'date-native'. -->
635
- {#if DateEditor}
636
- <DateEditor
637
- value={ctrl.editingCell?.value as string | number | Date | null}
638
- formatString="yyyy-MM-dd"
639
- dropDownDisplayMode="calendar"
640
- autoOpen
641
- block
642
- onChange={(d) => updateEditingCellValue(d)}
643
- onCommit={() => saveEditingCell()}
644
- onCancel={() => {
645
- ctrl.editingCell = null;
646
- ctrl.gridRootEl?.focus({ preventScroll: true });
647
- }}
648
- />
649
- {/if}
650
- {:else if ctrl.editingCell?.editorType === "datetime"}
651
- {#if DateEditor}
652
- <DateEditor
653
- value={ctrl.editingCell?.value as string | number | Date | null}
654
- formatString="yyyy-MM-dd HH:mm"
655
- dropDownDisplayMode="both"
656
- autoOpen
657
- block
658
- onChange={(d) => updateEditingCellValue(d)}
659
- onCommit={() => saveEditingCell()}
660
- onCancel={() => {
661
- ctrl.editingCell = null;
662
- ctrl.gridRootEl?.focus({ preventScroll: true });
663
- }}
664
- />
665
- {/if}
666
- {:else if ctrl.editingCell?.editorType === "time"}
667
- <!-- Rich time editor: SvTimePicker dial. Stores the 'HH:MM' string the
668
- `time` parser expects; opts out via editorType 'time-native'. -->
669
- {#if DateEditor}
670
- <DateEditor
671
- value={timeStringToDate(ctrl.editingCell?.value)}
672
- formatString="HH:mm"
673
- dropDownDisplayMode="time"
674
- autoOpen
675
- block
676
- onChange={(d) => updateEditingCellValue(dateToTimeString(d))}
677
- onCommit={() => saveEditingCell()}
678
- onCancel={() => {
679
- ctrl.editingCell = null;
680
- ctrl.gridRootEl?.focus({ preventScroll: true });
681
- }}
682
- />
683
- {/if}
684
- {:else if ctrl.editingCell?.editorType === "color"}
685
- <!-- Native <input type="color"> opens its picker in a separate OS
686
- overlay; once the picker closes, focus stays on the input so
687
- `blur` never fires on its own. Commit on `change` (which fires
688
- exactly once when the picker is dismissed) so the chosen color
689
- is saved without needing the user to click elsewhere. -->
690
- <input
691
- use:focusOnMount
692
- class={getEditorClass("color")}
693
- type="color"
694
- value={getEditableInputValue("color", ctrl.editingCell?.value)}
695
- oninput={(event) =>
696
- updateEditingCellValue(
697
- (event.currentTarget as HTMLInputElement).value,
698
- )}
699
- onchange={(event) => {
700
- updateEditingCellValue(
701
- (event.currentTarget as HTMLInputElement).value,
702
- );
703
- saveEditingCell();
704
- }}
705
- onblur={() => saveEditingCell()}
706
- onkeydown={onEditorKeyDown}
707
- />
708
- {:else if hasCellEditor(String(ctrl.editingCell?.editorType ?? ""))}
709
- <!-- Custom editor registered via `registerCellEditor(type, Component)`:
710
- mount it and hand it the uniform change / commit / cancel context. -->
711
- {@const _reg = getCellEditor(String(ctrl.editingCell?.editorType))!}
712
- {@const CustomCellEditor = _reg.component}
713
- <CustomCellEditor {...resolveEditorProps(_reg, buildRegisteredEditorContext())} />
714
- {:else}
715
- {@const editorType = ctrl.editingCell?.editorType ?? "text"}
716
- {@const isNumberEditor = getCellEditorInputType(editorType) === "text" &&
717
- getEditorInputType(editorType) === "number"}
718
- <input
719
- use:focusOnMount
720
- class={getEditorClass(editorType)}
721
- type={getCellEditorInputType(editorType)}
722
- inputmode={isNumberEditor ? "decimal" : undefined}
723
- value={getEditableInputValue(
724
- editorType,
725
- ctrl.editingCell?.value,
726
- )}
727
- oninput={(event) => {
728
- const input = event.currentTarget as HTMLInputElement;
729
- // The number editor is a TEXT input so intermediate values survive
730
- // (see getCellEditorInputType). That means nothing stops a letter
731
- // being typed, which type="number" used to - so reject it here and
732
- // put the last good draft back, rather than letting "12x" reach the
733
- // commit and coerce to null.
734
- if (isNumberEditor && !isNumericEditorInput(input.value)) {
735
- input.value = String(ctrl.editingCell?.value ?? "");
736
- return;
737
- }
738
- updateEditingCellValue(input.value);
739
- }}
740
- onblur={() => saveEditingCell()}
741
- onkeydown={onEditorKeyDown}
742
- />
743
- {/if}
744
- {/if}
1
+ <script lang="ts" generics="TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData">
2
+ /**
3
+ * The in-cell and full-row editor UI, lifted out of SvGrid.svelte.
4
+ *
5
+ * Editing is off by default (`editable` / `enableInlineEditing` both default
6
+ * to false) and this markup only ever renders while a cell is actually being
7
+ * edited, so it has no business sitting in the base bundle. SvGrid.svelte
8
+ * loads it with `import()` the moment editing is enabled - well before any
9
+ * double-click can happen - so behaviour is unchanged either way.
10
+ *
11
+ * Keep `SvGridDropdown` and `SvDateTimePicker` DYNAMIC below. Importing
12
+ * either statically would drag ~22 KB into this chunk for every text edit,
13
+ * and a static import from anywhere reachable by SvGrid.svelte has silently
14
+ * defeated a lazy boundary in this codebase before.
15
+ */
16
+ import {
17
+ type CellEditorOption,
18
+ type Column,
19
+ type EditorContext,
20
+ type Row,
21
+ type RowData,
22
+ type TableFeatures,
23
+ } from "./index";
24
+ import {
25
+ dateToTimeString,
26
+ getEditableInputValue,
27
+ getEditorClass,
28
+ getEditorInputType,
29
+ getCellEditorInputType,
30
+ isNumericEditorInput,
31
+ timeStringToDate,
32
+ toValueArray,
33
+ } from "./SvGrid.helpers";
34
+ import {
35
+ getCellEditor,
36
+ hasCellEditor,
37
+ resolveEditorProps,
38
+ type CellEditorContext,
39
+ } from "./editor-registry";
40
+ import type { SvGridController } from "./SvGrid.controller.svelte";
41
+
42
+ let {
43
+ ctrl,
44
+ column,
45
+ row,
46
+ fullRow = false,
47
+ }: {
48
+ ctrl: SvGridController<TFeatures, TData>;
49
+ column: Column<TData>;
50
+ row: Row<TData>;
51
+ /** Render the full-row editor for this cell instead of the single-cell one. */
52
+ fullRow?: boolean;
53
+ } = $props();
54
+
55
+ // Heavy editors stay behind their own `import()` so a text edit never pays
56
+ // for the dropdown or the date picker.
57
+ let DropdownEditor = $state<typeof import("./SvGridDropdown.svelte").default | null>(null);
58
+ let DateEditor = $state<typeof import("./SvDateTimePicker.svelte").default | null>(null);
59
+ $effect(() => {
60
+ const t = ctrl.editingCell?.editorType;
61
+ if (
62
+ (t === "list" || t === "chips" || t === "select" || t === "rich-select") &&
63
+ !DropdownEditor
64
+ )
65
+ import("./SvGridDropdown.svelte").then((m) => (DropdownEditor = m.default));
66
+ if ((t === "date" || t === "datetime" || t === "time") && !DateEditor)
67
+ import("./SvDateTimePicker.svelte").then((m) => (DateEditor = m.default));
68
+ });
69
+
70
+ const grid = $derived(ctrl.grid);
71
+ const fullRowEdit = $derived(ctrl.fullRowEdit);
72
+ const getColumnEditorOptions = $derived(ctrl.getColumnEditorOptions);
73
+ const areEditorOptionsLoading = $derived(ctrl.areEditorOptionsLoading);
74
+ const saveEditingCell = $derived(ctrl.saveEditingCell);
75
+ const updateEditingCellValue = $derived(ctrl.updateEditingCellValue);
76
+ const onEditorKeyDown = $derived(ctrl.onEditorKeyDown);
77
+ const focusOnMount = $derived(ctrl.focusOnMount);
78
+ const toggleCheckboxWithKeyboard = $derived(ctrl.toggleCheckboxWithKeyboard);
79
+
80
+ /**
81
+ * Focus a button-based editor on mount. `focusOnMount` is for text controls -
82
+ * it also places the caret - and the checkbox and rating editors, being
83
+ * <button>s, had no equivalent and so never took focus at all. Their own key
84
+ * handlers therefore never fired: neither could be operated from the
85
+ * keyboard, and Escape never reached them, so the cell stayed stuck in edit
86
+ * mode until the user clicked somewhere else.
87
+ *
88
+ * `enabled` lets a group of buttons (the rating stars) pick which one holds
89
+ * focus, since an action cannot be applied conditionally.
90
+ *
91
+ * Synchronous, for the same reason as `focusOnMount`: a deferred frame left
92
+ * the cell marked as editing with nothing inside it focused, and keystrokes
93
+ * in that window were dropped by both the grid root and the editor.
94
+ */
95
+ function focusControlOnMount(node: HTMLElement, enabled: boolean = true) {
96
+ if (!enabled) return;
97
+ node.focus({ preventScroll: true });
98
+ }
99
+
100
+ function fullRowKeydown(e: KeyboardEvent) {
101
+ if (e.key === "Enter") {
102
+ e.preventDefault();
103
+ ctrl.commitFullRowEdit();
104
+ ctrl.gridRootEl?.focus({ preventScroll: true });
105
+ } else if (e.key === "Escape") {
106
+ e.preventDefault();
107
+ ctrl.cancelFullRowEdit();
108
+ ctrl.gridRootEl?.focus({ preventScroll: true });
109
+ }
110
+ }
111
+ function optionValueOf(o: unknown): string {
112
+ return typeof o === "object" && o !== null && "value" in o
113
+ ? String((o as { value: unknown }).value)
114
+ : String(o);
115
+ }
116
+ function optionLabelOf(o: unknown): string {
117
+ return typeof o === "object" && o !== null && "label" in o
118
+ ? String((o as { label: unknown }).label)
119
+ : String(o);
120
+ }
121
+
122
+ // Build the interaction context handed to a CUSTOM cell editor registered via
123
+ // `registerCellEditor`. Maps the grid's editing lifecycle onto the uniform
124
+ // change / commit / cancel contract every registered editor speaks.
125
+ function buildRegisteredEditorContext(): CellEditorContext {
126
+ const cell = ctrl.editingCell;
127
+ return {
128
+ value: cell?.value,
129
+ rowId: cell?.rowId ?? "",
130
+ columnId: cell?.columnId ?? "",
131
+ onChange: (v: unknown) => updateEditingCellValue(v),
132
+ onCommit: (v?: unknown) => {
133
+ if (v !== undefined) updateEditingCellValue(v);
134
+ saveEditingCell();
135
+ },
136
+ onCancel: () => {
137
+ ctrl.editingCell = null;
138
+ ctrl.gridRootEl?.focus({ preventScroll: true });
139
+ },
140
+ // Tab out of an editor: commit, then step to the adjacent cell. Without
141
+ // this a registered editor's Tab walks focus out of the grid (#48) - the
142
+ // built-in editors already route through the same helper.
143
+ onCommitAndMove: (v: unknown, direction: 1 | -1) => {
144
+ if (v !== undefined) updateEditingCellValue(v);
145
+ ctrl.commitAndMoveByTab(direction === -1);
146
+ },
147
+ // Close a popover editor without committing. Same effect as cancel today;
148
+ // named separately so an editor can distinguish "dismiss my panel" from
149
+ // "abandon the edit" if that ever diverges.
150
+ onRequestClose: () => {
151
+ ctrl.editingCell = null;
152
+ ctrl.gridRootEl?.focus({ preventScroll: true });
153
+ },
154
+ inCell: true,
155
+ };
156
+ }
157
+ </script>
158
+
159
+ {#snippet chipsEditor(
160
+ opts: CellEditorOption[],
161
+ multi: boolean,
162
+ selectedArr: Array<string | number>,
163
+ optsLoading: boolean,
164
+ )}
165
+ {#if opts.length > 0}
166
+ <!-- Options-driven chips editor: defer to the custom dropdown,
167
+ which renders the selected values as chips in its trigger and
168
+ pops out a styled listbox. Identical UX to the list editor
169
+ with renderChipsInTrigger flipped on. -->
170
+ {#if DropdownEditor}
171
+ <DropdownEditor
172
+ options={opts}
173
+ loading={optsLoading}
174
+ value={ctrl.editingCell?.value}
175
+ multiple={multi}
176
+ placeholder="Pick…"
177
+ renderChipsInTrigger={true}
178
+ onChange={(next) => {
179
+ ctrl.editingCell = ctrl.editingCell
180
+ ? { ...ctrl.editingCell, value: next }
181
+ : ctrl.editingCell;
182
+ }}
183
+ onCommit={() => saveEditingCell()}
184
+ onCancel={() => {
185
+ ctrl.editingCell = null;
186
+ ctrl.gridRootEl?.focus({ preventScroll: true });
187
+ }}
188
+ />
189
+ {/if}
190
+ {:else}
191
+ <!-- Free-form chips: typed tags. Enter / comma commits a chip,
192
+ Backspace on empty input removes the last chip, blur saves. -->
193
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
194
+ <div
195
+ class="sv-grid-cell-editor sv-grid-cell-editor-chips"
196
+ role="group"
197
+ tabindex={-1}
198
+ >
199
+ <div class="sv-grid-chips-row">
200
+ {#each selectedArr as v, idx (String(v) + "_" + idx)}
201
+ <span class="sv-grid-chip sv-grid-chip-removable">
202
+ {String(v)}
203
+ <button
204
+ type="button"
205
+ class="sv-grid-chip-remove"
206
+ aria-label="Remove {String(v)}"
207
+ onmousedown={(event) => event.preventDefault()}
208
+ onclick={() => {
209
+ const next = selectedArr.filter((_, i) => i !== idx);
210
+ ctrl.editingCell = ctrl.editingCell
211
+ ? {
212
+ ...ctrl.editingCell,
213
+ value: multi ? next : (next[0] ?? null),
214
+ }
215
+ : ctrl.editingCell;
216
+ }}>×</button
217
+ >
218
+ </span>
219
+ {/each}
220
+ <input
221
+ use:focusOnMount
222
+ class="sv-grid-chip-input"
223
+ type="text"
224
+ placeholder={multi ? "Type, Enter to add" : "Type a value"}
225
+ onkeydown={(event) => {
226
+ if (event.key === "Enter" || (multi && event.key === ",")) {
227
+ event.preventDefault();
228
+ event.stopPropagation();
229
+ const input = event.currentTarget as HTMLInputElement;
230
+ const raw = input.value.trim();
231
+ if (raw) {
232
+ const next = multi ? [...selectedArr, raw] : [raw];
233
+ ctrl.editingCell = ctrl.editingCell
234
+ ? { ...ctrl.editingCell, value: multi ? next : raw }
235
+ : ctrl.editingCell;
236
+ input.value = "";
237
+ if (!multi) saveEditingCell();
238
+ }
239
+ } else if (event.key === "Escape") {
240
+ onEditorKeyDown(event);
241
+ } else if (event.key === "Backspace") {
242
+ const input = event.currentTarget as HTMLInputElement;
243
+ if (input.value === "" && selectedArr.length > 0) {
244
+ event.preventDefault();
245
+ const next = selectedArr.slice(0, -1);
246
+ ctrl.editingCell = ctrl.editingCell
247
+ ? {
248
+ ...ctrl.editingCell,
249
+ value: multi ? next : (next[0] ?? null),
250
+ }
251
+ : ctrl.editingCell;
252
+ }
253
+ }
254
+ }}
255
+ onblur={() => saveEditingCell()}
256
+ />
257
+ {#if multi}
258
+ <button
259
+ type="button"
260
+ class="sv-grid-chip-commit"
261
+ onmousedown={(event) => event.preventDefault()}
262
+ onclick={() => saveEditingCell()}
263
+ aria-label="Commit chip selection">Done</button
264
+ >
265
+ {/if}
266
+ </div>
267
+ </div>
268
+ {/if}
269
+ {/snippet}
270
+
271
+ {#if fullRow}
272
+ {@const et = column.columnDef.editorType ?? "text"}
273
+ {@const val = fullRowEdit?.draft[column.id]}
274
+ {#if et === "checkbox"}
275
+ <input
276
+ type="checkbox"
277
+ class="sv-grid-fr-editor sv-grid-fr-checkbox"
278
+ checked={Boolean(val)}
279
+ onchange={(e) =>
280
+ ctrl.setFullRowDraft(column.id, e.currentTarget.checked)}
281
+ onkeydown={fullRowKeydown}
282
+ onpointerdown={(e) => e.stopPropagation()}
283
+ onclick={(e) => e.stopPropagation()}
284
+ />
285
+ {:else if et === "list" || et === "select" || et === "rich-select"}
286
+ {@const opts = getColumnEditorOptions(column, row)}
287
+ <select
288
+ class="sv-grid-cell-editor sv-grid-fr-editor"
289
+ value={String(val ?? "")}
290
+ onchange={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
291
+ onkeydown={fullRowKeydown}
292
+ onpointerdown={(e) => e.stopPropagation()}
293
+ onclick={(e) => e.stopPropagation()}
294
+ >
295
+ {#each opts as o (optionValueOf(o))}
296
+ <option value={optionValueOf(o)}>{optionLabelOf(o)}</option>
297
+ {/each}
298
+ </select>
299
+ {:else}
300
+ {@const inputType =
301
+ et === "number"
302
+ ? "number"
303
+ : et === "date"
304
+ ? "date"
305
+ : et === "datetime"
306
+ ? "datetime-local"
307
+ : et === "time"
308
+ ? "time"
309
+ : et === "password"
310
+ ? "password"
311
+ : "text"}
312
+ <input
313
+ type={inputType}
314
+ class="sv-grid-cell-editor sv-grid-fr-editor"
315
+ value={String(val ?? "")}
316
+ oninput={(e) => ctrl.setFullRowDraft(column.id, e.currentTarget.value)}
317
+ onkeydown={fullRowKeydown}
318
+ onpointerdown={(e) => e.stopPropagation()}
319
+ onclick={(e) => e.stopPropagation()}
320
+ />
321
+ {/if}
322
+ {:else}
323
+ {#if column.columnDef.cellEditor}
324
+ <!-- Custom editor slot. The columnDef provides a snippet that
325
+ receives the editor context (value + commit + cancel) so the
326
+ consumer fully owns the in-cell UI. -->
327
+ {@const customEditor = column.columnDef
328
+ .cellEditor as unknown as import("svelte").Snippet<
329
+ [EditorContext<TData>]
330
+ >}
331
+ {@render customEditor({
332
+ cell: row.getAllCells().find((c) => c.column.id === column.id)!,
333
+ row,
334
+ column,
335
+ table: grid,
336
+ getValue: () => ctrl.editingCell?.value,
337
+ value: ctrl.editingCell?.value,
338
+ update: (next: unknown) => {
339
+ // Stage the draft without closing. Live-preview controls
340
+ // (sliders, color pickers) call this on every input tick.
341
+ ctrl.editingCell = ctrl.editingCell
342
+ ? { ...ctrl.editingCell, value: next }
343
+ : ctrl.editingCell;
344
+ },
345
+ commit: (next?: unknown) => {
346
+ // Write + close. If the caller passed a value, stage it
347
+ // first; otherwise save whatever update() last wrote.
348
+ if (next !== undefined) {
349
+ ctrl.editingCell = ctrl.editingCell
350
+ ? { ...ctrl.editingCell, value: next }
351
+ : ctrl.editingCell;
352
+ }
353
+ saveEditingCell();
354
+ },
355
+ cancel: () => {
356
+ ctrl.editingCell = null;
357
+ ctrl.gridRootEl?.focus({ preventScroll: true });
358
+ },
359
+ })}
360
+ {:else if ctrl.editingCell?.editorType === "checkbox"}
361
+ <button
362
+ use:focusControlOnMount
363
+ type="button"
364
+ class="sv-grid-checkbox"
365
+ role="checkbox"
366
+ aria-checked={Boolean(ctrl.editingCell.value)}
367
+ aria-label="Edit checkbox value"
368
+ onclick={(event) => {
369
+ event.stopPropagation();
370
+ const nextValue = !ctrl.editingCell?.value;
371
+ ctrl.editingCell = ctrl.editingCell
372
+ ? { ...ctrl.editingCell, value: nextValue }
373
+ : ctrl.editingCell;
374
+ saveEditingCell();
375
+ }}
376
+ onkeydown={(event) => {
377
+ // Enter / Space are the checkbox's own activation keys. Everything
378
+ // else - Escape to cancel, Tab to commit and move on - goes to the
379
+ // shared editor handler, which this editor used to bypass entirely.
380
+ if (event.key === "Enter" || event.key === " ") {
381
+ toggleCheckboxWithKeyboard(event, () => {
382
+ event.stopPropagation();
383
+ const nextValue = !ctrl.editingCell?.value;
384
+ ctrl.editingCell = ctrl.editingCell
385
+ ? { ...ctrl.editingCell, value: nextValue }
386
+ : ctrl.editingCell;
387
+ saveEditingCell();
388
+ });
389
+ return;
390
+ }
391
+ onEditorKeyDown(event);
392
+ }}
393
+ onblur={() => saveEditingCell()}
394
+ ></button>
395
+ {:else if ctrl.editingCell?.editorType === "list"}
396
+ {@const opts = getColumnEditorOptions(column, row)}
397
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
398
+ {@const multi = column.columnDef.editorMultiple === true}
399
+ {#if DropdownEditor}
400
+ <DropdownEditor
401
+ options={opts}
402
+ loading={optsLoading}
403
+ value={ctrl.editingCell?.value}
404
+ multiple={multi}
405
+ placeholder="Select…"
406
+ onChange={(next) => {
407
+ ctrl.editingCell = ctrl.editingCell
408
+ ? { ...ctrl.editingCell, value: next }
409
+ : ctrl.editingCell;
410
+ }}
411
+ onCommit={() => saveEditingCell()}
412
+ onCancel={() => {
413
+ ctrl.editingCell = null;
414
+ ctrl.gridRootEl?.focus({ preventScroll: true });
415
+ }}
416
+ />
417
+ {/if}
418
+ {:else if ctrl.editingCell?.editorType === "chips"}
419
+ {@const opts = getColumnEditorOptions(column, row)}
420
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
421
+ {@const multi = column.columnDef.editorMultiple === true}
422
+ {@const selectedArr = toValueArray(ctrl.editingCell?.value)}
423
+ {@render chipsEditor(opts, multi, selectedArr, optsLoading)}
424
+ {:else if ctrl.editingCell?.editorType === "rating"}
425
+ {@const ratingVal = Math.max(
426
+ 0,
427
+ Math.min(5, Math.round(Number(ctrl.editingCell?.value) || 0)),
428
+ )}
429
+ <!-- Focus the star for the current rating (the first one when unrated),
430
+ the radio a radiogroup is expected to enter on. Without it nothing
431
+ in this editor held focus, so `onEditorKeyDown` below never ran and
432
+ Escape could not close the cell. -->
433
+ {@const focusStar = Math.min(5, Math.max(1, ratingVal))}
434
+ <span class="sv-grid-rating-editor" role="radiogroup" aria-label="Rating">
435
+ {#each [1, 2, 3, 4, 5] as n (n)}
436
+ <button
437
+ use:focusControlOnMount={n === focusStar}
438
+ type="button"
439
+ role="radio"
440
+ aria-checked={ratingVal >= n}
441
+ aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
442
+ class={`sv-grid-rating-star ${ratingVal >= n ? "sv-grid-rating-star-on" : ""}`}
443
+ onmousedown={(event) => event.preventDefault()}
444
+ onclick={(event) => {
445
+ event.stopPropagation();
446
+ ctrl.editingCell = ctrl.editingCell
447
+ ? { ...ctrl.editingCell, value: n }
448
+ : ctrl.editingCell;
449
+ saveEditingCell();
450
+ }}
451
+ onkeydown={(event) => {
452
+ // Arrows move between the stars, the way a radiogroup is
453
+ // expected to work - without them the editor takes focus (see
454
+ // focusControlOnMount) but Space can only re-pick the star that
455
+ // already holds the value. Enter / Escape / Tab still fall
456
+ // through to the shared handler.
457
+ const key = event.key;
458
+ if (
459
+ key !== "ArrowLeft" &&
460
+ key !== "ArrowRight" &&
461
+ key !== "Home" &&
462
+ key !== "End"
463
+ ) {
464
+ onEditorKeyDown(event);
465
+ return;
466
+ }
467
+ event.preventDefault();
468
+ event.stopPropagation();
469
+ const current = Math.min(5, Math.max(1, ratingVal));
470
+ const next =
471
+ key === "Home"
472
+ ? 1
473
+ : key === "End"
474
+ ? 5
475
+ : key === "ArrowLeft"
476
+ ? Math.max(1, current - 1)
477
+ : Math.min(5, current + 1);
478
+ ctrl.editingCell = ctrl.editingCell
479
+ ? { ...ctrl.editingCell, value: next }
480
+ : ctrl.editingCell;
481
+ const stars = (
482
+ event.currentTarget as HTMLElement
483
+ ).parentElement?.querySelectorAll<HTMLElement>(
484
+ ".sv-grid-rating-star",
485
+ );
486
+ stars?.[next - 1]?.focus({ preventScroll: true });
487
+ }}>★</button
488
+ >
489
+ {/each}
490
+ <button
491
+ type="button"
492
+ aria-label="Clear rating"
493
+ class="sv-grid-rating-clear"
494
+ onmousedown={(event) => event.preventDefault()}
495
+ onclick={(event) => {
496
+ event.stopPropagation();
497
+ ctrl.editingCell = ctrl.editingCell
498
+ ? { ...ctrl.editingCell, value: 0 }
499
+ : ctrl.editingCell;
500
+ saveEditingCell();
501
+ }}>×</button
502
+ >
503
+ </span>
504
+ {:else if ctrl.editingCell?.editorType === "select"}
505
+ <!-- Custom dropdown: opens a themed popover identical in feel to
506
+ the existing 'list' editor (single-select, no typeahead). -->
507
+ {@const selectOpts = getColumnEditorOptions(column, row)}
508
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
509
+ {#if DropdownEditor}
510
+ <DropdownEditor
511
+ options={selectOpts}
512
+ loading={optsLoading}
513
+ value={ctrl.editingCell?.value}
514
+ multiple={false}
515
+ placeholder="Select…"
516
+ onChange={(next) => {
517
+ ctrl.editingCell = ctrl.editingCell
518
+ ? { ...ctrl.editingCell, value: next }
519
+ : ctrl.editingCell;
520
+ }}
521
+ onCommit={() => saveEditingCell()}
522
+ onCancel={() => {
523
+ ctrl.editingCell = null;
524
+ ctrl.gridRootEl?.focus({ preventScroll: true });
525
+ }}
526
+ />
527
+ {/if}
528
+ {:else if ctrl.editingCell?.editorType === "rich-select"}
529
+ <!-- Searchable combobox: same popover as 'select' with a
530
+ typeahead filter input baked in at the top. -->
531
+ {@const richOpts = getColumnEditorOptions(column, row)}
532
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
533
+ {#if DropdownEditor}
534
+ <DropdownEditor
535
+ options={richOpts}
536
+ loading={optsLoading}
537
+ value={ctrl.editingCell?.value}
538
+ multiple={false}
539
+ searchable={true}
540
+ placeholder="Search…"
541
+ onChange={(next) => {
542
+ ctrl.editingCell = ctrl.editingCell
543
+ ? { ...ctrl.editingCell, value: next }
544
+ : ctrl.editingCell;
545
+ }}
546
+ onCommit={() => saveEditingCell()}
547
+ onCancel={() => {
548
+ ctrl.editingCell = null;
549
+ ctrl.gridRootEl?.focus({ preventScroll: true });
550
+ }}
551
+ />
552
+ {/if}
553
+ {:else if ctrl.editingCell?.editorType === "textarea"}
554
+ <!-- Multi-line editor. Commits on Tab, Ctrl/Cmd+Enter, or blur.
555
+ Plain Enter inserts a newline (the whole point of textarea).
556
+ Esc cancels. -->
557
+ <textarea
558
+ use:focusOnMount
559
+ class="sv-grid-cell-editor sv-grid-cell-editor-textarea"
560
+ rows="4"
561
+ value={String(ctrl.editingCell?.value ?? "")}
562
+ onpointerdown={(event) => event.stopPropagation()}
563
+ oninput={(event) =>
564
+ updateEditingCellValue(
565
+ (event.currentTarget as HTMLTextAreaElement).value,
566
+ )}
567
+ onkeydown={(event) => {
568
+ event.stopPropagation();
569
+ if (event.key === "Escape") {
570
+ event.preventDefault();
571
+ ctrl.editingCell = null;
572
+ ctrl.gridRootEl?.focus({ preventScroll: true });
573
+ return;
574
+ }
575
+ // Tab and Ctrl/Cmd+Enter both commit. Plain Enter inserts a newline.
576
+ // Tab goes through the shared helper so it advances the active cell
577
+ // like every other editor does (#48).
578
+ if (event.key === "Tab") {
579
+ event.preventDefault();
580
+ ctrl.commitAndMoveByTab(event.shiftKey);
581
+ return;
582
+ }
583
+ if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
584
+ event.preventDefault();
585
+ saveEditingCell();
586
+ ctrl.gridRootEl?.focus({ preventScroll: true });
587
+ }
588
+ }}
589
+ onblur={() => saveEditingCell()}
590
+ ></textarea>
591
+ {:else if ctrl.editingCell?.editorType === "autocomplete"}
592
+ <!-- Free-text autocomplete: a text input with a live-filtered
593
+ suggestion list. Typing edits the value freely; clicking a
594
+ suggestion (or blur) commits. Accepts values not in the list. -->
595
+ {@const acOpts = getColumnEditorOptions(column, row)}
596
+ {@const acText = String(ctrl.editingCell?.value ?? "")}
597
+ {@const acFiltered = acText.trim()
598
+ ? acOpts.filter((o) =>
599
+ String(o.label).toLowerCase().includes(acText.toLowerCase()),
600
+ )
601
+ : acOpts}
602
+ <div class="sv-grid-autocomplete">
603
+ <input
604
+ use:focusOnMount
605
+ class="sv-grid-cell-editor sv-grid-cell-editor-autocomplete"
606
+ type="text"
607
+ value={acText}
608
+ onpointerdown={(event) => event.stopPropagation()}
609
+ oninput={(event) =>
610
+ updateEditingCellValue(
611
+ (event.currentTarget as HTMLInputElement).value,
612
+ )}
613
+ onblur={() => saveEditingCell()}
614
+ onkeydown={onEditorKeyDown}
615
+ />
616
+ {#if acFiltered.length > 0}
617
+ <div class="sv-grid-autocomplete-list" role="listbox">
618
+ {#each acFiltered.slice(0, 50) as opt (opt.value)}
619
+ <button
620
+ type="button"
621
+ class="sv-grid-autocomplete-option"
622
+ role="option"
623
+ aria-selected={String(opt.value) === acText}
624
+ onmousedown={(event) => {
625
+ event.preventDefault();
626
+ ctrl.editingCell = ctrl.editingCell
627
+ ? { ...ctrl.editingCell, value: opt.value }
628
+ : ctrl.editingCell;
629
+ saveEditingCell();
630
+ }}>{opt.label}</button
631
+ >
632
+ {/each}
633
+ </div>
634
+ {/if}
635
+ </div>
636
+ {:else if ctrl.editingCell?.editorType === "date"}
637
+ <!-- Rich date editor: SvCalendar popover over a formatted input. Opts
638
+ out to the native <input type="date"> via editorType 'date-native'. -->
639
+ {#if DateEditor}
640
+ <DateEditor
641
+ value={ctrl.editingCell?.value as string | number | Date | null}
642
+ formatString="yyyy-MM-dd"
643
+ dropDownDisplayMode="calendar"
644
+ autoOpen
645
+ block
646
+ onChange={(d) => updateEditingCellValue(d)}
647
+ onCommit={() => saveEditingCell()}
648
+ onCancel={() => {
649
+ ctrl.editingCell = null;
650
+ ctrl.gridRootEl?.focus({ preventScroll: true });
651
+ }}
652
+ />
653
+ {/if}
654
+ {:else if ctrl.editingCell?.editorType === "datetime"}
655
+ {#if DateEditor}
656
+ <DateEditor
657
+ value={ctrl.editingCell?.value as string | number | Date | null}
658
+ formatString="yyyy-MM-dd HH:mm"
659
+ dropDownDisplayMode="both"
660
+ autoOpen
661
+ block
662
+ onChange={(d) => updateEditingCellValue(d)}
663
+ onCommit={() => saveEditingCell()}
664
+ onCancel={() => {
665
+ ctrl.editingCell = null;
666
+ ctrl.gridRootEl?.focus({ preventScroll: true });
667
+ }}
668
+ />
669
+ {/if}
670
+ {:else if ctrl.editingCell?.editorType === "time"}
671
+ <!-- Rich time editor: SvTimePicker dial. Stores the 'HH:MM' string the
672
+ `time` parser expects; opts out via editorType 'time-native'. -->
673
+ {#if DateEditor}
674
+ <DateEditor
675
+ value={timeStringToDate(ctrl.editingCell?.value)}
676
+ formatString="HH:mm"
677
+ dropDownDisplayMode="time"
678
+ autoOpen
679
+ block
680
+ onChange={(d) => updateEditingCellValue(dateToTimeString(d))}
681
+ onCommit={() => saveEditingCell()}
682
+ onCancel={() => {
683
+ ctrl.editingCell = null;
684
+ ctrl.gridRootEl?.focus({ preventScroll: true });
685
+ }}
686
+ />
687
+ {/if}
688
+ {:else if ctrl.editingCell?.editorType === "color"}
689
+ <!-- Native <input type="color"> opens its picker in a separate OS
690
+ overlay; once the picker closes, focus stays on the input so
691
+ `blur` never fires on its own. Commit on `change` (which fires
692
+ exactly once when the picker is dismissed) so the chosen color
693
+ is saved without needing the user to click elsewhere. -->
694
+ <input
695
+ use:focusOnMount
696
+ class={getEditorClass("color")}
697
+ type="color"
698
+ value={getEditableInputValue("color", ctrl.editingCell?.value)}
699
+ oninput={(event) =>
700
+ updateEditingCellValue(
701
+ (event.currentTarget as HTMLInputElement).value,
702
+ )}
703
+ onchange={(event) => {
704
+ updateEditingCellValue(
705
+ (event.currentTarget as HTMLInputElement).value,
706
+ );
707
+ saveEditingCell();
708
+ }}
709
+ onblur={() => saveEditingCell()}
710
+ onkeydown={onEditorKeyDown}
711
+ />
712
+ {:else if hasCellEditor(String(ctrl.editingCell?.editorType ?? ""))}
713
+ <!-- Custom editor registered via `registerCellEditor(type, Component)`:
714
+ mount it and hand it the uniform change / commit / cancel context. -->
715
+ {@const _reg = getCellEditor(String(ctrl.editingCell?.editorType))!}
716
+ {@const CustomCellEditor = _reg.component}
717
+ <CustomCellEditor {...resolveEditorProps(_reg, buildRegisteredEditorContext())} />
718
+ {:else}
719
+ {@const editorType = ctrl.editingCell?.editorType ?? "text"}
720
+ {@const isNumberEditor = getCellEditorInputType(editorType) === "text" &&
721
+ getEditorInputType(editorType) === "number"}
722
+ <input
723
+ use:focusOnMount
724
+ class={getEditorClass(editorType)}
725
+ type={getCellEditorInputType(editorType)}
726
+ inputmode={isNumberEditor ? "decimal" : undefined}
727
+ value={getEditableInputValue(
728
+ editorType,
729
+ ctrl.editingCell?.value,
730
+ )}
731
+ oninput={(event) => {
732
+ const input = event.currentTarget as HTMLInputElement;
733
+ // The number editor is a TEXT input so intermediate values survive
734
+ // (see getCellEditorInputType). That means nothing stops a letter
735
+ // being typed, which type="number" used to - so reject it here and
736
+ // put the last good draft back, rather than letting "12x" reach the
737
+ // commit and coerce to null.
738
+ if (isNumberEditor && !isNumericEditorInput(input.value)) {
739
+ input.value = String(ctrl.editingCell?.value ?? "");
740
+ return;
741
+ }
742
+ updateEditingCellValue(input.value);
743
+ }}
744
+ onblur={() => saveEditingCell()}
745
+ onkeydown={onEditorKeyDown}
746
+ />
747
+ {/if}
748
+ {/if}