@svgrid/grid 1.0.0 → 1.1.0

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 (144) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +137 -39
  3. package/dist/GridFooter.svelte +164 -0
  4. package/dist/GridFooter.svelte.d.ts +29 -0
  5. package/dist/GridMenus.svelte +570 -0
  6. package/dist/GridMenus.svelte.d.ts +31 -0
  7. package/dist/SvGrid.controller.svelte.d.ts +429 -0
  8. package/dist/SvGrid.controller.svelte.js +1732 -0
  9. package/dist/SvGrid.css +1709 -0
  10. package/dist/SvGrid.helpers.d.ts +35 -0
  11. package/dist/SvGrid.helpers.js +160 -0
  12. package/dist/SvGrid.svelte +344 -7043
  13. package/dist/SvGrid.svelte.d.ts +4 -357
  14. package/dist/SvGrid.types.d.ts +436 -0
  15. package/dist/SvGrid.types.js +1 -0
  16. package/dist/SvGridChart.svelte +1060 -23
  17. package/dist/SvGridChart.svelte.d.ts +17 -0
  18. package/dist/build-api.d.ts +5 -0
  19. package/dist/build-api.js +527 -0
  20. package/dist/cell-render.d.ts +15 -0
  21. package/dist/cell-render.js +246 -0
  22. package/dist/cell-values.d.ts +28 -0
  23. package/dist/cell-values.js +89 -0
  24. package/dist/chart.d.ts +370 -3
  25. package/dist/chart.js +1135 -42
  26. package/dist/clipboard.d.ts +15 -0
  27. package/dist/clipboard.js +356 -0
  28. package/dist/columns.d.ts +30 -0
  29. package/dist/columns.js +277 -0
  30. package/dist/core.d.ts +1 -1
  31. package/dist/css.d.ts +3 -0
  32. package/dist/editing.d.ts +24 -0
  33. package/dist/editing.js +343 -0
  34. package/dist/editors/cell-editors.d.ts +1 -1
  35. package/dist/facet-buckets.d.ts +13 -0
  36. package/dist/facet-buckets.js +54 -0
  37. package/dist/features.d.ts +5 -0
  38. package/dist/features.js +30 -0
  39. package/dist/filter-operators.d.ts +16 -0
  40. package/dist/filter-operators.js +69 -0
  41. package/dist/hyperformula-adapter.d.ts +82 -0
  42. package/dist/hyperformula-adapter.js +73 -0
  43. package/dist/index.d.ts +5 -2
  44. package/dist/index.js +5 -2
  45. package/dist/keyboard-handlers.d.ts +7 -0
  46. package/dist/keyboard-handlers.js +197 -0
  47. package/dist/menus.d.ts +40 -0
  48. package/dist/menus.js +389 -0
  49. package/dist/named-views.d.ts +27 -0
  50. package/dist/named-views.js +39 -0
  51. package/dist/row-resize.d.ts +43 -0
  52. package/dist/row-resize.js +158 -0
  53. package/dist/scroll-sync.d.ts +9 -0
  54. package/dist/scroll-sync.js +86 -0
  55. package/dist/selection.d.ts +26 -0
  56. package/dist/selection.js +387 -0
  57. package/dist/spreadsheet.d.ts +80 -0
  58. package/dist/spreadsheet.js +194 -0
  59. package/dist/summaries.d.ts +12 -0
  60. package/dist/summaries.js +65 -0
  61. package/dist/sv-grid-scrollbar.js +7 -1
  62. package/dist/svgrid-wrapper.types.d.ts +12 -1
  63. package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
  64. package/dist/svgrid.comments-autocomplete.test.js +96 -0
  65. package/dist/svgrid.context-menu.test.d.ts +1 -0
  66. package/dist/svgrid.context-menu.test.js +102 -0
  67. package/dist/svgrid.new-features.wrapper.test.js +30 -4
  68. package/dist/svgrid.wrapper.test.js +27 -1
  69. package/dist/virtualization/column-virtualizer.d.ts +2 -0
  70. package/dist/virtualization/scroll-scaling.d.ts +28 -0
  71. package/dist/virtualization/scroll-scaling.js +64 -0
  72. package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
  73. package/dist/virtualization/scroll-scaling.test.js +86 -0
  74. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
  75. package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
  76. package/dist/virtualization/virtualizer.d.ts +7 -0
  77. package/dist/virtualization/virtualizer.js +30 -0
  78. package/package.json +1 -1
  79. package/src/GridFooter.svelte +164 -0
  80. package/src/GridMenus.svelte +570 -0
  81. package/src/SvGrid.controller.svelte.ts +2195 -0
  82. package/src/SvGrid.css +1747 -0
  83. package/src/SvGrid.helpers.test.ts +415 -0
  84. package/src/SvGrid.helpers.ts +185 -0
  85. package/src/SvGrid.svelte +348 -7043
  86. package/src/SvGrid.types.ts +456 -0
  87. package/src/SvGridChart.svelte +1060 -23
  88. package/src/build-api.coverage.test.ts +532 -0
  89. package/src/build-api.ts +663 -0
  90. package/src/cell-render.test.ts +451 -0
  91. package/src/cell-render.ts +426 -0
  92. package/src/cell-values.ts +114 -0
  93. package/src/chart-export.test.ts +370 -0
  94. package/src/chart.coverage.test.ts +814 -0
  95. package/src/chart.ts +1352 -47
  96. package/src/clipboard.test.ts +731 -0
  97. package/src/clipboard.ts +524 -0
  98. package/src/collaboration.coverage.test.ts +220 -0
  99. package/src/columns.test.ts +702 -0
  100. package/src/columns.ts +419 -0
  101. package/src/core.ts +8 -0
  102. package/src/css.d.ts +3 -0
  103. package/src/editing.test.ts +837 -0
  104. package/src/editing.ts +513 -0
  105. package/src/editors/cell-editors.coverage.test.ts +156 -0
  106. package/src/editors/cell-editors.ts +1 -0
  107. package/src/facet-buckets.test.ts +353 -0
  108. package/src/facet-buckets.ts +67 -0
  109. package/src/features.ts +128 -0
  110. package/src/filter-operators.test.ts +174 -0
  111. package/src/filter-operators.ts +87 -0
  112. package/src/hyperformula-adapter.test.ts +256 -0
  113. package/src/hyperformula-adapter.ts +124 -0
  114. package/src/index.ts +37 -0
  115. package/src/keyboard-handlers.coverage.test.ts +560 -0
  116. package/src/keyboard-handlers.ts +353 -0
  117. package/src/keyboard.ts +97 -97
  118. package/src/menus.test.ts +620 -0
  119. package/src/menus.ts +554 -0
  120. package/src/named-views.coverage.test.ts +210 -0
  121. package/src/named-views.ts +48 -0
  122. package/src/row-resize.test.ts +369 -0
  123. package/src/row-resize.ts +171 -0
  124. package/src/scroll-sync.test.ts +330 -0
  125. package/src/scroll-sync.ts +216 -0
  126. package/src/selection.test.ts +722 -0
  127. package/src/selection.ts +545 -0
  128. package/src/server-data-source.coverage.test.ts +180 -0
  129. package/src/spreadsheet.test.ts +445 -0
  130. package/src/spreadsheet.ts +246 -0
  131. package/src/summaries.ts +204 -0
  132. package/src/sv-grid-scrollbar.ts +21 -2
  133. package/src/svgrid-wrapper.types.ts +12 -1
  134. package/src/svgrid.behavior.test.ts +22 -0
  135. package/src/svgrid.comments-autocomplete.test.ts +112 -0
  136. package/src/svgrid.context-menu.test.ts +126 -0
  137. package/src/svgrid.interaction.test.ts +30 -0
  138. package/src/svgrid.new-features.wrapper.test.ts +65 -4
  139. package/src/svgrid.wrapper.test.ts +27 -1
  140. package/src/test-setup.ts +9 -6
  141. package/src/virtualization/scroll-scaling.test.ts +148 -0
  142. package/src/virtualization/scroll-scaling.ts +121 -0
  143. package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
  144. package/src/virtualization/virtualizer.ts +26 -0
@@ -0,0 +1,702 @@
1
+ /**
2
+ * Unit tests for the column handlers produced by createColumns(ctx).
3
+ *
4
+ * createColumns is a factory: it closes over a mutable `ctx` handle (the
5
+ * controller's reactive state in production) and returns imperative event
6
+ * handlers. We drive it here with a plain fake ctx object so we can assert on
7
+ * pinning / ordering / sizing / visibility logic without mounting the grid.
8
+ */
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { createColumns } from './columns'
11
+
12
+ type AnyCtx = Record<string, any>
13
+
14
+ /** A minimally-populated ctx covering every field the handlers read/write. */
15
+ function makeCtx(overrides: Partial<AnyCtx> = {}): AnyCtx {
16
+ const allColumns = [
17
+ { id: 'a', columnDef: { width: 120 } },
18
+ { id: 'b', columnDef: {} },
19
+ { id: 'c', columnDef: { width: 200 } },
20
+ { id: 'd', columnDef: {} },
21
+ ]
22
+ const ctx: AnyCtx = {
23
+ allColumns,
24
+ toolPanelColumns: allColumns.map((c) => ({ id: c.id })),
25
+ props: {},
26
+ userColumnOrder: undefined,
27
+ pinnedOffsets: { left: {}, right: {} },
28
+ columnPinning: { left: [], right: [] },
29
+ hiddenColumns: {},
30
+ columnWidths: {},
31
+ fittedColumnWidths: {},
32
+ MIN_COLUMN_WIDTH: 40,
33
+ closeMenus: vi.fn(),
34
+ // drag state
35
+ colDragId: null,
36
+ colDropOnId: null,
37
+ colDropSide: null,
38
+ // resize state
39
+ resizingColumnId: null,
40
+ resizeStartX: 0,
41
+ resizeStartWidth: 0,
42
+ resizePendingWidth: 0,
43
+ resizeRaf: null,
44
+ measureCanvas: null,
45
+ gridRootEl: null,
46
+ grid: {
47
+ getAllColumns: () => allColumns,
48
+ getState: () => ({ grouping: [] }),
49
+ setGrouping: vi.fn(),
50
+ },
51
+ ...overrides,
52
+ }
53
+ return ctx
54
+ }
55
+
56
+ describe('cellPinStyle', () => {
57
+ it('returns a left sticky style when the column has a left offset', () => {
58
+ const ctx = makeCtx({ pinnedOffsets: { left: { a: 0, b: 120 }, right: {} } })
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;')
62
+ })
63
+
64
+ it('returns a right sticky style when the column has a right offset', () => {
65
+ const ctx = makeCtx({ pinnedOffsets: { left: {}, right: { d: 0 } } })
66
+ const h = createColumns(ctx)
67
+ expect(h.cellPinStyle('d')).toBe('position: sticky; right: 0px; z-index: 30;')
68
+ })
69
+
70
+ it('returns empty string for an unpinned column', () => {
71
+ const h = createColumns(makeCtx())
72
+ expect(h.cellPinStyle('a')).toBe('')
73
+ })
74
+
75
+ it('prefers the left offset when a column appears in both maps', () => {
76
+ const ctx = makeCtx({ pinnedOffsets: { left: { a: 5 }, right: { a: 9 } } })
77
+ const h = createColumns(ctx)
78
+ expect(h.cellPinStyle('a')).toBe('position: sticky; left: 5px; z-index: 30;')
79
+ })
80
+ })
81
+
82
+ describe('isColumnPinned', () => {
83
+ it('reports left / right / null', () => {
84
+ const ctx = makeCtx({ columnPinning: { left: ['a'], right: ['d'] } })
85
+ const h = createColumns(ctx)
86
+ expect(h.isColumnPinned('a')).toBe('left')
87
+ expect(h.isColumnPinned('d')).toBe('right')
88
+ expect(h.isColumnPinned('b')).toBeNull()
89
+ })
90
+ })
91
+
92
+ describe('getCurrentColumnOrder / emitColumnOrder / setColumnOrderInternal', () => {
93
+ it('reads the order from allColumns ids', () => {
94
+ const h = createColumns(makeCtx())
95
+ expect(h.getCurrentColumnOrder()).toEqual(['a', 'b', 'c', 'd'])
96
+ })
97
+
98
+ it('emitColumnOrder invokes the callback with the current order', () => {
99
+ const onColumnOrderChange = vi.fn()
100
+ const h = createColumns(makeCtx({ props: { onColumnOrderChange } }))
101
+ h.emitColumnOrder()
102
+ expect(onColumnOrderChange).toHaveBeenCalledWith(['a', 'b', 'c', 'd'])
103
+ })
104
+
105
+ it('emitColumnOrder is a no-op when no callback is provided', () => {
106
+ const h = createColumns(makeCtx())
107
+ expect(() => h.emitColumnOrder()).not.toThrow()
108
+ })
109
+
110
+ it('setColumnOrderInternal writes userColumnOrder and emits', () => {
111
+ const onColumnOrderChange = vi.fn()
112
+ const ctx = makeCtx({ props: { onColumnOrderChange } })
113
+ const h = createColumns(ctx)
114
+ h.setColumnOrderInternal(['d', 'c', 'b', 'a'])
115
+ expect(ctx.userColumnOrder).toEqual(['d', 'c', 'b', 'a'])
116
+ expect(onColumnOrderChange).toHaveBeenCalledWith(['a', 'b', 'c', 'd'])
117
+ })
118
+ })
119
+
120
+ describe('applyColumnDrop', () => {
121
+ let ctx: AnyCtx
122
+ let h: ReturnType<typeof createColumns>
123
+ beforeEach(() => {
124
+ ctx = makeCtx()
125
+ h = createColumns(ctx)
126
+ })
127
+
128
+ it('does nothing when dragId === targetId', () => {
129
+ h.applyColumnDrop('a', 'a', 'before')
130
+ expect(ctx.userColumnOrder).toBeUndefined()
131
+ })
132
+
133
+ it('drops before the target', () => {
134
+ h.applyColumnDrop('a', 'c', 'before')
135
+ expect(ctx.userColumnOrder).toEqual(['b', 'a', 'c', 'd'])
136
+ })
137
+
138
+ it('drops after the target', () => {
139
+ h.applyColumnDrop('a', 'c', 'after')
140
+ expect(ctx.userColumnOrder).toEqual(['b', 'c', 'a', 'd'])
141
+ })
142
+
143
+ it('moving the last column to the front', () => {
144
+ h.applyColumnDrop('d', 'a', 'before')
145
+ expect(ctx.userColumnOrder).toEqual(['d', 'a', 'b', 'c'])
146
+ })
147
+
148
+ it('does nothing when the target id is unknown', () => {
149
+ h.applyColumnDrop('a', 'zzz', 'before')
150
+ expect(ctx.userColumnOrder).toBeUndefined()
151
+ })
152
+ })
153
+
154
+ describe('column header drag handlers', () => {
155
+ function dragEvent(): any {
156
+ return {
157
+ preventDefault: vi.fn(),
158
+ dataTransfer: { setData: vi.fn(), effectAllowed: '', dropEffect: '' },
159
+ clientX: 0,
160
+ currentTarget: {
161
+ getBoundingClientRect: () => ({ left: 0, width: 100 }),
162
+ },
163
+ }
164
+ }
165
+
166
+ it('dragStart is gated off unless enableColumnReorder', () => {
167
+ const ctx = makeCtx()
168
+ const h = createColumns(ctx)
169
+ const e = dragEvent()
170
+ h.onColumnHeaderDragStart(e, 'a')
171
+ expect(ctx.colDragId).toBeNull()
172
+ expect(e.dataTransfer.setData).not.toHaveBeenCalled()
173
+ })
174
+
175
+ it('dragStart records the drag id when reorder is enabled', () => {
176
+ const ctx = makeCtx({ props: { enableColumnReorder: true } })
177
+ const h = createColumns(ctx)
178
+ const e = dragEvent()
179
+ h.onColumnHeaderDragStart(e, 'a')
180
+ expect(ctx.colDragId).toBe('a')
181
+ expect(e.dataTransfer.setData).toHaveBeenCalledWith('text/plain', 'a')
182
+ expect(e.dataTransfer.effectAllowed).toBe('move')
183
+ })
184
+
185
+ it('dragOver does nothing when no drag is active or over self', () => {
186
+ const ctx = makeCtx()
187
+ const h = createColumns(ctx)
188
+ const e = dragEvent()
189
+ h.onColumnHeaderDragOver(e, 'b')
190
+ expect(e.preventDefault).not.toHaveBeenCalled()
191
+
192
+ ctx.colDragId = 'b'
193
+ h.onColumnHeaderDragOver(e, 'b') // over the dragged column itself
194
+ expect(e.preventDefault).not.toHaveBeenCalled()
195
+ })
196
+
197
+ it('dragOver sets side=before on the left half', () => {
198
+ const ctx = makeCtx({ colDragId: 'a' })
199
+ const h = createColumns(ctx)
200
+ const e = dragEvent()
201
+ e.clientX = 10 // < left + width/2 (50)
202
+ h.onColumnHeaderDragOver(e, 'b')
203
+ expect(e.preventDefault).toHaveBeenCalled()
204
+ expect(ctx.colDropSide).toBe('before')
205
+ expect(ctx.colDropOnId).toBe('b')
206
+ })
207
+
208
+ it('dragOver sets side=after on the right half', () => {
209
+ const ctx = makeCtx({ colDragId: 'a' })
210
+ const h = createColumns(ctx)
211
+ const e = dragEvent()
212
+ e.clientX = 80 // > 50
213
+ h.onColumnHeaderDragOver(e, 'b')
214
+ expect(ctx.colDropSide).toBe('after')
215
+ })
216
+
217
+ it('dragLeave clears state only for the matching column', () => {
218
+ const ctx = makeCtx({ colDropOnId: 'b', colDropSide: 'after' })
219
+ const h = createColumns(ctx)
220
+ h.onColumnHeaderDragLeave('a') // different column - no change
221
+ expect(ctx.colDropOnId).toBe('b')
222
+ h.onColumnHeaderDragLeave('b')
223
+ expect(ctx.colDropOnId).toBeNull()
224
+ expect(ctx.colDropSide).toBeNull()
225
+ })
226
+
227
+ it('drop applies the pending reorder then clears drag state', () => {
228
+ const ctx = makeCtx({ colDragId: 'a', colDropSide: 'after' })
229
+ const h = createColumns(ctx)
230
+ const e = dragEvent()
231
+ h.onColumnHeaderDrop(e, 'c')
232
+ expect(e.preventDefault).toHaveBeenCalled()
233
+ expect(ctx.userColumnOrder).toEqual(['b', 'c', 'a', 'd'])
234
+ expect(ctx.colDragId).toBeNull()
235
+ expect(ctx.colDropOnId).toBeNull()
236
+ expect(ctx.colDropSide).toBeNull()
237
+ })
238
+
239
+ it('drop without an active drag just clears state', () => {
240
+ const ctx = makeCtx()
241
+ const h = createColumns(ctx)
242
+ const e = dragEvent()
243
+ h.onColumnHeaderDrop(e, 'c')
244
+ expect(ctx.userColumnOrder).toBeUndefined()
245
+ expect(ctx.colDragId).toBeNull()
246
+ })
247
+
248
+ it('dragEnd clears all drag state', () => {
249
+ const ctx = makeCtx({ colDragId: 'a', colDropOnId: 'b', colDropSide: 'after' })
250
+ const h = createColumns(ctx)
251
+ h.onColumnHeaderDragEnd()
252
+ expect(ctx.colDragId).toBeNull()
253
+ expect(ctx.colDropOnId).toBeNull()
254
+ expect(ctx.colDropSide).toBeNull()
255
+ })
256
+ })
257
+
258
+ describe('pinning handlers', () => {
259
+ it('pinColumnLeft appends to left, removes from right, closes menus', () => {
260
+ const ctx = makeCtx({ columnPinning: { left: ['a'], right: ['b'] } })
261
+ const h = createColumns(ctx)
262
+ h.pinColumnLeft('b')
263
+ expect(ctx.columnPinning).toEqual({ left: ['a', 'b'], right: [] })
264
+ expect(ctx.closeMenus).toHaveBeenCalled()
265
+ })
266
+
267
+ it('pinColumnLeft de-duplicates a column already pinned left', () => {
268
+ const ctx = makeCtx({ columnPinning: { left: ['a', 'b'], right: [] } })
269
+ const h = createColumns(ctx)
270
+ h.pinColumnLeft('a')
271
+ expect(ctx.columnPinning).toEqual({ left: ['b', 'a'], right: [] })
272
+ })
273
+
274
+ it('pinColumnRight prepends to right, removes from left', () => {
275
+ const ctx = makeCtx({ columnPinning: { left: ['a'], right: ['c'] } })
276
+ const h = createColumns(ctx)
277
+ h.pinColumnRight('a')
278
+ expect(ctx.columnPinning).toEqual({ left: [], right: ['a', 'c'] })
279
+ expect(ctx.closeMenus).toHaveBeenCalled()
280
+ })
281
+
282
+ it('unpinColumn removes from both sides', () => {
283
+ const ctx = makeCtx({ columnPinning: { left: ['a'], right: ['a'] } })
284
+ const h = createColumns(ctx)
285
+ h.unpinColumn('a')
286
+ expect(ctx.columnPinning).toEqual({ left: [], right: [] })
287
+ expect(ctx.closeMenus).toHaveBeenCalled()
288
+ })
289
+ })
290
+
291
+ describe('toggleColumnVisibleInPanel', () => {
292
+ it('hides a visible column', () => {
293
+ const ctx = makeCtx()
294
+ const h = createColumns(ctx)
295
+ h.toggleColumnVisibleInPanel('a')
296
+ expect(ctx.hiddenColumns).toEqual({ a: true })
297
+ })
298
+
299
+ it('shows a hidden column (removes the key)', () => {
300
+ const ctx = makeCtx({ hiddenColumns: { a: true, b: true } })
301
+ const h = createColumns(ctx)
302
+ h.toggleColumnVisibleInPanel('a')
303
+ expect(ctx.hiddenColumns).toEqual({ b: true })
304
+ })
305
+ })
306
+
307
+ describe('moveColumnInPanel', () => {
308
+ it('moves a column down (dir +1)', () => {
309
+ const ctx = makeCtx()
310
+ const h = createColumns(ctx)
311
+ h.moveColumnInPanel('a', 1)
312
+ expect(ctx.userColumnOrder).toEqual(['b', 'a', 'c', 'd'])
313
+ })
314
+
315
+ it('moves a column up (dir -1)', () => {
316
+ const ctx = makeCtx()
317
+ const h = createColumns(ctx)
318
+ h.moveColumnInPanel('c', -1)
319
+ expect(ctx.userColumnOrder).toEqual(['a', 'c', 'b', 'd'])
320
+ })
321
+
322
+ it('refuses to move the first column up (out of bounds)', () => {
323
+ const ctx = makeCtx()
324
+ const h = createColumns(ctx)
325
+ h.moveColumnInPanel('a', -1)
326
+ expect(ctx.userColumnOrder).toBeUndefined()
327
+ })
328
+
329
+ it('refuses to move the last column down (out of bounds)', () => {
330
+ const ctx = makeCtx()
331
+ const h = createColumns(ctx)
332
+ h.moveColumnInPanel('d', 1)
333
+ expect(ctx.userColumnOrder).toBeUndefined()
334
+ })
335
+
336
+ it('does nothing for an unknown column id', () => {
337
+ const ctx = makeCtx()
338
+ const h = createColumns(ctx)
339
+ h.moveColumnInPanel('zzz', 1)
340
+ expect(ctx.userColumnOrder).toBeUndefined()
341
+ })
342
+ })
343
+
344
+ describe('toggleGroupInPanel', () => {
345
+ it('adds a column to grouping when absent', () => {
346
+ const setGrouping = vi.fn()
347
+ const ctx = makeCtx()
348
+ ctx.grid.getState = () => ({ grouping: ['x'] })
349
+ ctx.grid.setGrouping = setGrouping
350
+ const h = createColumns(ctx)
351
+ h.toggleGroupInPanel('a')
352
+ expect(setGrouping).toHaveBeenCalledWith(['x', 'a'])
353
+ })
354
+
355
+ it('removes a column from grouping when present', () => {
356
+ const setGrouping = vi.fn()
357
+ const ctx = makeCtx()
358
+ ctx.grid.getState = () => ({ grouping: ['x', 'a'] })
359
+ ctx.grid.setGrouping = setGrouping
360
+ const h = createColumns(ctx)
361
+ h.toggleGroupInPanel('a')
362
+ expect(setGrouping).toHaveBeenCalledWith(['x'])
363
+ })
364
+
365
+ it('handles a missing grouping array (defaults to empty)', () => {
366
+ const setGrouping = vi.fn()
367
+ const ctx = makeCtx()
368
+ ctx.grid.getState = () => ({})
369
+ ctx.grid.setGrouping = setGrouping
370
+ const h = createColumns(ctx)
371
+ h.toggleGroupInPanel('a')
372
+ expect(setGrouping).toHaveBeenCalledWith(['a'])
373
+ })
374
+ })
375
+
376
+ describe('getColumnBaseWidth', () => {
377
+ it('returns an explicit override from columnWidths', () => {
378
+ const ctx = makeCtx({ columnWidths: { a: 333 } })
379
+ const h = createColumns(ctx)
380
+ expect(h.getColumnBaseWidth('a')).toBe(333)
381
+ })
382
+
383
+ it('falls back to the columnDef width', () => {
384
+ const h = createColumns(makeCtx())
385
+ expect(h.getColumnBaseWidth('c')).toBe(200)
386
+ })
387
+
388
+ it('falls back to props.columnWidth when neither override nor def width set', () => {
389
+ const ctx = makeCtx({ props: { columnWidth: 99 } })
390
+ const h = createColumns(ctx)
391
+ expect(h.getColumnBaseWidth('b')).toBe(99)
392
+ })
393
+
394
+ it('falls back to the hard default 140 with no config', () => {
395
+ const h = createColumns(makeCtx())
396
+ expect(h.getColumnBaseWidth('b')).toBe(140)
397
+ })
398
+
399
+ it('returns the default for an unknown column id', () => {
400
+ const h = createColumns(makeCtx())
401
+ expect(h.getColumnBaseWidth('zzz')).toBe(140)
402
+ })
403
+ })
404
+
405
+ describe('getColumnWidth', () => {
406
+ it('uses the fitted width when no explicit override exists', () => {
407
+ const ctx = makeCtx({ fittedColumnWidths: { a: 77 } })
408
+ const h = createColumns(ctx)
409
+ expect(h.getColumnWidth('a')).toBe(77)
410
+ })
411
+
412
+ it('prefers an explicit override over the fitted width', () => {
413
+ const ctx = makeCtx({
414
+ fittedColumnWidths: { a: 77 },
415
+ columnWidths: { a: 300 },
416
+ })
417
+ const h = createColumns(ctx)
418
+ expect(h.getColumnWidth('a')).toBe(300)
419
+ })
420
+
421
+ it('falls through to base width when there is no fitted width', () => {
422
+ const h = createColumns(makeCtx())
423
+ expect(h.getColumnWidth('c')).toBe(200)
424
+ })
425
+ })
426
+
427
+ describe('column resize lifecycle', () => {
428
+ let rafCb: FrameRequestCallback | null
429
+ beforeEach(() => {
430
+ rafCb = null
431
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
432
+ rafCb = cb
433
+ return 1
434
+ })
435
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
436
+ })
437
+
438
+ it('startColumnResize seeds the resize state and registers listeners', () => {
439
+ const ctx = makeCtx({ columnWidths: { a: 150 } })
440
+ const h = createColumns(ctx)
441
+ const addSpy = vi.spyOn(document, 'addEventListener')
442
+ const e: any = {
443
+ stopPropagation: vi.fn(),
444
+ preventDefault: vi.fn(),
445
+ clientX: 500,
446
+ }
447
+ h.startColumnResize(e, 'a')
448
+ expect(ctx.resizingColumnId).toBe('a')
449
+ expect(ctx.resizeStartX).toBe(500)
450
+ expect(ctx.resizeStartWidth).toBe(150)
451
+ expect(addSpy).toHaveBeenCalledWith('pointermove', expect.any(Function))
452
+ addSpy.mockRestore()
453
+ })
454
+
455
+ it('onColumnResizeMove bails when no column is resizing', () => {
456
+ const ctx = makeCtx({ resizingColumnId: null })
457
+ const h = createColumns(ctx)
458
+ h.onColumnResizeMove({ clientX: 100 } as any)
459
+ expect(ctx.resizePendingWidth).toBe(0)
460
+ })
461
+
462
+ it('onColumnResizeMove clamps to MIN_COLUMN_WIDTH and commits on the frame', () => {
463
+ const ctx = makeCtx({
464
+ resizingColumnId: 'a',
465
+ resizeStartX: 100,
466
+ resizeStartWidth: 120,
467
+ })
468
+ const h = createColumns(ctx)
469
+ // moving far left should clamp at MIN_COLUMN_WIDTH (40)
470
+ h.onColumnResizeMove({ clientX: -1000 } as any)
471
+ expect(ctx.resizePendingWidth).toBe(40)
472
+ expect(rafCb).not.toBeNull()
473
+ rafCb!(0)
474
+ expect(ctx.columnWidths.a).toBe(40)
475
+ })
476
+
477
+ it('onColumnResizeMove grows the column and coalesces multiple moves into one frame', () => {
478
+ const ctx = makeCtx({
479
+ resizingColumnId: 'a',
480
+ resizeStartX: 100,
481
+ resizeStartWidth: 120,
482
+ })
483
+ const h = createColumns(ctx)
484
+ h.onColumnResizeMove({ clientX: 150 } as any) // +50 -> 170, schedules raf
485
+ const firstCb = rafCb
486
+ h.onColumnResizeMove({ clientX: 200 } as any) // +100 -> 220, raf already pending
487
+ expect(ctx.resizePendingWidth).toBe(220)
488
+ expect(rafCb).toBe(firstCb) // not rescheduled
489
+ rafCb!(0)
490
+ expect(ctx.columnWidths.a).toBe(220)
491
+ })
492
+
493
+ it('onColumnResizeMove skips the commit if the column stopped resizing mid-frame', () => {
494
+ const ctx = makeCtx({
495
+ resizingColumnId: 'a',
496
+ resizeStartX: 100,
497
+ resizeStartWidth: 120,
498
+ })
499
+ const h = createColumns(ctx)
500
+ h.onColumnResizeMove({ clientX: 150 } as any)
501
+ ctx.resizingColumnId = null // resize ended before the frame ran
502
+ rafCb!(0)
503
+ expect(ctx.columnWidths.a).toBeUndefined()
504
+ })
505
+
506
+ it('endColumnResize commits the final pending width and tears down listeners', () => {
507
+ const ctx = makeCtx({
508
+ resizingColumnId: 'a',
509
+ resizePendingWidth: 222,
510
+ resizeRaf: 9,
511
+ })
512
+ const h = createColumns(ctx)
513
+ const removeSpy = vi.spyOn(document, 'removeEventListener')
514
+ h.endColumnResize()
515
+ expect(ctx.columnWidths.a).toBe(222)
516
+ expect(ctx.resizingColumnId).toBeNull()
517
+ expect(ctx.resizeRaf).toBeNull()
518
+ expect(removeSpy).toHaveBeenCalledWith('pointermove', expect.any(Function))
519
+ removeSpy.mockRestore()
520
+ })
521
+
522
+ it('endColumnResize is safe when nothing was resizing', () => {
523
+ const ctx = makeCtx()
524
+ const h = createColumns(ctx)
525
+ expect(() => h.endColumnResize()).not.toThrow()
526
+ expect(ctx.columnWidths).toEqual({})
527
+ })
528
+ })
529
+
530
+ describe('measureText', () => {
531
+ it('returns 0 for empty text', () => {
532
+ const h = createColumns(makeCtx())
533
+ expect(h.measureText('', '16px sans-serif')).toBe(0)
534
+ })
535
+
536
+ it('measures via a lazily-created canvas and caches it', () => {
537
+ const fakeCtx2d = { font: '', measureText: vi.fn(() => ({ width: 42 })) }
538
+ const fakeCanvas = { getContext: vi.fn(() => fakeCtx2d) }
539
+ const createElement = vi
540
+ .spyOn(document, 'createElement')
541
+ .mockReturnValue(fakeCanvas as any)
542
+ const ctx = makeCtx()
543
+ const h = createColumns(ctx)
544
+ expect(h.measureText('hello', '16px sans-serif')).toBe(42)
545
+ expect(ctx.measureCanvas).toBe(fakeCanvas)
546
+ // second call reuses the cached canvas
547
+ h.measureText('again', '16px sans-serif')
548
+ expect(createElement).toHaveBeenCalledTimes(1)
549
+ createElement.mockRestore()
550
+ })
551
+
552
+ it('returns 0 when the 2d context is unavailable', () => {
553
+ const fakeCanvas = { getContext: vi.fn(() => null) }
554
+ const createElement = vi
555
+ .spyOn(document, 'createElement')
556
+ .mockReturnValue(fakeCanvas as any)
557
+ const h = createColumns(makeCtx())
558
+ expect(h.measureText('hello', '16px sans-serif')).toBe(0)
559
+ createElement.mockRestore()
560
+ })
561
+ })
562
+
563
+ describe('autosizeColumn / autosizeAllColumns', () => {
564
+ // jsdom doesn't provide the global CSS object; autosizeColumn uses
565
+ // CSS.escape to build attribute selectors. Provide a minimal stub.
566
+ beforeEach(() => {
567
+ if (typeof (globalThis as any).CSS === 'undefined') {
568
+ ;(globalThis as any).CSS = { escape: (s: string) => s }
569
+ } else if (typeof (globalThis as any).CSS.escape !== 'function') {
570
+ ;(globalThis as any).CSS.escape = (s: string) => s
571
+ }
572
+ })
573
+
574
+ function fakeRoot(opts: {
575
+ sampleCell?: any
576
+ header?: any
577
+ cells?: any[]
578
+ }): any {
579
+ const { sampleCell, header, cells = [] } = opts
580
+ return {
581
+ querySelector: (sel: string) => {
582
+ if (sel.startsWith('[data-col-id=')) return sampleCell ?? null
583
+ if (sel.startsWith('[data-svgrid-header-col=')) return header ?? null
584
+ return null
585
+ },
586
+ querySelectorAll: () => cells,
587
+ }
588
+ }
589
+
590
+ it('bails when there is no grid root element', () => {
591
+ const ctx = makeCtx({ gridRootEl: null })
592
+ const h = createColumns(ctx)
593
+ expect(() => h.autosizeColumn('a')).not.toThrow()
594
+ expect(ctx.columnWidths).toEqual({})
595
+ })
596
+
597
+ it('bails when no sample cell is found', () => {
598
+ const ctx = makeCtx({ gridRootEl: fakeRoot({ sampleCell: null }) })
599
+ const h = createColumns(ctx)
600
+ h.autosizeColumn('a')
601
+ expect(ctx.columnWidths).toEqual({})
602
+ })
603
+
604
+ it('snaps the width to the widest measured content', () => {
605
+ // header label is 100px wide, one body cell is 200px wide.
606
+ const labelEl = { textContent: 'Header' }
607
+ const header = {
608
+ querySelector: () => labelEl,
609
+ }
610
+ const sampleCell = { textContent: 'cell' }
611
+ const bodyCell = { textContent: 'a very wide cell value' }
612
+ const ctx = makeCtx({
613
+ gridRootEl: fakeRoot({ sampleCell, header, cells: [bodyCell] }),
614
+ })
615
+ const h = createColumns(ctx)
616
+
617
+ // stub getComputedStyle so .font is defined for measureText calls
618
+ const gcs = vi
619
+ .spyOn(window, 'getComputedStyle')
620
+ .mockReturnValue({ font: '16px sans-serif' } as any)
621
+
622
+ // Make measureText deterministic: header label -> 100, body cell -> 200.
623
+ const fakeCtx2d = {
624
+ font: '',
625
+ measureText: (t: string) => ({ width: t === 'Header' ? 100 : 200 }),
626
+ }
627
+ const createElement = vi
628
+ .spyOn(document, 'createElement')
629
+ .mockReturnValue({ getContext: () => fakeCtx2d } as any)
630
+
631
+ h.autosizeColumn('a')
632
+ // widest is the 200px body cell -> 200 + 24 padding
633
+ expect(ctx.columnWidths.a).toBe(224)
634
+
635
+ gcs.mockRestore()
636
+ createElement.mockRestore()
637
+ })
638
+
639
+ it('floors at MIN_COLUMN_WIDTH (+24 padding) when content measures zero', () => {
640
+ // No label element -> the header itself is used as the measure target
641
+ // (covering the `labelEl ?? header` fallback branch). Every measureText
642
+ // returns 0, so max stays at MIN_COLUMN_WIDTH and the +24 padding is added.
643
+ const header = { querySelector: () => null }
644
+ const sampleCell = { textContent: '' }
645
+ const ctx = makeCtx({
646
+ MIN_COLUMN_WIDTH: 80,
647
+ gridRootEl: fakeRoot({ sampleCell, header, cells: [] }),
648
+ })
649
+ const h = createColumns(ctx)
650
+ const gcs = vi
651
+ .spyOn(window, 'getComputedStyle')
652
+ .mockReturnValue({ font: '16px sans-serif' } as any)
653
+ const createElement = vi.spyOn(document, 'createElement').mockReturnValue({
654
+ getContext: () => ({ font: '', measureText: () => ({ width: 0 }) }),
655
+ } as any)
656
+
657
+ h.autosizeColumn('a')
658
+ // max = MIN(80); width = max(80, ceil(80) + 24) = 104
659
+ expect(ctx.columnWidths.a).toBe(104)
660
+
661
+ gcs.mockRestore()
662
+ createElement.mockRestore()
663
+ })
664
+
665
+ it('autosizeAllColumns iterates every column and writes each width', () => {
666
+ // Every querySelector returns a cell, every measured width is 50.
667
+ const cell = { textContent: 'cell' }
668
+ const header = { querySelector: () => ({ textContent: 'H' }) }
669
+ const ctx = makeCtx({
670
+ gridRootEl: fakeRoot({ sampleCell: cell, header, cells: [cell] }),
671
+ })
672
+ const h = createColumns(ctx)
673
+ const gcs = vi
674
+ .spyOn(window, 'getComputedStyle')
675
+ .mockReturnValue({ font: '16px sans-serif' } as any)
676
+ const createElement = vi.spyOn(document, 'createElement').mockReturnValue({
677
+ getContext: () => ({ font: '', measureText: () => ({ width: 50 }) }),
678
+ } as any)
679
+
680
+ h.autosizeAllColumns()
681
+ // a width was computed and stored for every column id
682
+ expect(Object.keys(ctx.columnWidths).sort()).toEqual(['a', 'b', 'c', 'd'])
683
+
684
+ gcs.mockRestore()
685
+ createElement.mockRestore()
686
+ })
687
+ })
688
+
689
+ describe('resetColumns', () => {
690
+ it('clears widths, hidden columns and pinning', () => {
691
+ const ctx = makeCtx({
692
+ columnWidths: { a: 10 },
693
+ hiddenColumns: { b: true },
694
+ columnPinning: { left: ['a'], right: ['d'] },
695
+ })
696
+ const h = createColumns(ctx)
697
+ h.resetColumns()
698
+ expect(ctx.columnWidths).toEqual({})
699
+ expect(ctx.hiddenColumns).toEqual({})
700
+ expect(ctx.columnPinning).toEqual({ left: [], right: [] })
701
+ })
702
+ })