@svgrid/grid 2.6.21 → 2.6.23

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 (151) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +22 -0
  3. package/dist/GridMenus.svelte +201 -18
  4. package/dist/SvAutoComplete.svelte +4 -0
  5. package/dist/SvComboBox.svelte +5 -1
  6. package/dist/SvDateRangeInput.svelte +25 -3
  7. package/dist/SvDropDownList.svelte +4 -0
  8. package/dist/SvForm.svelte +8 -8
  9. package/dist/SvGrid.controller.svelte.d.ts +61 -9
  10. package/dist/SvGrid.controller.svelte.js +363 -123
  11. package/dist/SvGrid.css +222 -1
  12. package/dist/SvGrid.helpers.d.ts +55 -0
  13. package/dist/SvGrid.helpers.js +83 -0
  14. package/dist/SvGrid.svelte +255 -56
  15. package/dist/SvGrid.types.d.ts +146 -1
  16. package/dist/SvGridCellEditor.svelte +99 -17
  17. package/dist/SvGridSelect.svelte +6 -2
  18. package/dist/SvGridSelect.svelte.d.ts +1 -1
  19. package/dist/SvListBox.svelte +8 -0
  20. package/dist/SvMultiSelect.svelte +6 -2
  21. package/dist/SvMultiSelect.svelte.d.ts +1 -1
  22. package/dist/SvNumberInput.svelte +4 -0
  23. package/dist/SvRichCell.svelte +125 -0
  24. package/dist/SvRichCell.svelte.d.ts +15 -0
  25. package/dist/SvTextArea.svelte +5 -1
  26. package/dist/SvTreeSelect.svelte +6 -2
  27. package/dist/SvTreeSelect.svelte.d.ts +1 -1
  28. package/dist/builtin-editors.d.ts +2 -2
  29. package/dist/builtin-editors.js +19 -3
  30. package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
  31. package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
  32. package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
  33. package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
  34. package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
  35. package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
  36. package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
  37. package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
  38. package/dist/cdn/column-resize-DsfNXMom.js +102 -0
  39. package/dist/cdn/date-format-BNii4zeD.js +1393 -0
  40. package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
  41. package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
  42. package/dist/cdn/row-resize-BRcimkUT.js +95 -0
  43. package/dist/cdn/{src-DBel9wRZ.js → src-DGo7IHug.js} +10017 -9488
  44. package/dist/cdn/{src-BYq-qyrp.js → src-skQN5eqh.js} +7829 -7300
  45. package/dist/cdn/svgrid.js +14 -10
  46. package/dist/cdn/svgrid.svelte-external.js +14 -10
  47. package/dist/cell-values.d.ts +16 -0
  48. package/dist/cell-values.js +28 -0
  49. package/dist/clipboard.d.ts +22 -0
  50. package/dist/clipboard.js +405 -27
  51. package/dist/column-groups.js +1 -1
  52. package/dist/column-resize.d.ts +46 -0
  53. package/dist/column-resize.js +205 -0
  54. package/dist/columns.d.ts +0 -3
  55. package/dist/columns.js +0 -57
  56. package/dist/core.d.ts +19 -4
  57. package/dist/core.js +460 -119
  58. package/dist/editing.js +12 -7
  59. package/dist/editors/cell-editors.d.ts +11 -0
  60. package/dist/editors/cell-editors.js +18 -1
  61. package/dist/filtering/excel-filters.js +28 -0
  62. package/dist/grid-messages.d.ts +15 -0
  63. package/dist/grid-messages.js +15 -0
  64. package/dist/group-display.d.ts +1 -1
  65. package/dist/index.d.ts +6 -1
  66. package/dist/index.js +10 -0
  67. package/dist/list-option.d.ts +6 -0
  68. package/dist/menus.js +41 -8
  69. package/dist/row-resize.d.ts +11 -0
  70. package/dist/row-resize.js +7 -1
  71. package/dist/sanitize-html.d.ts +37 -0
  72. package/dist/sanitize-html.js +234 -0
  73. package/dist/selection-bar-view.svelte.d.ts +25 -0
  74. package/dist/selection-bar-view.svelte.js +13 -0
  75. package/dist/selection.d.ts +2 -1
  76. package/dist/selection.js +133 -8
  77. package/dist/spreadsheet.d.ts +1 -1
  78. package/dist/spreadsheet.js +1 -1
  79. package/package.json +1 -1
  80. package/src/GridMenus.svelte +201 -18
  81. package/src/SvAutoComplete.svelte +4 -0
  82. package/src/SvComboBox.svelte +5 -1
  83. package/src/SvDateRangeInput.svelte +25 -3
  84. package/src/SvDropDownList.svelte +4 -0
  85. package/src/SvForm.svelte +8 -8
  86. package/src/SvGrid.controller.svelte.ts +404 -133
  87. package/src/SvGrid.css +222 -1
  88. package/src/SvGrid.helpers.ts +87 -0
  89. package/src/SvGrid.svelte +255 -56
  90. package/src/SvGrid.types.ts +153 -1
  91. package/src/SvGridCellEditor.svelte +99 -17
  92. package/src/SvGridSelect.svelte +6 -2
  93. package/src/SvListBox.svelte +8 -0
  94. package/src/SvMultiSelect.svelte +6 -2
  95. package/src/SvNumberInput.svelte +4 -0
  96. package/src/SvRichCell.svelte +125 -0
  97. package/src/SvTextArea.svelte +5 -1
  98. package/src/SvTreeSelect.svelte +6 -2
  99. package/src/builtin-editors.test.ts +18 -1
  100. package/src/builtin-editors.ts +19 -3
  101. package/src/cell-values.ts +30 -0
  102. package/src/clipboard.test.ts +93 -20
  103. package/src/clipboard.ts +433 -33
  104. package/src/column-groups.ts +1 -1
  105. package/src/column-resize.test.ts +381 -0
  106. package/src/column-resize.ts +227 -0
  107. package/src/columns.test.ts +0 -103
  108. package/src/columns.ts +0 -58
  109. package/src/core.aggregate.test.ts +134 -0
  110. package/src/core.filter.test.ts +156 -0
  111. package/src/core.grouping.test.ts +146 -0
  112. package/src/core.row-shape.test.ts +119 -0
  113. package/src/core.rowmodel-cache.test.ts +121 -0
  114. package/src/core.sort.test.ts +293 -0
  115. package/src/core.ts +516 -119
  116. package/src/date-string-column.test.ts +87 -0
  117. package/src/editing.test.ts +25 -0
  118. package/src/editing.ts +12 -9
  119. package/src/editor-block-prop.test.ts +132 -0
  120. package/src/editors/cell-editors.ts +27 -1
  121. package/src/filtering/excel-filters.ts +30 -0
  122. package/src/filtering/normalize-fast-path.test.ts +104 -0
  123. package/src/grid-messages.ts +34 -0
  124. package/src/group-display.ts +1 -1
  125. package/src/index.ts +25 -1
  126. package/src/list-option.ts +6 -0
  127. package/src/menus.test.ts +43 -0
  128. package/src/menus.ts +38 -8
  129. package/src/move-cells.test.ts +850 -0
  130. package/src/number-editor-literal.test.ts +84 -0
  131. package/src/number-editor.grid.test.ts +160 -0
  132. package/src/resize-props.test.ts +361 -0
  133. package/src/row-resize.test.ts +31 -0
  134. package/src/row-resize.ts +21 -3
  135. package/src/sanitize-html.test.ts +182 -0
  136. package/src/sanitize-html.ts +235 -0
  137. package/src/selection-bar-view.svelte.ts +36 -0
  138. package/src/selection.test.ts +157 -0
  139. package/src/selection.ts +135 -8
  140. package/src/spreadsheet.ts +1 -1
  141. package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
  142. package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
  143. package/src/svgrid.menu-scroll-close.test.ts +204 -0
  144. package/src/svgrid.selection-bar-seam.test.ts +185 -0
  145. package/src/svgrid.upsell-license.test.ts +117 -0
  146. package/dist/cdn/GridMenus-BfTAKn84.js +0 -488
  147. package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
  148. package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
  149. package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
  150. package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
  151. package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
package/dist/core.js CHANGED
@@ -1,31 +1,70 @@
1
1
  import { resolveColumnId } from './column-id.js';
2
2
  /** Apply a group aggregator over a bucket's leaf rows for one column. */
3
3
  export function applyGroupAggregate(agg, columnId, rows) {
4
- const raw = rows.map((r) => r.getCellValueByColumnId(columnId));
4
+ // One pass, no intermediate arrays.
5
+ //
6
+ // This used to build a `raw` array, then a coerced one, then a filtered one -
7
+ // three allocations per aggregated column PER GROUP - before reducing. On a
8
+ // 100k-row grid grouped two levels deep, aggregation was about two thirds of
9
+ // the total grouping cost (213ms with three aggregators against 81ms with
10
+ // none), and each additional aggregated column added roughly 80ms.
11
+ //
12
+ // `count` first: it never needs to look at a value at all.
13
+ if (agg === 'count')
14
+ return rows.length;
15
+ if (agg === 'first') {
16
+ return rows.length ? rows[0].getCellValueByColumnId(columnId) : undefined;
17
+ }
18
+ if (agg === 'countDistinct') {
19
+ const seen = new Set();
20
+ for (const row of rows)
21
+ seen.add(String(row.getCellValueByColumnId(columnId) ?? ''));
22
+ return seen.size;
23
+ }
5
24
  if (typeof agg === 'function') {
6
- const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n));
25
+ // Custom aggregators keep their contract: the finite numbers, then the
26
+ // original row objects. Note `Number(null)` is 0 and therefore finite, so
27
+ // nulls DO reach the callback as zeros - long-standing behaviour.
28
+ const nums = [];
29
+ for (const row of rows) {
30
+ const n = Number(row.getCellValueByColumnId(columnId));
31
+ if (Number.isFinite(n))
32
+ nums.push(n);
33
+ }
7
34
  return agg(nums, rows.map((r) => r.original));
8
35
  }
9
- if (agg === 'count')
10
- return rows.length;
11
- if (agg === 'countDistinct')
12
- return new Set(raw.map((v) => String(v ?? ''))).size;
13
- if (agg === 'first')
14
- return raw[0];
15
- const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n));
16
- if (!nums.length)
36
+ // sum / avg / min / max / extent share one accumulation pass.
37
+ let count = 0;
38
+ let sum = 0;
39
+ let min = Infinity;
40
+ let max = -Infinity;
41
+ for (const row of rows) {
42
+ const n = Number(row.getCellValueByColumnId(columnId));
43
+ if (!Number.isFinite(n))
44
+ continue;
45
+ count++;
46
+ // Left-to-right, matching the previous `reduce`, so float rounding is
47
+ // bit-identical rather than merely close.
48
+ sum += n;
49
+ // Math.min/max on scalars rather than `<`, which differs on -0, and rather
50
+ // than the old `Math.min(...nums)` - spreading a whole group throws
51
+ // RangeError once the bucket is big enough to exhaust the argument stack.
52
+ min = Math.min(min, n);
53
+ max = Math.max(max, n);
54
+ }
55
+ if (!count)
17
56
  return undefined;
18
57
  switch (agg) {
19
58
  case 'sum':
20
- return nums.reduce((a, b) => a + b, 0);
59
+ return sum;
21
60
  case 'avg':
22
- return nums.reduce((a, b) => a + b, 0) / nums.length;
61
+ return sum / count;
23
62
  case 'min':
24
- return Math.min(...nums);
63
+ return min;
25
64
  case 'max':
26
- return Math.max(...nums);
65
+ return max;
27
66
  case 'extent':
28
- return `${Math.min(...nums)} – ${Math.max(...nums)}`;
67
+ return `${min} – ${max}`;
29
68
  default:
30
69
  return undefined;
31
70
  }
@@ -107,6 +146,124 @@ export const filterFns = {
107
146
  includesString: (value, query) => String(value).toLowerCase().includes(query.toLowerCase()),
108
147
  equals: (value, query) => value === query,
109
148
  };
149
+ /**
150
+ * Keys for a base row's private fields.
151
+ *
152
+ * Symbols, not string keys, and that is load-bearing. A row's shared methods
153
+ * need a pointer back to the table, but `_ctx` as a normal property made every
154
+ * row serialise the entire grid: `JSON.stringify(oneRow)` grew with the dataset
155
+ * (981 chars at 3 rows, 67,719 at 3,000) because `options.data` is reachable
156
+ * through it, so stringifying a row model was quadratic. Rows used to serialise
157
+ * to a small constant and must again.
158
+ *
159
+ * A symbol key is invisible to `JSON.stringify`, `Object.keys` and `for...in`,
160
+ * yet IS copied by object spread - which matters because several row models
161
+ * legitimately do `{ ...row, depth }` and the clone needs these to work.
162
+ * Non-enumerable string keys would have hidden them from JSON but also from the
163
+ * spread, silently breaking every cloned row.
164
+ */
165
+ const ROW_CTX = Symbol('svgrid.row.ctx');
166
+ const ROW_VALUES = Symbol('svgrid.row.values');
167
+ const ROW_CELLS = Symbol('svgrid.row.cells');
168
+ /**
169
+ * The methods every base row carries, defined ONCE and assigned by reference.
170
+ *
171
+ * Rows used to be built as object literals whose methods were closures, which
172
+ * meant a 100k-row grid allocated 700k closures and a closure scope per row
173
+ * before painting anything. Measured at 100k x 9: 13.8 ms and 56.5 MB to build,
174
+ * against 2.0 ms and 14.5 MB for this shape - the single largest cost in
175
+ * mounting a large grid.
176
+ *
177
+ * They read their row through `this` rather than a captured variable, which is
178
+ * why they can be shared. Note they are assigned as OWN properties rather than
179
+ * put on a prototype: `Row` is public, several row models legitimately do
180
+ * `{ ...row, depth }`, and a spread copies own properties but not a prototype.
181
+ * A class here would silently strip every method off a cloned row.
182
+ */
183
+ const BASE_ROW_METHODS = {
184
+ getCanExpand() {
185
+ return false;
186
+ },
187
+ getIsExpanded() {
188
+ return Boolean((this[ROW_CTX].store.state.expanded ?? {})[this.id]);
189
+ },
190
+ toggleExpanded() {
191
+ const id = this.id;
192
+ this[ROW_CTX].grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
193
+ },
194
+ getIsSelected() {
195
+ return Boolean((this[ROW_CTX].store.state.rowSelection ?? {})[this.id]);
196
+ },
197
+ toggleSelected() {
198
+ const id = this.id;
199
+ this[ROW_CTX].grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }));
200
+ },
201
+ getAllCells() {
202
+ return this[ROW_CELLS] ?? buildBaseRowCells(this);
203
+ },
204
+ getCellValueByColumnId(columnId) {
205
+ const idx = this[ROW_CTX].columnIndexById.get(columnId);
206
+ if (idx === undefined)
207
+ return undefined;
208
+ if (!this[ROW_VALUES])
209
+ this[ROW_VALUES] = baseRowValues(this);
210
+ return this[ROW_VALUES][idx];
211
+ },
212
+ };
213
+ /**
214
+ * Materialise one row's `Cell[]`, memoised on the row.
215
+ *
216
+ * A free function taking the row rather than a method using `this`, because the
217
+ * cell closures need a stable reference to it and aliasing `this` inside a
218
+ * method is exactly the pattern that produces `self`/`that` bugs.
219
+ */
220
+ function buildBaseRowCells(row) {
221
+ const { columns, columnCount, grid } = row[ROW_CTX];
222
+ const built = new Array(columnCount);
223
+ for (let i = 0; i < columnCount; i++) {
224
+ const column = columns[i];
225
+ const colIndex = i;
226
+ const cell = {
227
+ id: `${row.id}_${column.id}`,
228
+ row,
229
+ column,
230
+ getValue: () => {
231
+ if (!row[ROW_VALUES])
232
+ row[ROW_VALUES] = baseRowValues(row);
233
+ return row[ROW_VALUES][colIndex];
234
+ },
235
+ getContext: () => ({
236
+ cell,
237
+ row,
238
+ column,
239
+ table: grid,
240
+ getValue: () => cell.getValue(),
241
+ }),
242
+ };
243
+ built[i] = cell;
244
+ }
245
+ row[ROW_CELLS] = built;
246
+ return built;
247
+ }
248
+ /**
249
+ * Resolve every column's value for one row. Kept lazy: a 100k-row grid showing
250
+ * twenty rows must not materialise 900k values to paint.
251
+ */
252
+ function baseRowValues(row) {
253
+ const { columns, columnCount } = row[ROW_CTX];
254
+ const original = row.original;
255
+ const values = new Array(columnCount);
256
+ for (let i = 0; i < columnCount; i++) {
257
+ const def = columns[i].columnDef;
258
+ if (def.fieldFn)
259
+ values[i] = def.fieldFn(original);
260
+ else if (def.field)
261
+ values[i] = original[def.field];
262
+ else
263
+ values[i] = undefined;
264
+ }
265
+ return values;
266
+ }
110
267
  /**
111
268
  * The identity stage that starts every pipeline. Always required, even when no
112
269
  * other stage is: it is what turns your data into rows.
@@ -123,15 +280,25 @@ export function createFilteredRowModel() {
123
280
  const filters = table.getState().columnFilters ?? [];
124
281
  if (!filters.length)
125
282
  return rows;
283
+ // Resolve each filter's match function once, outside the row loop.
284
+ const compiled = filters.map((filter) => ({
285
+ id: filter.id,
286
+ value: filter.value,
287
+ fn: filter.fn ? filterFns[filter.fn] : filterFns.includesString,
288
+ }));
126
289
  return rows.filter((row) => {
127
- return filters.every((filter) => {
128
- const cellValue = row
129
- .getAllCells()
130
- .find((cell) => cell.column.id === filter.id)
131
- ?.getValue();
132
- const filterFn = filter.fn ? filterFns[filter.fn] : filterFns.includesString;
133
- return filterFn(cellValue, filter.value);
134
- });
290
+ for (let i = 0; i < compiled.length; i++) {
291
+ const filter = compiled[i];
292
+ // `getCellValueByColumnId` rather than `getAllCells().find(...)`.
293
+ // Both read the same lazily-built `cachedValues` array, but the latter
294
+ // also builds and caches the row's whole `Cell[]` - one object per
295
+ // column - purely to reach one field. On a 100k-row grid that is
296
+ // 100,000 cell arrays the filter never looks at again, and it defeats
297
+ // the laziness the row factory exists to provide.
298
+ if (!filter.fn(row.getCellValueByColumnId(filter.id), filter.value))
299
+ return false;
300
+ }
301
+ return true;
135
302
  });
136
303
  };
137
304
  }
@@ -160,7 +327,10 @@ export function createGroupedRowModel() {
160
327
  // group row is built that stands in for its children - a non-group column
161
328
  // resolves to the value shared by every leaf row, or to undefined when the
162
329
  // leaves disagree.
163
- function buildGroups(input, levelIndex, depth, idPrefix) {
330
+ function buildGroups(input, levelIndex, depth, idPrefix,
331
+ /** Grouping columns already fixed by an ancestor bucket, and their raw
332
+ * values - `undefined` where that bucket mixed several. */
333
+ fixedValues) {
164
334
  if (levelIndex >= grouping.length) {
165
335
  // Leaves: actual data rows, with their nesting depth recorded.
166
336
  return input.map((row) => ({ ...row, depth }));
@@ -172,22 +342,51 @@ export function createGroupedRowModel() {
172
342
  for (const row of input) {
173
343
  const value = row.getCellValueByColumnId(groupKey);
174
344
  const key = String(value ?? '');
175
- const list = buckets.get(key) ?? [];
176
- list.push(row);
177
- buckets.set(key, list);
345
+ const bucket = buckets.get(key);
346
+ if (bucket) {
347
+ bucket.rows.push(row);
348
+ if (!bucket.mixed && bucket.raw !== value)
349
+ bucket.mixed = true;
350
+ }
351
+ else {
352
+ buckets.set(key, { rows: [row], raw: value, mixed: false });
353
+ }
178
354
  }
179
355
  const groupRows = [];
180
356
  let index = 0;
181
- buckets.forEach((children, key) => {
357
+ buckets.forEach((bucket, key) => {
358
+ const children = bucket.rows;
182
359
  const id = `${idPrefix}_${groupKey}_${key}`;
183
- const subRows = buildGroups(children, levelIndex + 1, depth + 1, id);
360
+ // Record this column as fixed for deeper levels ONLY when the bucket is
361
+ // homogeneous. If all rows here share a raw value, so does every subset
362
+ // of them, which is what makes the shortcut sound.
363
+ //
364
+ // A MIXED bucket must not be recorded at all - not even as "undefined".
365
+ // `null`, `undefined` and `''` share a bucket key, so a mixed bucket can
366
+ // still split into homogeneous children one level down, and those
367
+ // children have a real shared value that a scan would find. Marking the
368
+ // column resolved here would hand them the parent's disagreement.
369
+ const nextFixed = bucket.mixed ? fixedValues : new Map(fixedValues).set(groupKey, bucket.raw);
370
+ const subRows = buildGroups(children, levelIndex + 1, depth + 1, id, nextFixed);
184
371
  const isDeepest = levelIndex + 1 >= grouping.length;
185
372
  const leafCount = isDeepest
186
373
  ? subRows.length
187
374
  : subRows.reduce((sum, sub) => sum + (sub.leafCount ?? 0), 0);
375
+ // Every grouping column ABOVE this level is already resolved: bucketing
376
+ // by it is what made it constant, so scanning the children to rediscover
377
+ // it is pure waste. Only the current level's key short-circuited before,
378
+ // so a second-level group walked all of its children to re-derive the
379
+ // first level's value - about 100,000 reads on the 100k x 9 two-level
380
+ // case, for an answer already in hand.
381
+ //
382
+ // The current level still returns the stringified bucket key rather than
383
+ // the raw value, because that is what it has always returned and the
384
+ // group row's display depends on it.
188
385
  const resolveColumnValue = (columnId) => {
189
386
  if (columnId === groupKey)
190
387
  return key;
388
+ if (fixedValues.has(columnId))
389
+ return fixedValues.get(columnId);
191
390
  let resolved;
192
391
  let hasResolved = false;
193
392
  for (const child of children) {
@@ -244,7 +443,7 @@ export function createGroupedRowModel() {
244
443
  });
245
444
  return groupRows;
246
445
  }
247
- return buildGroups(rows, 0, 0, 'group');
446
+ return buildGroups(rows, 0, 0, 'group', new Map());
248
447
  };
249
448
  }
250
449
  /**
@@ -362,30 +561,206 @@ export function createExpandedRowModel() {
362
561
  * domain-specific ordering.
363
562
  */
364
563
  export function createSortedRowModel(localSortFns = sortFns) {
365
- return ({ table, rows }) => {
564
+ return function sortedRowModelStage({ table, rows }) {
366
565
  const sorting = table.getState().sorting ?? [];
367
566
  if (!sorting.length)
368
567
  return rows;
369
- const sorted = [...rows].sort((a, b) => {
370
- for (const clause of sorting) {
371
- const column = table.getAllColumns().find((col) => col.id === clause.id);
372
- if (!column)
373
- continue;
374
- const editorType = column.columnDef.editorType;
375
- const comparator = editorType === 'number'
376
- ? localSortFns.number
377
- : editorType === 'date' || editorType === 'datetime'
378
- ? localSortFns.date
379
- : localSortFns.auto;
380
- const result = comparator(a.getCellValueByColumnId(column.id), b.getCellValueByColumnId(column.id));
381
- if (result !== 0)
382
- return clause.desc ? -result : result;
568
+ // Resolve every clause ONCE, before sorting.
569
+ //
570
+ // This used to live inside the comparator, so `getAllColumns().find(...)`
571
+ // ran per comparison per clause: a single-clause sort of 100k rows made
572
+ // 1,528,947 array scans, and a three-clause sort made 3,933,751 (measured;
573
+ // `pnpm bench --case=sort-1col`). The comparator is called O(n log n)
574
+ // times, so anything inside it that is not O(1) sets the cost of the sort.
575
+ const allColumns = table.getAllColumns();
576
+ const clauses = [];
577
+ for (const clause of sorting) {
578
+ const column = allColumns.find((col) => col.id === clause.id);
579
+ if (!column)
580
+ continue;
581
+ const editorType = column.columnDef.editorType;
582
+ const comparator = editorType === 'number'
583
+ ? localSortFns.number
584
+ : editorType === 'date' || editorType === 'datetime'
585
+ ? localSortFns.date
586
+ : localSortFns.auto;
587
+ // Precompute one sort key per row, so the comparator reads an array slot
588
+ // instead of walking the row's column index on every comparison. For the
589
+ // three built-in comparators the key is also cheaper to compare than the
590
+ // raw value: a timestamp rather than two `new Date()` allocations, a
591
+ // number rather than two `Number()` coercions, a collator rather than a
592
+ // fresh one per `localeCompare` call.
593
+ //
594
+ // The identity checks against `sortFns` matter: `localSortFns` is a
595
+ // public parameter, so a caller can substitute their own comparators.
596
+ // When they have, we fall through to calling their function with the raw
597
+ // values - still hoisted, just not specialised.
598
+ const columnId = column.id;
599
+ const n = rows.length;
600
+ let keys = new Array(n);
601
+ let compare;
602
+ if (comparator === sortFns.number) {
603
+ for (let i = 0; i < n; i++)
604
+ keys[i] = Number(rows[i].getCellValueByColumnId(columnId) ?? 0);
605
+ compare = compareNumericKeys;
383
606
  }
384
- return 0;
385
- });
607
+ else if (comparator === sortFns.date) {
608
+ for (let i = 0; i < n; i++) {
609
+ keys[i] = new Date(rows[i].getCellValueByColumnId(columnId)).getTime();
610
+ }
611
+ compare = compareNumericKeys;
612
+ }
613
+ else if (comparator === sortFns.auto) {
614
+ const strings = new Array(n);
615
+ // Decide whether ranking is worth attempting BEFORE paying for it.
616
+ //
617
+ // Building the distinct set and then discarding it costs about 9 ms on
618
+ // a 100k-row column where nearly every value is unique, and ranking
619
+ // saves about 19 ms where they repeat - so guessing wrong in either
620
+ // direction is measurable. A small stride sample answers it for well
621
+ // under a millisecond.
622
+ //
623
+ // Strided rather than the first N rows: data arrives sorted or
624
+ // clustered often enough that a prefix is a bad estimator of the whole
625
+ // column. Reading every k-th row is no more expensive and does not care
626
+ // how the rows are arranged.
627
+ const rankLimit = n >> 1;
628
+ let distinct = null;
629
+ if (n > 0) {
630
+ const sampleTarget = Math.min(n, 256);
631
+ const stride = Math.max(1, Math.floor(n / sampleTarget));
632
+ const sample = new Set();
633
+ let sampled = 0;
634
+ for (let i = 0; i < n; i += stride) {
635
+ sample.add(String(rows[i].getCellValueByColumnId(columnId)));
636
+ sampled++;
637
+ }
638
+ // Only attempt ranking when the sample suggests real repetition.
639
+ if (sample.size * 2 <= sampled)
640
+ distinct = new Set();
641
+ }
642
+ for (let i = 0; i < n; i++) {
643
+ const s = String(rows[i].getCellValueByColumnId(columnId));
644
+ strings[i] = s;
645
+ if (distinct) {
646
+ distinct.add(s);
647
+ if (distinct.size > rankLimit)
648
+ distinct = null;
649
+ }
650
+ }
651
+ // Collation is by far the most expensive comparison we do - a CPU
652
+ // profile of a 100k text sort put 65% of the whole operation inside the
653
+ // collator. But a column's DISTINCT values are usually far fewer than
654
+ // its rows (statuses, regions, categories, owners), so rank the
655
+ // distinct values once and sort by rank afterwards. That turns
656
+ // O(n log n) collator calls into O(u log u), where u is the number of
657
+ // distinct values, and the resulting order is identical because rank is
658
+ // a monotone relabelling of the collated order - equal strings share a
659
+ // rank, so ties still fall through to the stable sort exactly as before.
660
+ //
661
+ // Guarded on the uniqueness ratio: when nearly every value is distinct
662
+ // the ranking pass cannot save any collator calls and would just add an
663
+ // O(n) Map build, so that case keeps comparing directly.
664
+ if (distinct) {
665
+ const ordered = Array.from(distinct).sort(compareCollatedKeys);
666
+ const rankOf = new Map();
667
+ for (let i = 0; i < ordered.length; i++)
668
+ rankOf.set(ordered[i], i);
669
+ for (let i = 0; i < n; i++)
670
+ keys[i] = rankOf.get(strings[i]);
671
+ compare = compareNumericKeys;
672
+ }
673
+ else {
674
+ keys = strings;
675
+ compare = compareCollatedKeys;
676
+ }
677
+ }
678
+ else {
679
+ for (let i = 0; i < n; i++)
680
+ keys[i] = rows[i].getCellValueByColumnId(columnId);
681
+ compare = comparator;
682
+ }
683
+ clauses.push({ keys, desc: clause.desc, compare });
684
+ }
685
+ if (!clauses.length)
686
+ return rows;
687
+ // Sort an index array, then materialise. `Array.prototype.sort` is stable,
688
+ // so equal keys keep their original relative order exactly as the previous
689
+ // `[...rows].sort(...)` did.
690
+ const order = new Array(rows.length);
691
+ for (let i = 0; i < order.length; i++)
692
+ order[i] = i;
693
+ // Single-clause sorts get a specialised comparator.
694
+ //
695
+ // Most sorts are one column, and that path runs O(n log n) times - 1.66
696
+ // million comparisons for 100k rows. The general loop pays a clause-array
697
+ // index, three property loads and an indirect call on every one of them,
698
+ // none of which vary once the clause list is fixed. Hoisting them into a
699
+ // closure and, for the numeric comparator, inlining the subtraction removes
700
+ // the call entirely.
701
+ //
702
+ // `keys[ib] - keys[ia]` for descending is exactly `-(keys[ia] - keys[ib])`
703
+ // as far as sorting is concerned: both are NaN for unorderable values,
704
+ // which the spec coerces to 0, and they differ only in producing 0 versus
705
+ // -0 for equal keys, which sorts identically.
706
+ if (clauses.length === 1) {
707
+ const { keys, compare, desc } = clauses[0];
708
+ if (compare === compareNumericKeys) {
709
+ order.sort(desc
710
+ ? function compareOneNumericDesc(ia, ib) { return keys[ib] - keys[ia]; }
711
+ : function compareOneNumericAsc(ia, ib) { return keys[ia] - keys[ib]; });
712
+ }
713
+ else {
714
+ order.sort(desc
715
+ ? function compareOneDesc(ia, ib) { return -compare(keys[ia], keys[ib]); }
716
+ : function compareOneAsc(ia, ib) { return compare(keys[ia], keys[ib]); });
717
+ }
718
+ }
719
+ else {
720
+ order.sort(function compareRowsByClauses(ia, ib) {
721
+ for (let k = 0; k < clauses.length; k++) {
722
+ const clause = clauses[k];
723
+ const result = clause.compare(clause.keys[ia], clause.keys[ib]);
724
+ if (result !== 0)
725
+ return clause.desc ? -result : result;
726
+ }
727
+ return 0;
728
+ });
729
+ }
730
+ const sorted = new Array(rows.length);
731
+ for (let i = 0; i < order.length; i++)
732
+ sorted[i] = rows[order[i]];
386
733
  return sorted;
387
734
  };
388
735
  }
736
+ /**
737
+ * Numeric key comparison for the built-in `number` and `date` comparators.
738
+ * Subtraction rather than `<`/`>` on purpose: it reproduces the originals
739
+ * exactly, NaN included. An unparseable date or a non-numeric value yields NaN,
740
+ * and the sort spec turns a NaN comparison result into 0 (SortCompare coerces
741
+ * it), which is the behaviour callers already depend on.
742
+ */
743
+ function compareNumericKeys(a, b) {
744
+ return a - b;
745
+ }
746
+ /**
747
+ * Text key comparison for the built-in `auto` comparator.
748
+ *
749
+ * `localeCompare`, NOT a hoisted `Intl.Collator`. The specification defines
750
+ * `localeCompare` with no locale or options as constructing a default collator
751
+ * per call, so hoisting one looks like the obvious optimisation - and it is
752
+ * measurably slower. V8 fast-paths `String.prototype.localeCompare` for the
753
+ * default locale; going through a collator object misses that path. Measured
754
+ * sorting 100k strings: 33 ms via `localeCompare` against 83 ms via a cached
755
+ * collator on ASCII, 58 ms against 99 ms with accents mixed in, and the two
756
+ * produce byte-identical orderings across all 100k positions.
757
+ *
758
+ * Left as its own function so the sort path has one place to change if that
759
+ * ever stops being true. Re-measure before "optimising" this again.
760
+ */
761
+ function compareCollatedKeys(a, b) {
762
+ return a.localeCompare(b);
763
+ }
389
764
  /**
390
765
  * Build a headless grid: state, the row pipeline, and the read methods, with no
391
766
  * DOM and no Svelte. This is the engine `<SvGrid>` renders.
@@ -601,94 +976,61 @@ export function createSvGridCore(options) {
601
976
  for (let i = 0; i < columns.length; i++)
602
977
  columnIndexById.set(columns[i].id, i);
603
978
  const columnCount = columns.length;
979
+ // One shared context for every row in this table, so a row carries a
980
+ // pointer rather than a closure scope. See BASE_ROW_METHODS.
981
+ const rowCtx = {
982
+ grid: grid,
983
+ store,
984
+ columns,
985
+ columnCount,
986
+ columnIndexById,
987
+ };
604
988
  cachedBaseRows = new Array(options.data.length);
605
989
  const getRowId = options.getRowId;
990
+ const m = BASE_ROW_METHODS;
606
991
  for (let index = 0; index < options.data.length; index++) {
607
992
  const original = options.data[index];
608
- const id = getRowId ? getRowId(original, index) : String(index);
609
- // `values` and `cells` are computed lazily - for a 100k-row grid
610
- // with only ~20 visible rows we don't want to materialise every
611
- // row's full value array or cell objects up front.
612
- let cachedValues = null;
613
- let cachedCells = null;
614
- function computeValues() {
615
- const values = new Array(columnCount);
616
- for (let i = 0; i < columnCount; i++) {
617
- const column = columns[i];
618
- if (column.columnDef.fieldFn) {
619
- values[i] = column.columnDef.fieldFn(original);
620
- }
621
- else if (column.columnDef.field) {
622
- values[i] = original[column.columnDef.field];
623
- }
624
- else {
625
- values[i] = undefined;
626
- }
627
- }
628
- return values;
629
- }
993
+ // `_values` and `_cells` stay null until something reads them - a
994
+ // 100k-row grid showing twenty rows must not materialise every row's
995
+ // values or cell objects to paint.
630
996
  const row = {
631
- id,
997
+ id: getRowId ? getRowId(original, index) : String(index),
632
998
  index,
633
999
  original,
634
1000
  depth: 0,
635
- getCanExpand: () => false,
636
- getIsExpanded: () => Boolean((store.state.expanded ?? {})[id]),
637
- toggleExpanded: () => {
638
- grid.setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
639
- },
640
- getIsSelected: () => Boolean((store.state.rowSelection ?? {})[id]),
641
- toggleSelected: () => {
642
- grid.setRowSelection((prev) => ({ ...prev, [id]: !prev[id] }));
643
- },
644
- getAllCells: () => {
645
- if (cachedCells)
646
- return cachedCells;
647
- const built = new Array(columnCount);
648
- for (let i = 0; i < columnCount; i++) {
649
- const column = columns[i];
650
- const colIndex = i;
651
- const cell = {
652
- id: `${id}_${column.id}`,
653
- row,
654
- column,
655
- getValue: () => {
656
- if (!cachedValues)
657
- cachedValues = computeValues();
658
- return cachedValues[colIndex];
659
- },
660
- getContext: () => ({
661
- cell,
662
- row,
663
- column,
664
- table: grid,
665
- getValue: () => cell.getValue(),
666
- }),
667
- };
668
- built[i] = cell;
669
- }
670
- cachedCells = built;
671
- return built;
672
- },
673
- getCellValueByColumnId: (columnId) => {
674
- const idx = columnIndexById.get(columnId);
675
- if (idx === undefined)
676
- return undefined;
677
- if (!cachedValues)
678
- cachedValues = computeValues();
679
- return cachedValues[idx];
680
- },
1001
+ [ROW_CTX]: rowCtx,
1002
+ [ROW_VALUES]: null,
1003
+ [ROW_CELLS]: null,
1004
+ getCanExpand: m.getCanExpand,
1005
+ getIsExpanded: m.getIsExpanded,
1006
+ toggleExpanded: m.toggleExpanded,
1007
+ getIsSelected: m.getIsSelected,
1008
+ toggleSelected: m.toggleSelected,
1009
+ getAllCells: m.getAllCells,
1010
+ getCellValueByColumnId: m.getCellValueByColumnId,
681
1011
  };
682
1012
  cachedBaseRows[index] = row;
683
1013
  }
684
1014
  }
1015
+ // Only the slices a pipeline stage actually READS belong in this key.
1016
+ //
1017
+ // `rowSelection` used to be here, which meant ticking one checkbox on a
1018
+ // 100k-row grid re-filtered and re-sorted the entire dataset to rebuild a
1019
+ // row array that was identical by construction. Nothing reads it: the two
1020
+ // consumers are `getIsSelected` closures (on data rows and on group rows)
1021
+ // that read `store.state` when called, so they observe a selection change
1022
+ // without the model being rebuilt.
1023
+ //
1024
+ // `_rowModels` is a closed set of six named slots, so no consumer stage
1025
+ // can be inserted that might read selection. A caller CAN supply a custom
1026
+ // function for one of those slots; if one ever needs a slice that is not
1027
+ // listed here, add it here rather than reinstating all of them.
685
1028
  const currentSlices = {
686
1029
  sorting: store.state.sorting,
687
1030
  columnFilters: store.state.columnFilters,
688
1031
  pagination: store.state.pagination,
689
1032
  grouping: store.state.grouping,
690
1033
  expanded: store.state.expanded,
691
- rowSelection: store.state.rowSelection,
692
1034
  };
693
1035
  if (cachedRowModel &&
694
1036
  cachedRowModelBaseRows === cachedBaseRows &&
@@ -697,8 +1039,7 @@ export function createSvGridCore(options) {
697
1039
  cachedSlices?.columnFilters === currentSlices.columnFilters &&
698
1040
  cachedSlices?.pagination === currentSlices.pagination &&
699
1041
  cachedSlices?.grouping === currentSlices.grouping &&
700
- cachedSlices?.expanded === currentSlices.expanded &&
701
- cachedSlices?.rowSelection === currentSlices.rowSelection) {
1042
+ cachedSlices?.expanded === currentSlices.expanded) {
702
1043
  return cachedRowModel;
703
1044
  }
704
1045
  let rows = cachedBaseRows;