@svgrid/grid 1.1.0 → 1.1.2

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