@svgrid/grid 2.2.27 → 2.2.29

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