@svgrid/grid 3.0.4 → 3.0.6

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 (176) hide show
  1. package/README.md +32 -11
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +33 -5
  4. package/dist/SvGrid.controller.svelte.js +397 -58
  5. package/dist/SvGrid.css +200 -2
  6. package/dist/SvGrid.svelte +350 -40
  7. package/dist/SvGrid.types.d.ts +433 -7
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +92 -37
  15. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-C-NtjlrJ.js} +155 -146
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/server-block-cache-DIqb3VZn.js +241 -0
  45. package/dist/cdn/{src-V1uu8iE9.js → src-CgoeSM8k.js} +5339 -4753
  46. package/dist/cdn/{src-BKhZ6eXd.js → src-DfwxB1TH.js} +9817 -9231
  47. package/dist/cdn/svgrid.js +28 -27
  48. package/dist/cdn/svgrid.svelte-external.js +28 -27
  49. package/dist/cdn/validate-AZoD8BoR.js +80 -0
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +401 -38
  59. package/dist/editing.js +34 -0
  60. package/dist/gantt-view.svelte.d.ts +24 -0
  61. package/dist/gantt-view.svelte.js +13 -0
  62. package/dist/grid-icons.d.ts +2 -2
  63. package/dist/grid-icons.js +2 -2
  64. package/dist/grid-messages.d.ts +10 -0
  65. package/dist/grid-messages.js +9 -0
  66. package/dist/headless.d.ts +5 -0
  67. package/dist/headless.js +12 -0
  68. package/dist/history.d.ts +21 -0
  69. package/dist/history.js +34 -4
  70. package/dist/index.d.ts +5 -4
  71. package/dist/index.js +3 -3
  72. package/dist/keyboard-handlers.js +24 -1
  73. package/dist/keyboard.js +4 -0
  74. package/dist/row-drag-touch.d.ts +1 -1
  75. package/dist/row-drag.js +35 -1
  76. package/dist/row-model.d.ts +139 -0
  77. package/dist/row-model.js +16 -0
  78. package/dist/row-resize.js +22 -1
  79. package/dist/selection.d.ts +2 -1
  80. package/dist/selection.js +31 -3
  81. package/dist/server-block-cache.d.ts +219 -0
  82. package/dist/server-block-cache.js +539 -0
  83. package/dist/server-data-source.d.ts +274 -8
  84. package/dist/server-data-source.js +229 -20
  85. package/dist/server.d.ts +13 -0
  86. package/dist/server.js +13 -0
  87. package/dist/sparkline.js +7 -5
  88. package/dist/svgrid-wrapper.types.d.ts +33 -0
  89. package/dist/validate.d.ts +4 -0
  90. package/dist/validate.js +29 -3
  91. package/package.json +11 -1
  92. package/src/GridMenus.svelte +34 -4
  93. package/src/SvGrid.controller.svelte.ts +397 -61
  94. package/src/SvGrid.css +200 -2
  95. package/src/SvGrid.svelte +350 -40
  96. package/src/SvGrid.types.ts +453 -7
  97. package/src/SvGridDropdown.svelte +20 -0
  98. package/src/SvModal.svelte +22 -0
  99. package/src/SvModal.test.ts +30 -1
  100. package/src/a11y/dismissable.test.ts +10 -1
  101. package/src/a11y/dismissable.ts +22 -2
  102. package/src/build-api.ts +96 -31
  103. package/src/cell-formatting.test.ts +17 -0
  104. package/src/cell-formatting.ts +18 -1
  105. package/src/cell-render.test.ts +24 -2
  106. package/src/cell-render.ts +24 -8
  107. package/src/clipboard.test.ts +107 -0
  108. package/src/clipboard.ts +62 -23
  109. package/src/columns.test.ts +4 -4
  110. package/src/columns.ts +7 -2
  111. package/src/conditional-formatting.test.ts +8 -0
  112. package/src/conditional-formatting.ts +3 -0
  113. package/src/core.rowmodel-cache.test.ts +51 -0
  114. package/src/core.tick-repair.test.ts +287 -0
  115. package/src/core.ts +407 -38
  116. package/src/editing.test.ts +52 -0
  117. package/src/editing.ts +33 -0
  118. package/src/gantt-stub.test.svelte +38 -0
  119. package/src/gantt-view.svelte.ts +35 -0
  120. package/src/grid-icons.ts +2 -2
  121. package/src/grid-messages.test.ts +7 -0
  122. package/src/grid-messages.ts +22 -0
  123. package/src/headless.ts +24 -0
  124. package/src/history.test.ts +49 -1
  125. package/src/history.ts +48 -2
  126. package/src/icon-seam.test.ts +2 -4
  127. package/src/index.ts +38 -10
  128. package/src/keyboard-handlers.coverage.test.ts +65 -0
  129. package/src/keyboard-handlers.ts +26 -1
  130. package/src/keyboard.ts +3 -0
  131. package/src/row-drag-touch.ts +2 -2
  132. package/src/row-drag.test.ts +51 -0
  133. package/src/row-drag.ts +35 -2
  134. package/src/row-model.ts +152 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.test.ts +3 -0
  137. package/src/selection.ts +33 -3
  138. package/src/server-block-cache.test.ts +672 -0
  139. package/src/server-block-cache.ts +690 -0
  140. package/src/server-data-source.infinite.test.ts +405 -0
  141. package/src/server-data-source.test.ts +19 -0
  142. package/src/server-data-source.ts +496 -29
  143. package/src/server.ts +49 -0
  144. package/src/sparkline.test.ts +7 -0
  145. package/src/sparkline.ts +6 -4
  146. package/src/svgrid-wrapper.types.ts +32 -0
  147. package/src/svgrid.api.test.ts +21 -0
  148. package/src/svgrid.charting.test.ts +12 -4
  149. package/src/svgrid.context-menu.test.ts +12 -0
  150. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  151. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  152. package/src/svgrid.gantt-seam.test.ts +199 -0
  153. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  156. package/src/svgrid.row-model-seam.svelte.test.ts +305 -0
  157. package/src/svgrid.row-pinning.test.ts +15 -1
  158. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  159. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  160. package/src/svgrid.upsell-license.test.ts +8 -5
  161. package/src/test-setup.ts +12 -0
  162. package/src/transaction.test.ts +58 -0
  163. package/src/validate.test.ts +29 -0
  164. package/src/validate.ts +34 -3
  165. package/dist/SvGroupCell.svelte +0 -141
  166. package/dist/SvGroupCell.svelte.d.ts +0 -49
  167. package/dist/SvRowGroupPanel.svelte +0 -186
  168. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  169. package/dist/cdn/GridMenus-7kbpnnBW.js +0 -635
  170. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  171. package/dist/server-group-model.d.ts +0 -98
  172. package/dist/server-group-model.js +0 -263
  173. package/src/SvGroupCell.svelte +0 -141
  174. package/src/SvRowGroupPanel.svelte +0 -186
  175. package/src/server-group-model.test.ts +0 -294
  176. package/src/server-group-model.ts +0 -370
@@ -1,6 +1,8 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest'
2
2
  import { createDismissableLayer, dismissableDepth, onScrollOutside } from './dismissable'
3
3
 
4
+ const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
5
+
4
6
  function panel(): HTMLElement {
5
7
  const el = document.createElement('div')
6
8
  document.body.appendChild(el)
@@ -77,13 +79,20 @@ describe('createDismissableLayer', () => {
77
79
  expect(dismissableDepth()).toBe(0)
78
80
  })
79
81
 
80
- it('close-on-scroll ignores scrolling inside the panel', () => {
82
+ it('close-on-scroll ignores scrolling inside the panel', async () => {
81
83
  const el = panel()
82
84
  const list = document.createElement('div') // e.g. the filter menu's facet list
83
85
  el.appendChild(list)
84
86
  let closed = 0
85
87
  const off = onScrollOutside(() => el, () => closed++)
86
88
 
89
+ // A scroll that finished before the panel opened is still queued for
90
+ // the next rendering step; it lands after the listener is asked for
91
+ // and must not close what the same click just opened.
92
+ document.body.dispatchEvent(new Event('scroll'))
93
+ expect(closed).toBe(0)
94
+ await nextFrame()
95
+
87
96
  // Inner scroll containers don't bubble, so this only reaches the capture
88
97
  // listener - it must NOT close the menu the user is scrolling.
89
98
  list.dispatchEvent(new Event('scroll'))
@@ -101,6 +101,16 @@ export function dismissableDepth(): number {
101
101
  * window listener gets wrong: inner scroll containers don't bubble, so the
102
102
  * capture listener sees them and dismisses the panel the user is scrolling.
103
103
  *
104
+ * The listener arms one animation frame after it is asked for, not at once.
105
+ * Scroll events are queued and dispatched in the next rendering step, so a
106
+ * scroll that FINISHED before the click that opened the panel - the browser
107
+ * pulling a half-hidden funnel button into view as it focuses it, a test
108
+ * driver scrolling the header into place - still lands after the panel has
109
+ * mounted and wired this listener, and used to close it on the spot. The
110
+ * panel measured its anchor after that scroll, so the event carries nothing
111
+ * it needs to react to. Rendering runs the scroll steps before the animation
112
+ * frame callbacks, which is why one frame is exactly the right delay.
113
+ *
104
114
  * Returns the unsubscribe function.
105
115
  */
106
116
  export function onScrollOutside(element: () => DismissTarget, onOutside: () => void): () => void {
@@ -109,6 +119,16 @@ export function onScrollOutside(element: () => DismissTarget, onOutside: () => v
109
119
  if (containsTarget(element(), event.target as Node)) return
110
120
  onOutside()
111
121
  }
112
- window.addEventListener('scroll', handler, true)
113
- return () => window.removeEventListener('scroll', handler, true)
122
+ let armed = false
123
+ let frame: number | null = requestAnimationFrame(() => {
124
+ frame = null
125
+ armed = true
126
+ window.addEventListener('scroll', handler, true)
127
+ })
128
+ return () => {
129
+ if (frame !== null) cancelAnimationFrame(frame)
130
+ frame = null
131
+ if (armed) window.removeEventListener('scroll', handler, true)
132
+ armed = false
133
+ }
114
134
  }
package/src/build-api.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  columnDefMatchesId,
27
27
  } from "./cell-values";
28
28
  import { hasAdvancedFilterEngine } from "./advanced-filter.svelte";
29
- import { undoHistory, redoHistory } from "./history";
29
+ import { undoHistory, redoHistory, peekUndoHistory, peekRedoHistory } from "./history";
30
30
  import { buildCommandContext } from "./command-context";
31
31
 
32
32
  type OrientedRange = {
@@ -35,6 +35,32 @@ type OrientedRange = {
35
35
  highlight: { row: number; col: number }
36
36
  }
37
37
 
38
+ /**
39
+ * Row id -> index, per data array, for `applyTransaction`.
40
+ *
41
+ * An update batch used to walk every row calling `getRowId` to find the ones
42
+ * it replaced - 100k calls per tick on a 100k-row feed, whatever the batch
43
+ * size. The map is built once per array and, for an update-only transaction,
44
+ * carried to the array it produces: same ids at the same indices. An add or
45
+ * remove changes indices, so those transactions leave the next array to
46
+ * build its own map on first use. Keyed weakly so a replaced array takes its
47
+ * map with it.
48
+ */
49
+ const rowIndexByData = new WeakMap<ReadonlyArray<unknown>, Map<string, number>>()
50
+
51
+ function rowIndexOf<TData>(
52
+ data: ReadonlyArray<TData>,
53
+ getId: (row: TData, index: number) => string,
54
+ ): Map<string, number> {
55
+ let byId = rowIndexByData.get(data)
56
+ if (!byId) {
57
+ byId = new Map()
58
+ for (let i = 0; i < data.length; i++) byId.set(getId(data[i]!, i), i)
59
+ rowIndexByData.set(data, byId)
60
+ }
61
+ return byId
62
+ }
63
+
38
64
  /**
39
65
  * The selection as the user made it: committed ranges first, the active one
40
66
  * last, each keeping its anchor as `from` and its focus as `to`. The active
@@ -111,6 +137,10 @@ export function createGridApi<
111
137
  };
112
138
 
113
139
  return {
140
+ getElement() {
141
+ const el = ctx.scrollContainer as HTMLElement | null
142
+ return (el?.closest(".sv-grid-root") as HTMLElement | null) ?? null
143
+ },
114
144
  getCellValue(rowIndex, columnId) {
115
145
  const row = ctx.internalData[rowIndex];
116
146
  const column = findColumn(columnId);
@@ -243,38 +273,41 @@ export function createGridApi<
243
273
  },
244
274
  applyTransaction(tx) {
245
275
  const getId = ctx.props.getRowId;
246
- let next = ctx.internalData.slice() as Array<TData>;
276
+ const current = ctx.internalData as ReadonlyArray<TData>;
277
+ let next = current.slice() as Array<TData>;
247
278
  let added = 0;
248
279
  let updated = 0;
249
280
  let removed = 0;
250
281
 
282
+ // Everything resolves against the indices of `current`: the id ->
283
+ // index map answers updates and id removals with one lookup each, a
284
+ // reference removal with one `indexOf`, and no pass over the rows
285
+ // that did not change. A row both updated and removed in the same
286
+ // transaction ends up removed, as it did when removes ran first.
287
+ const removeIdx = new Set<number>();
251
288
  if (tx.remove?.length) {
252
- const removeIds = new Set<string>();
253
- const removeRefs = new Set<TData>();
289
+ const byId = getId ? rowIndexOf(current, getId) : null;
254
290
  for (const r of tx.remove) {
255
- if (typeof r === "string") removeIds.add(r);
256
- else removeRefs.add(r as TData);
291
+ const idx = typeof r === "string" ? byId?.get(r) : current.indexOf(r as TData);
292
+ if (idx !== undefined && idx >= 0) removeIdx.add(idx);
257
293
  }
258
- next = next.filter((row, i) => {
259
- const hit =
260
- removeRefs.has(row) ||
261
- (getId ? removeIds.has(getId(row, i)) : false);
262
- if (hit) removed += 1;
263
- return !hit;
264
- });
265
294
  }
266
295
 
267
296
  if (tx.update?.length && getId) {
268
- const byId = new Map<string, TData>();
269
- for (const u of tx.update) byId.set(getId(u, 0), u);
270
- next = next.map((row, i) => {
271
- const u = byId.get(getId(row, i));
272
- if (u) {
273
- updated += 1;
274
- return u;
275
- }
276
- return row;
277
- });
297
+ const byId = rowIndexOf(current, getId);
298
+ for (const u of tx.update) {
299
+ const idx = byId.get(getId(u, 0));
300
+ if (idx === undefined) continue;
301
+ next[idx] = u;
302
+ updated += 1;
303
+ }
304
+ // Same ids at the same indices: the next array can reuse the map.
305
+ if (!removeIdx.size && !tx.add?.length) rowIndexByData.set(next, byId);
306
+ }
307
+
308
+ if (removeIdx.size) {
309
+ next = next.filter((_, i) => !removeIdx.has(i));
310
+ removed = removeIdx.size;
278
311
  }
279
312
 
280
313
  if (tx.add?.length) {
@@ -507,6 +540,28 @@ export function createGridApi<
507
540
  getData() {
508
541
  return ctx.internalData;
509
542
  },
543
+ getRowId(row) {
544
+ // The stable id the grid keys a row by: what `getRowId` computed,
545
+ // or the row's position when no `getRowId` prop is set. A number is
546
+ // a DISPLAY index into the rendered rows, the space getActiveCell,
547
+ // scrollToRow and the cell methods work in; a row object is looked
548
+ // up wherever it sits. Null when the index or the object is unknown,
549
+ // so a caller can tell "no such row" from a real id.
550
+ if (typeof row === "number") {
551
+ const found = ctx.allRows[row];
552
+ return found && !isGroupRow(found) ? (found.id as string) : null;
553
+ }
554
+ const inRows = ctx.allRows.find(
555
+ (r: { original?: unknown; id: string }) => r.original === row,
556
+ );
557
+ if (inRows) return inRows.id as string;
558
+ const getId = ctx.props.getRowId as
559
+ | ((r: TData, index: number) => string)
560
+ | undefined;
561
+ const index = ctx.internalData.indexOf(row as TData);
562
+ if (index < 0) return null;
563
+ return getId ? getId(row as TData, index) : String(index);
564
+ },
510
565
  getColumns() {
511
566
  // Snapshot every column with its human-readable label and
512
567
  // visibility flag. Used by external code (exporters, column
@@ -631,10 +686,10 @@ export function createGridApi<
631
686
  ctx.autosizeColumn(columnId);
632
687
  },
633
688
  getRowHeight(rowIndex: number) {
634
- const own = ctx.rowResizeHeightPx(rowIndex);
635
- if (own != null) return own;
636
- const declared = ctx.props.rowHeight;
637
- return typeof declared === "function" ? declared(rowIndex) : (declared ?? 30);
689
+ // The height the grid draws the row at, whatever decided it: a drag,
690
+ // a measurement under auto height, a detail row's own height, the
691
+ // declared rowHeight or the default.
692
+ return ctx.rowSizePxOf(rowIndex);
638
693
  },
639
694
  setRowHeight(rowIndex: number, height: number | null) {
640
695
  ctx.setRowResizeHeight(rowIndex, height);
@@ -705,6 +760,9 @@ export function createGridApi<
705
760
  canUndo() { void ctx.historyVersion; return ctx.historyPtr >= 0 },
706
761
  canRedo() { void ctx.historyVersion; return ctx.historyPtr < ctx.history.length - 1 },
707
762
  clearHistory() { ctx.history = []; ctx.historyPtr = -1; ctx.historyVersion += 1 },
763
+ setHistoryTag(tag) { ctx.historyTag = tag },
764
+ peekUndo() { void ctx.historyVersion; return peekUndoHistory(ctx) },
765
+ peekRedo() { void ctx.historyVersion; return peekRedoHistory(ctx) },
708
766
  // ---- Find
709
767
  openFind() { ctx.findOpen = true },
710
768
  closeFind() { ctx.findOpen = false; ctx.findQuery = '' },
@@ -735,9 +793,10 @@ export function createGridApi<
735
793
  });
736
794
  },
737
795
  selectAllRows() {
738
- const next: Record<string, boolean> = {};
739
- for (const row of ctx.allRows) if (!isGroupRow(row)) next[row.id] = true;
740
- ctx.grid.setRowSelection(() => next);
796
+ // One implementation of "select everything", shared with the header
797
+ // checkbox, so it knows about an external selection model and about
798
+ // rows whose data has not arrived.
799
+ ctx.setSelectAllRows(true);
741
800
  },
742
801
  toggleRowSelected(id) {
743
802
  ctx.toggleRowSelectionById(id);
@@ -745,7 +804,13 @@ export function createGridApi<
745
804
  // ---- Pagination
746
805
  getPageInfo() {
747
806
  const { pageIndex, pageSize } = ctx.paginationState;
748
- const total = ctx.allRowsBeforePagination.length;
807
+ // Under external pagination the grid holds ONE page, so counting the
808
+ // rows in hand would report a 95-row table as a 20-row one. The
809
+ // consumer tells us the real total through rowCount, which is what
810
+ // the footer has always shown.
811
+ const total = ctx.externalPaginationEnabled
812
+ ? (ctx.props.rowCount ?? ctx.allRowsBeforePagination.length)
813
+ : ctx.allRowsBeforePagination.length;
749
814
  const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
750
815
  return { pageIndex, pageSize, pageCount, total };
751
816
  },
@@ -271,3 +271,20 @@ describe('formatNumericWithConfig - cache reuse', () => {
271
271
  expect(typeof result).toBe('string')
272
272
  })
273
273
  })
274
+
275
+ describe('formatNumericWithConfig - formatter cache key', () => {
276
+ it('does not hand a signDisplay column the formatter another column cached without it', () => {
277
+ // Same digits, no sign: cached first, as a currency-like column would.
278
+ expect(
279
+ formatNumericWithConfig(3.27, { type: 'number', locales: 'en-US', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } }),
280
+ ).toBe('3.27')
281
+ // Same digits, with a sign: must not reuse the one above.
282
+ expect(
283
+ formatNumericWithConfig(3.27, {
284
+ type: 'number',
285
+ locales: 'en-US',
286
+ options: { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: 'always' },
287
+ }),
288
+ ).toBe('+3.27')
289
+ })
290
+ })
@@ -101,7 +101,24 @@ export function getNumberFormatter(
101
101
  '|' +
102
102
  (options.currencyDisplay ?? '') +
103
103
  '|' +
104
- (options.notation ?? '')
104
+ (options.notation ?? '') +
105
+ // Every option that changes the output belongs in the key. `signDisplay`
106
+ // was missing, so a percent column asking for "+3.27" got the formatter
107
+ // a currency column with the same digits had already cached, and no sign.
108
+ '|' +
109
+ (options.signDisplay ?? '') +
110
+ '|' +
111
+ (options.minimumIntegerDigits ?? '') +
112
+ '|' +
113
+ (options.compactDisplay ?? '') +
114
+ '|' +
115
+ (options.unit ?? '') +
116
+ '|' +
117
+ (options.unitDisplay ?? '') +
118
+ '|' +
119
+ (options.minimumSignificantDigits ?? '') +
120
+ '|' +
121
+ (options.maximumSignificantDigits ?? '')
105
122
  let fmt = numberFormatterCache.get(key)
106
123
  if (!fmt) {
107
124
  fmt = new Intl.NumberFormat(locales as string | string[] | undefined, options)
@@ -141,6 +141,26 @@ describe('createCellRender / computeCellValidity', () => {
141
141
  message: 'Salary must be at least $1,000',
142
142
  })
143
143
  })
144
+
145
+ it('judges the open editor draft, parsed as a commit would parse it', () => {
146
+ // With rejectInvalid a refused value never lands, so the red flag while
147
+ // the value is typed is the only feedback the user gets.
148
+ const col = makeColumn('salary', { validate: validateSalary, editorType: 'number' })
149
+ const typing = createCellRender(makeCtx({
150
+ editedCellValues: {},
151
+ editingCell: { rowId: 'r1', columnId: 'salary', editorType: 'number', value: '5' },
152
+ }))
153
+ expect(typing.computeCellValidity(makeRow('r1', { salary: 85000 }), col)).toEqual({
154
+ invalid: true,
155
+ message: 'Salary must be at least $1,000',
156
+ })
157
+ // Another cell's editor does not bleed into this one.
158
+ const elsewhere = createCellRender(makeCtx({
159
+ editedCellValues: {},
160
+ editingCell: { rowId: 'r2', columnId: 'salary', editorType: 'number', value: '5' },
161
+ }))
162
+ expect(elsewhere.computeCellValidity(makeRow('r1', { salary: 85000 }), col)).toEqual({ invalid: false, message: null })
163
+ })
144
164
  })
145
165
 
146
166
  describe('createCellRender / computeRowClass', () => {
@@ -489,9 +509,11 @@ describe('createCellRender / formatPinnedValue', () => {
489
509
  })
490
510
 
491
511
  describe('createCellRender / computePinnedCellClass', () => {
492
- it('returns empty string when cellClass is not a function', () => {
512
+ it('applies a static cellClass, a string or a list, the way a body cell does', () => {
493
513
  const cr = createCellRender(makeCtx())
494
- expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', { cellClass: 'x' }))).toBe('')
514
+ expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', { cellClass: 'x' }))).toBe('x')
515
+ expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', { cellClass: ['x', 'y'] }))).toBe('x y')
516
+ expect(cr.computePinnedCellClass({ a: 1 }, makeColumn('a', {}))).toBe('')
495
517
  })
496
518
 
497
519
  it('invokes a function cellClass with a null row and the pinned value', () => {
@@ -30,6 +30,7 @@ import {
30
30
  import {
31
31
  getPinnedCellValue,
32
32
  } from "./cell-values";
33
+ import { parseEditorValue } from "./editors/cell-editors";
33
34
 
34
35
  export function createCellRender<
35
36
  TFeatures extends TableFeatures = TableFeatures,
@@ -131,11 +132,22 @@ export function createCellRender<
131
132
  // cache, so `getCellValueByColumnId` would return the stale pre-edit value
132
133
  // and validation would never re-run after an edit. Consult the overlay
133
134
  // first - same rule the display path (getCellDisplayValue) and summaries
134
- // use - so a cell re-validates live as the user types.
135
+ // use. While this cell's editor is open the draft is what the rule sees,
136
+ // parsed the way a commit would parse it, so the red highlight and the
137
+ // message show as the user types - which, with `rejectInvalid`, is the
138
+ // only time a refused value is ever visible.
135
139
  const edited = ctx.editedCellValues ?? {}
136
140
  const key = getCellKey(row.id, column.id)
141
+ const editing = ctx.editingCell
137
142
  const value =
138
- key in edited ? edited[key] : row.getCellValueByColumnId(column.id)
143
+ editing && editing.rowId === row.id && editing.columnId === column.id
144
+ ? parseEditorValue(editing.editorType, editing.value, {
145
+ multiple: column.columnDef.editorMultiple === true,
146
+ dateOnly: column.columnDef.cellDataType === "dateString",
147
+ })
148
+ : key in edited
149
+ ? edited[key]
150
+ : row.getCellValueByColumnId(column.id)
139
151
  const out = rule({
140
152
  value,
141
153
  row: row.original as TData,
@@ -470,13 +482,17 @@ export function createCellRender<
470
482
  rowData: TData,
471
483
  column: Column<TData>,
472
484
  ): string {
473
- const cellClass = column.columnDef.cellClass as
474
- | ((ctx: { getValue: () => unknown; value: unknown; row: null; column: Column<TData> }) => string | undefined | null)
475
- | undefined;
476
- if (typeof cellClass !== "function") return "";
485
+ const raw = column.columnDef.cellClass;
486
+ if (raw == null) return "";
487
+ // A static class applies to the pinned cell the way it does to a body
488
+ // cell; a function gets the pinned value and `row: null`, since the
489
+ // pinned row is not a row of the table.
490
+ if (typeof raw === "string" || Array.isArray(raw)) return resolveClassList(raw);
491
+ if (typeof raw !== "function") return "";
492
+ const cellClass = raw as unknown as
493
+ (ctx: { getValue: () => unknown; value: unknown; row: null; column: Column<TData> }) => string | ReadonlyArray<string> | Record<string, boolean> | undefined | null;
477
494
  const value = getPinnedCellValue(rowData, column);
478
- const out = cellClass({ getValue: () => value, value, row: null, column });
479
- return out ? String(out) : "";
495
+ return resolveClassList(cellClass({ getValue: () => value, value, row: null, column }));
480
496
  }
481
497
 
482
498
  return {
@@ -441,6 +441,25 @@ describe('onFillPointerUp / applyFillPattern', () => {
441
441
  expect(ctx.selectionRange.focus).toEqual({ rowIndex: 3, colIndex: 0 })
442
442
  })
443
443
 
444
+ it('turns the fill the other way when the release holds Ctrl, as Excel does', () => {
445
+ // A series becomes a copy of the block...
446
+ const ctx = makeCtx({
447
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
448
+ data: [{ a: 1 }, { a: 2 }, { a: 0 }, { a: 0 }, { a: 0 }],
449
+ })
450
+ ctx.fillDrag = { sourceMinRow: 0, sourceMaxRow: 1, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 4, targetCol: 0 }
451
+ createClipboard(ctx).onFillPointerUp({ ctrlKey: true } as PointerEvent)
452
+ expect([ctx.internalData[2].a, ctx.internalData[3].a, ctx.internalData[4].a]).toEqual([1, 2, 1])
453
+ // ...and one number, which copies on its own, becomes a series.
454
+ const one = makeCtx({
455
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
456
+ data: [{ a: 7 }, { a: 0 }, { a: 0 }],
457
+ })
458
+ one.fillDrag = { sourceMinRow: 0, sourceMaxRow: 0, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 2, targetCol: 0 }
459
+ createClipboard(one).onFillPointerUp({ ctrlKey: true } as PointerEvent)
460
+ expect([one.internalData[1].a, one.internalData[2].a]).toEqual([8, 9])
461
+ })
462
+
444
463
  it('records the drag as one grouped history entry, so Ctrl+Z takes it all back', () => {
445
464
  // The fill used to write through writeCellRaw and push nothing: a drag
446
465
  // over twenty rows was invisible to undo.
@@ -487,6 +506,38 @@ describe('onFillPointerUp / applyFillPattern', () => {
487
506
  expect(numeric.internalData[3].a).toBe(4)
488
507
  })
489
508
 
509
+ it('skips a collapsed row the consumer does not count, and moves the series over the rows that show', () => {
510
+ // A filtered row in a spreadsheet: not written, not a step of the
511
+ // number series, and the formula's delta is the real row distance.
512
+ const ctx = makeCtx({
513
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
514
+ data: [{ a: 10 }, { a: 20 }, { a: 0 }, { a: 0 }, { a: 0 }],
515
+ })
516
+ ctx.isRowCollapsed = (r: number) => r === 3
517
+ const deltas: number[] = []
518
+ ctx.props.processCellForFill = ({ delta }: { delta: { rows: number } }) => { deltas.push(delta.rows); return undefined }
519
+ ctx.fillDrag = { sourceMinRow: 0, sourceMaxRow: 1, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 4, targetCol: 0 }
520
+ createClipboard(ctx).onFillPointerUp()
521
+ // Row 3 is left holding its 0; the series 30, (skip), 40 lands on 2 and 4.
522
+ expect(ctx.internalData.map((r: any) => r.a)).toEqual([10, 20, 30, 0, 40])
523
+ // The hook saw the real row distance from the source cell each target
524
+ // cycled from: row 2 is 2 below source row 0, row 4 is 3 below source
525
+ // row 1, not the 1 and 2 a step count would have given.
526
+ expect(deltas).toEqual([2, 3])
527
+ })
528
+
529
+ it('includeCollapsedRows brings a collapsed row back into the fill', () => {
530
+ const ctx = makeCtx({
531
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
532
+ data: [{ a: 10 }, { a: 0 }, { a: 0 }],
533
+ })
534
+ ctx.isRowCollapsed = (r: number) => r === 1
535
+ ctx.props.includeCollapsedRows = (r: number) => r === 1
536
+ ctx.fillDrag = { sourceMinRow: 0, sourceMaxRow: 0, sourceMinCol: 0, sourceMaxCol: 0, targetRow: 2, targetCol: 0 }
537
+ createClipboard(ctx).onFillPointerUp()
538
+ expect(ctx.internalData.map((r: any) => r.a)).toEqual([10, 10, 10])
539
+ })
540
+
490
541
  it('double-click fills down as far as the neighbouring column has data', () => {
491
542
  const ctx = makeCtx({
492
543
  columns: [
@@ -676,6 +727,45 @@ describe('copySelectionToClipboard', () => {
676
727
  expect(writeText).toHaveBeenCalledWith('a0\tb0\na1\tb1')
677
728
  })
678
729
 
730
+ // A QA pass on the spreadsheet shell: a filtered or hidden row is folded
731
+ // to nothing, and a copy that carried it put rows the user had filtered
732
+ // out into the paste.
733
+ it('leaves out a collapsed row, as a spreadsheet leaves out a filtered one', () => {
734
+ const ctx = makeCtx({
735
+ data: [
736
+ { a: 'a0', b: 'b0' },
737
+ { a: 'a1', b: 'b1' },
738
+ { a: 'a2', b: 'b2' },
739
+ ],
740
+ })
741
+ ctx.isRowCollapsed = (index: number) => index === 1
742
+ ctx.selectionRange = {
743
+ anchor: { rowIndex: 0, colIndex: 0 },
744
+ focus: { rowIndex: 2, colIndex: 1 },
745
+ }
746
+ const cb = createClipboard(ctx)
747
+ cb.copySelectionToClipboard()
748
+ expect(writeText).toHaveBeenCalledWith('a0\tb0\na2\tb2')
749
+ })
750
+
751
+ it('leaves out a collapsed column too, header row included', () => {
752
+ const ctx = makeCtx({
753
+ data: [
754
+ { a: 'a0', b: 'b0' },
755
+ { a: 'a1', b: 'b1' },
756
+ ],
757
+ })
758
+ ctx.collapsedColumns = { b: true }
759
+ ctx.props.copyHeadersToClipboard = true
760
+ ctx.selectionRange = {
761
+ anchor: { rowIndex: 0, colIndex: 0 },
762
+ focus: { rowIndex: 1, colIndex: 1 },
763
+ }
764
+ const cb = createClipboard(ctx)
765
+ cb.copySelectionToClipboard()
766
+ expect(writeText).toHaveBeenCalledWith('a\na0\na1')
767
+ })
768
+
679
769
  it('prepends a header row when copyHeadersToClipboard is set', () => {
680
770
  const ctx = makeCtx({
681
771
  data: [
@@ -849,6 +939,23 @@ describe('clearSelectedCells', () => {
849
939
  expect(ctx.grid.store.setState).toHaveBeenCalled()
850
940
  })
851
941
 
942
+ // The same QA pass: Delete over a filtered block wiped the rows the
943
+ // filter had hidden, which is data the user could not even see.
944
+ it('leaves a collapsed row alone, as a spreadsheet leaves a filtered one', () => {
945
+ const ctx = makeCtx({
946
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
947
+ data: [{ a: 'one' }, { a: 'two' }, { a: 'three' }],
948
+ })
949
+ ctx.isRowCollapsed = (index: number) => index === 1
950
+ ctx.selectionRange = {
951
+ anchor: { rowIndex: 0, colIndex: 0 },
952
+ focus: { rowIndex: 2, colIndex: 0 },
953
+ }
954
+ const cb = createClipboard(ctx)
955
+ expect(cb.clearSelectedCells()).toBe(true)
956
+ expect(ctx.internalData.map((r: { a: string }) => r.a)).toEqual(['', 'two', ''])
957
+ })
958
+
852
959
  it('fires onCellValueChange per cleared cell (so formula engines recompute)', () => {
853
960
  const onCellValueChange = vi.fn()
854
961
  const ctx = makeCtx({