@svgrid/grid 1.1.0 → 1.1.1

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 (117) hide show
  1. package/dist/GridMenus.svelte +205 -127
  2. package/dist/SvGrid.controller.svelte.d.ts +44 -1
  3. package/dist/SvGrid.controller.svelte.js +223 -38
  4. package/dist/SvGrid.css +2012 -1709
  5. package/dist/SvGrid.helpers.test.d.ts +1 -0
  6. package/dist/SvGrid.helpers.test.js +298 -0
  7. package/dist/SvGrid.svelte +2346 -2043
  8. package/dist/SvGrid.types.d.ts +91 -4
  9. package/dist/aligned-grids.d.ts +6 -0
  10. package/dist/aligned-grids.js +84 -0
  11. package/dist/aligned-grids.test.d.ts +1 -0
  12. package/dist/aligned-grids.test.js +75 -0
  13. package/dist/build-api.coverage.test.d.ts +20 -0
  14. package/dist/build-api.coverage.test.js +505 -0
  15. package/dist/build-api.js +59 -29
  16. package/dist/cell-render.test.d.ts +1 -0
  17. package/dist/cell-render.test.js +338 -0
  18. package/dist/chart-export.test.d.ts +1 -0
  19. package/dist/chart-export.test.js +302 -0
  20. package/dist/chart.coverage.test.d.ts +1 -0
  21. package/dist/chart.coverage.test.js +748 -0
  22. package/dist/clipboard.js +88 -24
  23. package/dist/clipboard.test.d.ts +1 -0
  24. package/dist/clipboard.test.js +700 -0
  25. package/dist/collaboration.coverage.test.d.ts +1 -0
  26. package/dist/collaboration.coverage.test.js +200 -0
  27. package/dist/column-groups.d.ts +19 -0
  28. package/dist/column-groups.js +62 -0
  29. package/dist/column-groups.test.d.ts +1 -0
  30. package/dist/column-groups.test.js +56 -0
  31. package/dist/column-types.d.ts +10 -0
  32. package/dist/column-types.js +63 -0
  33. package/dist/column-types.test.d.ts +1 -0
  34. package/dist/column-types.test.js +62 -0
  35. package/dist/columns.test.d.ts +1 -0
  36. package/dist/columns.test.js +625 -0
  37. package/dist/core.d.ts +85 -0
  38. package/dist/editing.d.ts +7 -0
  39. package/dist/editing.js +191 -5
  40. package/dist/editing.test.d.ts +1 -0
  41. package/dist/editing.test.js +732 -0
  42. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  43. package/dist/editors/cell-editors.coverage.test.js +139 -0
  44. package/dist/facet-buckets.test.d.ts +1 -0
  45. package/dist/facet-buckets.test.js +296 -0
  46. package/dist/filter-operators.test.d.ts +1 -0
  47. package/dist/filter-operators.test.js +135 -0
  48. package/dist/hyperformula-adapter.test.d.ts +1 -0
  49. package/dist/hyperformula-adapter.test.js +205 -0
  50. package/dist/index.d.ts +5 -2
  51. package/dist/index.js +4 -1
  52. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  53. package/dist/keyboard-handlers.coverage.test.js +495 -0
  54. package/dist/keyboard-handlers.js +7 -2
  55. package/dist/menus.js +1 -0
  56. package/dist/menus.test.d.ts +1 -0
  57. package/dist/menus.test.js +560 -0
  58. package/dist/named-views.coverage.test.d.ts +1 -0
  59. package/dist/named-views.coverage.test.js +180 -0
  60. package/dist/row-drag.d.ts +49 -0
  61. package/dist/row-drag.js +221 -0
  62. package/dist/row-drag.test.d.ts +1 -0
  63. package/dist/row-drag.test.js +142 -0
  64. package/dist/row-resize.test.d.ts +1 -0
  65. package/dist/row-resize.test.js +329 -0
  66. package/dist/scroll-sync.js +3 -0
  67. package/dist/scroll-sync.test.d.ts +1 -0
  68. package/dist/scroll-sync.test.js +290 -0
  69. package/dist/selection.d.ts +7 -1
  70. package/dist/selection.js +76 -36
  71. package/dist/selection.multi-range.test.d.ts +1 -0
  72. package/dist/selection.multi-range.test.js +55 -0
  73. package/dist/selection.test.d.ts +1 -0
  74. package/dist/selection.test.js +647 -0
  75. package/dist/server-data-source.coverage.test.d.ts +1 -0
  76. package/dist/server-data-source.coverage.test.js +154 -0
  77. package/dist/spreadsheet.d.ts +30 -0
  78. package/dist/spreadsheet.js +48 -0
  79. package/dist/spreadsheet.test.d.ts +1 -0
  80. package/dist/spreadsheet.test.js +446 -0
  81. package/dist/sv-grid-scrollbar.js +13 -1
  82. package/dist/svgrid-wrapper.types.d.ts +19 -0
  83. package/dist/svgrid.behavior.test.js +20 -0
  84. package/dist/svgrid.interaction.test.js +31 -0
  85. package/dist/svgrid.new-features.wrapper.test.js +34 -2
  86. package/dist/test-setup.js +9 -3
  87. package/dist/virtualization/scroll-scaling.d.ts +17 -0
  88. package/dist/virtualization/scroll-scaling.js +35 -0
  89. package/dist/virtualization/scroll-scaling.test.js +42 -1
  90. package/package.json +2 -1
  91. package/src/GridMenus.svelte +205 -127
  92. package/src/SvGrid.controller.svelte.ts +2352 -2195
  93. package/src/SvGrid.css +2012 -1747
  94. package/src/SvGrid.svelte +2346 -2047
  95. package/src/SvGrid.types.ts +537 -456
  96. package/src/aligned-grids.test.ts +80 -0
  97. package/src/aligned-grids.ts +87 -0
  98. package/src/build-api.ts +683 -663
  99. package/src/clipboard.test.ts +49 -0
  100. package/src/clipboard.ts +51 -23
  101. package/src/column-groups.test.ts +59 -0
  102. package/src/column-groups.ts +80 -0
  103. package/src/column-types.test.ts +68 -0
  104. package/src/column-types.ts +82 -0
  105. package/src/core.ts +78 -0
  106. package/src/editing.ts +669 -513
  107. package/src/index.ts +12 -0
  108. package/src/menus.ts +1 -0
  109. package/src/row-drag.test.ts +168 -0
  110. package/src/row-drag.ts +255 -0
  111. package/src/scroll-sync.ts +2 -0
  112. package/src/selection.multi-range.test.ts +61 -0
  113. package/src/selection.ts +71 -37
  114. package/src/spreadsheet.test.ts +489 -445
  115. package/src/spreadsheet.ts +304 -246
  116. package/src/svgrid-wrapper.types.ts +19 -0
  117. package/src/svgrid.new-features.wrapper.test.ts +2 -2
@@ -0,0 +1,700 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createClipboard } from './clipboard';
3
+ function makeCtx(opts = {}) {
4
+ const columns = opts.columns ?? [
5
+ { id: 'a', field: 'a', editable: true, editorType: 'text' },
6
+ { id: 'b', field: 'b', editable: true, editorType: 'text' },
7
+ ];
8
+ const data = (opts.data ?? [
9
+ { a: 'a0', b: 'b0' },
10
+ { a: 'a1', b: 'b1' },
11
+ ]).map((d) => ({ ...d }));
12
+ const allColumns = columns.map((c) => ({
13
+ id: c.id,
14
+ columnDef: {
15
+ field: c.field,
16
+ editable: c.editable,
17
+ editorType: c.editorType,
18
+ },
19
+ }));
20
+ // allRows mirror internalData by index; row.id === index as a string.
21
+ const buildRows = () => data.map((row, index) => ({
22
+ id: String(index),
23
+ original: row,
24
+ getCanExpand: () => opts.groupRows?.has(index) ?? false,
25
+ getCellValueByColumnId: (colId) => row[colId],
26
+ }));
27
+ let activeCell = null;
28
+ const ctx = {
29
+ internalData: data,
30
+ allColumns,
31
+ get allRows() {
32
+ // Recompute from current internalData so writeCellRaw's
33
+ // `r.original === row` lookup stays consistent with internalData.
34
+ return ctx._rows;
35
+ },
36
+ _rows: buildRows(),
37
+ selectionRange: { anchor: null, focus: null },
38
+ selectionRanges: [],
39
+ // Mirrors the real controller's getSelectionRects: committed ranges + active.
40
+ getSelectionRects() {
41
+ const rects = [];
42
+ const norm = (r) => r?.anchor && r?.focus
43
+ ? {
44
+ minRow: Math.min(r.anchor.rowIndex, r.focus.rowIndex),
45
+ maxRow: Math.max(r.anchor.rowIndex, r.focus.rowIndex),
46
+ minCol: Math.min(r.anchor.colIndex, r.focus.colIndex),
47
+ maxCol: Math.max(r.anchor.colIndex, r.focus.colIndex),
48
+ }
49
+ : null;
50
+ for (const r of ctx.selectionRanges) {
51
+ const n = norm(r);
52
+ if (n)
53
+ rects.push(n);
54
+ }
55
+ const active = norm(ctx.selectionRange);
56
+ if (active)
57
+ rects.push(active);
58
+ return rects;
59
+ },
60
+ fillDrag: null,
61
+ editedCellValues: {},
62
+ userHasActivatedCell: false,
63
+ gridRootEl: null,
64
+ props: {},
65
+ findColumnById: (id) => allColumns.find((c) => c.id === id),
66
+ isCellEditableAt: opts.editableAt ?? (() => true),
67
+ getCellDisplayValue: vi.fn((_rowId, _colId, base) => base),
68
+ grid: {
69
+ getState: () => ({ activeCell }),
70
+ store: { setState: vi.fn() },
71
+ },
72
+ _setActiveCell: (c) => {
73
+ activeCell = c;
74
+ },
75
+ };
76
+ // Keep _rows pointing at the live internalData objects whenever it is
77
+ // reassigned by writeCellRaw / clearSelectedCells.
78
+ let backing = data;
79
+ Object.defineProperty(ctx, 'internalData', {
80
+ get: () => backing,
81
+ set: (next) => {
82
+ backing = next;
83
+ ctx._rows = next.map((row, index) => ({
84
+ id: String(index),
85
+ original: row,
86
+ getCanExpand: () => opts.groupRows?.has(index) ?? false,
87
+ getCellValueByColumnId: (colId) => row[colId],
88
+ }));
89
+ },
90
+ });
91
+ return ctx;
92
+ }
93
+ describe('readCellRaw', () => {
94
+ it('reads the underlying field value', () => {
95
+ const ctx = makeCtx();
96
+ const cb = createClipboard(ctx);
97
+ expect(cb.readCellRaw(0, 'a')).toBe('a0');
98
+ expect(cb.readCellRaw(1, 'b')).toBe('b1');
99
+ });
100
+ it('returns undefined when the row is missing', () => {
101
+ const ctx = makeCtx();
102
+ const cb = createClipboard(ctx);
103
+ expect(cb.readCellRaw(99, 'a')).toBeUndefined();
104
+ });
105
+ it('returns undefined when the column has no field', () => {
106
+ const ctx = makeCtx({
107
+ columns: [{ id: 'noField' }],
108
+ data: [{ a: 1 }],
109
+ });
110
+ const cb = createClipboard(ctx);
111
+ expect(cb.readCellRaw(0, 'noField')).toBeUndefined();
112
+ });
113
+ });
114
+ describe('writeCellRaw', () => {
115
+ it('writes a new value and fires onCellValueChange', () => {
116
+ const onCellValueChange = vi.fn();
117
+ const ctx = makeCtx();
118
+ ctx.props.onCellValueChange = onCellValueChange;
119
+ const cb = createClipboard(ctx);
120
+ cb.writeCellRaw(0, 'a', 'X');
121
+ expect(ctx.internalData[0].a).toBe('X');
122
+ // Original array object was replaced (immutable update).
123
+ expect(onCellValueChange).toHaveBeenCalledWith(expect.objectContaining({
124
+ rowIndex: 0,
125
+ columnId: 'a',
126
+ oldValue: 'a0',
127
+ newValue: 'X',
128
+ }));
129
+ });
130
+ it('records the edit in editedCellValues keyed by rowId:colId', () => {
131
+ const ctx = makeCtx();
132
+ const cb = createClipboard(ctx);
133
+ cb.writeCellRaw(1, 'b', 'Y');
134
+ expect(ctx.editedCellValues['1:b']).toBe('Y');
135
+ });
136
+ it('is a no-op when the value is unchanged', () => {
137
+ const onCellValueChange = vi.fn();
138
+ const ctx = makeCtx();
139
+ ctx.props.onCellValueChange = onCellValueChange;
140
+ const cb = createClipboard(ctx);
141
+ cb.writeCellRaw(0, 'a', 'a0');
142
+ expect(onCellValueChange).not.toHaveBeenCalled();
143
+ });
144
+ it('does nothing when the row is missing', () => {
145
+ const ctx = makeCtx();
146
+ const cb = createClipboard(ctx);
147
+ cb.writeCellRaw(99, 'a', 'Z');
148
+ expect(ctx.internalData).toHaveLength(2);
149
+ });
150
+ it('does nothing when the column has no field', () => {
151
+ const ctx = makeCtx({
152
+ columns: [{ id: 'noField' }],
153
+ data: [{ a: 1 }],
154
+ });
155
+ const cb = createClipboard(ctx);
156
+ cb.writeCellRaw(0, 'noField', 5);
157
+ expect(ctx.internalData[0]).toEqual({ a: 1 });
158
+ });
159
+ });
160
+ describe('clearSelectedCellValues', () => {
161
+ it('nulls every editable cell in the selection range', () => {
162
+ const ctx = makeCtx();
163
+ ctx.selectionRange = {
164
+ anchor: { rowIndex: 0, colIndex: 0 },
165
+ focus: { rowIndex: 1, colIndex: 1 },
166
+ };
167
+ const cb = createClipboard(ctx);
168
+ cb.clearSelectedCellValues();
169
+ expect(ctx.internalData[0]).toEqual({ a: null, b: null });
170
+ expect(ctx.internalData[1]).toEqual({ a: null, b: null });
171
+ });
172
+ it('skips non-editable cells', () => {
173
+ const ctx = makeCtx({ editableAt: (r, c) => !(r === 0 && c === 0) });
174
+ ctx.selectionRange = {
175
+ anchor: { rowIndex: 0, colIndex: 0 },
176
+ focus: { rowIndex: 0, colIndex: 1 },
177
+ };
178
+ const cb = createClipboard(ctx);
179
+ cb.clearSelectedCellValues();
180
+ expect(ctx.internalData[0].a).toBe('a0'); // untouched
181
+ expect(ctx.internalData[0].b).toBeNull();
182
+ });
183
+ it('clears just the active cell when no range is selected', () => {
184
+ const ctx = makeCtx();
185
+ ctx.userHasActivatedCell = true;
186
+ ctx._setActiveCell({ rowIndex: 1, colIndex: 1 });
187
+ const cb = createClipboard(ctx);
188
+ cb.clearSelectedCellValues();
189
+ expect(ctx.internalData[1].b).toBeNull();
190
+ expect(ctx.internalData[0].a).toBe('a0');
191
+ });
192
+ it('does nothing for the active cell when not activated', () => {
193
+ const ctx = makeCtx();
194
+ ctx.userHasActivatedCell = false;
195
+ ctx._setActiveCell({ rowIndex: 1, colIndex: 1 });
196
+ const cb = createClipboard(ctx);
197
+ cb.clearSelectedCellValues();
198
+ expect(ctx.internalData[1].b).toBe('b1');
199
+ });
200
+ });
201
+ describe('startFillDrag', () => {
202
+ function fakeEvent() {
203
+ return {
204
+ stopPropagation: vi.fn(),
205
+ preventDefault: vi.fn(),
206
+ };
207
+ }
208
+ it('seeds the drag from the selection range', () => {
209
+ const ctx = makeCtx();
210
+ ctx.selectionRange = {
211
+ anchor: { rowIndex: 0, colIndex: 0 },
212
+ focus: { rowIndex: 1, colIndex: 1 },
213
+ };
214
+ const cb = createClipboard(ctx);
215
+ cb.startFillDrag(fakeEvent(), 2, 1);
216
+ expect(ctx.fillDrag).toEqual({
217
+ sourceMinRow: 0,
218
+ sourceMaxRow: 1,
219
+ sourceMinCol: 0,
220
+ sourceMaxCol: 1,
221
+ targetRow: 2,
222
+ targetCol: 1,
223
+ });
224
+ });
225
+ it('seeds a 1x1 drag from the active cell when no range', () => {
226
+ const ctx = makeCtx();
227
+ const cb = createClipboard(ctx);
228
+ const event = fakeEvent();
229
+ cb.startFillDrag(event, 3, 4);
230
+ expect(ctx.fillDrag).toEqual({
231
+ sourceMinRow: 3,
232
+ sourceMaxRow: 3,
233
+ sourceMinCol: 4,
234
+ sourceMaxCol: 4,
235
+ targetRow: 3,
236
+ targetCol: 4,
237
+ });
238
+ expect(event.stopPropagation).toHaveBeenCalled();
239
+ expect(event.preventDefault).toHaveBeenCalled();
240
+ });
241
+ });
242
+ describe('onFillPointerMove', () => {
243
+ it('does nothing when there is no active fillDrag', () => {
244
+ const ctx = makeCtx();
245
+ const cb = createClipboard(ctx);
246
+ cb.onFillPointerMove({ clientX: 0, clientY: 0 });
247
+ expect(ctx.fillDrag).toBeNull();
248
+ });
249
+ it('updates the target when a cell is found under the pointer', () => {
250
+ const ctx = makeCtx();
251
+ ctx.fillDrag = {
252
+ sourceMinRow: 0,
253
+ sourceMaxRow: 0,
254
+ sourceMinCol: 0,
255
+ sourceMaxCol: 0,
256
+ targetRow: 0,
257
+ targetCol: 0,
258
+ };
259
+ const cb = createClipboard(ctx);
260
+ const td = document.createElement('td');
261
+ td.setAttribute('data-svgrid-row', '2');
262
+ td.setAttribute('data-svgrid-col', '1');
263
+ document.elementFromPoint = () => td;
264
+ cb.onFillPointerMove({ clientX: 5, clientY: 5 });
265
+ expect(ctx.fillDrag.targetRow).toBe(2);
266
+ expect(ctx.fillDrag.targetCol).toBe(1);
267
+ });
268
+ it('does not re-set the drag when hovering the same target', () => {
269
+ const ctx = makeCtx();
270
+ const original = {
271
+ sourceMinRow: 0,
272
+ sourceMaxRow: 0,
273
+ sourceMinCol: 0,
274
+ sourceMaxCol: 0,
275
+ targetRow: 1,
276
+ targetCol: 1,
277
+ };
278
+ ctx.fillDrag = original;
279
+ const cb = createClipboard(ctx);
280
+ const td = document.createElement('td');
281
+ td.setAttribute('data-svgrid-row', '1');
282
+ td.setAttribute('data-svgrid-col', '1');
283
+ document.elementFromPoint = () => td;
284
+ cb.onFillPointerMove({ clientX: 5, clientY: 5 });
285
+ expect(ctx.fillDrag).toBe(original); // unchanged reference
286
+ });
287
+ it('ignores a pointer that is not over a grid cell', () => {
288
+ const ctx = makeCtx();
289
+ const original = {
290
+ sourceMinRow: 0,
291
+ sourceMaxRow: 0,
292
+ sourceMinCol: 0,
293
+ sourceMaxCol: 0,
294
+ targetRow: 0,
295
+ targetCol: 0,
296
+ };
297
+ ctx.fillDrag = original;
298
+ const cb = createClipboard(ctx);
299
+ document.elementFromPoint = () => document.createElement('div');
300
+ cb.onFillPointerMove({ clientX: 5, clientY: 5 });
301
+ expect(ctx.fillDrag).toBe(original);
302
+ });
303
+ });
304
+ describe('onFillPointerUp / applyFillPattern', () => {
305
+ it('does nothing when there is no fillDrag', () => {
306
+ const ctx = makeCtx();
307
+ const cb = createClipboard(ctx);
308
+ cb.onFillPointerUp();
309
+ expect(ctx.selectionRange).toEqual({ anchor: null, focus: null });
310
+ });
311
+ it('fills downward from a numeric source pattern', () => {
312
+ const ctx = makeCtx({
313
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
314
+ data: [{ a: 1 }, { a: 2 }, { a: 0 }, { a: 0 }],
315
+ });
316
+ ctx.fillDrag = {
317
+ sourceMinRow: 0,
318
+ sourceMaxRow: 1,
319
+ sourceMinCol: 0,
320
+ sourceMaxCol: 0,
321
+ targetRow: 3,
322
+ targetCol: 0,
323
+ };
324
+ const cb = createClipboard(ctx);
325
+ cb.onFillPointerUp();
326
+ // 1,2 -> linear pattern continues 3,4
327
+ expect(ctx.internalData[2].a).toBe(3);
328
+ expect(ctx.internalData[3].a).toBe(4);
329
+ expect(ctx.fillDrag).toBeNull();
330
+ expect(ctx.selectionRange.focus).toEqual({ rowIndex: 3, colIndex: 0 });
331
+ });
332
+ it('fills upward, reverse-extrapolating the pattern', () => {
333
+ const ctx = makeCtx({
334
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
335
+ data: [{ a: 0 }, { a: 0 }, { a: 10 }, { a: 20 }],
336
+ });
337
+ ctx.fillDrag = {
338
+ sourceMinRow: 2,
339
+ sourceMaxRow: 3,
340
+ sourceMinCol: 0,
341
+ sourceMaxCol: 0,
342
+ targetRow: 0,
343
+ targetCol: 0,
344
+ };
345
+ const cb = createClipboard(ctx);
346
+ cb.applyFillPattern();
347
+ // reversed source 20,10 -> continues 0,-10 going up; row1=0, row0=-10
348
+ expect(ctx.internalData[1].a).toBe(0);
349
+ expect(ctx.internalData[0].a).toBe(-10);
350
+ });
351
+ it('skips columns that are not editable', () => {
352
+ const ctx = makeCtx({
353
+ columns: [{ id: 'a', field: 'a', editable: false, editorType: 'number' }],
354
+ data: [{ a: 1 }, { a: 2 }, { a: 0 }],
355
+ });
356
+ ctx.fillDrag = {
357
+ sourceMinRow: 0,
358
+ sourceMaxRow: 1,
359
+ sourceMinCol: 0,
360
+ sourceMaxCol: 0,
361
+ targetRow: 2,
362
+ targetCol: 0,
363
+ };
364
+ const cb = createClipboard(ctx);
365
+ cb.applyFillPattern();
366
+ expect(ctx.internalData[2].a).toBe(0); // untouched
367
+ });
368
+ it('fills horizontally to the right', () => {
369
+ const ctx = makeCtx({
370
+ columns: [
371
+ { id: 'a', field: 'a', editable: true, editorType: 'number' },
372
+ { id: 'b', field: 'b', editable: true, editorType: 'number' },
373
+ { id: 'c', field: 'c', editable: true, editorType: 'number' },
374
+ ],
375
+ data: [{ a: 2, b: 4, c: 0 }],
376
+ });
377
+ ctx.fillDrag = {
378
+ sourceMinRow: 0,
379
+ sourceMaxRow: 0,
380
+ sourceMinCol: 0,
381
+ sourceMaxCol: 1,
382
+ targetRow: 0,
383
+ targetCol: 2,
384
+ };
385
+ const cb = createClipboard(ctx);
386
+ cb.applyFillPattern();
387
+ // 2,4 -> next is 6
388
+ expect(ctx.internalData[0].c).toBe(6);
389
+ });
390
+ it('fills horizontally to the left', () => {
391
+ const ctx = makeCtx({
392
+ columns: [
393
+ { id: 'a', field: 'a', editable: true, editorType: 'number' },
394
+ { id: 'b', field: 'b', editable: true, editorType: 'number' },
395
+ { id: 'c', field: 'c', editable: true, editorType: 'number' },
396
+ ],
397
+ data: [{ a: 0, b: 10, c: 20 }],
398
+ });
399
+ ctx.fillDrag = {
400
+ sourceMinRow: 0,
401
+ sourceMaxRow: 0,
402
+ sourceMinCol: 1,
403
+ sourceMaxCol: 2,
404
+ targetRow: 0,
405
+ targetCol: 0,
406
+ };
407
+ const cb = createClipboard(ctx);
408
+ cb.applyFillPattern();
409
+ // reversed 20,10 -> next going left is 0
410
+ expect(ctx.internalData[0].a).toBe(0);
411
+ });
412
+ });
413
+ describe('toggleBooleanCell', () => {
414
+ it('flips a falsy cell to true and records the edit', () => {
415
+ const ctx = makeCtx({
416
+ columns: [{ id: 'a', field: 'a', editable: true }],
417
+ data: [{ a: false }],
418
+ });
419
+ const cb = createClipboard(ctx);
420
+ cb.toggleBooleanCell(0, 0);
421
+ expect(ctx.internalData[0].a).toBe(true);
422
+ expect(ctx.editedCellValues['0:a']).toBe(true);
423
+ expect(ctx.grid.store.setState).toHaveBeenCalled();
424
+ });
425
+ it('flips a truthy cell to false', () => {
426
+ const ctx = makeCtx({
427
+ columns: [{ id: 'a', field: 'a', editable: true }],
428
+ data: [{ a: true }],
429
+ });
430
+ const cb = createClipboard(ctx);
431
+ cb.toggleBooleanCell(0, 0);
432
+ expect(ctx.internalData[0].a).toBe(false);
433
+ });
434
+ it('does nothing when the row or column is missing', () => {
435
+ const ctx = makeCtx({
436
+ columns: [{ id: 'a', field: 'a' }],
437
+ data: [{ a: true }],
438
+ });
439
+ const cb = createClipboard(ctx);
440
+ cb.toggleBooleanCell(99, 0);
441
+ cb.toggleBooleanCell(0, 99);
442
+ expect(ctx.internalData[0].a).toBe(true);
443
+ });
444
+ it('does nothing when the column has no field', () => {
445
+ const ctx = makeCtx({
446
+ columns: [{ id: 'a' }],
447
+ data: [{ a: true }],
448
+ });
449
+ const cb = createClipboard(ctx);
450
+ cb.toggleBooleanCell(0, 0);
451
+ expect(ctx.grid.store.setState).not.toHaveBeenCalled();
452
+ });
453
+ });
454
+ describe('copySelectionToClipboard', () => {
455
+ let writeText;
456
+ beforeEach(() => {
457
+ writeText = vi.fn().mockResolvedValue(undefined);
458
+ Object.defineProperty(navigator, 'clipboard', {
459
+ value: { writeText },
460
+ configurable: true,
461
+ });
462
+ });
463
+ it('does nothing when there is no selection', () => {
464
+ const ctx = makeCtx();
465
+ const cb = createClipboard(ctx);
466
+ cb.copySelectionToClipboard();
467
+ expect(writeText).not.toHaveBeenCalled();
468
+ });
469
+ it('writes the selection as tab/newline separated text', () => {
470
+ const ctx = makeCtx({
471
+ data: [
472
+ { a: 'a0', b: 'b0' },
473
+ { a: 'a1', b: 'b1' },
474
+ ],
475
+ });
476
+ ctx.selectionRange = {
477
+ anchor: { rowIndex: 0, colIndex: 0 },
478
+ focus: { rowIndex: 1, colIndex: 1 },
479
+ };
480
+ const cb = createClipboard(ctx);
481
+ cb.copySelectionToClipboard();
482
+ expect(writeText).toHaveBeenCalledWith('a0\tb0\na1\tb1');
483
+ });
484
+ it('prepends a header row when copyHeadersToClipboard is set', () => {
485
+ const ctx = makeCtx({
486
+ data: [
487
+ { a: 'a0', b: 'b0' },
488
+ { a: 'a1', b: 'b1' },
489
+ ],
490
+ });
491
+ ctx.props.copyHeadersToClipboard = true;
492
+ ctx.selectionRange = {
493
+ anchor: { rowIndex: 0, colIndex: 0 },
494
+ focus: { rowIndex: 1, colIndex: 1 },
495
+ };
496
+ const cb = createClipboard(ctx);
497
+ cb.copySelectionToClipboard();
498
+ expect(writeText).toHaveBeenCalledWith('a\tb\na0\tb0\na1\tb1');
499
+ });
500
+ it('runs each value through processCellForClipboard', () => {
501
+ const ctx = makeCtx({
502
+ data: [{ a: 'a0', b: 'b0' }],
503
+ });
504
+ ctx.props.processCellForClipboard = ({ value }) => `[${value}]`;
505
+ ctx.selectionRange = {
506
+ anchor: { rowIndex: 0, colIndex: 0 },
507
+ focus: { rowIndex: 0, colIndex: 1 },
508
+ };
509
+ const cb = createClipboard(ctx);
510
+ cb.copySelectionToClipboard();
511
+ expect(writeText).toHaveBeenCalledWith('[a0]\t[b0]');
512
+ });
513
+ it('emits empty strings for missing columns and nullish values', () => {
514
+ const ctx = makeCtx({
515
+ columns: [{ id: 'a', field: 'a' }],
516
+ data: [{ a: null }],
517
+ });
518
+ ctx.selectionRange = {
519
+ anchor: { rowIndex: 0, colIndex: 0 },
520
+ focus: { rowIndex: 0, colIndex: 1 }, // col 1 doesn't exist
521
+ };
522
+ const cb = createClipboard(ctx);
523
+ cb.copySelectionToClipboard();
524
+ expect(writeText).toHaveBeenCalledWith('\t');
525
+ });
526
+ it('skips group rows', () => {
527
+ const ctx = makeCtx({
528
+ data: [
529
+ { a: 'g', b: 'g' },
530
+ { a: 'a1', b: 'b1' },
531
+ ],
532
+ groupRows: new Set([0]),
533
+ });
534
+ ctx.selectionRange = {
535
+ anchor: { rowIndex: 0, colIndex: 0 },
536
+ focus: { rowIndex: 1, colIndex: 1 },
537
+ };
538
+ const cb = createClipboard(ctx);
539
+ cb.copySelectionToClipboard();
540
+ expect(writeText).toHaveBeenCalledWith('a1\tb1');
541
+ });
542
+ it('falls back to legacy copy when clipboard API is absent', () => {
543
+ Object.defineProperty(navigator, 'clipboard', {
544
+ value: undefined,
545
+ configurable: true,
546
+ });
547
+ const execCommand = vi.fn().mockReturnValue(true);
548
+ // @ts-expect-error - jsdom lacks execCommand by default
549
+ document.execCommand = execCommand;
550
+ const ctx = makeCtx();
551
+ ctx.selectionRange = {
552
+ anchor: { rowIndex: 0, colIndex: 0 },
553
+ focus: { rowIndex: 0, colIndex: 0 },
554
+ };
555
+ const cb = createClipboard(ctx);
556
+ cb.copySelectionToClipboard();
557
+ expect(execCommand).toHaveBeenCalledWith('copy');
558
+ });
559
+ it('swallows an execCommand error in the legacy path', () => {
560
+ Object.defineProperty(navigator, 'clipboard', {
561
+ value: undefined,
562
+ configurable: true,
563
+ });
564
+ // @ts-expect-error - override execCommand to throw
565
+ document.execCommand = () => {
566
+ throw new Error('blocked');
567
+ };
568
+ const ctx = makeCtx();
569
+ ctx.selectionRange = {
570
+ anchor: { rowIndex: 0, colIndex: 0 },
571
+ focus: { rowIndex: 0, colIndex: 0 },
572
+ };
573
+ const cb = createClipboard(ctx);
574
+ // Must not throw even though execCommand blows up.
575
+ expect(() => cb.copySelectionToClipboard()).not.toThrow();
576
+ });
577
+ });
578
+ describe('clearSelectedCells', () => {
579
+ it('returns false when there is no anchor or active cell', () => {
580
+ const ctx = makeCtx();
581
+ const cb = createClipboard(ctx);
582
+ expect(cb.clearSelectedCells()).toBe(false);
583
+ });
584
+ it('clears a text cell to empty string and returns true', () => {
585
+ const ctx = makeCtx({
586
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
587
+ data: [{ a: 'hello' }],
588
+ });
589
+ ctx.selectionRange = {
590
+ anchor: { rowIndex: 0, colIndex: 0 },
591
+ focus: { rowIndex: 0, colIndex: 0 },
592
+ };
593
+ const cb = createClipboard(ctx);
594
+ expect(cb.clearSelectedCells()).toBe(true);
595
+ expect(ctx.internalData[0].a).toBe('');
596
+ expect(ctx.grid.store.setState).toHaveBeenCalled();
597
+ });
598
+ it('clears a number cell via parseEditorValue coercion', () => {
599
+ const ctx = makeCtx({
600
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'number' }],
601
+ data: [{ a: 5 }],
602
+ });
603
+ ctx.selectionRange = {
604
+ anchor: { rowIndex: 0, colIndex: 0 },
605
+ focus: { rowIndex: 0, colIndex: 0 },
606
+ };
607
+ const cb = createClipboard(ctx);
608
+ cb.clearSelectedCells();
609
+ // parseEditorValue('number', '') coerces '' -> 0 (Number('') is finite).
610
+ expect(ctx.internalData[0].a).toBe(0);
611
+ });
612
+ it('falls back to the active cell when no selection range', () => {
613
+ const ctx = makeCtx({
614
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
615
+ data: [{ a: 'x' }],
616
+ });
617
+ ctx._setActiveCell({ rowIndex: 0, colIndex: 0 });
618
+ const cb = createClipboard(ctx);
619
+ expect(cb.clearSelectedCells()).toBe(true);
620
+ expect(ctx.internalData[0].a).toBe('');
621
+ });
622
+ it('skips group rows and non-editable cells, returning false when nothing changed', () => {
623
+ const ctx = makeCtx({
624
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
625
+ data: [{ a: 'g' }],
626
+ groupRows: new Set([0]),
627
+ });
628
+ ctx.selectionRange = {
629
+ anchor: { rowIndex: 0, colIndex: 0 },
630
+ focus: { rowIndex: 0, colIndex: 0 },
631
+ };
632
+ const cb = createClipboard(ctx);
633
+ expect(cb.clearSelectedCells()).toBe(false);
634
+ expect(ctx.internalData[0].a).toBe('g');
635
+ });
636
+ it('skips cells with no field', () => {
637
+ const ctx = makeCtx({
638
+ columns: [{ id: 'a' }],
639
+ data: [{ a: 'x' }],
640
+ });
641
+ ctx.selectionRange = {
642
+ anchor: { rowIndex: 0, colIndex: 0 },
643
+ focus: { rowIndex: 0, colIndex: 0 },
644
+ };
645
+ const cb = createClipboard(ctx);
646
+ expect(cb.clearSelectedCells()).toBe(false);
647
+ });
648
+ it('skips non-editable cells', () => {
649
+ const ctx = makeCtx({
650
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
651
+ data: [{ a: 'x' }],
652
+ editableAt: () => false,
653
+ });
654
+ ctx.selectionRange = {
655
+ anchor: { rowIndex: 0, colIndex: 0 },
656
+ focus: { rowIndex: 0, colIndex: 0 },
657
+ };
658
+ const cb = createClipboard(ctx);
659
+ expect(cb.clearSelectedCells()).toBe(false);
660
+ expect(ctx.internalData[0].a).toBe('x');
661
+ });
662
+ it('skips rows whose id maps outside the data bounds', () => {
663
+ const ctx = makeCtx({
664
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
665
+ data: [{ a: 'x' }],
666
+ });
667
+ // Force the row's id to a non-numeric value so Number(id) is NaN
668
+ // and the integer/bounds guard skips it.
669
+ ctx._rows[0].id = 'not-a-number';
670
+ ctx.selectionRange = {
671
+ anchor: { rowIndex: 0, colIndex: 0 },
672
+ focus: { rowIndex: 0, colIndex: 0 },
673
+ };
674
+ const cb = createClipboard(ctx);
675
+ expect(cb.clearSelectedCells()).toBe(false);
676
+ expect(ctx.internalData[0].a).toBe('x');
677
+ });
678
+ });
679
+ describe('cutSelectionToClipboard', () => {
680
+ beforeEach(() => {
681
+ Object.defineProperty(navigator, 'clipboard', {
682
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
683
+ configurable: true,
684
+ });
685
+ });
686
+ it('copies then clears the selection', async () => {
687
+ const ctx = makeCtx({
688
+ columns: [{ id: 'a', field: 'a', editable: true, editorType: 'text' }],
689
+ data: [{ a: 'hello' }],
690
+ });
691
+ ctx.selectionRange = {
692
+ anchor: { rowIndex: 0, colIndex: 0 },
693
+ focus: { rowIndex: 0, colIndex: 0 },
694
+ };
695
+ const cb = createClipboard(ctx);
696
+ await cb.cutSelectionToClipboard();
697
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello');
698
+ expect(ctx.internalData[0].a).toBe('');
699
+ });
700
+ });
@@ -0,0 +1 @@
1
+ export {};