@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,496 +1,496 @@
1
- // cell-render handlers extracted from the controller. Imperative event handlers
2
- // reading/writing controller state via the `ctx` handle; the reactive core
3
- // ($state/$derived/$effect) stays in the controller.
4
- import {
5
- normalizeEditorOptions,
6
- type CellEditorOption,
7
- type CellFormatter,
8
- type CellFormatConfig,
9
- type Column,
10
- type Row,
11
- type RowData,
12
- type TableFeatures,
13
- } from "./index";
14
- import "./sv-grid-scrollbar";
15
- import {
16
- formatNumericWithConfig,
17
- getDateFormatter,
18
- resolveDatePattern,
19
- } from "./cell-formatting";
20
- import {
21
- resolveCellFormat,
22
- type ResolvedCellFormat,
23
- } from "./conditional-formatting";
24
- import {
25
- getCellKey,
26
- resolveClassList,
27
- getOptionLabel,
28
- asDate,
29
- } from "./SvGrid.helpers";
30
- import {
31
- getPinnedCellValue,
32
- } from "./cell-values";
33
-
34
- export function createCellRender<
35
- TFeatures extends TableFeatures = TableFeatures,
36
- TData extends RowData = RowData,
37
- >(ctx: any) {
38
- function cellConditionalFormat(
39
- row: Row<TData>,
40
- column: Column<TData>,
41
- value: unknown,
42
- ): ResolvedCellFormat | null {
43
- const formats = ctx.props.conditionalFormats;
44
- if (!formats?.length) return null;
45
- return resolveCellFormat(
46
- value,
47
- row.original,
48
- column.id,
49
- formats,
50
- ctx.conditionalColumnStats.get(column.id) ?? null,
51
- );
52
- }
53
-
54
- /** Compute the consumer-supplied row class for one rendered row. */
55
- function computeRowClass(row: Row<TData>, rowIndex: number): string {
56
- if (!ctx.props.rowClass) return "";
57
- return resolveClassList(
58
- ctx.props.rowClass({ row: row.original as TData, rowIndex }),
59
- );
60
- }
61
-
62
- /** Compute the consumer-supplied cell class for one rendered cell. */
63
- function computeCellClass(row: Row<TData>, column: Column<TData>): string {
64
- const raw = column.columnDef.cellClass;
65
- if (raw == null) return "";
66
- if (typeof raw === "string" || Array.isArray(raw)) {
67
- return resolveClassList(raw);
68
- }
69
- if (typeof raw === "function") {
70
- // Build a minimal CellContext - the only fields the wrapper-side
71
- // cellClass author needs are `row` and `column`. Callers can read
72
- // `ctx.row.original` and `ctx.column.id` exactly like in a `cell`
73
- // renderer. The other fields are stubbed for compatibility.
74
- const cellCtx = {
75
- row,
76
- column,
77
- cell: undefined as any,
78
- table: undefined as any,
79
- getValue: () => row.getCellValueByColumnId(column.id),
80
- };
81
- return resolveClassList(raw(cellCtx as any));
82
- }
83
- return "";
84
- }
85
-
86
- /**
87
- * Resolve the per-cell tooltip. Column-level `tooltip` field can be a
88
- * plain string (rendered as `title=`) or a `(ctx) => string` callback
89
- * for value-dependent text. Returning empty / nullish means no
90
- * tooltip - the renderer omits the `title=` attribute entirely.
91
- */
92
- function computeCellTooltip(row: Row<TData>, column: Column<TData>): string | null {
93
- const raw = column.columnDef.tooltip
94
- if (raw == null) return null
95
- if (typeof raw === "string") return raw || null
96
- if (typeof raw === "function") {
97
- const ctx = {
98
- row,
99
- column,
100
- cell: undefined as any,
101
- table: undefined as any,
102
- getValue: () => row.getCellValueByColumnId(column.id),
103
- }
104
- const out = (raw as any)(ctx)
105
- return out ? String(out) : null
106
- }
107
- return null
108
- }
109
-
110
- /**
111
- * Resolve per-cell validity via the column's declarative `validate` hook.
112
- * Runs for every rendered cell so pre-existing bad data is flagged on load.
113
- * Returns `{ invalid, message }` - `invalid` drives the red highlight class,
114
- * `message` (when present) becomes the cell's tooltip.
115
- */
116
- function computeCellValidity(
117
- row: Row<TData>,
118
- column: Column<TData>,
119
- ): { invalid: boolean; message: string | null } {
120
- const rule = column.columnDef.validate as
121
- | ((params: {
122
- value: unknown
123
- row: TData
124
- rowIndex: number
125
- column: Column<TData>
126
- }) => string | boolean | null | undefined)
127
- | undefined
128
- if (typeof rule !== "function") return { invalid: false, message: null }
129
- // Read the edit-aware value: inline edits land in `editedCellValues`
130
- // (keyed rowId:colId) and are NOT written into the row's memoized value
131
- // cache, so `getCellValueByColumnId` would return the stale pre-edit value
132
- // and validation would never re-run after an edit. Consult the overlay
133
- // first - same rule the display path (getCellDisplayValue) and summaries
134
- // use - so a cell re-validates live as the user types.
135
- const edited = ctx.editedCellValues ?? {}
136
- const key = getCellKey(row.id, column.id)
137
- const value =
138
- key in edited ? edited[key] : row.getCellValueByColumnId(column.id)
139
- const out = rule({
140
- value,
141
- row: row.original as TData,
142
- rowIndex: row.index,
143
- column,
144
- })
145
- // Valid: null / undefined / true. Invalid: false or a message string.
146
- if (out == null || out === true) return { invalid: false, message: null }
147
- if (out === false) return { invalid: true, message: null }
148
- const msg = String(out)
149
- return { invalid: true, message: msg.trim() ? msg : null }
150
- }
151
-
152
- /**
153
- * Resolve a per-cell note (a longer comment / annotation). Notes
154
- * come from the grid's `notes` prop - a `{ [rowId]: { [columnId]: string } }`
155
- * map - so the consumer keeps note storage. Returning non-empty
156
- * paints a corner indicator AND becomes the cell's tooltip text.
157
- */
158
- function computeCellNote(row: Row<TData>, column: Column<TData>): string | null {
159
- // Internal overlay (from the comment editor) wins over props.notes so
160
- // edits show immediately even when `notes` is controlled. An empty
161
- // overlay entry means "removed".
162
- const ov = ctx.noteOverrides?.[row.id]?.[column.id]
163
- if (ov !== undefined) return ov.trim() ? ov : null
164
- const map = ctx.props.notes
165
- if (!map) return null
166
- const byCol = map[row.id]
167
- if (!byCol) return null
168
- const v = byCol[column.id]
169
- return v && v.trim() ? v : null
170
- }
171
-
172
- const isThenable = (v: unknown): v is Promise<unknown> =>
173
- typeof (v as { then?: unknown } | null)?.then === "function";
174
-
175
- /**
176
- * Resolve a promised option list once per key, then keep it.
177
- *
178
- * Render paths call `getColumnEditorOptions` synchronously and often, so an
179
- * async source cannot block: the first call starts the fetch and returns an
180
- * empty list, and the resolved value lands in reactive state which re-renders
181
- * the editor. `asyncEditorOptionsPending` tracks in-flight keys so repeated
182
- * renders do not fire the request again.
183
- */
184
- function resolveAsyncOptions(
185
- key: string,
186
- promise: Promise<unknown>,
187
- /** Drop other cached entries starting with this prefix when the result
188
- * lands - used to evict a cascade's superseded signatures. */
189
- prunePrefix?: string,
190
- ): CellEditorOption[] {
191
- const done = ctx.asyncEditorOptions[key];
192
- if (done) return done;
193
- const pending: Set<string> = ctx.asyncEditorOptionsPending;
194
- if (!pending.has(key)) {
195
- // Plain Set, not reactive state: this runs inside the template, and a
196
- // $state write during render is a Svelte 5 error.
197
- pending.add(key);
198
- const store = (value: CellEditorOption[]) => {
199
- const next: Record<string, CellEditorOption[]> = {};
200
- for (const [k, v] of Object.entries(ctx.asyncEditorOptions as Record<string, CellEditorOption[]>)) {
201
- // Keep everything except this cell's stale signatures.
202
- if (prunePrefix && k !== key && k.startsWith(prunePrefix)) continue;
203
- next[k] = v;
204
- }
205
- next[key] = value;
206
- ctx.asyncEditorOptions = next;
207
- };
208
- promise
209
- .then((value) => store(normalizeEditorOptions(value as never)))
210
- // A failed lookup settles on "no options" rather than leaving the
211
- // editor spinning forever.
212
- .catch(() => store([]))
213
- .finally(() => {
214
- pending.delete(key);
215
- });
216
- }
217
- return [];
218
- }
219
-
220
- /**
221
- * Cache key for a per-row async source.
222
- *
223
- * A cascade depends on the row's OTHER cells - City is a function of Country -
224
- * so keying by row id alone made the first result permanent: changing the
225
- * country left the old city list cached forever. The row's own values are
226
- * folded into the key, so editing the row naturally supersedes the entry.
227
- *
228
- * Only reached for columns already known to be async, so the stringify cost
229
- * is bounded by the rows actually rendering such a column.
230
- */
231
- function asyncRowKey(column: Column<TData>, row: Row<TData>): string {
232
- let signature: string;
233
- try {
234
- signature = JSON.stringify(row.original);
235
- } catch {
236
- // Circular / non-serializable row: fall back to identity, which at least
237
- // invalidates when the consumer replaces the object immutably.
238
- signature = String(row.index);
239
- }
240
- return `${column.id}::${row.id}::${signature}`;
241
- }
242
-
243
- function getColumnEditorOptions(
244
- column: Column<TData>,
245
- row?: Row<TData> | null,
246
- ): CellEditorOption[] {
247
- const def = column.columnDef.editorOptions;
248
- if (typeof def === "function") {
249
- // Dynamic per-row: must be re-evaluated because the row's other
250
- // cells may have just changed (cascade).
251
- if (!row?.original) return [];
252
- // Once a column is known to hand back promises, answer from the cache
253
- // WITHOUT calling the source again. Render reads options constantly, so
254
- // calling an async source every time fires a real request per render -
255
- // and any state the source touches is being mutated mid-render, which
256
- // Svelte rejects outright. The key folds in the row's values, so editing
257
- // the row supersedes the entry and the cascade refetches.
258
- const known = (ctx.asyncEditorColumns as Set<string>).has(column.id);
259
- if (known) {
260
- const key = asyncRowKey(column, row);
261
- const done = ctx.asyncEditorOptions[key];
262
- if (done) return done;
263
- if ((ctx.asyncEditorOptionsPending as Set<string>).has(key)) return [];
264
- const produced = def(row.original as TData);
265
- if (isThenable(produced)) {
266
- return resolveAsyncOptions(key, produced, `${column.id}::${row.id}::`);
267
- }
268
- return normalizeEditorOptions(produced);
269
- }
270
- const produced = def(row.original as TData);
271
- if (isThenable(produced)) {
272
- (ctx.asyncEditorColumns as Set<string>).add(column.id);
273
- return resolveAsyncOptions(
274
- asyncRowKey(column, row),
275
- produced,
276
- `${column.id}::${row.id}::`,
277
- );
278
- }
279
- return normalizeEditorOptions(produced);
280
- }
281
- if (isThenable(def)) return resolveAsyncOptions(column.id, def);
282
- const id = column.id;
283
- if (
284
- !ctx.editorOptionsCache[id] ||
285
- ctx.editorOptionsCache[id + "__src"] !== (def as unknown as object)
286
- ) {
287
- ctx.editorOptionsCache[id] = normalizeEditorOptions(def);
288
- (ctx.editorOptionsCache as Record<string, unknown>)[id + "__src"] = def;
289
- }
290
- return ctx.editorOptionsCache[id];
291
- }
292
-
293
- /**
294
- * True while a column's (or cell's) async option list is still loading.
295
- * Derived from "the source is async and no result has landed" rather than
296
- * from the pending Set, so it stays reactive - `asyncEditorOptions` is the
297
- * reactive half, the Set is only a dedupe guard.
298
- */
299
- function areEditorOptionsLoading(
300
- column: Column<TData>,
301
- row?: Row<TData> | null,
302
- ): boolean {
303
- const def = column.columnDef.editorOptions;
304
- if (typeof def === "function") {
305
- if (!row?.original) return false;
306
- // Never call the source here - that would fire another request just to
307
- // answer "are we loading?". The resolver records which columns are async.
308
- if (!(ctx.asyncEditorColumns as Set<string>).has(column.id)) return false;
309
- return !ctx.asyncEditorOptions[asyncRowKey(column, row)];
310
- }
311
- return isThenable(def) && !ctx.asyncEditorOptions[column.id];
312
- }
313
-
314
- /** Joined display string for list/chips cells. */
315
- function formatListCellValue(
316
- column: Column<TData>,
317
- value: unknown,
318
- row?: Row<TData> | null,
319
- ): string {
320
- const options = getColumnEditorOptions(column, row);
321
- const sep = column.columnDef.editorSeparator ?? ", ";
322
- if (Array.isArray(value)) {
323
- return value.map((v) => getOptionLabel(options, v)).join(sep);
324
- }
325
- if (value == null || value === "") return "";
326
- return getOptionLabel(options, value);
327
- }
328
-
329
- function formatCellValue(
330
- column: Column<TData>,
331
- value: unknown,
332
- row: Row<TData>,
333
- ) {
334
- const formatter = column.columnDef.formatter as
335
- | CellFormatter<TData>
336
- | undefined;
337
- if (typeof formatter === "function") {
338
- const formatted = formatter({ value, row, column, table: ctx.grid });
339
- return String(formatted ?? "");
340
- }
341
-
342
- // Password columns: mask the stored value with bullets when the cell
343
- // is in read-only mode. The editor still receives the real string.
344
- if (column.columnDef.editorType === "password") {
345
- const s = String(value ?? "");
346
- return s.length > 0 ? "•".repeat(Math.min(s.length, 12)) : "";
347
- }
348
-
349
- const formatConfig = column.columnDef.format as
350
- | CellFormatConfig
351
- | undefined;
352
- if (!formatConfig) return String(value ?? "");
353
-
354
- if (
355
- formatConfig.type === "number" ||
356
- formatConfig.type === "currency" ||
357
- formatConfig.type === "percent"
358
- ) {
359
- return formatNumericWithConfig(value, {
360
- type: formatConfig.type,
361
- locales: (formatConfig.locales ?? ctx.props.localization?.locale),
362
- currency:
363
- formatConfig.type === "currency"
364
- ? (formatConfig.currency ?? "USD")
365
- : undefined,
366
- valueIsPercentPoints:
367
- formatConfig.type === "percent"
368
- ? formatConfig.valueIsPercentPoints
369
- : undefined,
370
- options: formatConfig.options,
371
- });
372
- }
373
-
374
- if (formatConfig.type === "date" || formatConfig.type === "datetime") {
375
- const parsedDate = asDate(value);
376
- if (parsedDate) {
377
- const preset = resolveDatePattern(
378
- formatConfig.pattern,
379
- formatConfig.type,
380
- );
381
- const merged: Intl.DateTimeFormatOptions =
382
- preset || formatConfig.options
383
- ? { ...preset, ...formatConfig.options }
384
- : formatConfig.type === "date"
385
- ? { year: "numeric", month: "2-digit", day: "2-digit" }
386
- : {
387
- year: "numeric",
388
- month: "2-digit",
389
- day: "2-digit",
390
- hour: "2-digit",
391
- minute: "2-digit",
392
- };
393
-
394
- return getDateFormatter((formatConfig.locales ?? ctx.props.localization?.locale), merged).format(
395
- parsedDate,
396
- );
397
- }
398
- }
399
-
400
- return String(value ?? "");
401
- }
402
-
403
- /**
404
- * Format a cell value for a PINNED row. Same surface as
405
- * `formatCellValue` but doesn't depend on Row<TData>. Custom
406
- * `formatter` callbacks are invoked with a `row` of `null` (their
407
- * type allows it; most don't read it).
408
- */
409
- function formatPinnedValue(
410
- column: Column<TData>,
411
- value: unknown,
412
- ): string {
413
- const def = column.columnDef;
414
- const formatter = def.formatter as
415
- | ((ctx: { value: unknown; row: null; column: Column<TData>; table: unknown }) => unknown)
416
- | undefined;
417
- if (typeof formatter === "function") {
418
- return String(
419
- formatter({ value, row: null, column, table: ctx.grid }) ?? "",
420
- );
421
- }
422
- if (def.editorType === "password") {
423
- const s = String(value ?? "");
424
- return s.length > 0 ? "•".repeat(Math.min(s.length, 12)) : "";
425
- }
426
- const formatConfig = def.format as CellFormatConfig | undefined;
427
- if (!formatConfig) return String(value ?? "");
428
- if (
429
- formatConfig.type === "number" ||
430
- formatConfig.type === "currency" ||
431
- formatConfig.type === "percent"
432
- ) {
433
- return formatNumericWithConfig(value, {
434
- type: formatConfig.type,
435
- locales: (formatConfig.locales ?? ctx.props.localization?.locale),
436
- currency:
437
- formatConfig.type === "currency"
438
- ? (formatConfig.currency ?? "USD")
439
- : undefined,
440
- valueIsPercentPoints:
441
- formatConfig.type === "percent"
442
- ? formatConfig.valueIsPercentPoints
443
- : undefined,
444
- options: formatConfig.options,
445
- });
446
- }
447
- if (formatConfig.type === "date" || formatConfig.type === "datetime") {
448
- const parsedDate = asDate(value);
449
- if (parsedDate) {
450
- const preset = resolveDatePattern(formatConfig.pattern, formatConfig.type);
451
- const merged: Intl.DateTimeFormatOptions =
452
- preset || formatConfig.options
453
- ? { ...preset, ...formatConfig.options }
454
- : formatConfig.type === "date"
455
- ? { year: "numeric", month: "2-digit", day: "2-digit" }
456
- : { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" };
457
- return getDateFormatter((formatConfig.locales ?? ctx.props.localization?.locale), merged).format(parsedDate);
458
- }
459
- }
460
- return String(value ?? "");
461
- }
462
-
463
- /**
464
- * Resolve `cellClass` for a pinned row. We synthesise a minimal
465
- * context `{ getValue, value, row: null, column }`; cell-class
466
- * callbacks that read only the value (the common case) work
467
- * unchanged.
468
- */
469
- function computePinnedCellClass(
470
- rowData: TData,
471
- column: Column<TData>,
472
- ): string {
473
- const cellClass = column.columnDef.cellClass as
474
- | ((ctx: { getValue: () => unknown; value: unknown; row: null; column: Column<TData> }) => string | undefined | null)
475
- | undefined;
476
- if (typeof cellClass !== "function") return "";
477
- const value = getPinnedCellValue(rowData, column);
478
- const out = cellClass({ getValue: () => value, value, row: null, column });
479
- return out ? String(out) : "";
480
- }
481
-
482
- return {
483
- cellConditionalFormat,
484
- computeRowClass,
485
- computeCellClass,
486
- computeCellTooltip,
487
- computeCellValidity,
488
- computeCellNote,
489
- getColumnEditorOptions,
490
- areEditorOptionsLoading,
491
- formatListCellValue,
492
- formatCellValue,
493
- formatPinnedValue,
494
- computePinnedCellClass,
495
- };
496
- }
1
+ // cell-render handlers extracted from the controller. Imperative event handlers
2
+ // reading/writing controller state via the `ctx` handle; the reactive core
3
+ // ($state/$derived/$effect) stays in the controller.
4
+ import {
5
+ normalizeEditorOptions,
6
+ type CellEditorOption,
7
+ type CellFormatter,
8
+ type CellFormatConfig,
9
+ type Column,
10
+ type Row,
11
+ type RowData,
12
+ type TableFeatures,
13
+ } from "./index";
14
+ import "./sv-grid-scrollbar";
15
+ import {
16
+ formatNumericWithConfig,
17
+ getDateFormatter,
18
+ resolveDatePattern,
19
+ } from "./cell-formatting";
20
+ import {
21
+ resolveCellFormat,
22
+ type ResolvedCellFormat,
23
+ } from "./conditional-formatting";
24
+ import {
25
+ getCellKey,
26
+ resolveClassList,
27
+ getOptionLabel,
28
+ asDate,
29
+ } from "./SvGrid.helpers";
30
+ import {
31
+ getPinnedCellValue,
32
+ } from "./cell-values";
33
+
34
+ export function createCellRender<
35
+ TFeatures extends TableFeatures = TableFeatures,
36
+ TData extends RowData = RowData,
37
+ >(ctx: any) {
38
+ function cellConditionalFormat(
39
+ row: Row<TData>,
40
+ column: Column<TData>,
41
+ value: unknown,
42
+ ): ResolvedCellFormat | null {
43
+ const formats = ctx.props.conditionalFormats;
44
+ if (!formats?.length) return null;
45
+ return resolveCellFormat(
46
+ value,
47
+ row.original,
48
+ column.id,
49
+ formats,
50
+ ctx.conditionalColumnStats.get(column.id) ?? null,
51
+ );
52
+ }
53
+
54
+ /** Compute the consumer-supplied row class for one rendered row. */
55
+ function computeRowClass(row: Row<TData>, rowIndex: number): string {
56
+ if (!ctx.props.rowClass) return "";
57
+ return resolveClassList(
58
+ ctx.props.rowClass({ row: row.original as TData, rowIndex }),
59
+ );
60
+ }
61
+
62
+ /** Compute the consumer-supplied cell class for one rendered cell. */
63
+ function computeCellClass(row: Row<TData>, column: Column<TData>): string {
64
+ const raw = column.columnDef.cellClass;
65
+ if (raw == null) return "";
66
+ if (typeof raw === "string" || Array.isArray(raw)) {
67
+ return resolveClassList(raw);
68
+ }
69
+ if (typeof raw === "function") {
70
+ // Build a minimal CellContext - the only fields the wrapper-side
71
+ // cellClass author needs are `row` and `column`. Callers can read
72
+ // `ctx.row.original` and `ctx.column.id` exactly like in a `cell`
73
+ // renderer. The other fields are stubbed for compatibility.
74
+ const cellCtx = {
75
+ row,
76
+ column,
77
+ cell: undefined as any,
78
+ table: undefined as any,
79
+ getValue: () => row.getCellValueByColumnId(column.id),
80
+ };
81
+ return resolveClassList(raw(cellCtx as any));
82
+ }
83
+ return "";
84
+ }
85
+
86
+ /**
87
+ * Resolve the per-cell tooltip. Column-level `tooltip` field can be a
88
+ * plain string (rendered as `title=`) or a `(ctx) => string` callback
89
+ * for value-dependent text. Returning empty / nullish means no
90
+ * tooltip - the renderer omits the `title=` attribute entirely.
91
+ */
92
+ function computeCellTooltip(row: Row<TData>, column: Column<TData>): string | null {
93
+ const raw = column.columnDef.tooltip
94
+ if (raw == null) return null
95
+ if (typeof raw === "string") return raw || null
96
+ if (typeof raw === "function") {
97
+ const ctx = {
98
+ row,
99
+ column,
100
+ cell: undefined as any,
101
+ table: undefined as any,
102
+ getValue: () => row.getCellValueByColumnId(column.id),
103
+ }
104
+ const out = (raw as any)(ctx)
105
+ return out ? String(out) : null
106
+ }
107
+ return null
108
+ }
109
+
110
+ /**
111
+ * Resolve per-cell validity via the column's declarative `validate` hook.
112
+ * Runs for every rendered cell so pre-existing bad data is flagged on load.
113
+ * Returns `{ invalid, message }` - `invalid` drives the red highlight class,
114
+ * `message` (when present) becomes the cell's tooltip.
115
+ */
116
+ function computeCellValidity(
117
+ row: Row<TData>,
118
+ column: Column<TData>,
119
+ ): { invalid: boolean; message: string | null } {
120
+ const rule = column.columnDef.validate as
121
+ | ((params: {
122
+ value: unknown
123
+ row: TData
124
+ rowIndex: number
125
+ column: Column<TData>
126
+ }) => string | boolean | null | undefined)
127
+ | undefined
128
+ if (typeof rule !== "function") return { invalid: false, message: null }
129
+ // Read the edit-aware value: inline edits land in `editedCellValues`
130
+ // (keyed rowId:colId) and are NOT written into the row's memoized value
131
+ // cache, so `getCellValueByColumnId` would return the stale pre-edit value
132
+ // and validation would never re-run after an edit. Consult the overlay
133
+ // first - same rule the display path (getCellDisplayValue) and summaries
134
+ // use - so a cell re-validates live as the user types.
135
+ const edited = ctx.editedCellValues ?? {}
136
+ const key = getCellKey(row.id, column.id)
137
+ const value =
138
+ key in edited ? edited[key] : row.getCellValueByColumnId(column.id)
139
+ const out = rule({
140
+ value,
141
+ row: row.original as TData,
142
+ rowIndex: row.index,
143
+ column,
144
+ })
145
+ // Valid: null / undefined / true. Invalid: false or a message string.
146
+ if (out == null || out === true) return { invalid: false, message: null }
147
+ if (out === false) return { invalid: true, message: null }
148
+ const msg = String(out)
149
+ return { invalid: true, message: msg.trim() ? msg : null }
150
+ }
151
+
152
+ /**
153
+ * Resolve a per-cell note (a longer comment / annotation). Notes
154
+ * come from the grid's `notes` prop - a `{ [rowId]: { [columnId]: string } }`
155
+ * map - so the consumer keeps note storage. Returning non-empty
156
+ * paints a corner indicator AND becomes the cell's tooltip text.
157
+ */
158
+ function computeCellNote(row: Row<TData>, column: Column<TData>): string | null {
159
+ // Internal overlay (from the comment editor) wins over props.notes so
160
+ // edits show immediately even when `notes` is controlled. An empty
161
+ // overlay entry means "removed".
162
+ const ov = ctx.noteOverrides?.[row.id]?.[column.id]
163
+ if (ov !== undefined) return ov.trim() ? ov : null
164
+ const map = ctx.props.notes
165
+ if (!map) return null
166
+ const byCol = map[row.id]
167
+ if (!byCol) return null
168
+ const v = byCol[column.id]
169
+ return v && v.trim() ? v : null
170
+ }
171
+
172
+ const isThenable = (v: unknown): v is Promise<unknown> =>
173
+ typeof (v as { then?: unknown } | null)?.then === "function";
174
+
175
+ /**
176
+ * Resolve a promised option list once per key, then keep it.
177
+ *
178
+ * Render paths call `getColumnEditorOptions` synchronously and often, so an
179
+ * async source cannot block: the first call starts the fetch and returns an
180
+ * empty list, and the resolved value lands in reactive state which re-renders
181
+ * the editor. `asyncEditorOptionsPending` tracks in-flight keys so repeated
182
+ * renders do not fire the request again.
183
+ */
184
+ function resolveAsyncOptions(
185
+ key: string,
186
+ promise: Promise<unknown>,
187
+ /** Drop other cached entries starting with this prefix when the result
188
+ * lands - used to evict a cascade's superseded signatures. */
189
+ prunePrefix?: string,
190
+ ): CellEditorOption[] {
191
+ const done = ctx.asyncEditorOptions[key];
192
+ if (done) return done;
193
+ const pending: Set<string> = ctx.asyncEditorOptionsPending;
194
+ if (!pending.has(key)) {
195
+ // Plain Set, not reactive state: this runs inside the template, and a
196
+ // $state write during render is a Svelte 5 error.
197
+ pending.add(key);
198
+ const store = (value: CellEditorOption[]) => {
199
+ const next: Record<string, CellEditorOption[]> = {};
200
+ for (const [k, v] of Object.entries(ctx.asyncEditorOptions as Record<string, CellEditorOption[]>)) {
201
+ // Keep everything except this cell's stale signatures.
202
+ if (prunePrefix && k !== key && k.startsWith(prunePrefix)) continue;
203
+ next[k] = v;
204
+ }
205
+ next[key] = value;
206
+ ctx.asyncEditorOptions = next;
207
+ };
208
+ promise
209
+ .then((value) => store(normalizeEditorOptions(value as never)))
210
+ // A failed lookup settles on "no options" rather than leaving the
211
+ // editor spinning forever.
212
+ .catch(() => store([]))
213
+ .finally(() => {
214
+ pending.delete(key);
215
+ });
216
+ }
217
+ return [];
218
+ }
219
+
220
+ /**
221
+ * Cache key for a per-row async source.
222
+ *
223
+ * A cascade depends on the row's OTHER cells - City is a function of Country -
224
+ * so keying by row id alone made the first result permanent: changing the
225
+ * country left the old city list cached forever. The row's own values are
226
+ * folded into the key, so editing the row naturally supersedes the entry.
227
+ *
228
+ * Only reached for columns already known to be async, so the stringify cost
229
+ * is bounded by the rows actually rendering such a column.
230
+ */
231
+ function asyncRowKey(column: Column<TData>, row: Row<TData>): string {
232
+ let signature: string;
233
+ try {
234
+ signature = JSON.stringify(row.original);
235
+ } catch {
236
+ // Circular / non-serializable row: fall back to identity, which at least
237
+ // invalidates when the consumer replaces the object immutably.
238
+ signature = String(row.index);
239
+ }
240
+ return `${column.id}::${row.id}::${signature}`;
241
+ }
242
+
243
+ function getColumnEditorOptions(
244
+ column: Column<TData>,
245
+ row?: Row<TData> | null,
246
+ ): CellEditorOption[] {
247
+ const def = column.columnDef.editorOptions;
248
+ if (typeof def === "function") {
249
+ // Dynamic per-row: must be re-evaluated because the row's other
250
+ // cells may have just changed (cascade).
251
+ if (!row?.original) return [];
252
+ // Once a column is known to hand back promises, answer from the cache
253
+ // WITHOUT calling the source again. Render reads options constantly, so
254
+ // calling an async source every time fires a real request per render -
255
+ // and any state the source touches is being mutated mid-render, which
256
+ // Svelte rejects outright. The key folds in the row's values, so editing
257
+ // the row supersedes the entry and the cascade refetches.
258
+ const known = (ctx.asyncEditorColumns as Set<string>).has(column.id);
259
+ if (known) {
260
+ const key = asyncRowKey(column, row);
261
+ const done = ctx.asyncEditorOptions[key];
262
+ if (done) return done;
263
+ if ((ctx.asyncEditorOptionsPending as Set<string>).has(key)) return [];
264
+ const produced = def(row.original as TData);
265
+ if (isThenable(produced)) {
266
+ return resolveAsyncOptions(key, produced, `${column.id}::${row.id}::`);
267
+ }
268
+ return normalizeEditorOptions(produced);
269
+ }
270
+ const produced = def(row.original as TData);
271
+ if (isThenable(produced)) {
272
+ (ctx.asyncEditorColumns as Set<string>).add(column.id);
273
+ return resolveAsyncOptions(
274
+ asyncRowKey(column, row),
275
+ produced,
276
+ `${column.id}::${row.id}::`,
277
+ );
278
+ }
279
+ return normalizeEditorOptions(produced);
280
+ }
281
+ if (isThenable(def)) return resolveAsyncOptions(column.id, def);
282
+ const id = column.id;
283
+ if (
284
+ !ctx.editorOptionsCache[id] ||
285
+ ctx.editorOptionsCache[id + "__src"] !== (def as unknown as object)
286
+ ) {
287
+ ctx.editorOptionsCache[id] = normalizeEditorOptions(def);
288
+ (ctx.editorOptionsCache as Record<string, unknown>)[id + "__src"] = def;
289
+ }
290
+ return ctx.editorOptionsCache[id];
291
+ }
292
+
293
+ /**
294
+ * True while a column's (or cell's) async option list is still loading.
295
+ * Derived from "the source is async and no result has landed" rather than
296
+ * from the pending Set, so it stays reactive - `asyncEditorOptions` is the
297
+ * reactive half, the Set is only a dedupe guard.
298
+ */
299
+ function areEditorOptionsLoading(
300
+ column: Column<TData>,
301
+ row?: Row<TData> | null,
302
+ ): boolean {
303
+ const def = column.columnDef.editorOptions;
304
+ if (typeof def === "function") {
305
+ if (!row?.original) return false;
306
+ // Never call the source here - that would fire another request just to
307
+ // answer "are we loading?". The resolver records which columns are async.
308
+ if (!(ctx.asyncEditorColumns as Set<string>).has(column.id)) return false;
309
+ return !ctx.asyncEditorOptions[asyncRowKey(column, row)];
310
+ }
311
+ return isThenable(def) && !ctx.asyncEditorOptions[column.id];
312
+ }
313
+
314
+ /** Joined display string for list/chips cells. */
315
+ function formatListCellValue(
316
+ column: Column<TData>,
317
+ value: unknown,
318
+ row?: Row<TData> | null,
319
+ ): string {
320
+ const options = getColumnEditorOptions(column, row);
321
+ const sep = column.columnDef.editorSeparator ?? ", ";
322
+ if (Array.isArray(value)) {
323
+ return value.map((v) => getOptionLabel(options, v)).join(sep);
324
+ }
325
+ if (value == null || value === "") return "";
326
+ return getOptionLabel(options, value);
327
+ }
328
+
329
+ function formatCellValue(
330
+ column: Column<TData>,
331
+ value: unknown,
332
+ row: Row<TData>,
333
+ ) {
334
+ const formatter = column.columnDef.formatter as
335
+ | CellFormatter<TData>
336
+ | undefined;
337
+ if (typeof formatter === "function") {
338
+ const formatted = formatter({ value, row, column, table: ctx.grid });
339
+ return String(formatted ?? "");
340
+ }
341
+
342
+ // Password columns: mask the stored value with bullets when the cell
343
+ // is in read-only mode. The editor still receives the real string.
344
+ if (column.columnDef.editorType === "password") {
345
+ const s = String(value ?? "");
346
+ return s.length > 0 ? "•".repeat(Math.min(s.length, 12)) : "";
347
+ }
348
+
349
+ const formatConfig = column.columnDef.format as
350
+ | CellFormatConfig
351
+ | undefined;
352
+ if (!formatConfig) return String(value ?? "");
353
+
354
+ if (
355
+ formatConfig.type === "number" ||
356
+ formatConfig.type === "currency" ||
357
+ formatConfig.type === "percent"
358
+ ) {
359
+ return formatNumericWithConfig(value, {
360
+ type: formatConfig.type,
361
+ locales: (formatConfig.locales ?? ctx.props.localization?.locale),
362
+ currency:
363
+ formatConfig.type === "currency"
364
+ ? (formatConfig.currency ?? "USD")
365
+ : undefined,
366
+ valueIsPercentPoints:
367
+ formatConfig.type === "percent"
368
+ ? formatConfig.valueIsPercentPoints
369
+ : undefined,
370
+ options: formatConfig.options,
371
+ });
372
+ }
373
+
374
+ if (formatConfig.type === "date" || formatConfig.type === "datetime") {
375
+ const parsedDate = asDate(value);
376
+ if (parsedDate) {
377
+ const preset = resolveDatePattern(
378
+ formatConfig.pattern,
379
+ formatConfig.type,
380
+ );
381
+ const merged: Intl.DateTimeFormatOptions =
382
+ preset || formatConfig.options
383
+ ? { ...preset, ...formatConfig.options }
384
+ : formatConfig.type === "date"
385
+ ? { year: "numeric", month: "2-digit", day: "2-digit" }
386
+ : {
387
+ year: "numeric",
388
+ month: "2-digit",
389
+ day: "2-digit",
390
+ hour: "2-digit",
391
+ minute: "2-digit",
392
+ };
393
+
394
+ return getDateFormatter((formatConfig.locales ?? ctx.props.localization?.locale), merged).format(
395
+ parsedDate,
396
+ );
397
+ }
398
+ }
399
+
400
+ return String(value ?? "");
401
+ }
402
+
403
+ /**
404
+ * Format a cell value for a PINNED row. Same surface as
405
+ * `formatCellValue` but doesn't depend on Row<TData>. Custom
406
+ * `formatter` callbacks are invoked with a `row` of `null` (their
407
+ * type allows it; most don't read it).
408
+ */
409
+ function formatPinnedValue(
410
+ column: Column<TData>,
411
+ value: unknown,
412
+ ): string {
413
+ const def = column.columnDef;
414
+ const formatter = def.formatter as
415
+ | ((ctx: { value: unknown; row: null; column: Column<TData>; table: unknown }) => unknown)
416
+ | undefined;
417
+ if (typeof formatter === "function") {
418
+ return String(
419
+ formatter({ value, row: null, column, table: ctx.grid }) ?? "",
420
+ );
421
+ }
422
+ if (def.editorType === "password") {
423
+ const s = String(value ?? "");
424
+ return s.length > 0 ? "•".repeat(Math.min(s.length, 12)) : "";
425
+ }
426
+ const formatConfig = def.format as CellFormatConfig | undefined;
427
+ if (!formatConfig) return String(value ?? "");
428
+ if (
429
+ formatConfig.type === "number" ||
430
+ formatConfig.type === "currency" ||
431
+ formatConfig.type === "percent"
432
+ ) {
433
+ return formatNumericWithConfig(value, {
434
+ type: formatConfig.type,
435
+ locales: (formatConfig.locales ?? ctx.props.localization?.locale),
436
+ currency:
437
+ formatConfig.type === "currency"
438
+ ? (formatConfig.currency ?? "USD")
439
+ : undefined,
440
+ valueIsPercentPoints:
441
+ formatConfig.type === "percent"
442
+ ? formatConfig.valueIsPercentPoints
443
+ : undefined,
444
+ options: formatConfig.options,
445
+ });
446
+ }
447
+ if (formatConfig.type === "date" || formatConfig.type === "datetime") {
448
+ const parsedDate = asDate(value);
449
+ if (parsedDate) {
450
+ const preset = resolveDatePattern(formatConfig.pattern, formatConfig.type);
451
+ const merged: Intl.DateTimeFormatOptions =
452
+ preset || formatConfig.options
453
+ ? { ...preset, ...formatConfig.options }
454
+ : formatConfig.type === "date"
455
+ ? { year: "numeric", month: "2-digit", day: "2-digit" }
456
+ : { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" };
457
+ return getDateFormatter((formatConfig.locales ?? ctx.props.localization?.locale), merged).format(parsedDate);
458
+ }
459
+ }
460
+ return String(value ?? "");
461
+ }
462
+
463
+ /**
464
+ * Resolve `cellClass` for a pinned row. We synthesise a minimal
465
+ * context `{ getValue, value, row: null, column }`; cell-class
466
+ * callbacks that read only the value (the common case) work
467
+ * unchanged.
468
+ */
469
+ function computePinnedCellClass(
470
+ rowData: TData,
471
+ column: Column<TData>,
472
+ ): string {
473
+ const cellClass = column.columnDef.cellClass as
474
+ | ((ctx: { getValue: () => unknown; value: unknown; row: null; column: Column<TData> }) => string | undefined | null)
475
+ | undefined;
476
+ if (typeof cellClass !== "function") return "";
477
+ const value = getPinnedCellValue(rowData, column);
478
+ const out = cellClass({ getValue: () => value, value, row: null, column });
479
+ return out ? String(out) : "";
480
+ }
481
+
482
+ return {
483
+ cellConditionalFormat,
484
+ computeRowClass,
485
+ computeCellClass,
486
+ computeCellTooltip,
487
+ computeCellValidity,
488
+ computeCellNote,
489
+ getColumnEditorOptions,
490
+ areEditorOptionsLoading,
491
+ formatListCellValue,
492
+ formatCellValue,
493
+ formatPinnedValue,
494
+ computePinnedCellClass,
495
+ };
496
+ }