@svgrid/grid 3.0.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 (259) 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-DMihUtma.js → GridMenus-CYzOvmNQ.js} +3 -3
  56. package/dist/cdn/{GridMenus-nmNDj1a3.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/{src-B_YS5AOc.js → src-C8QjSaq-.js} +42 -44
  74. package/dist/cdn/{src-DYXXpuSk.js → src-D0hO2eYN.js} +41 -43
  75. package/dist/cdn/svgrid.js +7 -7
  76. package/dist/cdn/svgrid.svelte-external.js +7 -7
  77. package/dist/chart-export.js +8 -8
  78. package/dist/editing.js +22 -13
  79. package/package.json +11 -11
  80. package/src/FlexRender.svelte +96 -96
  81. package/src/GridFooter.svelte +181 -181
  82. package/src/SvAutoComplete.svelte +169 -169
  83. package/src/SvAvatar.svelte +75 -75
  84. package/src/SvCalendar.svelte +503 -503
  85. package/src/SvCalendar.test.ts +226 -226
  86. package/src/SvCarousel.svelte +141 -141
  87. package/src/SvCheckBox.svelte +102 -102
  88. package/src/SvCircularProgress.svelte +109 -109
  89. package/src/SvColorInput.svelte +181 -181
  90. package/src/SvComboBox.svelte +279 -279
  91. package/src/SvContextMenu.svelte +116 -116
  92. package/src/SvCountryInput.svelte +163 -163
  93. package/src/SvDrawer.svelte +254 -254
  94. package/src/SvDropDownList.svelte +378 -378
  95. package/src/SvDurationInput.svelte +126 -126
  96. package/src/SvField.svelte +293 -293
  97. package/src/SvForm.svelte +437 -437
  98. package/src/SvForm.test.ts +411 -411
  99. package/src/SvGrid.types.ts +2092 -2092
  100. package/src/SvGridCellEditor.svelte +748 -744
  101. package/src/SvGridChart.svelte +1716 -1716
  102. package/src/SvGridChartPanel.svelte +485 -485
  103. package/src/SvGridChartView.svelte +70 -70
  104. package/src/SvGridDropdown.svelte +728 -728
  105. package/src/SvGridSelect.svelte +270 -270
  106. package/src/SvGroupCell.svelte +116 -116
  107. package/src/SvListBox.svelte +334 -334
  108. package/src/SvMaskedInput.svelte +122 -122
  109. package/src/SvMenu.svelte +124 -124
  110. package/src/SvMenu.test.ts +97 -97
  111. package/src/SvMenuList.svelte +146 -146
  112. package/src/SvMultiSelect.svelte +293 -293
  113. package/src/SvNumberInput.svelte +172 -172
  114. package/src/SvOtpInput.svelte +158 -158
  115. package/src/SvPasswordInput.svelte +151 -151
  116. package/src/SvPhoneInput.svelte +133 -133
  117. package/src/SvPopover.svelte +197 -197
  118. package/src/SvProgress.svelte +116 -116
  119. package/src/SvRadioGroup.svelte +107 -107
  120. package/src/SvRating.svelte +112 -112
  121. package/src/SvResult.svelte +73 -73
  122. package/src/SvRichText.svelte +211 -211
  123. package/src/SvRowGroupPanel.svelte +170 -170
  124. package/src/SvScrollArea.svelte +61 -61
  125. package/src/SvSlider.svelte +203 -203
  126. package/src/SvSwitchButton.svelte +108 -108
  127. package/src/SvTagsInput.svelte +115 -115
  128. package/src/SvTextInput.svelte +147 -147
  129. package/src/SvTimePicker.svelte +245 -245
  130. package/src/SvToaster.svelte +159 -159
  131. package/src/SvToaster.test.ts +95 -95
  132. package/src/SvToggleButton.svelte +85 -85
  133. package/src/SvTooltip.svelte +161 -161
  134. package/src/SvTour.svelte +208 -208
  135. package/src/SvTree.svelte +444 -444
  136. package/src/SvTreeSelect.svelte +239 -239
  137. package/src/a11y/dismissable.test.ts +119 -119
  138. package/src/a11y/dismissable.ts +114 -114
  139. package/src/a11y.contract.test.ts +49 -49
  140. package/src/a11y.test.ts +59 -59
  141. package/src/a11y.ts +61 -61
  142. package/src/ai.test.ts +502 -502
  143. package/src/ai.ts +1419 -1419
  144. package/src/build-api.coverage.test.ts +633 -633
  145. package/src/build-api.ts +846 -846
  146. package/src/builtin-editors.grid.test.ts +83 -83
  147. package/src/cell-formatting.ts +171 -171
  148. package/src/cell-render.test.ts +513 -513
  149. package/src/cell-render.ts +496 -496
  150. package/src/cell-values.ts +148 -148
  151. package/src/chart-export.ts +202 -202
  152. package/src/chart-view.svelte.ts +36 -36
  153. package/src/chart.ts +2321 -2321
  154. package/src/collaboration.test.ts +104 -104
  155. package/src/collaboration.ts +167 -167
  156. package/src/column-groups.ts +78 -78
  157. package/src/core.performance.test.ts +30 -30
  158. package/src/core.ts +1865 -1865
  159. package/src/createAutocomplete.svelte.ts +132 -132
  160. package/src/createCombobox.svelte.ts +191 -191
  161. package/src/createCountryInput.svelte.ts +157 -157
  162. package/src/createDropdownList.svelte.ts +168 -168
  163. package/src/createForm.svelte.ts +386 -386
  164. package/src/createGrid.svelte.ts +42 -42
  165. package/src/createGrid.test.ts +10 -10
  166. package/src/createGridState.svelte.ts +17 -17
  167. package/src/createListbox.svelte.ts +250 -250
  168. package/src/createMenu.svelte.ts +224 -224
  169. package/src/createPopoverSelect.svelte.ts +213 -213
  170. package/src/createSlider.svelte.ts +191 -191
  171. package/src/createTooltip.svelte.ts +144 -144
  172. package/src/createTree.svelte.ts +322 -322
  173. package/src/datetime/date-core.ts +206 -206
  174. package/src/datetime/date-restrict.ts +61 -61
  175. package/src/datetime/timezone.ts +135 -135
  176. package/src/dock-manager-model.ts +596 -596
  177. package/src/dock-model.ts +374 -374
  178. package/src/editing.test.ts +974 -974
  179. package/src/editing.ts +618 -609
  180. package/src/editor-contract.ts +171 -171
  181. package/src/editor-registry.grid.test.ts +144 -144
  182. package/src/editor-registry.ts +122 -122
  183. package/src/export-data-api.test.ts +126 -126
  184. package/src/export-format.test.ts +107 -107
  185. package/src/export-format.ts +601 -601
  186. package/src/filter-operators.ts +160 -160
  187. package/src/filtering/excel-filters.ts +325 -325
  188. package/src/flex-render.ts +3 -3
  189. package/src/form-field.ts +127 -127
  190. package/src/group-display.test.ts +167 -167
  191. package/src/group-display.ts +200 -200
  192. package/src/headless.ts +87 -87
  193. package/src/js-scroller.svelte.ts +173 -173
  194. package/src/keyboard-handlers.ts +270 -270
  195. package/src/keyboard.test.ts +59 -59
  196. package/src/keyboard.ts +97 -97
  197. package/src/list-nav.test.ts +49 -49
  198. package/src/list-nav.ts +29 -29
  199. package/src/list-option.test.ts +56 -56
  200. package/src/list-option.ts +179 -179
  201. package/src/menus.ts +597 -597
  202. package/src/merge-objects.ts +48 -48
  203. package/src/overlays.test.ts +90 -90
  204. package/src/positioning.ts +268 -268
  205. package/src/render-component.ts +28 -28
  206. package/src/row-drag.test.ts +401 -401
  207. package/src/row-drag.ts +419 -419
  208. package/src/row-resize.test.ts +524 -524
  209. package/src/row-resize.ts +228 -228
  210. package/src/scheduler-ical.ts +181 -181
  211. package/src/scheduler-model.test.ts +562 -562
  212. package/src/scheduler-model.ts +873 -873
  213. package/src/selection.test.ts +885 -885
  214. package/src/server-data-source.test.ts +383 -383
  215. package/src/server-data-source.ts +469 -469
  216. package/src/sparkline.test.ts +68 -68
  217. package/src/sparkline.ts +169 -169
  218. package/src/spreadsheet.test.ts +488 -488
  219. package/src/spreadsheet.ts +312 -312
  220. package/src/static-functions.ts +11 -11
  221. package/src/subscribe.ts +38 -38
  222. package/src/summaries.ts +113 -113
  223. package/src/svgrid-wrapper.types.ts +563 -563
  224. package/src/svgrid.async-editor-options.test.ts +273 -273
  225. package/src/svgrid.auto-row-height.test.ts +204 -204
  226. package/src/svgrid.behavior.test.ts +910 -910
  227. package/src/svgrid.charting.test.ts +534 -534
  228. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  229. package/src/svgrid.context-menu.test.ts +147 -147
  230. package/src/svgrid.features.test.ts +157 -157
  231. package/src/svgrid.filter-depth.test.ts +163 -163
  232. package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
  233. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  234. package/src/svgrid.grand-total.test.ts +188 -188
  235. package/src/svgrid.group-display-mode.test.ts +171 -171
  236. package/src/svgrid.group-footers.test.ts +121 -121
  237. package/src/svgrid.group-pagination.test.ts +153 -153
  238. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  239. package/src/svgrid.tree-data.test.ts +186 -186
  240. package/src/svgrid.wrapper.test.ts +63 -63
  241. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  242. package/src/test-setup.ts +62 -62
  243. package/src/themes/index.ts +288 -288
  244. package/src/toast-store.svelte.ts +250 -250
  245. package/src/toast-store.test.ts +147 -147
  246. package/src/tree-row-model.test.ts +168 -168
  247. package/src/ui-buttons.test.ts +144 -144
  248. package/src/ui-inputs.test.ts +118 -118
  249. package/src/ui-localization.test.ts +113 -113
  250. package/src/ui-range.test.ts +70 -70
  251. package/src/ui-selection.test.ts +155 -155
  252. package/src/ui-tier1.test.ts +142 -142
  253. package/src/virtual.test.ts +88 -88
  254. package/src/virtualization/column-virtualizer.test.ts +27 -27
  255. package/src/virtualization/column-virtualizer.ts +30 -30
  256. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  257. package/src/virtualization/types.ts +30 -30
  258. package/src/virtualization/virtualizer.test.ts +47 -47
  259. 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}