@svgrid/grid 2.6.23 → 3.0.0

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 (263) hide show
  1. package/CHANGELOG.md +88 -88
  2. package/README.md +199 -199
  3. package/dist/FlexRender.svelte +96 -96
  4. package/dist/GridFooter.svelte +181 -181
  5. package/dist/SvAutoComplete.svelte +169 -169
  6. package/dist/SvAvatar.svelte +75 -75
  7. package/dist/SvCalendar.svelte +503 -503
  8. package/dist/SvCarousel.svelte +141 -141
  9. package/dist/SvCheckBox.svelte +102 -102
  10. package/dist/SvCircularProgress.svelte +109 -109
  11. package/dist/SvColorInput.svelte +181 -181
  12. package/dist/SvComboBox.svelte +279 -279
  13. package/dist/SvContextMenu.svelte +116 -116
  14. package/dist/SvCountryInput.svelte +163 -163
  15. package/dist/SvDrawer.svelte +254 -254
  16. package/dist/SvDropDownList.svelte +378 -378
  17. package/dist/SvDurationInput.svelte +126 -126
  18. package/dist/SvField.svelte +293 -293
  19. package/dist/SvForm.svelte +437 -437
  20. package/dist/SvGridCellEditor.svelte +744 -744
  21. package/dist/SvGridChart.svelte +1716 -1716
  22. package/dist/SvGridChartPanel.svelte +485 -485
  23. package/dist/SvGridChartView.svelte +70 -70
  24. package/dist/SvGridDropdown.svelte +728 -728
  25. package/dist/SvGridSelect.svelte +270 -270
  26. package/dist/SvGroupCell.svelte +116 -116
  27. package/dist/SvListBox.svelte +334 -334
  28. package/dist/SvMaskedInput.svelte +122 -122
  29. package/dist/SvMenu.svelte +124 -124
  30. package/dist/SvMenuList.svelte +146 -146
  31. package/dist/SvMultiSelect.svelte +293 -293
  32. package/dist/SvNumberInput.svelte +172 -172
  33. package/dist/SvOtpInput.svelte +158 -158
  34. package/dist/SvPasswordInput.svelte +151 -151
  35. package/dist/SvPhoneInput.svelte +133 -133
  36. package/dist/SvPopover.svelte +197 -197
  37. package/dist/SvProgress.svelte +116 -116
  38. package/dist/SvRadioGroup.svelte +107 -107
  39. package/dist/SvRating.svelte +112 -112
  40. package/dist/SvResult.svelte +73 -73
  41. package/dist/SvRichText.svelte +211 -211
  42. package/dist/SvRowGroupPanel.svelte +170 -170
  43. package/dist/SvScrollArea.svelte +61 -61
  44. package/dist/SvSlider.svelte +203 -203
  45. package/dist/SvSwitchButton.svelte +108 -108
  46. package/dist/SvTagsInput.svelte +115 -115
  47. package/dist/SvTextInput.svelte +147 -147
  48. package/dist/SvTimePicker.svelte +245 -245
  49. package/dist/SvToaster.svelte +159 -159
  50. package/dist/SvToggleButton.svelte +85 -85
  51. package/dist/SvTooltip.svelte +161 -161
  52. package/dist/SvTour.svelte +208 -208
  53. package/dist/SvTree.svelte +444 -444
  54. package/dist/SvTreeSelect.svelte +239 -239
  55. package/dist/cdn/{GridMenus-BzWXQjv3.js → GridMenus-DMihUtma.js} +3 -3
  56. package/dist/cdn/{GridMenus-xr_rHx8F.js → GridMenus-nmNDj1a3.js} +3 -3
  57. package/dist/cdn/{SvDateRangeInput-DMLKmGEc.js → SvDateRangeInput-Bh0A0JkF.js} +1 -1
  58. package/dist/cdn/{SvDateRangeInput-CaOuMs8O.js → SvDateRangeInput-DflbiP7N.js} +1 -1
  59. package/dist/cdn/{SvDateTimePicker-sonaH0oh.js → SvDateTimePicker-BWwpfB_o.js} +1 -1
  60. package/dist/cdn/{SvDateTimePicker-CCbDZNZB.js → SvDateTimePicker-Bivn8dAP.js} +1 -1
  61. package/dist/cdn/{SvGridCellEditor-BAPSC7EL.js → SvGridCellEditor-AXL8cHGO.js} +1 -1
  62. package/dist/cdn/{SvGridCellEditor-BwzmUWtL.js → SvGridCellEditor-Ba7rY3eu.js} +1 -1
  63. package/dist/cdn/{SvGridChart-BwVos976.js → SvGridChart-Bs2GIR2Q.js} +1 -1
  64. package/dist/cdn/{SvGridChart-BEJmNNx9.js → SvGridChart-CwhFz7GV.js} +1 -1
  65. package/dist/cdn/{SvGridChartPanel-BOknOkrP.js → SvGridChartPanel-1WXuStzM.js} +1 -1
  66. package/dist/cdn/{SvGridChartPanel-D2PjII4O.js → SvGridChartPanel-e5_nqNP9.js} +1 -1
  67. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-SmPW10dI.js} +1 -1
  68. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-eSPuJE6g.js} +1 -1
  69. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-B1ZcLpgs.js} +1 -1
  70. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CtdbHcIS.js} +1 -1
  71. package/dist/cdn/{date-format-BnnHlqGw.js → date-format-CcP1tafP.js} +2 -2
  72. package/dist/cdn/{date-format-BNii4zeD.js → date-format-DZT7T1wf.js} +2 -2
  73. package/dist/cdn/{row-drag-touch-Ddaa-QeB.js → row-drag-touch-CycRR65n.js} +12 -10
  74. package/dist/cdn/{src-skQN5eqh.js → src-B_YS5AOc.js} +45 -45
  75. package/dist/cdn/{src-DGo7IHug.js → src-DYXXpuSk.js} +44 -44
  76. package/dist/cdn/svgrid.js +7 -7
  77. package/dist/cdn/svgrid.svelte-external.js +7 -7
  78. package/dist/chart-export.js +8 -8
  79. package/dist/row-drag-touch.d.ts +5 -1
  80. package/dist/row-drag-touch.js +15 -4
  81. package/dist/row-drag.js +7 -3
  82. package/package.json +11 -11
  83. package/src/FlexRender.svelte +96 -96
  84. package/src/GridFooter.svelte +181 -181
  85. package/src/SvAutoComplete.svelte +169 -169
  86. package/src/SvAvatar.svelte +75 -75
  87. package/src/SvCalendar.svelte +503 -503
  88. package/src/SvCalendar.test.ts +226 -226
  89. package/src/SvCarousel.svelte +141 -141
  90. package/src/SvCheckBox.svelte +102 -102
  91. package/src/SvCircularProgress.svelte +109 -109
  92. package/src/SvColorInput.svelte +181 -181
  93. package/src/SvComboBox.svelte +279 -279
  94. package/src/SvContextMenu.svelte +116 -116
  95. package/src/SvCountryInput.svelte +163 -163
  96. package/src/SvDrawer.svelte +254 -254
  97. package/src/SvDropDownList.svelte +378 -378
  98. package/src/SvDurationInput.svelte +126 -126
  99. package/src/SvField.svelte +293 -293
  100. package/src/SvForm.svelte +437 -437
  101. package/src/SvForm.test.ts +411 -411
  102. package/src/SvGrid.types.ts +2092 -2092
  103. package/src/SvGridCellEditor.svelte +744 -744
  104. package/src/SvGridChart.svelte +1716 -1716
  105. package/src/SvGridChartPanel.svelte +485 -485
  106. package/src/SvGridChartView.svelte +70 -70
  107. package/src/SvGridDropdown.svelte +728 -728
  108. package/src/SvGridSelect.svelte +270 -270
  109. package/src/SvGroupCell.svelte +116 -116
  110. package/src/SvListBox.svelte +334 -334
  111. package/src/SvMaskedInput.svelte +122 -122
  112. package/src/SvMenu.svelte +124 -124
  113. package/src/SvMenu.test.ts +97 -97
  114. package/src/SvMenuList.svelte +146 -146
  115. package/src/SvMultiSelect.svelte +293 -293
  116. package/src/SvNumberInput.svelte +172 -172
  117. package/src/SvOtpInput.svelte +158 -158
  118. package/src/SvPasswordInput.svelte +151 -151
  119. package/src/SvPhoneInput.svelte +133 -133
  120. package/src/SvPopover.svelte +197 -197
  121. package/src/SvProgress.svelte +116 -116
  122. package/src/SvRadioGroup.svelte +107 -107
  123. package/src/SvRating.svelte +112 -112
  124. package/src/SvResult.svelte +73 -73
  125. package/src/SvRichText.svelte +211 -211
  126. package/src/SvRowGroupPanel.svelte +170 -170
  127. package/src/SvScrollArea.svelte +61 -61
  128. package/src/SvSlider.svelte +203 -203
  129. package/src/SvSwitchButton.svelte +108 -108
  130. package/src/SvTagsInput.svelte +115 -115
  131. package/src/SvTextInput.svelte +147 -147
  132. package/src/SvTimePicker.svelte +245 -245
  133. package/src/SvToaster.svelte +159 -159
  134. package/src/SvToaster.test.ts +95 -95
  135. package/src/SvToggleButton.svelte +85 -85
  136. package/src/SvTooltip.svelte +161 -161
  137. package/src/SvTour.svelte +208 -208
  138. package/src/SvTree.svelte +444 -444
  139. package/src/SvTreeSelect.svelte +239 -239
  140. package/src/a11y/dismissable.test.ts +119 -119
  141. package/src/a11y/dismissable.ts +114 -114
  142. package/src/a11y.contract.test.ts +49 -49
  143. package/src/a11y.test.ts +59 -59
  144. package/src/a11y.ts +61 -61
  145. package/src/ai.test.ts +502 -502
  146. package/src/ai.ts +1419 -1419
  147. package/src/build-api.coverage.test.ts +633 -633
  148. package/src/build-api.ts +846 -846
  149. package/src/builtin-editors.grid.test.ts +83 -83
  150. package/src/cell-formatting.ts +171 -171
  151. package/src/cell-render.test.ts +513 -513
  152. package/src/cell-render.ts +496 -496
  153. package/src/cell-values.ts +148 -148
  154. package/src/chart-export.ts +202 -202
  155. package/src/chart-view.svelte.ts +36 -36
  156. package/src/chart.ts +2321 -2321
  157. package/src/collaboration.test.ts +104 -104
  158. package/src/collaboration.ts +167 -167
  159. package/src/column-groups.ts +78 -78
  160. package/src/core.performance.test.ts +30 -30
  161. package/src/core.ts +1865 -1865
  162. package/src/createAutocomplete.svelte.ts +132 -132
  163. package/src/createCombobox.svelte.ts +191 -191
  164. package/src/createCountryInput.svelte.ts +157 -157
  165. package/src/createDropdownList.svelte.ts +168 -168
  166. package/src/createForm.svelte.ts +386 -386
  167. package/src/createGrid.svelte.ts +42 -42
  168. package/src/createGrid.test.ts +10 -10
  169. package/src/createGridState.svelte.ts +17 -17
  170. package/src/createListbox.svelte.ts +250 -250
  171. package/src/createMenu.svelte.ts +224 -224
  172. package/src/createPopoverSelect.svelte.ts +213 -213
  173. package/src/createSlider.svelte.ts +191 -191
  174. package/src/createTooltip.svelte.ts +144 -144
  175. package/src/createTree.svelte.ts +322 -322
  176. package/src/datetime/date-core.ts +206 -206
  177. package/src/datetime/date-restrict.ts +61 -61
  178. package/src/datetime/timezone.ts +135 -135
  179. package/src/dock-manager-model.ts +596 -596
  180. package/src/dock-model.ts +374 -374
  181. package/src/editing.test.ts +974 -974
  182. package/src/editing.ts +609 -609
  183. package/src/editor-contract.ts +171 -171
  184. package/src/editor-registry.grid.test.ts +144 -144
  185. package/src/editor-registry.ts +122 -122
  186. package/src/export-data-api.test.ts +126 -126
  187. package/src/export-format.test.ts +107 -107
  188. package/src/export-format.ts +601 -601
  189. package/src/filter-operators.ts +160 -160
  190. package/src/filtering/excel-filters.ts +325 -325
  191. package/src/flex-render.ts +3 -3
  192. package/src/form-field.ts +127 -127
  193. package/src/group-display.test.ts +167 -167
  194. package/src/group-display.ts +200 -200
  195. package/src/headless.ts +87 -87
  196. package/src/js-scroller.svelte.ts +173 -173
  197. package/src/keyboard-handlers.ts +270 -270
  198. package/src/keyboard.test.ts +59 -59
  199. package/src/keyboard.ts +97 -97
  200. package/src/list-nav.test.ts +49 -49
  201. package/src/list-nav.ts +29 -29
  202. package/src/list-option.test.ts +56 -56
  203. package/src/list-option.ts +179 -179
  204. package/src/menus.ts +597 -597
  205. package/src/merge-objects.ts +48 -48
  206. package/src/overlays.test.ts +90 -90
  207. package/src/positioning.ts +268 -268
  208. package/src/render-component.ts +28 -28
  209. package/src/row-drag-touch.ts +20 -5
  210. package/src/row-drag.test.ts +401 -353
  211. package/src/row-drag.ts +419 -415
  212. package/src/row-resize.test.ts +524 -524
  213. package/src/row-resize.ts +228 -228
  214. package/src/scheduler-ical.ts +181 -181
  215. package/src/scheduler-model.test.ts +562 -562
  216. package/src/scheduler-model.ts +873 -873
  217. package/src/selection.test.ts +885 -885
  218. package/src/server-data-source.test.ts +383 -383
  219. package/src/server-data-source.ts +469 -469
  220. package/src/sparkline.test.ts +68 -68
  221. package/src/sparkline.ts +169 -169
  222. package/src/spreadsheet.test.ts +488 -488
  223. package/src/spreadsheet.ts +312 -312
  224. package/src/static-functions.ts +11 -11
  225. package/src/subscribe.ts +38 -38
  226. package/src/summaries.ts +113 -113
  227. package/src/svgrid-wrapper.types.ts +563 -563
  228. package/src/svgrid.async-editor-options.test.ts +273 -273
  229. package/src/svgrid.auto-row-height.test.ts +204 -204
  230. package/src/svgrid.behavior.test.ts +910 -910
  231. package/src/svgrid.charting.test.ts +534 -534
  232. package/src/svgrid.comments-autocomplete.test.ts +127 -127
  233. package/src/svgrid.context-menu.test.ts +147 -147
  234. package/src/svgrid.features.test.ts +157 -157
  235. package/src/svgrid.filter-depth.test.ts +163 -163
  236. package/src/svgrid.filter-menu-listbox.svelte.test.ts +377 -377
  237. package/src/svgrid.filter-menu-scroll.test.ts +112 -112
  238. package/src/svgrid.grand-total.test.ts +188 -188
  239. package/src/svgrid.group-display-mode.test.ts +171 -171
  240. package/src/svgrid.group-footers.test.ts +121 -121
  241. package/src/svgrid.group-pagination.test.ts +153 -153
  242. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  243. package/src/svgrid.tree-data.test.ts +186 -186
  244. package/src/svgrid.wrapper.test.ts +63 -63
  245. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
  246. package/src/test-setup.ts +62 -62
  247. package/src/themes/index.ts +288 -288
  248. package/src/toast-store.svelte.ts +250 -250
  249. package/src/toast-store.test.ts +147 -147
  250. package/src/tree-row-model.test.ts +168 -168
  251. package/src/ui-buttons.test.ts +144 -144
  252. package/src/ui-inputs.test.ts +118 -118
  253. package/src/ui-localization.test.ts +113 -113
  254. package/src/ui-range.test.ts +70 -70
  255. package/src/ui-selection.test.ts +155 -155
  256. package/src/ui-tier1.test.ts +142 -142
  257. package/src/virtual.test.ts +88 -88
  258. package/src/virtualization/column-virtualizer.test.ts +27 -27
  259. package/src/virtualization/column-virtualizer.ts +30 -30
  260. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  261. package/src/virtualization/types.ts +30 -30
  262. package/src/virtualization/virtualizer.test.ts +47 -47
  263. package/src/virtualization/virtualizer.ts +322 -322
package/src/core.ts CHANGED
@@ -1,1865 +1,1865 @@
1
- import type { SparklineConfig } from './sparkline'
2
- import { resolveColumnId } from './column-id'
3
-
4
- /**
5
- * The constraint every row type satisfies: an object keyed by string. Your own
6
- * row type (`type Person = { name: string }`) is what flows through the generics
7
- * below; this is only the lower bound they are declared against.
8
- */
9
- export type RowData = Record<string, unknown>
10
-
11
- /**
12
- * A new value, or a function that derives it from the previous one - the shape
13
- * every `set*` on the grid accepts, so callers can update state without first
14
- * reading it.
15
- *
16
- * api.setSorting([{ id: 'name', desc: false }])
17
- * api.setSorting((prev) => [...prev, { id: 'age', desc: true }])
18
- */
19
- export type Updater<T> = T | ((prev: T) => T)
20
-
21
- /** Active sort clauses, outermost first. `desc: false` is ascending. */
22
- export type SortingState = Array<{ id: string; desc: boolean }>
23
-
24
- /**
25
- * One column's filter: the column `id`, the `value` being matched, and
26
- * optionally which comparison to use. `fn` defaults to the column's own type -
27
- * see {@link filterFns} for the available names.
28
- */
29
- export type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }
30
-
31
- /** Every active column filter. A column with no entry here is unfiltered. */
32
- export type ColumnFiltersState = Array<ColumnFilter>
33
-
34
- /** Current page position. `pageIndex` is 0-based, so page 1 is index 0. */
35
- export type PaginationState = { pageIndex: number; pageSize: number }
36
-
37
- /** Column ids the rows are grouped by, outermost first. */
38
- export type GroupingState = Array<string>
39
-
40
- /** Which rows are expanded, keyed by row id. Absent means collapsed. */
41
- export type ExpandedState = Record<string, boolean>
42
-
43
- /** Which rows are selected, keyed by row id. Absent means unselected. */
44
- export type RowSelectionState = Record<string, boolean>
45
-
46
- /**
47
- * Where keyboard focus sits. The indices address the *displayed* grid (after
48
- * sorting, filtering and paging), not the source data.
49
- */
50
- export type ActiveCellState = {
51
- rowIndex: number
52
- colIndex: number
53
- cellId: string | null
54
- }
55
-
56
- /**
57
- * The set of features a grid has registered, as built by {@link tableFeatures}.
58
- * Deliberately open: a feature is identified by its key, so the type carries
59
- * which ones are on without enumerating them.
60
- */
61
- export type TableFeatures = Record<string, unknown>
62
-
63
- /** A cell's value. Unconstrained - a column can hold anything. */
64
- export type CellData = unknown
65
-
66
- /** What a column's `header` render function receives. */
67
- export type HeaderContext<TData extends RowData> = {
68
- header: Header<TData>
69
- column: Column<TData>
70
- table: SvGrid<TData>
71
- }
72
-
73
- /**
74
- * What a column's `cell` render function receives. `getValue()` applies the
75
- * column's accessor (`field` or `fieldFn`); `row.original` is the raw object.
76
- */
77
- export type CellContext<TData extends RowData> = {
78
- cell: Cell<TData>
79
- row: Row<TData>
80
- column: Column<TData>
81
- table: SvGrid<TData>
82
- getValue: () => unknown
83
- }
84
-
85
- /** Params passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks. */
86
- export type CellSpanParams<TData extends RowData = RowData> = {
87
- /** The row's underlying data object. */
88
- data: TData
89
- /** Display-row index in the current (filtered/sorted) row set. */
90
- rowIndex: number
91
- /** The column's id. */
92
- columnId: string
93
- /** The cell's base value for this column. */
94
- value: unknown
95
- }
96
-
97
- /** The raw option list a column's `editorOptions` can supply. */
98
- export type EditorOptionSource = ReadonlyArray<
99
- string | number | { value: string | number; label?: string; color?: string }
100
- >
101
-
102
- /** Params passed to a column's `valueParser(...)` on edit commit. */
103
- export type ValueParserParams<TData extends RowData = RowData> = {
104
- /** The value after built-in per-`editorType` coercion. */
105
- newValue: unknown
106
- /** The cell's previous value. */
107
- oldValue: unknown
108
- /** The raw string the editor produced (pre-coercion). */
109
- rawInput: string
110
- /** The row's underlying data object. */
111
- data: TData
112
- /** The column's id. */
113
- columnId: string
114
- }
115
-
116
- /**
117
- * Context passed to a custom `cellEditor` snippet/component. Three write
118
- * helpers cover the lifecycle:
119
- *
120
- * - `update(next)` - stage `next` as the draft, keep the editor open.
121
- * Use this for live-preview controls (sliders,
122
- * color pickers) so the user can keep adjusting.
123
- * - `commit(next?)` - write the value AND close the editor. The
124
- * argument is optional; when omitted, the most
125
- * recently `update()`d value is saved. Use this
126
- * for "done" gestures (Enter, picking an option).
127
- * - `cancel()` - discard the draft and close the editor.
128
- */
129
- export type EditorContext<TData extends RowData> = CellContext<TData> & {
130
- value: unknown
131
- update: (next: unknown) => void
132
- commit: (next?: unknown) => void
133
- cancel: () => void
134
- }
135
-
136
- /**
137
- * Declarative cell formatting, applied through `Intl` - number, currency,
138
- * percent, date and datetime. Prefer this over a `formatter` function: it is
139
- * locale-aware, and export and the clipboard reuse the same configuration.
140
- */
141
- export type CellFormatConfig =
142
- | {
143
- type: 'number'
144
- locales?: string | Array<string>
145
- options?: Intl.NumberFormatOptions
146
- }
147
- | {
148
- type: 'currency'
149
- /** ISO 4217 (default USD) */
150
- currency?: string
151
- locales?: string | Array<string>
152
- options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>
153
- }
154
- | {
155
- type: 'percent'
156
- locales?: string | Array<string>
157
- options?: Omit<Intl.NumberFormatOptions, 'style'>
158
- /**
159
- * If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).
160
- * Default false.
161
- */
162
- valueIsPercentPoints?: boolean
163
- }
164
- | {
165
- type: 'date' | 'datetime'
166
- locales?: string | Array<string>
167
- /**
168
- * Shortcut patterns merged with `options`:
169
- * `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,
170
- * `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.
171
- */
172
- pattern?: string
173
- options?: Intl.DateTimeFormatOptions
174
- }
175
-
176
- /**
177
- * A column's custom display function, for anything {@link CellFormatConfig}
178
- * cannot express. Returns a string - to render markup, use `cell` instead.
179
- */
180
- export type CellFormatter<TData extends RowData> = (context: {
181
- value: unknown
182
- row: Row<TData>
183
- column: Column<TData>
184
- table: SvGrid<TData>
185
- }) => string
186
-
187
- /** A header or cell slot: a literal string, or a function returning renderable content. */
188
- export type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)
189
-
190
- /**
191
- * How a column's value is aggregated for a group row when `columnGrouping`
192
- * is active. Built-in reducers cover the common cases; pass a function for
193
- * anything custom (weighted average, median, percentile, distinct count).
194
- * The function receives the finite numeric values AND the raw leaf rows.
195
- */
196
- export type GroupAggregator<TData = any> =
197
- | 'sum'
198
- | 'avg'
199
- | 'min'
200
- | 'max'
201
- | 'count'
202
- | 'countDistinct'
203
- | 'extent'
204
- | 'first'
205
- | ((values: number[], rows: Array<TData>) => unknown)
206
-
207
- /** Apply a group aggregator over a bucket's leaf rows for one column. */
208
- export function applyGroupAggregate<TData extends RowData>(
209
- agg: GroupAggregator<TData>,
210
- columnId: string,
211
- rows: ReadonlyArray<Row<TData>>,
212
- ): unknown {
213
- // One pass, no intermediate arrays.
214
- //
215
- // This used to build a `raw` array, then a coerced one, then a filtered one -
216
- // three allocations per aggregated column PER GROUP - before reducing. On a
217
- // 100k-row grid grouped two levels deep, aggregation was about two thirds of
218
- // the total grouping cost (213ms with three aggregators against 81ms with
219
- // none), and each additional aggregated column added roughly 80ms.
220
- //
221
- // `count` first: it never needs to look at a value at all.
222
- if (agg === 'count') return rows.length
223
-
224
- if (agg === 'first') {
225
- return rows.length ? rows[0]!.getCellValueByColumnId(columnId) : undefined
226
- }
227
-
228
- if (agg === 'countDistinct') {
229
- const seen = new Set<string>()
230
- for (const row of rows) seen.add(String(row.getCellValueByColumnId(columnId) ?? ''))
231
- return seen.size
232
- }
233
-
234
- if (typeof agg === 'function') {
235
- // Custom aggregators keep their contract: the finite numbers, then the
236
- // original row objects. Note `Number(null)` is 0 and therefore finite, so
237
- // nulls DO reach the callback as zeros - long-standing behaviour.
238
- const nums: number[] = []
239
- for (const row of rows) {
240
- const n = Number(row.getCellValueByColumnId(columnId))
241
- if (Number.isFinite(n)) nums.push(n)
242
- }
243
- return agg(nums, rows.map((r) => r.original))
244
- }
245
-
246
- // sum / avg / min / max / extent share one accumulation pass.
247
- let count = 0
248
- let sum = 0
249
- let min = Infinity
250
- let max = -Infinity
251
- for (const row of rows) {
252
- const n = Number(row.getCellValueByColumnId(columnId))
253
- if (!Number.isFinite(n)) continue
254
- count++
255
- // Left-to-right, matching the previous `reduce`, so float rounding is
256
- // bit-identical rather than merely close.
257
- sum += n
258
- // Math.min/max on scalars rather than `<`, which differs on -0, and rather
259
- // than the old `Math.min(...nums)` - spreading a whole group throws
260
- // RangeError once the bucket is big enough to exhaust the argument stack.
261
- min = Math.min(min, n)
262
- max = Math.max(max, n)
263
- }
264
- if (!count) return undefined
265
- switch (agg) {
266
- case 'sum':
267
- return sum
268
- case 'avg':
269
- return sum / count
270
- case 'min':
271
- return min
272
- case 'max':
273
- return max
274
- case 'extent':
275
- return `${min} – ${max}`
276
- default:
277
- return undefined
278
- }
279
- }
280
-
281
- /**
282
- * A column definition.
283
- *
284
- * `TFeatures` is a phantom parameter - it is threaded through nested
285
- * `columns` groups but no member depends on it, so `{}`, `TableFeatures` and
286
- * `typeof features` are all interchangeable here. It is deliberately left
287
- * WITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot
288
- * and silently type your data as `RowData`, losing every field-name check.
289
- * Prefer {@link GridColumns} / {@link GridColumnDef} for the common case.
290
- */
291
- export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
292
- id?: string
293
- field?: keyof TData & string
294
- fieldFn?: (row: TData) => unknown
295
- header?: ColumnDefTemplate<HeaderContext<TData>>
296
- footer?: ColumnDefTemplate<HeaderContext<TData>>
297
- cell?: ColumnDefTemplate<CellContext<TData>>
298
- columns?: Array<ColumnDef<TFeatures, TData>>
299
- /**
300
- * Declarative cell spanning (merged cells). Return how many COLUMNS this
301
- * cell spans to the right (1 = no span). Value-driven. Feed
302
- * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
303
- * the same real `colspan`/`rowspan` merge engine (no separate code path).
304
- */
305
- colSpan?: (params: CellSpanParams<TData>) => number
306
- /**
307
- * Declarative cell spanning (merged cells). Return how many ROWS this cell
308
- * spans downward (1 = no span). See `colSpan` for how to apply.
309
- */
310
- rowSpan?: (params: CellSpanParams<TData>) => number
311
- /**
312
- * High-level data type for the column. A convenience that resolves to the
313
- * right `editorType`, alignment, date `format`, and filter operators without
314
- * setting each by hand:
315
- * 'text' → text editor, left-aligned
316
- * 'number' → number editor, right-aligned, numeric filter operators
317
- * 'boolean' → checkbox editor, centered
318
- * 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format
319
- * 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')
320
- * Anything you set explicitly (`editorType`, `align`, `format`) still wins -
321
- * `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers
322
- * this from the first data row for columns that declare neither.
323
- */
324
- cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'
325
- /**
326
- * Hide this column when the grid's `responsive` mode is on and the grid is
327
- * narrower than this many pixels - drop low-priority columns on small
328
- * screens. No effect unless the grid has `responsive` set.
329
- */
330
- hideBelow?: number
331
- /**
332
- * For a column INSIDE a collapsible column group: `'open'` shows this column
333
- * only while the group is expanded, `'closed'` only while collapsed. Omit to
334
- * always show it. Setting it on any direct child gives the parent group a
335
- * collapse toggle. Pair with `openByDefault` on the group.
336
- */
337
- columnGroupShow?: 'open' | 'closed'
338
- /**
339
- * For a GROUP column (one with `columns: [...]`): start the group expanded.
340
- * Defaults to `false` (collapsed), the conventional default - so only the always-on
341
- * and `columnGroupShow: 'closed'` children show until the user expands it.
342
- */
343
- openByDefault?: boolean
344
- editorType?:
345
- | 'text'
346
- | 'number'
347
- | 'date' // rich SvCalendar popover (opt out with 'date-native')
348
- | 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
349
- | 'time' // rich SvTimePicker dial (opt out with 'time-native')
350
- | 'date-native' // plain <input type="date">
351
- | 'datetime-native' // plain <input type="datetime-local">
352
- | 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
353
- | 'password' // native <input type="password"> with masked rendering
354
- | 'checkbox'
355
- | 'list'
356
- | 'chips'
357
- | 'select' // custom dropdown - single value, no typeahead
358
- | 'rich-select' // custom dropdown with a typeahead search input
359
- | 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)
360
- | 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline
361
- | 'color' // native <input type="color"> swatch
362
- | 'rating' // 5-star rating control
363
- // Any other string names a CUSTOM editor registered via `registerCellEditor`
364
- // (or `registerBuiltinEditors`). `(string & {})` keeps the literals above
365
- // autocompleting while allowing arbitrary custom type names.
366
- | (string & {})
367
- /**
368
- * Custom in-cell editor. Receives the cell context PLUS a `commit(value)`
369
- * and `cancel()` helper. Use when none of the built-in `editorType`s fit;
370
- * the snippet's outer element is mounted inside the editing cell and
371
- * inherits keyboard handling (Esc cancels, Enter commits unless your
372
- * snippet preventDefaults it).
373
- *
374
- * Coexists with `editorType`: when both are set, `cellEditor` wins and
375
- * `editorType` is treated as a hint for parsing the saved value.
376
- */
377
- cellEditor?: ColumnDefTemplate<EditorContext<TData>>
378
- /**
379
- * Per-column tooltip. String shows as a native `title=`; `(ctx) => string`
380
- * runs per cell so the tooltip can reflect the value. Returning an empty
381
- * string skips the tooltip.
382
- */
383
- tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
384
- /**
385
- * Declarative per-cell validation. Runs for EVERY
386
- * rendered cell - including values already present in `data` on load, not
387
- * just on edit - so bad data is flagged immediately. Invalid cells get the
388
- * `sv-grid-cell-invalid` class (red highlight) and the returned message as
389
- * their tooltip.
390
- *
391
- * Return value:
392
- * - `null` / `undefined` / `true` → valid (no highlight)
393
- * - `false` → invalid, no message
394
- * - a non-empty `string` → invalid, string is the tooltip
395
- *
396
- * The value keeps rendering as-is (the grid does NOT roll it back); pair
397
- * with `onCellValueChange` if you also want to reject the commit.
398
- */
399
- validate?: (params: {
400
- value: unknown
401
- row: TData
402
- rowIndex: number
403
- column: Column<TData>
404
- }) => string | boolean | null | undefined
405
- /**
406
- * Gate editing per column or per cell.
407
- *
408
- * - `true` (or omitted): the column is fully editable.
409
- * - `false`: the column is read-only - double-click, type-to-edit,
410
- * fill-handle drag, Delete, and clipboard paste all skip it.
411
- * - `(ctx) => boolean`: evaluated for each cell, so you can lock
412
- * individual rows (e.g. by role, status, ownership). Returning
413
- * `false` opts the cell out of every editing path, identical to
414
- * setting `editable: false` on the whole column for that row.
415
- *
416
- * The grid-wide `enableInlineEditing` prop still wins when set to
417
- * `false`.
418
- */
419
- editable?: boolean | ((context: CellContext<TData>) => boolean)
420
- /**
421
- * Transform the committed edit value before it is written to the row.
422
- * Runs after the built-in per-`editorType` coercion, so `newValue` is
423
- * already type-parsed; return the final value to store (e.g. round a
424
- * number, uppercase a code, look up an id). A `valueParser` hook.
425
- */
426
- valueParser?: (params: ValueParserParams<TData>) => unknown
427
- /**
428
- * Briefly flash / highlight this column's cell when its value changes
429
- * (streaming feeds, edits, server pushes). `true` uses the default flash;
430
- * pass `{ className }` to apply your own animation class instead.
431
- */
432
- cellFlash?: boolean | { className?: string }
433
- /**
434
- * When `false`, this column never shows a sort indicator and clicking
435
- * its header is a no-op - `api.setSort(thisColumn, ...)` is also
436
- * ignored. Defaults to `true` (the column participates in sorting as
437
- * long as `rowSortingFeature` is registered).
438
- */
439
- sortable?: boolean
440
- /**
441
- * When `false`, this column never shows a filter funnel / menu and
442
- * `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the
443
- * column is filterable as long as `columnFilteringFeature` is
444
- * registered).
445
- */
446
- filterable?: boolean
447
- /**
448
- * Options for `editorType: 'list' | 'chips'`. Either bare values (the
449
- * string is both value and label) or `{ value, label }` objects.
450
- * For `chips` this is optional - when omitted, the chips editor becomes
451
- * free-form (user types and presses Enter to commit a chip).
452
- *
453
- * Pass a function `(row) => options` for row-dependent (cascading)
454
- * options - e.g. City options that depend on Country in the same row.
455
- *
456
- * Either form may return a **Promise**, for options that come from the
457
- * server. While it resolves, the editor shows a loading state and the cell
458
- * renders its raw value.
459
- *
460
- * Results are cached so reopening an editor does not refetch: a static source
461
- * per column, a per-row source per row AND per that row's data - so a cascade
462
- * reloads by itself when the cell it depends on is edited. Call
463
- * `api.refreshEditorOptions(columnId?)` when the list changes server-side.
464
- */
465
- editorOptions?:
466
- | EditorOptionSource
467
- | Promise<EditorOptionSource>
468
- | ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)
469
- /** When true, list/chips allow multiple selections. Cell value becomes an array. */
470
- editorMultiple?: boolean
471
- /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
472
- editorSeparator?: string
473
- format?: CellFormatConfig
474
- formatter?: CellFormatter<TData>
475
- /**
476
- * Aggregate this column's values into the group row when grouping is
477
- * active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |
478
- * 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result
479
- * is formatted with this column's `format` and shown in the group header.
480
- */
481
- aggregate?: GroupAggregator<TData>
482
- /**
483
- * What this column contributes to the grid's footer summary row (the one
484
- * turned on with `summary` / `enableRowSummaries`). Takes the same
485
- * aggregators as {@link aggregate}, and the result is formatted with this
486
- * column's `format`.
487
- *
488
- * Without it the footer falls back to its default: the sum of a numeric
489
- * column, `Count: N` otherwise. Set `false` to leave the cell blank, which is
490
- * usually what an actions or checkbox column wants.
491
- *
492
- * { field: 'amount', summary: 'avg' }
493
- * { id: 'actions', summary: false }
494
- */
495
- summary?: GroupAggregator<TData> | false
496
- /**
497
- * Render the cell as an in-cell sparkline chart. The cell value should be
498
- * an array of numbers (or a comma/space separated string). Mutually
499
- * exclusive with a custom `cell` renderer (a `cell` wins if both are set).
500
- *
501
- * { sparkline: { type: 'line' } } // default line
502
- * { sparkline: { type: 'bar', color: '#16a34a' } }
503
- * { sparkline: { type: 'winloss' } } // sign-only up/down
504
- *
505
- * See `SparklineConfig` for the full option set (type, color,
506
- * negativeColor, width, height, fixed min/max).
507
- */
508
- sparkline?: SparklineConfig
509
- /** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */
510
- width?: number
511
- /**
512
- * Whether the user may resize this column. Only consulted when the grid has
513
- * `columnResize` on - it narrows that, it does not enable anything.
514
- *
515
- * `false` removes the column's drag handle entirely, so pointer drag, the
516
- * keyboard arrows and double-click-to-autosize are all gone with it, and the
517
- * column menu drops its Autosize item. Use it for the columns whose width is
518
- * part of the layout rather than a preference: a row-number gutter, a
519
- * checkbox column, a fixed icon column.
520
- *
521
- * Programmatic sizing is unaffected - `api.autosizeColumn()`,
522
- * `api.setColumnWidth()` and `fitColumns` all still apply, the same way they
523
- * do when `columnResize` is off. This governs the user affordance only.
524
- */
525
- resizable?: boolean
526
- /**
527
- * Initial visibility. Set `false` to start the column hidden while still
528
- * listing it in the Choose Columns UI for the user to re-enable. Applied
529
- * once at mount; after that `api.setColumnVisible` / user toggles win.
530
- * On a group column, `false` hides the whole group's leaf columns.
531
- */
532
- visible?: boolean
533
- /**
534
- * Horizontal alignment for header and body cells. When omitted, the
535
- * default is inferred from `editorType`:
536
- * - `'number' | 'date' | 'datetime'` → `'right'`
537
- * - `'checkbox'` → `'center'`
538
- * - everything else → `'left'`
539
- */
540
- align?: 'left' | 'center' | 'right'
541
- /**
542
- * Per-cell conditional CSS. Two shapes:
543
- *
544
- * - **String** (or array of strings): class name(s) added to the
545
- * cell's `<td>` for every row in this column.
546
- * - **Function**: invoked per cell with the same `CellContext` shape
547
- * the `cell` renderer receives. Return a string, an array of
548
- * strings, or an object mapping class names to booleans.
549
- *
550
- * Use it for status tinting, conditional bold, "negative number"
551
- * coloring - anything that's a function of the row's value. Cells
552
- * still receive their format / cell renderer; the class just
553
- * augments the rendered `<td>`.
554
- */
555
- cellClass?:
556
- | string
557
- | ReadonlyArray<string>
558
- | ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
559
- }
560
-
561
- /**
562
- * A column definition keyed only by your row type - the ergonomic form of
563
- * {@link ColumnDef}, whose first parameter is a phantom feature bag that is
564
- * almost always `{}`.
565
- *
566
- * ```ts
567
- * const columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]
568
- * ```
569
- *
570
- * Interchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,
571
- * TData>` in both directions, so it mixes freely with existing code.
572
- */
573
- export type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>
574
-
575
- /** An array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`. */
576
- export type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>
577
-
578
- /**
579
- * A resolved column: your {@link ColumnDef} plus everything the grid computed
580
- * from it - its id, its depth under any group header, and the sort handlers a
581
- * header needs. This is what you receive in render contexts; the `ColumnDef`
582
- * is what you wrote.
583
- */
584
- export type Column<TData extends RowData> = {
585
- id: string
586
- columnDef: ColumnDef<any, TData>
587
- depth: number
588
- parentId?: string
589
- getCanSort: () => boolean
590
- getCanFilter: () => boolean
591
- getIsSorted: () => false | 'asc' | 'desc'
592
- getToggleSortingHandler: () => () => void
593
- }
594
-
595
- /**
596
- * One header cell. `colSpan` is how many leaf columns it covers, and
597
- * `isPlaceholder` marks the empty cells that pad a group-header row so the
598
- * levels line up.
599
- */
600
- export type Header<TData extends RowData> = {
601
- id: string
602
- isPlaceholder: boolean
603
- colSpan: number
604
- column: Column<TData>
605
- getContext: () => HeaderContext<TData>
606
- }
607
-
608
- /** One row of header cells. A grid with grouped columns has several, outermost first. */
609
- export type HeaderGroup<TData extends RowData> = {
610
- id: string
611
- headers: Array<Header<TData>>
612
- }
613
-
614
- /** One cell: the intersection of a {@link Row} and a {@link Column}. */
615
- export type Cell<TData extends RowData> = {
616
- id: string
617
- row: Row<TData>
618
- column: Column<TData>
619
- getValue: () => unknown
620
- getContext: () => CellContext<TData>
621
- }
622
-
623
- /**
624
- * A row in the display model. `original` is your untouched data object;
625
- * everything else is grid-computed. `index` is the position in the displayed
626
- * set, so it shifts as sorting and filtering change - key on `id`, not index.
627
- *
628
- * Group rows and tree parents carry `subRows`; a plain data row does not.
629
- */
630
- export type Row<TData extends RowData> = {
631
- id: string
632
- index: number
633
- original: TData
634
- depth: number
635
- subRows?: Array<Row<TData>>
636
- /** Total leaf (data) rows under this group row. Undefined for data rows. */
637
- leafCount?: number
638
- getCanExpand: () => boolean
639
- getIsExpanded: () => boolean
640
- toggleExpanded: () => void
641
- getIsSelected: () => boolean
642
- toggleSelected: () => void
643
- getAllCells: () => Array<Cell<TData>>
644
- getCellValueByColumnId: (columnId: string) => unknown
645
- }
646
-
647
- /** The output of the row pipeline: the rows to display, in order. */
648
- export type RowModel<TData extends RowData> = {
649
- rows: Array<Row<TData>>
650
- }
651
-
652
- /**
653
- * The minimal reactive store behind the headless core - read `state`, write
654
- * through `setState`, and `subscribe` for changes. Deliberately framework
655
- * free, which is what lets the core run under plain Node.
656
- *
657
- * In Svelte you rarely touch this: `subscribeGrid` wraps it with fine-grained
658
- * selectors so a component only re-runs for the slice it read.
659
- */
660
- export type Store<T> = {
661
- readonly state: T
662
- setState: (updater: (prev: T) => T) => void
663
- subscribe: (listener: () => void) => () => void
664
- }
665
-
666
- function createStore<T>(initial: T): Store<T> {
667
- let value = initial
668
- const listeners = new Set<() => void>()
669
- return {
670
- get state() {
671
- return value
672
- },
673
- setState(updater) {
674
- value = updater(value)
675
- listeners.forEach((listener) => listener())
676
- },
677
- subscribe(listener) {
678
- listeners.add(listener)
679
- return () => listeners.delete(listener)
680
- },
681
- }
682
- }
683
-
684
- /**
685
- * Click-to-sort. Injected by the `sortable` shortcut.
686
- *
687
- * This and the five features below are opaque markers: pass the ones you want
688
- * to {@link tableFeatures} and the grid wires up the matching row model. With
689
- * `<SvGrid>` you rarely name them - the boolean shortcuts (`sortable`,
690
- * `filterable`, `pageable`, `groupable`) inject them for you. Reach for them
691
- * directly when driving the headless core, or when you want a feature on
692
- * without its UI.
693
- *
694
- * The names match TanStack Table v9, so a features object written for it works
695
- * here unchanged.
696
- */
697
- export const rowSortingFeature = { key: 'rowSortingFeature' }
698
- /** Per-column filtering. Injected by the `filterable` shortcut. */
699
- export const columnFilteringFeature = { key: 'columnFilteringFeature' }
700
- /** Paging of the row model. Injected by the `pageable` shortcut. */
701
- export const rowPaginationFeature = { key: 'rowPaginationFeature' }
702
- /** Row grouping with aggregation. Injected by the `groupable` shortcut. */
703
- export const columnGroupingFeature = { key: 'columnGroupingFeature' }
704
- /** Row selection state (the checkbox column reads it). */
705
- export const rowSelectionFeature = { key: 'rowSelectionFeature' }
706
- /** Expand / collapse, for tree rows and master-detail. */
707
- export const rowExpandingFeature = { key: 'rowExpandingFeature' }
708
-
709
- /**
710
- * Declare which features a grid uses. Identity at runtime - its whole job is to
711
- * capture the exact set in the type, so `ColumnDef<typeof features, Row>` knows
712
- * what is registered and anything you did not register is tree-shaken out.
713
- *
714
- * ```ts
715
- * const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
716
- * ```
717
- *
718
- * Same call signature as TanStack Table v9, so a features object written for it
719
- * transfers unchanged.
720
- */
721
- export function tableFeatures<T extends TableFeatures>(features: T): T {
722
- return features
723
- }
724
-
725
- /**
726
- * Built-in comparators, chosen per column by its data type. `auto` compares as
727
- * text; set a column's type or supply your own comparator to override.
728
- */
729
- export const sortFns = {
730
- auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
731
- number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
732
- date: (a: unknown, b: unknown) => {
733
- const aa = new Date(a as any).getTime()
734
- const bb = new Date(b as any).getTime()
735
- return aa - bb
736
- },
737
- }
738
-
739
- /**
740
- * Built-in match functions, named by {@link ColumnFilter}'s `fn`.
741
- * `includesString` is case-insensitive substring; `equals` is strict identity.
742
- */
743
- export const filterFns = {
744
- includesString: (value: unknown, query: string) =>
745
- String(value).toLowerCase().includes(query.toLowerCase()),
746
- equals: (value: unknown, query: unknown) => value === query,
747
- }
748
-
749
- /**
750
- * Everything a base row needs that is the same for every row in the table.
751
- *
752
- * One object per table, referenced by every row, instead of one closure scope
753
- * per row. See {@link BASE_ROW_METHODS}.
754
- */
755
- type BaseRowCtx<TData extends RowData> = {
756
- grid: SvGrid<TData>
757
- store: { state: Record<string, any> }
758
- columns: Array<Column<TData>>
759
- columnCount: number
760
- columnIndexById: Map<string, number>
761
- }
762
-
763
- /**
764
- * Keys for a base row's private fields.
765
- *
766
- * Symbols, not string keys, and that is load-bearing. A row's shared methods
767
- * need a pointer back to the table, but `_ctx` as a normal property made every
768
- * row serialise the entire grid: `JSON.stringify(oneRow)` grew with the dataset
769
- * (981 chars at 3 rows, 67,719 at 3,000) because `options.data` is reachable
770
- * through it, so stringifying a row model was quadratic. Rows used to serialise
771
- * to a small constant and must again.
772
- *
773
- * A symbol key is invisible to `JSON.stringify`, `Object.keys` and `for...in`,
774
- * yet IS copied by object spread - which matters because several row models
775
- * legitimately do `{ ...row, depth }` and the clone needs these to work.
776
- * Non-enumerable string keys would have hidden them from JSON but also from the
777
- * spread, silently breaking every cloned row.
778
- */
779
- const ROW_CTX = Symbol('svgrid.row.ctx')
780
- const ROW_VALUES = Symbol('svgrid.row.values')
781
- const ROW_CELLS = Symbol('svgrid.row.cells')
782
-
783
- /** A base row's private fields, on top of the public {@link Row} surface. */
784
- type BaseRowState<TData extends RowData> = Row<TData> & {
785
- [ROW_CTX]: BaseRowCtx<TData>
786
- [ROW_VALUES]: Array<unknown> | null
787
- [ROW_CELLS]: Array<Cell<TData>> | null
788
- }
789
-
790
- /**
791
- * The methods every base row carries, defined ONCE and assigned by reference.
792
- *
793
- * Rows used to be built as object literals whose methods were closures, which
794
- * meant a 100k-row grid allocated 700k closures and a closure scope per row
795
- * before painting anything. Measured at 100k x 9: 13.8 ms and 56.5 MB to build,
796
- * against 2.0 ms and 14.5 MB for this shape - the single largest cost in
797
- * mounting a large grid.
798
- *
799
- * They read their row through `this` rather than a captured variable, which is
800
- * why they can be shared. Note they are assigned as OWN properties rather than
801
- * put on a prototype: `Row` is public, several row models legitimately do
802
- * `{ ...row, depth }`, and a spread copies own properties but not a prototype.
803
- * A class here would silently strip every method off a cloned row.
804
- */
805
- const BASE_ROW_METHODS = {
806
- getCanExpand(this: BaseRowState<RowData>) {
807
- return false
808
- },
809
- getIsExpanded(this: BaseRowState<RowData>) {
810
- return Boolean((this[ROW_CTX].store.state.expanded ?? {})[this.id])
811
- },
812
- toggleExpanded(this: BaseRowState<RowData>) {
813
- const id = this.id
814
- this[ROW_CTX].grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
815
- },
816
- getIsSelected(this: BaseRowState<RowData>) {
817
- return Boolean((this[ROW_CTX].store.state.rowSelection ?? {})[this.id])
818
- },
819
- toggleSelected(this: BaseRowState<RowData>) {
820
- const id = this.id
821
- this[ROW_CTX].grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
822
- },
823
- getAllCells(this: BaseRowState<RowData>) {
824
- return this[ROW_CELLS] ?? buildBaseRowCells(this)
825
- },
826
- getCellValueByColumnId(this: BaseRowState<RowData>, columnId: string) {
827
- const idx = this[ROW_CTX].columnIndexById.get(columnId)
828
- if (idx === undefined) return undefined
829
- if (!this[ROW_VALUES]) this[ROW_VALUES] = baseRowValues(this)
830
- return this[ROW_VALUES][idx]
831
- },
832
- }
833
-
834
- /**
835
- * Materialise one row's `Cell[]`, memoised on the row.
836
- *
837
- * A free function taking the row rather than a method using `this`, because the
838
- * cell closures need a stable reference to it and aliasing `this` inside a
839
- * method is exactly the pattern that produces `self`/`that` bugs.
840
- */
841
- function buildBaseRowCells<TData extends RowData>(row: BaseRowState<TData>): Array<Cell<TData>> {
842
- const { columns, columnCount, grid } = row[ROW_CTX]
843
- const built = new Array<Cell<TData>>(columnCount)
844
- for (let i = 0; i < columnCount; i++) {
845
- const column = columns[i]!
846
- const colIndex = i
847
- const cell: Cell<TData> = {
848
- id: `${row.id}_${column.id}`,
849
- row,
850
- column,
851
- getValue: () => {
852
- if (!row[ROW_VALUES]) row[ROW_VALUES] = baseRowValues(row)
853
- return row[ROW_VALUES][colIndex]
854
- },
855
- getContext: () => ({
856
- cell,
857
- row,
858
- column,
859
- table: grid,
860
- getValue: () => cell.getValue(),
861
- }),
862
- }
863
- built[i] = cell
864
- }
865
- row[ROW_CELLS] = built
866
- return built
867
- }
868
-
869
- /**
870
- * Resolve every column's value for one row. Kept lazy: a 100k-row grid showing
871
- * twenty rows must not materialise 900k values to paint.
872
- */
873
- function baseRowValues<TData extends RowData>(row: BaseRowState<TData>): Array<unknown> {
874
- const { columns, columnCount } = row[ROW_CTX]
875
- const original = row.original as Record<string, unknown>
876
- const values = new Array<unknown>(columnCount)
877
- for (let i = 0; i < columnCount; i++) {
878
- const def = columns[i]!.columnDef
879
- if (def.fieldFn) values[i] = def.fieldFn(original as TData)
880
- else if (def.field) values[i] = original[def.field]
881
- else values[i] = undefined
882
- }
883
- return values
884
- }
885
-
886
- /**
887
- * One stage of the row pipeline: takes the rows produced so far and returns the
888
- * next set. Stages compose in the order given to `_rowModels`, so filtering
889
- * before sorting sorts only what survived the filter.
890
- */
891
- export type RowModelFactory<TData extends RowData> = (args: {
892
- table: SvGrid<TData>
893
- rows: Array<Row<TData>>
894
- }) => Array<Row<TData>>
895
-
896
- /**
897
- * The identity stage that starts every pipeline. Always required, even when no
898
- * other stage is: it is what turns your data into rows.
899
- */
900
- export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {
901
- return ({ rows }) => rows
902
- }
903
- /**
904
- * Drops rows that fail the active {@link ColumnFiltersState}. Pairs with
905
- * `columnFilteringFeature`; without it there are no filters to apply.
906
- */
907
- export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
908
- return ({ table, rows }) => {
909
- const filters: ColumnFiltersState = table.getState().columnFilters ?? []
910
- if (!filters.length) return rows
911
-
912
- // Resolve each filter's match function once, outside the row loop.
913
- const compiled = filters.map((filter) => ({
914
- id: filter.id,
915
- value: filter.value,
916
- fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,
917
- }))
918
-
919
- return rows.filter((row) => {
920
- for (let i = 0; i < compiled.length; i++) {
921
- const filter = compiled[i]!
922
- // `getCellValueByColumnId` rather than `getAllCells().find(...)`.
923
- // Both read the same lazily-built `cachedValues` array, but the latter
924
- // also builds and caches the row's whole `Cell[]` - one object per
925
- // column - purely to reach one field. On a 100k-row grid that is
926
- // 100,000 cell arrays the filter never looks at again, and it defeats
927
- // the laziness the row factory exists to provide.
928
- if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false
929
- }
930
- return true
931
- })
932
- }
933
- }
934
- /**
935
- * Narrows the rows to the current page. Put it LAST: anything after it would
936
- * only ever see one page of data.
937
- */
938
- export function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {
939
- return ({ table, rows }) => {
940
- const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }
941
- const start = pagination.pageIndex * pagination.pageSize
942
- return rows.slice(start, start + pagination.pageSize)
943
- }
944
- }
945
- /**
946
- * Buckets rows by the active {@link GroupingState} and inserts a group row
947
- * ahead of each bucket, carrying that bucket's aggregates.
948
- */
949
- export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {
950
- return ({ table, rows }) => {
951
- const grouping: GroupingState = table.getState().grouping ?? []
952
- if (!grouping.length) return rows
953
- const columns = table.getAllColumns()
954
-
955
- // Recursively bucket rows by each grouping column in turn. At every level a
956
- // group row is built that stands in for its children - a non-group column
957
- // resolves to the value shared by every leaf row, or to undefined when the
958
- // leaves disagree.
959
- function buildGroups(
960
- input: Array<Row<TData>>,
961
- levelIndex: number,
962
- depth: number,
963
- idPrefix: string,
964
- /** Grouping columns already fixed by an ancestor bucket, and their raw
965
- * values - `undefined` where that bucket mixed several. */
966
- fixedValues: ReadonlyMap<string, unknown>,
967
- ): Array<Row<TData>> {
968
- if (levelIndex >= grouping.length) {
969
- // Leaves: actual data rows, with their nesting depth recorded.
970
- return input.map((row) => ({ ...row, depth }))
971
- }
972
- const groupKey = grouping[levelIndex]
973
- if (!groupKey) return input
974
-
975
- // Buckets carry the RAW grouping value alongside the rows, plus whether
976
- // the bucket saw more than one distinct raw value. Both are needed to let
977
- // deeper levels skip re-scanning this column: buckets are keyed by
978
- // `String(value ?? '')`, so `null`, `undefined` and `''` collapse into one
979
- // bucket, and a scan of such a bucket would report disagreement. Tracking
980
- // it here costs one comparison per row and keeps the shortcut honest.
981
- type Bucket = { rows: Array<Row<TData>>; raw: unknown; mixed: boolean }
982
- const buckets = new Map<string, Bucket>()
983
- for (const row of input) {
984
- const value = row.getCellValueByColumnId(groupKey)
985
- const key = String(value ?? '')
986
- const bucket = buckets.get(key)
987
- if (bucket) {
988
- bucket.rows.push(row)
989
- if (!bucket.mixed && bucket.raw !== value) bucket.mixed = true
990
- } else {
991
- buckets.set(key, { rows: [row], raw: value, mixed: false })
992
- }
993
- }
994
-
995
- const groupRows: Array<Row<TData>> = []
996
- let index = 0
997
- buckets.forEach((bucket, key) => {
998
- const children = bucket.rows
999
- const id = `${idPrefix}_${groupKey}_${key}`
1000
- // Record this column as fixed for deeper levels ONLY when the bucket is
1001
- // homogeneous. If all rows here share a raw value, so does every subset
1002
- // of them, which is what makes the shortcut sound.
1003
- //
1004
- // A MIXED bucket must not be recorded at all - not even as "undefined".
1005
- // `null`, `undefined` and `''` share a bucket key, so a mixed bucket can
1006
- // still split into homogeneous children one level down, and those
1007
- // children have a real shared value that a scan would find. Marking the
1008
- // column resolved here would hand them the parent's disagreement.
1009
- const nextFixed = bucket.mixed ? fixedValues : new Map(fixedValues).set(groupKey, bucket.raw)
1010
- const subRows = buildGroups(children, levelIndex + 1, depth + 1, id, nextFixed)
1011
- const isDeepest = levelIndex + 1 >= grouping.length
1012
- const leafCount = isDeepest
1013
- ? subRows.length
1014
- : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)
1015
-
1016
- // Every grouping column ABOVE this level is already resolved: bucketing
1017
- // by it is what made it constant, so scanning the children to rediscover
1018
- // it is pure waste. Only the current level's key short-circuited before,
1019
- // so a second-level group walked all of its children to re-derive the
1020
- // first level's value - about 100,000 reads on the 100k x 9 two-level
1021
- // case, for an answer already in hand.
1022
- //
1023
- // The current level still returns the stringified bucket key rather than
1024
- // the raw value, because that is what it has always returned and the
1025
- // group row's display depends on it.
1026
- const resolveColumnValue = (columnId: string): unknown => {
1027
- if (columnId === groupKey) return key
1028
- if (fixedValues.has(columnId)) return fixedValues.get(columnId)
1029
- let resolved: unknown
1030
- let hasResolved = false
1031
- for (const child of children) {
1032
- const childValue = child.getCellValueByColumnId(columnId)
1033
- if (!hasResolved) {
1034
- resolved = childValue
1035
- hasResolved = true
1036
- } else if (childValue !== resolved) {
1037
- return undefined
1038
- }
1039
- }
1040
- return resolved
1041
- }
1042
-
1043
- const groupOriginal: Record<string, unknown> = {}
1044
- columns.forEach((column) => {
1045
- const field = column.columnDef.field
1046
- if (!field) return
1047
- const agg = column.columnDef.aggregate
1048
- groupOriginal[field] = agg
1049
- ? applyGroupAggregate(agg, column.id, children)
1050
- : resolveColumnValue(column.id)
1051
- })
1052
-
1053
- const groupRow: Row<TData> = {
1054
- id,
1055
- index: index++,
1056
- original: groupOriginal as TData,
1057
- depth,
1058
- subRows,
1059
- leafCount,
1060
- getCanExpand: () => true,
1061
- getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
1062
- toggleExpanded: () => {
1063
- table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
1064
- },
1065
- getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),
1066
- toggleSelected: () => {
1067
- table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
1068
- },
1069
- getAllCells: () => [],
1070
- // Prefer the precomputed group value (which carries aggregates)
1071
- // and fall back to the shared-value resolver for columns without
1072
- // a field.
1073
- getCellValueByColumnId: (columnId: string) => {
1074
- const col = columns.find((c) => c.id === columnId)
1075
- const field = col?.columnDef.field
1076
- if (field && field in groupOriginal) return groupOriginal[field]
1077
- return resolveColumnValue(columnId)
1078
- },
1079
- }
1080
- groupRows.push(groupRow)
1081
- })
1082
- return groupRows
1083
- }
1084
-
1085
- return buildGroups(rows, 0, 0, 'group', new Map())
1086
- }
1087
- }
1088
- /**
1089
- * How to read a hierarchy out of FLAT rows: each row names its parent, and the
1090
- * grid reconstructs the tree. Rows whose parent id matches nothing become roots
1091
- * rather than disappearing.
1092
- *
1093
- * For nested source data (`children: [...]`), flatten it first with
1094
- * {@link flattenTreeData}.
1095
- */
1096
- export type TreeRowModelOptions = {
1097
- /** Field holding each row's parent id. Rows with no parent are roots. */
1098
- parentField: string
1099
- /** Field holding the row's own id. Defaults to `'id'`. */
1100
- idField?: string
1101
- }
1102
-
1103
- /**
1104
- * Client-side tree data: nest the grid's own flat rows into a parent/child
1105
- * hierarchy that `createExpandedRowModel` then walks.
1106
- *
1107
- * This works on the rows the grid already built rather than on raw data, so
1108
- * tree rows keep their cells, editing, selection and formatting - they are real
1109
- * data rows that happen to have children, not synthetic banners like grouping's.
1110
- * That is also why the model is parent-id based: nested source arrays never
1111
- * become rows (the grid only builds rows for `data`), so nested input is
1112
- * flattened first with {@link flattenTreeData}. One code path, no duplicated
1113
- * row construction.
1114
- *
1115
- * Rows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable
1116
- * data row for a full-width group banner.
1117
- */
1118
- export function createTreeRowModel<TData extends RowData>(
1119
- options: TreeRowModelOptions,
1120
- ): RowModelFactory<TData> {
1121
- const { parentField, idField = 'id' } = options
1122
- return ({ table, rows }) => {
1123
- if (!rows.length) return rows
1124
- const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]
1125
- const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]
1126
-
1127
- const present = new Set<unknown>()
1128
- for (const row of rows) present.add(keyOf(row))
1129
-
1130
- const childrenByParent = new Map<unknown, Array<Row<TData>>>()
1131
- const roots: Array<Row<TData>> = []
1132
- for (const row of rows) {
1133
- const parent = parentOf(row)
1134
- // A row whose parent is absent (filtered out, or never existed) becomes a
1135
- // root rather than disappearing - silently dropping rows is worse than a
1136
- // shallower tree. Self-parenting is treated the same way.
1137
- if (parent == null || parent === keyOf(row) || !present.has(parent)) {
1138
- roots.push(row)
1139
- continue
1140
- }
1141
- const list = childrenByParent.get(parent) ?? []
1142
- list.push(row)
1143
- childrenByParent.set(parent, list)
1144
- }
1145
-
1146
- // Guards a cycle in the parent chain from recursing forever.
1147
- const seen = new Set<unknown>()
1148
- const build = (row: Row<TData>, depth: number): Row<TData> => {
1149
- const key = keyOf(row)
1150
- const id = row.id
1151
- if (seen.has(key)) {
1152
- return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>
1153
- }
1154
- seen.add(key)
1155
- const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))
1156
- return {
1157
- ...row,
1158
- depth,
1159
- subRows,
1160
- leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),
1161
- __treeRow: true,
1162
- getCanExpand: () => subRows.length > 0,
1163
- getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
1164
- toggleExpanded: () => {
1165
- table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
1166
- },
1167
- } as Row<TData>
1168
- }
1169
-
1170
- return roots.map((root) => build(root, 0))
1171
- }
1172
- }
1173
-
1174
- /**
1175
- * How to flatten NESTED source data into the parent-id shape tree rows need.
1176
- * `parentField` is written onto each row, so point `treeData.parentField` at
1177
- * the same name afterwards.
1178
- */
1179
- export type FlattenTreeOptions = {
1180
- /** Field holding an array of child objects. */
1181
- childrenField: string
1182
- /** Field holding each object's id. Defaults to `'id'`. */
1183
- idField?: string
1184
- /** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */
1185
- parentField?: string
1186
- }
1187
-
1188
- /**
1189
- * Flatten nested tree data into the flat parent-id shape `createTreeRowModel`
1190
- * consumes, stamping each child with its parent's id.
1191
- *
1192
- * Children are emitted directly after their parent so the natural order already
1193
- * matches the rendered tree. The `childrenField` array is left on the objects
1194
- * (harmless, and callers often still want it); only the parent link is added.
1195
- */
1196
- export function flattenTreeData<T extends RowData>(
1197
- data: ReadonlyArray<T>,
1198
- options: FlattenTreeOptions,
1199
- ): T[] {
1200
- const { childrenField, idField = 'id', parentField = '__parentId' } = options
1201
- const out: T[] = []
1202
- const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {
1203
- for (const node of nodes) {
1204
- const flat = { ...node, [parentField]: parentId } as T
1205
- out.push(flat)
1206
- const kids = (node as any)[childrenField]
1207
- if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])
1208
- }
1209
- }
1210
- walk(data, null)
1211
- return out
1212
- }
1213
-
1214
- /**
1215
- * Hides the descendants of collapsed rows. Needed for grouping, tree data and
1216
- * master-detail alike - all three are the same expand/collapse mechanism.
1217
- */
1218
- export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
1219
- return ({ table, rows }) => {
1220
- const expanded: ExpandedState = table.getState().expanded ?? {}
1221
- const flattened: Array<Row<TData>> = []
1222
- const visit = (row: Row<TData>) => {
1223
- flattened.push(row)
1224
- if (row.subRows?.length && expanded[row.id]) {
1225
- for (const sub of row.subRows) visit(sub)
1226
- }
1227
- }
1228
- for (const row of rows) visit(row)
1229
- return flattened
1230
- }
1231
- }
1232
- /**
1233
- * Orders rows by the active {@link SortingState}. Pass your own comparators to
1234
- * override the built-in {@link sortFns} - useful for locale-aware or
1235
- * domain-specific ordering.
1236
- */
1237
- export function createSortedRowModel<TData extends RowData>(
1238
- localSortFns: typeof sortFns = sortFns,
1239
- ): RowModelFactory<TData> {
1240
- return function sortedRowModelStage({ table, rows }) {
1241
- const sorting = table.getState().sorting ?? []
1242
- if (!sorting.length) return rows
1243
-
1244
- // Resolve every clause ONCE, before sorting.
1245
- //
1246
- // This used to live inside the comparator, so `getAllColumns().find(...)`
1247
- // ran per comparison per clause: a single-clause sort of 100k rows made
1248
- // 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;
1249
- // `pnpm bench --case=sort-1col`). The comparator is called O(n log n)
1250
- // times, so anything inside it that is not O(1) sets the cost of the sort.
1251
- const allColumns = table.getAllColumns()
1252
- const clauses: Array<{
1253
- keys: Array<any>
1254
- desc: boolean
1255
- compare: (a: any, b: any) => number
1256
- }> = []
1257
-
1258
- for (const clause of sorting) {
1259
- const column = allColumns.find((col) => col.id === clause.id)
1260
- if (!column) continue
1261
- const editorType = column.columnDef.editorType
1262
- const comparator =
1263
- editorType === 'number'
1264
- ? localSortFns.number
1265
- : editorType === 'date' || editorType === 'datetime'
1266
- ? localSortFns.date
1267
- : localSortFns.auto
1268
-
1269
- // Precompute one sort key per row, so the comparator reads an array slot
1270
- // instead of walking the row's column index on every comparison. For the
1271
- // three built-in comparators the key is also cheaper to compare than the
1272
- // raw value: a timestamp rather than two `new Date()` allocations, a
1273
- // number rather than two `Number()` coercions, a collator rather than a
1274
- // fresh one per `localeCompare` call.
1275
- //
1276
- // The identity checks against `sortFns` matter: `localSortFns` is a
1277
- // public parameter, so a caller can substitute their own comparators.
1278
- // When they have, we fall through to calling their function with the raw
1279
- // values - still hoisted, just not specialised.
1280
- const columnId = column.id
1281
- const n = rows.length
1282
- let keys: Array<any> = new Array(n)
1283
- let compare: (a: any, b: any) => number
1284
-
1285
- if (comparator === sortFns.number) {
1286
- for (let i = 0; i < n; i++) keys[i] = Number(rows[i]!.getCellValueByColumnId(columnId) ?? 0)
1287
- compare = compareNumericKeys
1288
- } else if (comparator === sortFns.date) {
1289
- for (let i = 0; i < n; i++) {
1290
- keys[i] = new Date(rows[i]!.getCellValueByColumnId(columnId) as any).getTime()
1291
- }
1292
- compare = compareNumericKeys
1293
- } else if (comparator === sortFns.auto) {
1294
- const strings: string[] = new Array(n)
1295
- // Decide whether ranking is worth attempting BEFORE paying for it.
1296
- //
1297
- // Building the distinct set and then discarding it costs about 9 ms on
1298
- // a 100k-row column where nearly every value is unique, and ranking
1299
- // saves about 19 ms where they repeat - so guessing wrong in either
1300
- // direction is measurable. A small stride sample answers it for well
1301
- // under a millisecond.
1302
- //
1303
- // Strided rather than the first N rows: data arrives sorted or
1304
- // clustered often enough that a prefix is a bad estimator of the whole
1305
- // column. Reading every k-th row is no more expensive and does not care
1306
- // how the rows are arranged.
1307
- const rankLimit = n >> 1
1308
- let distinct: Set<string> | null = null
1309
- if (n > 0) {
1310
- const sampleTarget = Math.min(n, 256)
1311
- const stride = Math.max(1, Math.floor(n / sampleTarget))
1312
- const sample = new Set<string>()
1313
- let sampled = 0
1314
- for (let i = 0; i < n; i += stride) {
1315
- sample.add(String(rows[i]!.getCellValueByColumnId(columnId)))
1316
- sampled++
1317
- }
1318
- // Only attempt ranking when the sample suggests real repetition.
1319
- if (sample.size * 2 <= sampled) distinct = new Set()
1320
- }
1321
-
1322
- for (let i = 0; i < n; i++) {
1323
- const s = String(rows[i]!.getCellValueByColumnId(columnId))
1324
- strings[i] = s
1325
- if (distinct) {
1326
- distinct.add(s)
1327
- if (distinct.size > rankLimit) distinct = null
1328
- }
1329
- }
1330
-
1331
- // Collation is by far the most expensive comparison we do - a CPU
1332
- // profile of a 100k text sort put 65% of the whole operation inside the
1333
- // collator. But a column's DISTINCT values are usually far fewer than
1334
- // its rows (statuses, regions, categories, owners), so rank the
1335
- // distinct values once and sort by rank afterwards. That turns
1336
- // O(n log n) collator calls into O(u log u), where u is the number of
1337
- // distinct values, and the resulting order is identical because rank is
1338
- // a monotone relabelling of the collated order - equal strings share a
1339
- // rank, so ties still fall through to the stable sort exactly as before.
1340
- //
1341
- // Guarded on the uniqueness ratio: when nearly every value is distinct
1342
- // the ranking pass cannot save any collator calls and would just add an
1343
- // O(n) Map build, so that case keeps comparing directly.
1344
- if (distinct) {
1345
- const ordered = Array.from(distinct).sort(compareCollatedKeys)
1346
- const rankOf = new Map<string, number>()
1347
- for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)
1348
- for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!
1349
- compare = compareNumericKeys
1350
- } else {
1351
- keys = strings
1352
- compare = compareCollatedKeys
1353
- }
1354
- } else {
1355
- for (let i = 0; i < n; i++) keys[i] = rows[i]!.getCellValueByColumnId(columnId)
1356
- compare = comparator
1357
- }
1358
-
1359
- clauses.push({ keys, desc: clause.desc, compare })
1360
- }
1361
-
1362
- if (!clauses.length) return rows
1363
-
1364
- // Sort an index array, then materialise. `Array.prototype.sort` is stable,
1365
- // so equal keys keep their original relative order exactly as the previous
1366
- // `[...rows].sort(...)` did.
1367
- const order = new Array<number>(rows.length)
1368
- for (let i = 0; i < order.length; i++) order[i] = i
1369
-
1370
- // Single-clause sorts get a specialised comparator.
1371
- //
1372
- // Most sorts are one column, and that path runs O(n log n) times - 1.66
1373
- // million comparisons for 100k rows. The general loop pays a clause-array
1374
- // index, three property loads and an indirect call on every one of them,
1375
- // none of which vary once the clause list is fixed. Hoisting them into a
1376
- // closure and, for the numeric comparator, inlining the subtraction removes
1377
- // the call entirely.
1378
- //
1379
- // `keys[ib] - keys[ia]` for descending is exactly `-(keys[ia] - keys[ib])`
1380
- // as far as sorting is concerned: both are NaN for unorderable values,
1381
- // which the spec coerces to 0, and they differ only in producing 0 versus
1382
- // -0 for equal keys, which sorts identically.
1383
- if (clauses.length === 1) {
1384
- const { keys, compare, desc } = clauses[0]!
1385
- if (compare === compareNumericKeys) {
1386
- order.sort(
1387
- desc
1388
- ? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }
1389
- : function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },
1390
- )
1391
- } else {
1392
- order.sort(
1393
- desc
1394
- ? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }
1395
- : function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },
1396
- )
1397
- }
1398
- } else {
1399
- order.sort(function compareRowsByClauses(ia, ib) {
1400
- for (let k = 0; k < clauses.length; k++) {
1401
- const clause = clauses[k]!
1402
- const result = clause.compare(clause.keys[ia], clause.keys[ib])
1403
- if (result !== 0) return clause.desc ? -result : result
1404
- }
1405
- return 0
1406
- })
1407
- }
1408
-
1409
- const sorted = new Array<Row<TData>>(rows.length)
1410
- for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!
1411
- return sorted
1412
- }
1413
- }
1414
-
1415
- /**
1416
- * Numeric key comparison for the built-in `number` and `date` comparators.
1417
- * Subtraction rather than `<`/`>` on purpose: it reproduces the originals
1418
- * exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
1419
- * and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
1420
- * it), which is the behaviour callers already depend on.
1421
- */
1422
- function compareNumericKeys(a: number, b: number): number {
1423
- return a - b
1424
- }
1425
-
1426
- /**
1427
- * Text key comparison for the built-in `auto` comparator.
1428
- *
1429
- * `localeCompare`, NOT a hoisted `Intl.Collator`. The specification defines
1430
- * `localeCompare` with no locale or options as constructing a default collator
1431
- * per call, so hoisting one looks like the obvious optimisation - and it is
1432
- * measurably slower. V8 fast-paths `String.prototype.localeCompare` for the
1433
- * default locale; going through a collator object misses that path. Measured
1434
- * sorting 100k strings: 33 ms via `localeCompare` against 83 ms via a cached
1435
- * collator on ASCII, 58 ms against 99 ms with accents mixed in, and the two
1436
- * produce byte-identical orderings across all 100k positions.
1437
- *
1438
- * Left as its own function so the sort path has one place to change if that
1439
- * ever stops being true. Re-measure before "optimising" this again.
1440
- */
1441
- function compareCollatedKeys(a: string, b: string): number {
1442
- return a.localeCompare(b)
1443
- }
1444
-
1445
- /**
1446
- * Everything {@link createSvGridCore} accepts: the data and columns, the
1447
- * features and row models that make up the pipeline, and an `on*Change`
1448
- * callback per piece of state for controlled use.
1449
- *
1450
- * `<SvGrid>` builds this for you from its props - you only construct it
1451
- * directly when driving the headless core.
1452
- */
1453
- export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {
1454
- _features: TFeatures
1455
- _rowModels?: {
1456
- coreRowModel?: RowModelFactory<TData>
1457
- filteredRowModel?: RowModelFactory<TData>
1458
- sortedRowModel?: RowModelFactory<TData>
1459
- paginatedRowModel?: RowModelFactory<TData>
1460
- groupedRowModel?: RowModelFactory<TData>
1461
- expandedRowModel?: RowModelFactory<TData>
1462
- }
1463
- columns: Array<ColumnDef<TFeatures, TData>>
1464
- data: ReadonlyArray<TData>
1465
- /**
1466
- * Optional row-id resolver. When set, the value it returns becomes
1467
- * `row.id` (and therefore the selection / expansion / edit key). When
1468
- * omitted, ids fall back to the row's array index as a string. Use a
1469
- * stable id (database PK, UUID, etc.) so selection survives reorders.
1470
- */
1471
- getRowId?: (row: TData, index: number) => string
1472
- state?: Partial<Record<string, any>>
1473
- onSortingChange?: (updater: Updater<SortingState>) => void
1474
- onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void
1475
- onPaginationChange?: (updater: Updater<PaginationState>) => void
1476
- onGroupingChange?: (updater: Updater<GroupingState>) => void
1477
- onExpandedChange?: (updater: Updater<ExpandedState>) => void
1478
- onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void
1479
- onActiveCellChange?: (updater: Updater<ActiveCellState>) => void
1480
- }
1481
-
1482
- /**
1483
- * The headless grid instance: the state stores plus the read methods a renderer
1484
- * needs (`getHeaderGroups()`, `getRowModel()`, the `set*` writers).
1485
- *
1486
- * Framework free by design - `<SvGrid>` is one renderer over this, and you can
1487
- * write another. See the "Why headless?" guide.
1488
- */
1489
- export type SvGrid<TData extends RowData> = {
1490
- store: Store<Record<string, any>>
1491
- optionsStore: Store<Record<string, any>>
1492
- state: Record<string, any>
1493
- getState: () => Record<string, any>
1494
- setOptions: (updater: Updater<Record<string, any>>) => void
1495
- setColumnFilters: (updater: Updater<ColumnFiltersState>) => void
1496
- setPagination: (updater: Updater<PaginationState>) => void
1497
- setGrouping: (updater: Updater<GroupingState>) => void
1498
- setExpanded: (updater: Updater<ExpandedState>) => void
1499
- setRowSelection: (updater: Updater<RowSelectionState>) => void
1500
- setActiveCell: (updater: Updater<ActiveCellState>) => void
1501
- moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void
1502
- getAllColumns: () => Array<Column<TData>>
1503
- getHeaderGroups: () => Array<HeaderGroup<TData>>
1504
- getFooterGroups: () => Array<HeaderGroup<TData>>
1505
- getRowModel: () => RowModel<TData>
1506
- }
1507
-
1508
- type InternalGrid<TData extends RowData> = SvGrid<TData> & {
1509
- getAllColumns: () => Array<Column<TData>>
1510
- }
1511
-
1512
- /**
1513
- * Build a headless grid: state, the row pipeline, and the read methods, with no
1514
- * DOM and no Svelte. This is the engine `<SvGrid>` renders.
1515
- *
1516
- * Most callers want `createSvGrid` (the runes-aware wrapper) or the component
1517
- * itself; reach for this when you are writing your own renderer or running the
1518
- * pipeline outside a browser.
1519
- */
1520
- export function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(
1521
- options: SvGridOptions<TFeatures, TData>,
1522
- ): SvGrid<TData> {
1523
- const internalState: Record<string, any> = {
1524
- sorting: [],
1525
- columnFilters: [],
1526
- pagination: { pageIndex: 0, pageSize: options.data.length || 10 },
1527
- grouping: [],
1528
- expanded: {},
1529
- rowSelection: {},
1530
- activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
1531
- ...(options.state ?? {}),
1532
- }
1533
- const store = createStore(internalState)
1534
- const optionsStore = createStore(options as Record<string, any>)
1535
- let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null
1536
- let cachedColumns: Array<Column<TData>> = []
1537
- let cachedHeaderGroups: Array<HeaderGroup<TData>> = []
1538
- let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
1539
- let cachedBaseRowsColumns: Array<Column<TData>> | null = null
1540
- let cachedBaseRows: Array<Row<TData>> = []
1541
- let cachedRowModel: RowModel<TData> | null = null
1542
- let cachedRowModelBaseRows: Array<Row<TData>> | null = null
1543
- let cachedPipeline = options._rowModels
1544
- let cachedSlices: {
1545
- sorting: SortingState | undefined
1546
- columnFilters: ColumnFiltersState | undefined
1547
- pagination: PaginationState | undefined
1548
- grouping: GroupingState | undefined
1549
- expanded: ExpandedState | undefined
1550
- } | null = null
1551
-
1552
- const grid = {
1553
- store,
1554
- optionsStore,
1555
- get state() {
1556
- return store.state
1557
- },
1558
- getState() {
1559
- return store.state
1560
- },
1561
- setOptions(updater: Updater<Record<string, any>>) {
1562
- optionsStore.setState((prev) =>
1563
- typeof updater === 'function' ? (updater as any)(prev) : updater,
1564
- )
1565
- },
1566
- setColumnFilters(updater: Updater<ColumnFiltersState>) {
1567
- store.setState((prev) => ({
1568
- ...prev,
1569
- columnFilters:
1570
- typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,
1571
- }))
1572
- options.onColumnFiltersChange?.(updater)
1573
- },
1574
- setPagination(updater: Updater<PaginationState>) {
1575
- store.setState((prev) => ({
1576
- ...prev,
1577
- pagination:
1578
- typeof updater === 'function'
1579
- ? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })
1580
- : updater,
1581
- }))
1582
- options.onPaginationChange?.(updater)
1583
- },
1584
- setGrouping(updater: Updater<GroupingState>) {
1585
- store.setState((prev) => ({
1586
- ...prev,
1587
- grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,
1588
- }))
1589
- options.onGroupingChange?.(updater)
1590
- },
1591
- setExpanded(updater: Updater<ExpandedState>) {
1592
- store.setState((prev) => ({
1593
- ...prev,
1594
- expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,
1595
- }))
1596
- options.onExpandedChange?.(updater)
1597
- },
1598
- setRowSelection(updater: Updater<RowSelectionState>) {
1599
- store.setState((prev) => ({
1600
- ...prev,
1601
- rowSelection:
1602
- typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,
1603
- }))
1604
- options.onRowSelectionChange?.(updater)
1605
- },
1606
- setActiveCell(updater: Updater<ActiveCellState>) {
1607
- store.setState((prev) => {
1608
- const previous: ActiveCellState = prev.activeCell ?? {
1609
- rowIndex: 0,
1610
- colIndex: 0,
1611
- cellId: null,
1612
- }
1613
- const nextActive =
1614
- typeof updater === 'function' ? updater(previous) : updater
1615
- return {
1616
- ...prev,
1617
- activeCell: nextActive,
1618
- }
1619
- })
1620
- options.onActiveCellChange?.(updater)
1621
- },
1622
- moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {
1623
- const rows = grid.getRowModel().rows
1624
- const columns = grid.getAllColumns()
1625
- const maxRow = Math.max(rows.length - 1, 0)
1626
- const maxCol = Math.max(columns.length - 1, 0)
1627
- const current: ActiveCellState = grid.getState().activeCell ?? {
1628
- rowIndex: 0,
1629
- colIndex: 0,
1630
- cellId: null,
1631
- }
1632
-
1633
- const rowIndex = Math.min(
1634
- Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),
1635
- maxRow,
1636
- )
1637
- const colIndex = Math.min(
1638
- Math.max(current.colIndex + (next.colDelta ?? 0), 0),
1639
- maxCol,
1640
- )
1641
- const columnId = columns[colIndex]?.id ?? 'col_0'
1642
- grid.setActiveCell({
1643
- rowIndex,
1644
- colIndex,
1645
- cellId: `${rowIndex}_${columnId}`,
1646
- })
1647
- },
1648
- getAllColumns() {
1649
- // Cache hit: referentially identical columns array.
1650
- if (cachedColumnsInput === options.columns && cachedColumns.length) {
1651
- return cachedColumns
1652
- }
1653
- // Soft cache hit: consumers commonly recreate the columns array
1654
- // inline on every render (e.g. `columns={[...]}`). If the new
1655
- // array has the same length AND each entry has the same `field` /
1656
- // `id` / `header` (the visibility-affecting structure of a
1657
- // column), trust the previous build. Mutable inner fields like
1658
- // `cell` and `editorOptions` are still picked up on the next real
1659
- // render that bumps an actual data dep - they're read at cell-
1660
- // render time, not at this top-level cache.
1661
- if (
1662
- cachedColumnsInput &&
1663
- options.columns.length === cachedColumnsInput.length &&
1664
- cachedColumns.length === options.columns.length &&
1665
- options.columns.every((c, i) => {
1666
- const prev = cachedColumnsInput![i]!
1667
- return (
1668
- c.field === prev.field &&
1669
- c.id === prev.id &&
1670
- c.header === prev.header &&
1671
- c.editorType === prev.editorType
1672
- )
1673
- })
1674
- ) {
1675
- // Update the stored input reference so the strict check hits
1676
- // next time, but reuse the built column model.
1677
- cachedColumnsInput = options.columns
1678
- return cachedColumns
1679
- }
1680
-
1681
- cachedColumnsInput = options.columns
1682
- cachedHeaderGroups = []
1683
- const build = (
1684
- defs: Array<ColumnDef<TFeatures, TData>>,
1685
- depth: number,
1686
- parentId?: string,
1687
- ): Array<Column<TData>> => {
1688
- const leaves: Array<Column<TData>> = []
1689
- defs.forEach((columnDef, index) => {
1690
- const id = resolveColumnId(columnDef, parentId, depth, index)
1691
- if (columnDef.columns?.length) {
1692
- leaves.push(...build(columnDef.columns, depth + 1, id))
1693
- return
1694
- }
1695
- leaves.push({
1696
- id,
1697
- depth,
1698
- parentId,
1699
- columnDef,
1700
- getCanSort: () =>
1701
- Boolean((options._features as any).rowSortingFeature) &&
1702
- columnDef.sortable !== false,
1703
- getCanFilter: () =>
1704
- Boolean((options._features as any).columnFilteringFeature) &&
1705
- columnDef.filterable !== false,
1706
- getIsSorted: () => {
1707
- const entry = store.state.sorting?.find((s: any) => s.id === id)
1708
- if (!entry) return false
1709
- return entry.desc ? 'desc' : 'asc'
1710
- },
1711
- getToggleSortingHandler: () => () => {
1712
- const clauses: SortingState = store.state.sorting ?? []
1713
- const current = clauses.find((s: any) => s.id === id)
1714
- const nextClause: SortingState = !current
1715
- ? [...clauses, { id, desc: false }]
1716
- : current.desc
1717
- ? clauses.filter((s) => s.id !== id)
1718
- : clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))
1719
- store.setState((prev) => ({ ...prev, sorting: nextClause }))
1720
- options.onSortingChange?.(nextClause)
1721
- },
1722
- })
1723
- })
1724
- return leaves
1725
- }
1726
- cachedColumns = build(options.columns, 0)
1727
- return cachedColumns
1728
- },
1729
- getHeaderGroups() {
1730
- if (cachedHeaderGroups.length) return cachedHeaderGroups
1731
- const headers = grid.getAllColumns().map((column) => {
1732
- const header: Header<TData> = {
1733
- id: column.id,
1734
- isPlaceholder: false,
1735
- colSpan: 1,
1736
- column,
1737
- getContext: () => ({ header, column, table: grid }),
1738
- }
1739
- return header
1740
- })
1741
- cachedHeaderGroups = [{ id: 'header_group_0', headers }]
1742
- return cachedHeaderGroups
1743
- },
1744
- getFooterGroups() {
1745
- return grid.getHeaderGroups()
1746
- },
1747
- getRowModel() {
1748
- const columns = grid.getAllColumns()
1749
- if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
1750
- cachedBaseRowsInput = options.data
1751
- cachedBaseRowsColumns = columns
1752
- // O(1) column-id → index lookup so getCellValueByColumnId doesn't do
1753
- // a linear `findIndex` on every cell read (was O(rows × cells × cols)).
1754
- const columnIndexById = new Map<string, number>()
1755
- for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
1756
- const columnCount = columns.length
1757
-
1758
- // One shared context for every row in this table, so a row carries a
1759
- // pointer rather than a closure scope. See BASE_ROW_METHODS.
1760
- const rowCtx: BaseRowCtx<TData> = {
1761
- grid: grid as SvGrid<TData>,
1762
- store,
1763
- columns,
1764
- columnCount,
1765
- columnIndexById,
1766
- }
1767
-
1768
- cachedBaseRows = new Array(options.data.length)
1769
- const getRowId = options.getRowId
1770
- const m = BASE_ROW_METHODS as unknown as {
1771
- getCanExpand: Row<TData>['getCanExpand']
1772
- getIsExpanded: Row<TData>['getIsExpanded']
1773
- toggleExpanded: Row<TData>['toggleExpanded']
1774
- getIsSelected: Row<TData>['getIsSelected']
1775
- toggleSelected: Row<TData>['toggleSelected']
1776
- getAllCells: Row<TData>['getAllCells']
1777
- getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
1778
- }
1779
- for (let index = 0; index < options.data.length; index++) {
1780
- const original = options.data[index]!
1781
- // `_values` and `_cells` stay null until something reads them - a
1782
- // 100k-row grid showing twenty rows must not materialise every row's
1783
- // values or cell objects to paint.
1784
- const row: BaseRowState<TData> = {
1785
- id: getRowId ? getRowId(original, index) : String(index),
1786
- index,
1787
- original,
1788
- depth: 0,
1789
- [ROW_CTX]: rowCtx,
1790
- [ROW_VALUES]: null,
1791
- [ROW_CELLS]: null,
1792
- getCanExpand: m.getCanExpand,
1793
- getIsExpanded: m.getIsExpanded,
1794
- toggleExpanded: m.toggleExpanded,
1795
- getIsSelected: m.getIsSelected,
1796
- toggleSelected: m.toggleSelected,
1797
- getAllCells: m.getAllCells,
1798
- getCellValueByColumnId: m.getCellValueByColumnId,
1799
- }
1800
- cachedBaseRows[index] = row
1801
- }
1802
- }
1803
-
1804
- // Only the slices a pipeline stage actually READS belong in this key.
1805
- //
1806
- // `rowSelection` used to be here, which meant ticking one checkbox on a
1807
- // 100k-row grid re-filtered and re-sorted the entire dataset to rebuild a
1808
- // row array that was identical by construction. Nothing reads it: the two
1809
- // consumers are `getIsSelected` closures (on data rows and on group rows)
1810
- // that read `store.state` when called, so they observe a selection change
1811
- // without the model being rebuilt.
1812
- //
1813
- // `_rowModels` is a closed set of six named slots, so no consumer stage
1814
- // can be inserted that might read selection. A caller CAN supply a custom
1815
- // function for one of those slots; if one ever needs a slice that is not
1816
- // listed here, add it here rather than reinstating all of them.
1817
- const currentSlices = {
1818
- sorting: store.state.sorting,
1819
- columnFilters: store.state.columnFilters,
1820
- pagination: store.state.pagination,
1821
- grouping: store.state.grouping,
1822
- expanded: store.state.expanded,
1823
- }
1824
- if (
1825
- cachedRowModel &&
1826
- cachedRowModelBaseRows === cachedBaseRows &&
1827
- cachedPipeline === options._rowModels &&
1828
- cachedSlices?.sorting === currentSlices.sorting &&
1829
- cachedSlices?.columnFilters === currentSlices.columnFilters &&
1830
- cachedSlices?.pagination === currentSlices.pagination &&
1831
- cachedSlices?.grouping === currentSlices.grouping &&
1832
- cachedSlices?.expanded === currentSlices.expanded
1833
- ) {
1834
- return cachedRowModel
1835
- }
1836
-
1837
- let rows: Array<Row<TData>> = cachedBaseRows
1838
-
1839
- const pipeline = options._rowModels ?? {}
1840
- const ordered: Array<RowModelFactory<TData> | undefined> = [
1841
- pipeline.coreRowModel,
1842
- pipeline.filteredRowModel,
1843
- pipeline.sortedRowModel,
1844
- pipeline.groupedRowModel,
1845
- pipeline.expandedRowModel,
1846
- pipeline.paginatedRowModel,
1847
- ]
1848
- ordered.forEach((fn) => {
1849
- if (fn) rows = fn({ table: grid, rows })
1850
- })
1851
- cachedPipeline = options._rowModels
1852
- cachedSlices = currentSlices
1853
- cachedRowModelBaseRows = cachedBaseRows
1854
- cachedRowModel = { rows }
1855
- return cachedRowModel
1856
- },
1857
- } as InternalGrid<TData>
1858
-
1859
- return grid
1860
- }
1861
-
1862
- /** Narrowing helper for the many options that accept a value or a function. */
1863
- export function isFunction(value: unknown): value is (...args: Array<any>) => any {
1864
- return typeof value === 'function'
1865
- }
1
+ import type { SparklineConfig } from './sparkline'
2
+ import { resolveColumnId } from './column-id'
3
+
4
+ /**
5
+ * The constraint every row type satisfies: an object keyed by string. Your own
6
+ * row type (`type Person = { name: string }`) is what flows through the generics
7
+ * below; this is only the lower bound they are declared against.
8
+ */
9
+ export type RowData = Record<string, unknown>
10
+
11
+ /**
12
+ * A new value, or a function that derives it from the previous one - the shape
13
+ * every `set*` on the grid accepts, so callers can update state without first
14
+ * reading it.
15
+ *
16
+ * api.setSorting([{ id: 'name', desc: false }])
17
+ * api.setSorting((prev) => [...prev, { id: 'age', desc: true }])
18
+ */
19
+ export type Updater<T> = T | ((prev: T) => T)
20
+
21
+ /** Active sort clauses, outermost first. `desc: false` is ascending. */
22
+ export type SortingState = Array<{ id: string; desc: boolean }>
23
+
24
+ /**
25
+ * One column's filter: the column `id`, the `value` being matched, and
26
+ * optionally which comparison to use. `fn` defaults to the column's own type -
27
+ * see {@link filterFns} for the available names.
28
+ */
29
+ export type ColumnFilter = { id: string; value: unknown; fn?: keyof typeof filterFns }
30
+
31
+ /** Every active column filter. A column with no entry here is unfiltered. */
32
+ export type ColumnFiltersState = Array<ColumnFilter>
33
+
34
+ /** Current page position. `pageIndex` is 0-based, so page 1 is index 0. */
35
+ export type PaginationState = { pageIndex: number; pageSize: number }
36
+
37
+ /** Column ids the rows are grouped by, outermost first. */
38
+ export type GroupingState = Array<string>
39
+
40
+ /** Which rows are expanded, keyed by row id. Absent means collapsed. */
41
+ export type ExpandedState = Record<string, boolean>
42
+
43
+ /** Which rows are selected, keyed by row id. Absent means unselected. */
44
+ export type RowSelectionState = Record<string, boolean>
45
+
46
+ /**
47
+ * Where keyboard focus sits. The indices address the *displayed* grid (after
48
+ * sorting, filtering and paging), not the source data.
49
+ */
50
+ export type ActiveCellState = {
51
+ rowIndex: number
52
+ colIndex: number
53
+ cellId: string | null
54
+ }
55
+
56
+ /**
57
+ * The set of features a grid has registered, as built by {@link tableFeatures}.
58
+ * Deliberately open: a feature is identified by its key, so the type carries
59
+ * which ones are on without enumerating them.
60
+ */
61
+ export type TableFeatures = Record<string, unknown>
62
+
63
+ /** A cell's value. Unconstrained - a column can hold anything. */
64
+ export type CellData = unknown
65
+
66
+ /** What a column's `header` render function receives. */
67
+ export type HeaderContext<TData extends RowData> = {
68
+ header: Header<TData>
69
+ column: Column<TData>
70
+ table: SvGrid<TData>
71
+ }
72
+
73
+ /**
74
+ * What a column's `cell` render function receives. `getValue()` applies the
75
+ * column's accessor (`field` or `fieldFn`); `row.original` is the raw object.
76
+ */
77
+ export type CellContext<TData extends RowData> = {
78
+ cell: Cell<TData>
79
+ row: Row<TData>
80
+ column: Column<TData>
81
+ table: SvGrid<TData>
82
+ getValue: () => unknown
83
+ }
84
+
85
+ /** Params passed to a column's `colSpan(...)` / `rowSpan(...)` callbacks. */
86
+ export type CellSpanParams<TData extends RowData = RowData> = {
87
+ /** The row's underlying data object. */
88
+ data: TData
89
+ /** Display-row index in the current (filtered/sorted) row set. */
90
+ rowIndex: number
91
+ /** The column's id. */
92
+ columnId: string
93
+ /** The cell's base value for this column. */
94
+ value: unknown
95
+ }
96
+
97
+ /** The raw option list a column's `editorOptions` can supply. */
98
+ export type EditorOptionSource = ReadonlyArray<
99
+ string | number | { value: string | number; label?: string; color?: string }
100
+ >
101
+
102
+ /** Params passed to a column's `valueParser(...)` on edit commit. */
103
+ export type ValueParserParams<TData extends RowData = RowData> = {
104
+ /** The value after built-in per-`editorType` coercion. */
105
+ newValue: unknown
106
+ /** The cell's previous value. */
107
+ oldValue: unknown
108
+ /** The raw string the editor produced (pre-coercion). */
109
+ rawInput: string
110
+ /** The row's underlying data object. */
111
+ data: TData
112
+ /** The column's id. */
113
+ columnId: string
114
+ }
115
+
116
+ /**
117
+ * Context passed to a custom `cellEditor` snippet/component. Three write
118
+ * helpers cover the lifecycle:
119
+ *
120
+ * - `update(next)` - stage `next` as the draft, keep the editor open.
121
+ * Use this for live-preview controls (sliders,
122
+ * color pickers) so the user can keep adjusting.
123
+ * - `commit(next?)` - write the value AND close the editor. The
124
+ * argument is optional; when omitted, the most
125
+ * recently `update()`d value is saved. Use this
126
+ * for "done" gestures (Enter, picking an option).
127
+ * - `cancel()` - discard the draft and close the editor.
128
+ */
129
+ export type EditorContext<TData extends RowData> = CellContext<TData> & {
130
+ value: unknown
131
+ update: (next: unknown) => void
132
+ commit: (next?: unknown) => void
133
+ cancel: () => void
134
+ }
135
+
136
+ /**
137
+ * Declarative cell formatting, applied through `Intl` - number, currency,
138
+ * percent, date and datetime. Prefer this over a `formatter` function: it is
139
+ * locale-aware, and export and the clipboard reuse the same configuration.
140
+ */
141
+ export type CellFormatConfig =
142
+ | {
143
+ type: 'number'
144
+ locales?: string | Array<string>
145
+ options?: Intl.NumberFormatOptions
146
+ }
147
+ | {
148
+ type: 'currency'
149
+ /** ISO 4217 (default USD) */
150
+ currency?: string
151
+ locales?: string | Array<string>
152
+ options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>
153
+ }
154
+ | {
155
+ type: 'percent'
156
+ locales?: string | Array<string>
157
+ options?: Omit<Intl.NumberFormatOptions, 'style'>
158
+ /**
159
+ * If true, numeric cell values are 0–100 (e.g. 42 → 42%) instead of Intl’s 0–1 fraction (0.42 → 42%).
160
+ * Default false.
161
+ */
162
+ valueIsPercentPoints?: boolean
163
+ }
164
+ | {
165
+ type: 'date' | 'datetime'
166
+ locales?: string | Array<string>
167
+ /**
168
+ * Shortcut patterns merged with `options`:
169
+ * `'d'` short numeric date, `'D'` long date, `'y-m-d'` yyyy/mm/dd-style,
170
+ * `'short'`|`'medium'`|`'long'` use dateStyle/timeStyle presets.
171
+ */
172
+ pattern?: string
173
+ options?: Intl.DateTimeFormatOptions
174
+ }
175
+
176
+ /**
177
+ * A column's custom display function, for anything {@link CellFormatConfig}
178
+ * cannot express. Returns a string - to render markup, use `cell` instead.
179
+ */
180
+ export type CellFormatter<TData extends RowData> = (context: {
181
+ value: unknown
182
+ row: Row<TData>
183
+ column: Column<TData>
184
+ table: SvGrid<TData>
185
+ }) => string
186
+
187
+ /** A header or cell slot: a literal string, or a function returning renderable content. */
188
+ export type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)
189
+
190
+ /**
191
+ * How a column's value is aggregated for a group row when `columnGrouping`
192
+ * is active. Built-in reducers cover the common cases; pass a function for
193
+ * anything custom (weighted average, median, percentile, distinct count).
194
+ * The function receives the finite numeric values AND the raw leaf rows.
195
+ */
196
+ export type GroupAggregator<TData = any> =
197
+ | 'sum'
198
+ | 'avg'
199
+ | 'min'
200
+ | 'max'
201
+ | 'count'
202
+ | 'countDistinct'
203
+ | 'extent'
204
+ | 'first'
205
+ | ((values: number[], rows: Array<TData>) => unknown)
206
+
207
+ /** Apply a group aggregator over a bucket's leaf rows for one column. */
208
+ export function applyGroupAggregate<TData extends RowData>(
209
+ agg: GroupAggregator<TData>,
210
+ columnId: string,
211
+ rows: ReadonlyArray<Row<TData>>,
212
+ ): unknown {
213
+ // One pass, no intermediate arrays.
214
+ //
215
+ // This used to build a `raw` array, then a coerced one, then a filtered one -
216
+ // three allocations per aggregated column PER GROUP - before reducing. On a
217
+ // 100k-row grid grouped two levels deep, aggregation was about two thirds of
218
+ // the total grouping cost (213ms with three aggregators against 81ms with
219
+ // none), and each additional aggregated column added roughly 80ms.
220
+ //
221
+ // `count` first: it never needs to look at a value at all.
222
+ if (agg === 'count') return rows.length
223
+
224
+ if (agg === 'first') {
225
+ return rows.length ? rows[0]!.getCellValueByColumnId(columnId) : undefined
226
+ }
227
+
228
+ if (agg === 'countDistinct') {
229
+ const seen = new Set<string>()
230
+ for (const row of rows) seen.add(String(row.getCellValueByColumnId(columnId) ?? ''))
231
+ return seen.size
232
+ }
233
+
234
+ if (typeof agg === 'function') {
235
+ // Custom aggregators keep their contract: the finite numbers, then the
236
+ // original row objects. Note `Number(null)` is 0 and therefore finite, so
237
+ // nulls DO reach the callback as zeros - long-standing behaviour.
238
+ const nums: number[] = []
239
+ for (const row of rows) {
240
+ const n = Number(row.getCellValueByColumnId(columnId))
241
+ if (Number.isFinite(n)) nums.push(n)
242
+ }
243
+ return agg(nums, rows.map((r) => r.original))
244
+ }
245
+
246
+ // sum / avg / min / max / extent share one accumulation pass.
247
+ let count = 0
248
+ let sum = 0
249
+ let min = Infinity
250
+ let max = -Infinity
251
+ for (const row of rows) {
252
+ const n = Number(row.getCellValueByColumnId(columnId))
253
+ if (!Number.isFinite(n)) continue
254
+ count++
255
+ // Left-to-right, matching the previous `reduce`, so float rounding is
256
+ // bit-identical rather than merely close.
257
+ sum += n
258
+ // Math.min/max on scalars rather than `<`, which differs on -0, and rather
259
+ // than the old `Math.min(...nums)` - spreading a whole group throws
260
+ // RangeError once the bucket is big enough to exhaust the argument stack.
261
+ min = Math.min(min, n)
262
+ max = Math.max(max, n)
263
+ }
264
+ if (!count) return undefined
265
+ switch (agg) {
266
+ case 'sum':
267
+ return sum
268
+ case 'avg':
269
+ return sum / count
270
+ case 'min':
271
+ return min
272
+ case 'max':
273
+ return max
274
+ case 'extent':
275
+ return `${min} – ${max}`
276
+ default:
277
+ return undefined
278
+ }
279
+ }
280
+
281
+ /**
282
+ * A column definition.
283
+ *
284
+ * `TFeatures` is a phantom parameter - it is threaded through nested
285
+ * `columns` groups but no member depends on it, so `{}`, `TableFeatures` and
286
+ * `typeof features` are all interchangeable here. It is deliberately left
287
+ * WITHOUT a default: `ColumnDef<Row>` would otherwise bind `Row` to this slot
288
+ * and silently type your data as `RowData`, losing every field-name check.
289
+ * Prefer {@link GridColumns} / {@link GridColumnDef} for the common case.
290
+ */
291
+ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> = {
292
+ id?: string
293
+ field?: keyof TData & string
294
+ fieldFn?: (row: TData) => unknown
295
+ header?: ColumnDefTemplate<HeaderContext<TData>>
296
+ footer?: ColumnDefTemplate<HeaderContext<TData>>
297
+ cell?: ColumnDefTemplate<CellContext<TData>>
298
+ columns?: Array<ColumnDef<TFeatures, TData>>
299
+ /**
300
+ * Declarative cell spanning (merged cells). Return how many COLUMNS this
301
+ * cell spans to the right (1 = no span). Value-driven. Feed
302
+ * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
303
+ * the same real `colspan`/`rowspan` merge engine (no separate code path).
304
+ */
305
+ colSpan?: (params: CellSpanParams<TData>) => number
306
+ /**
307
+ * Declarative cell spanning (merged cells). Return how many ROWS this cell
308
+ * spans downward (1 = no span). See `colSpan` for how to apply.
309
+ */
310
+ rowSpan?: (params: CellSpanParams<TData>) => number
311
+ /**
312
+ * High-level data type for the column. A convenience that resolves to the
313
+ * right `editorType`, alignment, date `format`, and filter operators without
314
+ * setting each by hand:
315
+ * 'text' → text editor, left-aligned
316
+ * 'number' → number editor, right-aligned, numeric filter operators
317
+ * 'boolean' → checkbox editor, centered
318
+ * 'date' → date editor (Date values), right-aligned, `{ type: 'date' }` format
319
+ * 'dateString' → date editor for ISO date STRINGS (e.g. '2026-06-27')
320
+ * Anything you set explicitly (`editorType`, `align`, `format`) still wins -
321
+ * `cellDataType` only fills the gaps. Grid-level `inferColumnTypes` infers
322
+ * this from the first data row for columns that declare neither.
323
+ */
324
+ cellDataType?: 'text' | 'number' | 'boolean' | 'date' | 'dateString'
325
+ /**
326
+ * Hide this column when the grid's `responsive` mode is on and the grid is
327
+ * narrower than this many pixels - drop low-priority columns on small
328
+ * screens. No effect unless the grid has `responsive` set.
329
+ */
330
+ hideBelow?: number
331
+ /**
332
+ * For a column INSIDE a collapsible column group: `'open'` shows this column
333
+ * only while the group is expanded, `'closed'` only while collapsed. Omit to
334
+ * always show it. Setting it on any direct child gives the parent group a
335
+ * collapse toggle. Pair with `openByDefault` on the group.
336
+ */
337
+ columnGroupShow?: 'open' | 'closed'
338
+ /**
339
+ * For a GROUP column (one with `columns: [...]`): start the group expanded.
340
+ * Defaults to `false` (collapsed), the conventional default - so only the always-on
341
+ * and `columnGroupShow: 'closed'` children show until the user expands it.
342
+ */
343
+ openByDefault?: boolean
344
+ editorType?:
345
+ | 'text'
346
+ | 'number'
347
+ | 'date' // rich SvCalendar popover (opt out with 'date-native')
348
+ | 'datetime' // rich SvDateTimePicker (opt out with 'datetime-native')
349
+ | 'time' // rich SvTimePicker dial (opt out with 'time-native')
350
+ | 'date-native' // plain <input type="date">
351
+ | 'datetime-native' // plain <input type="datetime-local">
352
+ | 'time-native' // plain <input type="time"> - HH:MM or HH:MM:SS
353
+ | 'password' // native <input type="password"> with masked rendering
354
+ | 'checkbox'
355
+ | 'list'
356
+ | 'chips'
357
+ | 'select' // custom dropdown - single value, no typeahead
358
+ | 'rich-select' // custom dropdown with a typeahead search input
359
+ | 'autocomplete' // free-text input with a live-filtered suggestion list (accepts any value)
360
+ | 'textarea' // multi-line editor; Tab or Ctrl+Enter commits, plain Enter inserts a newline
361
+ | 'color' // native <input type="color"> swatch
362
+ | 'rating' // 5-star rating control
363
+ // Any other string names a CUSTOM editor registered via `registerCellEditor`
364
+ // (or `registerBuiltinEditors`). `(string & {})` keeps the literals above
365
+ // autocompleting while allowing arbitrary custom type names.
366
+ | (string & {})
367
+ /**
368
+ * Custom in-cell editor. Receives the cell context PLUS a `commit(value)`
369
+ * and `cancel()` helper. Use when none of the built-in `editorType`s fit;
370
+ * the snippet's outer element is mounted inside the editing cell and
371
+ * inherits keyboard handling (Esc cancels, Enter commits unless your
372
+ * snippet preventDefaults it).
373
+ *
374
+ * Coexists with `editorType`: when both are set, `cellEditor` wins and
375
+ * `editorType` is treated as a hint for parsing the saved value.
376
+ */
377
+ cellEditor?: ColumnDefTemplate<EditorContext<TData>>
378
+ /**
379
+ * Per-column tooltip. String shows as a native `title=`; `(ctx) => string`
380
+ * runs per cell so the tooltip can reflect the value. Returning an empty
381
+ * string skips the tooltip.
382
+ */
383
+ tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
384
+ /**
385
+ * Declarative per-cell validation. Runs for EVERY
386
+ * rendered cell - including values already present in `data` on load, not
387
+ * just on edit - so bad data is flagged immediately. Invalid cells get the
388
+ * `sv-grid-cell-invalid` class (red highlight) and the returned message as
389
+ * their tooltip.
390
+ *
391
+ * Return value:
392
+ * - `null` / `undefined` / `true` → valid (no highlight)
393
+ * - `false` → invalid, no message
394
+ * - a non-empty `string` → invalid, string is the tooltip
395
+ *
396
+ * The value keeps rendering as-is (the grid does NOT roll it back); pair
397
+ * with `onCellValueChange` if you also want to reject the commit.
398
+ */
399
+ validate?: (params: {
400
+ value: unknown
401
+ row: TData
402
+ rowIndex: number
403
+ column: Column<TData>
404
+ }) => string | boolean | null | undefined
405
+ /**
406
+ * Gate editing per column or per cell.
407
+ *
408
+ * - `true` (or omitted): the column is fully editable.
409
+ * - `false`: the column is read-only - double-click, type-to-edit,
410
+ * fill-handle drag, Delete, and clipboard paste all skip it.
411
+ * - `(ctx) => boolean`: evaluated for each cell, so you can lock
412
+ * individual rows (e.g. by role, status, ownership). Returning
413
+ * `false` opts the cell out of every editing path, identical to
414
+ * setting `editable: false` on the whole column for that row.
415
+ *
416
+ * The grid-wide `enableInlineEditing` prop still wins when set to
417
+ * `false`.
418
+ */
419
+ editable?: boolean | ((context: CellContext<TData>) => boolean)
420
+ /**
421
+ * Transform the committed edit value before it is written to the row.
422
+ * Runs after the built-in per-`editorType` coercion, so `newValue` is
423
+ * already type-parsed; return the final value to store (e.g. round a
424
+ * number, uppercase a code, look up an id). A `valueParser` hook.
425
+ */
426
+ valueParser?: (params: ValueParserParams<TData>) => unknown
427
+ /**
428
+ * Briefly flash / highlight this column's cell when its value changes
429
+ * (streaming feeds, edits, server pushes). `true` uses the default flash;
430
+ * pass `{ className }` to apply your own animation class instead.
431
+ */
432
+ cellFlash?: boolean | { className?: string }
433
+ /**
434
+ * When `false`, this column never shows a sort indicator and clicking
435
+ * its header is a no-op - `api.setSort(thisColumn, ...)` is also
436
+ * ignored. Defaults to `true` (the column participates in sorting as
437
+ * long as `rowSortingFeature` is registered).
438
+ */
439
+ sortable?: boolean
440
+ /**
441
+ * When `false`, this column never shows a filter funnel / menu and
442
+ * `api.setFilter(thisColumn, ...)` is ignored. Defaults to `true` (the
443
+ * column is filterable as long as `columnFilteringFeature` is
444
+ * registered).
445
+ */
446
+ filterable?: boolean
447
+ /**
448
+ * Options for `editorType: 'list' | 'chips'`. Either bare values (the
449
+ * string is both value and label) or `{ value, label }` objects.
450
+ * For `chips` this is optional - when omitted, the chips editor becomes
451
+ * free-form (user types and presses Enter to commit a chip).
452
+ *
453
+ * Pass a function `(row) => options` for row-dependent (cascading)
454
+ * options - e.g. City options that depend on Country in the same row.
455
+ *
456
+ * Either form may return a **Promise**, for options that come from the
457
+ * server. While it resolves, the editor shows a loading state and the cell
458
+ * renders its raw value.
459
+ *
460
+ * Results are cached so reopening an editor does not refetch: a static source
461
+ * per column, a per-row source per row AND per that row's data - so a cascade
462
+ * reloads by itself when the cell it depends on is edited. Call
463
+ * `api.refreshEditorOptions(columnId?)` when the list changes server-side.
464
+ */
465
+ editorOptions?:
466
+ | EditorOptionSource
467
+ | Promise<EditorOptionSource>
468
+ | ((row: TData) => EditorOptionSource | Promise<EditorOptionSource>)
469
+ /** When true, list/chips allow multiple selections. Cell value becomes an array. */
470
+ editorMultiple?: boolean
471
+ /** Separator used when joining array values for the readonly cell display. Defaults to ', '. */
472
+ editorSeparator?: string
473
+ format?: CellFormatConfig
474
+ formatter?: CellFormatter<TData>
475
+ /**
476
+ * Aggregate this column's values into the group row when grouping is
477
+ * active. `'sum' | 'avg' | 'min' | 'max' | 'count' | 'countDistinct' |
478
+ * 'extent' | 'first'`, or a custom `(values, rows) => unknown`. The result
479
+ * is formatted with this column's `format` and shown in the group header.
480
+ */
481
+ aggregate?: GroupAggregator<TData>
482
+ /**
483
+ * What this column contributes to the grid's footer summary row (the one
484
+ * turned on with `summary` / `enableRowSummaries`). Takes the same
485
+ * aggregators as {@link aggregate}, and the result is formatted with this
486
+ * column's `format`.
487
+ *
488
+ * Without it the footer falls back to its default: the sum of a numeric
489
+ * column, `Count: N` otherwise. Set `false` to leave the cell blank, which is
490
+ * usually what an actions or checkbox column wants.
491
+ *
492
+ * { field: 'amount', summary: 'avg' }
493
+ * { id: 'actions', summary: false }
494
+ */
495
+ summary?: GroupAggregator<TData> | false
496
+ /**
497
+ * Render the cell as an in-cell sparkline chart. The cell value should be
498
+ * an array of numbers (or a comma/space separated string). Mutually
499
+ * exclusive with a custom `cell` renderer (a `cell` wins if both are set).
500
+ *
501
+ * { sparkline: { type: 'line' } } // default line
502
+ * { sparkline: { type: 'bar', color: '#16a34a' } }
503
+ * { sparkline: { type: 'winloss' } } // sign-only up/down
504
+ *
505
+ * See `SparklineConfig` for the full option set (type, color,
506
+ * negativeColor, width, height, fixed min/max).
507
+ */
508
+ sparkline?: SparklineConfig
509
+ /** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */
510
+ width?: number
511
+ /**
512
+ * Whether the user may resize this column. Only consulted when the grid has
513
+ * `columnResize` on - it narrows that, it does not enable anything.
514
+ *
515
+ * `false` removes the column's drag handle entirely, so pointer drag, the
516
+ * keyboard arrows and double-click-to-autosize are all gone with it, and the
517
+ * column menu drops its Autosize item. Use it for the columns whose width is
518
+ * part of the layout rather than a preference: a row-number gutter, a
519
+ * checkbox column, a fixed icon column.
520
+ *
521
+ * Programmatic sizing is unaffected - `api.autosizeColumn()`,
522
+ * `api.setColumnWidth()` and `fitColumns` all still apply, the same way they
523
+ * do when `columnResize` is off. This governs the user affordance only.
524
+ */
525
+ resizable?: boolean
526
+ /**
527
+ * Initial visibility. Set `false` to start the column hidden while still
528
+ * listing it in the Choose Columns UI for the user to re-enable. Applied
529
+ * once at mount; after that `api.setColumnVisible` / user toggles win.
530
+ * On a group column, `false` hides the whole group's leaf columns.
531
+ */
532
+ visible?: boolean
533
+ /**
534
+ * Horizontal alignment for header and body cells. When omitted, the
535
+ * default is inferred from `editorType`:
536
+ * - `'number' | 'date' | 'datetime'` → `'right'`
537
+ * - `'checkbox'` → `'center'`
538
+ * - everything else → `'left'`
539
+ */
540
+ align?: 'left' | 'center' | 'right'
541
+ /**
542
+ * Per-cell conditional CSS. Two shapes:
543
+ *
544
+ * - **String** (or array of strings): class name(s) added to the
545
+ * cell's `<td>` for every row in this column.
546
+ * - **Function**: invoked per cell with the same `CellContext` shape
547
+ * the `cell` renderer receives. Return a string, an array of
548
+ * strings, or an object mapping class names to booleans.
549
+ *
550
+ * Use it for status tinting, conditional bold, "negative number"
551
+ * coloring - anything that's a function of the row's value. Cells
552
+ * still receive their format / cell renderer; the class just
553
+ * augments the rendered `<td>`.
554
+ */
555
+ cellClass?:
556
+ | string
557
+ | ReadonlyArray<string>
558
+ | ((ctx: CellContext<TData>) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null)
559
+ }
560
+
561
+ /**
562
+ * A column definition keyed only by your row type - the ergonomic form of
563
+ * {@link ColumnDef}, whose first parameter is a phantom feature bag that is
564
+ * almost always `{}`.
565
+ *
566
+ * ```ts
567
+ * const columns: GridColumns<Person> = [{ field: 'firstName', header: 'Name' }]
568
+ * ```
569
+ *
570
+ * Interchangeable with `ColumnDef<{}, TData>` and `ColumnDef<typeof features,
571
+ * TData>` in both directions, so it mixes freely with existing code.
572
+ */
573
+ export type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeatures, TData>
574
+
575
+ /** An array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`. */
576
+ export type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>
577
+
578
+ /**
579
+ * A resolved column: your {@link ColumnDef} plus everything the grid computed
580
+ * from it - its id, its depth under any group header, and the sort handlers a
581
+ * header needs. This is what you receive in render contexts; the `ColumnDef`
582
+ * is what you wrote.
583
+ */
584
+ export type Column<TData extends RowData> = {
585
+ id: string
586
+ columnDef: ColumnDef<any, TData>
587
+ depth: number
588
+ parentId?: string
589
+ getCanSort: () => boolean
590
+ getCanFilter: () => boolean
591
+ getIsSorted: () => false | 'asc' | 'desc'
592
+ getToggleSortingHandler: () => () => void
593
+ }
594
+
595
+ /**
596
+ * One header cell. `colSpan` is how many leaf columns it covers, and
597
+ * `isPlaceholder` marks the empty cells that pad a group-header row so the
598
+ * levels line up.
599
+ */
600
+ export type Header<TData extends RowData> = {
601
+ id: string
602
+ isPlaceholder: boolean
603
+ colSpan: number
604
+ column: Column<TData>
605
+ getContext: () => HeaderContext<TData>
606
+ }
607
+
608
+ /** One row of header cells. A grid with grouped columns has several, outermost first. */
609
+ export type HeaderGroup<TData extends RowData> = {
610
+ id: string
611
+ headers: Array<Header<TData>>
612
+ }
613
+
614
+ /** One cell: the intersection of a {@link Row} and a {@link Column}. */
615
+ export type Cell<TData extends RowData> = {
616
+ id: string
617
+ row: Row<TData>
618
+ column: Column<TData>
619
+ getValue: () => unknown
620
+ getContext: () => CellContext<TData>
621
+ }
622
+
623
+ /**
624
+ * A row in the display model. `original` is your untouched data object;
625
+ * everything else is grid-computed. `index` is the position in the displayed
626
+ * set, so it shifts as sorting and filtering change - key on `id`, not index.
627
+ *
628
+ * Group rows and tree parents carry `subRows`; a plain data row does not.
629
+ */
630
+ export type Row<TData extends RowData> = {
631
+ id: string
632
+ index: number
633
+ original: TData
634
+ depth: number
635
+ subRows?: Array<Row<TData>>
636
+ /** Total leaf (data) rows under this group row. Undefined for data rows. */
637
+ leafCount?: number
638
+ getCanExpand: () => boolean
639
+ getIsExpanded: () => boolean
640
+ toggleExpanded: () => void
641
+ getIsSelected: () => boolean
642
+ toggleSelected: () => void
643
+ getAllCells: () => Array<Cell<TData>>
644
+ getCellValueByColumnId: (columnId: string) => unknown
645
+ }
646
+
647
+ /** The output of the row pipeline: the rows to display, in order. */
648
+ export type RowModel<TData extends RowData> = {
649
+ rows: Array<Row<TData>>
650
+ }
651
+
652
+ /**
653
+ * The minimal reactive store behind the headless core - read `state`, write
654
+ * through `setState`, and `subscribe` for changes. Deliberately framework
655
+ * free, which is what lets the core run under plain Node.
656
+ *
657
+ * In Svelte you rarely touch this: `subscribeGrid` wraps it with fine-grained
658
+ * selectors so a component only re-runs for the slice it read.
659
+ */
660
+ export type Store<T> = {
661
+ readonly state: T
662
+ setState: (updater: (prev: T) => T) => void
663
+ subscribe: (listener: () => void) => () => void
664
+ }
665
+
666
+ function createStore<T>(initial: T): Store<T> {
667
+ let value = initial
668
+ const listeners = new Set<() => void>()
669
+ return {
670
+ get state() {
671
+ return value
672
+ },
673
+ setState(updater) {
674
+ value = updater(value)
675
+ listeners.forEach((listener) => listener())
676
+ },
677
+ subscribe(listener) {
678
+ listeners.add(listener)
679
+ return () => listeners.delete(listener)
680
+ },
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Click-to-sort. Injected by the `sortable` shortcut.
686
+ *
687
+ * This and the five features below are opaque markers: pass the ones you want
688
+ * to {@link tableFeatures} and the grid wires up the matching row model. With
689
+ * `<SvGrid>` you rarely name them - the boolean shortcuts (`sortable`,
690
+ * `filterable`, `pageable`, `groupable`) inject them for you. Reach for them
691
+ * directly when driving the headless core, or when you want a feature on
692
+ * without its UI.
693
+ *
694
+ * The names match TanStack Table v9, so a features object written for it works
695
+ * here unchanged.
696
+ */
697
+ export const rowSortingFeature = { key: 'rowSortingFeature' }
698
+ /** Per-column filtering. Injected by the `filterable` shortcut. */
699
+ export const columnFilteringFeature = { key: 'columnFilteringFeature' }
700
+ /** Paging of the row model. Injected by the `pageable` shortcut. */
701
+ export const rowPaginationFeature = { key: 'rowPaginationFeature' }
702
+ /** Row grouping with aggregation. Injected by the `groupable` shortcut. */
703
+ export const columnGroupingFeature = { key: 'columnGroupingFeature' }
704
+ /** Row selection state (the checkbox column reads it). */
705
+ export const rowSelectionFeature = { key: 'rowSelectionFeature' }
706
+ /** Expand / collapse, for tree rows and master-detail. */
707
+ export const rowExpandingFeature = { key: 'rowExpandingFeature' }
708
+
709
+ /**
710
+ * Declare which features a grid uses. Identity at runtime - its whole job is to
711
+ * capture the exact set in the type, so `ColumnDef<typeof features, Row>` knows
712
+ * what is registered and anything you did not register is tree-shaken out.
713
+ *
714
+ * ```ts
715
+ * const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
716
+ * ```
717
+ *
718
+ * Same call signature as TanStack Table v9, so a features object written for it
719
+ * transfers unchanged.
720
+ */
721
+ export function tableFeatures<T extends TableFeatures>(features: T): T {
722
+ return features
723
+ }
724
+
725
+ /**
726
+ * Built-in comparators, chosen per column by its data type. `auto` compares as
727
+ * text; set a column's type or supply your own comparator to override.
728
+ */
729
+ export const sortFns = {
730
+ auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
731
+ number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
732
+ date: (a: unknown, b: unknown) => {
733
+ const aa = new Date(a as any).getTime()
734
+ const bb = new Date(b as any).getTime()
735
+ return aa - bb
736
+ },
737
+ }
738
+
739
+ /**
740
+ * Built-in match functions, named by {@link ColumnFilter}'s `fn`.
741
+ * `includesString` is case-insensitive substring; `equals` is strict identity.
742
+ */
743
+ export const filterFns = {
744
+ includesString: (value: unknown, query: string) =>
745
+ String(value).toLowerCase().includes(query.toLowerCase()),
746
+ equals: (value: unknown, query: unknown) => value === query,
747
+ }
748
+
749
+ /**
750
+ * Everything a base row needs that is the same for every row in the table.
751
+ *
752
+ * One object per table, referenced by every row, instead of one closure scope
753
+ * per row. See {@link BASE_ROW_METHODS}.
754
+ */
755
+ type BaseRowCtx<TData extends RowData> = {
756
+ grid: SvGrid<TData>
757
+ store: { state: Record<string, any> }
758
+ columns: Array<Column<TData>>
759
+ columnCount: number
760
+ columnIndexById: Map<string, number>
761
+ }
762
+
763
+ /**
764
+ * Keys for a base row's private fields.
765
+ *
766
+ * Symbols, not string keys, and that is load-bearing. A row's shared methods
767
+ * need a pointer back to the table, but `_ctx` as a normal property made every
768
+ * row serialise the entire grid: `JSON.stringify(oneRow)` grew with the dataset
769
+ * (981 chars at 3 rows, 67,719 at 3,000) because `options.data` is reachable
770
+ * through it, so stringifying a row model was quadratic. Rows used to serialise
771
+ * to a small constant and must again.
772
+ *
773
+ * A symbol key is invisible to `JSON.stringify`, `Object.keys` and `for...in`,
774
+ * yet IS copied by object spread - which matters because several row models
775
+ * legitimately do `{ ...row, depth }` and the clone needs these to work.
776
+ * Non-enumerable string keys would have hidden them from JSON but also from the
777
+ * spread, silently breaking every cloned row.
778
+ */
779
+ const ROW_CTX = Symbol('svgrid.row.ctx')
780
+ const ROW_VALUES = Symbol('svgrid.row.values')
781
+ const ROW_CELLS = Symbol('svgrid.row.cells')
782
+
783
+ /** A base row's private fields, on top of the public {@link Row} surface. */
784
+ type BaseRowState<TData extends RowData> = Row<TData> & {
785
+ [ROW_CTX]: BaseRowCtx<TData>
786
+ [ROW_VALUES]: Array<unknown> | null
787
+ [ROW_CELLS]: Array<Cell<TData>> | null
788
+ }
789
+
790
+ /**
791
+ * The methods every base row carries, defined ONCE and assigned by reference.
792
+ *
793
+ * Rows used to be built as object literals whose methods were closures, which
794
+ * meant a 100k-row grid allocated 700k closures and a closure scope per row
795
+ * before painting anything. Measured at 100k x 9: 13.8 ms and 56.5 MB to build,
796
+ * against 2.0 ms and 14.5 MB for this shape - the single largest cost in
797
+ * mounting a large grid.
798
+ *
799
+ * They read their row through `this` rather than a captured variable, which is
800
+ * why they can be shared. Note they are assigned as OWN properties rather than
801
+ * put on a prototype: `Row` is public, several row models legitimately do
802
+ * `{ ...row, depth }`, and a spread copies own properties but not a prototype.
803
+ * A class here would silently strip every method off a cloned row.
804
+ */
805
+ const BASE_ROW_METHODS = {
806
+ getCanExpand(this: BaseRowState<RowData>) {
807
+ return false
808
+ },
809
+ getIsExpanded(this: BaseRowState<RowData>) {
810
+ return Boolean((this[ROW_CTX].store.state.expanded ?? {})[this.id])
811
+ },
812
+ toggleExpanded(this: BaseRowState<RowData>) {
813
+ const id = this.id
814
+ this[ROW_CTX].grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
815
+ },
816
+ getIsSelected(this: BaseRowState<RowData>) {
817
+ return Boolean((this[ROW_CTX].store.state.rowSelection ?? {})[this.id])
818
+ },
819
+ toggleSelected(this: BaseRowState<RowData>) {
820
+ const id = this.id
821
+ this[ROW_CTX].grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
822
+ },
823
+ getAllCells(this: BaseRowState<RowData>) {
824
+ return this[ROW_CELLS] ?? buildBaseRowCells(this)
825
+ },
826
+ getCellValueByColumnId(this: BaseRowState<RowData>, columnId: string) {
827
+ const idx = this[ROW_CTX].columnIndexById.get(columnId)
828
+ if (idx === undefined) return undefined
829
+ if (!this[ROW_VALUES]) this[ROW_VALUES] = baseRowValues(this)
830
+ return this[ROW_VALUES][idx]
831
+ },
832
+ }
833
+
834
+ /**
835
+ * Materialise one row's `Cell[]`, memoised on the row.
836
+ *
837
+ * A free function taking the row rather than a method using `this`, because the
838
+ * cell closures need a stable reference to it and aliasing `this` inside a
839
+ * method is exactly the pattern that produces `self`/`that` bugs.
840
+ */
841
+ function buildBaseRowCells<TData extends RowData>(row: BaseRowState<TData>): Array<Cell<TData>> {
842
+ const { columns, columnCount, grid } = row[ROW_CTX]
843
+ const built = new Array<Cell<TData>>(columnCount)
844
+ for (let i = 0; i < columnCount; i++) {
845
+ const column = columns[i]!
846
+ const colIndex = i
847
+ const cell: Cell<TData> = {
848
+ id: `${row.id}_${column.id}`,
849
+ row,
850
+ column,
851
+ getValue: () => {
852
+ if (!row[ROW_VALUES]) row[ROW_VALUES] = baseRowValues(row)
853
+ return row[ROW_VALUES][colIndex]
854
+ },
855
+ getContext: () => ({
856
+ cell,
857
+ row,
858
+ column,
859
+ table: grid,
860
+ getValue: () => cell.getValue(),
861
+ }),
862
+ }
863
+ built[i] = cell
864
+ }
865
+ row[ROW_CELLS] = built
866
+ return built
867
+ }
868
+
869
+ /**
870
+ * Resolve every column's value for one row. Kept lazy: a 100k-row grid showing
871
+ * twenty rows must not materialise 900k values to paint.
872
+ */
873
+ function baseRowValues<TData extends RowData>(row: BaseRowState<TData>): Array<unknown> {
874
+ const { columns, columnCount } = row[ROW_CTX]
875
+ const original = row.original as Record<string, unknown>
876
+ const values = new Array<unknown>(columnCount)
877
+ for (let i = 0; i < columnCount; i++) {
878
+ const def = columns[i]!.columnDef
879
+ if (def.fieldFn) values[i] = def.fieldFn(original as TData)
880
+ else if (def.field) values[i] = original[def.field]
881
+ else values[i] = undefined
882
+ }
883
+ return values
884
+ }
885
+
886
+ /**
887
+ * One stage of the row pipeline: takes the rows produced so far and returns the
888
+ * next set. Stages compose in the order given to `_rowModels`, so filtering
889
+ * before sorting sorts only what survived the filter.
890
+ */
891
+ export type RowModelFactory<TData extends RowData> = (args: {
892
+ table: SvGrid<TData>
893
+ rows: Array<Row<TData>>
894
+ }) => Array<Row<TData>>
895
+
896
+ /**
897
+ * The identity stage that starts every pipeline. Always required, even when no
898
+ * other stage is: it is what turns your data into rows.
899
+ */
900
+ export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {
901
+ return ({ rows }) => rows
902
+ }
903
+ /**
904
+ * Drops rows that fail the active {@link ColumnFiltersState}. Pairs with
905
+ * `columnFilteringFeature`; without it there are no filters to apply.
906
+ */
907
+ export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
908
+ return ({ table, rows }) => {
909
+ const filters: ColumnFiltersState = table.getState().columnFilters ?? []
910
+ if (!filters.length) return rows
911
+
912
+ // Resolve each filter's match function once, outside the row loop.
913
+ const compiled = filters.map((filter) => ({
914
+ id: filter.id,
915
+ value: filter.value,
916
+ fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,
917
+ }))
918
+
919
+ return rows.filter((row) => {
920
+ for (let i = 0; i < compiled.length; i++) {
921
+ const filter = compiled[i]!
922
+ // `getCellValueByColumnId` rather than `getAllCells().find(...)`.
923
+ // Both read the same lazily-built `cachedValues` array, but the latter
924
+ // also builds and caches the row's whole `Cell[]` - one object per
925
+ // column - purely to reach one field. On a 100k-row grid that is
926
+ // 100,000 cell arrays the filter never looks at again, and it defeats
927
+ // the laziness the row factory exists to provide.
928
+ if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value as any)) return false
929
+ }
930
+ return true
931
+ })
932
+ }
933
+ }
934
+ /**
935
+ * Narrows the rows to the current page. Put it LAST: anything after it would
936
+ * only ever see one page of data.
937
+ */
938
+ export function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {
939
+ return ({ table, rows }) => {
940
+ const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }
941
+ const start = pagination.pageIndex * pagination.pageSize
942
+ return rows.slice(start, start + pagination.pageSize)
943
+ }
944
+ }
945
+ /**
946
+ * Buckets rows by the active {@link GroupingState} and inserts a group row
947
+ * ahead of each bucket, carrying that bucket's aggregates.
948
+ */
949
+ export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {
950
+ return ({ table, rows }) => {
951
+ const grouping: GroupingState = table.getState().grouping ?? []
952
+ if (!grouping.length) return rows
953
+ const columns = table.getAllColumns()
954
+
955
+ // Recursively bucket rows by each grouping column in turn. At every level a
956
+ // group row is built that stands in for its children - a non-group column
957
+ // resolves to the value shared by every leaf row, or to undefined when the
958
+ // leaves disagree.
959
+ function buildGroups(
960
+ input: Array<Row<TData>>,
961
+ levelIndex: number,
962
+ depth: number,
963
+ idPrefix: string,
964
+ /** Grouping columns already fixed by an ancestor bucket, and their raw
965
+ * values - `undefined` where that bucket mixed several. */
966
+ fixedValues: ReadonlyMap<string, unknown>,
967
+ ): Array<Row<TData>> {
968
+ if (levelIndex >= grouping.length) {
969
+ // Leaves: actual data rows, with their nesting depth recorded.
970
+ return input.map((row) => ({ ...row, depth }))
971
+ }
972
+ const groupKey = grouping[levelIndex]
973
+ if (!groupKey) return input
974
+
975
+ // Buckets carry the RAW grouping value alongside the rows, plus whether
976
+ // the bucket saw more than one distinct raw value. Both are needed to let
977
+ // deeper levels skip re-scanning this column: buckets are keyed by
978
+ // `String(value ?? '')`, so `null`, `undefined` and `''` collapse into one
979
+ // bucket, and a scan of such a bucket would report disagreement. Tracking
980
+ // it here costs one comparison per row and keeps the shortcut honest.
981
+ type Bucket = { rows: Array<Row<TData>>; raw: unknown; mixed: boolean }
982
+ const buckets = new Map<string, Bucket>()
983
+ for (const row of input) {
984
+ const value = row.getCellValueByColumnId(groupKey)
985
+ const key = String(value ?? '')
986
+ const bucket = buckets.get(key)
987
+ if (bucket) {
988
+ bucket.rows.push(row)
989
+ if (!bucket.mixed && bucket.raw !== value) bucket.mixed = true
990
+ } else {
991
+ buckets.set(key, { rows: [row], raw: value, mixed: false })
992
+ }
993
+ }
994
+
995
+ const groupRows: Array<Row<TData>> = []
996
+ let index = 0
997
+ buckets.forEach((bucket, key) => {
998
+ const children = bucket.rows
999
+ const id = `${idPrefix}_${groupKey}_${key}`
1000
+ // Record this column as fixed for deeper levels ONLY when the bucket is
1001
+ // homogeneous. If all rows here share a raw value, so does every subset
1002
+ // of them, which is what makes the shortcut sound.
1003
+ //
1004
+ // A MIXED bucket must not be recorded at all - not even as "undefined".
1005
+ // `null`, `undefined` and `''` share a bucket key, so a mixed bucket can
1006
+ // still split into homogeneous children one level down, and those
1007
+ // children have a real shared value that a scan would find. Marking the
1008
+ // column resolved here would hand them the parent's disagreement.
1009
+ const nextFixed = bucket.mixed ? fixedValues : new Map(fixedValues).set(groupKey, bucket.raw)
1010
+ const subRows = buildGroups(children, levelIndex + 1, depth + 1, id, nextFixed)
1011
+ const isDeepest = levelIndex + 1 >= grouping.length
1012
+ const leafCount = isDeepest
1013
+ ? subRows.length
1014
+ : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)
1015
+
1016
+ // Every grouping column ABOVE this level is already resolved: bucketing
1017
+ // by it is what made it constant, so scanning the children to rediscover
1018
+ // it is pure waste. Only the current level's key short-circuited before,
1019
+ // so a second-level group walked all of its children to re-derive the
1020
+ // first level's value - about 100,000 reads on the 100k x 9 two-level
1021
+ // case, for an answer already in hand.
1022
+ //
1023
+ // The current level still returns the stringified bucket key rather than
1024
+ // the raw value, because that is what it has always returned and the
1025
+ // group row's display depends on it.
1026
+ const resolveColumnValue = (columnId: string): unknown => {
1027
+ if (columnId === groupKey) return key
1028
+ if (fixedValues.has(columnId)) return fixedValues.get(columnId)
1029
+ let resolved: unknown
1030
+ let hasResolved = false
1031
+ for (const child of children) {
1032
+ const childValue = child.getCellValueByColumnId(columnId)
1033
+ if (!hasResolved) {
1034
+ resolved = childValue
1035
+ hasResolved = true
1036
+ } else if (childValue !== resolved) {
1037
+ return undefined
1038
+ }
1039
+ }
1040
+ return resolved
1041
+ }
1042
+
1043
+ const groupOriginal: Record<string, unknown> = {}
1044
+ columns.forEach((column) => {
1045
+ const field = column.columnDef.field
1046
+ if (!field) return
1047
+ const agg = column.columnDef.aggregate
1048
+ groupOriginal[field] = agg
1049
+ ? applyGroupAggregate(agg, column.id, children)
1050
+ : resolveColumnValue(column.id)
1051
+ })
1052
+
1053
+ const groupRow: Row<TData> = {
1054
+ id,
1055
+ index: index++,
1056
+ original: groupOriginal as TData,
1057
+ depth,
1058
+ subRows,
1059
+ leafCount,
1060
+ getCanExpand: () => true,
1061
+ getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
1062
+ toggleExpanded: () => {
1063
+ table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
1064
+ },
1065
+ getIsSelected: () => Boolean((table.getState().rowSelection ?? {})[id]),
1066
+ toggleSelected: () => {
1067
+ table.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
1068
+ },
1069
+ getAllCells: () => [],
1070
+ // Prefer the precomputed group value (which carries aggregates)
1071
+ // and fall back to the shared-value resolver for columns without
1072
+ // a field.
1073
+ getCellValueByColumnId: (columnId: string) => {
1074
+ const col = columns.find((c) => c.id === columnId)
1075
+ const field = col?.columnDef.field
1076
+ if (field && field in groupOriginal) return groupOriginal[field]
1077
+ return resolveColumnValue(columnId)
1078
+ },
1079
+ }
1080
+ groupRows.push(groupRow)
1081
+ })
1082
+ return groupRows
1083
+ }
1084
+
1085
+ return buildGroups(rows, 0, 0, 'group', new Map())
1086
+ }
1087
+ }
1088
+ /**
1089
+ * How to read a hierarchy out of FLAT rows: each row names its parent, and the
1090
+ * grid reconstructs the tree. Rows whose parent id matches nothing become roots
1091
+ * rather than disappearing.
1092
+ *
1093
+ * For nested source data (`children: [...]`), flatten it first with
1094
+ * {@link flattenTreeData}.
1095
+ */
1096
+ export type TreeRowModelOptions = {
1097
+ /** Field holding each row's parent id. Rows with no parent are roots. */
1098
+ parentField: string
1099
+ /** Field holding the row's own id. Defaults to `'id'`. */
1100
+ idField?: string
1101
+ }
1102
+
1103
+ /**
1104
+ * Client-side tree data: nest the grid's own flat rows into a parent/child
1105
+ * hierarchy that `createExpandedRowModel` then walks.
1106
+ *
1107
+ * This works on the rows the grid already built rather than on raw data, so
1108
+ * tree rows keep their cells, editing, selection and formatting - they are real
1109
+ * data rows that happen to have children, not synthetic banners like grouping's.
1110
+ * That is also why the model is parent-id based: nested source arrays never
1111
+ * become rows (the grid only builds rows for `data`), so nested input is
1112
+ * flattened first with {@link flattenTreeData}. One code path, no duplicated
1113
+ * row construction.
1114
+ *
1115
+ * Rows are tagged `__treeRow` so `isGroupRow` does not mistake an expandable
1116
+ * data row for a full-width group banner.
1117
+ */
1118
+ export function createTreeRowModel<TData extends RowData>(
1119
+ options: TreeRowModelOptions,
1120
+ ): RowModelFactory<TData> {
1121
+ const { parentField, idField = 'id' } = options
1122
+ return ({ table, rows }) => {
1123
+ if (!rows.length) return rows
1124
+ const keyOf = (row: Row<TData>) => (row.original as any)?.[idField]
1125
+ const parentOf = (row: Row<TData>) => (row.original as any)?.[parentField]
1126
+
1127
+ const present = new Set<unknown>()
1128
+ for (const row of rows) present.add(keyOf(row))
1129
+
1130
+ const childrenByParent = new Map<unknown, Array<Row<TData>>>()
1131
+ const roots: Array<Row<TData>> = []
1132
+ for (const row of rows) {
1133
+ const parent = parentOf(row)
1134
+ // A row whose parent is absent (filtered out, or never existed) becomes a
1135
+ // root rather than disappearing - silently dropping rows is worse than a
1136
+ // shallower tree. Self-parenting is treated the same way.
1137
+ if (parent == null || parent === keyOf(row) || !present.has(parent)) {
1138
+ roots.push(row)
1139
+ continue
1140
+ }
1141
+ const list = childrenByParent.get(parent) ?? []
1142
+ list.push(row)
1143
+ childrenByParent.set(parent, list)
1144
+ }
1145
+
1146
+ // Guards a cycle in the parent chain from recursing forever.
1147
+ const seen = new Set<unknown>()
1148
+ const build = (row: Row<TData>, depth: number): Row<TData> => {
1149
+ const key = keyOf(row)
1150
+ const id = row.id
1151
+ if (seen.has(key)) {
1152
+ return { ...row, depth, subRows: [], getCanExpand: () => false } as Row<TData>
1153
+ }
1154
+ seen.add(key)
1155
+ const subRows = (childrenByParent.get(key) ?? []).map((child) => build(child, depth + 1))
1156
+ return {
1157
+ ...row,
1158
+ depth,
1159
+ subRows,
1160
+ leafCount: subRows.reduce((n, sub) => n + 1 + (sub.leafCount ?? 0), 0),
1161
+ __treeRow: true,
1162
+ getCanExpand: () => subRows.length > 0,
1163
+ getIsExpanded: () => Boolean((table.getState().expanded ?? {})[id]),
1164
+ toggleExpanded: () => {
1165
+ table.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
1166
+ },
1167
+ } as Row<TData>
1168
+ }
1169
+
1170
+ return roots.map((root) => build(root, 0))
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * How to flatten NESTED source data into the parent-id shape tree rows need.
1176
+ * `parentField` is written onto each row, so point `treeData.parentField` at
1177
+ * the same name afterwards.
1178
+ */
1179
+ export type FlattenTreeOptions = {
1180
+ /** Field holding an array of child objects. */
1181
+ childrenField: string
1182
+ /** Field holding each object's id. Defaults to `'id'`. */
1183
+ idField?: string
1184
+ /** Field to WRITE the resolved parent id onto. Defaults to `'__parentId'`. */
1185
+ parentField?: string
1186
+ }
1187
+
1188
+ /**
1189
+ * Flatten nested tree data into the flat parent-id shape `createTreeRowModel`
1190
+ * consumes, stamping each child with its parent's id.
1191
+ *
1192
+ * Children are emitted directly after their parent so the natural order already
1193
+ * matches the rendered tree. The `childrenField` array is left on the objects
1194
+ * (harmless, and callers often still want it); only the parent link is added.
1195
+ */
1196
+ export function flattenTreeData<T extends RowData>(
1197
+ data: ReadonlyArray<T>,
1198
+ options: FlattenTreeOptions,
1199
+ ): T[] {
1200
+ const { childrenField, idField = 'id', parentField = '__parentId' } = options
1201
+ const out: T[] = []
1202
+ const walk = (nodes: ReadonlyArray<T>, parentId: unknown) => {
1203
+ for (const node of nodes) {
1204
+ const flat = { ...node, [parentField]: parentId } as T
1205
+ out.push(flat)
1206
+ const kids = (node as any)[childrenField]
1207
+ if (Array.isArray(kids) && kids.length) walk(kids as ReadonlyArray<T>, (node as any)[idField])
1208
+ }
1209
+ }
1210
+ walk(data, null)
1211
+ return out
1212
+ }
1213
+
1214
+ /**
1215
+ * Hides the descendants of collapsed rows. Needed for grouping, tree data and
1216
+ * master-detail alike - all three are the same expand/collapse mechanism.
1217
+ */
1218
+ export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
1219
+ return ({ table, rows }) => {
1220
+ const expanded: ExpandedState = table.getState().expanded ?? {}
1221
+ const flattened: Array<Row<TData>> = []
1222
+ const visit = (row: Row<TData>) => {
1223
+ flattened.push(row)
1224
+ if (row.subRows?.length && expanded[row.id]) {
1225
+ for (const sub of row.subRows) visit(sub)
1226
+ }
1227
+ }
1228
+ for (const row of rows) visit(row)
1229
+ return flattened
1230
+ }
1231
+ }
1232
+ /**
1233
+ * Orders rows by the active {@link SortingState}. Pass your own comparators to
1234
+ * override the built-in {@link sortFns} - useful for locale-aware or
1235
+ * domain-specific ordering.
1236
+ */
1237
+ export function createSortedRowModel<TData extends RowData>(
1238
+ localSortFns: typeof sortFns = sortFns,
1239
+ ): RowModelFactory<TData> {
1240
+ return function sortedRowModelStage({ table, rows }) {
1241
+ const sorting = table.getState().sorting ?? []
1242
+ if (!sorting.length) return rows
1243
+
1244
+ // Resolve every clause ONCE, before sorting.
1245
+ //
1246
+ // This used to live inside the comparator, so `getAllColumns().find(...)`
1247
+ // ran per comparison per clause: a single-clause sort of 100k rows made
1248
+ // 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;
1249
+ // `pnpm bench --case=sort-1col`). The comparator is called O(n log n)
1250
+ // times, so anything inside it that is not O(1) sets the cost of the sort.
1251
+ const allColumns = table.getAllColumns()
1252
+ const clauses: Array<{
1253
+ keys: Array<any>
1254
+ desc: boolean
1255
+ compare: (a: any, b: any) => number
1256
+ }> = []
1257
+
1258
+ for (const clause of sorting) {
1259
+ const column = allColumns.find((col) => col.id === clause.id)
1260
+ if (!column) continue
1261
+ const editorType = column.columnDef.editorType
1262
+ const comparator =
1263
+ editorType === 'number'
1264
+ ? localSortFns.number
1265
+ : editorType === 'date' || editorType === 'datetime'
1266
+ ? localSortFns.date
1267
+ : localSortFns.auto
1268
+
1269
+ // Precompute one sort key per row, so the comparator reads an array slot
1270
+ // instead of walking the row's column index on every comparison. For the
1271
+ // three built-in comparators the key is also cheaper to compare than the
1272
+ // raw value: a timestamp rather than two `new Date()` allocations, a
1273
+ // number rather than two `Number()` coercions, a collator rather than a
1274
+ // fresh one per `localeCompare` call.
1275
+ //
1276
+ // The identity checks against `sortFns` matter: `localSortFns` is a
1277
+ // public parameter, so a caller can substitute their own comparators.
1278
+ // When they have, we fall through to calling their function with the raw
1279
+ // values - still hoisted, just not specialised.
1280
+ const columnId = column.id
1281
+ const n = rows.length
1282
+ let keys: Array<any> = new Array(n)
1283
+ let compare: (a: any, b: any) => number
1284
+
1285
+ if (comparator === sortFns.number) {
1286
+ for (let i = 0; i < n; i++) keys[i] = Number(rows[i]!.getCellValueByColumnId(columnId) ?? 0)
1287
+ compare = compareNumericKeys
1288
+ } else if (comparator === sortFns.date) {
1289
+ for (let i = 0; i < n; i++) {
1290
+ keys[i] = new Date(rows[i]!.getCellValueByColumnId(columnId) as any).getTime()
1291
+ }
1292
+ compare = compareNumericKeys
1293
+ } else if (comparator === sortFns.auto) {
1294
+ const strings: string[] = new Array(n)
1295
+ // Decide whether ranking is worth attempting BEFORE paying for it.
1296
+ //
1297
+ // Building the distinct set and then discarding it costs about 9 ms on
1298
+ // a 100k-row column where nearly every value is unique, and ranking
1299
+ // saves about 19 ms where they repeat - so guessing wrong in either
1300
+ // direction is measurable. A small stride sample answers it for well
1301
+ // under a millisecond.
1302
+ //
1303
+ // Strided rather than the first N rows: data arrives sorted or
1304
+ // clustered often enough that a prefix is a bad estimator of the whole
1305
+ // column. Reading every k-th row is no more expensive and does not care
1306
+ // how the rows are arranged.
1307
+ const rankLimit = n >> 1
1308
+ let distinct: Set<string> | null = null
1309
+ if (n > 0) {
1310
+ const sampleTarget = Math.min(n, 256)
1311
+ const stride = Math.max(1, Math.floor(n / sampleTarget))
1312
+ const sample = new Set<string>()
1313
+ let sampled = 0
1314
+ for (let i = 0; i < n; i += stride) {
1315
+ sample.add(String(rows[i]!.getCellValueByColumnId(columnId)))
1316
+ sampled++
1317
+ }
1318
+ // Only attempt ranking when the sample suggests real repetition.
1319
+ if (sample.size * 2 <= sampled) distinct = new Set()
1320
+ }
1321
+
1322
+ for (let i = 0; i < n; i++) {
1323
+ const s = String(rows[i]!.getCellValueByColumnId(columnId))
1324
+ strings[i] = s
1325
+ if (distinct) {
1326
+ distinct.add(s)
1327
+ if (distinct.size > rankLimit) distinct = null
1328
+ }
1329
+ }
1330
+
1331
+ // Collation is by far the most expensive comparison we do - a CPU
1332
+ // profile of a 100k text sort put 65% of the whole operation inside the
1333
+ // collator. But a column's DISTINCT values are usually far fewer than
1334
+ // its rows (statuses, regions, categories, owners), so rank the
1335
+ // distinct values once and sort by rank afterwards. That turns
1336
+ // O(n log n) collator calls into O(u log u), where u is the number of
1337
+ // distinct values, and the resulting order is identical because rank is
1338
+ // a monotone relabelling of the collated order - equal strings share a
1339
+ // rank, so ties still fall through to the stable sort exactly as before.
1340
+ //
1341
+ // Guarded on the uniqueness ratio: when nearly every value is distinct
1342
+ // the ranking pass cannot save any collator calls and would just add an
1343
+ // O(n) Map build, so that case keeps comparing directly.
1344
+ if (distinct) {
1345
+ const ordered = Array.from(distinct).sort(compareCollatedKeys)
1346
+ const rankOf = new Map<string, number>()
1347
+ for (let i = 0; i < ordered.length; i++) rankOf.set(ordered[i]!, i)
1348
+ for (let i = 0; i < n; i++) keys[i] = rankOf.get(strings[i]!)!
1349
+ compare = compareNumericKeys
1350
+ } else {
1351
+ keys = strings
1352
+ compare = compareCollatedKeys
1353
+ }
1354
+ } else {
1355
+ for (let i = 0; i < n; i++) keys[i] = rows[i]!.getCellValueByColumnId(columnId)
1356
+ compare = comparator
1357
+ }
1358
+
1359
+ clauses.push({ keys, desc: clause.desc, compare })
1360
+ }
1361
+
1362
+ if (!clauses.length) return rows
1363
+
1364
+ // Sort an index array, then materialise. `Array.prototype.sort` is stable,
1365
+ // so equal keys keep their original relative order exactly as the previous
1366
+ // `[...rows].sort(...)` did.
1367
+ const order = new Array<number>(rows.length)
1368
+ for (let i = 0; i < order.length; i++) order[i] = i
1369
+
1370
+ // Single-clause sorts get a specialised comparator.
1371
+ //
1372
+ // Most sorts are one column, and that path runs O(n log n) times - 1.66
1373
+ // million comparisons for 100k rows. The general loop pays a clause-array
1374
+ // index, three property loads and an indirect call on every one of them,
1375
+ // none of which vary once the clause list is fixed. Hoisting them into a
1376
+ // closure and, for the numeric comparator, inlining the subtraction removes
1377
+ // the call entirely.
1378
+ //
1379
+ // `keys[ib] - keys[ia]` for descending is exactly `-(keys[ia] - keys[ib])`
1380
+ // as far as sorting is concerned: both are NaN for unorderable values,
1381
+ // which the spec coerces to 0, and they differ only in producing 0 versus
1382
+ // -0 for equal keys, which sorts identically.
1383
+ if (clauses.length === 1) {
1384
+ const { keys, compare, desc } = clauses[0]!
1385
+ if (compare === compareNumericKeys) {
1386
+ order.sort(
1387
+ desc
1388
+ ? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia] }
1389
+ : function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib] },
1390
+ )
1391
+ } else {
1392
+ order.sort(
1393
+ desc
1394
+ ? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]) }
1395
+ : function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]) },
1396
+ )
1397
+ }
1398
+ } else {
1399
+ order.sort(function compareRowsByClauses(ia, ib) {
1400
+ for (let k = 0; k < clauses.length; k++) {
1401
+ const clause = clauses[k]!
1402
+ const result = clause.compare(clause.keys[ia], clause.keys[ib])
1403
+ if (result !== 0) return clause.desc ? -result : result
1404
+ }
1405
+ return 0
1406
+ })
1407
+ }
1408
+
1409
+ const sorted = new Array<Row<TData>>(rows.length)
1410
+ for (let i = 0; i < order.length; i++) sorted[i] = rows[order[i]!]!
1411
+ return sorted
1412
+ }
1413
+ }
1414
+
1415
+ /**
1416
+ * Numeric key comparison for the built-in `number` and `date` comparators.
1417
+ * Subtraction rather than `<`/`>` on purpose: it reproduces the originals
1418
+ * exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
1419
+ * and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
1420
+ * it), which is the behaviour callers already depend on.
1421
+ */
1422
+ function compareNumericKeys(a: number, b: number): number {
1423
+ return a - b
1424
+ }
1425
+
1426
+ /**
1427
+ * Text key comparison for the built-in `auto` comparator.
1428
+ *
1429
+ * `localeCompare`, NOT a hoisted `Intl.Collator`. The specification defines
1430
+ * `localeCompare` with no locale or options as constructing a default collator
1431
+ * per call, so hoisting one looks like the obvious optimisation - and it is
1432
+ * measurably slower. V8 fast-paths `String.prototype.localeCompare` for the
1433
+ * default locale; going through a collator object misses that path. Measured
1434
+ * sorting 100k strings: 33 ms via `localeCompare` against 83 ms via a cached
1435
+ * collator on ASCII, 58 ms against 99 ms with accents mixed in, and the two
1436
+ * produce byte-identical orderings across all 100k positions.
1437
+ *
1438
+ * Left as its own function so the sort path has one place to change if that
1439
+ * ever stops being true. Re-measure before "optimising" this again.
1440
+ */
1441
+ function compareCollatedKeys(a: string, b: string): number {
1442
+ return a.localeCompare(b)
1443
+ }
1444
+
1445
+ /**
1446
+ * Everything {@link createSvGridCore} accepts: the data and columns, the
1447
+ * features and row models that make up the pipeline, and an `on*Change`
1448
+ * callback per piece of state for controlled use.
1449
+ *
1450
+ * `<SvGrid>` builds this for you from its props - you only construct it
1451
+ * directly when driving the headless core.
1452
+ */
1453
+ export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {
1454
+ _features: TFeatures
1455
+ _rowModels?: {
1456
+ coreRowModel?: RowModelFactory<TData>
1457
+ filteredRowModel?: RowModelFactory<TData>
1458
+ sortedRowModel?: RowModelFactory<TData>
1459
+ paginatedRowModel?: RowModelFactory<TData>
1460
+ groupedRowModel?: RowModelFactory<TData>
1461
+ expandedRowModel?: RowModelFactory<TData>
1462
+ }
1463
+ columns: Array<ColumnDef<TFeatures, TData>>
1464
+ data: ReadonlyArray<TData>
1465
+ /**
1466
+ * Optional row-id resolver. When set, the value it returns becomes
1467
+ * `row.id` (and therefore the selection / expansion / edit key). When
1468
+ * omitted, ids fall back to the row's array index as a string. Use a
1469
+ * stable id (database PK, UUID, etc.) so selection survives reorders.
1470
+ */
1471
+ getRowId?: (row: TData, index: number) => string
1472
+ state?: Partial<Record<string, any>>
1473
+ onSortingChange?: (updater: Updater<SortingState>) => void
1474
+ onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void
1475
+ onPaginationChange?: (updater: Updater<PaginationState>) => void
1476
+ onGroupingChange?: (updater: Updater<GroupingState>) => void
1477
+ onExpandedChange?: (updater: Updater<ExpandedState>) => void
1478
+ onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void
1479
+ onActiveCellChange?: (updater: Updater<ActiveCellState>) => void
1480
+ }
1481
+
1482
+ /**
1483
+ * The headless grid instance: the state stores plus the read methods a renderer
1484
+ * needs (`getHeaderGroups()`, `getRowModel()`, the `set*` writers).
1485
+ *
1486
+ * Framework free by design - `<SvGrid>` is one renderer over this, and you can
1487
+ * write another. See the "Why headless?" guide.
1488
+ */
1489
+ export type SvGrid<TData extends RowData> = {
1490
+ store: Store<Record<string, any>>
1491
+ optionsStore: Store<Record<string, any>>
1492
+ state: Record<string, any>
1493
+ getState: () => Record<string, any>
1494
+ setOptions: (updater: Updater<Record<string, any>>) => void
1495
+ setColumnFilters: (updater: Updater<ColumnFiltersState>) => void
1496
+ setPagination: (updater: Updater<PaginationState>) => void
1497
+ setGrouping: (updater: Updater<GroupingState>) => void
1498
+ setExpanded: (updater: Updater<ExpandedState>) => void
1499
+ setRowSelection: (updater: Updater<RowSelectionState>) => void
1500
+ setActiveCell: (updater: Updater<ActiveCellState>) => void
1501
+ moveActiveCell: (next: { rowDelta?: number; colDelta?: number }) => void
1502
+ getAllColumns: () => Array<Column<TData>>
1503
+ getHeaderGroups: () => Array<HeaderGroup<TData>>
1504
+ getFooterGroups: () => Array<HeaderGroup<TData>>
1505
+ getRowModel: () => RowModel<TData>
1506
+ }
1507
+
1508
+ type InternalGrid<TData extends RowData> = SvGrid<TData> & {
1509
+ getAllColumns: () => Array<Column<TData>>
1510
+ }
1511
+
1512
+ /**
1513
+ * Build a headless grid: state, the row pipeline, and the read methods, with no
1514
+ * DOM and no Svelte. This is the engine `<SvGrid>` renders.
1515
+ *
1516
+ * Most callers want `createSvGrid` (the runes-aware wrapper) or the component
1517
+ * itself; reach for this when you are writing your own renderer or running the
1518
+ * pipeline outside a browser.
1519
+ */
1520
+ export function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(
1521
+ options: SvGridOptions<TFeatures, TData>,
1522
+ ): SvGrid<TData> {
1523
+ const internalState: Record<string, any> = {
1524
+ sorting: [],
1525
+ columnFilters: [],
1526
+ pagination: { pageIndex: 0, pageSize: options.data.length || 10 },
1527
+ grouping: [],
1528
+ expanded: {},
1529
+ rowSelection: {},
1530
+ activeCell: { rowIndex: 0, colIndex: 0, cellId: null },
1531
+ ...(options.state ?? {}),
1532
+ }
1533
+ const store = createStore(internalState)
1534
+ const optionsStore = createStore(options as Record<string, any>)
1535
+ let cachedColumnsInput: Array<ColumnDef<TFeatures, TData>> | null = null
1536
+ let cachedColumns: Array<Column<TData>> = []
1537
+ let cachedHeaderGroups: Array<HeaderGroup<TData>> = []
1538
+ let cachedBaseRowsInput: ReadonlyArray<TData> | null = null
1539
+ let cachedBaseRowsColumns: Array<Column<TData>> | null = null
1540
+ let cachedBaseRows: Array<Row<TData>> = []
1541
+ let cachedRowModel: RowModel<TData> | null = null
1542
+ let cachedRowModelBaseRows: Array<Row<TData>> | null = null
1543
+ let cachedPipeline = options._rowModels
1544
+ let cachedSlices: {
1545
+ sorting: SortingState | undefined
1546
+ columnFilters: ColumnFiltersState | undefined
1547
+ pagination: PaginationState | undefined
1548
+ grouping: GroupingState | undefined
1549
+ expanded: ExpandedState | undefined
1550
+ } | null = null
1551
+
1552
+ const grid = {
1553
+ store,
1554
+ optionsStore,
1555
+ get state() {
1556
+ return store.state
1557
+ },
1558
+ getState() {
1559
+ return store.state
1560
+ },
1561
+ setOptions(updater: Updater<Record<string, any>>) {
1562
+ optionsStore.setState((prev) =>
1563
+ typeof updater === 'function' ? (updater as any)(prev) : updater,
1564
+ )
1565
+ },
1566
+ setColumnFilters(updater: Updater<ColumnFiltersState>) {
1567
+ store.setState((prev) => ({
1568
+ ...prev,
1569
+ columnFilters:
1570
+ typeof updater === 'function' ? (updater as any)(prev.columnFilters ?? []) : updater,
1571
+ }))
1572
+ options.onColumnFiltersChange?.(updater)
1573
+ },
1574
+ setPagination(updater: Updater<PaginationState>) {
1575
+ store.setState((prev) => ({
1576
+ ...prev,
1577
+ pagination:
1578
+ typeof updater === 'function'
1579
+ ? (updater as any)(prev.pagination ?? { pageIndex: 0, pageSize: 10 })
1580
+ : updater,
1581
+ }))
1582
+ options.onPaginationChange?.(updater)
1583
+ },
1584
+ setGrouping(updater: Updater<GroupingState>) {
1585
+ store.setState((prev) => ({
1586
+ ...prev,
1587
+ grouping: typeof updater === 'function' ? (updater as any)(prev.grouping ?? []) : updater,
1588
+ }))
1589
+ options.onGroupingChange?.(updater)
1590
+ },
1591
+ setExpanded(updater: Updater<ExpandedState>) {
1592
+ store.setState((prev) => ({
1593
+ ...prev,
1594
+ expanded: typeof updater === 'function' ? (updater as any)(prev.expanded ?? {}) : updater,
1595
+ }))
1596
+ options.onExpandedChange?.(updater)
1597
+ },
1598
+ setRowSelection(updater: Updater<RowSelectionState>) {
1599
+ store.setState((prev) => ({
1600
+ ...prev,
1601
+ rowSelection:
1602
+ typeof updater === 'function' ? (updater as any)(prev.rowSelection ?? {}) : updater,
1603
+ }))
1604
+ options.onRowSelectionChange?.(updater)
1605
+ },
1606
+ setActiveCell(updater: Updater<ActiveCellState>) {
1607
+ store.setState((prev) => {
1608
+ const previous: ActiveCellState = prev.activeCell ?? {
1609
+ rowIndex: 0,
1610
+ colIndex: 0,
1611
+ cellId: null,
1612
+ }
1613
+ const nextActive =
1614
+ typeof updater === 'function' ? updater(previous) : updater
1615
+ return {
1616
+ ...prev,
1617
+ activeCell: nextActive,
1618
+ }
1619
+ })
1620
+ options.onActiveCellChange?.(updater)
1621
+ },
1622
+ moveActiveCell(next: { rowDelta?: number; colDelta?: number }) {
1623
+ const rows = grid.getRowModel().rows
1624
+ const columns = grid.getAllColumns()
1625
+ const maxRow = Math.max(rows.length - 1, 0)
1626
+ const maxCol = Math.max(columns.length - 1, 0)
1627
+ const current: ActiveCellState = grid.getState().activeCell ?? {
1628
+ rowIndex: 0,
1629
+ colIndex: 0,
1630
+ cellId: null,
1631
+ }
1632
+
1633
+ const rowIndex = Math.min(
1634
+ Math.max(current.rowIndex + (next.rowDelta ?? 0), 0),
1635
+ maxRow,
1636
+ )
1637
+ const colIndex = Math.min(
1638
+ Math.max(current.colIndex + (next.colDelta ?? 0), 0),
1639
+ maxCol,
1640
+ )
1641
+ const columnId = columns[colIndex]?.id ?? 'col_0'
1642
+ grid.setActiveCell({
1643
+ rowIndex,
1644
+ colIndex,
1645
+ cellId: `${rowIndex}_${columnId}`,
1646
+ })
1647
+ },
1648
+ getAllColumns() {
1649
+ // Cache hit: referentially identical columns array.
1650
+ if (cachedColumnsInput === options.columns && cachedColumns.length) {
1651
+ return cachedColumns
1652
+ }
1653
+ // Soft cache hit: consumers commonly recreate the columns array
1654
+ // inline on every render (e.g. `columns={[...]}`). If the new
1655
+ // array has the same length AND each entry has the same `field` /
1656
+ // `id` / `header` (the visibility-affecting structure of a
1657
+ // column), trust the previous build. Mutable inner fields like
1658
+ // `cell` and `editorOptions` are still picked up on the next real
1659
+ // render that bumps an actual data dep - they're read at cell-
1660
+ // render time, not at this top-level cache.
1661
+ if (
1662
+ cachedColumnsInput &&
1663
+ options.columns.length === cachedColumnsInput.length &&
1664
+ cachedColumns.length === options.columns.length &&
1665
+ options.columns.every((c, i) => {
1666
+ const prev = cachedColumnsInput![i]!
1667
+ return (
1668
+ c.field === prev.field &&
1669
+ c.id === prev.id &&
1670
+ c.header === prev.header &&
1671
+ c.editorType === prev.editorType
1672
+ )
1673
+ })
1674
+ ) {
1675
+ // Update the stored input reference so the strict check hits
1676
+ // next time, but reuse the built column model.
1677
+ cachedColumnsInput = options.columns
1678
+ return cachedColumns
1679
+ }
1680
+
1681
+ cachedColumnsInput = options.columns
1682
+ cachedHeaderGroups = []
1683
+ const build = (
1684
+ defs: Array<ColumnDef<TFeatures, TData>>,
1685
+ depth: number,
1686
+ parentId?: string,
1687
+ ): Array<Column<TData>> => {
1688
+ const leaves: Array<Column<TData>> = []
1689
+ defs.forEach((columnDef, index) => {
1690
+ const id = resolveColumnId(columnDef, parentId, depth, index)
1691
+ if (columnDef.columns?.length) {
1692
+ leaves.push(...build(columnDef.columns, depth + 1, id))
1693
+ return
1694
+ }
1695
+ leaves.push({
1696
+ id,
1697
+ depth,
1698
+ parentId,
1699
+ columnDef,
1700
+ getCanSort: () =>
1701
+ Boolean((options._features as any).rowSortingFeature) &&
1702
+ columnDef.sortable !== false,
1703
+ getCanFilter: () =>
1704
+ Boolean((options._features as any).columnFilteringFeature) &&
1705
+ columnDef.filterable !== false,
1706
+ getIsSorted: () => {
1707
+ const entry = store.state.sorting?.find((s: any) => s.id === id)
1708
+ if (!entry) return false
1709
+ return entry.desc ? 'desc' : 'asc'
1710
+ },
1711
+ getToggleSortingHandler: () => () => {
1712
+ const clauses: SortingState = store.state.sorting ?? []
1713
+ const current = clauses.find((s: any) => s.id === id)
1714
+ const nextClause: SortingState = !current
1715
+ ? [...clauses, { id, desc: false }]
1716
+ : current.desc
1717
+ ? clauses.filter((s) => s.id !== id)
1718
+ : clauses.map((s) => (s.id === id ? { ...s, desc: true } : s))
1719
+ store.setState((prev) => ({ ...prev, sorting: nextClause }))
1720
+ options.onSortingChange?.(nextClause)
1721
+ },
1722
+ })
1723
+ })
1724
+ return leaves
1725
+ }
1726
+ cachedColumns = build(options.columns, 0)
1727
+ return cachedColumns
1728
+ },
1729
+ getHeaderGroups() {
1730
+ if (cachedHeaderGroups.length) return cachedHeaderGroups
1731
+ const headers = grid.getAllColumns().map((column) => {
1732
+ const header: Header<TData> = {
1733
+ id: column.id,
1734
+ isPlaceholder: false,
1735
+ colSpan: 1,
1736
+ column,
1737
+ getContext: () => ({ header, column, table: grid }),
1738
+ }
1739
+ return header
1740
+ })
1741
+ cachedHeaderGroups = [{ id: 'header_group_0', headers }]
1742
+ return cachedHeaderGroups
1743
+ },
1744
+ getFooterGroups() {
1745
+ return grid.getHeaderGroups()
1746
+ },
1747
+ getRowModel() {
1748
+ const columns = grid.getAllColumns()
1749
+ if (cachedBaseRowsInput !== options.data || cachedBaseRowsColumns !== columns) {
1750
+ cachedBaseRowsInput = options.data
1751
+ cachedBaseRowsColumns = columns
1752
+ // O(1) column-id → index lookup so getCellValueByColumnId doesn't do
1753
+ // a linear `findIndex` on every cell read (was O(rows × cells × cols)).
1754
+ const columnIndexById = new Map<string, number>()
1755
+ for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
1756
+ const columnCount = columns.length
1757
+
1758
+ // One shared context for every row in this table, so a row carries a
1759
+ // pointer rather than a closure scope. See BASE_ROW_METHODS.
1760
+ const rowCtx: BaseRowCtx<TData> = {
1761
+ grid: grid as SvGrid<TData>,
1762
+ store,
1763
+ columns,
1764
+ columnCount,
1765
+ columnIndexById,
1766
+ }
1767
+
1768
+ cachedBaseRows = new Array(options.data.length)
1769
+ const getRowId = options.getRowId
1770
+ const m = BASE_ROW_METHODS as unknown as {
1771
+ getCanExpand: Row<TData>['getCanExpand']
1772
+ getIsExpanded: Row<TData>['getIsExpanded']
1773
+ toggleExpanded: Row<TData>['toggleExpanded']
1774
+ getIsSelected: Row<TData>['getIsSelected']
1775
+ toggleSelected: Row<TData>['toggleSelected']
1776
+ getAllCells: Row<TData>['getAllCells']
1777
+ getCellValueByColumnId: Row<TData>['getCellValueByColumnId']
1778
+ }
1779
+ for (let index = 0; index < options.data.length; index++) {
1780
+ const original = options.data[index]!
1781
+ // `_values` and `_cells` stay null until something reads them - a
1782
+ // 100k-row grid showing twenty rows must not materialise every row's
1783
+ // values or cell objects to paint.
1784
+ const row: BaseRowState<TData> = {
1785
+ id: getRowId ? getRowId(original, index) : String(index),
1786
+ index,
1787
+ original,
1788
+ depth: 0,
1789
+ [ROW_CTX]: rowCtx,
1790
+ [ROW_VALUES]: null,
1791
+ [ROW_CELLS]: null,
1792
+ getCanExpand: m.getCanExpand,
1793
+ getIsExpanded: m.getIsExpanded,
1794
+ toggleExpanded: m.toggleExpanded,
1795
+ getIsSelected: m.getIsSelected,
1796
+ toggleSelected: m.toggleSelected,
1797
+ getAllCells: m.getAllCells,
1798
+ getCellValueByColumnId: m.getCellValueByColumnId,
1799
+ }
1800
+ cachedBaseRows[index] = row
1801
+ }
1802
+ }
1803
+
1804
+ // Only the slices a pipeline stage actually READS belong in this key.
1805
+ //
1806
+ // `rowSelection` used to be here, which meant ticking one checkbox on a
1807
+ // 100k-row grid re-filtered and re-sorted the entire dataset to rebuild a
1808
+ // row array that was identical by construction. Nothing reads it: the two
1809
+ // consumers are `getIsSelected` closures (on data rows and on group rows)
1810
+ // that read `store.state` when called, so they observe a selection change
1811
+ // without the model being rebuilt.
1812
+ //
1813
+ // `_rowModels` is a closed set of six named slots, so no consumer stage
1814
+ // can be inserted that might read selection. A caller CAN supply a custom
1815
+ // function for one of those slots; if one ever needs a slice that is not
1816
+ // listed here, add it here rather than reinstating all of them.
1817
+ const currentSlices = {
1818
+ sorting: store.state.sorting,
1819
+ columnFilters: store.state.columnFilters,
1820
+ pagination: store.state.pagination,
1821
+ grouping: store.state.grouping,
1822
+ expanded: store.state.expanded,
1823
+ }
1824
+ if (
1825
+ cachedRowModel &&
1826
+ cachedRowModelBaseRows === cachedBaseRows &&
1827
+ cachedPipeline === options._rowModels &&
1828
+ cachedSlices?.sorting === currentSlices.sorting &&
1829
+ cachedSlices?.columnFilters === currentSlices.columnFilters &&
1830
+ cachedSlices?.pagination === currentSlices.pagination &&
1831
+ cachedSlices?.grouping === currentSlices.grouping &&
1832
+ cachedSlices?.expanded === currentSlices.expanded
1833
+ ) {
1834
+ return cachedRowModel
1835
+ }
1836
+
1837
+ let rows: Array<Row<TData>> = cachedBaseRows
1838
+
1839
+ const pipeline = options._rowModels ?? {}
1840
+ const ordered: Array<RowModelFactory<TData> | undefined> = [
1841
+ pipeline.coreRowModel,
1842
+ pipeline.filteredRowModel,
1843
+ pipeline.sortedRowModel,
1844
+ pipeline.groupedRowModel,
1845
+ pipeline.expandedRowModel,
1846
+ pipeline.paginatedRowModel,
1847
+ ]
1848
+ ordered.forEach((fn) => {
1849
+ if (fn) rows = fn({ table: grid, rows })
1850
+ })
1851
+ cachedPipeline = options._rowModels
1852
+ cachedSlices = currentSlices
1853
+ cachedRowModelBaseRows = cachedBaseRows
1854
+ cachedRowModel = { rows }
1855
+ return cachedRowModel
1856
+ },
1857
+ } as InternalGrid<TData>
1858
+
1859
+ return grid
1860
+ }
1861
+
1862
+ /** Narrowing helper for the many options that accept a value or a function. */
1863
+ export function isFunction(value: unknown): value is (...args: Array<any>) => any {
1864
+ return typeof value === 'function'
1865
+ }