@svgrid/grid 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/GridMenus.svelte +205 -127
  2. package/dist/SvGrid.controller.svelte.d.ts +44 -1
  3. package/dist/SvGrid.controller.svelte.js +223 -38
  4. package/dist/SvGrid.css +2012 -1709
  5. package/dist/SvGrid.helpers.test.d.ts +1 -0
  6. package/dist/SvGrid.helpers.test.js +298 -0
  7. package/dist/SvGrid.svelte +2346 -2043
  8. package/dist/SvGrid.types.d.ts +91 -4
  9. package/dist/aligned-grids.d.ts +6 -0
  10. package/dist/aligned-grids.js +84 -0
  11. package/dist/aligned-grids.test.d.ts +1 -0
  12. package/dist/aligned-grids.test.js +75 -0
  13. package/dist/build-api.coverage.test.d.ts +20 -0
  14. package/dist/build-api.coverage.test.js +505 -0
  15. package/dist/build-api.js +59 -29
  16. package/dist/cell-render.test.d.ts +1 -0
  17. package/dist/cell-render.test.js +338 -0
  18. package/dist/chart-export.test.d.ts +1 -0
  19. package/dist/chart-export.test.js +302 -0
  20. package/dist/chart.coverage.test.d.ts +1 -0
  21. package/dist/chart.coverage.test.js +748 -0
  22. package/dist/clipboard.js +88 -24
  23. package/dist/clipboard.test.d.ts +1 -0
  24. package/dist/clipboard.test.js +700 -0
  25. package/dist/collaboration.coverage.test.d.ts +1 -0
  26. package/dist/collaboration.coverage.test.js +200 -0
  27. package/dist/column-groups.d.ts +19 -0
  28. package/dist/column-groups.js +62 -0
  29. package/dist/column-groups.test.d.ts +1 -0
  30. package/dist/column-groups.test.js +56 -0
  31. package/dist/column-types.d.ts +10 -0
  32. package/dist/column-types.js +63 -0
  33. package/dist/column-types.test.d.ts +1 -0
  34. package/dist/column-types.test.js +62 -0
  35. package/dist/columns.test.d.ts +1 -0
  36. package/dist/columns.test.js +625 -0
  37. package/dist/core.d.ts +85 -0
  38. package/dist/editing.d.ts +7 -0
  39. package/dist/editing.js +191 -5
  40. package/dist/editing.test.d.ts +1 -0
  41. package/dist/editing.test.js +732 -0
  42. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  43. package/dist/editors/cell-editors.coverage.test.js +139 -0
  44. package/dist/facet-buckets.test.d.ts +1 -0
  45. package/dist/facet-buckets.test.js +296 -0
  46. package/dist/filter-operators.test.d.ts +1 -0
  47. package/dist/filter-operators.test.js +135 -0
  48. package/dist/hyperformula-adapter.test.d.ts +1 -0
  49. package/dist/hyperformula-adapter.test.js +205 -0
  50. package/dist/index.d.ts +5 -2
  51. package/dist/index.js +4 -1
  52. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  53. package/dist/keyboard-handlers.coverage.test.js +495 -0
  54. package/dist/keyboard-handlers.js +7 -2
  55. package/dist/menus.js +1 -0
  56. package/dist/menus.test.d.ts +1 -0
  57. package/dist/menus.test.js +560 -0
  58. package/dist/named-views.coverage.test.d.ts +1 -0
  59. package/dist/named-views.coverage.test.js +180 -0
  60. package/dist/row-drag.d.ts +49 -0
  61. package/dist/row-drag.js +221 -0
  62. package/dist/row-drag.test.d.ts +1 -0
  63. package/dist/row-drag.test.js +142 -0
  64. package/dist/row-resize.test.d.ts +1 -0
  65. package/dist/row-resize.test.js +329 -0
  66. package/dist/scroll-sync.js +3 -0
  67. package/dist/scroll-sync.test.d.ts +1 -0
  68. package/dist/scroll-sync.test.js +290 -0
  69. package/dist/selection.d.ts +7 -1
  70. package/dist/selection.js +76 -36
  71. package/dist/selection.multi-range.test.d.ts +1 -0
  72. package/dist/selection.multi-range.test.js +55 -0
  73. package/dist/selection.test.d.ts +1 -0
  74. package/dist/selection.test.js +647 -0
  75. package/dist/server-data-source.coverage.test.d.ts +1 -0
  76. package/dist/server-data-source.coverage.test.js +154 -0
  77. package/dist/spreadsheet.d.ts +30 -0
  78. package/dist/spreadsheet.js +48 -0
  79. package/dist/spreadsheet.test.d.ts +1 -0
  80. package/dist/spreadsheet.test.js +446 -0
  81. package/dist/sv-grid-scrollbar.js +13 -1
  82. package/dist/svgrid-wrapper.types.d.ts +19 -0
  83. package/dist/svgrid.behavior.test.js +20 -0
  84. package/dist/svgrid.interaction.test.js +31 -0
  85. package/dist/svgrid.new-features.wrapper.test.js +34 -2
  86. package/dist/test-setup.js +9 -3
  87. package/dist/virtualization/scroll-scaling.d.ts +17 -0
  88. package/dist/virtualization/scroll-scaling.js +35 -0
  89. package/dist/virtualization/scroll-scaling.test.js +42 -1
  90. package/package.json +2 -1
  91. package/src/GridMenus.svelte +205 -127
  92. package/src/SvGrid.controller.svelte.ts +2352 -2195
  93. package/src/SvGrid.css +2012 -1747
  94. package/src/SvGrid.svelte +2346 -2047
  95. package/src/SvGrid.types.ts +537 -456
  96. package/src/aligned-grids.test.ts +80 -0
  97. package/src/aligned-grids.ts +87 -0
  98. package/src/build-api.ts +683 -663
  99. package/src/clipboard.test.ts +49 -0
  100. package/src/clipboard.ts +51 -23
  101. package/src/column-groups.test.ts +59 -0
  102. package/src/column-groups.ts +80 -0
  103. package/src/column-types.test.ts +68 -0
  104. package/src/column-types.ts +82 -0
  105. package/src/core.ts +78 -0
  106. package/src/editing.ts +669 -513
  107. package/src/index.ts +12 -0
  108. package/src/menus.ts +1 -0
  109. package/src/row-drag.test.ts +168 -0
  110. package/src/row-drag.ts +255 -0
  111. package/src/scroll-sync.ts +2 -0
  112. package/src/selection.multi-range.test.ts +61 -0
  113. package/src/selection.ts +71 -37
  114. package/src/spreadsheet.test.ts +489 -445
  115. package/src/spreadsheet.ts +304 -246
  116. package/src/svgrid-wrapper.types.ts +19 -0
  117. package/src/svgrid.new-features.wrapper.test.ts +2 -2
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export {
19
19
  tableFeatures,
20
20
  type Cell,
21
21
  type CellContext,
22
+ type CellSpanParams,
23
+ type ValueParserParams,
22
24
  type EditorContext,
23
25
  type CellData,
24
26
  type ActiveCellState,
@@ -105,12 +107,22 @@ export {
105
107
  } from './sparkline'
106
108
  export {
107
109
  spreadsheetLayout,
110
+ spansToMerges,
111
+ type SpanColumn,
108
112
  type SpreadsheetActionOptions,
109
113
  type MergeSpec,
110
114
  type CellBorderSpec,
111
115
  type BorderSpec,
112
116
  } from './spreadsheet'
113
117
  export { rowResize, type RowResizeOptions } from './row-resize'
118
+ export { rowDropZone, type RowDragEndEvent, type RowDropZoneOptions } from './row-drag'
119
+ export { resolveColumnTypes, inferCellDataType, type CellDataType } from './column-types'
120
+ export {
121
+ computeColumnGroupMeta,
122
+ hiddenLeavesForCollapse,
123
+ type ColumnGroupMeta,
124
+ type ColumnGroupShow,
125
+ } from './column-groups'
114
126
  export {
115
127
  createHyperFormulaSheet,
116
128
  type HyperFormulaSheet,
package/src/menus.ts CHANGED
@@ -231,6 +231,7 @@ export function createMenus<
231
231
  x: clampMenuX(rect.right - 240, 240),
232
232
  y: rect.bottom + 4,
233
233
  };
234
+ ctx.columnMenuTab = "general";
234
235
  ctx.columnMenuFor = columnId;
235
236
  }
236
237
 
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Unit tests for the managed row-drag handlers produced by createRowDrag(ctx).
3
+ *
4
+ * createRowDrag is a factory closing over a mutable `ctx` handle and a
5
+ * module-level singleton "bus" that carries the dragged row between grids.
6
+ * Because the bus is shared, we can create TWO fake ctxs (two grids) and drive
7
+ * a genuine grid-to-grid move without mounting anything.
8
+ */
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { createRowDrag, rowDropZone } from './row-drag'
11
+
12
+ type AnyCtx = Record<string, any>
13
+ type Task = { id: number; title: string }
14
+
15
+ function makeCtx(rows: Task[], props: AnyCtx = {}): AnyCtx {
16
+ return {
17
+ props,
18
+ internalData: rows,
19
+ get allRows() {
20
+ // allRows tracks internalData order; each Row wraps `.original`.
21
+ return (this.internalData as Task[]).map((r) => ({ original: r }))
22
+ },
23
+ rowDragActive: false,
24
+ rowDropIndex: null,
25
+ rowDropSide: null,
26
+ }
27
+ }
28
+
29
+ function fakeEvent(clientY = 0, rectTop = 0, rectHeight = 40): any {
30
+ return {
31
+ clientY,
32
+ preventDefault: vi.fn(),
33
+ stopPropagation: vi.fn(),
34
+ dataTransfer: { setData: vi.fn(), effectAllowed: '', dropEffect: '' },
35
+ currentTarget: {
36
+ getBoundingClientRect: () => ({ top: rectTop, height: rectHeight, bottom: rectTop + rectHeight, left: 0 }),
37
+ },
38
+ }
39
+ }
40
+
41
+ const P = { rowDragManaged: true }
42
+
43
+ describe('createRowDrag - same-grid reorder', () => {
44
+ it('moves a dragged row after the drop target', () => {
45
+ const rows: Task[] = [
46
+ { id: 1, title: 'a' },
47
+ { id: 2, title: 'b' },
48
+ { id: 3, title: 'c' },
49
+ { id: 4, title: 'd' },
50
+ ]
51
+ const ctx = makeCtx(rows, { ...P })
52
+ const h = createRowDrag(ctx)
53
+
54
+ h.onRowDragStart(fakeEvent(), 0) // drag "a"
55
+ ctx.rowDropSide = 'after'
56
+ h.onRowDrop(fakeEvent(), 2) // drop after "c"
57
+
58
+ expect((ctx.internalData as Task[]).map((r) => r.title)).toEqual(['b', 'c', 'a', 'd'])
59
+ })
60
+
61
+ it('drops before the target when the side is "before"', () => {
62
+ const rows: Task[] = [
63
+ { id: 1, title: 'a' },
64
+ { id: 2, title: 'b' },
65
+ { id: 3, title: 'c' },
66
+ ]
67
+ const ctx = makeCtx(rows, { ...P })
68
+ const h = createRowDrag(ctx)
69
+ h.onRowDragStart(fakeEvent(), 2) // drag "c"
70
+ ctx.rowDropSide = 'before'
71
+ h.onRowDrop(fakeEvent(), 0) // before "a"
72
+ expect((ctx.internalData as Task[]).map((r) => r.title)).toEqual(['c', 'a', 'b'])
73
+ })
74
+ })
75
+
76
+ describe('createRowDrag - grid-to-grid', () => {
77
+ it('moves a row from source into target when groups match', () => {
78
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
79
+ const b = makeCtx([{ id: 9, title: 'b1' }], { ...P, rowDragGroup: 'g', onRowDragEnd: vi.fn() })
80
+ const ha = createRowDrag(a)
81
+ const hb = createRowDrag(b)
82
+
83
+ ha.onRowDragStart(fakeEvent(), 1) // drag "a2" out of grid A
84
+ b.rowDropSide = 'before'
85
+ hb.onRowDrop(fakeEvent(), 0) // drop onto grid B before "b1"
86
+
87
+ expect((a.internalData as Task[]).map((r) => r.title)).toEqual(['a1'])
88
+ expect((b.internalData as Task[]).map((r) => r.title)).toEqual(['a2', 'b1'])
89
+ expect(b.props.onRowDragEnd).toHaveBeenCalledWith(
90
+ expect.objectContaining({ sameGrid: false, toIndex: 0, row: expect.objectContaining({ title: 'a2' }) }),
91
+ )
92
+ })
93
+
94
+ it('does NOT accept a drop when groups differ', () => {
95
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
96
+ const b = makeCtx([{ id: 9, title: 'b1' }], { ...P, rowDragGroup: 'other' })
97
+ const ha = createRowDrag(a)
98
+ const hb = createRowDrag(b)
99
+
100
+ ha.onRowDragStart(fakeEvent(), 0)
101
+ const ev = fakeEvent()
102
+ hb.onRowDragOver(ev, 0)
103
+ expect(ev.preventDefault).not.toHaveBeenCalled() // over rejected
104
+ hb.onRowDrop(fakeEvent(), 0)
105
+ expect((b.internalData as Task[]).map((r) => r.title)).toEqual(['b1']) // unchanged
106
+ expect((a.internalData as Task[]).length).toBe(2) // source untouched
107
+ })
108
+
109
+ it('appends to the target on an empty-space (container) drop', () => {
110
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
111
+ const b = makeCtx([{ id: 9, title: 'b1' }], { ...P, rowDragGroup: 'g' })
112
+ const ha = createRowDrag(a)
113
+ const hb = createRowDrag(b)
114
+ ha.onRowDragStart(fakeEvent(), 0) // drag "a1"
115
+ hb.onRowsContainerDrop(fakeEvent())
116
+ expect((b.internalData as Task[]).map((r) => r.title)).toEqual(['b1', 'a1'])
117
+ expect((a.internalData as Task[]).map((r) => r.title)).toEqual(['a2'])
118
+ })
119
+ })
120
+
121
+ describe('createRowDrag - disabled', () => {
122
+ it('is inert when rowDragManaged is off', () => {
123
+ const ctx = makeCtx([{ id: 1, title: 'a' }, { id: 2, title: 'b' }], {})
124
+ const h = createRowDrag(ctx)
125
+ h.onRowDragStart(fakeEvent(), 0)
126
+ ctx.rowDropSide = 'after'
127
+ h.onRowDrop(fakeEvent(), 1)
128
+ expect((ctx.internalData as Task[]).map((r) => r.title)).toEqual(['a', 'b'])
129
+ })
130
+ })
131
+
132
+ describe('rowDropZone (external drop target)', () => {
133
+ function dropOn(el: HTMLElement) {
134
+ const ev = new Event('drop', { bubbles: true, cancelable: true })
135
+ el.dispatchEvent(ev)
136
+ }
137
+
138
+ it('removes the dragged row from its source grid and calls onDrop', () => {
139
+ const a = makeCtx([{ id: 1, title: 'a1' }, { id: 2, title: 'a2' }], { ...P, rowDragGroup: 'g' })
140
+ const ha = createRowDrag(a)
141
+ ha.onRowDragStart(fakeEvent(), 0) // start dragging "a1"
142
+
143
+ const el = document.createElement('div')
144
+ const dropped: any[] = []
145
+ const action = rowDropZone(el, { group: 'g', onDrop: (e) => dropped.push(e) })
146
+ dropOn(el)
147
+
148
+ expect(dropped.length).toBe(1)
149
+ expect(dropped[0].row.title).toBe('a1')
150
+ expect((a.internalData as Task[]).map((r) => r.title)).toEqual(['a2']) // removed from source
151
+ action.destroy()
152
+ })
153
+
154
+ it('ignores drops when the group does not match', () => {
155
+ const a = makeCtx([{ id: 1, title: 'a1' }], { ...P, rowDragGroup: 'g' })
156
+ const ha = createRowDrag(a)
157
+ ha.onRowDragStart(fakeEvent(), 0)
158
+
159
+ const el = document.createElement('div')
160
+ const dropped: any[] = []
161
+ const action = rowDropZone(el, { group: 'other', onDrop: (e) => dropped.push(e) })
162
+ dropOn(el)
163
+
164
+ expect(dropped.length).toBe(0)
165
+ expect((a.internalData as Task[]).length).toBe(1) // untouched
166
+ action.destroy()
167
+ })
168
+ })
@@ -0,0 +1,255 @@
1
+ // Managed row dragging - reorder rows within a grid and move rows between
2
+ // grids that share a `rowDragGroup`. "Managed" means the grid mutates its own
3
+ // `internalData` on drop (splice out of the source, splice into the target) so
4
+ // consumers get working drag-and-drop with zero wiring; an optional
5
+ // `onRowDragEnd` callback lets them mirror the change into their own state.
6
+ //
7
+ // Cross-grid coordination rides on a module-level singleton (`bus`). Because
8
+ // every SvGrid instance in a bundle shares this module, the source grid can
9
+ // hand the target grid both the dragged row and a closure that removes it from
10
+ // the source - the missing half of an HTML5 drag payload, which only carries
11
+ // strings. Rows are matched by object identity, so this works without
12
+ // `getRowId`.
13
+
14
+ type RowDragBus = {
15
+ group: string | null;
16
+ gridId: number;
17
+ row: unknown;
18
+ removeFromSource: () => void;
19
+ sourceProps: { onRowDragEnd?: (e: RowDragEndEvent) => void };
20
+ };
21
+
22
+ export type RowDragEndEvent<TData = unknown> = {
23
+ row: TData;
24
+ toIndex: number;
25
+ sameGrid: boolean;
26
+ fromGridId: number;
27
+ toGridId: number;
28
+ };
29
+
30
+ // Shared across all grids in the bundle - this is what makes grid-to-grid work.
31
+ let bus: RowDragBus | null = null;
32
+ let gridSeq = 0;
33
+
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ export function createRowDrag<TFeatures, TData>(ctx: any) {
36
+ const gridId = ++gridSeq;
37
+
38
+ const managed = () => ctx.props.rowDragManaged === true;
39
+ const group = (): string | null => ctx.props.rowDragGroup ?? null;
40
+
41
+ function originalAt(rowIndex: number): unknown {
42
+ const r = ctx.allRows[rowIndex];
43
+ return r ? r.original : null;
44
+ }
45
+
46
+ // Can THIS grid accept the row currently on the bus? Same grid always may
47
+ // (a reorder); a different grid only when both opt into the same group.
48
+ function canAccept(): boolean {
49
+ if (!managed() || !bus) return false;
50
+ if (bus.gridId === gridId) return true;
51
+ return bus.group != null && bus.group === group();
52
+ }
53
+
54
+ function clearIndicators() {
55
+ ctx.rowDragActive = false;
56
+ ctx.rowDropIndex = null;
57
+ ctx.rowDropSide = null;
58
+ }
59
+
60
+ function emitEnd(row: unknown, toIndex: number, sameGrid: boolean, fromGridId: number) {
61
+ ctx.props.onRowDragEnd?.({
62
+ row,
63
+ toIndex,
64
+ sameGrid,
65
+ fromGridId,
66
+ toGridId: gridId,
67
+ } as RowDragEndEvent<TData>);
68
+ }
69
+
70
+ function onRowDragStart(e: DragEvent, rowIndex: number) {
71
+ if (!managed()) return;
72
+ const row = originalAt(rowIndex);
73
+ if (row == null) return;
74
+ bus = {
75
+ group: group(),
76
+ gridId,
77
+ row,
78
+ sourceProps: ctx.props,
79
+ removeFromSource: () => {
80
+ ctx.internalData = (ctx.internalData as unknown[]).filter((r) => r !== row);
81
+ },
82
+ };
83
+ ctx.rowDragActive = true;
84
+ try {
85
+ e.dataTransfer?.setData("text/plain", String(rowIndex));
86
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
87
+ } catch {
88
+ // Some environments (tests) provide a partial dataTransfer.
89
+ }
90
+ }
91
+
92
+ function onRowDragOver(e: DragEvent, rowIndex: number) {
93
+ if (!canAccept()) return;
94
+ e.preventDefault();
95
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
96
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
97
+ ctx.rowDropIndex = rowIndex;
98
+ ctx.rowDropSide = e.clientY < rect.top + rect.height / 2 ? "before" : "after";
99
+ }
100
+
101
+ function onRowDragLeave(rowIndex: number) {
102
+ if (ctx.rowDropIndex === rowIndex) {
103
+ ctx.rowDropIndex = null;
104
+ ctx.rowDropSide = null;
105
+ }
106
+ }
107
+
108
+ function onRowDrop(e: DragEvent, rowIndex: number) {
109
+ if (!canAccept() || !bus) return;
110
+ e.preventDefault();
111
+ e.stopPropagation(); // don't let the tbody handler also fire (end-append)
112
+ const side = ctx.rowDropSide ?? "before";
113
+ const dragged = bus.row;
114
+ const target = originalAt(rowIndex);
115
+ const sourceGridId = bus.gridId;
116
+ const sameGrid = sourceGridId === gridId;
117
+ clearIndicators();
118
+
119
+ const data = (ctx.internalData as unknown[]).slice();
120
+ if (sameGrid) {
121
+ const from = data.indexOf(dragged);
122
+ if (from < 0) {
123
+ bus = null;
124
+ return;
125
+ }
126
+ data.splice(from, 1);
127
+ }
128
+ let to = target != null ? data.indexOf(target) : data.length;
129
+ if (to < 0) to = data.length;
130
+ if (side === "after") to += 1;
131
+ data.splice(to, 0, dragged);
132
+ ctx.internalData = data;
133
+ if (!sameGrid) bus.removeFromSource();
134
+ emitEnd(dragged, to, sameGrid, sourceGridId);
135
+ bus = null;
136
+ }
137
+
138
+ // Dropping in the empty area below the last row, or into an empty target
139
+ // grid, appends. Row-level drops call stopPropagation so this only fires for
140
+ // genuine empty-space drops.
141
+ function onRowsContainerDragOver(e: DragEvent) {
142
+ if (!canAccept()) return;
143
+ e.preventDefault();
144
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
145
+ }
146
+
147
+ function onRowsContainerDrop(e: DragEvent) {
148
+ if (!canAccept() || !bus) return;
149
+ e.preventDefault();
150
+ const dragged = bus.row;
151
+ const sourceGridId = bus.gridId;
152
+ const sameGrid = sourceGridId === gridId;
153
+ clearIndicators();
154
+
155
+ const data = (ctx.internalData as unknown[]).slice();
156
+ if (sameGrid) {
157
+ const from = data.indexOf(dragged);
158
+ if (from >= 0) data.splice(from, 1);
159
+ }
160
+ data.push(dragged);
161
+ ctx.internalData = data;
162
+ if (!sameGrid) bus.removeFromSource();
163
+ emitEnd(dragged, data.length - 1, sameGrid, sourceGridId);
164
+ bus = null;
165
+ }
166
+
167
+ function onRowDragEnd() {
168
+ clearIndicators();
169
+ bus = null;
170
+ }
171
+
172
+ return {
173
+ onRowDragStart,
174
+ onRowDragOver,
175
+ onRowDragLeave,
176
+ onRowDrop,
177
+ onRowsContainerDragOver,
178
+ onRowsContainerDrop,
179
+ onRowDragEnd,
180
+ rowDragGridId: gridId,
181
+ };
182
+ }
183
+
184
+ /** Options for the {@link rowDropZone} action. */
185
+ export type RowDropZoneOptions<TData = unknown> = {
186
+ /**
187
+ * Only accept rows dragged from a grid with this `rowDragGroup`. Omit to
188
+ * accept a managed row drag from any grid.
189
+ */
190
+ group?: string;
191
+ /**
192
+ * Called when a managed row is dropped on the element. The row has already
193
+ * been removed from its source grid; add it to your own list/state here.
194
+ */
195
+ onDrop: (payload: { row: TData; fromGridId: number }) => void;
196
+ /** Class toggled on the element while a matching row is dragged over it. */
197
+ overClass?: string;
198
+ };
199
+
200
+ /**
201
+ * Svelte action turning ANY element into an external drop target for managed
202
+ * row dragging - a trash can, an "assign to" bucket, a second pane. Attach with
203
+ * `use:rowDropZone={{ group, onDrop }}`. On drop the dragged row is removed from
204
+ * its source grid and handed to `onDrop`.
205
+ *
206
+ * ```svelte
207
+ * <div use:rowDropZone={{ group: 'board', onDrop: (e) => archive(e.row) }}>Archive</div>
208
+ * ```
209
+ */
210
+ export function rowDropZone<TData = unknown>(
211
+ node: HTMLElement,
212
+ options: RowDropZoneOptions<TData>,
213
+ ) {
214
+ let opts = options;
215
+ const cls = () => opts.overClass ?? "sv-grid-row-dropzone-over";
216
+
217
+ function matches(): boolean {
218
+ if (!bus) return false;
219
+ return opts.group == null || bus.group === opts.group;
220
+ }
221
+ function onOver(e: DragEvent) {
222
+ if (!matches()) return;
223
+ e.preventDefault();
224
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
225
+ node.classList.add(cls());
226
+ }
227
+ function onLeave() {
228
+ node.classList.remove(cls());
229
+ }
230
+ function onDrop(e: DragEvent) {
231
+ if (!matches() || !bus) return;
232
+ e.preventDefault();
233
+ e.stopPropagation();
234
+ node.classList.remove(cls());
235
+ const row = bus.row as TData;
236
+ const fromGridId = bus.gridId;
237
+ bus.removeFromSource();
238
+ bus = null;
239
+ opts.onDrop({ row, fromGridId });
240
+ }
241
+
242
+ node.addEventListener("dragover", onOver);
243
+ node.addEventListener("dragleave", onLeave);
244
+ node.addEventListener("drop", onDrop);
245
+ return {
246
+ update(next: RowDropZoneOptions<TData>) {
247
+ opts = next;
248
+ },
249
+ destroy() {
250
+ node.removeEventListener("dragover", onOver);
251
+ node.removeEventListener("dragleave", onLeave);
252
+ node.removeEventListener("drop", onDrop);
253
+ },
254
+ };
255
+ }
@@ -193,6 +193,8 @@ export function createScrollSync<
193
193
  if (!container) return;
194
194
  if (ctx.columnMenuFor || ctx.operatorMenuFor) ctx.closeMenus();
195
195
  scheduleScrollSync(container.scrollTop, container.scrollLeft);
196
+ // Mirror horizontal scroll to any aligned grids in the same group.
197
+ if (ctx.props.alignedGridGroup != null) ctx.broadcastAlignedScroll(container.scrollLeft);
196
198
 
197
199
  if (ctx.props.onScrollBottomReached) {
198
200
  const { scrollTop, scrollHeight, clientHeight } = container;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Unit tests for multiple-range (Ctrl+drag) cell selection, driven through the
3
+ * pure range helpers of createSelection(ctx) with a minimal fake ctx.
4
+ */
5
+ import { describe, expect, it } from 'vitest'
6
+ import { createSelection } from './selection'
7
+
8
+ function makeCtx() {
9
+ return {
10
+ enableCellSelectionEffective: true,
11
+ selectionRange: { anchor: null, focus: null },
12
+ selectionRanges: [],
13
+ } as Record<string, any>
14
+ }
15
+
16
+ describe('multiple range selection', () => {
17
+ it('a plain selection replaces any committed ranges', () => {
18
+ const ctx = makeCtx()
19
+ const s = createSelection(ctx)
20
+ s.setSelection(0, 0)
21
+ s.extendSelection(1, 1) // active range 0,0..1,1
22
+ s.setSelection(5, 5, true) // additive: commit prior, start new
23
+ expect(ctx.selectionRanges.length).toBe(1)
24
+ s.setSelection(9, 9, false) // plain: clears committed
25
+ expect(ctx.selectionRanges.length).toBe(0)
26
+ })
27
+
28
+ it('Ctrl+drag commits the prior range and keeps both highlighted', () => {
29
+ const ctx = makeCtx()
30
+ const s = createSelection(ctx)
31
+ s.setSelection(0, 0)
32
+ s.extendSelection(1, 1) // range A: rows 0-1, cols 0-1
33
+ s.setSelection(3, 3, true) // start range B additively
34
+ s.extendSelection(4, 4) // range B: rows 3-4, cols 3-4
35
+
36
+ const rects = s.getSelectionRects()
37
+ expect(rects.length).toBe(2)
38
+ // both ranges report cells as selected
39
+ expect(s.isCellInSelectedRange(0, 0)).toBe(true)
40
+ expect(s.isCellInSelectedRange(1, 1)).toBe(true)
41
+ expect(s.isCellInSelectedRange(4, 4)).toBe(true)
42
+ // a cell in neither is not selected
43
+ expect(s.isCellInSelectedRange(2, 2)).toBe(false)
44
+ })
45
+
46
+ it('getCellRangeEdges outlines each range independently', () => {
47
+ const ctx = makeCtx()
48
+ const s = createSelection(ctx)
49
+ s.setSelection(0, 0)
50
+ s.extendSelection(1, 1)
51
+ s.setSelection(3, 3, true)
52
+ s.extendSelection(4, 4)
53
+
54
+ // top-left corner of range A
55
+ expect(s.getCellRangeEdges(0, 0)).toEqual({ top: true, bottom: false, left: true, right: false })
56
+ // bottom-right corner of range B
57
+ expect(s.getCellRangeEdges(4, 4)).toEqual({ top: false, bottom: true, left: false, right: true })
58
+ // outside every range
59
+ expect(s.getCellRangeEdges(2, 2)).toBeNull()
60
+ })
61
+ })
package/src/selection.ts CHANGED
@@ -283,8 +283,44 @@ export function createSelection<
283
283
  // onBodyScroll which flushes via rAF, doing one batched update.
284
284
  }
285
285
 
286
- function setSelection(rowIndex: number, colIndex: number) {
286
+ // Normalize a range to a rectangle, or null when incomplete.
287
+ function rangeRect(range: SelectionRange | null) {
288
+ const a = range?.anchor;
289
+ const f = range?.focus;
290
+ if (!a || !f) return null;
291
+ return {
292
+ minRow: Math.min(a.rowIndex, f.rowIndex),
293
+ maxRow: Math.max(a.rowIndex, f.rowIndex),
294
+ minCol: Math.min(a.colIndex, f.colIndex),
295
+ maxCol: Math.max(a.colIndex, f.colIndex),
296
+ };
297
+ }
298
+
299
+ // Every selected rectangle: the committed extra ranges plus the active one.
300
+ // Order matters for copy (added-order); the active range comes last.
301
+ function getSelectionRects() {
302
+ const rects = [] as Array<{ minRow: number; maxRow: number; minCol: number; maxCol: number }>;
303
+ for (const r of (ctx.selectionRanges as SelectionRange[]) ?? []) {
304
+ const rect = rangeRect(r);
305
+ if (rect) rects.push(rect);
306
+ }
307
+ const active = rangeRect(ctx.selectionRange);
308
+ if (active) rects.push(active);
309
+ return rects;
310
+ }
311
+
312
+ function setSelection(rowIndex: number, colIndex: number, additive = false) {
287
313
  if (!ctx.enableCellSelectionEffective) return;
314
+ if (additive) {
315
+ // Commit the current active range (if any) and start a fresh one, so the
316
+ // previous rectangle stays highlighted alongside the new Ctrl+drag.
317
+ const active = ctx.selectionRange as SelectionRange;
318
+ if (active.anchor && active.focus) {
319
+ ctx.selectionRanges = [...(ctx.selectionRanges as SelectionRange[]), active];
320
+ }
321
+ } else {
322
+ ctx.selectionRanges = [];
323
+ }
288
324
  const point = { rowIndex, colIndex };
289
325
  ctx.selectionRange = { anchor: point, focus: point };
290
326
  }
@@ -296,47 +332,43 @@ export function createSelection<
296
332
  }
297
333
 
298
334
  function isCellInSelectedRange(rowIndex: number, colIndex: number) {
299
- const anchor = ctx.selectionRange.anchor;
300
- const focus = ctx.selectionRange.focus;
301
- if (!anchor || !focus) return false;
302
- const minRow = Math.min(anchor.rowIndex, focus.rowIndex);
303
- const maxRow = Math.max(anchor.rowIndex, focus.rowIndex);
304
- const minCol = Math.min(anchor.colIndex, focus.colIndex);
305
- const maxCol = Math.max(anchor.colIndex, focus.colIndex);
306
- return (
307
- rowIndex >= minRow &&
308
- rowIndex <= maxRow &&
309
- colIndex >= minCol &&
310
- colIndex <= maxCol
311
- );
335
+ for (const rect of getSelectionRects()) {
336
+ if (
337
+ rowIndex >= rect.minRow &&
338
+ rowIndex <= rect.maxRow &&
339
+ colIndex >= rect.minCol &&
340
+ colIndex <= rect.maxCol
341
+ ) {
342
+ return true;
343
+ }
344
+ }
345
+ return false;
312
346
  }
313
347
 
314
348
  /**
315
- * Returns which sides of the selection rectangle a cell sits on, for the
316
- * Excel-style outline. Returns null when the cell is outside the range.
349
+ * Returns which sides of a selection rectangle a cell sits on, for the
350
+ * Excel-style outline. With multiple ranges, the edges of the FIRST range
351
+ * that contains the cell are returned (active range checked last so its
352
+ * outline wins on overlap). Returns null when the cell is in no range.
317
353
  */
318
354
  function getCellRangeEdges(rowIndex: number, colIndex: number) {
319
- const anchor = ctx.selectionRange.anchor;
320
- const focus = ctx.selectionRange.focus;
321
- if (!anchor || !focus) return null;
322
- const minRow = Math.min(anchor.rowIndex, focus.rowIndex);
323
- const maxRow = Math.max(anchor.rowIndex, focus.rowIndex);
324
- const minCol = Math.min(anchor.colIndex, focus.colIndex);
325
- const maxCol = Math.max(anchor.colIndex, focus.colIndex);
326
- if (
327
- rowIndex < minRow ||
328
- rowIndex > maxRow ||
329
- colIndex < minCol ||
330
- colIndex > maxCol
331
- ) {
332
- return null;
355
+ for (const rect of getSelectionRects()) {
356
+ if (
357
+ rowIndex < rect.minRow ||
358
+ rowIndex > rect.maxRow ||
359
+ colIndex < rect.minCol ||
360
+ colIndex > rect.maxCol
361
+ ) {
362
+ continue;
363
+ }
364
+ return {
365
+ top: rowIndex === rect.minRow,
366
+ bottom: rowIndex === rect.maxRow,
367
+ left: colIndex === rect.minCol,
368
+ right: colIndex === rect.maxCol,
369
+ };
333
370
  }
334
- return {
335
- top: rowIndex === minRow,
336
- bottom: rowIndex === maxRow,
337
- left: colIndex === minCol,
338
- right: colIndex === maxCol,
339
- };
371
+ return null;
340
372
  }
341
373
 
342
374
  /** Returns true when the given cell is inside the fill-drag preview
@@ -398,7 +430,8 @@ export function createSelection<
398
430
  setActiveCell(rowIndex, colIndex);
399
431
  } else {
400
432
  setActiveCell(rowIndex, colIndex);
401
- setSelection(rowIndex, colIndex);
433
+ // Ctrl/Cmd starts an ADDITIONAL range, keeping prior ranges highlighted.
434
+ setSelection(rowIndex, colIndex, event.ctrlKey || event.metaKey);
402
435
  // Range drag-select is a mouse/pen affordance. On touch, starting a drag
403
436
  // here would rubber-band a selection AND fight the browser's native
404
437
  // scroll (we never preventDefault), so a finger-drag to scroll the grid
@@ -533,6 +566,7 @@ export function createSelection<
533
566
  extendSelection,
534
567
  isCellInSelectedRange,
535
568
  getCellRangeEdges,
569
+ getSelectionRects,
536
570
  isInFillPreview,
537
571
  findColumnById,
538
572
  onCellPointerDown,