@svgrid/grid 2.6.20 → 2.6.22

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 (110) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +22 -0
  3. package/dist/GridMenus.svelte +17 -12
  4. package/dist/SvGrid.controller.svelte.d.ts +14 -8
  5. package/dist/SvGrid.controller.svelte.js +214 -74
  6. package/dist/SvGrid.css +1 -1
  7. package/dist/SvGrid.svelte +112 -57
  8. package/dist/SvGrid.types.d.ts +191 -1
  9. package/dist/ai.d.ts +28 -0
  10. package/dist/ai.js +6 -0
  11. package/dist/cdn/{GridMenus-B0F9iBrG.js → GridMenus-BuoBPqxx.js} +137 -132
  12. package/dist/cdn/GridMenus-n4llxoOI.js +494 -0
  13. package/dist/cdn/column-resize-DsfNXMom.js +102 -0
  14. package/dist/cdn/row-resize-BRcimkUT.js +95 -0
  15. package/dist/cdn/{src-Cd0tearp.js → src-C9Hihx1W.js} +3497 -3487
  16. package/dist/cdn/{src-B1TdiyS8.js → src-D1lXwq1l.js} +8258 -8248
  17. package/dist/cdn/svgrid.js +10 -8
  18. package/dist/cdn/svgrid.svelte-external.js +10 -8
  19. package/dist/cdn/validate-_CDJzgIo.js +75 -0
  20. package/dist/cell-formatting.d.ts +2 -0
  21. package/dist/cell-formatting.js +2 -0
  22. package/dist/chart-export.d.ts +1 -0
  23. package/dist/chart.d.ts +31 -5
  24. package/dist/chart.js +9 -3
  25. package/dist/column-groups.js +1 -1
  26. package/dist/column-resize.d.ts +46 -0
  27. package/dist/column-resize.js +205 -0
  28. package/dist/columns.d.ts +0 -3
  29. package/dist/columns.js +0 -57
  30. package/dist/core.d.ts +216 -4
  31. package/dist/core.js +532 -119
  32. package/dist/createTree.svelte.d.ts +3 -0
  33. package/dist/createTree.svelte.js +1 -0
  34. package/dist/datetime/date-core.d.ts +2 -0
  35. package/dist/datetime/date-restrict.d.ts +1 -0
  36. package/dist/datetime/timezone.d.ts +1 -0
  37. package/dist/dock-manager-model.d.ts +3 -0
  38. package/dist/dock-manager-model.js +1 -0
  39. package/dist/dock-model.d.ts +6 -0
  40. package/dist/dock-model.js +3 -0
  41. package/dist/editor-contract.d.ts +1 -0
  42. package/dist/filtering/excel-filters.js +28 -0
  43. package/dist/group-display.d.ts +1 -1
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.js +6 -0
  46. package/dist/list-option.d.ts +1 -0
  47. package/dist/menus.js +1 -1
  48. package/dist/positioning.d.ts +2 -0
  49. package/dist/row-resize.d.ts +11 -0
  50. package/dist/row-resize.js +7 -1
  51. package/dist/scheduler-ical.d.ts +1 -0
  52. package/dist/scheduler-model.d.ts +1 -0
  53. package/dist/selection.js +9 -0
  54. package/dist/spreadsheet.d.ts +1 -1
  55. package/dist/spreadsheet.js +1 -1
  56. package/dist/summaries.js +22 -0
  57. package/dist/svgrid-wrapper.types.d.ts +5 -0
  58. package/dist/toast-store.svelte.d.ts +4 -0
  59. package/dist/validate.d.ts +50 -0
  60. package/dist/validate.js +187 -0
  61. package/package.json +4 -1
  62. package/src/GridMenus.svelte +17 -12
  63. package/src/SvGrid.controller.svelte.ts +223 -76
  64. package/src/SvGrid.css +1 -1
  65. package/src/SvGrid.svelte +112 -57
  66. package/src/SvGrid.types.ts +191 -1
  67. package/src/ai.ts +28 -0
  68. package/src/cell-formatting.ts +2 -0
  69. package/src/chart-export.ts +1 -0
  70. package/src/chart.ts +31 -5
  71. package/src/column-groups.ts +1 -1
  72. package/src/column-resize.test.ts +381 -0
  73. package/src/column-resize.ts +227 -0
  74. package/src/columns.test.ts +0 -103
  75. package/src/columns.ts +0 -58
  76. package/src/core.aggregate.test.ts +134 -0
  77. package/src/core.filter.test.ts +156 -0
  78. package/src/core.grouping.test.ts +146 -0
  79. package/src/core.row-shape.test.ts +119 -0
  80. package/src/core.rowmodel-cache.test.ts +121 -0
  81. package/src/core.sort.test.ts +293 -0
  82. package/src/core.ts +723 -119
  83. package/src/createTree.svelte.ts +3 -0
  84. package/src/datetime/date-core.ts +2 -0
  85. package/src/datetime/date-restrict.ts +1 -0
  86. package/src/datetime/timezone.ts +1 -0
  87. package/src/dock-manager-model.ts +3 -0
  88. package/src/dock-model.ts +6 -0
  89. package/src/editor-contract.ts +1 -0
  90. package/src/filtering/excel-filters.ts +30 -0
  91. package/src/filtering/normalize-fast-path.test.ts +104 -0
  92. package/src/group-display.ts +1 -1
  93. package/src/index.ts +12 -1
  94. package/src/list-option.ts +1 -0
  95. package/src/menus.ts +1 -1
  96. package/src/positioning.ts +2 -0
  97. package/src/resize-props.test.ts +361 -0
  98. package/src/row-resize.test.ts +31 -0
  99. package/src/row-resize.ts +21 -3
  100. package/src/scheduler-ical.ts +1 -0
  101. package/src/scheduler-model.ts +1 -0
  102. package/src/selection.ts +9 -0
  103. package/src/spreadsheet.ts +1 -1
  104. package/src/summaries.ts +21 -0
  105. package/src/svgrid-wrapper.types.ts +5 -0
  106. package/src/svgrid.summaries.test.ts +217 -0
  107. package/src/toast-store.svelte.ts +4 -0
  108. package/src/validate.test.ts +207 -0
  109. package/src/validate.ts +269 -0
  110. package/dist/cdn/GridMenus-IHK_l7m6.js +0 -489
package/src/core.ts CHANGED
@@ -1,30 +1,79 @@
1
1
  import type { SparklineConfig } from './sparkline'
2
2
  import { resolveColumnId } from './column-id'
3
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
+ */
4
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
+ */
5
19
  export type Updater<T> = T | ((prev: T) => T)
20
+
21
+ /** Active sort clauses, outermost first. `desc: false` is ascending. */
6
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
+ */
7
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. */
8
32
  export type ColumnFiltersState = Array<ColumnFilter>
33
+
34
+ /** Current page position. `pageIndex` is 0-based, so page 1 is index 0. */
9
35
  export type PaginationState = { pageIndex: number; pageSize: number }
36
+
37
+ /** Column ids the rows are grouped by, outermost first. */
10
38
  export type GroupingState = Array<string>
39
+
40
+ /** Which rows are expanded, keyed by row id. Absent means collapsed. */
11
41
  export type ExpandedState = Record<string, boolean>
42
+
43
+ /** Which rows are selected, keyed by row id. Absent means unselected. */
12
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
+ */
13
50
  export type ActiveCellState = {
14
51
  rowIndex: number
15
52
  colIndex: number
16
53
  cellId: string | null
17
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
+ */
18
61
  export type TableFeatures = Record<string, unknown>
19
62
 
63
+ /** A cell's value. Unconstrained - a column can hold anything. */
20
64
  export type CellData = unknown
21
65
 
66
+ /** What a column's `header` render function receives. */
22
67
  export type HeaderContext<TData extends RowData> = {
23
68
  header: Header<TData>
24
69
  column: Column<TData>
25
70
  table: SvGrid<TData>
26
71
  }
27
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
+ */
28
77
  export type CellContext<TData extends RowData> = {
29
78
  cell: Cell<TData>
30
79
  row: Row<TData>
@@ -84,6 +133,11 @@ export type EditorContext<TData extends RowData> = CellContext<TData> & {
84
133
  cancel: () => void
85
134
  }
86
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
+ */
87
141
  export type CellFormatConfig =
88
142
  | {
89
143
  type: 'number'
@@ -119,6 +173,10 @@ export type CellFormatConfig =
119
173
  options?: Intl.DateTimeFormatOptions
120
174
  }
121
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
+ */
122
180
  export type CellFormatter<TData extends RowData> = (context: {
123
181
  value: unknown
124
182
  row: Row<TData>
@@ -126,6 +184,7 @@ export type CellFormatter<TData extends RowData> = (context: {
126
184
  table: SvGrid<TData>
127
185
  }) => string
128
186
 
187
+ /** A header or cell slot: a literal string, or a function returning renderable content. */
129
188
  export type ColumnDefTemplate<TContext> = string | ((context: TContext) => unknown)
130
189
 
131
190
  /**
@@ -151,27 +210,69 @@ export function applyGroupAggregate<TData extends RowData>(
151
210
  columnId: string,
152
211
  rows: ReadonlyArray<Row<TData>>,
153
212
  ): unknown {
154
- const raw = rows.map((r) => r.getCellValueByColumnId(columnId))
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
+
155
234
  if (typeof agg === 'function') {
156
- const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
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
+ }
157
243
  return agg(nums, rows.map((r) => r.original))
158
244
  }
159
- if (agg === 'count') return rows.length
160
- if (agg === 'countDistinct') return new Set(raw.map((v) => String(v ?? ''))).size
161
- if (agg === 'first') return raw[0]
162
- const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
163
- if (!nums.length) return undefined
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
164
265
  switch (agg) {
165
266
  case 'sum':
166
- return nums.reduce((a, b) => a + b, 0)
267
+ return sum
167
268
  case 'avg':
168
- return nums.reduce((a, b) => a + b, 0) / nums.length
269
+ return sum / count
169
270
  case 'min':
170
- return Math.min(...nums)
271
+ return min
171
272
  case 'max':
172
- return Math.max(...nums)
273
+ return max
173
274
  case 'extent':
174
- return `${Math.min(...nums)} – ${Math.max(...nums)}`
275
+ return `${min} – ${max}`
175
276
  default:
176
277
  return undefined
177
278
  }
@@ -197,7 +298,7 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
197
298
  columns?: Array<ColumnDef<TFeatures, TData>>
198
299
  /**
199
300
  * Declarative cell spanning (merged cells). Return how many COLUMNS this
200
- * cell spans to the right (1 = no span). Value-driven, AG-Grid-style. Feed
301
+ * cell spans to the right (1 = no span). Value-driven. Feed
201
302
  * `spansToMerges(rows, columns)` into `spreadsheetLayout` to apply - it uses
202
303
  * the same real `colspan`/`rowspan` merge engine (no separate code path).
203
304
  */
@@ -236,7 +337,7 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
236
337
  columnGroupShow?: 'open' | 'closed'
237
338
  /**
238
339
  * For a GROUP column (one with `columns: [...]`): start the group expanded.
239
- * Defaults to `false` (collapsed), matching AG Grid - so only the always-on
340
+ * Defaults to `false` (collapsed), the conventional default - so only the always-on
240
341
  * and `columnGroupShow: 'closed'` children show until the user expands it.
241
342
  */
242
343
  openByDefault?: boolean
@@ -281,7 +382,7 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
281
382
  */
282
383
  tooltip?: string | ((ctx: CellContext<TData>) => string | null | undefined)
283
384
  /**
284
- * Declarative per-cell validation (Handsontable-style). Runs for EVERY
385
+ * Declarative per-cell validation. Runs for EVERY
285
386
  * rendered cell - including values already present in `data` on load, not
286
387
  * just on edit - so bad data is flagged immediately. Invalid cells get the
287
388
  * `sv-grid-cell-invalid` class (red highlight) and the returned message as
@@ -320,7 +421,7 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
320
421
  * Transform the committed edit value before it is written to the row.
321
422
  * Runs after the built-in per-`editorType` coercion, so `newValue` is
322
423
  * already type-parsed; return the final value to store (e.g. round a
323
- * number, uppercase a code, look up an id). AG-Grid-style `valueParser`.
424
+ * number, uppercase a code, look up an id). A `valueParser` hook.
324
425
  */
325
426
  valueParser?: (params: ValueParserParams<TData>) => unknown
326
427
  /**
@@ -378,6 +479,20 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
378
479
  * is formatted with this column's `format` and shown in the group header.
379
480
  */
380
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
381
496
  /**
382
497
  * Render the cell as an in-cell sparkline chart. The cell value should be
383
498
  * an array of numbers (or a comma/space separated string). Mutually
@@ -393,6 +508,21 @@ export type ColumnDef<TFeatures extends TableFeatures, TData extends RowData> =
393
508
  sparkline?: SparklineConfig
394
509
  /** Initial column width in pixels. Falls back to the grid's `columnWidth` prop. */
395
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
396
526
  /**
397
527
  * Initial visibility. Set `false` to start the column hidden while still
398
528
  * listing it in the Choose Columns UI for the user to re-enable. Applied
@@ -445,6 +575,12 @@ export type GridColumnDef<TData extends RowData = RowData> = ColumnDef<TableFeat
445
575
  /** An array of {@link GridColumnDef} - what you pass to `<SvGrid columns={...}>`. */
446
576
  export type GridColumns<TData extends RowData = RowData> = Array<GridColumnDef<TData>>
447
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
+ */
448
584
  export type Column<TData extends RowData> = {
449
585
  id: string
450
586
  columnDef: ColumnDef<any, TData>
@@ -456,6 +592,11 @@ export type Column<TData extends RowData> = {
456
592
  getToggleSortingHandler: () => () => void
457
593
  }
458
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
+ */
459
600
  export type Header<TData extends RowData> = {
460
601
  id: string
461
602
  isPlaceholder: boolean
@@ -464,11 +605,13 @@ export type Header<TData extends RowData> = {
464
605
  getContext: () => HeaderContext<TData>
465
606
  }
466
607
 
608
+ /** One row of header cells. A grid with grouped columns has several, outermost first. */
467
609
  export type HeaderGroup<TData extends RowData> = {
468
610
  id: string
469
611
  headers: Array<Header<TData>>
470
612
  }
471
613
 
614
+ /** One cell: the intersection of a {@link Row} and a {@link Column}. */
472
615
  export type Cell<TData extends RowData> = {
473
616
  id: string
474
617
  row: Row<TData>
@@ -477,6 +620,13 @@ export type Cell<TData extends RowData> = {
477
620
  getContext: () => CellContext<TData>
478
621
  }
479
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
+ */
480
630
  export type Row<TData extends RowData> = {
481
631
  id: string
482
632
  index: number
@@ -494,10 +644,19 @@ export type Row<TData extends RowData> = {
494
644
  getCellValueByColumnId: (columnId: string) => unknown
495
645
  }
496
646
 
647
+ /** The output of the row pipeline: the rows to display, in order. */
497
648
  export type RowModel<TData extends RowData> = {
498
649
  rows: Array<Row<TData>>
499
650
  }
500
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
+ */
501
660
  export type Store<T> = {
502
661
  readonly state: T
503
662
  setState: (updater: (prev: T) => T) => void
@@ -522,17 +681,51 @@ function createStore<T>(initial: T): Store<T> {
522
681
  }
523
682
  }
524
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
+ */
525
697
  export const rowSortingFeature = { key: 'rowSortingFeature' }
698
+ /** Per-column filtering. Injected by the `filterable` shortcut. */
526
699
  export const columnFilteringFeature = { key: 'columnFilteringFeature' }
700
+ /** Paging of the row model. Injected by the `pageable` shortcut. */
527
701
  export const rowPaginationFeature = { key: 'rowPaginationFeature' }
702
+ /** Row grouping with aggregation. Injected by the `groupable` shortcut. */
528
703
  export const columnGroupingFeature = { key: 'columnGroupingFeature' }
704
+ /** Row selection state (the checkbox column reads it). */
529
705
  export const rowSelectionFeature = { key: 'rowSelectionFeature' }
706
+ /** Expand / collapse, for tree rows and master-detail. */
530
707
  export const rowExpandingFeature = { key: 'rowExpandingFeature' }
531
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
+ */
532
721
  export function tableFeatures<T extends TableFeatures>(features: T): T {
533
722
  return features
534
723
  }
535
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
+ */
536
729
  export const sortFns = {
537
730
  auto: (a: unknown, b: unknown) => String(a).localeCompare(String(b)),
538
731
  number: (a: unknown, b: unknown) => Number(a ?? 0) - Number(b ?? 0),
@@ -543,36 +736,205 @@ export const sortFns = {
543
736
  },
544
737
  }
545
738
 
739
+ /**
740
+ * Built-in match functions, named by {@link ColumnFilter}'s `fn`.
741
+ * `includesString` is case-insensitive substring; `equals` is strict identity.
742
+ */
546
743
  export const filterFns = {
547
744
  includesString: (value: unknown, query: string) =>
548
745
  String(value).toLowerCase().includes(query.toLowerCase()),
549
746
  equals: (value: unknown, query: unknown) => value === query,
550
747
  }
551
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
+ */
552
891
  export type RowModelFactory<TData extends RowData> = (args: {
553
892
  table: SvGrid<TData>
554
893
  rows: Array<Row<TData>>
555
894
  }) => Array<Row<TData>>
556
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
+ */
557
900
  export function createCoreRowModel<TData extends RowData>(): RowModelFactory<TData> {
558
901
  return ({ rows }) => rows
559
902
  }
903
+ /**
904
+ * Drops rows that fail the active {@link ColumnFiltersState}. Pairs with
905
+ * `columnFilteringFeature`; without it there are no filters to apply.
906
+ */
560
907
  export function createFilteredRowModel<TData extends RowData>(): RowModelFactory<TData> {
561
908
  return ({ table, rows }) => {
562
909
  const filters: ColumnFiltersState = table.getState().columnFilters ?? []
563
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
+
564
919
  return rows.filter((row) => {
565
- return filters.every((filter) => {
566
- const cellValue = row
567
- .getAllCells()
568
- .find((cell) => cell.column.id === filter.id)
569
- ?.getValue()
570
- const filterFn = filter.fn ? filterFns[filter.fn] : filterFns.includesString
571
- return filterFn(cellValue, filter.value as any)
572
- })
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
573
931
  })
574
932
  }
575
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
+ */
576
938
  export function createPaginatedRowModel<TData extends RowData>(): RowModelFactory<TData> {
577
939
  return ({ table, rows }) => {
578
940
  const pagination = table.getState().pagination ?? { pageIndex: 0, pageSize: rows.length || 10 }
@@ -580,6 +942,10 @@ export function createPaginatedRowModel<TData extends RowData>(): RowModelFactor
580
942
  return rows.slice(start, start + pagination.pageSize)
581
943
  }
582
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
+ */
583
949
  export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<TData> {
584
950
  return ({ table, rows }) => {
585
951
  const grouping: GroupingState = table.getState().grouping ?? []
@@ -595,6 +961,9 @@ export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<
595
961
  levelIndex: number,
596
962
  depth: number,
597
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>,
598
967
  ): Array<Row<TData>> {
599
968
  if (levelIndex >= grouping.length) {
600
969
  // Leaves: actual data rows, with their nesting depth recorded.
@@ -603,27 +972,60 @@ export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<
603
972
  const groupKey = grouping[levelIndex]
604
973
  if (!groupKey) return input
605
974
 
606
- const buckets = new Map<string, Array<Row<TData>>>()
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>()
607
983
  for (const row of input) {
608
984
  const value = row.getCellValueByColumnId(groupKey)
609
985
  const key = String(value ?? '')
610
- const list = buckets.get(key) ?? []
611
- list.push(row)
612
- buckets.set(key, list)
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
+ }
613
993
  }
614
994
 
615
995
  const groupRows: Array<Row<TData>> = []
616
996
  let index = 0
617
- buckets.forEach((children, key) => {
997
+ buckets.forEach((bucket, key) => {
998
+ const children = bucket.rows
618
999
  const id = `${idPrefix}_${groupKey}_${key}`
619
- const subRows = buildGroups(children, levelIndex + 1, depth + 1, id)
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)
620
1011
  const isDeepest = levelIndex + 1 >= grouping.length
621
1012
  const leafCount = isDeepest
622
1013
  ? subRows.length
623
1014
  : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0)
624
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.
625
1026
  const resolveColumnValue = (columnId: string): unknown => {
626
1027
  if (columnId === groupKey) return key
1028
+ if (fixedValues.has(columnId)) return fixedValues.get(columnId)
627
1029
  let resolved: unknown
628
1030
  let hasResolved = false
629
1031
  for (const child of children) {
@@ -680,9 +1082,17 @@ export function createGroupedRowModel<TData extends RowData>(): RowModelFactory<
680
1082
  return groupRows
681
1083
  }
682
1084
 
683
- return buildGroups(rows, 0, 0, 'group')
1085
+ return buildGroups(rows, 0, 0, 'group', new Map())
684
1086
  }
685
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
+ */
686
1096
  export type TreeRowModelOptions = {
687
1097
  /** Field holding each row's parent id. Rows with no parent are roots. */
688
1098
  parentField: string
@@ -761,6 +1171,11 @@ export function createTreeRowModel<TData extends RowData>(
761
1171
  }
762
1172
  }
763
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
+ */
764
1179
  export type FlattenTreeOptions = {
765
1180
  /** Field holding an array of child objects. */
766
1181
  childrenField: string
@@ -796,6 +1211,10 @@ export function flattenTreeData<T extends RowData>(
796
1211
  return out
797
1212
  }
798
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
+ */
799
1218
  export function createExpandedRowModel<TData extends RowData>(): RowModelFactory<TData> {
800
1219
  return ({ table, rows }) => {
801
1220
  const expanded: ExpandedState = table.getState().expanded ?? {}
@@ -810,36 +1229,227 @@ export function createExpandedRowModel<TData extends RowData>(): RowModelFactory
810
1229
  return flattened
811
1230
  }
812
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
+ */
813
1237
  export function createSortedRowModel<TData extends RowData>(
814
1238
  localSortFns: typeof sortFns = sortFns,
815
1239
  ): RowModelFactory<TData> {
816
- return ({ table, rows }) => {
1240
+ return function sortedRowModelStage({ table, rows }) {
817
1241
  const sorting = table.getState().sorting ?? []
818
1242
  if (!sorting.length) return rows
819
1243
 
820
- const sorted = [...rows].sort((a, b) => {
821
- for (const clause of sorting) {
822
- const column = table.getAllColumns().find((col) => col.id === clause.id)
823
- if (!column) continue
824
- const editorType = column.columnDef.editorType
825
- const comparator =
826
- editorType === 'number'
827
- ? localSortFns.number
828
- : editorType === 'date' || editorType === 'datetime'
829
- ? localSortFns.date
830
- : localSortFns.auto
831
- const result = comparator(
832
- a.getCellValueByColumnId(column.id),
833
- b.getCellValueByColumnId(column.id),
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]) },
834
1396
  )
835
- if (result !== 0) return clause.desc ? -result : result
836
1397
  }
837
- return 0
838
- })
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]!]!
839
1411
  return sorted
840
1412
  }
841
1413
  }
842
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
+ */
843
1453
  export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData> = {
844
1454
  _features: TFeatures
845
1455
  _rowModels?: {
@@ -869,6 +1479,13 @@ export type SvGridOptions<TFeatures extends TableFeatures, TData extends RowData
869
1479
  onActiveCellChange?: (updater: Updater<ActiveCellState>) => void
870
1480
  }
871
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
+ */
872
1489
  export type SvGrid<TData extends RowData> = {
873
1490
  store: Store<Record<string, any>>
874
1491
  optionsStore: Store<Record<string, any>>
@@ -892,6 +1509,14 @@ type InternalGrid<TData extends RowData> = SvGrid<TData> & {
892
1509
  getAllColumns: () => Array<Column<TData>>
893
1510
  }
894
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
+ */
895
1520
  export function createSvGridCore<TFeatures extends TableFeatures, TData extends RowData>(
896
1521
  options: SvGridOptions<TFeatures, TData>,
897
1522
  ): SvGrid<TData> {
@@ -922,7 +1547,6 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
922
1547
  pagination: PaginationState | undefined
923
1548
  grouping: GroupingState | undefined
924
1549
  expanded: ExpandedState | undefined
925
- rowSelection: RowSelectionState | undefined
926
1550
  } | null = null
927
1551
 
928
1552
  const grid = {
@@ -1131,91 +1755,71 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1131
1755
  for (let i = 0; i < columns.length; i++) columnIndexById.set(columns[i]!.id, i)
1132
1756
  const columnCount = columns.length
1133
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
+
1134
1768
  cachedBaseRows = new Array(options.data.length)
1135
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
+ }
1136
1779
  for (let index = 0; index < options.data.length; index++) {
1137
1780
  const original = options.data[index]!
1138
- const id = getRowId ? getRowId(original, index) : String(index)
1139
- // `values` and `cells` are computed lazily - for a 100k-row grid
1140
- // with only ~20 visible rows we don't want to materialise every
1141
- // row's full value array or cell objects up front.
1142
- let cachedValues: Array<unknown> | null = null
1143
- let cachedCells: Array<Cell<TData>> | null = null
1144
-
1145
- function computeValues(): Array<unknown> {
1146
- const values = new Array<unknown>(columnCount)
1147
- for (let i = 0; i < columnCount; i++) {
1148
- const column = columns[i]!
1149
- if (column.columnDef.fieldFn) {
1150
- values[i] = column.columnDef.fieldFn(original)
1151
- } else if (column.columnDef.field) {
1152
- values[i] = (original as any)[column.columnDef.field]
1153
- } else {
1154
- values[i] = undefined
1155
- }
1156
- }
1157
- return values
1158
- }
1159
-
1160
- const row: Row<TData> = {
1161
- id,
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),
1162
1786
  index,
1163
1787
  original,
1164
1788
  depth: 0,
1165
- getCanExpand: () => false,
1166
- getIsExpanded: () => Boolean((store.state.expanded ?? {})[id]),
1167
- toggleExpanded: () => {
1168
- grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }))
1169
- },
1170
- getIsSelected: () => Boolean((store.state.rowSelection ?? {})[id]),
1171
- toggleSelected: () => {
1172
- grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }))
1173
- },
1174
- getAllCells: () => {
1175
- if (cachedCells) return cachedCells
1176
- const built = new Array<Cell<TData>>(columnCount)
1177
- for (let i = 0; i < columnCount; i++) {
1178
- const column = columns[i]!
1179
- const colIndex = i
1180
- const cell: Cell<TData> = {
1181
- id: `${id}_${column.id}`,
1182
- row,
1183
- column,
1184
- getValue: () => {
1185
- if (!cachedValues) cachedValues = computeValues()
1186
- return cachedValues[colIndex]
1187
- },
1188
- getContext: () => ({
1189
- cell,
1190
- row,
1191
- column,
1192
- table: grid,
1193
- getValue: () => cell.getValue(),
1194
- }),
1195
- }
1196
- built[i] = cell
1197
- }
1198
- cachedCells = built
1199
- return built
1200
- },
1201
- getCellValueByColumnId: (columnId: string) => {
1202
- const idx = columnIndexById.get(columnId)
1203
- if (idx === undefined) return undefined
1204
- if (!cachedValues) cachedValues = computeValues()
1205
- return cachedValues[idx]
1206
- },
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,
1207
1799
  }
1208
1800
  cachedBaseRows[index] = row
1209
1801
  }
1210
1802
  }
1211
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.
1212
1817
  const currentSlices = {
1213
1818
  sorting: store.state.sorting,
1214
1819
  columnFilters: store.state.columnFilters,
1215
1820
  pagination: store.state.pagination,
1216
1821
  grouping: store.state.grouping,
1217
1822
  expanded: store.state.expanded,
1218
- rowSelection: store.state.rowSelection,
1219
1823
  }
1220
1824
  if (
1221
1825
  cachedRowModel &&
@@ -1225,8 +1829,7 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1225
1829
  cachedSlices?.columnFilters === currentSlices.columnFilters &&
1226
1830
  cachedSlices?.pagination === currentSlices.pagination &&
1227
1831
  cachedSlices?.grouping === currentSlices.grouping &&
1228
- cachedSlices?.expanded === currentSlices.expanded &&
1229
- cachedSlices?.rowSelection === currentSlices.rowSelection
1832
+ cachedSlices?.expanded === currentSlices.expanded
1230
1833
  ) {
1231
1834
  return cachedRowModel
1232
1835
  }
@@ -1256,6 +1859,7 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
1256
1859
  return grid
1257
1860
  }
1258
1861
 
1862
+ /** Narrowing helper for the many options that accept a value or a function. */
1259
1863
  export function isFunction(value: unknown): value is (...args: Array<any>) => any {
1260
1864
  return typeof value === 'function'
1261
1865
  }