@voila.dev/ui-spreadsheet 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +58 -0
- package/src/components/nested-table-input.tsx +85 -0
- package/src/components/spreadsheet.tsx +1288 -0
- package/src/hooks/use-spreadsheet-column-sizing.ts +120 -0
- package/src/hooks/use-spreadsheet-drag.ts +617 -0
- package/src/hooks/use-spreadsheet-grid.ts +743 -0
- package/src/hooks/use-spreadsheet-image-drop.ts +114 -0
- package/src/styles.css +10 -0
|
@@ -0,0 +1,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
|
+
};
|