@svgrid/grid 2.6.22 → 2.6.23
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/dist/GridMenus.svelte +184 -6
- package/dist/SvAutoComplete.svelte +4 -0
- package/dist/SvComboBox.svelte +5 -1
- package/dist/SvDateRangeInput.svelte +25 -3
- package/dist/SvDropDownList.svelte +4 -0
- package/dist/SvForm.svelte +8 -8
- package/dist/SvGrid.controller.svelte.d.ts +48 -1
- package/dist/SvGrid.controller.svelte.js +214 -52
- package/dist/SvGrid.css +221 -0
- package/dist/SvGrid.helpers.d.ts +55 -0
- package/dist/SvGrid.helpers.js +83 -0
- package/dist/SvGrid.svelte +145 -0
- package/dist/SvGrid.types.d.ts +105 -0
- package/dist/SvGridCellEditor.svelte +99 -17
- package/dist/SvGridSelect.svelte +6 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvListBox.svelte +8 -0
- package/dist/SvMultiSelect.svelte +6 -2
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +4 -0
- package/dist/SvRichCell.svelte +125 -0
- package/dist/SvRichCell.svelte.d.ts +15 -0
- package/dist/SvTextArea.svelte +5 -1
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/builtin-editors.d.ts +2 -2
- package/dist/builtin-editors.js +19 -3
- package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
- package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
- package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
- package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
- package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
- package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
- package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
- package/dist/cdn/date-format-BNii4zeD.js +1393 -0
- package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
- package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
- package/dist/cdn/{src-D1lXwq1l.js → src-DGo7IHug.js} +5730 -5198
- package/dist/cdn/{src-C9Hihx1W.js → src-skQN5eqh.js} +7216 -6684
- package/dist/cdn/svgrid.js +14 -12
- package/dist/cdn/svgrid.svelte-external.js +14 -12
- package/dist/cell-values.d.ts +16 -0
- package/dist/cell-values.js +28 -0
- package/dist/clipboard.d.ts +22 -0
- package/dist/clipboard.js +405 -27
- package/dist/editing.js +12 -7
- package/dist/editors/cell-editors.d.ts +11 -0
- package/dist/editors/cell-editors.js +18 -1
- package/dist/grid-messages.d.ts +15 -0
- package/dist/grid-messages.js +15 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/list-option.d.ts +6 -0
- package/dist/menus.js +40 -7
- package/dist/sanitize-html.d.ts +37 -0
- package/dist/sanitize-html.js +234 -0
- package/dist/selection-bar-view.svelte.d.ts +25 -0
- package/dist/selection-bar-view.svelte.js +13 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +129 -13
- package/package.json +1 -1
- package/src/GridMenus.svelte +184 -6
- package/src/SvAutoComplete.svelte +4 -0
- package/src/SvComboBox.svelte +5 -1
- package/src/SvDateRangeInput.svelte +25 -3
- package/src/SvDropDownList.svelte +4 -0
- package/src/SvForm.svelte +8 -8
- package/src/SvGrid.controller.svelte.ts +251 -61
- package/src/SvGrid.css +221 -0
- package/src/SvGrid.helpers.ts +87 -0
- package/src/SvGrid.svelte +145 -0
- package/src/SvGrid.types.ts +112 -0
- package/src/SvGridCellEditor.svelte +99 -17
- package/src/SvGridSelect.svelte +6 -2
- package/src/SvListBox.svelte +8 -0
- package/src/SvMultiSelect.svelte +6 -2
- package/src/SvNumberInput.svelte +4 -0
- package/src/SvRichCell.svelte +125 -0
- package/src/SvTextArea.svelte +5 -1
- package/src/SvTreeSelect.svelte +6 -2
- package/src/builtin-editors.test.ts +18 -1
- package/src/builtin-editors.ts +19 -3
- package/src/cell-values.ts +30 -0
- package/src/clipboard.test.ts +93 -20
- package/src/clipboard.ts +433 -33
- package/src/date-string-column.test.ts +87 -0
- package/src/editing.test.ts +25 -0
- package/src/editing.ts +12 -9
- package/src/editor-block-prop.test.ts +132 -0
- package/src/editors/cell-editors.ts +27 -1
- package/src/grid-messages.ts +34 -0
- package/src/index.ts +13 -0
- package/src/list-option.ts +6 -0
- package/src/menus.test.ts +43 -0
- package/src/menus.ts +37 -7
- package/src/move-cells.test.ts +850 -0
- package/src/number-editor-literal.test.ts +84 -0
- package/src/number-editor.grid.test.ts +160 -0
- package/src/sanitize-html.test.ts +182 -0
- package/src/sanitize-html.ts +235 -0
- package/src/selection-bar-view.svelte.ts +36 -0
- package/src/selection.test.ts +157 -0
- package/src/selection.ts +131 -13
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
- package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
- package/src/svgrid.menu-scroll-close.test.ts +204 -0
- package/src/svgrid.selection-bar-seam.test.ts +185 -0
- package/src/svgrid.upsell-license.test.ts +117 -0
- package/dist/cdn/GridMenus-BuoBPqxx.js +0 -493
- package/dist/cdn/GridMenus-n4llxoOI.js +0 -494
- package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
- package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
- package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
- package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection-bar-view registry - the injection seam for the bulk-action bar that
|
|
3
|
+
* floats over the grid while rows are selected. `<SvGrid selectionBar={...}>`
|
|
4
|
+
* and its config types ship in the free grid, but the BAR itself is a paid
|
|
5
|
+
* feature in `@svgrid/enterprise`. This tiny reactive holder lets enterprise
|
|
6
|
+
* register the renderer at import time; the grid looks it up and mounts it, or
|
|
7
|
+
* shows an upsell placeholder when it is absent.
|
|
8
|
+
*
|
|
9
|
+
* Same shape as `scheduler-view` and `board-view`, deliberately: three seams
|
|
10
|
+
* that behave differently would be three things to learn.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* // in @svgrid/enterprise, at module load:
|
|
14
|
+
* import { registerSelectionBarView } from '@svgrid/grid'
|
|
15
|
+
* import SvGridSelectionBar from './SvGridSelectionBar.svelte'
|
|
16
|
+
* registerSelectionBarView(SvGridSelectionBar)
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import type { Component } from 'svelte'
|
|
20
|
+
|
|
21
|
+
let renderer = $state<Component<any> | null>(null)
|
|
22
|
+
|
|
23
|
+
/** Register the component that renders the selection bar. Enterprise calls this. */
|
|
24
|
+
export function registerSelectionBarView(component: Component<any>): void {
|
|
25
|
+
renderer = component
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The registered selection-bar renderer, or null when enterprise is not installed. */
|
|
29
|
+
export function getSelectionBarView(): Component<any> | null {
|
|
30
|
+
return renderer
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a selection-bar renderer has been registered. */
|
|
34
|
+
export function hasSelectionBarView(): boolean {
|
|
35
|
+
return renderer != null
|
|
36
|
+
}
|
package/src/selection.test.ts
CHANGED
|
@@ -44,6 +44,9 @@ function makeCtx(overrides: any = {}) {
|
|
|
44
44
|
selectionRange: { anchor: null, focus: null },
|
|
45
45
|
isDraggingSelection: false,
|
|
46
46
|
fillDrag: null,
|
|
47
|
+
moveDrag: null,
|
|
48
|
+
moveGrabHover: false,
|
|
49
|
+
moveCellsEffective: false,
|
|
47
50
|
activeAtPointerDown: null,
|
|
48
51
|
editingEnabled: false,
|
|
49
52
|
editingCell: undefined,
|
|
@@ -61,6 +64,17 @@ function makeCtx(overrides: any = {}) {
|
|
|
61
64
|
onCellDoubleClick: vi.fn(),
|
|
62
65
|
onFillPointerUp: vi.fn(),
|
|
63
66
|
onFillPointerMove: vi.fn(),
|
|
67
|
+
// Range drag-and-drop. `startMoveDrag` returning false is the "the pointer
|
|
68
|
+
// was not on the border" answer, which is what every test below assumes -
|
|
69
|
+
// override it to true to exercise the grab path.
|
|
70
|
+
startMoveDrag: vi.fn(() => false),
|
|
71
|
+
onMovePointerUp: vi.fn(),
|
|
72
|
+
onMovePointerMove: vi.fn(),
|
|
73
|
+
isOnMoveGrabStrip: vi.fn(() => false),
|
|
74
|
+
// Edge auto-scroll: one rAF loop in the clipboard module, driven from here
|
|
75
|
+
// for plain drag-select. Its own behaviour is covered in move-cells.test.ts.
|
|
76
|
+
trackEdgeScroll: vi.fn(),
|
|
77
|
+
stopEdgeScroll: vi.fn(),
|
|
64
78
|
grid: {
|
|
65
79
|
setRowSelection,
|
|
66
80
|
setActiveCell,
|
|
@@ -428,6 +442,149 @@ describe('createSelection - endDragSelection / window move', () => {
|
|
|
428
442
|
sel.onWindowPointerMove({ buttons: 1 } as any)
|
|
429
443
|
expect(ctx.isDraggingSelection).toBe(true)
|
|
430
444
|
})
|
|
445
|
+
|
|
446
|
+
it('endDragSelection cancels the edge-scroll loop', () => {
|
|
447
|
+
// The loop exits on its own once no drag is live, but cancelling the
|
|
448
|
+
// pending frame here stops one more from firing after the gesture ended.
|
|
449
|
+
const ctx = makeCtx({ isDraggingSelection: true })
|
|
450
|
+
const sel = createSelection(ctx)
|
|
451
|
+
sel.endDragSelection()
|
|
452
|
+
expect(ctx.stopEdgeScroll).toHaveBeenCalled()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('feeds the edge-scroll loop while drag-selecting', () => {
|
|
456
|
+
// Drag-select had the same ceiling as the other two drags: a range could
|
|
457
|
+
// only ever cover cells that were on screen when it started.
|
|
458
|
+
const ctx = makeCtx({ isDraggingSelection: true })
|
|
459
|
+
const sel = createSelection(ctx)
|
|
460
|
+
const ev = { buttons: 1, clientX: 10, clientY: 10 } as any
|
|
461
|
+
sel.onWindowPointerMove(ev)
|
|
462
|
+
expect(ctx.trackEdgeScroll).toHaveBeenCalledWith(ev)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('does not feed the loop once the button is released', () => {
|
|
466
|
+
const ctx = makeCtx({ isDraggingSelection: true })
|
|
467
|
+
const sel = createSelection(ctx)
|
|
468
|
+
sel.onWindowPointerMove({ buttons: 0 } as any)
|
|
469
|
+
expect(ctx.trackEdgeScroll).not.toHaveBeenCalled()
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('endDragSelection commits a pending range move', () => {
|
|
473
|
+
const ctx = makeCtx({ moveDrag: { x: 1 } })
|
|
474
|
+
const sel = createSelection(ctx)
|
|
475
|
+
sel.endDragSelection()
|
|
476
|
+
expect(ctx.onMovePointerUp).toHaveBeenCalled()
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('onWindowPointerMove delegates to the range move when one is in flight', () => {
|
|
480
|
+
// A move outranks a fill: they can never both be active, and checking it
|
|
481
|
+
// first keeps the fill path from swallowing the event.
|
|
482
|
+
const ctx = makeCtx({ moveDrag: { x: 1 }, fillDrag: { x: 1 } })
|
|
483
|
+
const sel = createSelection(ctx)
|
|
484
|
+
const ev = { buttons: 1 } as any
|
|
485
|
+
sel.onWindowPointerMove(ev)
|
|
486
|
+
expect(ctx.onMovePointerMove).toHaveBeenCalledWith(ev)
|
|
487
|
+
expect(ctx.onFillPointerMove).not.toHaveBeenCalled()
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
describe('createSelection - range move grab', () => {
|
|
492
|
+
/** A pointer event over a td that reports itself from `closest()`. */
|
|
493
|
+
function overCell() {
|
|
494
|
+
const cell: any = { dataset: { svgridRow: '1', svgridCol: '1' } }
|
|
495
|
+
cell.closest = () => cell
|
|
496
|
+
return { button: 0, clientX: 10, clientY: 10, target: cell, pointerType: 'mouse' } as any
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
it('hands the pointerdown to the move grab before touching the selection', () => {
|
|
500
|
+
const ctx = makeCtx({
|
|
501
|
+
allRows: [{ id: 'r0' }, { id: 'r1' }],
|
|
502
|
+
allColumns: [{ id: 'a' }, { id: 'b' }],
|
|
503
|
+
startMoveDrag: vi.fn(() => true),
|
|
504
|
+
})
|
|
505
|
+
const sel = createSelection(ctx)
|
|
506
|
+
sel.onCellPointerDown(1, 1, overCell())
|
|
507
|
+
expect(ctx.startMoveDrag).toHaveBeenCalled()
|
|
508
|
+
// The selection the user grabbed is still intact, and no drag-select began.
|
|
509
|
+
expect(ctx.selectionRange).toEqual({ anchor: null, focus: null })
|
|
510
|
+
expect(ctx.isDraggingSelection).toBe(false)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('swallows the click that follows a grab, so the range survives', () => {
|
|
514
|
+
const onCellClick = vi.fn()
|
|
515
|
+
const ctx = makeCtx({
|
|
516
|
+
allRows: [makeDataRow('r0', { id: 0 }), makeDataRow('r1', { id: 1 })],
|
|
517
|
+
allColumns: [makeColumn('c0', { editorType: 'text' }), makeColumn('c1', { editorType: 'text' })],
|
|
518
|
+
gridRootEl: { focus: vi.fn() },
|
|
519
|
+
props: { onCellClick },
|
|
520
|
+
startMoveDrag: vi.fn(() => true),
|
|
521
|
+
})
|
|
522
|
+
const sel = createSelection(ctx)
|
|
523
|
+
sel.onCellPointerDown(1, 1, overCell())
|
|
524
|
+
sel.onCellClick(1, 1)
|
|
525
|
+
expect(onCellClick).not.toHaveBeenCalled()
|
|
526
|
+
// Only the one click is swallowed - the next behaves normally.
|
|
527
|
+
sel.onCellClick(1, 1)
|
|
528
|
+
expect(onCellClick).toHaveBeenCalledTimes(1)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('falls through to normal selection when the pointer misses the border', () => {
|
|
532
|
+
const ctx = makeCtx({
|
|
533
|
+
allRows: [{ id: 'r0' }, { id: 'r1' }],
|
|
534
|
+
allColumns: [{ id: 'a' }, { id: 'b' }],
|
|
535
|
+
})
|
|
536
|
+
const sel = createSelection(ctx)
|
|
537
|
+
sel.onCellPointerDown(1, 1, overCell())
|
|
538
|
+
expect(ctx.selectionRange.anchor).toEqual({ rowIndex: 1, colIndex: 1 })
|
|
539
|
+
expect(ctx.isDraggingSelection).toBe(true)
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('updateMoveGrabHover leaves the flag alone while moveCells is off', () => {
|
|
543
|
+
const ctx = makeCtx({ moveCellsEffective: false })
|
|
544
|
+
const sel = createSelection(ctx)
|
|
545
|
+
sel.onWindowPointerMove(overCell())
|
|
546
|
+
expect(ctx.isOnMoveGrabStrip).not.toHaveBeenCalled()
|
|
547
|
+
expect(ctx.moveGrabHover).toBe(false)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('raises the hover flag on the border and drops it off it', () => {
|
|
551
|
+
const ctx = makeCtx({
|
|
552
|
+
moveCellsEffective: true,
|
|
553
|
+
// Without an anchored range the hover check bails before touching the
|
|
554
|
+
// DOM - correctly, since there is then no border to grab.
|
|
555
|
+
selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 2, colIndex: 2 } },
|
|
556
|
+
isOnMoveGrabStrip: vi.fn(() => true),
|
|
557
|
+
})
|
|
558
|
+
const sel = createSelection(ctx)
|
|
559
|
+
sel.onWindowPointerMove(overCell())
|
|
560
|
+
expect(ctx.moveGrabHover).toBe(true)
|
|
561
|
+
ctx.isOnMoveGrabStrip = vi.fn(() => false)
|
|
562
|
+
sel.onWindowPointerMove(overCell())
|
|
563
|
+
expect(ctx.moveGrabHover).toBe(false)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('never walks the DOM while nothing is selected', () => {
|
|
567
|
+
// This runs on every window pointermove, so the guard order matters:
|
|
568
|
+
// two property reads must come before any closest() call.
|
|
569
|
+
const closest = vi.fn(() => null)
|
|
570
|
+
const ctx = makeCtx({ moveCellsEffective: true, isOnMoveGrabStrip: vi.fn(() => true) })
|
|
571
|
+
const sel = createSelection(ctx)
|
|
572
|
+
sel.onWindowPointerMove({ target: { closest }, buttons: 0 } as any)
|
|
573
|
+
expect(closest).not.toHaveBeenCalled()
|
|
574
|
+
expect(ctx.isOnMoveGrabStrip).not.toHaveBeenCalled()
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('drops the hover flag when the pointer leaves the grid entirely', () => {
|
|
578
|
+
const ctx = makeCtx({
|
|
579
|
+
moveCellsEffective: true,
|
|
580
|
+
moveGrabHover: true,
|
|
581
|
+
selectionRange: { anchor: { rowIndex: 1, colIndex: 1 }, focus: { rowIndex: 2, colIndex: 2 } },
|
|
582
|
+
isOnMoveGrabStrip: vi.fn(() => true),
|
|
583
|
+
})
|
|
584
|
+
const sel = createSelection(ctx)
|
|
585
|
+
sel.onWindowPointerMove({ target: { closest: () => null }, buttons: 0 } as any)
|
|
586
|
+
expect(ctx.moveGrabHover).toBe(false)
|
|
587
|
+
})
|
|
431
588
|
})
|
|
432
589
|
|
|
433
590
|
describe('createSelection - onCellClick', () => {
|
package/src/selection.ts
CHANGED
|
@@ -242,15 +242,22 @@ export function createSelection<
|
|
|
242
242
|
* that contains the cell are returned (active range checked last so its
|
|
243
243
|
* outline wins on overlap). Returns null when the cell is in no range.
|
|
244
244
|
*/
|
|
245
|
+
/** Could `getSelectionRects()` return anything? An anchored active range or
|
|
246
|
+
* a committed one is the only way a rect can exist, and both are plain
|
|
247
|
+
* property reads - so this is the cheap guard in front of anything that
|
|
248
|
+
* would otherwise allocate a rect array to discover there is nothing. */
|
|
249
|
+
function hasAnySelectionRect() {
|
|
250
|
+
const active = ctx.selectionRange as SelectionRange | undefined;
|
|
251
|
+
const committed = ctx.selectionRanges as SelectionRange[] | undefined;
|
|
252
|
+
return Boolean(committed?.length) || Boolean(active?.anchor && active?.focus);
|
|
253
|
+
}
|
|
254
|
+
|
|
245
255
|
function getCellRangeEdges(rowIndex: number, colIndex: number) {
|
|
246
256
|
// Bail before `getSelectionRects()`, which allocates an array and resolves
|
|
247
257
|
// every range. This runs once per rendered CELL on every render - about 250
|
|
248
258
|
// times per frame on a default grid - and with nothing selected it did all
|
|
249
|
-
// that work to return null every time.
|
|
250
|
-
|
|
251
|
-
const active = ctx.selectionRange as SelectionRange | undefined;
|
|
252
|
-
const committed = ctx.selectionRanges as SelectionRange[] | undefined;
|
|
253
|
-
if (!committed?.length && !(active?.anchor && active?.focus)) return null;
|
|
259
|
+
// that work to return null every time.
|
|
260
|
+
if (!hasAnySelectionRect()) return null;
|
|
254
261
|
|
|
255
262
|
for (const rect of getSelectionRects()) {
|
|
256
263
|
if (
|
|
@@ -271,10 +278,40 @@ export function createSelection<
|
|
|
271
278
|
return null;
|
|
272
279
|
}
|
|
273
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The rectangle a drag is currently promising, or null when no drag is in
|
|
283
|
+
* flight. A fill grows the source rectangle out to the pointer; a move
|
|
284
|
+
* detaches an identically-sized rectangle and drops it at the offset the
|
|
285
|
+
* pointer has travelled. Both feed the same preview tint and marquee, so a
|
|
286
|
+
* range move looks like the fill handle users already know.
|
|
287
|
+
*/
|
|
288
|
+
function dragPreviewRect() {
|
|
289
|
+
const m = ctx.moveDrag;
|
|
290
|
+
if (m) return ctx.moveDestRect(m);
|
|
291
|
+
const d = ctx.fillDrag;
|
|
292
|
+
if (!d) return null;
|
|
293
|
+
return {
|
|
294
|
+
minRow: Math.min(d.sourceMinRow, d.targetRow),
|
|
295
|
+
maxRow: Math.max(d.sourceMaxRow, d.targetRow),
|
|
296
|
+
minCol: Math.min(d.sourceMinCol, d.targetCol),
|
|
297
|
+
maxCol: Math.max(d.sourceMaxCol, d.targetCol),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
274
301
|
/** Returns true when the given cell is inside the fill-drag preview
|
|
275
302
|
* range BUT outside the original source range. Used to paint a
|
|
276
303
|
* dashed-outline preview while the user is dragging the handle. */
|
|
277
304
|
function isInFillPreview(rowIndex: number, colIndex: number) {
|
|
305
|
+
if (ctx.moveDrag) {
|
|
306
|
+
const rect = dragPreviewRect();
|
|
307
|
+
if (!rect) return false;
|
|
308
|
+
return (
|
|
309
|
+
rowIndex >= rect.minRow &&
|
|
310
|
+
rowIndex <= rect.maxRow &&
|
|
311
|
+
colIndex >= rect.minCol &&
|
|
312
|
+
colIndex <= rect.maxCol
|
|
313
|
+
);
|
|
314
|
+
}
|
|
278
315
|
const d = ctx.fillDrag;
|
|
279
316
|
if (!d) return false;
|
|
280
317
|
const minR = Math.min(d.sourceMinRow, d.targetRow);
|
|
@@ -303,12 +340,12 @@ export function createSelection<
|
|
|
303
340
|
* border. Returns null when the cell isn't on the rectangle's boundary.
|
|
304
341
|
*/
|
|
305
342
|
function fillMarqueeEdges(rowIndex: number, colIndex: number) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
const maxC =
|
|
343
|
+
// Cheap bail before the shared rect helper, which allocates: this runs
|
|
344
|
+
// once per rendered cell and no drag is in flight almost all the time.
|
|
345
|
+
if (!ctx.fillDrag && !ctx.moveDrag) return null;
|
|
346
|
+
const rect = dragPreviewRect();
|
|
347
|
+
if (!rect) return null;
|
|
348
|
+
const { minRow: minR, maxRow: maxR, minCol: minC, maxCol: maxC } = rect;
|
|
312
349
|
if (rowIndex < minR || rowIndex > maxR || colIndex < minC || colIndex > maxC) {
|
|
313
350
|
return null;
|
|
314
351
|
}
|
|
@@ -335,6 +372,18 @@ export function createSelection<
|
|
|
335
372
|
const row = ctx.allRows[rowIndex];
|
|
336
373
|
const column = ctx.allColumns[colIndex];
|
|
337
374
|
if (!row || !column || isGroupRow(row)) return;
|
|
375
|
+
// A pointerdown on the outer few pixels of a selection border grabs the
|
|
376
|
+
// range to move it. Checked before anything else, because everything below
|
|
377
|
+
// would first collapse the selection this drag is about to relocate.
|
|
378
|
+
// Returns false unless the pointer really was on the strip, so a click
|
|
379
|
+
// anywhere further inside falls straight through to the old behaviour.
|
|
380
|
+
if (ctx.startMoveDrag(event, rowIndex, colIndex)) {
|
|
381
|
+
// A grab that never moves still ends in a `click` on the cell under the
|
|
382
|
+
// border. Left alone that click would collapse the range the user was
|
|
383
|
+
// clearly reaching for, and on a checkbox column it would flip the value.
|
|
384
|
+
moveGrabSwallowsClick = true;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
338
387
|
// NOTE: we no longer bail for checkbox columns. Bailing here meant a
|
|
339
388
|
// range drag could never START on a checkbox cell. A plain click still
|
|
340
389
|
// toggles the checkbox via onCellClick (a same-cell click), while a
|
|
@@ -369,27 +418,95 @@ export function createSelection<
|
|
|
369
418
|
setActiveCell(rowIndex, colIndex);
|
|
370
419
|
}
|
|
371
420
|
|
|
372
|
-
|
|
421
|
+
/** `event` is the window `pointerup` when this comes from the real gesture,
|
|
422
|
+
* and absent when `onWindowPointerMove` calls it to recover from a release
|
|
423
|
+
* it never saw. A range move reads its copy modifier from it, so holding
|
|
424
|
+
* Ctrl between the last movement and the release still copies. */
|
|
425
|
+
function endDragSelection(event?: PointerEvent) {
|
|
373
426
|
ctx.isDraggingSelection = false;
|
|
427
|
+
// Every drag shares one edge-scroll loop, and it exits on its own once no
|
|
428
|
+
// drag is live - but stopping it here cancels the pending frame rather
|
|
429
|
+
// than letting one more fire after the gesture is over.
|
|
430
|
+
ctx.stopEdgeScroll();
|
|
374
431
|
// Also commit a fill-handle drag if one was in progress - the user
|
|
375
432
|
// released the mouse, time to apply the pattern.
|
|
376
433
|
if (ctx.fillDrag) ctx.onFillPointerUp();
|
|
434
|
+
if (ctx.moveDrag) ctx.onMovePointerUp(event);
|
|
377
435
|
}
|
|
378
436
|
|
|
379
437
|
function onWindowPointerMove(event: PointerEvent) {
|
|
438
|
+
if (ctx.moveDrag) {
|
|
439
|
+
ctx.onMovePointerMove(event);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
380
442
|
if (ctx.fillDrag) {
|
|
381
443
|
ctx.onFillPointerMove(event);
|
|
382
444
|
return;
|
|
383
445
|
}
|
|
384
|
-
if (!ctx.isDraggingSelection)
|
|
446
|
+
if (!ctx.isDraggingSelection) {
|
|
447
|
+
updateMoveGrabHover(event);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
385
450
|
// Safety: if no mouse button is held the drag is over (we may have
|
|
386
451
|
// missed pointerup because the user lifted outside the window).
|
|
387
452
|
if (event.buttons === 0) {
|
|
388
453
|
endDragSelection();
|
|
454
|
+
return;
|
|
389
455
|
}
|
|
456
|
+
// Drag-select past the viewport edge scrolls the grid, same as the fill
|
|
457
|
+
// and move drags. Without it a range can only ever cover what was already
|
|
458
|
+
// on screen when the drag started. `onCellPointerEnter` keeps extending
|
|
459
|
+
// the range for cells the pointer actually crosses; this covers the case
|
|
460
|
+
// where the pointer has stopped at the edge and the CELLS are moving.
|
|
461
|
+
ctx.trackEdgeScroll(event);
|
|
390
462
|
}
|
|
391
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Switch the grid to a move cursor while the pointer sits on the grab strip
|
|
466
|
+
* of a selection border - without it the feature is invisible, since the
|
|
467
|
+
* border is painted, not a hit target.
|
|
468
|
+
*
|
|
469
|
+
* This runs on every window pointermove, so the order of the guards is the
|
|
470
|
+
* point: `moveCellsEffective` and the already-hovering flag are plain
|
|
471
|
+
* property reads, and `isOnMoveGrabStrip` bails on `getCellRangeEdges`
|
|
472
|
+
* before measuring anything when no range is selected.
|
|
473
|
+
*/
|
|
474
|
+
function updateMoveGrabHover(event: PointerEvent) {
|
|
475
|
+
const wasHovering = ctx.moveGrabHover;
|
|
476
|
+
// Two property reads before anything touches the DOM. `closest()` walks
|
|
477
|
+
// ancestors on EVERY pointer move over the page, so it has to sit behind
|
|
478
|
+
// both "the feature is on" and "there is a range to grab".
|
|
479
|
+
if (!ctx.moveCellsEffective || !hasAnySelectionRect()) {
|
|
480
|
+
if (wasHovering) ctx.moveGrabHover = false;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const target = event.target as HTMLElement | null;
|
|
484
|
+
const cell = target?.closest?.(
|
|
485
|
+
"td[data-svgrid-row][data-svgrid-col]",
|
|
486
|
+
) as HTMLElement | null;
|
|
487
|
+
let hovering = false;
|
|
488
|
+
if (cell) {
|
|
489
|
+
const r = Number(cell.dataset.svgridRow);
|
|
490
|
+
const c = Number(cell.dataset.svgridCol);
|
|
491
|
+
if (Number.isFinite(r) && Number.isFinite(c)) {
|
|
492
|
+
hovering = ctx.isOnMoveGrabStrip(cell, r, c, event.clientX, event.clientY);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (hovering !== wasHovering) ctx.moveGrabHover = hovering;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Set when a pointerdown started a range move, so the `click` that
|
|
499
|
+
* follows a zero-distance grab is discarded instead of collapsing the
|
|
500
|
+
* selection it grabbed. One gesture, one flag - cleared on the next click
|
|
501
|
+
* whether or not the grab turned into a real drag. */
|
|
502
|
+
let moveGrabSwallowsClick = false;
|
|
503
|
+
|
|
392
504
|
function onCellClick(rowIndex: number, colIndex: number) {
|
|
505
|
+
if (moveGrabSwallowsClick) {
|
|
506
|
+
moveGrabSwallowsClick = false;
|
|
507
|
+
ctx.gridRootEl?.focus({ preventScroll: true });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
393
510
|
const row = ctx.allRows[rowIndex];
|
|
394
511
|
const column = ctx.allColumns[colIndex];
|
|
395
512
|
if (!row || !column) return;
|
|
@@ -489,6 +606,7 @@ export function createSelection<
|
|
|
489
606
|
fillMarqueeEdges,
|
|
490
607
|
getSelectionRects,
|
|
491
608
|
isInFillPreview,
|
|
609
|
+
dragPreviewRect,
|
|
492
610
|
findColumnById,
|
|
493
611
|
onCellPointerDown,
|
|
494
612
|
onCellPointerEnter,
|
|
@@ -79,8 +79,10 @@ function mountGrid(data: Row[]) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/** Open the funnel popover on the first column and return the menu element. */
|
|
82
|
-
async function openFilterMenu(target: HTMLElement) {
|
|
83
|
-
const btn = target.
|
|
82
|
+
async function openFilterMenu(target: HTMLElement, columnIndex = 0) {
|
|
83
|
+
const btn = target.querySelectorAll('.sv-grid-col-filter-btn')[
|
|
84
|
+
columnIndex
|
|
85
|
+
] as HTMLButtonElement
|
|
84
86
|
btn.click()
|
|
85
87
|
// GridMenus is a lazy chunk, so poll until the popover mounts AND its value
|
|
86
88
|
// checklist has rendered.
|
|
@@ -110,8 +112,21 @@ async function openFilterMenu(target: HTMLElement) {
|
|
|
110
112
|
return target.querySelector('.sv-grid-filter-menu') as HTMLElement
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
// The LABEL only. The row also carries a trailing match count, which these
|
|
116
|
+
// tests are not about - reading the whole row's text would couple them to it.
|
|
117
|
+
const labelOf = (el: Element) =>
|
|
118
|
+
(el.querySelector('.sv-listbox__label') ?? el).textContent?.trim() ?? ''
|
|
119
|
+
|
|
113
120
|
const optionLabels = (menu: HTMLElement) =>
|
|
114
|
-
Array.from(menu.querySelectorAll('[role="option"]')).map(
|
|
121
|
+
Array.from(menu.querySelectorAll('[role="option"]')).map(labelOf)
|
|
122
|
+
|
|
123
|
+
const findOption = (menu: HTMLElement, label: string) => {
|
|
124
|
+
const hit = Array.from(menu.querySelectorAll('[role="option"]')).find(
|
|
125
|
+
(el) => labelOf(el) === label,
|
|
126
|
+
)
|
|
127
|
+
if (!hit) throw new Error(`no option labelled "${label}" in [${optionLabels(menu)}]`)
|
|
128
|
+
return hit as HTMLElement
|
|
129
|
+
}
|
|
115
130
|
|
|
116
131
|
describe('filter menu value checklist', () => {
|
|
117
132
|
it('windows a high-cardinality column instead of mounting every value', async () => {
|
|
@@ -252,9 +267,7 @@ describe('filter menu value checklist', () => {
|
|
|
252
267
|
expect(optionLabels(suggest).length).toBeGreaterThan(1)
|
|
253
268
|
|
|
254
269
|
// Pick another: the fragment is consumed, not left beside it.
|
|
255
|
-
const pick =
|
|
256
|
-
(el) => el.textContent?.trim() === 'SYM00005',
|
|
257
|
-
) as HTMLElement
|
|
270
|
+
const pick = findOption(suggest, 'SYM00005')
|
|
258
271
|
pick.click()
|
|
259
272
|
await tick()
|
|
260
273
|
|
|
@@ -263,7 +276,7 @@ describe('filter menu value checklist', () => {
|
|
|
263
276
|
// dropdown has no trailing separator, so anything that mistook the last
|
|
264
277
|
// token for a half-typed fragment would show it unchecked.
|
|
265
278
|
const checked = suggest.querySelectorAll('[role="option"][aria-selected="true"]')
|
|
266
|
-
expect(Array.from(checked).map(
|
|
279
|
+
expect(Array.from(checked).map(labelOf).sort()).toEqual([
|
|
267
280
|
'SYM00003',
|
|
268
281
|
'SYM00005',
|
|
269
282
|
])
|
|
@@ -291,9 +304,7 @@ describe('filter menu value checklist', () => {
|
|
|
291
304
|
await vi.waitFor(() => expect(target.querySelector('.sv-grid-in-suggest')).not.toBeNull())
|
|
292
305
|
const suggest = target.querySelector('.sv-grid-in-suggest') as HTMLElement
|
|
293
306
|
|
|
294
|
-
const pick =
|
|
295
|
-
(el) => el.textContent?.trim() === 'Aug 17, 2026',
|
|
296
|
-
) as HTMLElement
|
|
307
|
+
const pick = findOption(suggest, 'Aug 17, 2026')
|
|
297
308
|
pick.click()
|
|
298
309
|
await tick()
|
|
299
310
|
|
|
@@ -328,4 +339,39 @@ describe('filter menu value checklist', () => {
|
|
|
328
339
|
|
|
329
340
|
destroy()
|
|
330
341
|
})
|
|
342
|
+
|
|
343
|
+
it('shows how many rows each value matches', async () => {
|
|
344
|
+
// 9 rows over teams A/B/C, so three each.
|
|
345
|
+
const { target, destroy } = await mountGrid(makeRows(9))
|
|
346
|
+
await tick()
|
|
347
|
+
const menu = await openFilterMenu(target, 1)
|
|
348
|
+
const rows = Array.from(menu.querySelectorAll('[role="option"]')).map((el) => ({
|
|
349
|
+
label: el.querySelector('.sv-listbox__label')?.textContent?.trim(),
|
|
350
|
+
hint: el.querySelector('.sv-listbox__hint')?.textContent?.trim(),
|
|
351
|
+
}))
|
|
352
|
+
expect(rows).toEqual([
|
|
353
|
+
{ label: 'A', hint: '3' },
|
|
354
|
+
{ label: 'B', hint: '3' },
|
|
355
|
+
{ label: 'C', hint: '3' },
|
|
356
|
+
])
|
|
357
|
+
destroy()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('offers only the values still reachable under the other columns\' filters', async () => {
|
|
361
|
+
// Symbols SYM00000..2 are teams A, B, C in turn. Filtering Symbol down to
|
|
362
|
+
// one row must leave the Team list showing that row's team alone - the
|
|
363
|
+
// list used to be built from the whole dataset, so it offered every team
|
|
364
|
+
// and two of the three were a one-click route to an empty grid.
|
|
365
|
+
const { target, api, destroy } = await mountGrid(makeRows(9))
|
|
366
|
+
await tick()
|
|
367
|
+
api.setFilter('symbol', { operator: 'equals', value: 'SYM00001' })
|
|
368
|
+
await tick()
|
|
369
|
+
expect(api.getDisplayedRows().length).toBe(1)
|
|
370
|
+
|
|
371
|
+
const menu = await openFilterMenu(target, 1)
|
|
372
|
+
const labels = optionLabels(menu)
|
|
373
|
+
expect(labels).toEqual(['B'])
|
|
374
|
+
|
|
375
|
+
destroy()
|
|
376
|
+
})
|
|
331
377
|
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `in` / `notIn` suggestions dropdown shares the facet scan with the
|
|
3
|
+
* filter menu's checklist. That scan now also runs the filter pipeline, to
|
|
4
|
+
* offer only values still reachable under the other columns' filters - which
|
|
5
|
+
* put an O(rows) pass on the typing path of the chip input, because the
|
|
6
|
+
* suggestions re-derive on every keystroke.
|
|
7
|
+
*
|
|
8
|
+
* They are snapshotted per open instead, the same way the menu's checklist is.
|
|
9
|
+
* This pins that: typing must not re-scan.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { mount, unmount } from 'svelte'
|
|
13
|
+
import SvGrid from './SvGrid.svelte'
|
|
14
|
+
import {
|
|
15
|
+
columnFilteringFeature,
|
|
16
|
+
rowSortingFeature,
|
|
17
|
+
tableFeatures,
|
|
18
|
+
} from './index'
|
|
19
|
+
import type { ColumnDef } from './index'
|
|
20
|
+
|
|
21
|
+
type Row = { id: number; symbol: string }
|
|
22
|
+
const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
|
|
23
|
+
const tick = () => new Promise<void>((r) => setTimeout(r))
|
|
24
|
+
|
|
25
|
+
describe('in/notIn suggestions', () => {
|
|
26
|
+
it('scans once per open, not once per keystroke', async () => {
|
|
27
|
+
const data: Row[] = Array.from({ length: 40 }, (_, i) => ({
|
|
28
|
+
id: i + 1,
|
|
29
|
+
symbol: `SYM${String(i).padStart(3, '0')}`,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
// Count reads of the accessor the facet scan goes through. A per-keystroke
|
|
33
|
+
// re-scan shows up here as another 40 reads per character typed.
|
|
34
|
+
let reads = 0
|
|
35
|
+
const columns: ColumnDef<typeof features, Row>[] = [
|
|
36
|
+
{
|
|
37
|
+
id: 'symbol',
|
|
38
|
+
header: 'Symbol',
|
|
39
|
+
width: 200,
|
|
40
|
+
fieldFn: (row: Row) => {
|
|
41
|
+
reads += 1
|
|
42
|
+
return row.symbol
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
const target = document.createElement('div')
|
|
48
|
+
document.body.appendChild(target)
|
|
49
|
+
const app = mount(SvGrid<typeof features, Row>, {
|
|
50
|
+
target,
|
|
51
|
+
props: { data, columns, features, filterMode: 'row', height: 300 } as never,
|
|
52
|
+
})
|
|
53
|
+
await tick()
|
|
54
|
+
|
|
55
|
+
// Switch the column to `in`, which is what turns the filter-row box into a
|
|
56
|
+
// chip input with suggestions.
|
|
57
|
+
const operatorBtn = target.querySelector(
|
|
58
|
+
'.sv-grid-filter-operator-btn',
|
|
59
|
+
) as HTMLButtonElement
|
|
60
|
+
operatorBtn.click()
|
|
61
|
+
await vi.waitFor(() => expect(document.querySelector('.sv-grid-operator-menu')).not.toBeNull())
|
|
62
|
+
const inItem = Array.from(
|
|
63
|
+
document.querySelectorAll('.sv-grid-operator-menu .sv-grid-menu-item'),
|
|
64
|
+
).find((el) => el.textContent?.trim() === 'In') as HTMLElement
|
|
65
|
+
inItem.click()
|
|
66
|
+
await tick()
|
|
67
|
+
|
|
68
|
+
const input = target.querySelector(
|
|
69
|
+
'.sv-grid-filter-chip-input, .sv-grid-filter-value',
|
|
70
|
+
) as HTMLInputElement
|
|
71
|
+
expect(input).not.toBeNull()
|
|
72
|
+
input.focus()
|
|
73
|
+
input.dispatchEvent(new Event('focus', { bubbles: true }))
|
|
74
|
+
await vi.waitFor(() => expect(document.querySelector('.sv-grid-in-suggest')).not.toBeNull())
|
|
75
|
+
|
|
76
|
+
const afterOpen = reads
|
|
77
|
+
expect(afterOpen).toBeGreaterThan(0)
|
|
78
|
+
|
|
79
|
+
// Type into the box: the list narrows from the snapshot, no fresh scan.
|
|
80
|
+
for (const text of ['S', 'SY', 'SYM', 'SYM0', 'SYM00']) {
|
|
81
|
+
input.value = text
|
|
82
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
83
|
+
await tick()
|
|
84
|
+
}
|
|
85
|
+
expect(document.querySelector('.sv-grid-in-suggest')).not.toBeNull()
|
|
86
|
+
expect(reads).toBe(afterOpen)
|
|
87
|
+
|
|
88
|
+
unmount(app)
|
|
89
|
+
target.remove()
|
|
90
|
+
})
|
|
91
|
+
})
|