@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.
- package/CHANGELOG.md +62 -0
- package/README.md +22 -0
- package/dist/GridMenus.svelte +17 -12
- package/dist/SvGrid.controller.svelte.d.ts +14 -8
- package/dist/SvGrid.controller.svelte.js +214 -74
- package/dist/SvGrid.css +1 -1
- package/dist/SvGrid.svelte +112 -57
- package/dist/SvGrid.types.d.ts +191 -1
- package/dist/ai.d.ts +28 -0
- package/dist/ai.js +6 -0
- package/dist/cdn/{GridMenus-B0F9iBrG.js → GridMenus-BuoBPqxx.js} +137 -132
- package/dist/cdn/GridMenus-n4llxoOI.js +494 -0
- package/dist/cdn/column-resize-DsfNXMom.js +102 -0
- package/dist/cdn/row-resize-BRcimkUT.js +95 -0
- package/dist/cdn/{src-Cd0tearp.js → src-C9Hihx1W.js} +3497 -3487
- package/dist/cdn/{src-B1TdiyS8.js → src-D1lXwq1l.js} +8258 -8248
- package/dist/cdn/svgrid.js +10 -8
- package/dist/cdn/svgrid.svelte-external.js +10 -8
- package/dist/cdn/validate-_CDJzgIo.js +75 -0
- package/dist/cell-formatting.d.ts +2 -0
- package/dist/cell-formatting.js +2 -0
- package/dist/chart-export.d.ts +1 -0
- package/dist/chart.d.ts +31 -5
- package/dist/chart.js +9 -3
- package/dist/column-groups.js +1 -1
- package/dist/column-resize.d.ts +46 -0
- package/dist/column-resize.js +205 -0
- package/dist/columns.d.ts +0 -3
- package/dist/columns.js +0 -57
- package/dist/core.d.ts +216 -4
- package/dist/core.js +532 -119
- package/dist/createTree.svelte.d.ts +3 -0
- package/dist/createTree.svelte.js +1 -0
- package/dist/datetime/date-core.d.ts +2 -0
- package/dist/datetime/date-restrict.d.ts +1 -0
- package/dist/datetime/timezone.d.ts +1 -0
- package/dist/dock-manager-model.d.ts +3 -0
- package/dist/dock-manager-model.js +1 -0
- package/dist/dock-model.d.ts +6 -0
- package/dist/dock-model.js +3 -0
- package/dist/editor-contract.d.ts +1 -0
- package/dist/filtering/excel-filters.js +28 -0
- package/dist/group-display.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +6 -0
- package/dist/list-option.d.ts +1 -0
- package/dist/menus.js +1 -1
- package/dist/positioning.d.ts +2 -0
- package/dist/row-resize.d.ts +11 -0
- package/dist/row-resize.js +7 -1
- package/dist/scheduler-ical.d.ts +1 -0
- package/dist/scheduler-model.d.ts +1 -0
- package/dist/selection.js +9 -0
- package/dist/spreadsheet.d.ts +1 -1
- package/dist/spreadsheet.js +1 -1
- package/dist/summaries.js +22 -0
- package/dist/svgrid-wrapper.types.d.ts +5 -0
- package/dist/toast-store.svelte.d.ts +4 -0
- package/dist/validate.d.ts +50 -0
- package/dist/validate.js +187 -0
- package/package.json +4 -1
- package/src/GridMenus.svelte +17 -12
- package/src/SvGrid.controller.svelte.ts +223 -76
- package/src/SvGrid.css +1 -1
- package/src/SvGrid.svelte +112 -57
- package/src/SvGrid.types.ts +191 -1
- package/src/ai.ts +28 -0
- package/src/cell-formatting.ts +2 -0
- package/src/chart-export.ts +1 -0
- package/src/chart.ts +31 -5
- package/src/column-groups.ts +1 -1
- package/src/column-resize.test.ts +381 -0
- package/src/column-resize.ts +227 -0
- package/src/columns.test.ts +0 -103
- package/src/columns.ts +0 -58
- package/src/core.aggregate.test.ts +134 -0
- package/src/core.filter.test.ts +156 -0
- package/src/core.grouping.test.ts +146 -0
- package/src/core.row-shape.test.ts +119 -0
- package/src/core.rowmodel-cache.test.ts +121 -0
- package/src/core.sort.test.ts +293 -0
- package/src/core.ts +723 -119
- package/src/createTree.svelte.ts +3 -0
- package/src/datetime/date-core.ts +2 -0
- package/src/datetime/date-restrict.ts +1 -0
- package/src/datetime/timezone.ts +1 -0
- package/src/dock-manager-model.ts +3 -0
- package/src/dock-model.ts +6 -0
- package/src/editor-contract.ts +1 -0
- package/src/filtering/excel-filters.ts +30 -0
- package/src/filtering/normalize-fast-path.test.ts +104 -0
- package/src/group-display.ts +1 -1
- package/src/index.ts +12 -1
- package/src/list-option.ts +1 -0
- package/src/menus.ts +1 -1
- package/src/positioning.ts +2 -0
- package/src/resize-props.test.ts +361 -0
- package/src/row-resize.test.ts +31 -0
- package/src/row-resize.ts +21 -3
- package/src/scheduler-ical.ts +1 -0
- package/src/scheduler-model.ts +1 -0
- package/src/selection.ts +9 -0
- package/src/spreadsheet.ts +1 -1
- package/src/summaries.ts +21 -0
- package/src/svgrid-wrapper.types.ts +5 -0
- package/src/svgrid.summaries.test.ts +217 -0
- package/src/toast-store.svelte.ts +4 -0
- package/src/validate.test.ts +207 -0
- package/src/validate.ts +269 -0
- 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
|
+
}
|