@svgrid/grid 3.0.5 → 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 (148) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +21 -3
  4. package/dist/SvGrid.controller.svelte.js +205 -50
  5. package/dist/SvGrid.css +127 -2
  6. package/dist/SvGrid.svelte +223 -38
  7. package/dist/SvGrid.types.d.ts +77 -4
  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 +81 -31
  15. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-C-NtjlrJ.js} +150 -141
  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-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  45. package/dist/cdn/{src-jcFMkBnN.js → src-CgoeSM8k.js} +4697 -4131
  46. package/dist/cdn/{src-Bklofsad.js → src-DfwxB1TH.js} +4727 -4161
  47. package/dist/cdn/svgrid.js +17 -17
  48. package/dist/cdn/svgrid.svelte-external.js +17 -17
  49. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  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 +365 -25
  59. package/dist/editing.js +24 -0
  60. package/dist/grid-messages.d.ts +4 -0
  61. package/dist/grid-messages.js +4 -0
  62. package/dist/headless.d.ts +5 -0
  63. package/dist/headless.js +12 -0
  64. package/dist/history.d.ts +21 -0
  65. package/dist/history.js +34 -4
  66. package/dist/index.d.ts +1 -1
  67. package/dist/keyboard-handlers.js +24 -1
  68. package/dist/keyboard.js +4 -0
  69. package/dist/row-drag-touch.d.ts +1 -1
  70. package/dist/row-drag.js +35 -1
  71. package/dist/row-model.d.ts +6 -0
  72. package/dist/row-resize.js +22 -1
  73. package/dist/selection.js +1 -1
  74. package/dist/server-block-cache.d.ts +5 -0
  75. package/dist/server-block-cache.js +8 -0
  76. package/dist/server-data-source.d.ts +35 -3
  77. package/dist/server-data-source.js +11 -2
  78. package/dist/server.d.ts +1 -1
  79. package/dist/sparkline.js +7 -5
  80. package/dist/svgrid-wrapper.types.d.ts +33 -0
  81. package/dist/validate.js +9 -1
  82. package/package.json +6 -1
  83. package/src/GridMenus.svelte +34 -4
  84. package/src/SvGrid.controller.svelte.ts +206 -53
  85. package/src/SvGrid.css +127 -2
  86. package/src/SvGrid.svelte +223 -38
  87. package/src/SvGrid.types.ts +77 -4
  88. package/src/SvGridDropdown.svelte +20 -0
  89. package/src/SvModal.svelte +22 -0
  90. package/src/SvModal.test.ts +30 -1
  91. package/src/a11y/dismissable.test.ts +10 -1
  92. package/src/a11y/dismissable.ts +22 -2
  93. package/src/build-api.ts +85 -27
  94. package/src/cell-formatting.test.ts +17 -0
  95. package/src/cell-formatting.ts +18 -1
  96. package/src/cell-render.test.ts +24 -2
  97. package/src/cell-render.ts +24 -8
  98. package/src/clipboard.test.ts +107 -0
  99. package/src/clipboard.ts +62 -23
  100. package/src/columns.test.ts +4 -4
  101. package/src/columns.ts +7 -2
  102. package/src/conditional-formatting.test.ts +8 -0
  103. package/src/conditional-formatting.ts +3 -0
  104. package/src/core.tick-repair.test.ts +287 -0
  105. package/src/core.ts +374 -26
  106. package/src/editing.test.ts +52 -0
  107. package/src/editing.ts +25 -0
  108. package/src/grid-messages.test.ts +7 -0
  109. package/src/grid-messages.ts +10 -0
  110. package/src/headless.ts +24 -0
  111. package/src/history.test.ts +49 -1
  112. package/src/history.ts +48 -2
  113. package/src/index.ts +2 -0
  114. package/src/keyboard-handlers.coverage.test.ts +65 -0
  115. package/src/keyboard-handlers.ts +26 -1
  116. package/src/keyboard.ts +3 -0
  117. package/src/row-drag-touch.ts +2 -2
  118. package/src/row-drag.test.ts +51 -0
  119. package/src/row-drag.ts +35 -2
  120. package/src/row-model.ts +6 -0
  121. package/src/row-resize.ts +24 -1
  122. package/src/selection.ts +1 -1
  123. package/src/server-block-cache.test.ts +27 -0
  124. package/src/server-block-cache.ts +13 -0
  125. package/src/server-data-source.infinite.test.ts +62 -0
  126. package/src/server-data-source.test.ts +19 -0
  127. package/src/server-data-source.ts +48 -4
  128. package/src/server.ts +2 -0
  129. package/src/sparkline.test.ts +7 -0
  130. package/src/sparkline.ts +6 -4
  131. package/src/svgrid-wrapper.types.ts +32 -0
  132. package/src/svgrid.api.test.ts +21 -0
  133. package/src/svgrid.charting.test.ts +12 -4
  134. package/src/svgrid.context-menu.test.ts +12 -0
  135. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  136. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  137. package/src/svgrid.gantt-seam.test.ts +1 -1
  138. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  139. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  140. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  141. package/src/svgrid.row-pinning.test.ts +15 -1
  142. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  143. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  144. package/src/svgrid.upsell-license.test.ts +1 -1
  145. package/src/test-setup.ts +12 -0
  146. package/src/transaction.test.ts +58 -0
  147. package/src/validate.ts +7 -1
  148. package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
package/src/clipboard.ts CHANGED
@@ -100,9 +100,9 @@ export function createClipboard<
100
100
  if (before !== value) steps.push({ rowId: row.id, columnId, field, before, after: value });
101
101
  }
102
102
 
103
- function applyFillPattern() {
103
+ function applyFillPattern(toggled = false) {
104
104
  const steps: HistoryStep[] = [];
105
- runHistoryGroup(ctx, () => applyFillCells(steps));
105
+ runHistoryGroup(ctx, () => applyFillCells(steps, toggled));
106
106
  if (steps.length) pushHistory(ctx, steps, nextGroupId());
107
107
  }
108
108
 
@@ -117,7 +117,7 @@ export function createClipboard<
117
117
  pattern: unknown[],
118
118
  i: number,
119
119
  sourceStart: number,
120
- targetStart: number,
120
+ at: number,
121
121
  step: 1 | -1,
122
122
  axis: "rows" | "cols",
123
123
  rowIndex: number,
@@ -126,7 +126,10 @@ export function createClipboard<
126
126
  const hook = ctx.props.processCellForFill;
127
127
  if (hook && sources.length) {
128
128
  const k = i % sources.length;
129
- const distance = (targetStart + step * i) - (sourceStart + step * k);
129
+ // The real distance to the cell being filled, which is not i when a
130
+ // row the fill skips lies between: a formula moves by the rows it
131
+ // actually travelled.
132
+ const distance = at - (sourceStart + step * k);
130
133
  const delta = axis === "rows" ? { rows: distance, cols: 0 } : { rows: 0, cols: distance };
131
134
  const decided = hook({ value: sources[k], delta, rowIndex, columnId });
132
135
  if (decided !== undefined) return decided;
@@ -169,7 +172,18 @@ export function createClipboard<
169
172
  applyFillPattern();
170
173
  }
171
174
 
172
- function applyFillCells(steps: HistoryStep[]) {
175
+ /** The fill's values the other way round from the pattern's default. */
176
+ function toggledPattern(sources: unknown[], count: number): unknown[] {
177
+ if (sources.length === 1 && typeof sources[0] === 'number' && Number.isFinite(sources[0])) {
178
+ return Array.from({ length: count }, (_, i) => (sources[0] as number) + i + 1);
179
+ }
180
+ return Array.from({ length: count }, (_, i) => sources[i % sources.length]);
181
+ }
182
+
183
+ function applyFillCells(steps: HistoryStep[], toggled = false) {
184
+ const pattern = (sources: unknown[], count: number) => (toggled ? toggledPattern(sources, count) : buildFillPattern(sources, count));
185
+ const includeCollapsed = ctx.props.includeCollapsedRows as ((rowIndex: number) => boolean) | undefined;
186
+ const skipsRow = (r: number) => ctx.isRowCollapsed?.(r) === true && !(includeCollapsed?.(r) ?? false);
173
187
  const d = ctx.fillDrag;
174
188
  if (!d) return;
175
189
  // Clear fillDrag FIRST so a thrown error doesn't leave the grid
@@ -197,27 +211,30 @@ export function createClipboard<
197
211
  for (let r = d.sourceMinRow; r <= d.sourceMaxRow; r += 1) {
198
212
  sourceColValues.push(readCellRaw(r, column.id));
199
213
  }
214
+ // A collapsed row the consumer does not count (a filtered row in a
215
+ // spreadsheet) is neither written nor a step of the series: the
216
+ // pattern runs over the rows that are there, as Excel's does.
200
217
  if (newMaxRow > d.sourceMaxRow) {
201
- const targetRows = newMaxRow - d.sourceMaxRow;
202
- const fills = buildFillPattern(sourceColValues, targetRows);
203
- for (let i = 0; i < targetRows; i += 1) {
204
- const targetRow = d.sourceMaxRow + 1 + i;
218
+ const targets: number[] = [];
219
+ for (let r = d.sourceMaxRow + 1; r <= newMaxRow; r += 1) if (!skipsRow(r)) targets.push(r);
220
+ const fills = pattern(sourceColValues, targets.length);
221
+ targets.forEach((targetRow, i) => {
205
222
  if (ctx.isCellEditableAt(targetRow, c))
206
223
  writeFilled(steps, targetRow, column.id,
207
- filledValue(sourceColValues, fills, i, d.sourceMinRow, d.sourceMaxRow + 1, 1, "rows", targetRow, column.id));
208
- }
224
+ filledValue(sourceColValues, fills, i, d.sourceMinRow, targetRow, 1, "rows", targetRow, column.id));
225
+ });
209
226
  }
210
227
  if (newMinRow < d.sourceMinRow) {
211
228
  // Filling upward - reverse-extrapolate.
212
229
  const reversed = sourceColValues.slice().reverse();
213
- const targetRows = d.sourceMinRow - newMinRow;
214
- const fills = buildFillPattern(reversed, targetRows);
215
- for (let i = 0; i < targetRows; i += 1) {
216
- const targetRow = d.sourceMinRow - 1 - i;
230
+ const targets: number[] = [];
231
+ for (let r = d.sourceMinRow - 1; r >= newMinRow; r -= 1) if (!skipsRow(r)) targets.push(r);
232
+ const fills = pattern(reversed, targets.length);
233
+ targets.forEach((targetRow, i) => {
217
234
  if (ctx.isCellEditableAt(targetRow, c))
218
235
  writeFilled(steps, targetRow, column.id,
219
- filledValue(reversed, fills, i, d.sourceMaxRow, d.sourceMinRow - 1, -1, "rows", targetRow, column.id));
220
- }
236
+ filledValue(reversed, fills, i, d.sourceMaxRow, targetRow, -1, "rows", targetRow, column.id));
237
+ });
221
238
  }
222
239
  }
223
240
  } else if (horizontalExtension) {
@@ -232,25 +249,25 @@ export function createClipboard<
232
249
  }
233
250
  if (newMaxCol > d.sourceMaxCol) {
234
251
  const targetCols = newMaxCol - d.sourceMaxCol;
235
- const fills = buildFillPattern(sourceRowValues, targetCols);
252
+ const fills = pattern(sourceRowValues, targetCols);
236
253
  for (let i = 0; i < targetCols; i += 1) {
237
254
  const targetCol = d.sourceMaxCol + 1 + i;
238
255
  const col = ctx.allColumns[targetCol];
239
256
  if (col && ctx.isCellEditableAt(r, targetCol))
240
257
  writeFilled(steps, r, col.id,
241
- filledValue(sourceRowValues, fills, i, d.sourceMinCol, d.sourceMaxCol + 1, 1, "cols", r, col.id));
258
+ filledValue(sourceRowValues, fills, i, d.sourceMinCol, targetCol, 1, "cols", r, col.id));
242
259
  }
243
260
  }
244
261
  if (newMinCol < d.sourceMinCol) {
245
262
  const reversed = sourceRowValues.slice().reverse();
246
263
  const targetCols = d.sourceMinCol - newMinCol;
247
- const fills = buildFillPattern(reversed, targetCols);
264
+ const fills = pattern(reversed, targetCols);
248
265
  for (let i = 0; i < targetCols; i += 1) {
249
266
  const targetCol = d.sourceMinCol - 1 - i;
250
267
  const col = ctx.allColumns[targetCol];
251
268
  if (col && ctx.isCellEditableAt(r, targetCol))
252
269
  writeFilled(steps, r, col.id,
253
- filledValue(reversed, fills, i, d.sourceMaxCol, d.sourceMinCol - 1, -1, "cols", r, col.id));
270
+ filledValue(reversed, fills, i, d.sourceMaxCol, targetCol, -1, "cols", r, col.id));
254
271
  }
255
272
  }
256
273
  }
@@ -277,8 +294,10 @@ export function createClipboard<
277
294
  const minCol = Math.min(anchor.colIndex, focus.colIndex);
278
295
  const maxCol = Math.max(anchor.colIndex, focus.colIndex);
279
296
  for (let r = minRow; r <= maxRow; r += 1) {
297
+ if (ctx.isRowCollapsed?.(r)) continue;
280
298
  for (let c = minCol; c <= maxCol; c += 1) {
281
299
  const col = ctx.allColumns[c];
300
+ if (col && ctx.collapsedColumns?.[col.id]) continue;
282
301
  if (col?.columnDef.field && ctx.isCellEditableAt(r, c)) {
283
302
  writeCellRaw(r, col.id, null);
284
303
  }
@@ -448,9 +467,15 @@ export function createClipboard<
448
467
  ctx.fillDrag = { ...ctx.fillDrag, targetRow: hit.row, targetCol: hit.col };
449
468
  }
450
469
 
451
- function onFillPointerUp() {
470
+ /**
471
+ * The release. Excel's Ctrl turns the fill the other way: a series
472
+ * becomes a copy of the block, and one number, which copies on its own,
473
+ * becomes a series stepping by one. The modifier is read here, at the
474
+ * release, so holding Ctrl only at the end still counts, as in Excel.
475
+ */
476
+ function onFillPointerUp(event?: PointerEvent) {
452
477
  if (!ctx.fillDrag) return;
453
- applyFillPattern();
478
+ applyFillPattern(!!(event && (event.ctrlKey || event.metaKey)));
454
479
  }
455
480
 
456
481
  // ---- Range move / copy (drag the selection border) --------------------
@@ -837,6 +862,7 @@ export function createClipboard<
837
862
  const processCell = ctx.props.processCellForClipboard as
838
863
  | ((params: { value: unknown; column: unknown; row: unknown; rowIndex: number; columnId: string }) => unknown)
839
864
  | undefined;
865
+ const copyCollapsed = ctx.props.includeCollapsedRows as ((rowIndex: number) => boolean) | undefined;
840
866
  const blocks: Array<string> = [];
841
867
  for (const rect of rects) {
842
868
  const lines: Array<string> = [];
@@ -844,6 +870,7 @@ export function createClipboard<
844
870
  const header: Array<string> = [];
845
871
  for (let c = rect.minCol; c <= rect.maxCol; c += 1) {
846
872
  const column = ctx.allColumns[c];
873
+ if (column && ctx.collapsedColumns?.[column.id]) continue;
847
874
  header.push(column ? toolPanelHeaderLabel(column) : "");
848
875
  }
849
876
  lines.push(header.join("\t"));
@@ -851,6 +878,13 @@ export function createClipboard<
851
878
  for (let r = rect.minRow; r <= rect.maxRow; r += 1) {
852
879
  const row = ctx.allRows[r];
853
880
  if (!row || isGroupRow(row)) continue;
881
+ // A collapsed row is folded to nothing: the user cannot see it and
882
+ // the keyboard walks past it, so a copy leaves it out too. That is
883
+ // what a spreadsheet does with a filtered row, and it is what makes
884
+ // "filter, copy, paste" carry the rows that matched. A consumer that
885
+ // tells hidden from filtered answers per row (`includeCollapsedRows`):
886
+ // Excel copies a row hidden by hand.
887
+ if (ctx.isRowCollapsed?.(r) && !copyCollapsed?.(r)) continue;
854
888
  const cells: Array<string> = [];
855
889
  for (let c = rect.minCol; c <= rect.maxCol; c += 1) {
856
890
  const column = ctx.allColumns[c];
@@ -858,6 +892,7 @@ export function createClipboard<
858
892
  cells.push("");
859
893
  continue;
860
894
  }
895
+ if (ctx.collapsedColumns?.[column.id]) continue;
861
896
  const base = getColumnBaseValue(row, column);
862
897
  let value: unknown = ctx.getCellDisplayValue(row.id, column.id, base);
863
898
  if (processCell) {
@@ -916,6 +951,9 @@ export function createClipboard<
916
951
  for (let r = startRow; r <= endRow; r += 1) {
917
952
  const row = ctx.allRows[r];
918
953
  if (!row || isGroupRow(row)) continue;
954
+ // A collapsed row is not there as far as the user is concerned, and a
955
+ // Delete over a filtered block must not wipe what the filter hid.
956
+ if (ctx.isRowCollapsed?.(r)) continue;
919
957
  // By identity, not `Number(row.id)` - that is the array index only under
920
958
  // the default getRowId, so a custom one cleared the wrong row.
921
959
  const dataIndex = dataIndexOf(row);
@@ -929,6 +967,7 @@ export function createClipboard<
929
967
  for (let c = startCol; c <= endCol; c += 1) {
930
968
  const column = ctx.allColumns[c];
931
969
  if (!column?.columnDef.field) continue;
970
+ if (ctx.collapsedColumns?.[column.id]) continue;
932
971
  if (!ctx.isCellEditableAt(r, c)) continue;
933
972
  // Clear means: empty string for text, undefined for everything
934
973
  // else. parseEditorValue handles the per-type coercion.
@@ -57,14 +57,14 @@ describe('cellPinStyle', () => {
57
57
  it('returns a left sticky style when the column has a left offset', () => {
58
58
  const ctx = makeCtx({ pinnedOffsets: { left: { a: 0, b: 120 }, right: {} } })
59
59
  const h = createColumns(ctx)
60
- expect(h.cellPinStyle('a')).toBe('position: sticky; left: 0px; z-index: 30;')
61
- expect(h.cellPinStyle('b')).toBe('position: sticky; left: 120px; z-index: 30;')
60
+ expect(h.cellPinStyle('a')).toBe('position: sticky; inset-inline-start: 0px; z-index: 30;')
61
+ expect(h.cellPinStyle('b')).toBe('position: sticky; inset-inline-start: 120px; z-index: 30;')
62
62
  })
63
63
 
64
64
  it('returns a right sticky style when the column has a right offset', () => {
65
65
  const ctx = makeCtx({ pinnedOffsets: { left: {}, right: { d: 0 } } })
66
66
  const h = createColumns(ctx)
67
- expect(h.cellPinStyle('d')).toBe('position: sticky; right: 0px; z-index: 30;')
67
+ expect(h.cellPinStyle('d')).toBe('position: sticky; inset-inline-end: 0px; z-index: 30;')
68
68
  })
69
69
 
70
70
  it('returns empty string for an unpinned column', () => {
@@ -75,7 +75,7 @@ describe('cellPinStyle', () => {
75
75
  it('prefers the left offset when a column appears in both maps', () => {
76
76
  const ctx = makeCtx({ pinnedOffsets: { left: { a: 5 }, right: { a: 9 } } })
77
77
  const h = createColumns(ctx)
78
- expect(h.cellPinStyle('a')).toBe('position: sticky; left: 5px; z-index: 30;')
78
+ expect(h.cellPinStyle('a')).toBe('position: sticky; inset-inline-start: 5px; z-index: 30;')
79
79
  })
80
80
  })
81
81
 
package/src/columns.ts CHANGED
@@ -16,13 +16,18 @@ export function createColumns<
16
16
  // context (z=20) so a selected cell in the scrollable middle never
17
17
  // bleeds over the pinned columns during horizontal scroll. Still
18
18
  // sits below dropdowns (z=900+) and the column menu.
19
+ // Logical insets, not `left` / `right`: the offsets are measured from the
20
+ // pinned side in READING order, so under `dir="rtl"` the left-pinned
21
+ // columns have to stick to the right edge. `inset-inline-start` is that
22
+ // edge in either direction; with `left` a right-to-left grid scrolls its
23
+ // pinned columns off the screen.
19
24
  const leftOffset = ctx.pinnedOffsets.left[columnId];
20
25
  if (leftOffset !== undefined) {
21
- return `position: sticky; left: ${leftOffset}px; z-index: 30;`;
26
+ return `position: sticky; inset-inline-start: ${leftOffset}px; z-index: 30;`;
22
27
  }
23
28
  const rightOffset = ctx.pinnedOffsets.right[columnId];
24
29
  if (rightOffset !== undefined) {
25
- return `position: sticky; right: ${rightOffset}px; z-index: 30;`;
30
+ return `position: sticky; inset-inline-end: ${rightOffset}px; z-index: 30;`;
26
31
  }
27
32
  return "";
28
33
  }
@@ -83,6 +83,14 @@ describe('resolveCellFormat', () => {
83
83
  expect(resolveCellFormat(1, {}, 'c', fmts, null).background).toBeUndefined()
84
84
  })
85
85
 
86
+ it('rule carries its icon while it matches', () => {
87
+ const fmts: ConditionalFormat[] = [
88
+ { type: 'rule', when: ({ value }) => Number(value) > 100, background: '#dbeafe', icon: '!' },
89
+ ]
90
+ expect(resolveCellFormat(150, {}, 'c', fmts, null).icon).toBe('!')
91
+ expect(resolveCellFormat(50, {}, 'c', fmts, null).icon).toBeUndefined()
92
+ })
93
+
86
94
  it('respects the columns scope', () => {
87
95
  const fmts: ConditionalFormat[] = [
88
96
  { type: 'colorScale', min: '#000000', max: '#ffffff', columns: ['other'] },
@@ -102,6 +102,8 @@ export type RuleFormat<TData = unknown> = {
102
102
  background?: string
103
103
  color?: string
104
104
  fontWeight?: string | number
105
+ /** A short glyph drawn before the value while the rule matches. */
106
+ icon?: string
105
107
  }
106
108
 
107
109
  export type ConditionalFormatSpec<TData = unknown> =
@@ -323,6 +325,7 @@ export function resolveCellFormat<TData = unknown>(
323
325
  }
324
326
  if (fmt.color) out.color = fmt.color
325
327
  if (fmt.fontWeight != null) out.fontWeight = fmt.fontWeight
328
+ if (fmt.icon) out.icon = fmt.icon
326
329
  }
327
330
  continue
328
331
  }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * A tick - a new data array where a few row objects were replaced - repairs
3
+ * the filtered and sorted stages' previous output instead of recomputing it,
4
+ * and the repaired output is exactly what the full pipeline would produce.
5
+ *
6
+ * The full pipeline is the oracle: for every case below a second grid is
7
+ * built fresh from the ticked data and its rows compared, object by object
8
+ * (same `original`, same order), against the ticked grid's rows. Sort keys
9
+ * of every kind the sorted stage specialises (number, date, ranked text,
10
+ * unranked text, a custom comparator), ties, descending, several clauses,
11
+ * an active filter, and the cases that must NOT repair: an add, a remove, a
12
+ * replacement whose filter membership flips, and a text value the ranking
13
+ * has not seen.
14
+ */
15
+ import { describe, expect, it } from 'vitest'
16
+ import {
17
+ createCoreRowModel,
18
+ createFilteredRowModel,
19
+ createSortedRowModel,
20
+ createSvGridCore,
21
+ rowSortingFeature,
22
+ columnFilteringFeature,
23
+ tableFeatures,
24
+ sortFns,
25
+ type ColumnDef,
26
+ type SortingState,
27
+ type ColumnFiltersState,
28
+ } from './core'
29
+
30
+ type Quote = { id: number; symbol: string; sector: string; price: number; asOf: string; note: string }
31
+
32
+ const SECTORS = ['Tech', 'Energy', 'Health', 'Finance']
33
+ function makeRows(n: number, seed = 1): Quote[] {
34
+ let s = seed
35
+ const rand = () => (s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff
36
+ return Array.from({ length: n }, (_, i) => ({
37
+ id: i,
38
+ symbol: `S${String(i).padStart(4, '0')}`,
39
+ sector: SECTORS[Math.floor(rand() * SECTORS.length)]!,
40
+ // Many ties on purpose: the stable tie-break is the thing to get right.
41
+ price: Math.round(rand() * 50),
42
+ asOf: new Date(Date.UTC(2026, 0, 1 + Math.floor(rand() * 200))).toISOString(),
43
+ note: `n${Math.floor(rand() * 1e6)}`,
44
+ }))
45
+ }
46
+
47
+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
48
+ const COLUMNS = [
49
+ { field: 'id', editorType: 'number' },
50
+ { field: 'symbol' },
51
+ { field: 'sector' },
52
+ { field: 'price', editorType: 'number' },
53
+ { field: 'asOf', editorType: 'date' },
54
+ { field: 'note' },
55
+ ] as unknown as Array<ColumnDef<typeof features, Quote>>
56
+
57
+ function makeGrid(
58
+ data: Quote[],
59
+ state: { sorting?: SortingState; columnFilters?: ColumnFiltersState },
60
+ fns: typeof sortFns = sortFns,
61
+ ) {
62
+ let sortRuns = 0
63
+ const sorted = createSortedRowModel<Quote>(fns)
64
+ // A live getter, as <SvGrid> passes it: getRowModel reads options.data on
65
+ // every call, so replacing the array here is what a tick looks like.
66
+ const ref = { data }
67
+ const grid = createSvGridCore<typeof features, Quote>({
68
+ _features: features,
69
+ _rowModels: {
70
+ coreRowModel: createCoreRowModel(),
71
+ filteredRowModel: createFilteredRowModel(),
72
+ sortedRowModel: (args) => {
73
+ sortRuns++
74
+ return sorted(args)
75
+ },
76
+ },
77
+ columns: COLUMNS,
78
+ get data() {
79
+ return ref.data
80
+ },
81
+ state: { sorting: [], columnFilters: [], ...state },
82
+ })
83
+ return { grid, sortRuns: () => sortRuns, set: (next: Quote[]) => (ref.data = next) }
84
+ }
85
+
86
+ /** The ticked data: the same array with `count` rows replaced by new objects. */
87
+ function tick(data: Quote[], count: number, mutate: (q: Quote, i: number) => Partial<Quote>, seed = 7): Quote[] {
88
+ let s = seed
89
+ const rand = () => (s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff
90
+ const next = data.slice()
91
+ const picked = new Set<number>()
92
+ while (picked.size < count) picked.add(Math.floor(rand() * data.length))
93
+ for (const i of picked) next[i] = { ...data[i]!, ...mutate(data[i]!, i) }
94
+ return next
95
+ }
96
+
97
+ function originals(grid: ReturnType<typeof makeGrid>['grid']) {
98
+ return grid.getRowModel().rows.map((r) => r.original)
99
+ }
100
+
101
+ function expectRepairedEqualsFull(
102
+ before: Quote[],
103
+ after: Quote[],
104
+ state: { sorting?: SortingState; columnFilters?: ColumnFiltersState },
105
+ fns?: typeof sortFns,
106
+ ) {
107
+ const ticked = makeGrid(before, state, fns)
108
+ ticked.grid.getRowModel()
109
+ ticked.set(after)
110
+ const repaired = originals(ticked.grid)
111
+
112
+ const oracle = makeGrid(after, state, fns)
113
+ const full = originals(oracle.grid)
114
+
115
+ expect(repaired.length).toBe(full.length)
116
+ for (let i = 0; i < full.length; i++) {
117
+ if (repaired[i] !== full[i]) {
118
+ throw new Error(`row ${i} differs: repaired id=${repaired[i]?.id} price=${repaired[i]?.price}, full id=${full[i]?.id} price=${full[i]?.price}`)
119
+ }
120
+ }
121
+ return ticked
122
+ }
123
+
124
+ describe('tick repair: sorted stage equals a full sort', () => {
125
+ const price = (q: Quote) => ({ price: (q.price * 7 + 13) % 50 })
126
+
127
+ it('numeric ascending, with ties', () => {
128
+ const before = makeRows(2000)
129
+ expectRepairedEqualsFull(before, tick(before, 150, price), { sorting: [{ id: 'price', desc: false }] })
130
+ })
131
+
132
+ it('numeric descending', () => {
133
+ const before = makeRows(2000)
134
+ expectRepairedEqualsFull(before, tick(before, 150, price), { sorting: [{ id: 'price', desc: true }] })
135
+ })
136
+
137
+ it('date column', () => {
138
+ const before = makeRows(1500)
139
+ expectRepairedEqualsFull(
140
+ before,
141
+ tick(before, 90, (q) => ({ asOf: new Date(Date.parse(q.asOf) + 86_400_000 * 37).toISOString() })),
142
+ { sorting: [{ id: 'asOf', desc: false }] },
143
+ )
144
+ })
145
+
146
+ it('ranked text (few distinct values) when the new value is a known one', () => {
147
+ const before = makeRows(2000)
148
+ expectRepairedEqualsFull(
149
+ before,
150
+ tick(before, 120, (q) => ({ sector: SECTORS[(SECTORS.indexOf(q.sector) + 1) % SECTORS.length]! })),
151
+ { sorting: [{ id: 'sector', desc: false }, { id: 'price', desc: true }] },
152
+ )
153
+ })
154
+
155
+ it('unranked text (mostly distinct values)', () => {
156
+ const before = makeRows(1200)
157
+ expectRepairedEqualsFull(before, tick(before, 60, (q, i) => ({ note: `z${i}-${q.note}` })), {
158
+ sorting: [{ id: 'note', desc: false }],
159
+ })
160
+ })
161
+
162
+ it('a custom comparator', () => {
163
+ const before = makeRows(800)
164
+ const custom = { ...sortFns, number: (a: unknown, b: unknown) => (Number(b) % 10) - (Number(a) % 10) }
165
+ expectRepairedEqualsFull(before, tick(before, 40, price), { sorting: [{ id: 'price', desc: false }] }, custom)
166
+ })
167
+
168
+ it('three clauses', () => {
169
+ const before = makeRows(3000)
170
+ expectRepairedEqualsFull(before, tick(before, 200, price), {
171
+ sorting: [{ id: 'sector', desc: true }, { id: 'price', desc: false }, { id: 'id', desc: true }],
172
+ })
173
+ })
174
+
175
+ it('a replacement that leaves the key unchanged keeps its slot', () => {
176
+ const before = makeRows(500)
177
+ expectRepairedEqualsFull(before, tick(before, 30, (q) => ({ note: q.note + '!' })), {
178
+ sorting: [{ id: 'price', desc: false }],
179
+ })
180
+ })
181
+
182
+ it('back-to-back ticks repair on top of a repair', () => {
183
+ const before = makeRows(2000)
184
+ const t1 = tick(before, 100, price, 3)
185
+ const t2 = tick(t1, 100, price, 5)
186
+ const t3 = tick(t2, 100, price, 9)
187
+ const g = makeGrid(before, { sorting: [{ id: 'price', desc: true }] })
188
+ g.grid.getRowModel()
189
+ for (const d of [t1, t2, t3]) g.set(d)
190
+ const full = originals(makeGrid(t3, { sorting: [{ id: 'price', desc: true }] }).grid)
191
+ expect(originals(g.grid)).toEqual(full)
192
+ })
193
+ })
194
+
195
+ describe('tick repair: filtered stage', () => {
196
+ const filters: ColumnFiltersState = [{ id: 'sector', value: 'Tech' }]
197
+
198
+ it('keeps a replaced row in its slot when it still passes', () => {
199
+ const before = makeRows(2000)
200
+ const g = expectRepairedEqualsFull(
201
+ before,
202
+ tick(before, 100, (q) => ({ price: q.price + 1 })),
203
+ { columnFilters: filters, sorting: [{ id: 'price', desc: false }] },
204
+ )
205
+ expect(g.sortRuns()).toBe(2)
206
+ })
207
+
208
+ it('falls back to the full pass when a replacement changes membership', () => {
209
+ const before = makeRows(2000)
210
+ expectRepairedEqualsFull(
211
+ before,
212
+ tick(before, 50, (q) => ({ sector: q.sector === 'Tech' ? 'Energy' : 'Tech' })),
213
+ { columnFilters: filters, sorting: [{ id: 'price', desc: false }] },
214
+ )
215
+ })
216
+ })
217
+
218
+ describe('tick repair: when it must not happen', () => {
219
+ it('an add goes through the full pipeline', () => {
220
+ const before = makeRows(1000)
221
+ const after = [...before, { ...before[0]!, id: 1000, symbol: 'S1000', price: 25 }]
222
+ expectRepairedEqualsFull(before, after, { sorting: [{ id: 'price', desc: false }] })
223
+ })
224
+
225
+ it('a remove goes through the full pipeline', () => {
226
+ const before = makeRows(1000)
227
+ const after = before.filter((_, i) => i !== 17)
228
+ expectRepairedEqualsFull(before, after, { sorting: [{ id: 'price', desc: false }] })
229
+ })
230
+
231
+ it('a ranked text column with a value the ranking has not seen', () => {
232
+ const before = makeRows(2000)
233
+ expectRepairedEqualsFull(before, tick(before, 10, () => ({ sector: 'Utilities' })), {
234
+ sorting: [{ id: 'sector', desc: false }],
235
+ })
236
+ })
237
+
238
+ it('more than a quarter of the rows replaced', () => {
239
+ const before = makeRows(1000)
240
+ expectRepairedEqualsFull(before, tick(before, 400, (q) => ({ price: q.price + 3 })), {
241
+ sorting: [{ id: 'price', desc: false }],
242
+ })
243
+ })
244
+
245
+ it('a sorting change between ticks', () => {
246
+ const before = makeRows(1000)
247
+ const g = makeGrid(before, { sorting: [{ id: 'price', desc: false }] })
248
+ g.grid.getRowModel()
249
+ const after = tick(before, 50, (q) => ({ price: q.price + 5 }))
250
+ g.grid.store.setState((prev) => ({ ...prev, sorting: [{ id: 'price', desc: true }] }))
251
+ g.set(after)
252
+ expect(originals(g.grid)).toEqual(originals(makeGrid(after, { sorting: [{ id: 'price', desc: true }] }).grid))
253
+ })
254
+
255
+ it('a new array with every object the same re-reads the rows (the in-place refresh idiom)', () => {
256
+ // `rows = [...rows]` after mutating objects in place is the documented
257
+ // refresh (docs/help/cells/view-refresh.md). Nothing was replaced, so
258
+ // there is no repair; the stages recompute from the current values and
259
+ // the mutation is picked up.
260
+ const before = makeRows(200)
261
+ const g = makeGrid(before, { sorting: [{ id: 'price', desc: false }] })
262
+ g.grid.getRowModel()
263
+ before[0]!.price = 999
264
+ g.set(before.slice())
265
+ const rows = originals(g.grid)
266
+ expect(rows[rows.length - 1]).toBe(before[0])
267
+ expect(g.sortRuns()).toBe(2)
268
+ })
269
+
270
+ it('a kept row is assumed unchanged in value: the immutable-update contract', () => {
271
+ // Replace one object and mutate another in place in the same tick. The
272
+ // replaced one lands in sorted position; the mutated one keeps the slot
273
+ // its old key earned, because the repair only reads the rows it was told
274
+ // changed. A feed that mutates in place must not mix in replacements, or
275
+ // must pass a new array with no replacements to refresh.
276
+ const before = makeRows(200)
277
+ const g = makeGrid(before, { sorting: [{ id: 'price', desc: false }] })
278
+ g.grid.getRowModel()
279
+ const next = before.slice()
280
+ next[5] = { ...before[5]!, price: 999 }
281
+ before[7]!.price = -1
282
+ g.set(next)
283
+ const rows = originals(g.grid)
284
+ expect(rows[rows.length - 1]).toBe(next[5])
285
+ expect(rows[0]).not.toBe(before[7])
286
+ })
287
+ })