@svgrid/grid 3.0.5 → 3.0.7

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 (166) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +23 -3
  4. package/dist/SvGrid.controller.svelte.js +235 -54
  5. package/dist/SvGrid.css +143 -7
  6. package/dist/SvGrid.helpers.js +2 -4
  7. package/dist/SvGrid.svelte +223 -38
  8. package/dist/SvGrid.types.d.ts +77 -4
  9. package/dist/SvGridDropdown.svelte +20 -0
  10. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  11. package/dist/SvModal.svelte +22 -0
  12. package/dist/SvModal.svelte.d.ts +7 -0
  13. package/dist/a11y/dismissable.d.ts +10 -0
  14. package/dist/a11y/dismissable.js +24 -2
  15. package/dist/build-api.js +81 -31
  16. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-BgUS-W3o.js} +151 -142
  17. package/dist/cdn/GridMenus-CcwU8I4t.js +644 -0
  18. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  19. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  21. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-9yz-SbdZ.js} +2 -2
  23. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-D7-ql1Hd.js} +2 -2
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DMUOFxOe.js} +4 -4
  25. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-N7ik0oWy.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-CB_Jnm2k.js} +4 -4
  27. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-Ca7gF8ZK.js} +4 -4
  28. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
  29. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-B1LFgoPP.js} +2 -2
  31. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-By3n7DGZ.js} +2 -2
  32. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  33. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  34. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  35. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  36. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-Mnv84zoE.js} +28 -6
  37. package/dist/cdn/{chart-Dz7SqMXH.js → chart-By7h2Guv.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-BC-tOgFY.js} +1 -1
  39. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DUaXr-sb.js} +1 -1
  40. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-BDtrNEiA.js} +1 -1
  41. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-C1aOAPBZ.js} +1 -1
  42. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  43. package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
  44. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-y1wdyCcv.js} +93 -97
  45. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  46. package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  47. package/dist/cdn/{src-jcFMkBnN.js → src-25JmJ_7g.js} +4768 -4149
  48. package/dist/cdn/{src-Bklofsad.js → src-kE9B98r6.js} +4796 -4177
  49. package/dist/cdn/svgrid.js +18 -18
  50. package/dist/cdn/svgrid.svelte-external.js +18 -18
  51. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  52. package/dist/cell-formatting.d.ts +9 -0
  53. package/dist/cell-formatting.js +52 -1
  54. package/dist/cell-render.js +25 -6
  55. package/dist/clipboard.d.ts +2 -2
  56. package/dist/clipboard.js +72 -23
  57. package/dist/columns.js +7 -2
  58. package/dist/conditional-formatting.d.ts +2 -0
  59. package/dist/conditional-formatting.js +2 -0
  60. package/dist/core.d.ts +22 -6
  61. package/dist/core.js +478 -40
  62. package/dist/editing.js +24 -0
  63. package/dist/export-format.d.ts +8 -0
  64. package/dist/export-format.js +23 -16
  65. package/dist/grid-messages.d.ts +4 -0
  66. package/dist/grid-messages.js +4 -0
  67. package/dist/headless.d.ts +5 -0
  68. package/dist/headless.js +12 -0
  69. package/dist/history.d.ts +21 -0
  70. package/dist/history.js +34 -4
  71. package/dist/index.d.ts +1 -1
  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 +6 -0
  77. package/dist/row-resize.js +22 -1
  78. package/dist/selection.js +1 -1
  79. package/dist/server-block-cache.d.ts +5 -0
  80. package/dist/server-block-cache.js +8 -0
  81. package/dist/server-data-source.d.ts +35 -3
  82. package/dist/server-data-source.js +11 -2
  83. package/dist/server.d.ts +1 -1
  84. package/dist/sparkline.js +7 -5
  85. package/dist/svgrid-wrapper.types.d.ts +33 -0
  86. package/dist/validate.js +9 -1
  87. package/dist/virtualization/column-virtualizer.d.ts +1 -0
  88. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
  89. package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
  90. package/dist/virtualization/virtualizer.d.ts +3 -0
  91. package/dist/virtualization/virtualizer.js +14 -1
  92. package/package.json +6 -1
  93. package/src/GridMenus.svelte +34 -4
  94. package/src/SvGrid.controller.svelte.ts +236 -57
  95. package/src/SvGrid.css +143 -7
  96. package/src/SvGrid.helpers.ts +2 -4
  97. package/src/SvGrid.svelte +223 -38
  98. package/src/SvGrid.types.ts +77 -4
  99. package/src/SvGridDropdown.svelte +20 -0
  100. package/src/SvModal.svelte +22 -0
  101. package/src/SvModal.test.ts +30 -1
  102. package/src/a11y/dismissable.test.ts +10 -1
  103. package/src/a11y/dismissable.ts +22 -2
  104. package/src/build-api.ts +85 -27
  105. package/src/cell-formatting.test.ts +17 -0
  106. package/src/cell-formatting.ts +49 -1
  107. package/src/cell-render.test.ts +24 -2
  108. package/src/cell-render.ts +24 -8
  109. package/src/clipboard.test.ts +107 -0
  110. package/src/clipboard.ts +62 -23
  111. package/src/columns.test.ts +4 -4
  112. package/src/columns.ts +7 -2
  113. package/src/conditional-formatting.test.ts +8 -0
  114. package/src/conditional-formatting.ts +3 -0
  115. package/src/core.sort.test.ts +86 -0
  116. package/src/core.tick-repair.test.ts +287 -0
  117. package/src/core.ts +478 -40
  118. package/src/editing.test.ts +52 -0
  119. package/src/editing.ts +25 -0
  120. package/src/export-dates.test.ts +114 -0
  121. package/src/export-format.ts +26 -14
  122. package/src/grid-messages.test.ts +7 -0
  123. package/src/grid-messages.ts +10 -0
  124. package/src/headless.ts +24 -0
  125. package/src/history.test.ts +49 -1
  126. package/src/history.ts +48 -2
  127. package/src/index.ts +2 -0
  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 +6 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.ts +1 -1
  137. package/src/server-block-cache.test.ts +27 -0
  138. package/src/server-block-cache.ts +13 -0
  139. package/src/server-data-source.infinite.test.ts +62 -0
  140. package/src/server-data-source.test.ts +19 -0
  141. package/src/server-data-source.ts +48 -4
  142. package/src/server.ts +2 -0
  143. package/src/sparkline.test.ts +7 -0
  144. package/src/sparkline.ts +6 -4
  145. package/src/svgrid-wrapper.types.ts +32 -0
  146. package/src/svgrid.api.test.ts +21 -0
  147. package/src/svgrid.charting.test.ts +12 -4
  148. package/src/svgrid.context-menu.test.ts +12 -0
  149. package/src/svgrid.data-push.svelte.test.ts +94 -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 +1 -1
  153. package/src/svgrid.live-update-paths.svelte.test.ts +186 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  156. package/src/svgrid.row-pinning.test.ts +15 -1
  157. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  158. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  159. package/src/svgrid.upsell-license.test.ts +1 -1
  160. package/src/test-setup.ts +12 -0
  161. package/src/transaction.test.ts +58 -0
  162. package/src/validate.ts +7 -1
  163. package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
  164. package/src/virtualization/virtualizer.test.ts +44 -0
  165. package/src/virtualization/virtualizer.ts +13 -1
  166. package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
@@ -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({
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
  }
@@ -12,6 +12,10 @@
12
12
  * snapshot the new output, `referenceSort` below is a literal transcription of
13
13
  * the ORIGINAL comparator, and every case asserts the two agree. If the rewrite
14
14
  * ever diverges on any input, including the randomised ones, this fails.
15
+ *
16
+ * The oracle calls the pairwise `sortFns`, so it tracks their semantics: since
17
+ * #104 those put blanks and unparseable values first instead of returning NaN,
18
+ * and `auto` compares numbers numerically.
15
19
  */
16
20
  import { describe, expect, it } from 'vitest'
17
21
  // From './core' rather than './index': `createSvGridCore` is the runes-free
@@ -233,6 +237,88 @@ describe('createSortedRowModel - equivalence with the original comparator', () =
233
237
  })
234
238
  })
235
239
 
240
+ describe('built-in comparators keep the Array.sort contract (#104)', () => {
241
+ it('number: non-numeric values sort together ahead of the numbers', () => {
242
+ expect([5, 'n/a', 3, 1, 'n/a', 4, 2].sort(sortFns.number)).toEqual(['n/a', 'n/a', 1, 2, 3, 4, 5])
243
+ expect(sortFns.number('n/a', 'x')).toBe(0)
244
+ expect(Number.isNaN(sortFns.number('n/a', 3))).toBe(false)
245
+ })
246
+
247
+ it('date: blanks and invalid dates sort first and never return NaN', () => {
248
+ // null, not undefined: Array.prototype.sort moves undefined to the end
249
+ // without calling the comparator.
250
+ const sorted = ['2021-01-01', null, 'junk', '2020-01-01'].sort(sortFns.date)
251
+ expect(sorted).toEqual([null, 'junk', '2020-01-01', '2021-01-01'])
252
+ expect(sortFns.date(undefined, 'junk')).toBe(0)
253
+ })
254
+
255
+ it('auto: numbers compare numerically, blanks come first', () => {
256
+ expect([2, 10, 1, 20, 100, 3].sort(sortFns.auto)).toEqual([1, 2, 3, 10, 20, 100])
257
+ expect(['oak', null, 'nuclear', '', 'apple'].sort(sortFns.auto)).toEqual([
258
+ null, '', 'apple', 'nuclear', 'oak',
259
+ ])
260
+ })
261
+
262
+ it('every built-in comparator is antisymmetric and transitive on mixed values', () => {
263
+ const pool: unknown[] = [
264
+ null, undefined, '', 0, -1, 2, 10, NaN, 1e21, '1a', '2', '10', 'n/a', 'apple', 'Apple',
265
+ true, '2020-01-01', 'junk', 1600000000000,
266
+ ]
267
+ for (const [name, cmp] of Object.entries(sortFns)) {
268
+ const sign = (a: unknown, b: unknown) => Math.sign(cmp(a, b))
269
+ for (const a of pool) {
270
+ for (const b of pool) {
271
+ const ab = sign(a, b)
272
+ expect(Number.isNaN(ab), `${name}(${String(a)}, ${String(b)}) is NaN`).toBe(false)
273
+ expect(ab, `${name} antisymmetry on ${String(a)}, ${String(b)}`).toBe(-sign(b, a) || 0)
274
+ for (const c of pool) {
275
+ if (ab <= 0 && sign(b, c) <= 0) {
276
+ expect(sign(a, c), `${name}: ${String(a)} <= ${String(b)} <= ${String(c)}`).toBeLessThanOrEqual(0)
277
+ }
278
+ }
279
+ }
280
+ }
281
+ }
282
+ })
283
+
284
+ it('sorts an untyped numeric column numerically in the row model', () => {
285
+ const rows: Row[] = [2, 10, null, 1, 20, 100, 3].map((qty) => ({ qty }))
286
+ const cols = [{ field: 'qty' }]
287
+ expect(actualSort(rows, cols, [{ id: 'qty', desc: false }]).map((r) => r.qty)).toEqual([
288
+ null, 1, 2, 3, 10, 20, 100,
289
+ ])
290
+ expect(actualSort(rows, cols, [{ id: 'qty', desc: true }]).map((r) => r.qty)).toEqual([
291
+ 100, 20, 10, 3, 2, 1, null,
292
+ ])
293
+ })
294
+
295
+ it('sorts a number column with placeholder text in order', () => {
296
+ const rows: Row[] = [5, 'n/a', 3, 1, 'n/a', 4, 2].map((v) => ({ v }))
297
+ const sorted = actualSort(rows, [{ field: 'v', editorType: 'number' }], [{ id: 'v', desc: false }])
298
+ expect(sorted.map((r) => r.v)).toEqual(['n/a', 'n/a', 1, 2, 3, 4, 5])
299
+ })
300
+
301
+ it('orders a mixed number and text column by kind, then value', () => {
302
+ const rows: Row[] = [10, 'b', 2, null, 'a', 1].map((v) => ({ v }))
303
+ const sorted = actualSort(rows, [{ field: 'v' }], [{ id: 'v', desc: false }])
304
+ expect(sorted.map((r) => r.v)).toEqual([null, 1, 2, 10, 'a', 'b'])
305
+ })
306
+
307
+ it('lets the next clause decide when two blanks tie on a numeric clause', () => {
308
+ // Two -Infinity keys subtract to NaN; the multi-clause loop must see 0.
309
+ const rows: Row[] = [
310
+ { num: null, text: 'b' },
311
+ { num: 'n/a', text: 'a' },
312
+ { num: 1, text: 'c' },
313
+ ]
314
+ const sorted = actualSort(rows, COLUMNS, [
315
+ { id: 'num', desc: false },
316
+ { id: 'text', desc: false },
317
+ ])
318
+ expect(sorted.map((r) => r.text)).toEqual(['a', 'b', 'c'])
319
+ })
320
+ })
321
+
236
322
  describe('createSortedRowModel - work budget', () => {
237
323
  /** Count `getAllColumns` calls the way tools/bench does, but in-process. */
238
324
  function countColumnLookups(rowCount: number, sorting: SortingState): number {