@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,743 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Internal hook for Spreadsheet's opt-in spreadsheet keyboard layer: grid
5
+ * navigation, rectangular selection and TSV copy/paste. Not part of the
6
+ * public kit surface - enable it through `gridNavigation` on `Spreadsheet`.
7
+ *
8
+ * The grid has two modes, told apart by where DOM focus sits:
9
+ * - navigation: focus is on a `<td>` itself (roving tabindex, arrow keys move
10
+ * around, Shift+arrows select, Cmd/Ctrl+C copies);
11
+ * - edit: focus is inside the cell, on the control (Escape returns to
12
+ * navigation, Enter commits and moves down, Tab moves to the next cell).
13
+ *
14
+ * Rather than keeping a React registry of rows and cells, the hook reads the
15
+ * grid straight from the DOM on every interaction and re-stamps the
16
+ * aria/tabindex attributes in an after-every-render effect: the parts stay
17
+ * uncontrolled and consumer re-renders (added rows, reordered columns) are
18
+ * picked up for free. Under `SpreadsheetVirtualRows` the mounted rows carry
19
+ * their absolute `data-index`, positions stay absolute (row indexes over the
20
+ * FULL set) and a MutationObserver re-stamps the rows that scrolling mounts
21
+ * without any root re-render.
22
+ */
23
+
24
+ const PAGE_FALLBACK_ROW_COUNT = 10;
25
+
26
+ interface SpreadsheetGridPosition {
27
+ row: number;
28
+ column: number;
29
+ }
30
+
31
+ interface SpreadsheetGridSelection {
32
+ anchor: SpreadsheetGridPosition;
33
+ focus: SpreadsheetGridPosition;
34
+ }
35
+
36
+ /** Rectangle covered by a selection, inclusive on every edge. */
37
+ interface SpreadsheetGridRect {
38
+ top: number;
39
+ bottom: number;
40
+ left: number;
41
+ right: number;
42
+ }
43
+
44
+ interface SpreadsheetPasteData {
45
+ /** 0-based grid position of the paste target's top-left cell. */
46
+ startRow: number;
47
+ startColumn: number;
48
+ /** Parsed TSV matrix, one entry per pasted row. */
49
+ values: string[][];
50
+ }
51
+
52
+ function getDataRows(table: HTMLTableElement) {
53
+ return Array.from(
54
+ table.querySelectorAll<HTMLTableRowElement>(
55
+ ":scope > tbody > tr[data-slot=spreadsheet-row]",
56
+ ),
57
+ );
58
+ }
59
+
60
+ interface SpreadsheetGridRowEntry {
61
+ row: HTMLTableRowElement;
62
+ /** Absolute row index: virtualized `data-index` when present, DOM order otherwise. */
63
+ index: number;
64
+ }
65
+
66
+ function getRowEntries(table: HTMLTableElement): SpreadsheetGridRowEntry[] {
67
+ return getDataRows(table).map((row, positional) => {
68
+ const declared = row.getAttribute("data-index");
69
+ return { row, index: declared === null ? positional : Number(declared) };
70
+ });
71
+ }
72
+
73
+ function getRowByIndex(table: HTMLTableElement, rowIndex: number) {
74
+ return (
75
+ getRowEntries(table).find((entry) => entry.index === rowIndex)?.row ?? null
76
+ );
77
+ }
78
+
79
+ function getCellAt(table: HTMLTableElement, position: SpreadsheetGridPosition) {
80
+ const row = getRowByIndex(table, position.row);
81
+ return row === null ? null : row.cells.item(position.column);
82
+ }
83
+
84
+ function findCellPosition(
85
+ table: HTMLTableElement,
86
+ cell: HTMLTableCellElement,
87
+ ): SpreadsheetGridPosition | null {
88
+ const row = cell.parentElement;
89
+ if (!(row instanceof HTMLTableRowElement)) {
90
+ return null;
91
+ }
92
+ const entry = getRowEntries(table).find((candidate) => candidate.row === row);
93
+ if (entry === undefined) {
94
+ return null;
95
+ }
96
+ const columnIndex = Array.from(row.cells).indexOf(cell);
97
+ return columnIndex === -1 ? null : { row: entry.index, column: columnIndex };
98
+ }
99
+
100
+ /**
101
+ * Clamps to the full grid (virtual rows included), then to the mounted
102
+ * window: a jump beyond what's rendered lands on the closest mounted row.
103
+ */
104
+ function clampPosition(
105
+ table: HTMLTableElement,
106
+ position: SpreadsheetGridPosition,
107
+ virtualRowCount: number | null,
108
+ ): SpreadsheetGridPosition | null {
109
+ const entries = getRowEntries(table);
110
+ const first = entries[0];
111
+ const last = entries.at(-1);
112
+ if (first === undefined || last === undefined) {
113
+ return null;
114
+ }
115
+ const totalRows = virtualRowCount ?? entries.length;
116
+ const wanted = Math.min(totalRows - 1, Math.max(0, position.row));
117
+ const entry =
118
+ entries.find((candidate) => candidate.index === wanted) ??
119
+ (wanted < first.index ? first : last);
120
+ const columnCount = entry.row.cells.length;
121
+ if (columnCount === 0) {
122
+ return null;
123
+ }
124
+ return {
125
+ row: entry.index,
126
+ column: Math.min(columnCount - 1, Math.max(0, position.column)),
127
+ };
128
+ }
129
+
130
+ function samePosition(
131
+ left: SpreadsheetGridPosition,
132
+ right: SpreadsheetGridPosition,
133
+ ) {
134
+ return left.row === right.row && left.column === right.column;
135
+ }
136
+
137
+ function toGridRect(
138
+ anchor: SpreadsheetGridPosition,
139
+ focus: SpreadsheetGridPosition,
140
+ ): SpreadsheetGridRect {
141
+ return {
142
+ top: Math.min(anchor.row, focus.row),
143
+ bottom: Math.max(anchor.row, focus.row),
144
+ left: Math.min(anchor.column, focus.column),
145
+ right: Math.max(anchor.column, focus.column),
146
+ };
147
+ }
148
+
149
+ function isInsideRect(
150
+ position: SpreadsheetGridPosition,
151
+ rect: SpreadsheetGridRect,
152
+ ) {
153
+ return (
154
+ position.row >= rect.top &&
155
+ position.row <= rect.bottom &&
156
+ position.column >= rect.left &&
157
+ position.column <= rect.right
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Copy value of one cell: the `value` declared on `SpreadsheetCell` wins,
163
+ * then the inner control's live value, then the cell text (covers
164
+ * `SpreadsheetCellText`).
165
+ */
166
+ function getCellCopyValue(cell: HTMLTableCellElement) {
167
+ const declared = cell.getAttribute("data-grid-value");
168
+ if (declared !== null) {
169
+ return declared;
170
+ }
171
+ const control = cell.querySelector<
172
+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
173
+ >("input, select, textarea");
174
+ if (control !== null) {
175
+ return control.value;
176
+ }
177
+ return cell.textContent?.trim() ?? "";
178
+ }
179
+
180
+ function serializeRectToTsv(
181
+ table: HTMLTableElement,
182
+ rect: SpreadsheetGridRect,
183
+ ) {
184
+ const rowsByIndex = new Map(
185
+ getRowEntries(table).map((entry) => [entry.index, entry.row]),
186
+ );
187
+ const lines: string[] = [];
188
+ for (let row = rect.top; row <= rect.bottom; row += 1) {
189
+ const cells = Array.from(rowsByIndex.get(row)?.cells ?? []);
190
+ const line: string[] = [];
191
+ for (let column = rect.left; column <= rect.right; column += 1) {
192
+ const cell = cells[column];
193
+ line.push(cell === undefined ? "" : getCellCopyValue(cell));
194
+ }
195
+ lines.push(line.join("\t"));
196
+ }
197
+ return lines.join("\n");
198
+ }
199
+
200
+ function parseTsvText(text: string): string[][] {
201
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
202
+ // Spreadsheets terminate the payload with a newline; that's not a row.
203
+ if (lines.at(-1) === "") {
204
+ lines.pop();
205
+ }
206
+ return lines.map((line) => line.split("\t"));
207
+ }
208
+
209
+ const ARROW_DELTAS: Record<string, SpreadsheetGridPosition> = {
210
+ ArrowUp: { row: -1, column: 0 },
211
+ ArrowDown: { row: 1, column: 0 },
212
+ ArrowLeft: { row: 0, column: -1 },
213
+ ArrowRight: { row: 0, column: 1 },
214
+ };
215
+
216
+ /**
217
+ * Home/End move within the row; with Cmd/Ctrl they jump to the grid's
218
+ * corners. Out-of-range coordinates are clamped by the caller.
219
+ */
220
+ function resolveHomeEndTarget(
221
+ position: SpreadsheetGridPosition,
222
+ event: React.KeyboardEvent,
223
+ ): SpreadsheetGridPosition {
224
+ const toStart = event.key === "Home";
225
+ const jumpRows = event.ctrlKey || event.metaKey;
226
+ const edgeRow = toStart ? 0 : Number.MAX_SAFE_INTEGER;
227
+ return {
228
+ row: jumpRows ? edgeRow : position.row,
229
+ column: toStart ? 0 : Number.MAX_SAFE_INTEGER,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Maps a navigation-mode keydown to the (unclamped) target position, or null
235
+ * when the key doesn't navigate.
236
+ */
237
+ function resolveMoveTarget(
238
+ position: SpreadsheetGridPosition,
239
+ event: React.KeyboardEvent,
240
+ pageRowCount: number,
241
+ ): SpreadsheetGridPosition | null {
242
+ const arrow = ARROW_DELTAS[event.key];
243
+ if (arrow !== undefined) {
244
+ return {
245
+ row: position.row + arrow.row,
246
+ column: position.column + arrow.column,
247
+ };
248
+ }
249
+ if (event.key === "PageUp" || event.key === "PageDown") {
250
+ const step = event.key === "PageUp" ? -pageRowCount : pageRowCount;
251
+ return { row: position.row + step, column: position.column };
252
+ }
253
+ if (event.key === "Home" || event.key === "End") {
254
+ return resolveHomeEndTarget(position, event);
255
+ }
256
+ return null;
257
+ }
258
+
259
+ function getPageRowCount(table: HTMLTableElement) {
260
+ const container = table.closest<HTMLElement>(
261
+ "[data-slot=spreadsheet-container]",
262
+ );
263
+ const rowHeight = getDataRows(table)[0]?.getBoundingClientRect().height ?? 0;
264
+ if (container === null || rowHeight === 0) {
265
+ return PAGE_FALLBACK_ROW_COUNT;
266
+ }
267
+ return Math.max(1, Math.floor(container.clientHeight / rowHeight));
268
+ }
269
+
270
+ function isPrintableKey(event: React.KeyboardEvent) {
271
+ return (
272
+ event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey
273
+ );
274
+ }
275
+
276
+ function isCopyShortcut(event: React.KeyboardEvent) {
277
+ return (
278
+ (event.metaKey || event.ctrlKey) &&
279
+ !event.shiftKey &&
280
+ !event.altKey &&
281
+ event.key.toLowerCase() === "c"
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Focuses the cell's control, selecting a text control's content so typing
287
+ * replaces it like a spreadsheet type-over. False when the cell has nothing
288
+ * to edit (e.g. a text cell).
289
+ */
290
+ function enterEditMode(cell: HTMLTableCellElement) {
291
+ const control = cell.querySelector<HTMLElement>(
292
+ "input, select, textarea, button, a[href], [tabindex]",
293
+ );
294
+ if (control === null) {
295
+ return false;
296
+ }
297
+ control.focus();
298
+ if (
299
+ control instanceof HTMLInputElement ||
300
+ control instanceof HTMLTextAreaElement
301
+ ) {
302
+ control.select();
303
+ }
304
+ return true;
305
+ }
306
+
307
+ function stampColumnIndexes(row: HTMLTableRowElement) {
308
+ for (const [index, cell] of Array.from(row.cells).entries()) {
309
+ cell.setAttribute("aria-colindex", String(index + 1));
310
+ }
311
+ }
312
+
313
+ /** In grid mode the cells are the single tab stop: controls leave tab order. */
314
+ function stampCellControls(cell: HTMLTableCellElement) {
315
+ for (const control of cell.querySelectorAll<HTMLElement>(
316
+ "input, select, textarea, button, a[href]",
317
+ )) {
318
+ control.setAttribute("tabindex", "-1");
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Edge flags let CSS draw one border around the whole range instead of a
324
+ * ring per cell; `rect` is null for unselected cells, dropping every flag.
325
+ */
326
+ function stampSelectionEdges(
327
+ cell: HTMLTableCellElement,
328
+ position: SpreadsheetGridPosition,
329
+ rect: SpreadsheetGridRect | null,
330
+ ) {
331
+ cell.toggleAttribute("data-grid-edge-top", rect?.top === position.row);
332
+ cell.toggleAttribute("data-grid-edge-bottom", rect?.bottom === position.row);
333
+ cell.toggleAttribute("data-grid-edge-left", rect?.left === position.column);
334
+ cell.toggleAttribute("data-grid-edge-right", rect?.right === position.column);
335
+ }
336
+
337
+ function stampBodyCell(
338
+ cell: HTMLTableCellElement,
339
+ position: SpreadsheetGridPosition,
340
+ active: SpreadsheetGridPosition | null,
341
+ rect: SpreadsheetGridRect | null,
342
+ ) {
343
+ cell.setAttribute("aria-colindex", String(position.column + 1));
344
+ const isActive = active !== null && samePosition(active, position);
345
+ cell.setAttribute("tabindex", isActive ? "0" : "-1");
346
+ const selected = rect !== null && isInsideRect(position, rect);
347
+ cell.setAttribute("aria-selected", selected ? "true" : "false");
348
+ cell.toggleAttribute("data-grid-selected", selected);
349
+ stampSelectionEdges(cell, position, selected ? rect : null);
350
+ stampCellControls(cell);
351
+ }
352
+
353
+ function stampGridAttributes(
354
+ table: HTMLTableElement,
355
+ activePosition: SpreadsheetGridPosition | null,
356
+ selection: SpreadsheetGridSelection | null,
357
+ virtualRowCount: number | null,
358
+ ) {
359
+ const headerRows = Array.from(
360
+ table.querySelectorAll<HTMLTableRowElement>(":scope > thead > tr"),
361
+ );
362
+ const entries = getRowEntries(table);
363
+ const columnCount = Math.max(
364
+ headerRows[0]?.cells.length ?? 0,
365
+ entries[0]?.row.cells.length ?? 0,
366
+ );
367
+ const totalRows = virtualRowCount ?? entries.length;
368
+ table.setAttribute("aria-rowcount", String(headerRows.length + totalRows));
369
+ table.setAttribute("aria-colcount", String(columnCount));
370
+ for (const [index, headerRow] of headerRows.entries()) {
371
+ headerRow.setAttribute("aria-rowindex", String(index + 1));
372
+ stampColumnIndexes(headerRow);
373
+ }
374
+ const active = clampPosition(
375
+ table,
376
+ activePosition ?? { row: 0, column: 0 },
377
+ virtualRowCount,
378
+ );
379
+ const rect =
380
+ selection === null ? null : toGridRect(selection.anchor, selection.focus);
381
+ for (const entry of entries) {
382
+ entry.row.setAttribute(
383
+ "aria-rowindex",
384
+ String(headerRows.length + entry.index + 1),
385
+ );
386
+ for (const [columnIndex, cell] of Array.from(entry.row.cells).entries()) {
387
+ stampBodyCell(
388
+ cell,
389
+ { row: entry.index, column: columnIndex },
390
+ active,
391
+ rect,
392
+ );
393
+ }
394
+ }
395
+ }
396
+
397
+ interface SpreadsheetGridTableProps {
398
+ role?: "grid";
399
+ "aria-multiselectable"?: boolean;
400
+ onFocus?: React.FocusEventHandler<HTMLTableElement>;
401
+ onKeyDown?: React.KeyboardEventHandler<HTMLTableElement>;
402
+ onPaste?: React.ClipboardEventHandler<HTMLTableElement>;
403
+ onPointerDown?: React.PointerEventHandler<HTMLTableElement>;
404
+ onPointerMove?: React.PointerEventHandler<HTMLTableElement>;
405
+ }
406
+
407
+ function useSpreadsheetGrid({
408
+ enabled,
409
+ onPasteData,
410
+ virtualRowCount,
411
+ }: {
412
+ enabled: boolean;
413
+ onPasteData: ((data: SpreadsheetPasteData) => void) | undefined;
414
+ /** Total row count under SpreadsheetVirtualRows, null otherwise. */
415
+ virtualRowCount: number | null;
416
+ }): {
417
+ tableRef: React.RefObject<HTMLTableElement | null>;
418
+ tableProps: SpreadsheetGridTableProps;
419
+ } {
420
+ const tableRef = React.useRef<HTMLTableElement | null>(null);
421
+ const [activePosition, setActivePosition] =
422
+ React.useState<SpreadsheetGridPosition | null>(null);
423
+ const [selection, setSelection] =
424
+ React.useState<SpreadsheetGridSelection | null>(null);
425
+ const dragState = React.useRef<{
426
+ pointerId: number;
427
+ anchor: SpreadsheetGridPosition;
428
+ dragging: boolean;
429
+ } | null>(null);
430
+
431
+ // Re-stamp after EVERY render: consumer re-renders (added rows, reordered
432
+ // columns) flow through the root, so the DOM attributes stay in sync
433
+ // without a cell registry. Virtualized scrolling mounts rows WITHOUT a
434
+ // root re-render, so a MutationObserver covers those until the next one
435
+ // (childList only - stamping mutates attributes, never nodes, no loop).
436
+ React.useEffect(() => {
437
+ const table = tableRef.current;
438
+ if (!enabled || table === null) {
439
+ return;
440
+ }
441
+ stampGridAttributes(table, activePosition, selection, virtualRowCount);
442
+ const observer = new MutationObserver(() => {
443
+ stampGridAttributes(table, activePosition, selection, virtualRowCount);
444
+ });
445
+ observer.observe(table, { childList: true, subtree: true });
446
+ return () => observer.disconnect();
447
+ });
448
+
449
+ const moveFocus = (
450
+ table: HTMLTableElement,
451
+ target: SpreadsheetGridPosition,
452
+ extend: boolean,
453
+ from: SpreadsheetGridPosition,
454
+ ) => {
455
+ const clamped = clampPosition(table, target, virtualRowCount);
456
+ if (clamped === null) {
457
+ return;
458
+ }
459
+ getCellAt(table, clamped)?.focus();
460
+ setActivePosition(clamped);
461
+ if (extend) {
462
+ setSelection((current) => ({
463
+ anchor: current?.anchor ?? from,
464
+ focus: clamped,
465
+ }));
466
+ } else {
467
+ setSelection(null);
468
+ }
469
+ };
470
+
471
+ const copyRange = (
472
+ table: HTMLTableElement,
473
+ position: SpreadsheetGridPosition,
474
+ ) => {
475
+ const rect =
476
+ selection === null
477
+ ? toGridRect(position, position)
478
+ : toGridRect(selection.anchor, selection.focus);
479
+ navigator.clipboard
480
+ ?.writeText(serializeRectToTsv(table, rect))
481
+ .catch(() => {
482
+ // Clipboard access can be denied; copying stays best-effort.
483
+ });
484
+ };
485
+
486
+ const startEditing = (
487
+ event: React.KeyboardEvent<HTMLTableElement>,
488
+ cell: HTMLTableCellElement,
489
+ ) => {
490
+ if (!enterEditMode(cell)) {
491
+ return;
492
+ }
493
+ // Enter/F2 must not leak into the control, while a printable key must
494
+ // land in it (it replaces the just-selected content).
495
+ if (!isPrintableKey(event)) {
496
+ event.preventDefault();
497
+ }
498
+ setSelection(null);
499
+ };
500
+
501
+ const clearSelection = (event: React.KeyboardEvent<HTMLTableElement>) => {
502
+ if (selection === null) {
503
+ return;
504
+ }
505
+ event.preventDefault();
506
+ // A dialog hosting the grid must not close on the Escape that only
507
+ // dropped the selection.
508
+ event.stopPropagation();
509
+ setSelection(null);
510
+ };
511
+
512
+ const handleNavigationKey = (
513
+ event: React.KeyboardEvent<HTMLTableElement>,
514
+ table: HTMLTableElement,
515
+ cell: HTMLTableCellElement,
516
+ position: SpreadsheetGridPosition,
517
+ ) => {
518
+ if (isCopyShortcut(event)) {
519
+ event.preventDefault();
520
+ copyRange(table, position);
521
+ return;
522
+ }
523
+ if (event.key === "Escape") {
524
+ clearSelection(event);
525
+ return;
526
+ }
527
+ if (event.key === "Enter" || event.key === "F2" || isPrintableKey(event)) {
528
+ startEditing(event, cell);
529
+ return;
530
+ }
531
+ const target = resolveMoveTarget(position, event, getPageRowCount(table));
532
+ if (target !== null) {
533
+ event.preventDefault();
534
+ moveFocus(table, target, event.shiftKey, position);
535
+ }
536
+ };
537
+
538
+ const moveEditTab = (
539
+ event: React.KeyboardEvent<HTMLTableElement>,
540
+ table: HTMLTableElement,
541
+ position: SpreadsheetGridPosition,
542
+ ) => {
543
+ const columnCount = getRowByIndex(table, position.row)?.cells.length ?? 0;
544
+ if (columnCount === 0) {
545
+ return;
546
+ }
547
+ const totalRows = virtualRowCount ?? getDataRows(table).length;
548
+ const linear =
549
+ position.row * columnCount + position.column + (event.shiftKey ? -1 : 1);
550
+ if (linear < 0 || linear >= totalRows * columnCount) {
551
+ return; // Grid boundary: let Tab leave the grid.
552
+ }
553
+ event.preventDefault();
554
+ moveFocus(
555
+ table,
556
+ { row: Math.floor(linear / columnCount), column: linear % columnCount },
557
+ false,
558
+ position,
559
+ );
560
+ };
561
+
562
+ const handleEditKey = (
563
+ event: React.KeyboardEvent<HTMLTableElement>,
564
+ table: HTMLTableElement,
565
+ target: HTMLElement,
566
+ position: SpreadsheetGridPosition,
567
+ ) => {
568
+ if (event.defaultPrevented) {
569
+ return; // e.g. Escape already consumed by a grabbed row-drag handle.
570
+ }
571
+ if (event.key === "Escape") {
572
+ event.preventDefault();
573
+ event.stopPropagation();
574
+ getCellAt(table, position)?.focus();
575
+ return;
576
+ }
577
+ // Enter commits and moves down - but only from text inputs: buttons,
578
+ // selects and textareas keep their native Enter behavior.
579
+ if (event.key === "Enter" && target instanceof HTMLInputElement) {
580
+ event.preventDefault();
581
+ moveFocus(
582
+ table,
583
+ { row: position.row + 1, column: position.column },
584
+ false,
585
+ position,
586
+ );
587
+ return;
588
+ }
589
+ if (event.key === "Tab") {
590
+ moveEditTab(event, table, position);
591
+ }
592
+ };
593
+
594
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLTableElement>) => {
595
+ const table = tableRef.current;
596
+ const targetElement = event.target as HTMLElement;
597
+ const cell = targetElement.closest("td");
598
+ if (table === null || cell === null) {
599
+ return;
600
+ }
601
+ const position = findCellPosition(table, cell);
602
+ if (position === null) {
603
+ return;
604
+ }
605
+ if (targetElement === cell) {
606
+ handleNavigationKey(event, table, cell, position);
607
+ } else {
608
+ handleEditKey(event, table, targetElement, position);
609
+ }
610
+ };
611
+
612
+ const handleFocus = (event: React.FocusEvent<HTMLTableElement>) => {
613
+ const table = tableRef.current;
614
+ const cell = (event.target as HTMLElement).closest("td");
615
+ if (table === null || cell === null) {
616
+ return;
617
+ }
618
+ const position = findCellPosition(table, cell);
619
+ if (position === null) {
620
+ return;
621
+ }
622
+ setActivePosition((current) =>
623
+ current !== null && samePosition(current, position) ? current : position,
624
+ );
625
+ };
626
+
627
+ const handlePaste = (event: React.ClipboardEvent<HTMLTableElement>) => {
628
+ const table = tableRef.current;
629
+ const targetElement = event.target as HTMLElement;
630
+ const cell = targetElement.closest("td");
631
+ if (table === null || onPasteData === undefined || cell === null) {
632
+ return;
633
+ }
634
+ if (targetElement !== cell) {
635
+ return; // Edit mode: the control receives the paste natively.
636
+ }
637
+ const position = findCellPosition(table, cell);
638
+ if (position === null) {
639
+ return;
640
+ }
641
+ const values = parseTsvText(event.clipboardData.getData("text/plain"));
642
+ if (values.length === 0) {
643
+ return;
644
+ }
645
+ event.preventDefault();
646
+ const rect =
647
+ selection === null
648
+ ? toGridRect(position, position)
649
+ : toGridRect(selection.anchor, selection.focus);
650
+ onPasteData({ startRow: rect.top, startColumn: rect.left, values });
651
+ };
652
+
653
+ const endPointerSelection = () => {
654
+ dragState.current = null;
655
+ };
656
+
657
+ const handlePointerDown = (event: React.PointerEvent<HTMLTableElement>) => {
658
+ const table = tableRef.current;
659
+ const targetElement = event.target as HTMLElement;
660
+ if (
661
+ table === null ||
662
+ event.button !== 0 ||
663
+ targetElement.closest("button, a, input, select, textarea, label") !==
664
+ null
665
+ ) {
666
+ return;
667
+ }
668
+ const cell = targetElement.closest("td");
669
+ if (cell === null) {
670
+ return;
671
+ }
672
+ const position = findCellPosition(table, cell);
673
+ if (position === null) {
674
+ return;
675
+ }
676
+ // Take over the native mousedown: no text selection, focus lands on the
677
+ // cell itself (navigation mode) and dragging extends the selection.
678
+ event.preventDefault();
679
+ cell.focus();
680
+ if (event.shiftKey) {
681
+ const anchor = activePosition ?? position;
682
+ setSelection((current) => ({
683
+ anchor: current?.anchor ?? anchor,
684
+ focus: position,
685
+ }));
686
+ return;
687
+ }
688
+ setSelection(null);
689
+ dragState.current = {
690
+ pointerId: event.pointerId,
691
+ anchor: position,
692
+ dragging: false,
693
+ };
694
+ // The drag can end anywhere on the page, not just above the table.
695
+ window.addEventListener("pointerup", endPointerSelection, { once: true });
696
+ window.addEventListener("pointercancel", endPointerSelection, {
697
+ once: true,
698
+ });
699
+ };
700
+
701
+ const handlePointerMove = (event: React.PointerEvent<HTMLTableElement>) => {
702
+ const state = dragState.current;
703
+ const table = tableRef.current;
704
+ if (
705
+ state === null ||
706
+ table === null ||
707
+ state.pointerId !== event.pointerId
708
+ ) {
709
+ return;
710
+ }
711
+ const cell = (event.target as HTMLElement).closest("td");
712
+ if (cell === null) {
713
+ return;
714
+ }
715
+ const position = findCellPosition(table, cell);
716
+ if (position === null) {
717
+ return;
718
+ }
719
+ if (!state.dragging && samePosition(position, state.anchor)) {
720
+ return;
721
+ }
722
+ state.dragging = true;
723
+ setSelection({ anchor: state.anchor, focus: position });
724
+ };
725
+
726
+ if (!enabled) {
727
+ return { tableRef, tableProps: {} };
728
+ }
729
+ return {
730
+ tableRef,
731
+ tableProps: {
732
+ role: "grid",
733
+ "aria-multiselectable": true,
734
+ onFocus: handleFocus,
735
+ onKeyDown: handleKeyDown,
736
+ onPaste: handlePaste,
737
+ onPointerDown: handlePointerDown,
738
+ onPointerMove: handlePointerMove,
739
+ },
740
+ };
741
+ }
742
+
743
+ export { type SpreadsheetPasteData, useSpreadsheetGrid };