@svgrid/grid 2.6.21 → 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 +13 -8
- package/dist/SvGrid.controller.svelte.js +150 -72
- package/dist/SvGrid.css +1 -1
- package/dist/SvGrid.svelte +110 -56
- package/dist/SvGrid.types.d.ts +41 -1
- package/dist/cdn/{GridMenus-BfTAKn84.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-BYq-qyrp.js → src-C9Hihx1W.js} +3456 -3459
- package/dist/cdn/{src-DBel9wRZ.js → src-D1lXwq1l.js} +8283 -8286
- package/dist/cdn/svgrid.js +10 -8
- package/dist/cdn/svgrid.svelte-external.js +10 -8
- 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 +19 -4
- package/dist/core.js +460 -119
- 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/menus.js +1 -1
- package/dist/row-resize.d.ts +11 -0
- package/dist/row-resize.js +7 -1
- package/dist/selection.js +9 -0
- package/dist/spreadsheet.d.ts +1 -1
- package/dist/spreadsheet.js +1 -1
- package/package.json +1 -1
- package/src/GridMenus.svelte +17 -12
- package/src/SvGrid.controller.svelte.ts +155 -74
- package/src/SvGrid.css +1 -1
- package/src/SvGrid.svelte +110 -56
- package/src/SvGrid.types.ts +41 -1
- 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 +516 -119
- 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/menus.ts +1 -1
- 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/selection.ts +9 -0
- package/src/spreadsheet.ts +1 -1
- package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
|
@@ -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
|
+
}
|
package/src/columns.test.ts
CHANGED
|
@@ -424,109 +424,6 @@ describe('getColumnWidth', () => {
|
|
|
424
424
|
})
|
|
425
425
|
})
|
|
426
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
427
|
describe('measureText', () => {
|
|
531
428
|
it('returns 0 for empty text', () => {
|
|
532
429
|
const h = createColumns(makeCtx())
|
package/src/columns.ts
CHANGED
|
@@ -167,61 +167,6 @@ export function createColumns<
|
|
|
167
167
|
return getColumnBaseWidth(columnId);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
function startColumnResize(event: PointerEvent, columnId: string) {
|
|
171
|
-
event.stopPropagation();
|
|
172
|
-
event.preventDefault();
|
|
173
|
-
ctx.resizingColumnId = columnId;
|
|
174
|
-
ctx.resizeStartX = event.clientX;
|
|
175
|
-
ctx.resizeStartWidth = getColumnWidth(columnId);
|
|
176
|
-
// Capture the pointer so the drag keeps tracking when it leaves the 4px
|
|
177
|
-
// handle - without this, touch (and fast mouse) drags drop mid-resize (#59).
|
|
178
|
-
try {
|
|
179
|
-
(event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId);
|
|
180
|
-
} catch { /* capture is best-effort */ }
|
|
181
|
-
document.addEventListener("pointermove", onColumnResizeMove);
|
|
182
|
-
document.addEventListener("pointerup", endColumnResize);
|
|
183
|
-
document.addEventListener("pointercancel", endColumnResize);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function onColumnResizeMove(event: PointerEvent) {
|
|
187
|
-
if (!ctx.resizingColumnId) return;
|
|
188
|
-
// Coalesce updates onto the animation frame: pointermove can fire many
|
|
189
|
-
// times per frame, and each $state mutation triggers a full reactive
|
|
190
|
-
// recompute of the column-layout pipeline. Without rAF coalescing the
|
|
191
|
-
// grid stutters during the drag.
|
|
192
|
-
ctx.resizePendingWidth = Math.max(
|
|
193
|
-
ctx.MIN_COLUMN_WIDTH,
|
|
194
|
-
ctx.resizeStartWidth + (event.clientX - ctx.resizeStartX),
|
|
195
|
-
);
|
|
196
|
-
if (ctx.resizeRaf !== null) return;
|
|
197
|
-
ctx.resizeRaf = requestAnimationFrame(() => {
|
|
198
|
-
ctx.resizeRaf = null;
|
|
199
|
-
if (!ctx.resizingColumnId) return;
|
|
200
|
-
ctx.columnWidths = {
|
|
201
|
-
...ctx.columnWidths,
|
|
202
|
-
[ctx.resizingColumnId]: ctx.resizePendingWidth,
|
|
203
|
-
};
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function endColumnResize() {
|
|
208
|
-
if (ctx.resizeRaf !== null) {
|
|
209
|
-
cancelAnimationFrame(ctx.resizeRaf);
|
|
210
|
-
ctx.resizeRaf = null;
|
|
211
|
-
}
|
|
212
|
-
if (ctx.resizingColumnId && ctx.resizePendingWidth) {
|
|
213
|
-
// Make sure the final width is committed even if the last rAF was
|
|
214
|
-
// canceled mid-flight.
|
|
215
|
-
ctx.columnWidths = {
|
|
216
|
-
...ctx.columnWidths,
|
|
217
|
-
[ctx.resizingColumnId]: ctx.resizePendingWidth,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
ctx.resizingColumnId = null;
|
|
221
|
-
document.removeEventListener("pointermove", onColumnResizeMove);
|
|
222
|
-
document.removeEventListener("pointerup", endColumnResize);
|
|
223
|
-
document.removeEventListener("pointercancel", endColumnResize);
|
|
224
|
-
}
|
|
225
170
|
|
|
226
171
|
function measureText(text: string, font: string): number {
|
|
227
172
|
if (!text) return 0;
|
|
@@ -304,9 +249,6 @@ export function createColumns<
|
|
|
304
249
|
toggleGroupInPanel,
|
|
305
250
|
getColumnBaseWidth,
|
|
306
251
|
getColumnWidth,
|
|
307
|
-
startColumnResize,
|
|
308
|
-
onColumnResizeMove,
|
|
309
|
-
endColumnResize,
|
|
310
252
|
measureText,
|
|
311
253
|
autosizeColumn,
|
|
312
254
|
autosizeAllColumns,
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Equivalence tests for `applyGroupAggregate`.
|
|
3
|
+
*
|
|
4
|
+
* Grouping 100k rows by two columns with three aggregators took 213ms, of which
|
|
5
|
+
* ~132ms was this function: each aggregated column of each group allocated a
|
|
6
|
+
* `raw` array, then a mapped array, then a filtered one, and `min`/`max`/
|
|
7
|
+
* `extent` finished by spreading the whole group into `Math.min(...)`.
|
|
8
|
+
* Measured by varying the aggregator count - 9 aggregated columns cost 823ms
|
|
9
|
+
* against 81ms for none.
|
|
10
|
+
*
|
|
11
|
+
* The rewrite is a single pass with no intermediate arrays. `reference` below
|
|
12
|
+
* is the original, verbatim, and every case asserts the two agree - including
|
|
13
|
+
* the awkward ones: empty groups, all-non-numeric values, -0, and the
|
|
14
|
+
* float-addition ORDER, since `reduce` and a loop must accumulate identically
|
|
15
|
+
* for the sums to match bit for bit.
|
|
16
|
+
*
|
|
17
|
+
* The spread also had a latent failure mode this fixes: `Math.min(...nums)`
|
|
18
|
+
* throws RangeError once a group is large enough to exhaust the argument
|
|
19
|
+
* stack, so `min` on a big single-bucket group could crash the grid. The last
|
|
20
|
+
* test pins that.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from 'vitest'
|
|
23
|
+
import { applyGroupAggregate, type GroupAggregator, type Row } from './core'
|
|
24
|
+
|
|
25
|
+
/** Minimal Row stand-in: the function only ever calls getCellValueByColumnId. */
|
|
26
|
+
function rowsOf(values: unknown[]): Array<Row<Record<string, unknown>>> {
|
|
27
|
+
return values.map(
|
|
28
|
+
(v, i) =>
|
|
29
|
+
({
|
|
30
|
+
id: String(i),
|
|
31
|
+
index: i,
|
|
32
|
+
original: { v },
|
|
33
|
+
depth: 0,
|
|
34
|
+
getCanExpand: () => false,
|
|
35
|
+
getIsExpanded: () => false,
|
|
36
|
+
toggleExpanded: () => {},
|
|
37
|
+
getIsSelected: () => false,
|
|
38
|
+
toggleSelected: () => {},
|
|
39
|
+
getAllCells: () => [],
|
|
40
|
+
getCellValueByColumnId: () => v,
|
|
41
|
+
}) as unknown as Row<Record<string, unknown>>,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The original implementation, verbatim, as the oracle. */
|
|
46
|
+
function reference<TData extends Record<string, unknown>>(
|
|
47
|
+
agg: GroupAggregator<TData>,
|
|
48
|
+
columnId: string,
|
|
49
|
+
rows: ReadonlyArray<Row<TData>>,
|
|
50
|
+
): unknown {
|
|
51
|
+
const raw = rows.map((r) => r.getCellValueByColumnId(columnId))
|
|
52
|
+
if (typeof agg === 'function') {
|
|
53
|
+
const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
|
|
54
|
+
return agg(nums, rows.map((r) => r.original))
|
|
55
|
+
}
|
|
56
|
+
if (agg === 'count') return rows.length
|
|
57
|
+
if (agg === 'countDistinct') return new Set(raw.map((v) => String(v ?? ''))).size
|
|
58
|
+
if (agg === 'first') return raw[0]
|
|
59
|
+
const nums = raw.map((v) => Number(v)).filter((n) => Number.isFinite(n))
|
|
60
|
+
if (!nums.length) return undefined
|
|
61
|
+
switch (agg) {
|
|
62
|
+
case 'sum':
|
|
63
|
+
return nums.reduce((a, b) => a + b, 0)
|
|
64
|
+
case 'avg':
|
|
65
|
+
return nums.reduce((a, b) => a + b, 0) / nums.length
|
|
66
|
+
case 'min':
|
|
67
|
+
return Math.min(...nums)
|
|
68
|
+
case 'max':
|
|
69
|
+
return Math.max(...nums)
|
|
70
|
+
case 'extent':
|
|
71
|
+
return `${Math.min(...nums)} – ${Math.max(...nums)}`
|
|
72
|
+
default:
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const AGGS: GroupAggregator[] = ['sum', 'avg', 'min', 'max', 'count', 'countDistinct', 'extent', 'first']
|
|
78
|
+
|
|
79
|
+
const DATASETS: Array<[string, unknown[]]> = [
|
|
80
|
+
['empty', []],
|
|
81
|
+
['single value', [5]],
|
|
82
|
+
['plain integers', [3, 1, 2, 10, -4]],
|
|
83
|
+
['floats that expose accumulation order', [0.1, 0.2, 0.3, 0.7, 1e-16, 1e16]],
|
|
84
|
+
['all non-numeric', ['a', 'b', {}, [], true]],
|
|
85
|
+
['mixed numeric and not', [1, 'x', 2, null, 3, undefined, NaN, Infinity, -Infinity]],
|
|
86
|
+
['nulls and undefined only', [null, undefined]],
|
|
87
|
+
['numeric strings', ['1', '2', '10']],
|
|
88
|
+
['duplicates for countDistinct', ['a', 'a', 'b', null, undefined, '']],
|
|
89
|
+
['negative zero', [-0, 0, 1]],
|
|
90
|
+
['very large and small', [Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER, 0]],
|
|
91
|
+
['booleans', [true, false, true]],
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
describe('applyGroupAggregate - equivalence with the original', () => {
|
|
95
|
+
for (const [name, values] of DATASETS) {
|
|
96
|
+
for (const agg of AGGS) {
|
|
97
|
+
it(`${agg} over ${name}`, () => {
|
|
98
|
+
const rows = rowsOf(values)
|
|
99
|
+
const expected = reference(agg, 'v', rows)
|
|
100
|
+
const actual = applyGroupAggregate(agg, 'v', rows)
|
|
101
|
+
// Object.is so NaN matches NaN and -0 does not silently pass as 0.
|
|
102
|
+
expect(Object.is(actual, expected) || actual === expected).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('passes finite numbers and originals to a custom aggregator, unchanged', () => {
|
|
108
|
+
const seen: Array<{ nums: number[]; count: number }> = []
|
|
109
|
+
const custom: GroupAggregator = (nums, originals) => {
|
|
110
|
+
seen.push({ nums: [...nums], count: originals.length })
|
|
111
|
+
return nums.length
|
|
112
|
+
}
|
|
113
|
+
const rows = rowsOf([1, 'x', 2, null, NaN, 3])
|
|
114
|
+
const expected = reference(custom, 'v', rows)
|
|
115
|
+
const actual = applyGroupAggregate(custom, 'v', rows)
|
|
116
|
+
expect(actual).toBe(expected)
|
|
117
|
+
// Both calls saw identical arguments.
|
|
118
|
+
expect(seen[0]).toEqual(seen[1])
|
|
119
|
+
// `null` is included: Number(null) is 0, which is finite. `'x'` and NaN are
|
|
120
|
+
// not. Worth pinning - it is the kind of coercion a rewrite silently drops.
|
|
121
|
+
expect(seen[0]!.nums).toEqual([1, 2, 0, 3])
|
|
122
|
+
expect(seen[0]!.count).toBe(6)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('handles a group larger than the argument-spread limit', () => {
|
|
126
|
+
// The original did `Math.min(...nums)`, which throws RangeError once the
|
|
127
|
+
// group exceeds the engine's argument cap. A single bucket holding this
|
|
128
|
+
// many rows is ordinary on a large grid grouped by a low-cardinality field.
|
|
129
|
+
const rows = rowsOf(Array.from({ length: 200_000 }, (_, i) => i))
|
|
130
|
+
expect(applyGroupAggregate('min', 'v', rows)).toBe(0)
|
|
131
|
+
expect(applyGroupAggregate('max', 'v', rows)).toBe(199_999)
|
|
132
|
+
expect(applyGroupAggregate('extent', 'v', rows)).toBe('0 – 199999')
|
|
133
|
+
})
|
|
134
|
+
})
|