@svgrid/grid 2.6.20 → 2.6.22

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 (110) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +22 -0
  3. package/dist/GridMenus.svelte +17 -12
  4. package/dist/SvGrid.controller.svelte.d.ts +14 -8
  5. package/dist/SvGrid.controller.svelte.js +214 -74
  6. package/dist/SvGrid.css +1 -1
  7. package/dist/SvGrid.svelte +112 -57
  8. package/dist/SvGrid.types.d.ts +191 -1
  9. package/dist/ai.d.ts +28 -0
  10. package/dist/ai.js +6 -0
  11. package/dist/cdn/{GridMenus-B0F9iBrG.js → GridMenus-BuoBPqxx.js} +137 -132
  12. package/dist/cdn/GridMenus-n4llxoOI.js +494 -0
  13. package/dist/cdn/column-resize-DsfNXMom.js +102 -0
  14. package/dist/cdn/row-resize-BRcimkUT.js +95 -0
  15. package/dist/cdn/{src-Cd0tearp.js → src-C9Hihx1W.js} +3497 -3487
  16. package/dist/cdn/{src-B1TdiyS8.js → src-D1lXwq1l.js} +8258 -8248
  17. package/dist/cdn/svgrid.js +10 -8
  18. package/dist/cdn/svgrid.svelte-external.js +10 -8
  19. package/dist/cdn/validate-_CDJzgIo.js +75 -0
  20. package/dist/cell-formatting.d.ts +2 -0
  21. package/dist/cell-formatting.js +2 -0
  22. package/dist/chart-export.d.ts +1 -0
  23. package/dist/chart.d.ts +31 -5
  24. package/dist/chart.js +9 -3
  25. package/dist/column-groups.js +1 -1
  26. package/dist/column-resize.d.ts +46 -0
  27. package/dist/column-resize.js +205 -0
  28. package/dist/columns.d.ts +0 -3
  29. package/dist/columns.js +0 -57
  30. package/dist/core.d.ts +216 -4
  31. package/dist/core.js +532 -119
  32. package/dist/createTree.svelte.d.ts +3 -0
  33. package/dist/createTree.svelte.js +1 -0
  34. package/dist/datetime/date-core.d.ts +2 -0
  35. package/dist/datetime/date-restrict.d.ts +1 -0
  36. package/dist/datetime/timezone.d.ts +1 -0
  37. package/dist/dock-manager-model.d.ts +3 -0
  38. package/dist/dock-manager-model.js +1 -0
  39. package/dist/dock-model.d.ts +6 -0
  40. package/dist/dock-model.js +3 -0
  41. package/dist/editor-contract.d.ts +1 -0
  42. package/dist/filtering/excel-filters.js +28 -0
  43. package/dist/group-display.d.ts +1 -1
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.js +6 -0
  46. package/dist/list-option.d.ts +1 -0
  47. package/dist/menus.js +1 -1
  48. package/dist/positioning.d.ts +2 -0
  49. package/dist/row-resize.d.ts +11 -0
  50. package/dist/row-resize.js +7 -1
  51. package/dist/scheduler-ical.d.ts +1 -0
  52. package/dist/scheduler-model.d.ts +1 -0
  53. package/dist/selection.js +9 -0
  54. package/dist/spreadsheet.d.ts +1 -1
  55. package/dist/spreadsheet.js +1 -1
  56. package/dist/summaries.js +22 -0
  57. package/dist/svgrid-wrapper.types.d.ts +5 -0
  58. package/dist/toast-store.svelte.d.ts +4 -0
  59. package/dist/validate.d.ts +50 -0
  60. package/dist/validate.js +187 -0
  61. package/package.json +4 -1
  62. package/src/GridMenus.svelte +17 -12
  63. package/src/SvGrid.controller.svelte.ts +223 -76
  64. package/src/SvGrid.css +1 -1
  65. package/src/SvGrid.svelte +112 -57
  66. package/src/SvGrid.types.ts +191 -1
  67. package/src/ai.ts +28 -0
  68. package/src/cell-formatting.ts +2 -0
  69. package/src/chart-export.ts +1 -0
  70. package/src/chart.ts +31 -5
  71. package/src/column-groups.ts +1 -1
  72. package/src/column-resize.test.ts +381 -0
  73. package/src/column-resize.ts +227 -0
  74. package/src/columns.test.ts +0 -103
  75. package/src/columns.ts +0 -58
  76. package/src/core.aggregate.test.ts +134 -0
  77. package/src/core.filter.test.ts +156 -0
  78. package/src/core.grouping.test.ts +146 -0
  79. package/src/core.row-shape.test.ts +119 -0
  80. package/src/core.rowmodel-cache.test.ts +121 -0
  81. package/src/core.sort.test.ts +293 -0
  82. package/src/core.ts +723 -119
  83. package/src/createTree.svelte.ts +3 -0
  84. package/src/datetime/date-core.ts +2 -0
  85. package/src/datetime/date-restrict.ts +1 -0
  86. package/src/datetime/timezone.ts +1 -0
  87. package/src/dock-manager-model.ts +3 -0
  88. package/src/dock-model.ts +6 -0
  89. package/src/editor-contract.ts +1 -0
  90. package/src/filtering/excel-filters.ts +30 -0
  91. package/src/filtering/normalize-fast-path.test.ts +104 -0
  92. package/src/group-display.ts +1 -1
  93. package/src/index.ts +12 -1
  94. package/src/list-option.ts +1 -0
  95. package/src/menus.ts +1 -1
  96. package/src/positioning.ts +2 -0
  97. package/src/resize-props.test.ts +361 -0
  98. package/src/row-resize.test.ts +31 -0
  99. package/src/row-resize.ts +21 -3
  100. package/src/scheduler-ical.ts +1 -0
  101. package/src/scheduler-model.ts +1 -0
  102. package/src/selection.ts +9 -0
  103. package/src/spreadsheet.ts +1 -1
  104. package/src/summaries.ts +21 -0
  105. package/src/svgrid-wrapper.types.ts +5 -0
  106. package/src/svgrid.summaries.test.ts +217 -0
  107. package/src/toast-store.svelte.ts +4 -0
  108. package/src/validate.test.ts +207 -0
  109. package/src/validate.ts +269 -0
  110. package/dist/cdn/GridMenus-IHK_l7m6.js +0 -489
@@ -0,0 +1,381 @@
1
+ /**
2
+ * The `columnResize` action. This coverage moved here from columns.test.ts when
3
+ * the drag logic left the controller: the same behaviours (min clamp, rAF
4
+ * coalescing, final commit, teardown) are asserted, but against real DOM rather
5
+ * than a synthetic ctx, because the action now owns the handles as well as the
6
+ * drag.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
+ import { columnResize } from './column-resize'
10
+
11
+ const HANDLE = 'sv-grid-resize-handle'
12
+
13
+ /** A header row shaped like SvGrid's: `th.sv-grid-column[data-svgrid-header-col]`. */
14
+ function buildHeaders(
15
+ specs: Array<{ id?: string; classes?: string[] }>,
16
+ ): { host: HTMLElement; ths: HTMLElement[] } {
17
+ const host = document.createElement('div')
18
+ const table = document.createElement('table')
19
+ const thead = document.createElement('thead')
20
+ const tr = document.createElement('tr')
21
+ const ths: HTMLElement[] = []
22
+ specs.forEach((spec, i) => {
23
+ const th = document.createElement('th')
24
+ th.className = ['sv-grid-column', ...(spec.classes ?? [])].join(' ')
25
+ if (spec.id !== undefined) th.dataset.svgridHeaderCol = spec.id
26
+ else if (!spec.classes) th.dataset.svgridHeaderCol = `c${i}`
27
+ tr.appendChild(th)
28
+ ths.push(th)
29
+ })
30
+ thead.appendChild(tr)
31
+ table.appendChild(thead)
32
+ host.appendChild(table)
33
+ document.body.appendChild(host)
34
+ return { host, ths }
35
+ }
36
+
37
+ const handleOf = (th: HTMLElement) => th.querySelector<HTMLElement>(`.${HANDLE}`)
38
+
39
+ function pointer(type: string, init: Partial<PointerEvent> = {}): PointerEvent {
40
+ return new PointerEvent(type, {
41
+ bubbles: true,
42
+ cancelable: true,
43
+ pointerId: 1,
44
+ ...init,
45
+ } as PointerEventInit)
46
+ }
47
+
48
+ const hosts: HTMLElement[] = []
49
+ const track = (h: HTMLElement) => (hosts.push(h), h)
50
+
51
+ let rafCb: FrameRequestCallback | null = null
52
+ beforeEach(() => {
53
+ rafCb = null
54
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
55
+ rafCb = cb
56
+ return 1
57
+ })
58
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
59
+ if (!HTMLElement.prototype.setPointerCapture) {
60
+ HTMLElement.prototype.setPointerCapture = function () {}
61
+ }
62
+ if (!HTMLElement.prototype.releasePointerCapture) {
63
+ HTMLElement.prototype.releasePointerCapture = function () {}
64
+ }
65
+ })
66
+ afterEach(() => {
67
+ while (hosts.length) hosts.pop()!.remove()
68
+ document.body.innerHTML = ''
69
+ document.body.style.cursor = ''
70
+ vi.unstubAllGlobals()
71
+ })
72
+
73
+ /** Widths a test can read back, standing in for the controller. */
74
+ function widthStore(initial: Record<string, number> = {}) {
75
+ const widths = { ...initial }
76
+ return {
77
+ widths,
78
+ getWidth: (id: string) => widths[id] ?? 100,
79
+ onResize: (id: string, w: number) => {
80
+ widths[id] = w
81
+ },
82
+ }
83
+ }
84
+
85
+ describe('columnResize - handle injection', () => {
86
+ it('injects one handle per identified header', () => {
87
+ const { host, ths } = buildHeaders([{ id: 'a' }, { id: 'b' }])
88
+ track(host)
89
+ const a = columnResize(host, widthStore())
90
+ expect(handleOf(ths[0]!)).not.toBeNull()
91
+ expect(handleOf(ths[1]!)).not.toBeNull()
92
+ a.destroy()
93
+ })
94
+
95
+ it('skips spacer columns and headers with no column id', () => {
96
+ // Group headers and the virtualization spacers have nothing to resize.
97
+ const { host, ths } = buildHeaders([
98
+ { id: 'a' },
99
+ { classes: ['sv-grid-column-spacer'] },
100
+ { classes: ['sv-grid-group-header'] },
101
+ ])
102
+ track(host)
103
+ const a = columnResize(host, widthStore())
104
+ expect(handleOf(ths[0]!)).not.toBeNull()
105
+ expect(handleOf(ths[1]!)).toBeNull()
106
+ expect(handleOf(ths[2]!)).toBeNull()
107
+ a.destroy()
108
+ })
109
+
110
+ it('does not double-inject on re-decorate', () => {
111
+ const { host, ths } = buildHeaders([{ id: 'a' }])
112
+ track(host)
113
+ const a = columnResize(host, widthStore())
114
+ a.update({ ...widthStore(), disabled: false })
115
+ expect(ths[0]!.querySelectorAll(`.${HANDLE}`).length).toBe(1)
116
+ a.destroy()
117
+ })
118
+
119
+ it('injects nothing when created disabled', () => {
120
+ const { host, ths } = buildHeaders([{ id: 'a' }])
121
+ track(host)
122
+ const a = columnResize(host, { ...widthStore(), disabled: true })
123
+ expect(handleOf(ths[0]!)).toBeNull()
124
+ a.destroy()
125
+ })
126
+
127
+ it('removes handles when toggled off and restores them when back on', () => {
128
+ const { host, ths } = buildHeaders([{ id: 'a' }])
129
+ track(host)
130
+ const store = widthStore()
131
+ const a = columnResize(host, store)
132
+ expect(handleOf(ths[0]!)).not.toBeNull()
133
+ a.update({ ...store, disabled: true })
134
+ expect(handleOf(ths[0]!)).toBeNull()
135
+ a.update({ ...store, disabled: false })
136
+ expect(handleOf(ths[0]!)).not.toBeNull()
137
+ a.destroy()
138
+ })
139
+
140
+ it('gives the handle an accessible separator role and a name', () => {
141
+ const { host, ths } = buildHeaders([{ id: 'a' }])
142
+ track(host)
143
+ const a = columnResize(host, { ...widthStore({ a: 150 }), label: () => 'Name' })
144
+ const h = handleOf(ths[0]!)!
145
+ expect(h.getAttribute('role')).toBe('separator')
146
+ expect(h.getAttribute('aria-orientation')).toBe('vertical')
147
+ expect(h.getAttribute('aria-label')).toBe('Resize Name')
148
+ expect(h.tabIndex).toBe(0)
149
+ expect(h.getAttribute('aria-valuenow')).toBe('150')
150
+ a.destroy()
151
+ })
152
+ })
153
+
154
+ describe('columnResize - drag', () => {
155
+ it('clamps to the minimum width and commits on the frame', () => {
156
+ const { host, ths } = buildHeaders([{ id: 'a' }])
157
+ track(host)
158
+ const store = widthStore({ a: 120 })
159
+ const a = columnResize(host, store)
160
+ const h = handleOf(ths[0]!)!
161
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
162
+ window.dispatchEvent(pointer('pointermove', { clientX: -1000 }))
163
+ expect(rafCb).not.toBeNull()
164
+ rafCb!(0)
165
+ expect(store.widths.a).toBe(40)
166
+ a.destroy()
167
+ })
168
+
169
+ it('coalesces multiple moves into a single frame', () => {
170
+ const { host, ths } = buildHeaders([{ id: 'a' }])
171
+ track(host)
172
+ const store = widthStore({ a: 120 })
173
+ const a = columnResize(host, store)
174
+ const h = handleOf(ths[0]!)!
175
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
176
+ window.dispatchEvent(pointer('pointermove', { clientX: 150 })) // +50 -> 170
177
+ const first = rafCb
178
+ window.dispatchEvent(pointer('pointermove', { clientX: 200 })) // +100 -> 220
179
+ expect(rafCb).toBe(first) // not rescheduled
180
+ rafCb!(0)
181
+ expect(store.widths.a).toBe(220)
182
+ a.destroy()
183
+ })
184
+
185
+ it('commits the final width on pointerup even if the frame never ran', () => {
186
+ const { host, ths } = buildHeaders([{ id: 'a' }])
187
+ track(host)
188
+ const store = widthStore({ a: 120 })
189
+ const a = columnResize(host, store)
190
+ const h = handleOf(ths[0]!)!
191
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
192
+ window.dispatchEvent(pointer('pointermove', { clientX: 190 }))
193
+ window.dispatchEvent(pointer('pointerup', { clientX: 190 })) // rAF cancelled
194
+ expect(store.widths.a).toBe(210)
195
+ a.destroy()
196
+ })
197
+
198
+ it('marks the handle while dragging and clears it afterwards', () => {
199
+ const { host, ths } = buildHeaders([{ id: 'a' }])
200
+ track(host)
201
+ const a = columnResize(host, widthStore({ a: 120 }))
202
+ const h = handleOf(ths[0]!)!
203
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
204
+ expect(h.classList.contains('is-resizing')).toBe(true)
205
+ window.dispatchEvent(pointer('pointerup', { clientX: 100 }))
206
+ expect(h.classList.contains('is-resizing')).toBe(false)
207
+ a.destroy()
208
+ })
209
+
210
+ it('ignores pointerdown when disabled, and on a non-handle target', () => {
211
+ const { host, ths } = buildHeaders([{ id: 'a' }])
212
+ track(host)
213
+ const store = widthStore({ a: 120 })
214
+ const a = columnResize(host, store)
215
+ const h = handleOf(ths[0]!)!
216
+ a.update({ ...store, disabled: true })
217
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
218
+ window.dispatchEvent(pointer('pointermove', { clientX: 300 }))
219
+ expect(rafCb).toBeNull()
220
+ // A click on the header itself must not start a drag either.
221
+ ths[0]!.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
222
+ expect(rafCb).toBeNull()
223
+ a.destroy()
224
+ })
225
+ })
226
+
227
+ describe('columnResize - keyboard', () => {
228
+ it('ArrowRight grows by 10 and ArrowLeft shrinks by 10', () => {
229
+ const { host, ths } = buildHeaders([{ id: 'a' }])
230
+ track(host)
231
+ const store = widthStore({ a: 120 })
232
+ const a = columnResize(host, store)
233
+ const h = handleOf(ths[0]!)!
234
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
235
+ expect(store.widths.a).toBe(130)
236
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
237
+ expect(store.widths.a).toBe(120)
238
+ a.destroy()
239
+ })
240
+
241
+ it('Shift gives a 1px step and the minimum still holds', () => {
242
+ const { host, ths } = buildHeaders([{ id: 'a' }])
243
+ track(host)
244
+ const store = widthStore({ a: 41 })
245
+ const a = columnResize(host, store)
246
+ const h = handleOf(ths[0]!)!
247
+ h.dispatchEvent(
248
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true, bubbles: true }),
249
+ )
250
+ expect(store.widths.a).toBe(40)
251
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
252
+ expect(store.widths.a).toBe(40) // clamped, not 30
253
+ a.destroy()
254
+ })
255
+
256
+ it('ignores other keys and does nothing while disabled', () => {
257
+ const { host, ths } = buildHeaders([{ id: 'a' }])
258
+ track(host)
259
+ const store = widthStore({ a: 120 })
260
+ const a = columnResize(host, store)
261
+ const h = handleOf(ths[0]!)!
262
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
263
+ expect(store.widths.a).toBe(120)
264
+ a.update({ ...store, disabled: true })
265
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
266
+ expect(store.widths.a).toBe(120)
267
+ a.destroy()
268
+ })
269
+ })
270
+
271
+ describe('columnResize - teardown', () => {
272
+ it('destroy removes every handle and detaches the drag listeners', () => {
273
+ const { host, ths } = buildHeaders([{ id: 'a' }, { id: 'b' }])
274
+ track(host)
275
+ const store = widthStore({ a: 120 })
276
+ const a = columnResize(host, store)
277
+ const h = handleOf(ths[0]!)!
278
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
279
+ a.destroy()
280
+ expect(host.querySelectorAll(`.${HANDLE}`).length).toBe(0)
281
+ // An in-flight drag must not keep writing after unmount (#58).
282
+ window.dispatchEvent(pointer('pointermove', { clientX: 400 }))
283
+ expect(rafCb).toBeNull()
284
+ expect(store.widths.a).toBe(120)
285
+ })
286
+ })
287
+
288
+ describe('columnResize - per-column resizable', () => {
289
+ it('creates no handle for a column that opts out', () => {
290
+ const { host, ths } = buildHeaders([{ id: 'a' }, { id: 'b' }])
291
+ track(host)
292
+ const a = columnResize(host, { ...widthStore(), canResize: (id) => id !== 'b' })
293
+ expect(handleOf(ths[0]!)).not.toBeNull()
294
+ expect(handleOf(ths[1]!)).toBeNull()
295
+ a.destroy()
296
+ })
297
+
298
+ it('takes the handle away when a column stops being resizable', () => {
299
+ // A re-decorate has to remove, not merely stop adding: the handle is
300
+ // already in the DOM by then.
301
+ const { host, ths } = buildHeaders([{ id: 'a' }])
302
+ track(host)
303
+ const store = widthStore()
304
+ let allow = true
305
+ const a = columnResize(host, { ...store, canResize: () => allow })
306
+ expect(handleOf(ths[0]!)).not.toBeNull()
307
+ allow = false
308
+ a.update({ ...store, canResize: () => allow, disabled: true })
309
+ a.update({ ...store, canResize: () => allow, disabled: false })
310
+ expect(handleOf(ths[0]!)).toBeNull()
311
+ a.destroy()
312
+ })
313
+
314
+ it('refuses the drag and the arrow keys even if a handle is reached', () => {
315
+ // Defence in depth: no handle is created, so this can only happen if one
316
+ // survives a stale render. It must still not resize.
317
+ const { host, ths } = buildHeaders([{ id: 'a' }])
318
+ track(host)
319
+ const store = widthStore({ a: 120 })
320
+ let allow = true
321
+ const a = columnResize(host, { ...store, canResize: () => allow })
322
+ const h = handleOf(ths[0]!)!
323
+ allow = false
324
+ a.update({ ...store, canResize: () => allow })
325
+
326
+ h.dispatchEvent(pointer('pointerdown', { clientX: 100 }))
327
+ window.dispatchEvent(pointer('pointermove', { clientX: 300 }))
328
+ expect(rafCb).toBeNull()
329
+ h.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
330
+ expect(store.widths.a).toBe(120)
331
+ a.destroy()
332
+ })
333
+ })
334
+
335
+ describe('columnResize - double-click autosize', () => {
336
+ it('double-clicking the handle autosizes that column', () => {
337
+ const { host, ths } = buildHeaders([{ id: 'a' }])
338
+ track(host)
339
+ const onAutosize = vi.fn()
340
+ const a = columnResize(host, { ...widthStore(), onAutosize })
341
+ handleOf(ths[0]!)!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
342
+ expect(onAutosize).toHaveBeenCalledWith('a')
343
+ a.destroy()
344
+ })
345
+
346
+ it('does not autosize a column that opted out - it has no handle to hit', () => {
347
+ const { host, ths } = buildHeaders([{ id: 'a' }])
348
+ track(host)
349
+ const onAutosize = vi.fn()
350
+ const a = columnResize(host, { ...widthStore(), onAutosize, canResize: () => false })
351
+ expect(handleOf(ths[0]!)).toBeNull()
352
+ // The header itself is all that is left, and it carries no autosize.
353
+ ths[0]!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
354
+ expect(onAutosize).not.toHaveBeenCalled()
355
+ a.destroy()
356
+ })
357
+
358
+ it('does not let the double-click reach the header underneath', () => {
359
+ const { host, ths } = buildHeaders([{ id: 'a' }])
360
+ track(host)
361
+ const onHeader = vi.fn()
362
+ ths[0]!.addEventListener('dblclick', onHeader)
363
+ const a = columnResize(host, { ...widthStore(), onAutosize: vi.fn() })
364
+ handleOf(ths[0]!)!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
365
+ expect(onHeader).not.toHaveBeenCalled()
366
+ a.destroy()
367
+ })
368
+
369
+ it('does nothing on double-click while disabled', () => {
370
+ const { host, ths } = buildHeaders([{ id: 'a' }])
371
+ track(host)
372
+ const onAutosize = vi.fn()
373
+ const store = widthStore()
374
+ const a = columnResize(host, { ...store, onAutosize })
375
+ const h = handleOf(ths[0]!)!
376
+ a.update({ ...store, onAutosize, disabled: true })
377
+ h.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
378
+ expect(onAutosize).not.toHaveBeenCalled()
379
+ a.destroy()
380
+ })
381
+ })
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Column-resize Svelte action - drag a header's right edge to widen / narrow
3
+ * the column. Counterpart of `rowResize`, and built the same way for the same
4
+ * reason: `<SvGrid columnResize>` is opt-in, so the code behind it must not sit
5
+ * in the base bundle of every grid that leaves it off. The grid pulls this
6
+ * module through `import()` the first time a consumer turns the prop on.
7
+ *
8
+ * Handles are injected rather than rendered, so nothing about them compiles
9
+ * into `SvGrid.svelte`. A header cell is keyed by `data-svgrid-header-col` -
10
+ * body cells use `data-col-id`, which is a different attribute on purpose, and
11
+ * matching the wrong one finds nothing. One handle goes into every such `th`
12
+ * under `node`, and a MutationObserver keeps up with virtualized columns
13
+ * scrolling in and out.
14
+ *
15
+ * <div use:columnResize={{ getWidth, onResize }}>
16
+ * <SvGrid ... />
17
+ * </div>
18
+ *
19
+ * Visual: a 5px strip on the header's right edge with an accent centre pill,
20
+ * from SvGrid.css. Cursor is `col-resize`.
21
+ */
22
+
23
+ const HANDLE_CLASS = 'sv-grid-resize-handle'
24
+
25
+ export type ColumnResizeOptions = {
26
+ /** Current width of a column, in px. Read when a drag starts. */
27
+ getWidth: (columnId: string) => number
28
+ /** Called on every animation frame of the drag, and once at the end. */
29
+ onResize: (columnId: string, width: number) => void
30
+ /** Accessible name for the handle. Defaults to the column id. */
31
+ label?: (columnId: string) => string
32
+ /**
33
+ * Per-column opt-out, backing `ColumnDef.resizable`. Returning false means no
34
+ * handle is created for that column at all - which is what makes the drag,
35
+ * the arrow keys and the double-click autosize go away together, rather than
36
+ * each needing its own guard.
37
+ */
38
+ canResize?: (columnId: string) => boolean
39
+ /** Size the column to its content. Bound to a double-click on the handle. */
40
+ onAutosize?: (columnId: string) => void
41
+ /** Minimum width in px. Default 40. */
42
+ min?: number
43
+ /** When true, the action removes its handles and ignores events. */
44
+ disabled?: boolean
45
+ }
46
+
47
+ type ActiveDrag = {
48
+ columnId: string
49
+ startX: number
50
+ startWidth: number
51
+ handle: HTMLElement
52
+ }
53
+
54
+ export function columnResize(node: HTMLElement, opts: ColumnResizeOptions) {
55
+ let current = opts
56
+ let drag: ActiveDrag | null = null
57
+ // Coalesce onto the animation frame: pointermove fires many times per frame
58
+ // and each width write triggers a full recompute of the column-layout
59
+ // pipeline. Without this the grid stutters for the whole drag.
60
+ let raf: number | null = null
61
+ let pendingWidth = 0
62
+
63
+ const minWidth = () => current.min ?? 40
64
+
65
+ function columnIdOf(el: HTMLElement): string | null {
66
+ return el.closest<HTMLElement>('[data-svgrid-header-col]')?.dataset.svgridHeaderCol ?? null
67
+ }
68
+
69
+ function commit() {
70
+ if (!drag) return
71
+ current.onResize(drag.columnId, pendingWidth)
72
+ }
73
+
74
+ function onPointerMove(e: PointerEvent) {
75
+ if (!drag) return
76
+ pendingWidth = Math.max(minWidth(), drag.startWidth + (e.clientX - drag.startX))
77
+ if (raf !== null) return
78
+ raf = requestAnimationFrame(() => {
79
+ raf = null
80
+ commit()
81
+ })
82
+ }
83
+
84
+ function onPointerUp(e: PointerEvent) {
85
+ if (!drag) return
86
+ if (raf !== null) {
87
+ cancelAnimationFrame(raf)
88
+ raf = null
89
+ }
90
+ // Commit the final width even if the last frame was cancelled mid-flight.
91
+ pendingWidth = Math.max(minWidth(), drag.startWidth + (e.clientX - drag.startX))
92
+ commit()
93
+ drag.handle.classList.remove('is-resizing')
94
+ syncAria(drag.handle, drag.columnId)
95
+ try {
96
+ drag.handle.releasePointerCapture(e.pointerId)
97
+ } catch {
98
+ /* release is best-effort */
99
+ }
100
+ drag = null
101
+ window.removeEventListener('pointermove', onPointerMove)
102
+ window.removeEventListener('pointerup', onPointerUp)
103
+ window.removeEventListener('pointercancel', onPointerUp)
104
+ document.body.style.cursor = ''
105
+ }
106
+
107
+ function onPointerDown(e: PointerEvent) {
108
+ if (current.disabled) return
109
+ const t = e.target as HTMLElement | null
110
+ if (!t?.classList.contains(HANDLE_CLASS)) return
111
+ const columnId = columnIdOf(t)
112
+ if (!columnId || current.canResize?.(columnId) === false) return
113
+ e.preventDefault()
114
+ e.stopPropagation()
115
+ t.classList.add('is-resizing')
116
+ pendingWidth = current.getWidth(columnId)
117
+ drag = { columnId, startX: e.clientX, startWidth: pendingWidth, handle: t }
118
+ try {
119
+ t.setPointerCapture(e.pointerId)
120
+ } catch {
121
+ /* capture is best-effort */
122
+ }
123
+ document.body.style.cursor = 'col-resize'
124
+ window.addEventListener('pointermove', onPointerMove)
125
+ window.addEventListener('pointerup', onPointerUp)
126
+ window.addEventListener('pointercancel', onPointerUp)
127
+ }
128
+
129
+ /**
130
+ * Keyboard resize (#79). The handle is a focusable `role="separator"` acting
131
+ * as a splitter, so it has to answer the arrow keys. Left/Right shrink/grow,
132
+ * Shift gives a 1px step - the same contract `rowResize` uses vertically.
133
+ */
134
+ function onKeyDown(e: KeyboardEvent) {
135
+ if (current.disabled) return
136
+ const t = e.target as HTMLElement | null
137
+ if (!t?.classList.contains(HANDLE_CLASS)) return
138
+ const step = e.shiftKey ? 1 : 10
139
+ let delta = 0
140
+ if (e.key === 'ArrowLeft') delta = -step
141
+ else if (e.key === 'ArrowRight') delta = step
142
+ else return
143
+ const columnId = columnIdOf(t)
144
+ if (!columnId || current.canResize?.(columnId) === false) return
145
+ e.preventDefault()
146
+ e.stopPropagation()
147
+ const next = Math.max(minWidth(), Math.round(current.getWidth(columnId) + delta))
148
+ current.onResize(columnId, next)
149
+ syncAria(t, columnId)
150
+ }
151
+
152
+ /** A focusable separator is a widget, so ARIA wants a current value. */
153
+ function syncAria(handle: HTMLElement, columnId: string) {
154
+ const w = Math.round(current.getWidth(columnId))
155
+ handle.setAttribute('aria-valuenow', String(w))
156
+ handle.setAttribute('aria-valuemin', String(minWidth()))
157
+ handle.setAttribute('aria-valuetext', `${w} pixels`)
158
+ }
159
+
160
+ /** One handle per header cell. Skips group headers and spacer columns,
161
+ * which have no id to resize. */
162
+ function decorate() {
163
+ if (current.disabled) {
164
+ removeAll()
165
+ return
166
+ }
167
+ const heads = node.querySelectorAll<HTMLElement>('th.sv-grid-column[data-svgrid-header-col]')
168
+ for (const th of heads) {
169
+ if (th.classList.contains('sv-grid-column-spacer')) continue
170
+ const columnId = th.dataset.svgridHeaderCol
171
+ if (!columnId) continue
172
+ if (current.canResize?.(columnId) === false) {
173
+ // The column may have been resizable a moment ago - a re-decorate has
174
+ // to take the handle away, not just stop adding one.
175
+ th.querySelector(`:scope > .${HANDLE_CLASS}`)?.remove()
176
+ continue
177
+ }
178
+ if (th.querySelector(`:scope > .${HANDLE_CLASS}`)) continue
179
+ const handle = document.createElement('div')
180
+ handle.className = HANDLE_CLASS
181
+ handle.setAttribute('role', 'separator')
182
+ handle.setAttribute('aria-orientation', 'vertical')
183
+ handle.setAttribute('aria-label', `Resize ${current.label?.(columnId) ?? columnId}`)
184
+ handle.tabIndex = 0
185
+ syncAria(handle, columnId)
186
+ // Double-click the handle to size the column to its content - the
187
+ // spreadsheet gesture. Stop it there so it does not also reach the
188
+ // header, where a double-click would toggle the sort twice.
189
+ handle.addEventListener('dblclick', (ev) => {
190
+ ev.stopPropagation()
191
+ ev.preventDefault()
192
+ if (current.disabled) return
193
+ current.onAutosize?.(columnId)
194
+ syncAria(handle, columnId)
195
+ })
196
+ th.appendChild(handle)
197
+ }
198
+ }
199
+
200
+ function removeAll() {
201
+ node.querySelectorAll(`.${HANDLE_CLASS}`).forEach((el) => el.remove())
202
+ }
203
+
204
+ node.addEventListener('pointerdown', onPointerDown, { capture: true })
205
+ node.addEventListener('keydown', onKeyDown, { capture: true })
206
+ const observer = new MutationObserver(() => decorate())
207
+ observer.observe(node, { childList: true, subtree: true })
208
+ decorate()
209
+
210
+ return {
211
+ update(next: ColumnResizeOptions) {
212
+ const wasDisabled = current.disabled
213
+ current = next
214
+ if (wasDisabled !== current.disabled) decorate()
215
+ },
216
+ destroy() {
217
+ node.removeEventListener('pointerdown', onPointerDown, { capture: true })
218
+ node.removeEventListener('keydown', onKeyDown, { capture: true })
219
+ window.removeEventListener('pointermove', onPointerMove)
220
+ window.removeEventListener('pointerup', onPointerUp)
221
+ window.removeEventListener('pointercancel', onPointerUp)
222
+ if (raf !== null) cancelAnimationFrame(raf)
223
+ observer.disconnect()
224
+ removeAll()
225
+ },
226
+ }
227
+ }