@svgrid/grid 1.1.0 → 1.1.2
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/FlexRender.svelte +96 -96
- package/dist/GridMenus.svelte +205 -127
- package/dist/SvGrid.controller.svelte.d.ts +44 -1
- package/dist/SvGrid.controller.svelte.js +240 -44
- package/dist/SvGrid.css +325 -22
- package/dist/SvGrid.helpers.test.d.ts +1 -0
- package/dist/SvGrid.helpers.test.js +298 -0
- package/dist/SvGrid.svelte +729 -203
- package/dist/SvGrid.types.d.ts +91 -4
- package/dist/aligned-grids.d.ts +6 -0
- package/dist/aligned-grids.js +84 -0
- package/dist/aligned-grids.test.d.ts +1 -0
- package/dist/aligned-grids.test.js +75 -0
- package/dist/build-api.coverage.test.d.ts +20 -0
- package/dist/build-api.coverage.test.js +505 -0
- package/dist/build-api.js +61 -31
- package/dist/cell-render.test.d.ts +1 -0
- package/dist/cell-render.test.js +338 -0
- package/dist/cell-values.d.ts +1 -1
- package/dist/cell-values.js +7 -7
- package/dist/chart-export.test.d.ts +1 -0
- package/dist/chart-export.test.js +302 -0
- package/dist/chart.coverage.test.d.ts +1 -0
- package/dist/chart.coverage.test.js +748 -0
- package/dist/clipboard.js +88 -24
- package/dist/clipboard.test.d.ts +1 -0
- package/dist/clipboard.test.js +700 -0
- package/dist/collaboration.coverage.test.d.ts +1 -0
- package/dist/collaboration.coverage.test.js +200 -0
- package/dist/column-groups.d.ts +19 -0
- package/dist/column-groups.js +62 -0
- package/dist/column-groups.test.d.ts +1 -0
- package/dist/column-groups.test.js +56 -0
- package/dist/column-types.d.ts +10 -0
- package/dist/column-types.js +63 -0
- package/dist/column-types.test.d.ts +1 -0
- package/dist/column-types.test.js +62 -0
- package/dist/columns.test.d.ts +1 -0
- package/dist/columns.test.js +625 -0
- package/dist/core.d.ts +86 -1
- package/dist/core.js +2 -2
- package/dist/editing.d.ts +7 -0
- package/dist/editing.js +191 -5
- package/dist/editing.test.d.ts +1 -0
- package/dist/editing.test.js +732 -0
- package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
- package/dist/editors/cell-editors.coverage.test.js +139 -0
- package/dist/facet-buckets.test.d.ts +1 -0
- package/dist/facet-buckets.test.js +296 -0
- package/dist/filter-operators.test.d.ts +1 -0
- package/dist/filter-operators.test.js +135 -0
- package/dist/hyperformula-adapter.test.d.ts +1 -0
- package/dist/hyperformula-adapter.test.js +205 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -1
- package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
- package/dist/keyboard-handlers.coverage.test.js +495 -0
- package/dist/keyboard-handlers.js +7 -2
- package/dist/menus.js +1 -0
- package/dist/menus.test.d.ts +1 -0
- package/dist/menus.test.js +560 -0
- package/dist/named-views.coverage.test.d.ts +1 -0
- package/dist/named-views.coverage.test.js +180 -0
- package/dist/row-drag.d.ts +49 -0
- package/dist/row-drag.js +221 -0
- package/dist/row-drag.test.d.ts +1 -0
- package/dist/row-drag.test.js +142 -0
- package/dist/row-resize.test.d.ts +1 -0
- package/dist/row-resize.test.js +329 -0
- package/dist/scroll-sync.js +3 -0
- package/dist/scroll-sync.test.d.ts +1 -0
- package/dist/scroll-sync.test.js +290 -0
- package/dist/selection.d.ts +7 -1
- package/dist/selection.js +76 -36
- package/dist/selection.multi-range.test.d.ts +1 -0
- package/dist/selection.multi-range.test.js +55 -0
- package/dist/selection.test.d.ts +1 -0
- package/dist/selection.test.js +647 -0
- package/dist/server-data-source.coverage.test.d.ts +1 -0
- package/dist/server-data-source.coverage.test.js +154 -0
- package/dist/spreadsheet.d.ts +30 -0
- package/dist/spreadsheet.js +48 -0
- package/dist/spreadsheet.test.d.ts +1 -0
- package/dist/spreadsheet.test.js +446 -0
- package/dist/summaries.js +4 -4
- package/dist/sv-grid-scrollbar.js +13 -1
- package/dist/svgrid-wrapper.types.d.ts +19 -0
- package/dist/svgrid.behavior.test.js +20 -0
- package/dist/svgrid.interaction.test.js +31 -0
- package/dist/svgrid.new-features.wrapper.test.js +34 -2
- package/dist/test-setup.js +9 -3
- package/dist/virtualization/scroll-scaling.d.ts +17 -0
- package/dist/virtualization/scroll-scaling.js +35 -0
- package/dist/virtualization/scroll-scaling.test.js +42 -1
- package/package.json +2 -1
- package/src/FlexRender.svelte +96 -96
- package/src/GridMenus.svelte +205 -127
- package/src/SvGrid.controller.svelte.ts +204 -36
- package/src/SvGrid.css +277 -12
- package/src/SvGrid.svelte +727 -205
- package/src/SvGrid.types.ts +85 -4
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +59 -59
- package/src/aligned-grids.test.ts +80 -0
- package/src/aligned-grids.ts +87 -0
- package/src/build-api.ts +43 -23
- package/src/cell-formatting.ts +169 -169
- package/src/cell-values.ts +4 -4
- package/src/clipboard.test.ts +49 -0
- package/src/clipboard.ts +51 -23
- package/src/column-groups.test.ts +59 -0
- package/src/column-groups.ts +80 -0
- package/src/column-types.test.ts +68 -0
- package/src/column-types.ts +82 -0
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1077 -999
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/editing.ts +161 -5
- package/src/flex-render.ts +3 -3
- package/src/index.ts +208 -196
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/menus.ts +1 -0
- package/src/merge-objects.ts +48 -48
- package/src/render-component.ts +28 -28
- package/src/row-drag.test.ts +168 -0
- package/src/row-drag.ts +255 -0
- package/src/scroll-sync.ts +2 -0
- package/src/selection.multi-range.test.ts +61 -0
- package/src/selection.ts +71 -37
- package/src/spreadsheet.test.ts +46 -2
- package/src/spreadsheet.ts +58 -0
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/summaries.ts +4 -4
- package/src/svgrid-wrapper.types.ts +412 -393
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.new-features.wrapper.test.ts +2 -2
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
package/src/row-drag.ts
ADDED
|
@@ -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
|
+
}
|
package/src/scroll-sync.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
316
|
-
* Excel-style outline.
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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,
|
package/src/spreadsheet.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
-
import { spreadsheetLayout } from './spreadsheet'
|
|
3
|
-
import type { SpreadsheetActionOptions } from './spreadsheet'
|
|
2
|
+
import { spreadsheetLayout, spansToMerges } from './spreadsheet'
|
|
3
|
+
import type { SpreadsheetActionOptions, SpanColumn } from './spreadsheet'
|
|
4
4
|
|
|
5
5
|
// jsdom does not implement the global `CSS` object, which `findCell` uses via
|
|
6
6
|
// `CSS.escape`. Provide a minimal polyfill so the action's selector queries
|
|
@@ -443,3 +443,47 @@ describe('spreadsheetLayout - update + cleanup', () => {
|
|
|
443
443
|
action.destroy()
|
|
444
444
|
})
|
|
445
445
|
})
|
|
446
|
+
|
|
447
|
+
describe('spansToMerges - declarative colSpan/rowSpan -> MergeSpec[]', () => {
|
|
448
|
+
type Row = { region: string; q: string; amt: number }
|
|
449
|
+
const rows: Row[] = [
|
|
450
|
+
{ region: 'AMER', q: 'Q1', amt: 1 },
|
|
451
|
+
{ region: 'AMER', q: 'Q2', amt: 2 },
|
|
452
|
+
{ region: 'AMER', q: 'Q3', amt: 3 },
|
|
453
|
+
{ region: 'EMEA', q: 'Q1', amt: 4 },
|
|
454
|
+
{ region: 'EMEA', q: 'Q2', amt: 5 },
|
|
455
|
+
]
|
|
456
|
+
// Merge each run of equal `region` values downward.
|
|
457
|
+
function regionRowSpan(): SpanColumn<Row>['rowSpan'] {
|
|
458
|
+
return ({ data, rowIndex }) => {
|
|
459
|
+
if (rowIndex > 0 && rows[rowIndex - 1]!.region === data.region) return 1 // covered
|
|
460
|
+
let n = 1
|
|
461
|
+
while (rows[rowIndex + n]?.region === data.region) n += 1
|
|
462
|
+
return n
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
it('emits a rowspan merge at each run origin and skips covered rows', () => {
|
|
467
|
+
const columns: SpanColumn<Row>[] = [{ id: 'region', field: 'region', rowSpan: regionRowSpan() }]
|
|
468
|
+
const merges = spansToMerges(rows, columns)
|
|
469
|
+
// AMER spans rows 0..2 (rowspan 3), EMEA spans rows 3..4 (rowspan 2).
|
|
470
|
+
expect(merges).toEqual([
|
|
471
|
+
{ rowIndex: 0, columnId: 'region', colspan: undefined, rowspan: 3 },
|
|
472
|
+
{ rowIndex: 3, columnId: 'region', colspan: undefined, rowspan: 2 },
|
|
473
|
+
])
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('supports colSpan and marks covered columns', () => {
|
|
477
|
+
const columns: SpanColumn<Row>[] = [
|
|
478
|
+
{ id: 'a', field: 'region', colSpan: ({ rowIndex }) => (rowIndex === 0 ? 2 : 1) },
|
|
479
|
+
{ id: 'b', field: 'q' },
|
|
480
|
+
{ id: 'c', field: 'amt' },
|
|
481
|
+
]
|
|
482
|
+
const merges = spansToMerges(rows.slice(0, 1), columns)
|
|
483
|
+
expect(merges).toEqual([{ rowIndex: 0, columnId: 'a', colspan: 2, rowspan: undefined }])
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('returns no merges when no column defines spans', () => {
|
|
487
|
+
expect(spansToMerges(rows, [{ id: 'region', field: 'region' }])).toEqual([])
|
|
488
|
+
})
|
|
489
|
+
})
|
package/src/spreadsheet.ts
CHANGED
|
@@ -45,6 +45,64 @@ export type MergeSpec = {
|
|
|
45
45
|
colspan?: number
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** A column with declarative spanning callbacks, as accepted by
|
|
49
|
+
* `spansToMerges`. Matches the relevant slice of `ColumnDef`. */
|
|
50
|
+
export type SpanColumn<TData = Record<string, unknown>> = {
|
|
51
|
+
id: string
|
|
52
|
+
field?: string
|
|
53
|
+
colSpan?: (params: { data: TData; rowIndex: number; columnId: string; value: unknown }) => number
|
|
54
|
+
rowSpan?: (params: { data: TData; rowIndex: number; columnId: string; value: unknown }) => number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Turn declarative per-column `colSpan` / `rowSpan` callbacks into a
|
|
59
|
+
* `MergeSpec[]` you can hand to `spreadsheetLayout` - so value-driven,
|
|
60
|
+
* AG-Grid-style spanning runs on the SAME real colspan/rowspan merge engine
|
|
61
|
+
* instead of a second code path. Recompute after sort/filter (indexes are
|
|
62
|
+
* display-row indexes). A common pattern is "merge runs of equal values":
|
|
63
|
+
*
|
|
64
|
+
* { field: 'region', rowSpan: ({ data, rowIndex }) =>
|
|
65
|
+
* rows.filter((r, i) => i >= rowIndex && r.region === data.region &&
|
|
66
|
+
* (i === rowIndex || rows[i-1].region === data.region)).length }
|
|
67
|
+
*/
|
|
68
|
+
export function spansToMerges<TData = Record<string, unknown>>(
|
|
69
|
+
rows: ReadonlyArray<TData>,
|
|
70
|
+
columns: ReadonlyArray<SpanColumn<TData>>,
|
|
71
|
+
getValue?: (row: TData, columnId: string) => unknown,
|
|
72
|
+
): MergeSpec[] {
|
|
73
|
+
const merges: MergeSpec[] = []
|
|
74
|
+
const covered = new Set<string>()
|
|
75
|
+
for (let r = 0; r < rows.length; r += 1) {
|
|
76
|
+
const row = rows[r]!
|
|
77
|
+
for (let ci = 0; ci < columns.length; ci += 1) {
|
|
78
|
+
const col = columns[ci]!
|
|
79
|
+
if (!col.colSpan && !col.rowSpan) continue
|
|
80
|
+
const key = `${r}:${ci}`
|
|
81
|
+
if (covered.has(key)) continue
|
|
82
|
+
const value = getValue
|
|
83
|
+
? getValue(row, col.id)
|
|
84
|
+
: (row as Record<string, unknown>)[col.field ?? col.id]
|
|
85
|
+
const params = { data: row, rowIndex: r, columnId: col.id, value }
|
|
86
|
+
const cs = Math.max(1, Math.floor(col.colSpan?.(params) ?? 1))
|
|
87
|
+
const rs = Math.max(1, Math.floor(col.rowSpan?.(params) ?? 1))
|
|
88
|
+
if (cs <= 1 && rs <= 1) continue
|
|
89
|
+
merges.push({
|
|
90
|
+
rowIndex: r,
|
|
91
|
+
columnId: col.id,
|
|
92
|
+
colspan: cs > 1 ? cs : undefined,
|
|
93
|
+
rowspan: rs > 1 ? rs : undefined,
|
|
94
|
+
})
|
|
95
|
+
for (let dr = 0; dr < rs; dr += 1) {
|
|
96
|
+
for (let dc = 0; dc < cs; dc += 1) {
|
|
97
|
+
if (dr === 0 && dc === 0) continue
|
|
98
|
+
covered.add(`${r + dr}:${ci + dc}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return merges
|
|
104
|
+
}
|
|
105
|
+
|
|
48
106
|
/** Borders for one cell. Edges left unset render as the default
|
|
49
107
|
* cell border (i.e. no override). */
|
|
50
108
|
export type CellBorderSpec = {
|
package/src/static-functions.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
export {
|
|
2
|
-
createCoreRowModel,
|
|
3
|
-
createExpandedRowModel,
|
|
4
|
-
createFilteredRowModel,
|
|
5
|
-
createGroupedRowModel,
|
|
6
|
-
createPaginatedRowModel,
|
|
7
|
-
createSortedRowModel,
|
|
8
|
-
filterFns,
|
|
9
|
-
sortFns,
|
|
10
|
-
tableFeatures,
|
|
11
|
-
} from './core'
|
|
1
|
+
export {
|
|
2
|
+
createCoreRowModel,
|
|
3
|
+
createExpandedRowModel,
|
|
4
|
+
createFilteredRowModel,
|
|
5
|
+
createGroupedRowModel,
|
|
6
|
+
createPaginatedRowModel,
|
|
7
|
+
createSortedRowModel,
|
|
8
|
+
filterFns,
|
|
9
|
+
sortFns,
|
|
10
|
+
tableFeatures,
|
|
11
|
+
} from './core'
|