@svgrid/grid 2.2.26 → 2.2.27

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 (218) hide show
  1. package/README.md +199 -145
  2. package/dist/FlexRender.svelte +96 -96
  3. package/dist/GridFooter.svelte +179 -179
  4. package/dist/GridMenus.svelte +734 -728
  5. package/dist/SvAutoComplete.svelte +165 -165
  6. package/dist/SvCalendar.svelte +503 -503
  7. package/dist/SvCarousel.svelte +141 -141
  8. package/dist/SvCheckBox.svelte +101 -101
  9. package/dist/SvColorInput.svelte +181 -181
  10. package/dist/SvComboBox.svelte +275 -275
  11. package/dist/SvContextMenu.svelte +116 -116
  12. package/dist/SvCountryInput.svelte +160 -160
  13. package/dist/SvDrawer.svelte +250 -250
  14. package/dist/SvDropDownList.svelte +374 -374
  15. package/dist/SvDurationInput.svelte +126 -126
  16. package/dist/SvField.svelte +293 -293
  17. package/dist/SvForm.svelte +437 -437
  18. package/dist/SvGrid.css +2723 -2719
  19. package/dist/SvGrid.svelte +3333 -3333
  20. package/dist/SvGridChart.svelte +1724 -1724
  21. package/dist/SvGridChartPanel.svelte +485 -485
  22. package/dist/SvGridChartView.svelte +70 -70
  23. package/dist/SvGridDropdown.svelte +696 -696
  24. package/dist/SvGridSelect.svelte +259 -259
  25. package/dist/SvGroupCell.svelte +109 -109
  26. package/dist/SvListBox.svelte +287 -287
  27. package/dist/SvMaskedInput.svelte +122 -122
  28. package/dist/SvMenu.svelte +124 -124
  29. package/dist/SvMenuList.svelte +146 -146
  30. package/dist/SvMultiSelect.svelte +281 -281
  31. package/dist/SvNumberInput.svelte +168 -168
  32. package/dist/SvOtpInput.svelte +158 -158
  33. package/dist/SvPasswordInput.svelte +151 -151
  34. package/dist/SvPhoneInput.svelte +131 -131
  35. package/dist/SvPopover.svelte +197 -197
  36. package/dist/SvRadioGroup.svelte +107 -107
  37. package/dist/SvRowGroupPanel.svelte +170 -170
  38. package/dist/SvScrollArea.svelte +61 -61
  39. package/dist/SvSlider.svelte +200 -200
  40. package/dist/SvSwitchButton.svelte +107 -107
  41. package/dist/SvTagsInput.svelte +112 -112
  42. package/dist/SvTextInput.svelte +147 -147
  43. package/dist/SvTimePicker.svelte +245 -245
  44. package/dist/SvToaster.svelte +159 -159
  45. package/dist/SvToggleButton.svelte +85 -85
  46. package/dist/SvTooltip.svelte +161 -161
  47. package/dist/SvTour.svelte +204 -204
  48. package/dist/SvTree.svelte +437 -437
  49. package/dist/SvTreeSelect.svelte +233 -233
  50. package/dist/a11y/dismissable.d.ts +11 -0
  51. package/dist/a11y/dismissable.js +21 -0
  52. package/dist/ai.js +3 -3
  53. package/dist/cdn/GridMenus-BzxEYNtW.js +421 -0
  54. package/dist/cdn/GridMenus-Dr-nGEXn.js +417 -0
  55. package/dist/cdn/{SvDateTimePicker-CYC7gRER.js → SvDateTimePicker-B1sDc9Pz.js} +451 -444
  56. package/dist/cdn/{SvDateTimePicker-DtQKd4Ns.js → SvDateTimePicker-Bh0FTs1E.js} +114 -107
  57. package/dist/cdn/{SvGridChart-BhnFdLd0.js → SvGridChart-BKQsO5fZ.js} +1 -1
  58. package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Bgk4hDs8.js} +1 -1
  59. package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-DSkHIQDd.js} +1 -1
  60. package/dist/cdn/{SvGridChartPanel-Cmx9DHAS.js → SvGridChartPanel-DkCc2Z7b.js} +1 -1
  61. package/dist/cdn/{SvGridChartView-B40DTJSX.js → SvGridChartView-BrbVaELm.js} +1 -1
  62. package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-i9rWvSKg.js} +1 -1
  63. package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CGxxopQ4.js} +1 -1
  64. package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D08sVmAy.js} +1 -1
  65. package/dist/cdn/{src-DysW9qbk.js → src-Cw8d9U8j.js} +5753 -5753
  66. package/dist/cdn/{src-bYfr_7aR.js → src-cgs1wcUS.js} +8102 -8102
  67. package/dist/cdn/svgrid.js +9 -9
  68. package/dist/cdn/svgrid.svelte-external.js +9 -9
  69. package/dist/chart-export.js +8 -8
  70. package/dist/index.d.ts +1 -1
  71. package/dist/index.js +1 -1
  72. package/package.json +30 -13
  73. package/src/FlexRender.svelte +96 -96
  74. package/src/GridFooter.svelte +179 -179
  75. package/src/GridMenus.svelte +734 -728
  76. package/src/SvAutoComplete.svelte +165 -165
  77. package/src/SvCalendar.svelte +503 -503
  78. package/src/SvCalendar.test.ts +226 -226
  79. package/src/SvCarousel.svelte +141 -141
  80. package/src/SvCheckBox.svelte +101 -101
  81. package/src/SvColorInput.svelte +181 -181
  82. package/src/SvComboBox.svelte +275 -275
  83. package/src/SvContextMenu.svelte +116 -116
  84. package/src/SvCountryInput.svelte +160 -160
  85. package/src/SvDrawer.svelte +250 -250
  86. package/src/SvDropDownList.svelte +374 -374
  87. package/src/SvDurationInput.svelte +126 -126
  88. package/src/SvField.svelte +293 -293
  89. package/src/SvForm.svelte +437 -437
  90. package/src/SvForm.test.ts +411 -411
  91. package/src/SvGrid.controller.svelte.ts +3239 -3239
  92. package/src/SvGrid.css +2723 -2719
  93. package/src/SvGrid.svelte +3333 -3333
  94. package/src/SvGrid.types.ts +1613 -1613
  95. package/src/SvGridChart.svelte +1724 -1724
  96. package/src/SvGridChartPanel.svelte +485 -485
  97. package/src/SvGridChartView.svelte +70 -70
  98. package/src/SvGridDropdown.svelte +696 -696
  99. package/src/SvGridSelect.svelte +259 -259
  100. package/src/SvGroupCell.svelte +109 -109
  101. package/src/SvListBox.svelte +287 -287
  102. package/src/SvMaskedInput.svelte +122 -122
  103. package/src/SvMenu.svelte +124 -124
  104. package/src/SvMenu.test.ts +97 -97
  105. package/src/SvMenuList.svelte +146 -146
  106. package/src/SvMultiSelect.svelte +281 -281
  107. package/src/SvNumberInput.svelte +168 -168
  108. package/src/SvOtpInput.svelte +158 -158
  109. package/src/SvPasswordInput.svelte +151 -151
  110. package/src/SvPhoneInput.svelte +131 -131
  111. package/src/SvPopover.svelte +197 -197
  112. package/src/SvRadioGroup.svelte +107 -107
  113. package/src/SvRowGroupPanel.svelte +170 -170
  114. package/src/SvScrollArea.svelte +61 -61
  115. package/src/SvSlider.svelte +200 -200
  116. package/src/SvSwitchButton.svelte +107 -107
  117. package/src/SvTagsInput.svelte +112 -112
  118. package/src/SvTextInput.svelte +147 -147
  119. package/src/SvTimePicker.svelte +245 -245
  120. package/src/SvToaster.svelte +159 -159
  121. package/src/SvToaster.test.ts +95 -95
  122. package/src/SvToggleButton.svelte +85 -85
  123. package/src/SvTooltip.svelte +161 -161
  124. package/src/SvTour.svelte +204 -204
  125. package/src/SvTree.svelte +437 -437
  126. package/src/SvTreeSelect.svelte +233 -233
  127. package/src/a11y/dismissable.test.ts +119 -95
  128. package/src/a11y/dismissable.ts +114 -94
  129. package/src/a11y.contract.test.ts +49 -49
  130. package/src/a11y.test.ts +59 -59
  131. package/src/a11y.ts +61 -61
  132. package/src/ai.test.ts +502 -502
  133. package/src/ai.ts +1391 -1391
  134. package/src/build-api.coverage.test.ts +633 -633
  135. package/src/build-api.ts +895 -895
  136. package/src/builtin-editors.grid.test.ts +85 -85
  137. package/src/cell-formatting.ts +169 -169
  138. package/src/cell-render.ts +469 -469
  139. package/src/chart-export.ts +201 -201
  140. package/src/chart-view.svelte.ts +36 -36
  141. package/src/chart.ts +2296 -2296
  142. package/src/collaboration.test.ts +104 -104
  143. package/src/collaboration.ts +167 -167
  144. package/src/core.performance.test.ts +30 -30
  145. package/src/core.ts +1111 -1111
  146. package/src/createAutocomplete.svelte.ts +132 -132
  147. package/src/createCombobox.svelte.ts +191 -191
  148. package/src/createCountryInput.svelte.ts +157 -157
  149. package/src/createDropdownList.svelte.ts +168 -168
  150. package/src/createForm.svelte.ts +386 -386
  151. package/src/createGrid.svelte.ts +42 -42
  152. package/src/createGrid.test.ts +10 -10
  153. package/src/createGridState.svelte.ts +17 -17
  154. package/src/createMenu.svelte.ts +225 -225
  155. package/src/createPopoverSelect.svelte.ts +213 -213
  156. package/src/createSlider.svelte.ts +191 -191
  157. package/src/createTooltip.svelte.ts +144 -144
  158. package/src/createTree.svelte.ts +319 -319
  159. package/src/editing.test.ts +859 -859
  160. package/src/editing.ts +675 -675
  161. package/src/editor-contract.ts +157 -157
  162. package/src/export-data-api.test.ts +126 -126
  163. package/src/export-format.test.ts +107 -107
  164. package/src/export-format.ts +598 -598
  165. package/src/filter-operators.ts +157 -157
  166. package/src/flex-render.ts +3 -3
  167. package/src/form-field.ts +127 -127
  168. package/src/index.ts +713 -712
  169. package/src/js-scroller.svelte.ts +173 -173
  170. package/src/keyboard.test.ts +59 -59
  171. package/src/keyboard.ts +97 -97
  172. package/src/list-nav.test.ts +49 -49
  173. package/src/list-nav.ts +29 -29
  174. package/src/list-option.test.ts +56 -56
  175. package/src/list-option.ts +172 -172
  176. package/src/menus.test.ts +700 -700
  177. package/src/menus.ts +652 -652
  178. package/src/merge-objects.ts +48 -48
  179. package/src/overlays.test.ts +90 -90
  180. package/src/render-component.ts +28 -28
  181. package/src/scheduler-model.test.ts +562 -562
  182. package/src/scheduler-model.ts +872 -872
  183. package/src/selection.test.ts +754 -754
  184. package/src/selection.ts +600 -600
  185. package/src/server-data-source.test.ts +289 -289
  186. package/src/server-data-source.ts +413 -413
  187. package/src/sparkline.test.ts +68 -68
  188. package/src/sparkline.ts +169 -169
  189. package/src/spreadsheet.test.ts +489 -489
  190. package/src/spreadsheet.ts +312 -312
  191. package/src/static-functions.ts +11 -11
  192. package/src/subscribe.ts +38 -38
  193. package/src/svgrid-wrapper.types.ts +524 -524
  194. package/src/svgrid.behavior.test.ts +706 -706
  195. package/src/svgrid.charting.test.ts +534 -534
  196. package/src/svgrid.features.test.ts +157 -157
  197. package/src/svgrid.filter-menu-scroll.test.ts +102 -0
  198. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  199. package/src/svgrid.wrapper.test.ts +40 -40
  200. package/src/test-setup.ts +62 -62
  201. package/src/themes/index.ts +215 -215
  202. package/src/toast-store.svelte.ts +246 -246
  203. package/src/toast-store.test.ts +147 -147
  204. package/src/ui-buttons.test.ts +144 -144
  205. package/src/ui-inputs.test.ts +118 -118
  206. package/src/ui-range.test.ts +70 -70
  207. package/src/ui-selection.test.ts +155 -155
  208. package/src/ui-tier1.test.ts +142 -142
  209. package/src/virtual.test.ts +88 -88
  210. package/src/virtualization/column-virtualizer.test.ts +27 -27
  211. package/src/virtualization/column-virtualizer.ts +30 -30
  212. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  213. package/src/virtualization/types.ts +30 -30
  214. package/src/virtualization/virtualizer.test.ts +47 -47
  215. package/src/virtualization/virtualizer.ts +296 -296
  216. package/themes/ember.css +71 -71
  217. package/dist/cdn/GridMenus-GfCAHgkZ.js +0 -420
  218. package/dist/cdn/GridMenus-GiVNSeDU.js +0 -416
package/src/core.ts CHANGED
@@ -1,1111 +1,1111 @@
1
- import type { SparklineConfig } from './sparkline'
2
-
3
- export type RowData = Record<string, unknown>
4
- export type Updater<T> = T | ((prev: T) => T)
5
- export type SortingState = Array<{ id: string; desc: boolean }>
6
- export type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }
7
- export type ColumnFiltersState = Array<ColumnFilter>
8
- export type PaginationState = { pageIndex: number; pageSize: number }
9
- export type GroupingState = Array<string>
10
- export type ExpandedState = Record<string, boolean>
11
- export type RowSelectionState = Record<string, boolean>
12
- export type ActiveCellState = {
13
- rowIndex: number
14
- colIndex: number
15
- cellId: string | null
16
- }
17
- export type TableFeatures = Record<string, unknown>
18
-
19
- export type CellData = unknown
20
-
21
- export type HeaderContext<TData extends RowData> = {
22
- header: Header<TData>
23
- column: Column<TData>
24
- table: SvGrid<TData>
25
- }
26
-
27
- export type CellContext<TData extends RowData> = {
28
- cell: Cell<TData>
29
- row: Row<TData>
30
- column: Column<TData>
31
- table: SvGrid<TData>
32
- getValue: () => unknown
33
- }
34
-
35
- /** Params passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks. */
36
- export type CellSpanParams<TData extends RowData = RowData> = {
37
- /** The row's underlying data object. */
38
- data: TData
39
- /** Display-row index in the current (filtered/sorted) row set. */
40
- rowIndex: number
41
- /** The column's id. */
42
- columnId: string
43
- /** The cell's base value for this column. */
44
- value: unknown
45
- }
46
-
47
- /** Params passed to a column's `valueParser(...)` on edit commit. */
48
- export type ValueParserParams<TData extends RowData = RowData> = {
49
- /** The value after built-in per-`editorType` coercion. */
50
- newValue: unknown
51
- /** The cell's previous value. */
52
- oldValue: unknown
53
- /** The raw string the editor produced (pre-coercion). */
54
- rawInput: string
55
- /** The row's underlying data object. */
56
- data: TData
57
- /** The column's id. */
58
- columnId: string
59
- }
60
-
61
- /**
62
- * Context passed to a custom `cellEditor` snippet/component. Three write
63
- * helpers cover the lifecycle:
64
- *
65
- * - `update(next)` - stage `next` as the draft, keep the editor open.
66
- * Use this for live-preview controls (sliders,
67
- * color pickers) so the user can keep adjusting.
68
- * - `commit(next?)` - write the value AND close the editor. The
69
- * argument is optional; when omitted, the most
70
- * recently `update()`d value is saved. Use this
71
- * for "done" gestures (Enter, picking an option).
72
- * - `cancel()` - discard the draft and close the editor.
73
- */
74
- export type EditorContext<TData extends RowData> = CellContext<TData> & {
75
- value: unknown
76
- update: (next: unknown) => void
77
- commit: (next?: unknown) => void
78
- cancel: () => void
79
- }
80
-
81
- export type CellFormatConfig =
82
- | {
83
- type: 'number'
84
- locales?: string | Array<string>
85
- options?: Intl.NumberFormatOptions
86
- }
87
- | {
88
- type: 'currency'
89
- /** ISO 4217 (default USD) */
90
- currency?: string
91
- locales?: string | Array<string>
92
- options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>
93
- }
94
- | {
95
- type: 'percent'
96
- locales?: string | Array<string>
97
- options?: Omit<Intl.NumberFormatOptions, 'style'>
98
- /**
99
- * If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).
100
- * Default false.
101
- */
102
- valueIsPercentPoints?: boolean
103
- }
104
- | {
105
- type: 'date' | 'datetime'
106
- locales?: string | Array<string>
107
- /**
108
- * Shortcut patterns merged with `options`:
109
- * `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,
110
- * `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.
111
- */
112
- pattern?: string
113
- options?: Intl.DateTimeFormatOptions
114
- }
115
-
116
- export type CellFormatter<TData extends RowData> = (context: {
117
- value: unknown
118
- row: Row<TData>
119
- column: Column<TData>
120
- table: SvGrid<TData>
121
- }) => string
122
-
123
- export type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)
124
-
125
- /**
126
- * How a column's value is aggregated for a group row when `columnGrouping`
127
- * is active. Built-in reducers cover the common cases; pass a function for
128
- * anything custom (weighted average, median, percentile, distinct count).
129
- * The function receives the finite numeric values AND the raw leaf rows.
130
- */
131
- export type GroupAggregator<TData = any> =
132
- | 'sum'
133
- | 'avg'
134
- | 'min'
135
- | 'max'
136
- | 'count'
137
- | 'countDistinct'
138
- | 'extent'
139
- | 'first'
140
- | ((values: number[], rows: Array<TData>) => unknown)
141
-
142
- /** Apply a group aggregator over a bucket's leaf rows for one column. */
143
- export function applyGroupAggregate<TData extends RowData>(
144
- agg: GroupAggregator<TData>,
145
- columnId: string,
146
- rows: ReadonlyArray<Row<TData>>,
147
- ): unknown {
148
- const raw = rows.map((r) => r.getCellValueByColumnId(columnId))
149
- if (typeof agg === 'function') {
150
- const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
151
- return agg(nums, rows.map((r) => r.original))
152
- }
153
- if (agg === 'count') return rows.length
154
- if (agg === 'countDistinct') return new Set(raw.map((v) => String(v ?? ''))).size
155
- if (agg === 'first') return raw[0]
156
- const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
157
- if (!nums.length) return undefined
158
- switch (agg) {
159
- case 'sum':
160
- return nums.reduce((a, b) => a + b, 0)
161
- case 'avg':
162
- return nums.reduce((a, b) => a + b, 0) / nums.length
163
- case 'min':
164
- return Math.min(...nums)
165
- case 'max':
166
- return Math.max(...nums)
167
- case 'extent':
168
- return `${Math.min(...nums)} – ${Math.max(...nums)}`
169
- default:
170
- return undefined
171
- }
172
- }
173
-
174
- export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
175
- id?: string
176
- field?: keyof TData & string
177
- fieldFn?: (row: TData) => unknown
178
- header?: ColumnDefTemplate<HeaderContext<TData>>
179
- footer?: ColumnDefTemplate<HeaderContext<TData>>
180
- cell?: ColumnDefTemplate<CellContext<TData>>
181
- columns?: Array<ColumnDef<TFeatures, TData>>
182
- /**
183
- * Declarative cell spanning (merged cells). Return how many COLUMNS this
184
- * cell spans to the right (1 = no span). Value-driven, AG-Grid-style. Feed
185
- * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
186
- * the same real `colspan`/`rowspan` merge engine (no separate code path).
187
- */
188
- colSpan?: (params: CellSpanParams<TData>) => number
189
- /**
190
- * Declarative cell spanning (merged cells). Return how many ROWS this cell
191
- * spans downward (1 = no span). See `colSpan` for how to apply.
192
- */
193
- rowSpan?: (params: CellSpanParams<TData>) => number
194
- /**
195
- * High-level data type for the column. A convenience that resolves to the
196
- * right `editorType`, alignment, date `format`, and filter operators without
197
- * setting each by hand:
198
- * 'text' → text editor, left-aligned
199
- * 'number' → number editor, right-aligned, numeric filter operators
200
- * 'boolean' → checkbox editor, centered
201
- * 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format
202
- * 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')
203
- * Anything you set explicitly (`editorType`, `align`, `format`) still wins -
204
- * `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers
205
- * this from the first data row for columns that declare neither.
206
- */
207
- cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'
208
- /**
209
- * Hide this column when the grid's `responsive` mode is on and the grid is
210
- * narrower than this many pixels - drop low-priority columns on small
211
- * screens. No effect unless the grid has `responsive` set.
212
- */
213
- hideBelow?: number
214
- /**
215
- * For a column INSIDE a collapsible column group: `'open'` shows this column
216
- * only while the group is expanded, `'closed'` only while collapsed. Omit to
217
- * always show it. Setting it on any direct child gives the parent group a
218
- * collapse toggle. Pair with `openByDefault` on the group.
219
- */
220
- columnGroupShow?: 'open' | 'closed'
221
- /**
222
- * For a GROUP column (one with `columns: [...]`): start the group expanded.
223
- * Defaults to `false` (collapsed), matching AG Grid - so only the always-on
224
- * and `columnGroupShow: 'closed'` children show until the user expands it.
225
- */
226
- openByDefault?: boolean
227
- editorType?:
228
- | 'text'
229
- | 'number'
230
- | 'date' // rich SvCalendar popover (opt out with 'date-native')
231
- | 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
232
- | 'time' // rich SvTimePicker dial (opt out with 'time-native')
233
- | 'date-native' // plain <input type="date">
234
- | 'datetime-native' // plain <input type="datetime-local">
235
- | 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
236
- | 'password' // native <input type="password"> with masked rendering
237
- | 'checkbox'
238
- | 'list'
239
- | 'chips'
240
- | 'select' // custom dropdown - single value, no typeahead
241
- | 'rich-select' // custom dropdown with a typeahead search input
242
- | 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)
243
- | 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline
244
- | 'color' // native <input type="color"> swatch
245
- | 'rating' // 5-star rating control
246
- // Any other string names a CUSTOM editor registered via `registerCellEditor`
247
- // (or `registerBuiltinEditors`). `(string & {})` keeps the literals above
248
- // autocompleting while allowing arbitrary custom type names.
249
- | (string & {})
250
- /**
251
- * Custom in-cell editor. Receives the cell context PLUS a `commit(value)`
252
- * and `cancel()` helper. Use when none of the built-in `editorType`s fit;
253
- * the snippet's outer element is mounted inside the editing cell and
254
- * inherits keyboard handling (Esc cancels, Enter commits unless your
255
- * snippet preventDefaults it).
256
- *
257
- * Coexists with `editorType`: when both are set, `cellEditor` wins and
258
- * `editorType` is treated as a hint for parsing the saved value.
259
- */
260
- cellEditor?: ColumnDefTemplate<EditorContext<TData>>
261
- /**
262
- * Per-column tooltip. String shows as a native `title=`; `(ctx) => string`
263
- * runs per cell so the tooltip can reflect the value. Returning an empty
264
- * string skips the tooltip.
265
- */
266
- tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
267
- /**
268
- * Declarative per-cell validation (Handsontable-style). Runs for EVERY
269
- * rendered cell - including values already present in `data` on load, not
270
- * just on edit - so bad data is flagged immediately. Invalid cells get the
271
- * `sv-grid-cell-invalid` class (red highlight) and the returned message as
272
- * their tooltip.
273
- *
274
- * Return value:
275
- * - `null` / `undefined` / `true` → valid (no highlight)
276
- * - `false` → invalid, no message
277
- * - a non-empty `string` → invalid, string is the tooltip
278
- *
279
- * The value keeps rendering as-is (the grid does NOT roll it back); pair
280
- * with `onCellValueChange` if you also want to reject the commit.
281
- */
282
- validate?: (params: {
283
- value: unknown
284
- row: TData
285
- rowIndex: number
286
- column: Column<TData>
287
- }) => string | boolean | null | undefined
288
- /**
289
- * Gate editing per column or per cell.
290
- *
291
- * - `true` (or omitted): the column is fully editable.
292
- * - `false`: the column is read-only - double-click, type-to-edit,
293
- * fill-handle drag, Delete, and clipboard paste all skip it.
294
- * - `(ctx) => boolean`: evaluated for each cell, so you can lock
295
- * individual rows (e.g. by role, status, ownership). Returning
296
- * `false` opts the cell out of every editing path, identical to
297
- * setting `editable: false` on the whole column for that row.
298
- *
299
- * The grid-wide `enableInlineEditing` prop still wins when set to
300
- * `false`.
301
- */
302
- editable?: boolean | ((context: CellContext<TData>) => boolean)
303
- /**
304
- * Transform the committed edit value before it is written to the row.
305
- * Runs after the built-in per-`editorType` coercion, so `newValue` is
306
- * already type-parsed; return the final value to store (e.g. round a
307
- * number, uppercase a code, look up an id). AG-Grid-style `valueParser`.
308
- */
309
- valueParser?: (params: ValueParserParams<TData>) => unknown
310
- /**
311
- * Briefly flash / highlight this column's cell when its value changes
312
- * (streaming feeds, edits, server pushes). `true` uses the default flash;
313
- * pass `{ className }` to apply your own animation class instead.
314
- */
315
- cellFlash?: boolean | { className?: string }
316
- /**
317
- * When `false`, this column never shows a sort indicator and clicking
318
- * its header is a no-op - `api.setSort(thisColumn, ...)` is also
319
- * ignored. Defaults to `true` (the column participates in sorting as
320
- * long as `rowSortingFeature` is registered).
321
- */
322
- sortable?: boolean
323
- /**
324
- * When `false`, this column never shows a filter funnel / menu and
325
- * `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the
326
- * column is filterable as long as `columnFilteringFeature` is
327
- * registered).
328
- */
329
- filterable?: boolean
330
- /**
331
- * Options for `editorType: 'list' | 'chips'`. Either bare values (the
332
- * string is both value and label) or `{ value, label }` objects.
333
- * For `chips` this is optional - when omitted, the chips editor becomes
334
- * free-form (user types and presses Enter to commit a chip).
335
- *
336
- * Pass a function `(row) => options` for row-dependent (cascading)
337
- * options - e.g. City options that depend on Country in the same row.
338
- */
339
- editorOptions?:
340
- | ReadonlyArray<
341
- string | number | { value: string | number; label?: string; color?: string }
342
- >
343
- | ((
344
- row: TData,
345
- ) => ReadonlyArray<
346
- string | number | { value: string | number; label?: string; color?: string }
347
- >)
348
- /** When true, list/chips allow multiple selections. Cell value becomes an array. */
349
- editorMultiple?: boolean
350
- /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
351
- editorSeparator?: string
352
- format?: CellFormatConfig
353
- formatter?: CellFormatter<TData>
354
- /**
355
- * Aggregate this column's values into the group row when grouping is
356
- * active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |
357
- * 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result
358
- * is formatted with this column's `format` and shown in the group header.
359
- */
360
- aggregate?: GroupAggregator<TData>
361
- /**
362
- * Render the cell as an in-cell sparkline chart. The cell value should be
363
- * an array of numbers (or a comma/space separated string). Mutually
364
- * exclusive with a custom `cell` renderer (a `cell` wins if both are set).
365
- *
366
- * { sparkline: { type: 'line' } } // default line
367
- * { sparkline: { type: 'bar', color: '#16a34a' } }
368
- * { sparkline: { type: 'winloss' } } // sign-only up/down
369
- *
370
- * See `SparklineConfig` for the full option set (type, color,
371
- * negativeColor, width, height, fixed min/max).
372
- */
373
- sparkline?: SparklineConfig
374
- /** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */
375
- width?: number
376
- /**
377
- * Initial visibility. Set `false` to start the column hidden while still
378
- * listing it in the Choose Columns UI for the user to re-enable. Applied
379
- * once at mount; after that `api.setColumnVisible` / user toggles win.
380
- * On a group column, `false` hides the whole group's leaf columns.
381
- */
382
- visible?: boolean
383
- /**
384
- * Horizontal alignment for header and body cells. When omitted, the
385
- * default is inferred from `editorType`:
386
- * - `'number' | 'date' | 'datetime'` → `'right'`
387
- * - `'checkbox'` → `'center'`
388
- * - everything else → `'left'`
389
- */
390
- align?: 'left' | 'center' | 'right'
391
- /**
392
- * Per-cell conditional CSS. Two shapes:
393
- *
394
- * - **String** (or array of strings): class name(s) added to the
395
- * cell's `<td>` for every row in this column.
396
- * - **Function**: invoked per cell with the same `CellContext` shape
397
- * the `cell` renderer receives. Return a string, an array of
398
- * strings, or an object mapping class names to booleans.
399
- *
400
- * Use it for status tinting, conditional bold, "negative number"
401
- * coloring - anything that's a function of the row's value. Cells
402
- * still receive their format / cell renderer; the class just
403
- * augments the rendered `<td>`.
404
- */
405
- cellClass?:
406
- | string
407
- | ReadonlyArray<string>
408
- | ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
409
- }
410
-
411
- export type Column<TData extends RowData> = {
412
- id: string
413
- columnDef: ColumnDef<any, TData>
414
- depth: number
415
- parentId?: string
416
- getCanSort: () => boolean
417
- getCanFilter: () => boolean
418
- getIsSorted: () => false | 'asc' | 'desc'
419
- getToggleSortingHandler: () => () => void
420
- }
421
-
422
- export type Header<TData extends RowData> = {
423
- id: string
424
- isPlaceholder: boolean
425
- colSpan: number
426
- column: Column<TData>
427
- getContext: () => HeaderContext<TData>
428
- }
429
-
430
- export type HeaderGroup<TData extends RowData> = {
431
- id: string
432
- headers: Array<Header<TData>>
433
- }
434
-
435
- export type Cell<TData extends RowData> = {
436
- id: string
437
- row: Row<TData>
438
- column: Column<TData>
439
- getValue: () => unknown
440
- getContext: () => CellContext<TData>
441
- }
442
-
443
- export type Row<TData extends RowData> = {
444
- id: string
445
- index: number
446
- original: TData
447
- depth: number
448
- subRows?: Array<Row<TData>>
449
- /** Total leaf (data) rows under this group row. Undefined for data rows. */
450
- leafCount?: number
451
- getCanExpand: () => boolean
452
- getIsExpanded: () => boolean
453
- toggleExpanded: () => void
454
- getIsSelected: () => boolean
455
- toggleSelected: () => void
456
- getAllCells: () => Array<Cell<TData>>
457
- getCellValueByColumnId: (columnId: string) => unknown
458
- }
459
-
460
- export type RowModel<TData extends RowData> = {
461
- rows: Array<Row<TData>>
462
- }
463
-
464
- export type Store<T> = {
465
- readonly state: T
466
- setState: (updater: (prev: T) => T) => void
467
- subscribe: (listener: () => void) => () => void
468
- }
469
-
470
- function createStore<T>(initial: T): Store<T> {
471
- let value = initial
472
- const listeners = new Set<() => void>()
473
- return {
474
- get state() {
475
- return value
476
- },
477
- setState(updater) {
478
- value = updater(value)
479
- listeners.forEach((listener) => listener())
480
- },
481
- subscribe(listener) {
482
- listeners.add(listener)
483
- return () => listeners.delete(listener)
484
- },
485
- }
486
- }
487
-
488
- export const rowSortingFeature = { key: 'rowSortingFeature' }
489
- export const columnFilteringFeature = { key: 'columnFilteringFeature' }
490
- export const rowPaginationFeature = { key: 'rowPaginationFeature' }
491
- export const columnGroupingFeature = { key: 'columnGroupingFeature' }
492
- export const rowSelectionFeature = { key: 'rowSelectionFeature' }
493
- export const rowExpandingFeature = { key: 'rowExpandingFeature' }
494
-
495
- export function tableFeatures<T extends TableFeatures>(features: T): T {
496
- return features
497
- }
498
-
499
- export const sortFns = {
500
- auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
501
- number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
502
- date: (a: unknown, b: unknown) => {
503
- const aa = new Date(a as any).getTime()
504
- const bb = new Date(b as any).getTime()
505
- return aa - bb
506
- },
507
- }
508
-
509
- export const filterFns = {
510
- includesString: (value: unknown, query: string) =>
511
- String(value).toLowerCase().includes(query.toLowerCase()),
512
- equals: (value: unknown, query: unknown) => value === query,
513
- }
514
-
515
- export type RowModelFactory<TData extends RowData> = (args: {
516
- table: SvGrid<TData>
517
- rows: Array<Row<TData>>
518
- }) => Array<Row<TData>>
519
-
520
- export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {
521
- return ({ rows }) => rows
522
- }
523
- export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
524
- return ({ table, rows }) => {
525
- const filters: ColumnFiltersState = table.getState().columnFilters ?? []
526
- if (!filters.length) return rows
527
- return rows.filter((row) => {
528
- return filters.every((filter) => {
529
- const cellValue = row
530
- .getAllCells()
531
- .find((cell) => cell.column.id === filter.id)
532
- ?.getValue()
533
- const filterFn = filter.fn ? filterFns[filter.fn] : filterFns.includesString
534
- return filterFn(cellValue, filter.value as any)
535
- })
536
- })
537
- }
538
- }
539
- export function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {
540
- return ({ table, rows }) => {
541
- const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }
542
- const start = pagination.pageIndex * pagination.pageSize
543
- return rows.slice(start, start + pagination.pageSize)
544
- }
545
- }
546
- export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {
547
- return ({ table, rows }) => {
548
- const grouping: GroupingState = table.getState().grouping ?? []
549
- if (!grouping.length) return rows
550
- const columns = table.getAllColumns()
551
-
552
- // Recursively bucket rows by each grouping column in turn. At every level a
553
- // group row is built that stands in for its children - a non-group column
554
- // resolves to the value shared by every leaf row, or to undefined when the
555
- // leaves disagree.
556
- function buildGroups(
557
- input: Array<Row<TData>>,
558
- levelIndex: number,
559
- depth: number,
560
- idPrefix: string,
561
- ): Array<Row<TData>> {
562
- if (levelIndex >= grouping.length) {
563
- // Leaves: actual data rows, with their nesting depth recorded.
564
- return input.map((row) => ({ ...row, depth }))
565
- }
566
- const groupKey = grouping[levelIndex]
567
- if (!groupKey) return input
568
-
569
- const buckets = new Map<string, Array<Row<TData>>>()
570
- for (const row of input) {
571
- const value = row.getCellValueByColumnId(groupKey)
572
- const key = String(value ?? '')
573
- const list = buckets.get(key) ?? []
574
- list.push(row)
575
- buckets.set(key, list)
576
- }
577
-
578
- const groupRows: Array<Row<TData>> = []
579
- let index = 0
580
- buckets.forEach((children, key) => {
581
- const id = `${idPrefix}_${groupKey}_${key}`
582
- const subRows = buildGroups(children, levelIndex + 1, depth + 1, id)
583
- const isDeepest = levelIndex + 1 >= grouping.length
584
- const leafCount = isDeepest
585
- ? subRows.length
586
- : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)
587
-
588
- const resolveColumnValue = (columnId: string): unknown => {
589
- if (columnId === groupKey) return key
590
- let resolved: unknown
591
- let hasResolved = false
592
- for (const child of children) {
593
- const childValue = child.getCellValueByColumnId(columnId)
594
- if (!hasResolved) {
595
- resolved = childValue
596
- hasResolved = true
597
- } else if (childValue !== resolved) {
598
- return undefined
599
- }
600
- }
601
- return resolved
602
- }
603
-
604
- const groupOriginal: Record<string, unknown> = {}
605
- columns.forEach((column) => {
606
- const field = column.columnDef.field
607
- if (!field) return
608
- const agg = column.columnDef.aggregate
609
- groupOriginal[field] = agg
610
- ? applyGroupAggregate(agg, column.id, children)
611
- : resolveColumnValue(column.id)
612
- })
613
-
614
- const groupRow: Row<TData> = {
615
- id,
616
- index: index++,
617
- original: groupOriginal as TData,
618
- depth,
619
- subRows,
620
- leafCount,
621
- getCanExpand: () => true,
622
- getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
623
- toggleExpanded: () => {
624
- table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
625
- },
626
- getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),
627
- toggleSelected: () => {
628
- table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
629
- },
630
- getAllCells: () => [],
631
- // Prefer the precomputed group value (which carries aggregates)
632
- // and fall back to the shared-value resolver for columns without
633
- // a field.
634
- getCellValueByColumnId: (columnId: string) => {
635
- const col = columns.find((c) => c.id === columnId)
636
- const field = col?.columnDef.field
637
- if (field && field in groupOriginal) return groupOriginal[field]
638
- return resolveColumnValue(columnId)
639
- },
640
- }
641
- groupRows.push(groupRow)
642
- })
643
- return groupRows
644
- }
645
-
646
- return buildGroups(rows, 0, 0, 'group')
647
- }
648
- }
649
- export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
650
- return ({ table, rows }) => {
651
- const expanded: ExpandedState = table.getState().expanded ?? {}
652
- const flattened: Array<Row<TData>> = []
653
- const visit = (row: Row<TData>) => {
654
- flattened.push(row)
655
- if (row.subRows?.length && expanded[row.id]) {
656
- for (const sub of row.subRows) visit(sub)
657
- }
658
- }
659
- for (const row of rows) visit(row)
660
- return flattened
661
- }
662
- }
663
- export function createSortedRowModel<TData extends RowData>(
664
- localSortFns: typeof sortFns = sortFns,
665
- ): RowModelFactory<TData> {
666
- return ({ table, rows }) => {
667
- const sorting = table.getState().sorting ?? []
668
- if (!sorting.length) return rows
669
-
670
- const sorted = [...rows].sort((a, b) => {
671
- for (const clause of sorting) {
672
- const column = table.getAllColumns().find((col) => col.id === clause.id)
673
- if (!column) continue
674
- const editorType = column.columnDef.editorType
675
- const comparator =
676
- editorType === 'number'
677
- ? localSortFns.number
678
- : editorType === 'date' || editorType === 'datetime'
679
- ? localSortFns.date
680
- : localSortFns.auto
681
- const result = comparator(
682
- a.getCellValueByColumnId(column.id),
683
- b.getCellValueByColumnId(column.id),
684
- )
685
- if (result !== 0) return clause.desc ? -result : result
686
- }
687
- return 0
688
- })
689
- return sorted
690
- }
691
- }
692
-
693
- export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {
694
- _features: TFeatures
695
- _rowModels?: {
696
- coreRowModel?: RowModelFactory<TData>
697
- filteredRowModel?: RowModelFactory<TData>
698
- sortedRowModel?: RowModelFactory<TData>
699
- paginatedRowModel?: RowModelFactory<TData>
700
- groupedRowModel?: RowModelFactory<TData>
701
- expandedRowModel?: RowModelFactory<TData>
702
- }
703
- columns: Array<ColumnDef<TFeatures, TData>>
704
- data: ReadonlyArray<TData>
705
- /**
706
- * Optional row-id resolver. When set, the value it returns becomes
707
- * `row.id` (and therefore the selection / expansion / edit key). When
708
- * omitted, ids fall back to the row's array index as a string. Use a
709
- * stable id (database PK, UUID, etc.) so selection survives reorders.
710
- */
711
- getRowId?: (row: TData, index: number) => string
712
- state?: Partial<Record<string, any>>
713
- onSortingChange?: (updater: Updater<SortingState>) => void
714
- onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void
715
- onPaginationChange?: (updater: Updater<PaginationState>) => void
716
- onGroupingChange?: (updater: Updater<GroupingState>) => void
717
- onExpandedChange?: (updater: Updater<ExpandedState>) => void
718
- onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void
719
- onActiveCellChange?: (updater: Updater<ActiveCellState>) => void
720
- }
721
-
722
- export type SvGrid<TData extends RowData> = {
723
- store: Store<Record<string, any>>
724
- optionsStore: Store<Record<string, any>>
725
- state: Record<string, any>
726
- getState: () => Record<string, any>
727
- setOptions: (updater: Updater<Record<string, any>>) => void
728
- setColumnFilters: (updater: Updater<ColumnFiltersState>) => void
729
- setPagination: (updater: Updater<PaginationState>) => void
730
- setGrouping: (updater: Updater<GroupingState>) => void
731
- setExpanded: (updater: Updater<ExpandedState>) => void
732
- setRowSelection: (updater: Updater<RowSelectionState>) => void
733
- setActiveCell: (updater: Updater<ActiveCellState>) => void
734
- moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void
735
- getAllColumns: () => Array<Column<TData>>
736
- getHeaderGroups: () => Array<HeaderGroup<TData>>
737
- getFooterGroups: () => Array<HeaderGroup<TData>>
738
- getRowModel: () => RowModel<TData>
739
- }
740
-
741
- type InternalGrid<TData extends RowData> = SvGrid<TData> & {
742
- getAllColumns: () => Array<Column<TData>>
743
- }
744
-
745
- export function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(
746
- options: SvGridOptions<TFeatures, TData>,
747
- ): SvGrid<TData> {
748
- const internalState: Record<string, any> = {
749
- sorting: [],
750
- columnFilters: [],
751
- pagination: { pageIndex: 0, pageSize: options.data.length || 10 },
752
- grouping: [],
753
- expanded: {},
754
- rowSelection: {},
755
- activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
756
- ...(options.state ?? {}),
757
- }
758
- const store = createStore(internalState)
759
- const optionsStore = createStore(options as Record<string, any>)
760
- let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null
761
- let cachedColumns: Array<Column<TData>> = []
762
- let cachedHeaderGroups: Array<HeaderGroup<TData>> = []
763
- let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
764
- let cachedBaseRowsColumns: Array<Column<TData>> | null = null
765
- let cachedBaseRows: Array<Row<TData>> = []
766
- let cachedRowModel: RowModel<TData> | null = null
767
- let cachedRowModelBaseRows: Array<Row<TData>> | null = null
768
- let cachedPipeline = options._rowModels
769
- let cachedSlices: {
770
- sorting: SortingState | undefined
771
- columnFilters: ColumnFiltersState | undefined
772
- pagination: PaginationState | undefined
773
- grouping: GroupingState | undefined
774
- expanded: ExpandedState | undefined
775
- rowSelection: RowSelectionState | undefined
776
- } | null = null
777
-
778
- const grid = {
779
- store,
780
- optionsStore,
781
- get state() {
782
- return store.state
783
- },
784
- getState() {
785
- return store.state
786
- },
787
- setOptions(updater: Updater<Record<string, any>>) {
788
- optionsStore.setState((prev) =>
789
- typeof updater === 'function' ? (updater as any)(prev) : updater,
790
- )
791
- },
792
- setColumnFilters(updater: Updater<ColumnFiltersState>) {
793
- store.setState((prev) => ({
794
- ...prev,
795
- columnFilters:
796
- typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,
797
- }))
798
- options.onColumnFiltersChange?.(updater)
799
- },
800
- setPagination(updater: Updater<PaginationState>) {
801
- store.setState((prev) => ({
802
- ...prev,
803
- pagination:
804
- typeof updater === 'function'
805
- ? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })
806
- : updater,
807
- }))
808
- options.onPaginationChange?.(updater)
809
- },
810
- setGrouping(updater: Updater<GroupingState>) {
811
- store.setState((prev) => ({
812
- ...prev,
813
- grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,
814
- }))
815
- options.onGroupingChange?.(updater)
816
- },
817
- setExpanded(updater: Updater<ExpandedState>) {
818
- store.setState((prev) => ({
819
- ...prev,
820
- expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,
821
- }))
822
- options.onExpandedChange?.(updater)
823
- },
824
- setRowSelection(updater: Updater<RowSelectionState>) {
825
- store.setState((prev) => ({
826
- ...prev,
827
- rowSelection:
828
- typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,
829
- }))
830
- options.onRowSelectionChange?.(updater)
831
- },
832
- setActiveCell(updater: Updater<ActiveCellState>) {
833
- store.setState((prev) => {
834
- const previous: ActiveCellState = prev.activeCell ?? {
835
- rowIndex: 0,
836
- colIndex: 0,
837
- cellId: null,
838
- }
839
- const nextActive =
840
- typeof updater === 'function' ? updater(previous) : updater
841
- return {
842
- ...prev,
843
- activeCell: nextActive,
844
- }
845
- })
846
- options.onActiveCellChange?.(updater)
847
- },
848
- moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {
849
- const rows = grid.getRowModel().rows
850
- const columns = grid.getAllColumns()
851
- const maxRow = Math.max(rows.length - 1, 0)
852
- const maxCol = Math.max(columns.length - 1, 0)
853
- const current: ActiveCellState = grid.getState().activeCell ?? {
854
- rowIndex: 0,
855
- colIndex: 0,
856
- cellId: null,
857
- }
858
-
859
- const rowIndex = Math.min(
860
- Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),
861
- maxRow,
862
- )
863
- const colIndex = Math.min(
864
- Math.max(current.colIndex + (next.colDelta ?? 0), 0),
865
- maxCol,
866
- )
867
- const columnId = columns[colIndex]?.id ?? 'col_0'
868
- grid.setActiveCell({
869
- rowIndex,
870
- colIndex,
871
- cellId: `${rowIndex}_${columnId}`,
872
- })
873
- },
874
- getAllColumns() {
875
- // Cache hit: referentially identical columns array.
876
- if (cachedColumnsInput === options.columns && cachedColumns.length) {
877
- return cachedColumns
878
- }
879
- // Soft cache hit: consumers commonly recreate the columns array
880
- // inline on every render (e.g. `columns={[...]}`). If the new
881
- // array has the same length AND each entry has the same `field` /
882
- // `id` / `header` (the visibility-affecting structure of a
883
- // column), trust the previous build. Mutable inner fields like
884
- // `cell` and `editorOptions` are still picked up on the next real
885
- // render that bumps an actual data dep - they're read at cell-
886
- // render time, not at this top-level cache.
887
- if (
888
- cachedColumnsInput &&
889
- options.columns.length === cachedColumnsInput.length &&
890
- cachedColumns.length === options.columns.length &&
891
- options.columns.every((c, i) => {
892
- const prev = cachedColumnsInput![i]!
893
- return (
894
- c.field === prev.field &&
895
- c.id === prev.id &&
896
- c.header === prev.header &&
897
- c.editorType === prev.editorType
898
- )
899
- })
900
- ) {
901
- // Update the stored input reference so the strict check hits
902
- // next time, but reuse the built column model.
903
- cachedColumnsInput = options.columns
904
- return cachedColumns
905
- }
906
-
907
- cachedColumnsInput = options.columns
908
- cachedHeaderGroups = []
909
- const build = (
910
- defs: Array<ColumnDef<TFeatures, TData>>,
911
- depth: number,
912
- parentId?: string,
913
- ): Array<Column<TData>> => {
914
- const leaves: Array<Column<TData>> = []
915
- defs.forEach((columnDef, index) => {
916
- const id = columnDef.id ?? columnDef.field ?? `${parentId ?? 'col'}_${depth}_${index}`
917
- if (columnDef.columns?.length) {
918
- leaves.push(...build(columnDef.columns, depth + 1, id))
919
- return
920
- }
921
- leaves.push({
922
- id,
923
- depth,
924
- parentId,
925
- columnDef,
926
- getCanSort: () =>
927
- Boolean((options._features as any).rowSortingFeature) &&
928
- columnDef.sortable !== false,
929
- getCanFilter: () =>
930
- Boolean((options._features as any).columnFilteringFeature) &&
931
- columnDef.filterable !== false,
932
- getIsSorted: () => {
933
- const entry = store.state.sorting?.find((s: any) => s.id === id)
934
- if (!entry) return false
935
- return entry.desc ? 'desc' : 'asc'
936
- },
937
- getToggleSortingHandler: () => () => {
938
- const clauses: SortingState = store.state.sorting ?? []
939
- const current = clauses.find((s: any) => s.id === id)
940
- const nextClause: SortingState = !current
941
- ? [...clauses, { id, desc: false }]
942
- : current.desc
943
- ? clauses.filter((s) => s.id !== id)
944
- : clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))
945
- store.setState((prev) => ({ ...prev, sorting: nextClause }))
946
- options.onSortingChange?.(nextClause)
947
- },
948
- })
949
- })
950
- return leaves
951
- }
952
- cachedColumns = build(options.columns, 0)
953
- return cachedColumns
954
- },
955
- getHeaderGroups() {
956
- if (cachedHeaderGroups.length) return cachedHeaderGroups
957
- const headers = grid.getAllColumns().map((column) => {
958
- const header: Header<TData> = {
959
- id: column.id,
960
- isPlaceholder: false,
961
- colSpan: 1,
962
- column,
963
- getContext: () => ({ header, column, table: grid }),
964
- }
965
- return header
966
- })
967
- cachedHeaderGroups = [{ id: 'header_group_0', headers }]
968
- return cachedHeaderGroups
969
- },
970
- getFooterGroups() {
971
- return grid.getHeaderGroups()
972
- },
973
- getRowModel() {
974
- const columns = grid.getAllColumns()
975
- if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
976
- cachedBaseRowsInput = options.data
977
- cachedBaseRowsColumns = columns
978
- // O(1) column-id → index lookup so getCellValueByColumnId doesn't do
979
- // a linear `findIndex` on every cell read (was O(rows × cells × cols)).
980
- const columnIndexById = new Map<string, number>()
981
- for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
982
- const columnCount = columns.length
983
-
984
- cachedBaseRows = new Array(options.data.length)
985
- const getRowId = options.getRowId
986
- for (let index = 0; index < options.data.length; index++) {
987
- const original = options.data[index]!
988
- const id = getRowId ? getRowId(original, index) : String(index)
989
- // `values` and `cells` are computed lazily - for a 100k-row grid
990
- // with only ~20 visible rows we don't want to materialise every
991
- // row's full value array or cell objects up front.
992
- let cachedValues: Array<unknown> | null = null
993
- let cachedCells: Array<Cell<TData>> | null = null
994
-
995
- function computeValues(): Array<unknown> {
996
- const values = new Array<unknown>(columnCount)
997
- for (let i = 0; i < columnCount; i++) {
998
- const column = columns[i]!
999
- if (column.columnDef.fieldFn) {
1000
- values[i] = column.columnDef.fieldFn(original)
1001
- } else if (column.columnDef.field) {
1002
- values[i] = (original as any)[column.columnDef.field]
1003
- } else {
1004
- values[i] = undefined
1005
- }
1006
- }
1007
- return values
1008
- }
1009
-
1010
- const row: Row<TData> = {
1011
- id,
1012
- index,
1013
- original,
1014
- depth: 0,
1015
- getCanExpand: () => false,
1016
- getIsExpanded: () => Boolean((store.state.expanded ?? {})[id]),
1017
- toggleExpanded: () => {
1018
- grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
1019
- },
1020
- getIsSelected: () => Boolean((store.state.rowSelection ?? {})[id]),
1021
- toggleSelected: () => {
1022
- grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
1023
- },
1024
- getAllCells: () => {
1025
- if (cachedCells) return cachedCells
1026
- const built = new Array<Cell<TData>>(columnCount)
1027
- for (let i = 0; i < columnCount; i++) {
1028
- const column = columns[i]!
1029
- const colIndex = i
1030
- const cell: Cell<TData> = {
1031
- id: `${id}_${column.id}`,
1032
- row,
1033
- column,
1034
- getValue: () => {
1035
- if (!cachedValues) cachedValues = computeValues()
1036
- return cachedValues[colIndex]
1037
- },
1038
- getContext: () => ({
1039
- cell,
1040
- row,
1041
- column,
1042
- table: grid,
1043
- getValue: () => cell.getValue(),
1044
- }),
1045
- }
1046
- built[i] = cell
1047
- }
1048
- cachedCells = built
1049
- return built
1050
- },
1051
- getCellValueByColumnId: (columnId: string) => {
1052
- const idx = columnIndexById.get(columnId)
1053
- if (idx === undefined) return undefined
1054
- if (!cachedValues) cachedValues = computeValues()
1055
- return cachedValues[idx]
1056
- },
1057
- }
1058
- cachedBaseRows[index] = row
1059
- }
1060
- }
1061
-
1062
- const currentSlices = {
1063
- sorting: store.state.sorting,
1064
- columnFilters: store.state.columnFilters,
1065
- pagination: store.state.pagination,
1066
- grouping: store.state.grouping,
1067
- expanded: store.state.expanded,
1068
- rowSelection: store.state.rowSelection,
1069
- }
1070
- if (
1071
- cachedRowModel &&
1072
- cachedRowModelBaseRows === cachedBaseRows &&
1073
- cachedPipeline === options._rowModels &&
1074
- cachedSlices?.sorting === currentSlices.sorting &&
1075
- cachedSlices?.columnFilters === currentSlices.columnFilters &&
1076
- cachedSlices?.pagination === currentSlices.pagination &&
1077
- cachedSlices?.grouping === currentSlices.grouping &&
1078
- cachedSlices?.expanded === currentSlices.expanded &&
1079
- cachedSlices?.rowSelection === currentSlices.rowSelection
1080
- ) {
1081
- return cachedRowModel
1082
- }
1083
-
1084
- let rows: Array<Row<TData>> = cachedBaseRows
1085
-
1086
- const pipeline = options._rowModels ?? {}
1087
- const ordered: Array<RowModelFactory<TData> | undefined> = [
1088
- pipeline.coreRowModel,
1089
- pipeline.filteredRowModel,
1090
- pipeline.sortedRowModel,
1091
- pipeline.groupedRowModel,
1092
- pipeline.expandedRowModel,
1093
- pipeline.paginatedRowModel,
1094
- ]
1095
- ordered.forEach((fn) => {
1096
- if (fn) rows = fn({ table: grid, rows })
1097
- })
1098
- cachedPipeline = options._rowModels
1099
- cachedSlices = currentSlices
1100
- cachedRowModelBaseRows = cachedBaseRows
1101
- cachedRowModel = { rows }
1102
- return cachedRowModel
1103
- },
1104
- } as InternalGrid<TData>
1105
-
1106
- return grid
1107
- }
1108
-
1109
- export function isFunction(value: unknown): value is (...args: Array<any>) => any {
1110
- return typeof value === 'function'
1111
- }
1
+ import type { SparklineConfig } from './sparkline'
2
+
3
+ export type RowData = Record<string, unknown>
4
+ export type Updater<T> = T | ((prev: T) => T)
5
+ export type SortingState = Array<{ id: string; desc: boolean }>
6
+ export type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }
7
+ export type ColumnFiltersState = Array<ColumnFilter>
8
+ export type PaginationState = { pageIndex: number; pageSize: number }
9
+ export type GroupingState = Array<string>
10
+ export type ExpandedState = Record<string, boolean>
11
+ export type RowSelectionState = Record<string, boolean>
12
+ export type ActiveCellState = {
13
+ rowIndex: number
14
+ colIndex: number
15
+ cellId: string | null
16
+ }
17
+ export type TableFeatures = Record<string, unknown>
18
+
19
+ export type CellData = unknown
20
+
21
+ export type HeaderContext<TData extends RowData> = {
22
+ header: Header<TData>
23
+ column: Column<TData>
24
+ table: SvGrid<TData>
25
+ }
26
+
27
+ export type CellContext<TData extends RowData> = {
28
+ cell: Cell<TData>
29
+ row: Row<TData>
30
+ column: Column<TData>
31
+ table: SvGrid<TData>
32
+ getValue: () => unknown
33
+ }
34
+
35
+ /** Params passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks. */
36
+ export type CellSpanParams<TData extends RowData = RowData> = {
37
+ /** The row's underlying data object. */
38
+ data: TData
39
+ /** Display-row index in the current (filtered/sorted) row set. */
40
+ rowIndex: number
41
+ /** The column's id. */
42
+ columnId: string
43
+ /** The cell's base value for this column. */
44
+ value: unknown
45
+ }
46
+
47
+ /** Params passed to a column's `valueParser(...)` on edit commit. */
48
+ export type ValueParserParams<TData extends RowData = RowData> = {
49
+ /** The value after built-in per-`editorType` coercion. */
50
+ newValue: unknown
51
+ /** The cell's previous value. */
52
+ oldValue: unknown
53
+ /** The raw string the editor produced (pre-coercion). */
54
+ rawInput: string
55
+ /** The row's underlying data object. */
56
+ data: TData
57
+ /** The column's id. */
58
+ columnId: string
59
+ }
60
+
61
+ /**
62
+ * Context passed to a custom `cellEditor` snippet/component. Three write
63
+ * helpers cover the lifecycle:
64
+ *
65
+ * - `update(next)` - stage `next` as the draft, keep the editor open.
66
+ * Use this for live-preview controls (sliders,
67
+ * color pickers) so the user can keep adjusting.
68
+ * - `commit(next?)` - write the value AND close the editor. The
69
+ * argument is optional; when omitted, the most
70
+ * recently `update()`d value is saved. Use this
71
+ * for "done" gestures (Enter, picking an option).
72
+ * - `cancel()` - discard the draft and close the editor.
73
+ */
74
+ export type EditorContext<TData extends RowData> = CellContext<TData> & {
75
+ value: unknown
76
+ update: (next: unknown) => void
77
+ commit: (next?: unknown) => void
78
+ cancel: () => void
79
+ }
80
+
81
+ export type CellFormatConfig =
82
+ | {
83
+ type: 'number'
84
+ locales?: string | Array<string>
85
+ options?: Intl.NumberFormatOptions
86
+ }
87
+ | {
88
+ type: 'currency'
89
+ /** ISO 4217 (default USD) */
90
+ currency?: string
91
+ locales?: string | Array<string>
92
+ options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>
93
+ }
94
+ | {
95
+ type: 'percent'
96
+ locales?: string | Array<string>
97
+ options?: Omit<Intl.NumberFormatOptions, 'style'>
98
+ /**
99
+ * If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).
100
+ * Default false.
101
+ */
102
+ valueIsPercentPoints?: boolean
103
+ }
104
+ | {
105
+ type: 'date' | 'datetime'
106
+ locales?: string | Array<string>
107
+ /**
108
+ * Shortcut patterns merged with `options`:
109
+ * `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,
110
+ * `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.
111
+ */
112
+ pattern?: string
113
+ options?: Intl.DateTimeFormatOptions
114
+ }
115
+
116
+ export type CellFormatter<TData extends RowData> = (context: {
117
+ value: unknown
118
+ row: Row<TData>
119
+ column: Column<TData>
120
+ table: SvGrid<TData>
121
+ }) => string
122
+
123
+ export type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)
124
+
125
+ /**
126
+ * How a column's value is aggregated for a group row when `columnGrouping`
127
+ * is active. Built-in reducers cover the common cases; pass a function for
128
+ * anything custom (weighted average, median, percentile, distinct count).
129
+ * The function receives the finite numeric values AND the raw leaf rows.
130
+ */
131
+ export type GroupAggregator<TData = any> =
132
+ | 'sum'
133
+ | 'avg'
134
+ | 'min'
135
+ | 'max'
136
+ | 'count'
137
+ | 'countDistinct'
138
+ | 'extent'
139
+ | 'first'
140
+ | ((values: number[], rows: Array<TData>) => unknown)
141
+
142
+ /** Apply a group aggregator over a bucket's leaf rows for one column. */
143
+ export function applyGroupAggregate<TData extends RowData>(
144
+ agg: GroupAggregator<TData>,
145
+ columnId: string,
146
+ rows: ReadonlyArray<Row<TData>>,
147
+ ): unknown {
148
+ const raw = rows.map((r) => r.getCellValueByColumnId(columnId))
149
+ if (typeof agg === 'function') {
150
+ const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
151
+ return agg(nums, rows.map((r) => r.original))
152
+ }
153
+ if (agg === 'count') return rows.length
154
+ if (agg === 'countDistinct') return new Set(raw.map((v) => String(v ?? ''))).size
155
+ if (agg === 'first') return raw[0]
156
+ const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
157
+ if (!nums.length) return undefined
158
+ switch (agg) {
159
+ case 'sum':
160
+ return nums.reduce((a, b) => a + b, 0)
161
+ case 'avg':
162
+ return nums.reduce((a, b) => a + b, 0) / nums.length
163
+ case 'min':
164
+ return Math.min(...nums)
165
+ case 'max':
166
+ return Math.max(...nums)
167
+ case 'extent':
168
+ return `${Math.min(...nums)} – ${Math.max(...nums)}`
169
+ default:
170
+ return undefined
171
+ }
172
+ }
173
+
174
+ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
175
+ id?: string
176
+ field?: keyof TData & string
177
+ fieldFn?: (row: TData) => unknown
178
+ header?: ColumnDefTemplate<HeaderContext<TData>>
179
+ footer?: ColumnDefTemplate<HeaderContext<TData>>
180
+ cell?: ColumnDefTemplate<CellContext<TData>>
181
+ columns?: Array<ColumnDef<TFeatures, TData>>
182
+ /**
183
+ * Declarative cell spanning (merged cells). Return how many COLUMNS this
184
+ * cell spans to the right (1 = no span). Value-driven, AG-Grid-style. Feed
185
+ * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
186
+ * the same real `colspan`/`rowspan` merge engine (no separate code path).
187
+ */
188
+ colSpan?: (params: CellSpanParams<TData>) => number
189
+ /**
190
+ * Declarative cell spanning (merged cells). Return how many ROWS this cell
191
+ * spans downward (1 = no span). See `colSpan` for how to apply.
192
+ */
193
+ rowSpan?: (params: CellSpanParams<TData>) => number
194
+ /**
195
+ * High-level data type for the column. A convenience that resolves to the
196
+ * right `editorType`, alignment, date `format`, and filter operators without
197
+ * setting each by hand:
198
+ * 'text' → text editor, left-aligned
199
+ * 'number' → number editor, right-aligned, numeric filter operators
200
+ * 'boolean' → checkbox editor, centered
201
+ * 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format
202
+ * 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')
203
+ * Anything you set explicitly (`editorType`, `align`, `format`) still wins -
204
+ * `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers
205
+ * this from the first data row for columns that declare neither.
206
+ */
207
+ cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'
208
+ /**
209
+ * Hide this column when the grid's `responsive` mode is on and the grid is
210
+ * narrower than this many pixels - drop low-priority columns on small
211
+ * screens. No effect unless the grid has `responsive` set.
212
+ */
213
+ hideBelow?: number
214
+ /**
215
+ * For a column INSIDE a collapsible column group: `'open'` shows this column
216
+ * only while the group is expanded, `'closed'` only while collapsed. Omit to
217
+ * always show it. Setting it on any direct child gives the parent group a
218
+ * collapse toggle. Pair with `openByDefault` on the group.
219
+ */
220
+ columnGroupShow?: 'open' | 'closed'
221
+ /**
222
+ * For a GROUP column (one with `columns: [...]`): start the group expanded.
223
+ * Defaults to `false` (collapsed), matching AG Grid - so only the always-on
224
+ * and `columnGroupShow: 'closed'` children show until the user expands it.
225
+ */
226
+ openByDefault?: boolean
227
+ editorType?:
228
+ | 'text'
229
+ | 'number'
230
+ | 'date' // rich SvCalendar popover (opt out with 'date-native')
231
+ | 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
232
+ | 'time' // rich SvTimePicker dial (opt out with 'time-native')
233
+ | 'date-native' // plain <input type="date">
234
+ | 'datetime-native' // plain <input type="datetime-local">
235
+ | 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
236
+ | 'password' // native <input type="password"> with masked rendering
237
+ | 'checkbox'
238
+ | 'list'
239
+ | 'chips'
240
+ | 'select' // custom dropdown - single value, no typeahead
241
+ | 'rich-select' // custom dropdown with a typeahead search input
242
+ | 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)
243
+ | 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline
244
+ | 'color' // native <input type="color"> swatch
245
+ | 'rating' // 5-star rating control
246
+ // Any other string names a CUSTOM editor registered via `registerCellEditor`
247
+ // (or `registerBuiltinEditors`). `(string & {})` keeps the literals above
248
+ // autocompleting while allowing arbitrary custom type names.
249
+ | (string & {})
250
+ /**
251
+ * Custom in-cell editor. Receives the cell context PLUS a `commit(value)`
252
+ * and `cancel()` helper. Use when none of the built-in `editorType`s fit;
253
+ * the snippet's outer element is mounted inside the editing cell and
254
+ * inherits keyboard handling (Esc cancels, Enter commits unless your
255
+ * snippet preventDefaults it).
256
+ *
257
+ * Coexists with `editorType`: when both are set, `cellEditor` wins and
258
+ * `editorType` is treated as a hint for parsing the saved value.
259
+ */
260
+ cellEditor?: ColumnDefTemplate<EditorContext<TData>>
261
+ /**
262
+ * Per-column tooltip. String shows as a native `title=`; `(ctx) => string`
263
+ * runs per cell so the tooltip can reflect the value. Returning an empty
264
+ * string skips the tooltip.
265
+ */
266
+ tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
267
+ /**
268
+ * Declarative per-cell validation (Handsontable-style). Runs for EVERY
269
+ * rendered cell - including values already present in `data` on load, not
270
+ * just on edit - so bad data is flagged immediately. Invalid cells get the
271
+ * `sv-grid-cell-invalid` class (red highlight) and the returned message as
272
+ * their tooltip.
273
+ *
274
+ * Return value:
275
+ * - `null` / `undefined` / `true` → valid (no highlight)
276
+ * - `false` → invalid, no message
277
+ * - a non-empty `string` → invalid, string is the tooltip
278
+ *
279
+ * The value keeps rendering as-is (the grid does NOT roll it back); pair
280
+ * with `onCellValueChange` if you also want to reject the commit.
281
+ */
282
+ validate?: (params: {
283
+ value: unknown
284
+ row: TData
285
+ rowIndex: number
286
+ column: Column<TData>
287
+ }) => string | boolean | null | undefined
288
+ /**
289
+ * Gate editing per column or per cell.
290
+ *
291
+ * - `true` (or omitted): the column is fully editable.
292
+ * - `false`: the column is read-only - double-click, type-to-edit,
293
+ * fill-handle drag, Delete, and clipboard paste all skip it.
294
+ * - `(ctx) => boolean`: evaluated for each cell, so you can lock
295
+ * individual rows (e.g. by role, status, ownership). Returning
296
+ * `false` opts the cell out of every editing path, identical to
297
+ * setting `editable: false` on the whole column for that row.
298
+ *
299
+ * The grid-wide `enableInlineEditing` prop still wins when set to
300
+ * `false`.
301
+ */
302
+ editable?: boolean | ((context: CellContext<TData>) => boolean)
303
+ /**
304
+ * Transform the committed edit value before it is written to the row.
305
+ * Runs after the built-in per-`editorType` coercion, so `newValue` is
306
+ * already type-parsed; return the final value to store (e.g. round a
307
+ * number, uppercase a code, look up an id). AG-Grid-style `valueParser`.
308
+ */
309
+ valueParser?: (params: ValueParserParams<TData>) => unknown
310
+ /**
311
+ * Briefly flash / highlight this column's cell when its value changes
312
+ * (streaming feeds, edits, server pushes). `true` uses the default flash;
313
+ * pass `{ className }` to apply your own animation class instead.
314
+ */
315
+ cellFlash?: boolean | { className?: string }
316
+ /**
317
+ * When `false`, this column never shows a sort indicator and clicking
318
+ * its header is a no-op - `api.setSort(thisColumn, ...)` is also
319
+ * ignored. Defaults to `true` (the column participates in sorting as
320
+ * long as `rowSortingFeature` is registered).
321
+ */
322
+ sortable?: boolean
323
+ /**
324
+ * When `false`, this column never shows a filter funnel / menu and
325
+ * `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the
326
+ * column is filterable as long as `columnFilteringFeature` is
327
+ * registered).
328
+ */
329
+ filterable?: boolean
330
+ /**
331
+ * Options for `editorType: 'list' | 'chips'`. Either bare values (the
332
+ * string is both value and label) or `{ value, label }` objects.
333
+ * For `chips` this is optional - when omitted, the chips editor becomes
334
+ * free-form (user types and presses Enter to commit a chip).
335
+ *
336
+ * Pass a function `(row) => options` for row-dependent (cascading)
337
+ * options - e.g. City options that depend on Country in the same row.
338
+ */
339
+ editorOptions?:
340
+ | ReadonlyArray<
341
+ string | number | { value: string | number; label?: string; color?: string }
342
+ >
343
+ | ((
344
+ row: TData,
345
+ ) => ReadonlyArray<
346
+ string | number | { value: string | number; label?: string; color?: string }
347
+ >)
348
+ /** When true, list/chips allow multiple selections. Cell value becomes an array. */
349
+ editorMultiple?: boolean
350
+ /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
351
+ editorSeparator?: string
352
+ format?: CellFormatConfig
353
+ formatter?: CellFormatter<TData>
354
+ /**
355
+ * Aggregate this column's values into the group row when grouping is
356
+ * active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |
357
+ * 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result
358
+ * is formatted with this column's `format` and shown in the group header.
359
+ */
360
+ aggregate?: GroupAggregator<TData>
361
+ /**
362
+ * Render the cell as an in-cell sparkline chart. The cell value should be
363
+ * an array of numbers (or a comma/space separated string). Mutually
364
+ * exclusive with a custom `cell` renderer (a `cell` wins if both are set).
365
+ *
366
+ * { sparkline: { type: 'line' } } // default line
367
+ * { sparkline: { type: 'bar', color: '#16a34a' } }
368
+ * { sparkline: { type: 'winloss' } } // sign-only up/down
369
+ *
370
+ * See `SparklineConfig` for the full option set (type, color,
371
+ * negativeColor, width, height, fixed min/max).
372
+ */
373
+ sparkline?: SparklineConfig
374
+ /** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */
375
+ width?: number
376
+ /**
377
+ * Initial visibility. Set `false` to start the column hidden while still
378
+ * listing it in the Choose Columns UI for the user to re-enable. Applied
379
+ * once at mount; after that `api.setColumnVisible` / user toggles win.
380
+ * On a group column, `false` hides the whole group's leaf columns.
381
+ */
382
+ visible?: boolean
383
+ /**
384
+ * Horizontal alignment for header and body cells. When omitted, the
385
+ * default is inferred from `editorType`:
386
+ * - `'number' | 'date' | 'datetime'` → `'right'`
387
+ * - `'checkbox'` → `'center'`
388
+ * - everything else → `'left'`
389
+ */
390
+ align?: 'left' | 'center' | 'right'
391
+ /**
392
+ * Per-cell conditional CSS. Two shapes:
393
+ *
394
+ * - **String** (or array of strings): class name(s) added to the
395
+ * cell's `<td>` for every row in this column.
396
+ * - **Function**: invoked per cell with the same `CellContext` shape
397
+ * the `cell` renderer receives. Return a string, an array of
398
+ * strings, or an object mapping class names to booleans.
399
+ *
400
+ * Use it for status tinting, conditional bold, "negative number"
401
+ * coloring - anything that's a function of the row's value. Cells
402
+ * still receive their format / cell renderer; the class just
403
+ * augments the rendered `<td>`.
404
+ */
405
+ cellClass?:
406
+ | string
407
+ | ReadonlyArray<string>
408
+ | ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
409
+ }
410
+
411
+ export type Column<TData extends RowData> = {
412
+ id: string
413
+ columnDef: ColumnDef<any, TData>
414
+ depth: number
415
+ parentId?: string
416
+ getCanSort: () => boolean
417
+ getCanFilter: () => boolean
418
+ getIsSorted: () => false | 'asc' | 'desc'
419
+ getToggleSortingHandler: () => () => void
420
+ }
421
+
422
+ export type Header<TData extends RowData> = {
423
+ id: string
424
+ isPlaceholder: boolean
425
+ colSpan: number
426
+ column: Column<TData>
427
+ getContext: () => HeaderContext<TData>
428
+ }
429
+
430
+ export type HeaderGroup<TData extends RowData> = {
431
+ id: string
432
+ headers: Array<Header<TData>>
433
+ }
434
+
435
+ export type Cell<TData extends RowData> = {
436
+ id: string
437
+ row: Row<TData>
438
+ column: Column<TData>
439
+ getValue: () => unknown
440
+ getContext: () => CellContext<TData>
441
+ }
442
+
443
+ export type Row<TData extends RowData> = {
444
+ id: string
445
+ index: number
446
+ original: TData
447
+ depth: number
448
+ subRows?: Array<Row<TData>>
449
+ /** Total leaf (data) rows under this group row. Undefined for data rows. */
450
+ leafCount?: number
451
+ getCanExpand: () => boolean
452
+ getIsExpanded: () => boolean
453
+ toggleExpanded: () => void
454
+ getIsSelected: () => boolean
455
+ toggleSelected: () => void
456
+ getAllCells: () => Array<Cell<TData>>
457
+ getCellValueByColumnId: (columnId: string) => unknown
458
+ }
459
+
460
+ export type RowModel<TData extends RowData> = {
461
+ rows: Array<Row<TData>>
462
+ }
463
+
464
+ export type Store<T> = {
465
+ readonly state: T
466
+ setState: (updater: (prev: T) => T) => void
467
+ subscribe: (listener: () => void) => () => void
468
+ }
469
+
470
+ function createStore<T>(initial: T): Store<T> {
471
+ let value = initial
472
+ const listeners = new Set<() => void>()
473
+ return {
474
+ get state() {
475
+ return value
476
+ },
477
+ setState(updater) {
478
+ value = updater(value)
479
+ listeners.forEach((listener) => listener())
480
+ },
481
+ subscribe(listener) {
482
+ listeners.add(listener)
483
+ return () => listeners.delete(listener)
484
+ },
485
+ }
486
+ }
487
+
488
+ export const rowSortingFeature = { key: 'rowSortingFeature' }
489
+ export const columnFilteringFeature = { key: 'columnFilteringFeature' }
490
+ export const rowPaginationFeature = { key: 'rowPaginationFeature' }
491
+ export const columnGroupingFeature = { key: 'columnGroupingFeature' }
492
+ export const rowSelectionFeature = { key: 'rowSelectionFeature' }
493
+ export const rowExpandingFeature = { key: 'rowExpandingFeature' }
494
+
495
+ export function tableFeatures<T extends TableFeatures>(features: T): T {
496
+ return features
497
+ }
498
+
499
+ export const sortFns = {
500
+ auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
501
+ number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
502
+ date: (a: unknown, b: unknown) => {
503
+ const aa = new Date(a as any).getTime()
504
+ const bb = new Date(b as any).getTime()
505
+ return aa - bb
506
+ },
507
+ }
508
+
509
+ export const filterFns = {
510
+ includesString: (value: unknown, query: string) =>
511
+ String(value).toLowerCase().includes(query.toLowerCase()),
512
+ equals: (value: unknown, query: unknown) => value === query,
513
+ }
514
+
515
+ export type RowModelFactory<TData extends RowData> = (args: {
516
+ table: SvGrid<TData>
517
+ rows: Array<Row<TData>>
518
+ }) => Array<Row<TData>>
519
+
520
+ export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {
521
+ return ({ rows }) => rows
522
+ }
523
+ export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
524
+ return ({ table, rows }) => {
525
+ const filters: ColumnFiltersState = table.getState().columnFilters ?? []
526
+ if (!filters.length) return rows
527
+ return rows.filter((row) => {
528
+ return filters.every((filter) => {
529
+ const cellValue = row
530
+ .getAllCells()
531
+ .find((cell) => cell.column.id === filter.id)
532
+ ?.getValue()
533
+ const filterFn = filter.fn ? filterFns[filter.fn] : filterFns.includesString
534
+ return filterFn(cellValue, filter.value as any)
535
+ })
536
+ })
537
+ }
538
+ }
539
+ export function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {
540
+ return ({ table, rows }) => {
541
+ const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }
542
+ const start = pagination.pageIndex * pagination.pageSize
543
+ return rows.slice(start, start + pagination.pageSize)
544
+ }
545
+ }
546
+ export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {
547
+ return ({ table, rows }) => {
548
+ const grouping: GroupingState = table.getState().grouping ?? []
549
+ if (!grouping.length) return rows
550
+ const columns = table.getAllColumns()
551
+
552
+ // Recursively bucket rows by each grouping column in turn. At every level a
553
+ // group row is built that stands in for its children - a non-group column
554
+ // resolves to the value shared by every leaf row, or to undefined when the
555
+ // leaves disagree.
556
+ function buildGroups(
557
+ input: Array<Row<TData>>,
558
+ levelIndex: number,
559
+ depth: number,
560
+ idPrefix: string,
561
+ ): Array<Row<TData>> {
562
+ if (levelIndex >= grouping.length) {
563
+ // Leaves: actual data rows, with their nesting depth recorded.
564
+ return input.map((row) => ({ ...row, depth }))
565
+ }
566
+ const groupKey = grouping[levelIndex]
567
+ if (!groupKey) return input
568
+
569
+ const buckets = new Map<string, Array<Row<TData>>>()
570
+ for (const row of input) {
571
+ const value = row.getCellValueByColumnId(groupKey)
572
+ const key = String(value ?? '')
573
+ const list = buckets.get(key) ?? []
574
+ list.push(row)
575
+ buckets.set(key, list)
576
+ }
577
+
578
+ const groupRows: Array<Row<TData>> = []
579
+ let index = 0
580
+ buckets.forEach((children, key) => {
581
+ const id = `${idPrefix}_${groupKey}_${key}`
582
+ const subRows = buildGroups(children, levelIndex + 1, depth + 1, id)
583
+ const isDeepest = levelIndex + 1 >= grouping.length
584
+ const leafCount = isDeepest
585
+ ? subRows.length
586
+ : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)
587
+
588
+ const resolveColumnValue = (columnId: string): unknown => {
589
+ if (columnId === groupKey) return key
590
+ let resolved: unknown
591
+ let hasResolved = false
592
+ for (const child of children) {
593
+ const childValue = child.getCellValueByColumnId(columnId)
594
+ if (!hasResolved) {
595
+ resolved = childValue
596
+ hasResolved = true
597
+ } else if (childValue !== resolved) {
598
+ return undefined
599
+ }
600
+ }
601
+ return resolved
602
+ }
603
+
604
+ const groupOriginal: Record<string, unknown> = {}
605
+ columns.forEach((column) => {
606
+ const field = column.columnDef.field
607
+ if (!field) return
608
+ const agg = column.columnDef.aggregate
609
+ groupOriginal[field] = agg
610
+ ? applyGroupAggregate(agg, column.id, children)
611
+ : resolveColumnValue(column.id)
612
+ })
613
+
614
+ const groupRow: Row<TData> = {
615
+ id,
616
+ index: index++,
617
+ original: groupOriginal as TData,
618
+ depth,
619
+ subRows,
620
+ leafCount,
621
+ getCanExpand: () => true,
622
+ getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
623
+ toggleExpanded: () => {
624
+ table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
625
+ },
626
+ getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),
627
+ toggleSelected: () => {
628
+ table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
629
+ },
630
+ getAllCells: () => [],
631
+ // Prefer the precomputed group value (which carries aggregates)
632
+ // and fall back to the shared-value resolver for columns without
633
+ // a field.
634
+ getCellValueByColumnId: (columnId: string) => {
635
+ const col = columns.find((c) => c.id === columnId)
636
+ const field = col?.columnDef.field
637
+ if (field && field in groupOriginal) return groupOriginal[field]
638
+ return resolveColumnValue(columnId)
639
+ },
640
+ }
641
+ groupRows.push(groupRow)
642
+ })
643
+ return groupRows
644
+ }
645
+
646
+ return buildGroups(rows, 0, 0, 'group')
647
+ }
648
+ }
649
+ export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
650
+ return ({ table, rows }) => {
651
+ const expanded: ExpandedState = table.getState().expanded ?? {}
652
+ const flattened: Array<Row<TData>> = []
653
+ const visit = (row: Row<TData>) => {
654
+ flattened.push(row)
655
+ if (row.subRows?.length && expanded[row.id]) {
656
+ for (const sub of row.subRows) visit(sub)
657
+ }
658
+ }
659
+ for (const row of rows) visit(row)
660
+ return flattened
661
+ }
662
+ }
663
+ export function createSortedRowModel<TData extends RowData>(
664
+ localSortFns: typeof sortFns = sortFns,
665
+ ): RowModelFactory<TData> {
666
+ return ({ table, rows }) => {
667
+ const sorting = table.getState().sorting ?? []
668
+ if (!sorting.length) return rows
669
+
670
+ const sorted = [...rows].sort((a, b) => {
671
+ for (const clause of sorting) {
672
+ const column = table.getAllColumns().find((col) => col.id === clause.id)
673
+ if (!column) continue
674
+ const editorType = column.columnDef.editorType
675
+ const comparator =
676
+ editorType === 'number'
677
+ ? localSortFns.number
678
+ : editorType === 'date' || editorType === 'datetime'
679
+ ? localSortFns.date
680
+ : localSortFns.auto
681
+ const result = comparator(
682
+ a.getCellValueByColumnId(column.id),
683
+ b.getCellValueByColumnId(column.id),
684
+ )
685
+ if (result !== 0) return clause.desc ? -result : result
686
+ }
687
+ return 0
688
+ })
689
+ return sorted
690
+ }
691
+ }
692
+
693
+ export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {
694
+ _features: TFeatures
695
+ _rowModels?: {
696
+ coreRowModel?: RowModelFactory<TData>
697
+ filteredRowModel?: RowModelFactory<TData>
698
+ sortedRowModel?: RowModelFactory<TData>
699
+ paginatedRowModel?: RowModelFactory<TData>
700
+ groupedRowModel?: RowModelFactory<TData>
701
+ expandedRowModel?: RowModelFactory<TData>
702
+ }
703
+ columns: Array<ColumnDef<TFeatures, TData>>
704
+ data: ReadonlyArray<TData>
705
+ /**
706
+ * Optional row-id resolver. When set, the value it returns becomes
707
+ * `row.id` (and therefore the selection / expansion / edit key). When
708
+ * omitted, ids fall back to the row's array index as a string. Use a
709
+ * stable id (database PK, UUID, etc.) so selection survives reorders.
710
+ */
711
+ getRowId?: (row: TData, index: number) => string
712
+ state?: Partial<Record<string, any>>
713
+ onSortingChange?: (updater: Updater<SortingState>) => void
714
+ onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void
715
+ onPaginationChange?: (updater: Updater<PaginationState>) => void
716
+ onGroupingChange?: (updater: Updater<GroupingState>) => void
717
+ onExpandedChange?: (updater: Updater<ExpandedState>) => void
718
+ onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void
719
+ onActiveCellChange?: (updater: Updater<ActiveCellState>) => void
720
+ }
721
+
722
+ export type SvGrid<TData extends RowData> = {
723
+ store: Store<Record<string, any>>
724
+ optionsStore: Store<Record<string, any>>
725
+ state: Record<string, any>
726
+ getState: () => Record<string, any>
727
+ setOptions: (updater: Updater<Record<string, any>>) => void
728
+ setColumnFilters: (updater: Updater<ColumnFiltersState>) => void
729
+ setPagination: (updater: Updater<PaginationState>) => void
730
+ setGrouping: (updater: Updater<GroupingState>) => void
731
+ setExpanded: (updater: Updater<ExpandedState>) => void
732
+ setRowSelection: (updater: Updater<RowSelectionState>) => void
733
+ setActiveCell: (updater: Updater<ActiveCellState>) => void
734
+ moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void
735
+ getAllColumns: () => Array<Column<TData>>
736
+ getHeaderGroups: () => Array<HeaderGroup<TData>>
737
+ getFooterGroups: () => Array<HeaderGroup<TData>>
738
+ getRowModel: () => RowModel<TData>
739
+ }
740
+
741
+ type InternalGrid<TData extends RowData> = SvGrid<TData> & {
742
+ getAllColumns: () => Array<Column<TData>>
743
+ }
744
+
745
+ export function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(
746
+ options: SvGridOptions<TFeatures, TData>,
747
+ ): SvGrid<TData> {
748
+ const internalState: Record<string, any> = {
749
+ sorting: [],
750
+ columnFilters: [],
751
+ pagination: { pageIndex: 0, pageSize: options.data.length || 10 },
752
+ grouping: [],
753
+ expanded: {},
754
+ rowSelection: {},
755
+ activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
756
+ ...(options.state ?? {}),
757
+ }
758
+ const store = createStore(internalState)
759
+ const optionsStore = createStore(options as Record<string, any>)
760
+ let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null
761
+ let cachedColumns: Array<Column<TData>> = []
762
+ let cachedHeaderGroups: Array<HeaderGroup<TData>> = []
763
+ let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
764
+ let cachedBaseRowsColumns: Array<Column<TData>> | null = null
765
+ let cachedBaseRows: Array<Row<TData>> = []
766
+ let cachedRowModel: RowModel<TData> | null = null
767
+ let cachedRowModelBaseRows: Array<Row<TData>> | null = null
768
+ let cachedPipeline = options._rowModels
769
+ let cachedSlices: {
770
+ sorting: SortingState | undefined
771
+ columnFilters: ColumnFiltersState | undefined
772
+ pagination: PaginationState | undefined
773
+ grouping: GroupingState | undefined
774
+ expanded: ExpandedState | undefined
775
+ rowSelection: RowSelectionState | undefined
776
+ } | null = null
777
+
778
+ const grid = {
779
+ store,
780
+ optionsStore,
781
+ get state() {
782
+ return store.state
783
+ },
784
+ getState() {
785
+ return store.state
786
+ },
787
+ setOptions(updater: Updater<Record<string, any>>) {
788
+ optionsStore.setState((prev) =>
789
+ typeof updater === 'function' ? (updater as any)(prev) : updater,
790
+ )
791
+ },
792
+ setColumnFilters(updater: Updater<ColumnFiltersState>) {
793
+ store.setState((prev) => ({
794
+ ...prev,
795
+ columnFilters:
796
+ typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,
797
+ }))
798
+ options.onColumnFiltersChange?.(updater)
799
+ },
800
+ setPagination(updater: Updater<PaginationState>) {
801
+ store.setState((prev) => ({
802
+ ...prev,
803
+ pagination:
804
+ typeof updater === 'function'
805
+ ? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })
806
+ : updater,
807
+ }))
808
+ options.onPaginationChange?.(updater)
809
+ },
810
+ setGrouping(updater: Updater<GroupingState>) {
811
+ store.setState((prev) => ({
812
+ ...prev,
813
+ grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,
814
+ }))
815
+ options.onGroupingChange?.(updater)
816
+ },
817
+ setExpanded(updater: Updater<ExpandedState>) {
818
+ store.setState((prev) => ({
819
+ ...prev,
820
+ expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,
821
+ }))
822
+ options.onExpandedChange?.(updater)
823
+ },
824
+ setRowSelection(updater: Updater<RowSelectionState>) {
825
+ store.setState((prev) => ({
826
+ ...prev,
827
+ rowSelection:
828
+ typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,
829
+ }))
830
+ options.onRowSelectionChange?.(updater)
831
+ },
832
+ setActiveCell(updater: Updater<ActiveCellState>) {
833
+ store.setState((prev) => {
834
+ const previous: ActiveCellState = prev.activeCell ?? {
835
+ rowIndex: 0,
836
+ colIndex: 0,
837
+ cellId: null,
838
+ }
839
+ const nextActive =
840
+ typeof updater === 'function' ? updater(previous) : updater
841
+ return {
842
+ ...prev,
843
+ activeCell: nextActive,
844
+ }
845
+ })
846
+ options.onActiveCellChange?.(updater)
847
+ },
848
+ moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {
849
+ const rows = grid.getRowModel().rows
850
+ const columns = grid.getAllColumns()
851
+ const maxRow = Math.max(rows.length - 1, 0)
852
+ const maxCol = Math.max(columns.length - 1, 0)
853
+ const current: ActiveCellState = grid.getState().activeCell ?? {
854
+ rowIndex: 0,
855
+ colIndex: 0,
856
+ cellId: null,
857
+ }
858
+
859
+ const rowIndex = Math.min(
860
+ Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),
861
+ maxRow,
862
+ )
863
+ const colIndex = Math.min(
864
+ Math.max(current.colIndex + (next.colDelta ?? 0), 0),
865
+ maxCol,
866
+ )
867
+ const columnId = columns[colIndex]?.id ?? 'col_0'
868
+ grid.setActiveCell({
869
+ rowIndex,
870
+ colIndex,
871
+ cellId: `${rowIndex}_${columnId}`,
872
+ })
873
+ },
874
+ getAllColumns() {
875
+ // Cache hit: referentially identical columns array.
876
+ if (cachedColumnsInput === options.columns && cachedColumns.length) {
877
+ return cachedColumns
878
+ }
879
+ // Soft cache hit: consumers commonly recreate the columns array
880
+ // inline on every render (e.g. `columns={[...]}`). If the new
881
+ // array has the same length AND each entry has the same `field` /
882
+ // `id` / `header` (the visibility-affecting structure of a
883
+ // column), trust the previous build. Mutable inner fields like
884
+ // `cell` and `editorOptions` are still picked up on the next real
885
+ // render that bumps an actual data dep - they're read at cell-
886
+ // render time, not at this top-level cache.
887
+ if (
888
+ cachedColumnsInput &&
889
+ options.columns.length === cachedColumnsInput.length &&
890
+ cachedColumns.length === options.columns.length &&
891
+ options.columns.every((c, i) => {
892
+ const prev = cachedColumnsInput![i]!
893
+ return (
894
+ c.field === prev.field &&
895
+ c.id === prev.id &&
896
+ c.header === prev.header &&
897
+ c.editorType === prev.editorType
898
+ )
899
+ })
900
+ ) {
901
+ // Update the stored input reference so the strict check hits
902
+ // next time, but reuse the built column model.
903
+ cachedColumnsInput = options.columns
904
+ return cachedColumns
905
+ }
906
+
907
+ cachedColumnsInput = options.columns
908
+ cachedHeaderGroups = []
909
+ const build = (
910
+ defs: Array<ColumnDef<TFeatures, TData>>,
911
+ depth: number,
912
+ parentId?: string,
913
+ ): Array<Column<TData>> => {
914
+ const leaves: Array<Column<TData>> = []
915
+ defs.forEach((columnDef, index) => {
916
+ const id = columnDef.id ?? columnDef.field ?? `${parentId ?? 'col'}_${depth}_${index}`
917
+ if (columnDef.columns?.length) {
918
+ leaves.push(...build(columnDef.columns, depth + 1, id))
919
+ return
920
+ }
921
+ leaves.push({
922
+ id,
923
+ depth,
924
+ parentId,
925
+ columnDef,
926
+ getCanSort: () =>
927
+ Boolean((options._features as any).rowSortingFeature) &&
928
+ columnDef.sortable !== false,
929
+ getCanFilter: () =>
930
+ Boolean((options._features as any).columnFilteringFeature) &&
931
+ columnDef.filterable !== false,
932
+ getIsSorted: () => {
933
+ const entry = store.state.sorting?.find((s: any) => s.id === id)
934
+ if (!entry) return false
935
+ return entry.desc ? 'desc' : 'asc'
936
+ },
937
+ getToggleSortingHandler: () => () => {
938
+ const clauses: SortingState = store.state.sorting ?? []
939
+ const current = clauses.find((s: any) => s.id === id)
940
+ const nextClause: SortingState = !current
941
+ ? [...clauses, { id, desc: false }]
942
+ : current.desc
943
+ ? clauses.filter((s) => s.id !== id)
944
+ : clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))
945
+ store.setState((prev) => ({ ...prev, sorting: nextClause }))
946
+ options.onSortingChange?.(nextClause)
947
+ },
948
+ })
949
+ })
950
+ return leaves
951
+ }
952
+ cachedColumns = build(options.columns, 0)
953
+ return cachedColumns
954
+ },
955
+ getHeaderGroups() {
956
+ if (cachedHeaderGroups.length) return cachedHeaderGroups
957
+ const headers = grid.getAllColumns().map((column) => {
958
+ const header: Header<TData> = {
959
+ id: column.id,
960
+ isPlaceholder: false,
961
+ colSpan: 1,
962
+ column,
963
+ getContext: () => ({ header, column, table: grid }),
964
+ }
965
+ return header
966
+ })
967
+ cachedHeaderGroups = [{ id: 'header_group_0', headers }]
968
+ return cachedHeaderGroups
969
+ },
970
+ getFooterGroups() {
971
+ return grid.getHeaderGroups()
972
+ },
973
+ getRowModel() {
974
+ const columns = grid.getAllColumns()
975
+ if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
976
+ cachedBaseRowsInput = options.data
977
+ cachedBaseRowsColumns = columns
978
+ // O(1) column-id → index lookup so getCellValueByColumnId doesn't do
979
+ // a linear `findIndex` on every cell read (was O(rows × cells × cols)).
980
+ const columnIndexById = new Map<string, number>()
981
+ for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
982
+ const columnCount = columns.length
983
+
984
+ cachedBaseRows = new Array(options.data.length)
985
+ const getRowId = options.getRowId
986
+ for (let index = 0; index < options.data.length; index++) {
987
+ const original = options.data[index]!
988
+ const id = getRowId ? getRowId(original, index) : String(index)
989
+ // `values` and `cells` are computed lazily - for a 100k-row grid
990
+ // with only ~20 visible rows we don't want to materialise every
991
+ // row's full value array or cell objects up front.
992
+ let cachedValues: Array<unknown> | null = null
993
+ let cachedCells: Array<Cell<TData>> | null = null
994
+
995
+ function computeValues(): Array<unknown> {
996
+ const values = new Array<unknown>(columnCount)
997
+ for (let i = 0; i < columnCount; i++) {
998
+ const column = columns[i]!
999
+ if (column.columnDef.fieldFn) {
1000
+ values[i] = column.columnDef.fieldFn(original)
1001
+ } else if (column.columnDef.field) {
1002
+ values[i] = (original as any)[column.columnDef.field]
1003
+ } else {
1004
+ values[i] = undefined
1005
+ }
1006
+ }
1007
+ return values
1008
+ }
1009
+
1010
+ const row: Row<TData> = {
1011
+ id,
1012
+ index,
1013
+ original,
1014
+ depth: 0,
1015
+ getCanExpand: () => false,
1016
+ getIsExpanded: () => Boolean((store.state.expanded ?? {})[id]),
1017
+ toggleExpanded: () => {
1018
+ grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
1019
+ },
1020
+ getIsSelected: () => Boolean((store.state.rowSelection ?? {})[id]),
1021
+ toggleSelected: () => {
1022
+ grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
1023
+ },
1024
+ getAllCells: () => {
1025
+ if (cachedCells) return cachedCells
1026
+ const built = new Array<Cell<TData>>(columnCount)
1027
+ for (let i = 0; i < columnCount; i++) {
1028
+ const column = columns[i]!
1029
+ const colIndex = i
1030
+ const cell: Cell<TData> = {
1031
+ id: `${id}_${column.id}`,
1032
+ row,
1033
+ column,
1034
+ getValue: () => {
1035
+ if (!cachedValues) cachedValues = computeValues()
1036
+ return cachedValues[colIndex]
1037
+ },
1038
+ getContext: () => ({
1039
+ cell,
1040
+ row,
1041
+ column,
1042
+ table: grid,
1043
+ getValue: () => cell.getValue(),
1044
+ }),
1045
+ }
1046
+ built[i] = cell
1047
+ }
1048
+ cachedCells = built
1049
+ return built
1050
+ },
1051
+ getCellValueByColumnId: (columnId: string) => {
1052
+ const idx = columnIndexById.get(columnId)
1053
+ if (idx === undefined) return undefined
1054
+ if (!cachedValues) cachedValues = computeValues()
1055
+ return cachedValues[idx]
1056
+ },
1057
+ }
1058
+ cachedBaseRows[index] = row
1059
+ }
1060
+ }
1061
+
1062
+ const currentSlices = {
1063
+ sorting: store.state.sorting,
1064
+ columnFilters: store.state.columnFilters,
1065
+ pagination: store.state.pagination,
1066
+ grouping: store.state.grouping,
1067
+ expanded: store.state.expanded,
1068
+ rowSelection: store.state.rowSelection,
1069
+ }
1070
+ if (
1071
+ cachedRowModel &&
1072
+ cachedRowModelBaseRows === cachedBaseRows &&
1073
+ cachedPipeline === options._rowModels &&
1074
+ cachedSlices?.sorting === currentSlices.sorting &&
1075
+ cachedSlices?.columnFilters === currentSlices.columnFilters &&
1076
+ cachedSlices?.pagination === currentSlices.pagination &&
1077
+ cachedSlices?.grouping === currentSlices.grouping &&
1078
+ cachedSlices?.expanded === currentSlices.expanded &&
1079
+ cachedSlices?.rowSelection === currentSlices.rowSelection
1080
+ ) {
1081
+ return cachedRowModel
1082
+ }
1083
+
1084
+ let rows: Array<Row<TData>> = cachedBaseRows
1085
+
1086
+ const pipeline = options._rowModels ?? {}
1087
+ const ordered: Array<RowModelFactory<TData> | undefined> = [
1088
+ pipeline.coreRowModel,
1089
+ pipeline.filteredRowModel,
1090
+ pipeline.sortedRowModel,
1091
+ pipeline.groupedRowModel,
1092
+ pipeline.expandedRowModel,
1093
+ pipeline.paginatedRowModel,
1094
+ ]
1095
+ ordered.forEach((fn) => {
1096
+ if (fn) rows = fn({ table: grid, rows })
1097
+ })
1098
+ cachedPipeline = options._rowModels
1099
+ cachedSlices = currentSlices
1100
+ cachedRowModelBaseRows = cachedBaseRows
1101
+ cachedRowModel = { rows }
1102
+ return cachedRowModel
1103
+ },
1104
+ } as InternalGrid<TData>
1105
+
1106
+ return grid
1107
+ }
1108
+
1109
+ export function isFunction(value: unknown): value is (...args: Array<any>) => any {
1110
+ return typeof value === 'function'
1111
+ }