@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,1288 @@
1
+ import {
2
+ CaretDownIcon,
3
+ CaretUpDownIcon,
4
+ CaretUpIcon,
5
+ DotsSixVerticalIcon,
6
+ ImageIcon,
7
+ PlusIcon,
8
+ SpinnerIcon,
9
+ XIcon,
10
+ } from "@phosphor-icons/react";
11
+ import { useVirtualizer } from "@tanstack/react-virtual";
12
+ import { Skeleton } from "@voila.dev/ui/components/skeleton";
13
+ import { cva } from "@voila.dev/ui/cva";
14
+ import { useIsMobile } from "@voila.dev/ui/hooks/use-mobile";
15
+ import { cn } from "@voila.dev/ui/lib/utils";
16
+ import * as React from "react";
17
+ import {
18
+ type SpreadsheetDropLine,
19
+ useSpreadsheetColumnReorder,
20
+ useSpreadsheetColumnResize,
21
+ useSpreadsheetRowDrag,
22
+ } from "#/hooks/use-spreadsheet-drag.ts";
23
+ import {
24
+ type SpreadsheetPasteData,
25
+ useSpreadsheetGrid,
26
+ } from "#/hooks/use-spreadsheet-grid.ts";
27
+ import { useSpreadsheetImageDrop } from "#/hooks/use-spreadsheet-image-drop.ts";
28
+
29
+ type SpreadsheetSortDirection = "asc" | "desc";
30
+
31
+ interface SpreadsheetSort {
32
+ columnId: string;
33
+ direction: SpreadsheetSortDirection;
34
+ }
35
+
36
+ interface SpreadsheetContextValue {
37
+ stickyHeader: boolean;
38
+ sort: SpreadsheetSort | null;
39
+ onSortChange: ((sort: SpreadsheetSort | null) => void) | undefined;
40
+ columnSizing: Record<string, number>;
41
+ resizeColumn: (columnId: string, width: number) => void;
42
+ columnOrder: readonly string[] | undefined;
43
+ onColumnOrderChange: ((order: string[]) => void) | undefined;
44
+ onRowMove: ((fromIndex: number, toIndex: number) => void) | undefined;
45
+ setDropLine: (line: SpreadsheetDropLine | null) => void;
46
+ announce: (message: string) => void;
47
+ gridNavigation: boolean;
48
+ /**
49
+ * The scroll container as STATE (callback ref), not a ref object: it lands
50
+ * after the initial commit, and the state change re-renders
51
+ * `SpreadsheetVirtualRows` so the virtualizer sees its scroll element -
52
+ * a plain ref would stay null in its first layout effect (descendant
53
+ * effects run before parent refs attach) with no re-render to recover.
54
+ */
55
+ scrollContainer: HTMLDivElement | null;
56
+ setVirtualRowCount: (count: number | null) => void;
57
+ }
58
+
59
+ const SpreadsheetContext = React.createContext<SpreadsheetContextValue>({
60
+ stickyHeader: false,
61
+ sort: null,
62
+ onSortChange: undefined,
63
+ columnSizing: {},
64
+ resizeColumn: () => {},
65
+ columnOrder: undefined,
66
+ onColumnOrderChange: undefined,
67
+ onRowMove: undefined,
68
+ setDropLine: () => {},
69
+ announce: () => {},
70
+ gridNavigation: false,
71
+ scrollContainer: null,
72
+ setVirtualRowCount: () => {},
73
+ });
74
+
75
+ /**
76
+ * Absolute row index injected by `SpreadsheetVirtualRows` so each mounted
77
+ * `SpreadsheetRow` can stamp `data-index`/`aria-rowindex` itself - grid
78
+ * navigation and screen readers keep working over the unmounted rows.
79
+ */
80
+ const SpreadsheetRowIndexContext = React.createContext<number | null>(null);
81
+
82
+ // Grid-mode visuals: the focused cell's ring (plain `:focus` - a
83
+ // click-selected cell must show it too) and the selection range (tint on
84
+ // every selected cell, one outer border drawn by the per-edge ::after
85
+ // segments stamped by the grid hook).
86
+ const gridNavigationTableClassName = cn(
87
+ "[&_td:focus]:outline-none [&_td:focus]:inset-ring-2 [&_td:focus]:inset-ring-ring/70",
88
+ "[&_td[data-grid-selected]]:bg-primary/10",
89
+ "[&_td[data-grid-selected]]:after:pointer-events-none [&_td[data-grid-selected]]:after:absolute [&_td[data-grid-selected]]:after:inset-0 [&_td[data-grid-selected]]:after:content-[''] [&_td[data-grid-selected]]:after:border-primary/60",
90
+ "[&_td[data-grid-edge-top]]:after:border-t-2 [&_td[data-grid-edge-bottom]]:after:border-b-2 [&_td[data-grid-edge-left]]:after:border-l-2 [&_td[data-grid-edge-right]]:after:border-r-2",
91
+ );
92
+
93
+ // Sticky first column: every row's first CELL PART pins to the left edge
94
+ // while the other columns pan beneath it. Targeting `td[data-slot]` (the cell
95
+ // parts all stamp one) skips the full-width rows - add-row, virtual spacers -
96
+ // where pinning would only paint a stray edge line. Collapsed borders don't
97
+ // travel with sticky cells (the sticky-header trap again), so the right rule
98
+ // is redrawn as an inset shadow; the backgrounds must be opaque - color-mix
99
+ // flattens the kit's translucent tints (header muted, invalid destructive,
100
+ // selection primary) over the page background - or the panned columns would
101
+ // show through. Once the container actually pans (`data-scrolled-x`, stamped
102
+ // by a scroll listener), an elevation shadow marks the cut edge.
103
+ const stickyFirstColumnTableClassName = cn(
104
+ "[&_thead_th:first-child]:sticky [&_thead_th:first-child]:left-0 [&_thead_th:first-child]:z-2",
105
+ "[&_thead_th:first-child]:border-r-0 [&_thead_th:first-child]:bg-[color-mix(in_oklab,var(--color-muted)_50%,var(--color-background))]",
106
+ "[&_thead_th:first-child]:shadow-[inset_-1px_0_var(--color-border)]",
107
+ "[&_tbody_td[data-slot]:first-child]:sticky [&_tbody_td[data-slot]:first-child]:left-0 [&_tbody_td[data-slot]:first-child]:z-2",
108
+ "[&_tbody_td[data-slot]:first-child]:border-r-0 [&_tbody_td[data-slot]:first-child]:bg-background",
109
+ "[&_tbody_td[data-slot]:first-child]:shadow-[inset_-1px_0_var(--color-border)]",
110
+ "[&_tbody_tr[data-invalid]_td[data-slot]:first-child]:bg-[color-mix(in_oklab,var(--color-destructive)_5%,var(--color-background))]",
111
+ "[&_tbody_td[data-slot][data-grid-selected]:first-child]:bg-[color-mix(in_oklab,var(--color-primary)_10%,var(--color-background))]",
112
+ "[[data-scrolled-x]_&_thead_th:first-child]:shadow-[inset_-1px_0_var(--color-border),6px_0_8px_-6px_rgb(0_0_0/0.25)]",
113
+ "[[data-scrolled-x]_&_tbody_td[data-slot]:first-child]:shadow-[inset_-1px_0_var(--color-border),6px_0_8px_-6px_rgb(0_0_0/0.25)]",
114
+ );
115
+
116
+ /**
117
+ * Stamps `data-scrolled-x` on the scroll container while it is panned - it
118
+ * gates the sticky column's elevation shadow. Written outside React so
119
+ * panning never re-renders the table.
120
+ */
121
+ function useSpreadsheetScrolledX(
122
+ scrollContainer: HTMLDivElement | null,
123
+ enabled: boolean,
124
+ ) {
125
+ React.useEffect(() => {
126
+ if (!enabled || scrollContainer === null) {
127
+ return;
128
+ }
129
+ const update = () => {
130
+ scrollContainer.toggleAttribute(
131
+ "data-scrolled-x",
132
+ scrollContainer.scrollLeft > 0,
133
+ );
134
+ };
135
+ update();
136
+ scrollContainer.addEventListener("scroll", update, { passive: true });
137
+ return () => {
138
+ scrollContainer.removeEventListener("scroll", update);
139
+ scrollContainer.removeAttribute("data-scrolled-x");
140
+ };
141
+ }, [enabled, scrollContainer]);
142
+ }
143
+
144
+ function editableTableClassName({
145
+ hasFixedSizing,
146
+ gridNavigation,
147
+ stickyFirstColumn,
148
+ stickyHeader,
149
+ className,
150
+ }: {
151
+ hasFixedSizing: boolean;
152
+ gridNavigation: boolean;
153
+ stickyFirstColumn: boolean;
154
+ stickyHeader: boolean;
155
+ className: string | undefined;
156
+ }) {
157
+ return cn(
158
+ "w-full caption-bottom text-sm",
159
+ hasFixedSizing && "table-fixed",
160
+ gridNavigation && gridNavigationTableClassName,
161
+ stickyFirstColumn && stickyFirstColumnTableClassName,
162
+ // A sticky header goes opaque (see SpreadsheetHeader); its pinned
163
+ // corner cell must follow.
164
+ stickyFirstColumn &&
165
+ stickyHeader &&
166
+ "[&_thead_th:first-child]:bg-background",
167
+ className,
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Assembles the memoized context value shared with every part, including the
173
+ * `resizeColumn` writer (internal state unless `columnSizing` is controlled).
174
+ */
175
+ function useSpreadsheetContextValue({
176
+ stickyHeader,
177
+ sort,
178
+ onSortChange,
179
+ sizing,
180
+ columnSizing,
181
+ onColumnSizingChange,
182
+ setInternalSizing,
183
+ columnOrder,
184
+ onColumnOrderChange,
185
+ onRowMove,
186
+ setDropLine,
187
+ announce,
188
+ gridNavigation,
189
+ scrollContainer,
190
+ setVirtualRowCount,
191
+ }: Omit<SpreadsheetContextValue, "columnSizing" | "resizeColumn"> & {
192
+ sizing: Record<string, number>;
193
+ columnSizing: Record<string, number> | undefined;
194
+ onColumnSizingChange: ((sizing: Record<string, number>) => void) | undefined;
195
+ setInternalSizing: (sizing: Record<string, number>) => void;
196
+ }) {
197
+ return React.useMemo<SpreadsheetContextValue>(
198
+ () => ({
199
+ stickyHeader,
200
+ sort,
201
+ onSortChange,
202
+ columnSizing: sizing,
203
+ resizeColumn: (columnId, width) => {
204
+ const next = { ...sizing, [columnId]: Math.round(width) };
205
+ if (columnSizing === undefined) {
206
+ setInternalSizing(next);
207
+ }
208
+ onColumnSizingChange?.(next);
209
+ },
210
+ columnOrder,
211
+ onColumnOrderChange,
212
+ onRowMove,
213
+ setDropLine,
214
+ announce,
215
+ gridNavigation,
216
+ scrollContainer,
217
+ setVirtualRowCount,
218
+ }),
219
+ [
220
+ stickyHeader,
221
+ sort,
222
+ onSortChange,
223
+ sizing,
224
+ columnSizing,
225
+ onColumnSizingChange,
226
+ setInternalSizing,
227
+ columnOrder,
228
+ onColumnOrderChange,
229
+ onRowMove,
230
+ setDropLine,
231
+ announce,
232
+ gridNavigation,
233
+ scrollContainer,
234
+ setVirtualRowCount,
235
+ ],
236
+ );
237
+ }
238
+
239
+ /** Insertion indicator for column/row drags, drawn over the container. */
240
+ function SpreadsheetDropLineIndicator({
241
+ dropLine,
242
+ }: {
243
+ dropLine: SpreadsheetDropLine | null;
244
+ }) {
245
+ if (dropLine === null) {
246
+ return null;
247
+ }
248
+ return (
249
+ <div
250
+ aria-hidden="true"
251
+ data-slot="spreadsheet-drop-line"
252
+ className="pointer-events-none absolute z-20 rounded-full bg-primary"
253
+ style={dropLine}
254
+ />
255
+ );
256
+ }
257
+
258
+ /** The scroll container + table + drag overlays of the desktop rendering. */
259
+ function SpreadsheetDesktopTable({
260
+ contextValue,
261
+ setScrollContainer,
262
+ stickyHeader,
263
+ containerClassName,
264
+ tableClassName,
265
+ ariaRowCount,
266
+ dropLine,
267
+ announcement,
268
+ grid,
269
+ tableProps,
270
+ }: {
271
+ contextValue: SpreadsheetContextValue;
272
+ setScrollContainer: (element: HTMLDivElement | null) => void;
273
+ stickyHeader: boolean;
274
+ containerClassName: string | undefined;
275
+ tableClassName: string;
276
+ ariaRowCount: number | undefined;
277
+ dropLine: SpreadsheetDropLine | null;
278
+ announcement: string;
279
+ grid: ReturnType<typeof useSpreadsheetGrid>;
280
+ tableProps: React.ComponentProps<"table">;
281
+ }) {
282
+ return (
283
+ <SpreadsheetContext.Provider value={contextValue}>
284
+ <div
285
+ ref={setScrollContainer}
286
+ data-slot="spreadsheet-container"
287
+ className={cn(
288
+ "relative w-full overflow-x-auto rounded-lg border border-input",
289
+ stickyHeader && "overflow-y-auto",
290
+ containerClassName,
291
+ )}
292
+ >
293
+ <table
294
+ data-slot="spreadsheet"
295
+ aria-rowcount={ariaRowCount}
296
+ className={tableClassName}
297
+ {...tableProps}
298
+ {...grid.tableProps}
299
+ ref={grid.tableRef}
300
+ />
301
+ <SpreadsheetDropLineIndicator dropLine={dropLine} />
302
+ </div>
303
+ {/* Drag interactions announce their keyboard steps here. */}
304
+ <div role="status" aria-live="polite" className="sr-only">
305
+ {announcement}
306
+ </div>
307
+ </SpreadsheetContext.Provider>
308
+ );
309
+ }
310
+
311
+ /** The below-`md` replacement for the table: one card per row. */
312
+ function SpreadsheetMobileList({
313
+ rowCount,
314
+ renderMobileRow,
315
+ mobileAddRow,
316
+ }: {
317
+ rowCount: number | undefined;
318
+ renderMobileRow: (index: number) => React.ReactNode;
319
+ mobileAddRow: React.ReactNode;
320
+ }) {
321
+ return (
322
+ <div data-slot="spreadsheet-mobile-list" className="flex flex-col gap-3">
323
+ {rowCount !== undefined && rowCount > 0 ? (
324
+ <ul className="flex flex-col gap-2">
325
+ {Array.from({ length: rowCount }, (_, index) => (
326
+ <li
327
+ // Index keys are safe here: the card fields are controlled by
328
+ // the consumer's row state, never by DOM state.
329
+ key={index}
330
+ data-slot="spreadsheet-mobile-card"
331
+ className="rounded-lg border border-input bg-card p-3"
332
+ >
333
+ {renderMobileRow(index)}
334
+ </li>
335
+ ))}
336
+ </ul>
337
+ ) : null}
338
+ {mobileAddRow}
339
+ </div>
340
+ );
341
+ }
342
+
343
+ type SpreadsheetRootProps = React.ComponentProps<"table"> & {
344
+ containerClassName?: string;
345
+ /**
346
+ * Pins the header row while the body scrolls. Only useful together with a
347
+ * `max-h-*` on the container (via `containerClassName`).
348
+ */
349
+ stickyHeader?: boolean;
350
+ /**
351
+ * Pins each row's first cell while the table pans horizontally, keeping the
352
+ * row label readable on narrow screens; an edge shadow appears once the
353
+ * container is actually scrolled. Render a read-only part
354
+ * (`SpreadsheetCellText`) as the first column - a panning grid under a
355
+ * pinned editable cell is disorienting.
356
+ */
357
+ stickyFirstColumn?: boolean;
358
+ /**
359
+ * Controlled sort state for `sortable` header cells. The table never sorts
360
+ * the rows itself: reorder your row array when this changes (a sorted view
361
+ * over indexed form rows would break their binding).
362
+ */
363
+ sort?: SpreadsheetSort | null;
364
+ onSortChange?: (sort: SpreadsheetSort | null) => void;
365
+ /**
366
+ * Column widths by `columnId`, written by `resizable` header cells. Leave
367
+ * undefined for internal state; pass it (with `onColumnSizingChange`) to
368
+ * control or persist the widths. Any entry switches the table to
369
+ * `table-layout: fixed`.
370
+ */
371
+ columnSizing?: Record<string, number>;
372
+ onColumnSizingChange?: (sizing: Record<string, number>) => void;
373
+ /**
374
+ * Controlled column order, as the list of `columnId`s currently rendered.
375
+ * Providing it (with `onColumnOrderChange`) makes identified header cells
376
+ * draggable and Alt+arrow movable; the consumer re-renders columns, header
377
+ * and body cells alike, in the received order.
378
+ */
379
+ columnOrder?: string[];
380
+ onColumnOrderChange?: (order: string[]) => void;
381
+ /**
382
+ * Enables `SpreadsheetDragHandle` cells: called with the dragged row's
383
+ * index and its target position. Apply the move to your row array.
384
+ */
385
+ onRowMove?: (fromIndex: number, toIndex: number) => void;
386
+ /**
387
+ * Opt-in spreadsheet keyboard layer, upgrading the table to `role="grid"`:
388
+ * the body cells become a single roving tab stop (arrows/Home/End/PageUp/
389
+ * PageDown move, Enter/F2/typing edits, Escape comes back), Shift+arrows or
390
+ * pointer drag select a rectangle and Cmd/Ctrl+C copies it as TSV (the
391
+ * `value` prop on `SpreadsheetCell` overrides what's copied). Off by
392
+ * default: the phase-1 native Tab order stays the baseline.
393
+ */
394
+ gridNavigation?: boolean;
395
+ /**
396
+ * Grid-mode paste (Cmd/Ctrl+V on a cell in navigation mode): receives the
397
+ * parsed TSV matrix and the target's top-left position. The table never
398
+ * mutates anything - apply the values to your row array, extending it when
399
+ * the paste overflows (multi-row pastes from Excel/Sheets are the target
400
+ * use case).
401
+ */
402
+ onPasteData?: (data: SpreadsheetPasteData) => void;
403
+ /** Total number of rows - only read by the mobile card mode. */
404
+ rowCount?: number;
405
+ /**
406
+ * Card content for one row below the `md` breakpoint. When provided (with
407
+ * `rowCount`), the table is replaced on mobile by a vertical stack of
408
+ * cards - a dense grid is not editable with a thumb. Render the SAME
409
+ * controlled fields in a labelled vertical layout (`Field` + regular
410
+ * controls, not flattened) plus the row's delete button; `mobileAddRow`
411
+ * takes over from `SpreadsheetAddRow` under the stack.
412
+ */
413
+ renderMobileRow?: (index: number) => React.ReactNode;
414
+ /**
415
+ * Rendered full-width under the mobile card stack - put an
416
+ * `SpreadsheetMobileAddRow` here, wired like your `SpreadsheetAddRow`.
417
+ */
418
+ mobileAddRow?: React.ReactNode;
419
+ };
420
+
421
+ /**
422
+ * Spreadsheet-style editable grid: rows of kit form controls (`Input`,
423
+ * `MoneyInput`, `NativeSelect`, `Checkbox`, `Switch`) rendered flush inside
424
+ * dense cells, with the focus/invalid ring drawn on the cell instead of the
425
+ * control. Purely presentational - the consumer owns the row state (an array
426
+ * field, effect-form rows, plain `useState`) and wires each control's
427
+ * controlled contract. Column headers don't label the controls for screen
428
+ * readers: give every control and action button its own `aria-label`.
429
+ * `gridNavigation` layers spreadsheet keyboard semantics (roving cells,
430
+ * range selection, TSV copy/paste) on top - see the prop's doc.
431
+ */
432
+ function SpreadsheetRoot({
433
+ rowCount,
434
+ renderMobileRow,
435
+ mobileAddRow,
436
+ ...props
437
+ }: SpreadsheetRootProps) {
438
+ const isMobile = useIsMobile();
439
+ if (isMobile && renderMobileRow !== undefined) {
440
+ return (
441
+ <SpreadsheetMobileList
442
+ rowCount={rowCount}
443
+ renderMobileRow={renderMobileRow}
444
+ mobileAddRow={mobileAddRow}
445
+ />
446
+ );
447
+ }
448
+ return <SpreadsheetDesktop {...props} />;
449
+ }
450
+
451
+ /** Hooks, context and class assembly behind the desktop rendering. */
452
+ function SpreadsheetDesktop({
453
+ className,
454
+ containerClassName,
455
+ stickyHeader = false,
456
+ stickyFirstColumn = false,
457
+ sort = null,
458
+ onSortChange,
459
+ columnSizing,
460
+ onColumnSizingChange,
461
+ columnOrder,
462
+ onColumnOrderChange,
463
+ onRowMove,
464
+ gridNavigation = false,
465
+ onPasteData,
466
+ ...props
467
+ }: Omit<
468
+ SpreadsheetRootProps,
469
+ "rowCount" | "renderMobileRow" | "mobileAddRow"
470
+ >) {
471
+ const [internalSizing, setInternalSizing] = React.useState<
472
+ Record<string, number>
473
+ >({});
474
+ const [dropLine, setDropLine] = React.useState<SpreadsheetDropLine | null>(
475
+ null,
476
+ );
477
+ const [announcement, setAnnouncement] = React.useState("");
478
+ const [virtualRowCount, setVirtualRowCount] = React.useState<number | null>(
479
+ null,
480
+ );
481
+ const [scrollContainer, setScrollContainer] =
482
+ React.useState<HTMLDivElement | null>(null);
483
+ const sizing = columnSizing ?? internalSizing;
484
+ const grid = useSpreadsheetGrid({
485
+ enabled: gridNavigation,
486
+ onPasteData,
487
+ virtualRowCount,
488
+ });
489
+ useSpreadsheetScrolledX(scrollContainer, stickyFirstColumn);
490
+ const contextValue = useSpreadsheetContextValue({
491
+ stickyHeader,
492
+ sort,
493
+ onSortChange,
494
+ sizing,
495
+ columnSizing,
496
+ onColumnSizingChange,
497
+ setInternalSizing,
498
+ columnOrder,
499
+ onColumnOrderChange,
500
+ onRowMove,
501
+ setDropLine,
502
+ announce: setAnnouncement,
503
+ gridNavigation,
504
+ scrollContainer,
505
+ setVirtualRowCount,
506
+ });
507
+ return (
508
+ <SpreadsheetDesktopTable
509
+ contextValue={contextValue}
510
+ setScrollContainer={setScrollContainer}
511
+ stickyHeader={stickyHeader}
512
+ containerClassName={containerClassName}
513
+ tableClassName={editableTableClassName({
514
+ hasFixedSizing: Object.keys(sizing).length > 0,
515
+ gridNavigation,
516
+ stickyFirstColumn,
517
+ stickyHeader,
518
+ className,
519
+ })}
520
+ // One header row assumed - grid navigation restamps the exact count
521
+ // when enabled.
522
+ ariaRowCount={virtualRowCount === null ? undefined : virtualRowCount + 1}
523
+ dropLine={dropLine}
524
+ announcement={announcement}
525
+ grid={grid}
526
+ tableProps={props}
527
+ />
528
+ );
529
+ }
530
+
531
+ function SpreadsheetColumns(props: React.ComponentProps<"colgroup">) {
532
+ return <colgroup data-slot="spreadsheet-columns" {...props} />;
533
+ }
534
+
535
+ /**
536
+ * Column widths live on `<col>` elements (not header classes) so column
537
+ * resizing and `table-layout: fixed` have a single place to write to. A
538
+ * resized width (matched by `columnId`) overrides the `width` prop.
539
+ */
540
+ function SpreadsheetColumn({
541
+ columnId,
542
+ width,
543
+ style,
544
+ ...props
545
+ }: React.ComponentProps<"col"> & {
546
+ /** Matches the `columnId` of the header cell that resizes this column. */
547
+ columnId?: string;
548
+ width?: number | string;
549
+ }) {
550
+ const { columnSizing } = React.useContext(SpreadsheetContext);
551
+ const sizedWidth =
552
+ columnId === undefined ? undefined : columnSizing[columnId];
553
+ const finalWidth = sizedWidth ?? width;
554
+ return (
555
+ <col
556
+ data-slot="spreadsheet-column"
557
+ style={finalWidth === undefined ? style : { ...style, width: finalWidth }}
558
+ {...props}
559
+ />
560
+ );
561
+ }
562
+
563
+ function SpreadsheetHeader({
564
+ className,
565
+ ...props
566
+ }: React.ComponentProps<"thead">) {
567
+ const { stickyHeader } = React.useContext(SpreadsheetContext);
568
+ return (
569
+ <thead
570
+ data-slot="spreadsheet-header"
571
+ className={cn(
572
+ "bg-muted/50",
573
+ // Collapsed borders don't travel with a sticky thead, so the bottom
574
+ // rule is redrawn as an inset shadow and the header cells drop their
575
+ // own border-b. The translucent tint also goes opaque: scrolled rows
576
+ // would show through `bg-muted/50`.
577
+ stickyHeader &&
578
+ "sticky top-0 z-10 bg-background shadow-[inset_0_-1px_0_0_var(--color-border)] [&_th]:border-b-0",
579
+ className,
580
+ )}
581
+ {...props}
582
+ />
583
+ );
584
+ }
585
+
586
+ const ARIA_SORT_BY_DIRECTION: Record<
587
+ SpreadsheetSortDirection,
588
+ "ascending" | "descending"
589
+ > = {
590
+ asc: "ascending",
591
+ desc: "descending",
592
+ };
593
+
594
+ function SpreadsheetSortCaret({
595
+ direction,
596
+ }: {
597
+ direction: SpreadsheetSortDirection | undefined;
598
+ }) {
599
+ if (direction === "asc") {
600
+ return <CaretUpIcon className="size-3.5 shrink-0" />;
601
+ }
602
+ if (direction === "desc") {
603
+ return <CaretDownIcon className="size-3.5 shrink-0" />;
604
+ }
605
+ return (
606
+ <CaretUpDownIcon className="size-3.5 shrink-0 text-muted-foreground/50" />
607
+ );
608
+ }
609
+
610
+ /**
611
+ * Focusable 8px strip on the header cell's right edge. Pointer drag or
612
+ * ArrowLeft/ArrowRight (16px steps) resize the column, writing through the
613
+ * table's `columnSizing`.
614
+ */
615
+ function SpreadsheetResizeHandle({ columnId }: { columnId: string }) {
616
+ const { columnSizing, resizeColumn } = React.useContext(SpreadsheetContext);
617
+ const handleProps = useSpreadsheetColumnResize({
618
+ width: columnSizing[columnId],
619
+ onWidthChange: (width) => resizeColumn(columnId, width),
620
+ });
621
+ return (
622
+ <div
623
+ data-slot="spreadsheet-resize-handle"
624
+ role="separator"
625
+ aria-orientation="vertical"
626
+ aria-label="Resize column"
627
+ tabIndex={0}
628
+ className="absolute top-0 right-0 z-10 h-full w-2 cursor-col-resize touch-none outline-none select-none after:absolute after:top-1/2 after:right-px after:h-4 after:w-0.5 after:-translate-y-1/2 after:rounded-full after:bg-transparent hover:after:bg-ring focus-visible:after:bg-ring"
629
+ {...handleProps}
630
+ />
631
+ );
632
+ }
633
+
634
+ /**
635
+ * `sortable` turns the header into a tri-state sort button (needs `columnId`
636
+ * plus `sort`/`onSortChange` on the table); `resizable` adds a resize handle
637
+ * on the right edge. When the table has a controlled `columnOrder`, any
638
+ * identified header cell also becomes draggable, with Alt+ArrowLeft/Right as
639
+ * the keyboard alternative (a `sortable` head stays pointer-draggable only
640
+ * through that keyboard path: its surface is the sort button).
641
+ */
642
+ function SpreadsheetHead({
643
+ className,
644
+ columnId,
645
+ sortable = false,
646
+ resizable = false,
647
+ children,
648
+ ...props
649
+ }: React.ComponentProps<"th"> & {
650
+ /** Stable column identifier for sorting/resizing/reordering. */
651
+ columnId?: string;
652
+ sortable?: boolean;
653
+ resizable?: boolean;
654
+ }) {
655
+ const context = React.useContext(SpreadsheetContext);
656
+ const reorder = useSpreadsheetColumnReorder({
657
+ columnId,
658
+ columnOrder: context.columnOrder,
659
+ onColumnOrderChange: context.onColumnOrderChange,
660
+ setDropLine: context.setDropLine,
661
+ announce: context.announce,
662
+ });
663
+ const canSort = sortable && columnId !== undefined;
664
+ const sortDirection =
665
+ canSort && context.sort?.columnId === columnId
666
+ ? context.sort.direction
667
+ : undefined;
668
+ return (
669
+ <th
670
+ scope="col"
671
+ data-slot="spreadsheet-head"
672
+ data-column-id={columnId}
673
+ aria-sort={
674
+ sortDirection === undefined
675
+ ? undefined
676
+ : ARIA_SORT_BY_DIRECTION[sortDirection]
677
+ }
678
+ tabIndex={reorder.enabled ? 0 : undefined}
679
+ className={cn(
680
+ "h-8 border-r border-b border-input px-2.5 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground last:border-r-0",
681
+ canSort && "p-0",
682
+ resizable && "relative",
683
+ reorder.enabled &&
684
+ "cursor-grab touch-none outline-none select-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring/70",
685
+ className,
686
+ )}
687
+ {...props}
688
+ {...reorder.headProps}
689
+ >
690
+ {canSort ? (
691
+ <button
692
+ type="button"
693
+ className="flex h-8 w-full items-center gap-1 px-2.5 text-left outline-none hover:bg-muted/50 hover:text-foreground focus-visible:inset-ring-2 focus-visible:inset-ring-ring/70"
694
+ onClick={() => {
695
+ if (context.onSortChange === undefined) {
696
+ return;
697
+ }
698
+ context.onSortChange(
699
+ sortDirection === undefined
700
+ ? { columnId, direction: "asc" }
701
+ : sortDirection === "asc"
702
+ ? { columnId, direction: "desc" }
703
+ : null,
704
+ );
705
+ }}
706
+ >
707
+ <span className="truncate">{children}</span>
708
+ <SpreadsheetSortCaret direction={sortDirection} />
709
+ </button>
710
+ ) : (
711
+ children
712
+ )}
713
+ {resizable && columnId !== undefined ? (
714
+ <SpreadsheetResizeHandle columnId={columnId} />
715
+ ) : null}
716
+ </th>
717
+ );
718
+ }
719
+
720
+ function SpreadsheetBody({
721
+ className,
722
+ ...props
723
+ }: React.ComponentProps<"tbody">) {
724
+ return (
725
+ <tbody
726
+ data-slot="spreadsheet-body"
727
+ className={cn("[&_tr:last-child>td]:border-b-0", className)}
728
+ {...props}
729
+ />
730
+ );
731
+ }
732
+
733
+ /**
734
+ * No hover background on purpose: the controls sit on transparent
735
+ * backgrounds, so a row tint would bleed into every field.
736
+ */
737
+ function SpreadsheetRow({
738
+ className,
739
+ invalid,
740
+ ...props
741
+ }: React.ComponentProps<"tr"> & { invalid?: boolean }) {
742
+ const virtualIndex = React.useContext(SpreadsheetRowIndexContext);
743
+ return (
744
+ <tr
745
+ data-slot="spreadsheet-row"
746
+ data-index={virtualIndex ?? undefined}
747
+ // 1-based, after the (single assumed) header row; grid navigation
748
+ // restamps with the exact header count when enabled.
749
+ aria-rowindex={virtualIndex === null ? undefined : virtualIndex + 2}
750
+ data-invalid={invalid || undefined}
751
+ className={cn("group/editable-row", className)}
752
+ {...props}
753
+ />
754
+ );
755
+ }
756
+
757
+ /**
758
+ * Windowed rendering for large row sets (worth it from ~100 rows), driven by
759
+ * `@tanstack/react-virtual`: only the visible slice of rows mounts, framed by
760
+ * two spacer rows so the native table layout (colgroup widths, borders) stays
761
+ * intact. `children` renders ONE row by absolute index - keep your usual
762
+ * `SpreadsheetRow` markup there, keyed by your row's identity.
763
+ *
764
+ * Constraints: fixed-height rows (the kit's 32px + 1px rule - pass
765
+ * `estimatedRowHeight` if yours differ), `stickyHeader` plus a `max-h-*`
766
+ * container (the container is the scroll element), and no `onRowMove` row
767
+ * dragging (positional drop targets don't survive windowing). Grid
768
+ * navigation works: rows stamp their absolute `data-index`, so positions,
769
+ * selection and paste targets stay correct; long jumps (PageUp/PageDown)
770
+ * clamp to the mounted window.
771
+ */
772
+ function SpreadsheetVirtualRows({
773
+ count,
774
+ estimatedRowHeight = 33,
775
+ overscan = 8,
776
+ children,
777
+ }: {
778
+ /** Total number of rows, mounted or not. */
779
+ count: number;
780
+ /** Pixel height of one row, borders included. */
781
+ estimatedRowHeight?: number;
782
+ /** Extra rows rendered on both sides of the visible window. */
783
+ overscan?: number;
784
+ children: (index: number) => React.ReactNode;
785
+ }) {
786
+ const { scrollContainer, setVirtualRowCount } =
787
+ React.useContext(SpreadsheetContext);
788
+ const virtualizer = useVirtualizer({
789
+ count,
790
+ getScrollElement: () => scrollContainer,
791
+ estimateSize: () => estimatedRowHeight,
792
+ overscan,
793
+ });
794
+ React.useEffect(() => {
795
+ setVirtualRowCount(count);
796
+ return () => setVirtualRowCount(null);
797
+ }, [count, setVirtualRowCount]);
798
+ const items = virtualizer.getVirtualItems();
799
+ const paddingTop = items[0]?.start ?? 0;
800
+ const paddingBottom = virtualizer.getTotalSize() - (items.at(-1)?.end ?? 0);
801
+ return (
802
+ <>
803
+ <SpreadsheetVirtualSpacer height={paddingTop} />
804
+ {items.map((item) => (
805
+ <SpreadsheetRowIndexContext.Provider key={item.key} value={item.index}>
806
+ {children(item.index)}
807
+ </SpreadsheetRowIndexContext.Provider>
808
+ ))}
809
+ <SpreadsheetVirtualSpacer height={paddingBottom} />
810
+ </>
811
+ );
812
+ }
813
+
814
+ /** Keeps the scrollbar honest for the unmounted rows above/below the window. */
815
+ function SpreadsheetVirtualSpacer({ height }: { height: number }) {
816
+ if (height <= 0) {
817
+ return null;
818
+ }
819
+ return (
820
+ <tr data-slot="spreadsheet-virtual-spacer">
821
+ <td
822
+ aria-hidden="true"
823
+ colSpan={1000}
824
+ className="border-0 p-0"
825
+ style={{ height }}
826
+ />
827
+ </tr>
828
+ );
829
+ }
830
+
831
+ /**
832
+ * The `control` variant melts the kit's form controls into the cell: borders,
833
+ * radii, backgrounds and focus/invalid rings are stripped off the control and
834
+ * replaced by an `inset-ring` on the `<td>` (a plain `ring` would be clipped
835
+ * by adjacent cells under border-collapse; the separate family also never
836
+ * collides with the controls' own `ring-*` classes). Traps encoded below:
837
+ *
838
+ * - `MoneyInput` overwrites its `InputGroup`'s slot with
839
+ * `data-slot="money-input"`, so group flattening targets `fieldset[data-slot]`,
840
+ * never `[data-slot=input-group]`.
841
+ * - The group's inner input is `data-slot="input-group-control"` and already
842
+ * flattened by `InputGroupInput`; only the fieldset needs neutralizing.
843
+ * - `InputGroup` raises its rings with `has-[...]` selectors, so the `ring-0`
844
+ * overrides must repeat the same `:has(...)` to win on specificity.
845
+ * - `border-0` zeroes the border width, so color-only rules like
846
+ * `aria-invalid:border-destructive` become inert - no specificity battle.
847
+ * - Never force `text-sm` on inputs: `text-base md:text-sm` in `input.tsx` is
848
+ * an iOS anti-zoom guard.
849
+ * - `mx-auto` + the cell's `align-middle` center Checkbox/Switch without
850
+ * turning the td into a flex box; Switch is inline-flex, so it also gets
851
+ * `flex` (auto margins only center block-level boxes).
852
+ */
853
+ const editableTableCellVariants = cva({
854
+ base: "relative border-r border-b border-input align-middle last:border-r-0 group-data-[invalid=true]/editable-row:bg-destructive/5",
855
+ variants: {
856
+ variant: {
857
+ text: "px-2.5 py-1.5 whitespace-nowrap text-muted-foreground",
858
+ control: cn(
859
+ "p-0 focus-within:z-1",
860
+ // Cell-level focus/invalid rings, replacing the controls' own. The
861
+ // focus ring is scoped to valid cells and the focused+invalid state
862
+ // stacks two :has(), so the destructive ring wins on specificity
863
+ // instead of depending on rule order in the generated stylesheet.
864
+ "not-has-[[aria-invalid=true]]:has-[:focus-visible]:inset-ring-2 not-has-[[aria-invalid=true]]:has-[:focus-visible]:inset-ring-ring/70",
865
+ "has-[[aria-invalid=true]]:bg-destructive/5 has-[[aria-invalid=true]]:inset-ring-2 has-[[aria-invalid=true]]:inset-ring-destructive/30",
866
+ "has-[[aria-invalid=true]]:has-[:focus-visible]:inset-ring-destructive/60",
867
+ // Flatten Input / Textarea.
868
+ "[&_[data-slot=input]]:rounded-none [&_[data-slot=input]]:border-0 [&_[data-slot=input]]:bg-transparent [&_[data-slot=input]]:shadow-none",
869
+ "[&_[data-slot=input]:focus-visible]:ring-0 [&_[data-slot=input][aria-invalid=true]]:ring-0",
870
+ "[&_[data-slot=input]:disabled]:bg-transparent dark:[&_[data-slot=input]]:bg-transparent",
871
+ // Flatten NativeSelect (the border lives on the inner <select>).
872
+ "[&_[data-slot=native-select]]:rounded-none [&_[data-slot=native-select]]:border-0",
873
+ "[&_[data-slot=native-select]:focus-visible]:ring-0 [&_[data-slot=native-select][aria-invalid=true]]:ring-0",
874
+ "dark:[&_[data-slot=native-select]]:bg-transparent",
875
+ // Flatten InputGroup AND MoneyInput - see the data-slot trap above.
876
+ "[&_fieldset[data-slot]]:rounded-none [&_fieldset[data-slot]]:border-0 [&_fieldset[data-slot]]:ring-0",
877
+ "[&_fieldset[data-slot]:has([data-slot=input-group-control]:focus-visible)]:ring-0",
878
+ "[&_fieldset[data-slot]:has([data-slot][aria-invalid=true])]:ring-0",
879
+ "dark:[&_fieldset[data-slot]]:bg-transparent",
880
+ // Flatten TranslationInput's trailing locale select (the group
881
+ // itself is already covered by the fieldset[data-slot] rules).
882
+ "[&_[data-slot=translation-input-locale]:focus-visible]:ring-0",
883
+ // NestedTableInput fills its cell and defers ring/tint to it.
884
+ "[&_[data-slot=nested-table-input]]:rounded-none [&_[data-slot=nested-table-input]]:border-0",
885
+ "[&_[data-slot=nested-table-input]:focus-visible]:ring-0",
886
+ // Center non-filling controls; cut their rings.
887
+ "[&_[data-slot=checkbox]]:mx-auto [&_[data-slot=switch]]:mx-auto [&_[data-slot=switch]]:flex",
888
+ "[&_[data-slot=checkbox]:focus-visible]:ring-0 [&_[data-slot=switch]:focus-visible]:ring-0",
889
+ ),
890
+ },
891
+ },
892
+ defaultVariants: { variant: "control" },
893
+ });
894
+
895
+ // Computed once: the control variant is a large string and runs through
896
+ // twMerge, no reason to redo that per cell per render.
897
+ const controlCellClassName = editableTableCellVariants({ variant: "control" });
898
+ const textCellClassName = editableTableCellVariants({ variant: "text" });
899
+
900
+ /**
901
+ * Hosts exactly one form control. Clicking the cell's empty space forwards
902
+ * focus to the control (or, under `gridNavigation`, to the cell itself); a
903
+ * consumer `onClick` runs first and can cancel the forwarding with
904
+ * `event.preventDefault()`. Clicks landing on interactive content
905
+ * (Checkbox/Switch render `<button>`s) are left alone.
906
+ */
907
+ function SpreadsheetCell({
908
+ className,
909
+ onClick,
910
+ value,
911
+ ...props
912
+ }: React.ComponentProps<"td"> & {
913
+ /**
914
+ * What `gridNavigation` copy serializes for this cell instead of the inner
915
+ * control's DOM value - required for controls whose DOM value isn't the
916
+ * data (Checkbox, Switch), useful whenever the copied text should differ
917
+ * from what the control displays.
918
+ */
919
+ value?: string;
920
+ }) {
921
+ const { gridNavigation } = React.useContext(SpreadsheetContext);
922
+ return (
923
+ <td
924
+ data-slot="spreadsheet-cell"
925
+ data-grid-value={value}
926
+ className={cn(controlCellClassName, className)}
927
+ onClick={(event) => {
928
+ onClick?.(event);
929
+ if (event.defaultPrevented) {
930
+ return;
931
+ }
932
+ const target = event.target as HTMLElement;
933
+ if (target.closest("button, a, input, select, textarea, label")) {
934
+ return;
935
+ }
936
+ if (gridNavigation) {
937
+ // Navigation mode owns plain clicks; editing starts from the
938
+ // control itself, Enter, F2 or typing.
939
+ event.currentTarget.focus();
940
+ return;
941
+ }
942
+ event.currentTarget
943
+ .querySelector<HTMLElement>(
944
+ "input, select, textarea, button, [tabindex]",
945
+ )
946
+ ?.focus();
947
+ }}
948
+ {...props}
949
+ />
950
+ );
951
+ }
952
+
953
+ /** Read-only cell (row labels, computed values) in the same dense grid. */
954
+ function SpreadsheetCellText({
955
+ className,
956
+ ...props
957
+ }: React.ComponentProps<"td">) {
958
+ return (
959
+ <td
960
+ data-slot="spreadsheet-cell-text"
961
+ className={cn(textCellClassName, className)}
962
+ {...props}
963
+ />
964
+ );
965
+ }
966
+
967
+ /** Thumbnail, spinner or empty placeholder - whichever the cell's state calls for. */
968
+ function SpreadsheetImagePreview({
969
+ src,
970
+ alt,
971
+ uploading,
972
+ }: {
973
+ src: string | null | undefined;
974
+ alt: string;
975
+ uploading: boolean;
976
+ }) {
977
+ if (uploading) {
978
+ return (
979
+ <SpinnerIcon
980
+ aria-hidden="true"
981
+ className="size-4 animate-spin text-muted-foreground"
982
+ />
983
+ );
984
+ }
985
+ if (src === null || src === undefined || src.length === 0) {
986
+ return (
987
+ <ImageIcon
988
+ aria-hidden="true"
989
+ className="size-4 text-muted-foreground/50 group-hover/image-cell:text-muted-foreground"
990
+ />
991
+ );
992
+ }
993
+ // `object-contain` over `cover`: catalogue shots are packshots on white,
994
+ // cropping them to a 24px square would cut the product out of frame.
995
+ return (
996
+ <img
997
+ src={src}
998
+ alt={alt}
999
+ className="size-6 rounded-sm object-contain"
1000
+ draggable={false}
1001
+ />
1002
+ );
1003
+ }
1004
+
1005
+ /**
1006
+ * Image cell: a thumbnail that imports a file three ways - click to open the
1007
+ * picker, drop a file onto the cell, or paste one from the clipboard while the
1008
+ * cell is focused. Presentational like every other part: it neither uploads
1009
+ * nor holds the file. `onFileSelect` hands over the picked `File` (upload it,
1010
+ * then feed the resulting URL back as `src`) and `uploading` swaps the
1011
+ * thumbnail for a spinner while that runs.
1012
+ *
1013
+ * Under `gridNavigation` the cell copies as `value` (the slug or id behind the
1014
+ * image, not the URL) - declaring it is what keeps the hidden file input from
1015
+ * being serialized as the cell's value.
1016
+ */
1017
+ function SpreadsheetCellImage({
1018
+ src,
1019
+ alt = "",
1020
+ value = "",
1021
+ onFileSelect,
1022
+ onRemove,
1023
+ uploading = false,
1024
+ disabled = false,
1025
+ accept = "image/*",
1026
+ pickLabel,
1027
+ removeLabel,
1028
+ className,
1029
+ ...props
1030
+ }: Omit<React.ComponentProps<"td">, "onDrop" | "onPaste"> & {
1031
+ /** Displayed thumbnail URL; empty or null renders the placeholder. */
1032
+ src?: string | null;
1033
+ /** Alt text of the thumbnail - the row's label, not "image". */
1034
+ alt?: string;
1035
+ /** What `gridNavigation` copy serializes for this cell. */
1036
+ value?: string;
1037
+ onFileSelect: (file: File) => void;
1038
+ /** Omit to make the image non-clearable (no remove affordance is drawn). */
1039
+ onRemove?: () => void;
1040
+ uploading?: boolean;
1041
+ disabled?: boolean;
1042
+ accept?: string;
1043
+ /** Accessible name of the picker button, e.g. "Import an image, row 3". */
1044
+ pickLabel: string;
1045
+ removeLabel?: string;
1046
+ }) {
1047
+ const inputRef = React.useRef<HTMLInputElement>(null);
1048
+ const { dragging, cellProps } = useSpreadsheetImageDrop({
1049
+ onFileSelect,
1050
+ disabled: disabled || uploading,
1051
+ });
1052
+ const hasImage = src !== null && src !== undefined && src.length > 0;
1053
+ return (
1054
+ <td
1055
+ data-slot="spreadsheet-cell"
1056
+ data-grid-value={value}
1057
+ className={cn(
1058
+ "group/image-cell relative border-r border-b border-input p-0 text-center align-middle last:border-r-0",
1059
+ dragging && "inset-ring-2 inset-ring-ring/70 bg-primary/10",
1060
+ className,
1061
+ )}
1062
+ {...cellProps}
1063
+ {...props}
1064
+ >
1065
+ <input
1066
+ ref={inputRef}
1067
+ type="file"
1068
+ accept={accept}
1069
+ className="hidden"
1070
+ tabIndex={-1}
1071
+ onChange={(event) => {
1072
+ const file = event.target.files?.[0];
1073
+ // Cleared so picking the SAME file twice in a row still fires
1074
+ // `change` (the browser skips it when the value is unchanged).
1075
+ event.target.value = "";
1076
+ if (file !== undefined) {
1077
+ onFileSelect(file);
1078
+ }
1079
+ }}
1080
+ />
1081
+ <button
1082
+ type="button"
1083
+ aria-label={pickLabel}
1084
+ disabled={disabled || uploading}
1085
+ className="flex h-8 w-full items-center justify-center outline-none hover:bg-muted/50 focus-visible:inset-ring-2 focus-visible:inset-ring-ring/70 disabled:pointer-events-none disabled:opacity-50"
1086
+ onClick={() => inputRef.current?.click()}
1087
+ >
1088
+ <SpreadsheetImagePreview src={src} alt={alt} uploading={uploading} />
1089
+ </button>
1090
+ {hasImage && onRemove !== undefined && removeLabel !== undefined ? (
1091
+ <button
1092
+ type="button"
1093
+ aria-label={removeLabel}
1094
+ disabled={disabled || uploading}
1095
+ // Pointer-revealed, but always reachable: focus brings it back for
1096
+ // keyboard users, who have no hover to trigger it with.
1097
+ className="absolute top-0.5 right-0.5 hidden size-4 place-items-center rounded-full bg-background text-muted-foreground outline-none group-hover/image-cell:grid hover:text-destructive focus-visible:grid focus-visible:inset-ring-2 focus-visible:inset-ring-ring/70"
1098
+ onClick={onRemove}
1099
+ >
1100
+ <XIcon aria-hidden="true" className="size-2.5" />
1101
+ </button>
1102
+ ) : null}
1103
+ </td>
1104
+ );
1105
+ }
1106
+
1107
+ /**
1108
+ * Trailing cell for per-row actions - put `<Button variant="ghost"
1109
+ * size="icon-sm" aria-label=... />` inside.
1110
+ */
1111
+ function SpreadsheetRowActions({
1112
+ className,
1113
+ ...props
1114
+ }: React.ComponentProps<"td">) {
1115
+ return (
1116
+ <td
1117
+ data-slot="spreadsheet-row-actions"
1118
+ className={cn(
1119
+ "relative w-0 border-r border-b border-input px-1 text-center align-middle whitespace-nowrap last:border-r-0",
1120
+ className,
1121
+ )}
1122
+ {...props}
1123
+ />
1124
+ );
1125
+ }
1126
+
1127
+ /**
1128
+ * Grab-handle cell for row reordering; needs `onRowMove` on the table and the
1129
+ * row's current `index`. Pointer: drag the handle vertically, a drop line
1130
+ * marks the target. Keyboard: Space grabs the row, ArrowUp/ArrowDown pick the
1131
+ * position, Space drops, Escape cancels (steps are announced politely).
1132
+ * Render it as the row's first cell so the handle stays predictable.
1133
+ */
1134
+ function SpreadsheetDragHandle({
1135
+ index,
1136
+ className,
1137
+ "aria-label": ariaLabel = "Reorder row",
1138
+ ...props
1139
+ }: React.ComponentProps<"td"> & { index: number }) {
1140
+ const { onRowMove, setDropLine, announce } =
1141
+ React.useContext(SpreadsheetContext);
1142
+ const buttonProps = useSpreadsheetRowDrag({
1143
+ index,
1144
+ onRowMove,
1145
+ setDropLine,
1146
+ announce,
1147
+ });
1148
+ return (
1149
+ <td
1150
+ data-slot="spreadsheet-drag-handle"
1151
+ className={cn(
1152
+ "relative w-0 border-r border-b border-input p-0 text-center align-middle last:border-r-0",
1153
+ className,
1154
+ )}
1155
+ {...props}
1156
+ >
1157
+ <button
1158
+ type="button"
1159
+ aria-label={ariaLabel}
1160
+ aria-roledescription="sortable row"
1161
+ className="flex h-8 w-full min-w-7 cursor-grab touch-none items-center justify-center text-muted-foreground outline-none select-none hover:text-foreground focus-visible:inset-ring-2 focus-visible:inset-ring-ring/70 active:cursor-grabbing data-[grabbed=true]:text-foreground"
1162
+ {...buttonProps}
1163
+ >
1164
+ <DotsSixVerticalIcon aria-hidden="true" className="size-4" />
1165
+ </button>
1166
+ </td>
1167
+ );
1168
+ }
1169
+
1170
+ /**
1171
+ * Full-width "add a row" affordance rendered as the table's last row.
1172
+ * `colSpan` must count ALL columns, the actions column included. All props
1173
+ * (`className` included) go to the inner `<button>`; the wrapping row and
1174
+ * cell are not styleable from the outside.
1175
+ */
1176
+ function SpreadsheetAddRow({
1177
+ colSpan,
1178
+ className,
1179
+ children,
1180
+ ...props
1181
+ }: React.ComponentProps<"button"> & { colSpan: number }) {
1182
+ return (
1183
+ <tr data-slot="spreadsheet-add-row">
1184
+ <td colSpan={colSpan} className="border-t border-input p-0">
1185
+ <button
1186
+ type="button"
1187
+ className={cn(
1188
+ "flex h-8 w-full items-center gap-2 px-2.5 text-sm text-muted-foreground outline-none hover:bg-muted/50 hover:text-foreground focus-visible:inset-ring-2 focus-visible:inset-ring-ring/70 disabled:pointer-events-none disabled:opacity-50",
1189
+ className,
1190
+ )}
1191
+ {...props}
1192
+ >
1193
+ <PlusIcon aria-hidden="true" className="size-4" />
1194
+ {children}
1195
+ </button>
1196
+ </td>
1197
+ </tr>
1198
+ );
1199
+ }
1200
+
1201
+ /**
1202
+ * Full-width "add a row" button for the mobile card mode - pass it through
1203
+ * the table's `mobileAddRow` prop, wired like the desktop
1204
+ * `SpreadsheetAddRow`.
1205
+ */
1206
+ function SpreadsheetMobileAddRow({
1207
+ className,
1208
+ children,
1209
+ ...props
1210
+ }: React.ComponentProps<"button">) {
1211
+ return (
1212
+ <button
1213
+ type="button"
1214
+ data-slot="spreadsheet-mobile-add-row"
1215
+ className={cn(
1216
+ "flex h-9 w-full items-center justify-center gap-2 rounded-lg border border-input border-dashed px-2.5 text-sm text-muted-foreground outline-none hover:bg-muted/50 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
1217
+ className,
1218
+ )}
1219
+ {...props}
1220
+ >
1221
+ <PlusIcon aria-hidden="true" className="size-4" />
1222
+ {children}
1223
+ </button>
1224
+ );
1225
+ }
1226
+
1227
+ /**
1228
+ * Loading placeholder rows, sized exactly like real rows (32px + 1px rule) so
1229
+ * the swap to live data causes no layout shift. Renders inside
1230
+ * `SpreadsheetBody`; the rows are `aria-hidden`, announce the load
1231
+ * elsewhere (e.g. a `role="status"` region near the table).
1232
+ */
1233
+ function SpreadsheetSkeleton({
1234
+ rows = 3,
1235
+ columns,
1236
+ }: {
1237
+ rows?: number;
1238
+ columns: number;
1239
+ }) {
1240
+ return (
1241
+ <>
1242
+ {Array.from({ length: rows }, (_, rowIndex) => (
1243
+ <SpreadsheetRow
1244
+ key={rowIndex}
1245
+ aria-hidden="true"
1246
+ data-slot="spreadsheet-skeleton-row"
1247
+ >
1248
+ {Array.from({ length: columns }, (_, columnIndex) => (
1249
+ <SpreadsheetCellText key={columnIndex}>
1250
+ <Skeleton className="h-5 w-full" />
1251
+ </SpreadsheetCellText>
1252
+ ))}
1253
+ </SpreadsheetRow>
1254
+ ))}
1255
+ </>
1256
+ );
1257
+ }
1258
+
1259
+ /**
1260
+ * The spreadsheet, as one namespace: `Spreadsheet.Root` owns the state and the
1261
+ * keyboard grid, every other part draws into it.
1262
+ */
1263
+ const Spreadsheet = {
1264
+ Root: SpreadsheetRoot,
1265
+ Columns: SpreadsheetColumns,
1266
+ Column: SpreadsheetColumn,
1267
+ Header: SpreadsheetHeader,
1268
+ Head: SpreadsheetHead,
1269
+ Body: SpreadsheetBody,
1270
+ Row: SpreadsheetRow,
1271
+ VirtualRows: SpreadsheetVirtualRows,
1272
+ Cell: SpreadsheetCell,
1273
+ CellText: SpreadsheetCellText,
1274
+ CellImage: SpreadsheetCellImage,
1275
+ RowActions: SpreadsheetRowActions,
1276
+ DragHandle: SpreadsheetDragHandle,
1277
+ AddRow: SpreadsheetAddRow,
1278
+ MobileAddRow: SpreadsheetMobileAddRow,
1279
+ Skeleton: SpreadsheetSkeleton,
1280
+ };
1281
+
1282
+ export {
1283
+ Spreadsheet,
1284
+ type SpreadsheetPasteData,
1285
+ type SpreadsheetRootProps,
1286
+ type SpreadsheetSort,
1287
+ type SpreadsheetSortDirection,
1288
+ };