@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.
@@ -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
+ };