@voila.dev/ui-spreadsheet 1.1.9
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/package.json +58 -0
- package/src/components/nested-table-input.tsx +85 -0
- package/src/components/spreadsheet.tsx +1288 -0
- package/src/hooks/use-spreadsheet-column-sizing.ts +120 -0
- package/src/hooks/use-spreadsheet-drag.ts +617 -0
- package/src/hooks/use-spreadsheet-grid.ts +743 -0
- package/src/hooks/use-spreadsheet-image-drop.ts +114 -0
- package/src/styles.css +10 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Internal hooks for Spreadsheet's pointer/keyboard interactions (column
|
|
5
|
+
* resize, column reorder, row drag). Not part of the public kit surface -
|
|
6
|
+
* import the parts from `#/components/ui/spreadsheet.tsx` instead.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
10
|
+
const MIN_COLUMN_WIDTH_PX = 48;
|
|
11
|
+
const KEYBOARD_RESIZE_STEP_PX = 16;
|
|
12
|
+
const ROW_DRAG_LONG_PRESS_MS = 300;
|
|
13
|
+
const LONG_PRESS_MOVE_TOLERANCE_PX = 10;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drop indicator drawn by the Spreadsheet root, absolutely positioned
|
|
17
|
+
* inside the scroll container in content coordinates (so it scrolls with the
|
|
18
|
+
* table).
|
|
19
|
+
*/
|
|
20
|
+
interface SpreadsheetDropLine {
|
|
21
|
+
left: number;
|
|
22
|
+
top: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getScrollContainer(element: HTMLElement) {
|
|
28
|
+
return element.closest<HTMLElement>("[data-slot=spreadsheet-container]");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Synthetic pointer events (tests, Storybook play functions) have no active
|
|
33
|
+
* pointer to capture and make `setPointerCapture` throw NotFoundError; the
|
|
34
|
+
* drag must survive that - it still works while the pointer stays over the
|
|
35
|
+
* element.
|
|
36
|
+
*/
|
|
37
|
+
function capturePointer(element: Element, pointerId: number) {
|
|
38
|
+
try {
|
|
39
|
+
element.setPointerCapture(pointerId);
|
|
40
|
+
} catch {
|
|
41
|
+
// No active pointer: keep going uncaptured.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Converts a viewport rect into the scroll container's content coordinates. */
|
|
46
|
+
function toContentRect(container: HTMLElement, rect: DOMRect) {
|
|
47
|
+
const containerRect = container.getBoundingClientRect();
|
|
48
|
+
return {
|
|
49
|
+
left: rect.left - containerRect.left + container.scrollLeft,
|
|
50
|
+
top: rect.top - containerRect.top + container.scrollTop,
|
|
51
|
+
width: rect.width,
|
|
52
|
+
height: rect.height,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getBodyRows(element: HTMLElement) {
|
|
57
|
+
const body = element.closest("tbody");
|
|
58
|
+
return body
|
|
59
|
+
? Array.from(
|
|
60
|
+
body.querySelectorAll<HTMLElement>("tr[data-slot=spreadsheet-row]"),
|
|
61
|
+
)
|
|
62
|
+
: [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Insertion target for a dragged column. `heads` are the reorderable header
|
|
67
|
+
* cells in DOM order (which matches `columnOrder`: the consumer renders
|
|
68
|
+
* columns from it); the returned `targetIndex` is the column's final position
|
|
69
|
+
* and `edgeX` the viewport x of the insertion edge.
|
|
70
|
+
*/
|
|
71
|
+
function computeColumnDropTarget(
|
|
72
|
+
heads: HTMLElement[],
|
|
73
|
+
draggedHead: HTMLElement,
|
|
74
|
+
clientX: number,
|
|
75
|
+
) {
|
|
76
|
+
let slot = heads.length;
|
|
77
|
+
for (const [index, candidate] of heads.entries()) {
|
|
78
|
+
const rect = candidate.getBoundingClientRect();
|
|
79
|
+
if (clientX < rect.left + rect.width / 2) {
|
|
80
|
+
slot = index;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const edgeHead = slot < heads.length ? heads[slot] : heads.at(-1);
|
|
85
|
+
if (edgeHead === undefined) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const edgeRect = edgeHead.getBoundingClientRect();
|
|
89
|
+
const fromIndex = heads.indexOf(draggedHead);
|
|
90
|
+
return {
|
|
91
|
+
targetIndex: slot > fromIndex ? slot - 1 : slot,
|
|
92
|
+
edgeX: slot < heads.length ? edgeRect.left : edgeRect.right,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Maps a keydown on a row drag handle to its action, if any. */
|
|
97
|
+
function resolveRowDragKey(key: string, grabbed: boolean) {
|
|
98
|
+
if (key === " ") {
|
|
99
|
+
return "toggle" as const;
|
|
100
|
+
}
|
|
101
|
+
if (!grabbed) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (key === "Escape") {
|
|
105
|
+
return "cancel" as const;
|
|
106
|
+
}
|
|
107
|
+
if (key === "ArrowUp") {
|
|
108
|
+
return "up" as const;
|
|
109
|
+
}
|
|
110
|
+
return key === "ArrowDown" ? ("down" as const) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Insertion target for a dragged row, among the rows WITHOUT the dragged one
|
|
115
|
+
* (so the returned index is directly the row's final position).
|
|
116
|
+
*/
|
|
117
|
+
function computeRowDropTarget(otherRows: HTMLElement[], clientY: number) {
|
|
118
|
+
for (const [candidate, row] of otherRows.entries()) {
|
|
119
|
+
const rect = row.getBoundingClientRect();
|
|
120
|
+
if (clientY < rect.top + rect.height / 2) {
|
|
121
|
+
return candidate;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return otherRows.length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pointer + keyboard resizing for one column's resize handle. The handle's
|
|
129
|
+
* `<th>` is measured lazily (on focus or drag start) so unresized columns
|
|
130
|
+
* keep flowing with `table-layout: auto` until the first interaction.
|
|
131
|
+
*/
|
|
132
|
+
function useSpreadsheetColumnResize({
|
|
133
|
+
width,
|
|
134
|
+
onWidthChange,
|
|
135
|
+
}: {
|
|
136
|
+
width: number | undefined;
|
|
137
|
+
onWidthChange: (width: number) => void;
|
|
138
|
+
}) {
|
|
139
|
+
const dragState = React.useRef<{
|
|
140
|
+
pointerId: number;
|
|
141
|
+
startX: number;
|
|
142
|
+
startWidth: number;
|
|
143
|
+
} | null>(null);
|
|
144
|
+
const [measuredWidth, setMeasuredWidth] = React.useState<number>();
|
|
145
|
+
const currentWidth = width ?? measuredWidth;
|
|
146
|
+
|
|
147
|
+
const measure = (handle: HTMLElement) => {
|
|
148
|
+
const head = handle.closest("th");
|
|
149
|
+
return head ? head.getBoundingClientRect().width : MIN_COLUMN_WIDTH_PX;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"aria-valuemin": MIN_COLUMN_WIDTH_PX,
|
|
154
|
+
"aria-valuenow":
|
|
155
|
+
currentWidth === undefined ? undefined : Math.round(currentWidth),
|
|
156
|
+
onFocus: (event: React.FocusEvent<HTMLElement>) => {
|
|
157
|
+
setMeasuredWidth(measure(event.currentTarget));
|
|
158
|
+
},
|
|
159
|
+
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
|
|
160
|
+
if (event.button !== 0) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// No text selection or click-through while dragging the handle.
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
event.stopPropagation();
|
|
166
|
+
const startWidth = width ?? measure(event.currentTarget);
|
|
167
|
+
setMeasuredWidth(startWidth);
|
|
168
|
+
dragState.current = {
|
|
169
|
+
pointerId: event.pointerId,
|
|
170
|
+
startX: event.clientX,
|
|
171
|
+
startWidth,
|
|
172
|
+
};
|
|
173
|
+
capturePointer(event.currentTarget, event.pointerId);
|
|
174
|
+
},
|
|
175
|
+
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
|
|
176
|
+
const state = dragState.current;
|
|
177
|
+
if (state === null || state.pointerId !== event.pointerId) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
onWidthChange(
|
|
181
|
+
Math.max(
|
|
182
|
+
MIN_COLUMN_WIDTH_PX,
|
|
183
|
+
state.startWidth + event.clientX - state.startX,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
onPointerUp: () => {
|
|
188
|
+
dragState.current = null;
|
|
189
|
+
},
|
|
190
|
+
onPointerCancel: () => {
|
|
191
|
+
dragState.current = null;
|
|
192
|
+
},
|
|
193
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
|
|
194
|
+
// Alt+arrows bubble up to the th for column reordering.
|
|
195
|
+
if (
|
|
196
|
+
event.altKey ||
|
|
197
|
+
(event.key !== "ArrowLeft" && event.key !== "ArrowRight")
|
|
198
|
+
) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
event.stopPropagation();
|
|
203
|
+
const base = width ?? measure(event.currentTarget);
|
|
204
|
+
const delta =
|
|
205
|
+
event.key === "ArrowLeft"
|
|
206
|
+
? -KEYBOARD_RESIZE_STEP_PX
|
|
207
|
+
: KEYBOARD_RESIZE_STEP_PX;
|
|
208
|
+
const next = Math.max(MIN_COLUMN_WIDTH_PX, base + delta);
|
|
209
|
+
setMeasuredWidth(next);
|
|
210
|
+
onWidthChange(next);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Pointer drag + Alt+arrow reordering for a header cell. Only active when the
|
|
217
|
+
* table received controlled `columnOrder`/`onColumnOrderChange`; the hook
|
|
218
|
+
* never reorders anything itself, it hands the next order to the consumer.
|
|
219
|
+
* Pointer drags starting on interactive content (sort button, resize handle)
|
|
220
|
+
* are left alone, so a sortable column stays reorderable by keyboard only.
|
|
221
|
+
*/
|
|
222
|
+
function useSpreadsheetColumnReorder({
|
|
223
|
+
columnId,
|
|
224
|
+
columnOrder,
|
|
225
|
+
onColumnOrderChange,
|
|
226
|
+
setDropLine,
|
|
227
|
+
announce,
|
|
228
|
+
}: {
|
|
229
|
+
columnId: string | undefined;
|
|
230
|
+
columnOrder: readonly string[] | undefined;
|
|
231
|
+
onColumnOrderChange: ((order: string[]) => void) | undefined;
|
|
232
|
+
setDropLine: (line: SpreadsheetDropLine | null) => void;
|
|
233
|
+
announce: (message: string) => void;
|
|
234
|
+
}) {
|
|
235
|
+
const dragState = React.useRef<{
|
|
236
|
+
pointerId: number;
|
|
237
|
+
startX: number;
|
|
238
|
+
dragging: boolean;
|
|
239
|
+
targetIndex: number | null;
|
|
240
|
+
} | null>(null);
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
columnId === undefined ||
|
|
244
|
+
columnOrder === undefined ||
|
|
245
|
+
onColumnOrderChange === undefined
|
|
246
|
+
) {
|
|
247
|
+
return { enabled: false as const, headProps: {} };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const moveColumn = (toIndex: number) => {
|
|
251
|
+
const fromIndex = columnOrder.indexOf(columnId);
|
|
252
|
+
if (
|
|
253
|
+
fromIndex === -1 ||
|
|
254
|
+
toIndex < 0 ||
|
|
255
|
+
toIndex >= columnOrder.length ||
|
|
256
|
+
toIndex === fromIndex
|
|
257
|
+
) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const next = [...columnOrder];
|
|
261
|
+
next.splice(fromIndex, 1);
|
|
262
|
+
next.splice(toIndex, 0, columnId);
|
|
263
|
+
onColumnOrderChange(next);
|
|
264
|
+
announce(`Column moved to position ${toIndex + 1} of ${next.length}.`);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
enabled: true as const,
|
|
269
|
+
headProps: {
|
|
270
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
|
|
271
|
+
if (
|
|
272
|
+
!event.altKey ||
|
|
273
|
+
(event.key !== "ArrowLeft" && event.key !== "ArrowRight")
|
|
274
|
+
) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
const fromIndex = columnOrder.indexOf(columnId);
|
|
279
|
+
moveColumn(event.key === "ArrowLeft" ? fromIndex - 1 : fromIndex + 1);
|
|
280
|
+
},
|
|
281
|
+
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
|
|
282
|
+
if (
|
|
283
|
+
event.button !== 0 ||
|
|
284
|
+
(event.target as HTMLElement).closest("button, [role=separator]")
|
|
285
|
+
) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
dragState.current = {
|
|
289
|
+
pointerId: event.pointerId,
|
|
290
|
+
startX: event.clientX,
|
|
291
|
+
dragging: false,
|
|
292
|
+
targetIndex: null,
|
|
293
|
+
};
|
|
294
|
+
capturePointer(event.currentTarget, event.pointerId);
|
|
295
|
+
},
|
|
296
|
+
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
|
|
297
|
+
const state = dragState.current;
|
|
298
|
+
if (state === null || state.pointerId !== event.pointerId) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (
|
|
302
|
+
!state.dragging &&
|
|
303
|
+
Math.abs(event.clientX - state.startX) < DRAG_THRESHOLD_PX
|
|
304
|
+
) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
state.dragging = true;
|
|
308
|
+
const head = event.currentTarget;
|
|
309
|
+
const container = getScrollContainer(head);
|
|
310
|
+
const headerRow = head.parentElement;
|
|
311
|
+
const table = container?.querySelector("table");
|
|
312
|
+
if (!container || !headerRow || !table) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const heads = Array.from(
|
|
316
|
+
headerRow.querySelectorAll<HTMLElement>("th[data-column-id]"),
|
|
317
|
+
);
|
|
318
|
+
const drop = computeColumnDropTarget(heads, head, event.clientX);
|
|
319
|
+
if (drop === null) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
state.targetIndex = drop.targetIndex;
|
|
323
|
+
const tableRect = toContentRect(
|
|
324
|
+
container,
|
|
325
|
+
table.getBoundingClientRect(),
|
|
326
|
+
);
|
|
327
|
+
const containerRect = container.getBoundingClientRect();
|
|
328
|
+
setDropLine({
|
|
329
|
+
left: drop.edgeX - containerRect.left + container.scrollLeft - 1,
|
|
330
|
+
top: tableRect.top,
|
|
331
|
+
width: 2,
|
|
332
|
+
height: tableRect.height,
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
onPointerUp: (event: React.PointerEvent<HTMLElement>) => {
|
|
336
|
+
const state = dragState.current;
|
|
337
|
+
if (state === null || state.pointerId !== event.pointerId) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
dragState.current = null;
|
|
341
|
+
setDropLine(null);
|
|
342
|
+
if (state.dragging && state.targetIndex !== null) {
|
|
343
|
+
moveColumn(state.targetIndex);
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
onPointerCancel: () => {
|
|
347
|
+
dragState.current = null;
|
|
348
|
+
setDropLine(null);
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Recomputes the insertion point under the pointer and draws the drop line
|
|
356
|
+
* (skipped when the geometry is unavailable). Returns the target index.
|
|
357
|
+
*/
|
|
358
|
+
function updateRowDropLine(
|
|
359
|
+
handle: HTMLElement,
|
|
360
|
+
rowIndex: number,
|
|
361
|
+
clientY: number,
|
|
362
|
+
setDropLine: (line: SpreadsheetDropLine | null) => void,
|
|
363
|
+
) {
|
|
364
|
+
const others = getBodyRows(handle).filter(
|
|
365
|
+
(_, candidate) => candidate !== rowIndex,
|
|
366
|
+
);
|
|
367
|
+
const targetIndex = computeRowDropTarget(others, clientY);
|
|
368
|
+
const container = getScrollContainer(handle);
|
|
369
|
+
const edgeRow = others[Math.min(targetIndex, others.length - 1)];
|
|
370
|
+
if (!container || edgeRow === undefined) {
|
|
371
|
+
return targetIndex;
|
|
372
|
+
}
|
|
373
|
+
const rect = toContentRect(container, edgeRow.getBoundingClientRect());
|
|
374
|
+
const y = targetIndex >= others.length ? rect.top + rect.height : rect.top;
|
|
375
|
+
setDropLine({ left: rect.left, top: y - 1, width: rect.width, height: 2 });
|
|
376
|
+
return targetIndex;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The row ghosts along the y axis while dragged (or armed by long-press). */
|
|
380
|
+
function applyRowGhost(row: HTMLElement, offsetY: number) {
|
|
381
|
+
row.style.transform = `translateY(${offsetY}px)`;
|
|
382
|
+
row.style.position = "relative";
|
|
383
|
+
row.style.zIndex = "1";
|
|
384
|
+
row.style.backgroundColor = "var(--color-background)";
|
|
385
|
+
row.setAttribute("data-dragging", "true");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Pointer drag + keyboard grab for a row drag handle. Pointer: the row
|
|
390
|
+
* ghosts along the y axis and a drop line marks the insertion point; a touch
|
|
391
|
+
* pointer must long-press the handle first (a finger that wanders before the
|
|
392
|
+
* delay is treated as an aborted press, never a drag). Keyboard: Space grabs,
|
|
393
|
+
* ArrowUp/ArrowDown pick the target position, Space drops, Escape cancels;
|
|
394
|
+
* every step is announced through the table's `aria-live` region. Both paths
|
|
395
|
+
* end in a single `onRowMove(from, to)` - the consumer applies the move to
|
|
396
|
+
* its own array.
|
|
397
|
+
*/
|
|
398
|
+
function useSpreadsheetRowDrag({
|
|
399
|
+
index,
|
|
400
|
+
onRowMove,
|
|
401
|
+
setDropLine,
|
|
402
|
+
announce,
|
|
403
|
+
}: {
|
|
404
|
+
index: number;
|
|
405
|
+
onRowMove: ((fromIndex: number, toIndex: number) => void) | undefined;
|
|
406
|
+
setDropLine: (line: SpreadsheetDropLine | null) => void;
|
|
407
|
+
announce: (message: string) => void;
|
|
408
|
+
}) {
|
|
409
|
+
// Keyboard grab: the pending target position, or null when not grabbed.
|
|
410
|
+
const [keyboardTarget, setKeyboardTarget] = React.useState<number | null>(
|
|
411
|
+
null,
|
|
412
|
+
);
|
|
413
|
+
const dragState = React.useRef<{
|
|
414
|
+
pointerId: number;
|
|
415
|
+
startY: number;
|
|
416
|
+
row: HTMLElement;
|
|
417
|
+
/** False until a touch long-press fires; mouse/pen arm immediately. */
|
|
418
|
+
armed: boolean;
|
|
419
|
+
longPressTimer: number | null;
|
|
420
|
+
dragging: boolean;
|
|
421
|
+
targetIndex: number | null;
|
|
422
|
+
} | null>(null);
|
|
423
|
+
|
|
424
|
+
const clearLongPressTimer = (state: { longPressTimer: number | null }) => {
|
|
425
|
+
if (state.longPressTimer !== null) {
|
|
426
|
+
window.clearTimeout(state.longPressTimer);
|
|
427
|
+
state.longPressTimer = null;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
React.useEffect(() => {
|
|
432
|
+
return () => {
|
|
433
|
+
const state = dragState.current;
|
|
434
|
+
if (state !== null) {
|
|
435
|
+
clearLongPressTimer(state);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
}, []);
|
|
439
|
+
|
|
440
|
+
const showLineAt = (handle: HTMLElement, targetIndex: number) => {
|
|
441
|
+
const container = getScrollContainer(handle);
|
|
442
|
+
const target = getBodyRows(handle)[targetIndex];
|
|
443
|
+
if (!container || !target) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const rect = toContentRect(container, target.getBoundingClientRect());
|
|
447
|
+
const y = targetIndex > index ? rect.top + rect.height : rect.top;
|
|
448
|
+
setDropLine({ left: rect.left, top: y - 1, width: rect.width, height: 2 });
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const releaseRowGhost = (row: HTMLElement) => {
|
|
452
|
+
row.style.transform = "";
|
|
453
|
+
row.style.position = "";
|
|
454
|
+
row.style.zIndex = "";
|
|
455
|
+
row.style.backgroundColor = "";
|
|
456
|
+
row.removeAttribute("data-dragging");
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const grabOrDrop = () => {
|
|
460
|
+
if (keyboardTarget === null) {
|
|
461
|
+
setKeyboardTarget(index);
|
|
462
|
+
announce(
|
|
463
|
+
`Row ${index + 1} grabbed. Press the arrow keys to move it, space to drop, escape to cancel.`,
|
|
464
|
+
);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
setKeyboardTarget(null);
|
|
468
|
+
setDropLine(null);
|
|
469
|
+
if (keyboardTarget !== index) {
|
|
470
|
+
onRowMove?.(index, keyboardTarget);
|
|
471
|
+
}
|
|
472
|
+
announce(`Row dropped at position ${keyboardTarget + 1}.`);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const moveKeyboardTarget = (handle: HTMLElement, step: -1 | 1) => {
|
|
476
|
+
if (keyboardTarget === null) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const rowCount = getBodyRows(handle).length;
|
|
480
|
+
const next = Math.min(rowCount - 1, Math.max(0, keyboardTarget + step));
|
|
481
|
+
setKeyboardTarget(next);
|
|
482
|
+
showLineAt(handle, next);
|
|
483
|
+
announce(`Move row to position ${next + 1} of ${rowCount}.`);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const cancelKeyboardMove = () => {
|
|
487
|
+
if (keyboardTarget === null) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
setKeyboardTarget(null);
|
|
491
|
+
setDropLine(null);
|
|
492
|
+
announce("Row move cancelled.");
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
"data-grabbed": keyboardTarget === null ? undefined : true,
|
|
497
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
|
|
498
|
+
if (onRowMove === undefined) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const action = resolveRowDragKey(event.key, keyboardTarget !== null);
|
|
502
|
+
if (action === null) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
event.preventDefault();
|
|
506
|
+
if (action === "toggle") {
|
|
507
|
+
grabOrDrop();
|
|
508
|
+
} else if (action === "cancel") {
|
|
509
|
+
cancelKeyboardMove();
|
|
510
|
+
} else {
|
|
511
|
+
moveKeyboardTarget(event.currentTarget, action === "down" ? 1 : -1);
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
onBlur: () => {
|
|
515
|
+
if (keyboardTarget !== null) {
|
|
516
|
+
setKeyboardTarget(null);
|
|
517
|
+
setDropLine(null);
|
|
518
|
+
}
|
|
519
|
+
},
|
|
520
|
+
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
|
|
521
|
+
// dragState non-null = a second concurrent pointer (multi-touch); it
|
|
522
|
+
// must not hijack the drag in flight.
|
|
523
|
+
if (
|
|
524
|
+
event.button !== 0 ||
|
|
525
|
+
onRowMove === undefined ||
|
|
526
|
+
dragState.current !== null
|
|
527
|
+
) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const row = event.currentTarget.closest("tr");
|
|
531
|
+
if (!row) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
event.preventDefault();
|
|
535
|
+
const state = {
|
|
536
|
+
pointerId: event.pointerId,
|
|
537
|
+
startY: event.clientY,
|
|
538
|
+
row,
|
|
539
|
+
armed: event.pointerType !== "touch",
|
|
540
|
+
longPressTimer: null as number | null,
|
|
541
|
+
dragging: false,
|
|
542
|
+
targetIndex: null,
|
|
543
|
+
};
|
|
544
|
+
if (!state.armed) {
|
|
545
|
+
state.longPressTimer = window.setTimeout(() => {
|
|
546
|
+
state.longPressTimer = null;
|
|
547
|
+
state.armed = true;
|
|
548
|
+
// Grab cue: the row lifts in place until the finger moves.
|
|
549
|
+
applyRowGhost(state.row, 0);
|
|
550
|
+
}, ROW_DRAG_LONG_PRESS_MS);
|
|
551
|
+
}
|
|
552
|
+
dragState.current = state;
|
|
553
|
+
capturePointer(event.currentTarget, event.pointerId);
|
|
554
|
+
},
|
|
555
|
+
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
|
|
556
|
+
const state = dragState.current;
|
|
557
|
+
if (state === null || state.pointerId !== event.pointerId) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const offsetY = event.clientY - state.startY;
|
|
561
|
+
if (!state.armed) {
|
|
562
|
+
if (Math.abs(offsetY) > LONG_PRESS_MOVE_TOLERANCE_PX) {
|
|
563
|
+
clearLongPressTimer(state);
|
|
564
|
+
dragState.current = null;
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!state.dragging && Math.abs(offsetY) < DRAG_THRESHOLD_PX) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
state.dragging = true;
|
|
572
|
+
// Ghost the real row: cheap, keeps cell widths, and the transform is
|
|
573
|
+
// wiped on drop before the consumer re-renders the new order.
|
|
574
|
+
applyRowGhost(state.row, offsetY);
|
|
575
|
+
state.targetIndex = updateRowDropLine(
|
|
576
|
+
event.currentTarget,
|
|
577
|
+
index,
|
|
578
|
+
event.clientY,
|
|
579
|
+
setDropLine,
|
|
580
|
+
);
|
|
581
|
+
},
|
|
582
|
+
onPointerUp: (event: React.PointerEvent<HTMLElement>) => {
|
|
583
|
+
const state = dragState.current;
|
|
584
|
+
if (state === null || state.pointerId !== event.pointerId) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
clearLongPressTimer(state);
|
|
588
|
+
dragState.current = null;
|
|
589
|
+
releaseRowGhost(state.row);
|
|
590
|
+
setDropLine(null);
|
|
591
|
+
if (
|
|
592
|
+
state.dragging &&
|
|
593
|
+
state.targetIndex !== null &&
|
|
594
|
+
state.targetIndex !== index
|
|
595
|
+
) {
|
|
596
|
+
onRowMove?.(index, state.targetIndex);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
onPointerCancel: () => {
|
|
600
|
+
const state = dragState.current;
|
|
601
|
+
if (state === null) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
clearLongPressTimer(state);
|
|
605
|
+
dragState.current = null;
|
|
606
|
+
releaseRowGhost(state.row);
|
|
607
|
+
setDropLine(null);
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export {
|
|
613
|
+
type SpreadsheetDropLine,
|
|
614
|
+
useSpreadsheetColumnReorder,
|
|
615
|
+
useSpreadsheetColumnResize,
|
|
616
|
+
useSpreadsheetRowDrag,
|
|
617
|
+
};
|