@marktiderman/genesis-ui 2.5.0 → 2.7.0
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/SKILL.md +1 -0
- package/dist/chunk-TXOZMKNW.js +320 -0
- package/dist/chunk-TXOZMKNW.js.map +1 -0
- package/dist/{chunk-DWM7ELFI.js → chunk-ZPPLUGLA.js} +3 -3
- package/dist/{chunk-DWM7ELFI.js.map → chunk-ZPPLUGLA.js.map} +1 -1
- package/dist/components/data/index.d.ts +38 -2
- package/dist/components/data/index.js +116 -24
- package/dist/components/data/index.js.map +1 -1
- package/dist/components/layout/index.js +2 -2
- package/dist/components/patterns/index.d.ts +1 -0
- package/dist/components/patterns/index.js +2 -0
- package/dist/components/patterns/sortable-list.d.ts +78 -0
- package/dist/components/patterns/sortable-list.js +5 -0
- package/dist/components/patterns/sortable-list.js.map +1 -0
- package/dist/components/sheet-grid/sheet-grid.d.ts +108 -0
- package/dist/components/sheet-grid/sheet-grid.js +434 -0
- package/dist/components/sheet-grid/sheet-grid.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +12 -11
- package/package.json +19 -4
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
export { AppShell, PageHeader } from '../../chunk-
|
|
1
|
+
export { AppShell, PageHeader } from '../../chunk-ZPPLUGLA.js';
|
|
2
2
|
import '../../chunk-46TZGMFW.js';
|
|
3
3
|
export { Container, Stack, containerVariants, stackVariants } from '../../chunk-4CQHPWJD.js';
|
|
4
4
|
export { FormPage } from '../../chunk-PLIYF6KI.js';
|
|
5
5
|
import '../../chunk-OZZTPE7F.js';
|
|
6
6
|
export { Section, SettingsPage, sectionHeadingVariants } from '../../chunk-P64MFQXZ.js';
|
|
7
|
-
import '../../chunk-QJ6P2DW6.js';
|
|
8
7
|
import '../../chunk-B2GIBUQD.js';
|
|
9
8
|
import '../../chunk-NPNADZHM.js';
|
|
10
9
|
import '../../chunk-FK42ZGGV.js';
|
|
11
10
|
import '../../chunk-22XB3Q6H.js';
|
|
11
|
+
import '../../chunk-QJ6P2DW6.js';
|
|
12
12
|
import '../../chunk-27ELYTGE.js';
|
|
13
13
|
import '../../chunk-NWSCYLPT.js';
|
|
14
14
|
import '../../chunk-F47U5KNW.js';
|
|
@@ -2,6 +2,7 @@ export { EmptyState, EmptyStateProps, NoFeaturesFound, NoFeedbackFound, NoSearch
|
|
|
2
2
|
export { FormDescription, FormError, FormField, FormLabel } from './form-field.js';
|
|
3
3
|
export { PageLoading, PageLoadingProps, pageLoadingVariants } from './page-loading.js';
|
|
4
4
|
export { SettingsRow, SettingsRowProps } from './settings-row.js';
|
|
5
|
+
export { SortableList, SortableListItemContext, SortableListProps } from './sortable-list.js';
|
|
5
6
|
export { StatusBadge, StatusBadgeProps, StatusBadgeStatus, statusBadgeVariants } from './status-badge.js';
|
|
6
7
|
export { ToggleRow, ToggleRowProps } from './toggle-row.js';
|
|
7
8
|
export { UserAvatar, UserAvatarProps, userAvatarVariants } from './user-avatar.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ViewSettings, ViewToggle } from '../../chunk-46TZGMFW.js';
|
|
2
|
+
export { SortableList } from '../../chunk-TXOZMKNW.js';
|
|
2
3
|
export { StatusBadge, statusBadgeVariants } from '../../chunk-XMK3T4TA.js';
|
|
3
4
|
export { ToggleRow } from '../../chunk-6M4NHHKG.js';
|
|
4
5
|
export { UserAvatar, userAvatarVariants } from '../../chunk-XBX6QMR5.js';
|
|
@@ -6,6 +7,7 @@ import '../../chunk-LNFF2RBD.js';
|
|
|
6
7
|
export { SettingsRow } from '../../chunk-A7AHVDG7.js';
|
|
7
8
|
import '../../chunk-FK42ZGGV.js';
|
|
8
9
|
import '../../chunk-22XB3Q6H.js';
|
|
10
|
+
import '../../chunk-QJ6P2DW6.js';
|
|
9
11
|
import '../../chunk-27ELYTGE.js';
|
|
10
12
|
export { EmptyState, NoFeaturesFound, NoFeedbackFound, NoSearchResults } from '../../chunk-65SPT7EA.js';
|
|
11
13
|
import '../../chunk-NWSCYLPT.js';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
interface SortableListItemContext {
|
|
4
|
+
/** The item's position as shown, from 0. */
|
|
5
|
+
index: number;
|
|
6
|
+
/**
|
|
7
|
+
* The drag handle: drag it with a pointer, or focus it and press Space or
|
|
8
|
+
* Enter to pick the item up, the arrow keys to move it and Space or Enter
|
|
9
|
+
* again to drop it. Render it somewhere in the row.
|
|
10
|
+
*/
|
|
11
|
+
handle: React.ReactNode;
|
|
12
|
+
/** True while this item is being dragged with a pointer. */
|
|
13
|
+
isDragging: boolean;
|
|
14
|
+
/** True while this item is picked up from the keyboard. */
|
|
15
|
+
isGrabbed: boolean;
|
|
16
|
+
/** Move this item without dragging, for a row menu of your own. */
|
|
17
|
+
moveUp: () => void;
|
|
18
|
+
moveDown: () => void;
|
|
19
|
+
moveTo: (index: number) => void;
|
|
20
|
+
}
|
|
21
|
+
interface SortableListReorder {
|
|
22
|
+
/** Key of the item that moved. */
|
|
23
|
+
key: string;
|
|
24
|
+
}
|
|
25
|
+
interface SortableListProps<T> {
|
|
26
|
+
items: readonly T[];
|
|
27
|
+
/** A stable key per item. Required: dragging finds the item again by key. */
|
|
28
|
+
getKey: (item: T) => string;
|
|
29
|
+
/** A name for an item, for the handle's label and announcements ("Week 1"). */
|
|
30
|
+
getItemLabel: (item: T) => string;
|
|
31
|
+
renderItem: (item: T, context: SortableListItemContext) => React.ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Move the item at `from` to `to` (both from 0, `to` counted after the item
|
|
34
|
+
* is taken out). Each finished move calls this once: a keyboard pick-up
|
|
35
|
+
* that wanders and comes back calls nothing.
|
|
36
|
+
*
|
|
37
|
+
* The list shows the new order straight away and keeps showing it until the
|
|
38
|
+
* returned promise settles; by then `items` must be in the new order. A
|
|
39
|
+
* synchronous handler must update `items` in the same tick. A rejected
|
|
40
|
+
* promise puts the old order back and announces that the move failed. Other
|
|
41
|
+
* moves wait until this one settles.
|
|
42
|
+
*/
|
|
43
|
+
onReorder: (from: number, to: number, move: SortableListReorder) => void | Promise<void>;
|
|
44
|
+
/** Accessible name of the list ("Course steps"). */
|
|
45
|
+
label: string;
|
|
46
|
+
/** Turns off dragging and every kind of move. */
|
|
47
|
+
disabled?: boolean | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* A "Move" menu on each row (up, down, to top, to bottom), on by default so
|
|
50
|
+
* touch and screen-reader users always have a way to move an item. Turn it
|
|
51
|
+
* off only when the row offers `moveUp` / `moveDown` / `moveTo` itself.
|
|
52
|
+
*/
|
|
53
|
+
moveMenu?: boolean | undefined;
|
|
54
|
+
className?: string | undefined;
|
|
55
|
+
itemClassName?: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A vertical list the user can rearrange: course steps, lesson blocks, form
|
|
59
|
+
* fields.
|
|
60
|
+
*
|
|
61
|
+
* Every way to move an item is equivalent, so dragging is never the only one
|
|
62
|
+
* (the same stance as KanbanBoard's "Move to…" menu):
|
|
63
|
+
* - drag the handle with a pointer;
|
|
64
|
+
* - focus the handle, Space or Enter to pick up, ↑/↓ and Home/End to move,
|
|
65
|
+
* Space or Enter to drop, Escape to put it back (tabbing away also puts it
|
|
66
|
+
* back); Alt+↑/↓ moves one step without picking up;
|
|
67
|
+
* - the row's "Move" menu, or `moveUp` / `moveDown` / `moveTo` from the render
|
|
68
|
+
* context.
|
|
69
|
+
*
|
|
70
|
+
* Moves are announced in a polite live region once they succeed. Native drag
|
|
71
|
+
* and drop needs no dependency and Radix has none to offer (docs/FRAMEWORK.md
|
|
72
|
+
* rule 3).
|
|
73
|
+
*
|
|
74
|
+
* @stability Beta
|
|
75
|
+
*/
|
|
76
|
+
declare function SortableList<T>({ items, getKey, getItemLabel, renderItem, onReorder, label, disabled, moveMenu, className, itemClassName, }: SortableListProps<T>): React.JSX.Element;
|
|
77
|
+
|
|
78
|
+
export { SortableList, type SortableListItemContext, type SortableListProps, type SortableListReorder };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"sortable-list.js"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface SheetGridColumn<T> {
|
|
5
|
+
key: string;
|
|
6
|
+
header: string;
|
|
7
|
+
/** Width in px. Default 160. */
|
|
8
|
+
width?: number;
|
|
9
|
+
/** The cell's text: what is shown when no `render` is given, edited, and copied. Default `String(row[key])`. */
|
|
10
|
+
getText?: (row: T) => string;
|
|
11
|
+
/** Custom display. Editing and copying still use `getText`. */
|
|
12
|
+
render?: (row: T) => ReactNode;
|
|
13
|
+
/** Whether this column's cells can be edited. */
|
|
14
|
+
editable?: boolean;
|
|
15
|
+
/** Why the cells can't be edited, shown on hover. */
|
|
16
|
+
readOnlyReason?: string;
|
|
17
|
+
/** Fixed choices: the editor becomes a select. */
|
|
18
|
+
options?: string[];
|
|
19
|
+
/** Shown before the header text, e.g. an icon for the field's type. */
|
|
20
|
+
icon?: ReactNode;
|
|
21
|
+
/** `end` right-aligns the cells, as for numbers. */
|
|
22
|
+
align?: "start" | "end";
|
|
23
|
+
}
|
|
24
|
+
/** A column pinned to the left of every row, outside the cell grid (row numbers, a checkbox, an open button). */
|
|
25
|
+
interface SheetGridRowHeader<T> {
|
|
26
|
+
width: number;
|
|
27
|
+
render: (row: T, index: number) => ReactNode;
|
|
28
|
+
/** The header cell above it. */
|
|
29
|
+
header?: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
/** One cell's new text. A paste or a clear produces several at once. */
|
|
32
|
+
interface SheetCellChange<T> {
|
|
33
|
+
row: T;
|
|
34
|
+
rowKey: string;
|
|
35
|
+
column: string;
|
|
36
|
+
value: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reject `onCommit` with this to mark specific cells, rather than every cell
|
|
40
|
+
* in the commit, as failed.
|
|
41
|
+
*/
|
|
42
|
+
declare class SheetCommitError extends Error {
|
|
43
|
+
readonly cells: {
|
|
44
|
+
rowKey: string;
|
|
45
|
+
column: string;
|
|
46
|
+
message: string;
|
|
47
|
+
}[];
|
|
48
|
+
constructor(message: string, cells: {
|
|
49
|
+
rowKey: string;
|
|
50
|
+
column: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}[]);
|
|
53
|
+
}
|
|
54
|
+
interface SheetGridProps<T> {
|
|
55
|
+
rows: T[];
|
|
56
|
+
columns: SheetGridColumn<T>[];
|
|
57
|
+
getKey: (row: T) => string;
|
|
58
|
+
/**
|
|
59
|
+
* Save one or more cells. Return a promise to show them as saving until it
|
|
60
|
+
* settles; a rejection marks them failed with the error's message (or the
|
|
61
|
+
* cells a `SheetCommitError` names) and keeps their old values.
|
|
62
|
+
*/
|
|
63
|
+
onCommit: (changes: SheetCellChange<T>[]) => void | Promise<void>;
|
|
64
|
+
/** Cmd/Ctrl+Z inside the grid. */
|
|
65
|
+
onUndo?: () => void;
|
|
66
|
+
/** Called when a paste or clear touched cells that can't be edited. */
|
|
67
|
+
onSkipped?: (count: number) => void;
|
|
68
|
+
/** Viewport height in px. Default 480. */
|
|
69
|
+
height?: number;
|
|
70
|
+
/** Row height in px. Default 32. */
|
|
71
|
+
rowHeight?: number;
|
|
72
|
+
/** Accessible name for the grid. */
|
|
73
|
+
label: string;
|
|
74
|
+
className?: string;
|
|
75
|
+
/** A pinned column at the left of every row. */
|
|
76
|
+
rowHeader?: SheetGridRowHeader<T>;
|
|
77
|
+
/** Rendered after the last row, inside the scroll area (e.g. "Add a record"). */
|
|
78
|
+
footer?: ReactNode;
|
|
79
|
+
/** Shown when there are no rows. */
|
|
80
|
+
empty?: ReactNode;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A spreadsheet grid: virtualized rows, a keyboard-driven active cell and
|
|
84
|
+
* range, in-place editing, and copy and paste through the system clipboard
|
|
85
|
+
* as tab-separated text, so it round-trips with Sheets and Excel.
|
|
86
|
+
*
|
|
87
|
+
* Keys: arrows, Tab and Shift+Tab move; Shift+arrows extend the range;
|
|
88
|
+
* Enter or F2 edits (Enter again saves and moves down); typing starts an
|
|
89
|
+
* edit; Escape cancels; Delete clears; Home/End and Ctrl+Home/End jump;
|
|
90
|
+
* PageUp/PageDown move a screen; Ctrl/Cmd+Z undoes.
|
|
91
|
+
*
|
|
92
|
+
* Why it isn't Radix (docs/FRAMEWORK.md rule 3): Radix has no data grid.
|
|
93
|
+
* The focus model follows the WAI-ARIA grid pattern instead: one tab stop
|
|
94
|
+
* (the grid), the active cell exposed through aria-activedescendant, and
|
|
95
|
+
* aria-selected for the range, so a screen reader announces each move.
|
|
96
|
+
* Virtualization comes from TanStack Virtual (decision 20's spike: stable,
|
|
97
|
+
* headless, actively released; react-data-grid's latest has been a beta for
|
|
98
|
+
* years and brings its own CSS).
|
|
99
|
+
*
|
|
100
|
+
* @stability Experimental
|
|
101
|
+
*/
|
|
102
|
+
declare function SheetGrid<T>({ rows, columns, getKey, onCommit, onUndo, onSkipped, height, rowHeight, label, className, rowHeader, footer, empty, }: SheetGridProps<T>): React.JSX.Element;
|
|
103
|
+
/** Parse spreadsheet clipboard text (tab-separated, quoted when needed). */
|
|
104
|
+
declare function parseTsv(text: string): string[][];
|
|
105
|
+
/** Write a block of cells as spreadsheet clipboard text (the inverse of `parseTsv`). */
|
|
106
|
+
declare function toTsv(grid: string[][]): string;
|
|
107
|
+
|
|
108
|
+
export { type SheetCellChange, SheetCommitError, SheetGrid, type SheetGridColumn, type SheetGridProps, type SheetGridRowHeader, parseTsv, toTsv };
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Spinner } from '../../chunk-OVU3Z2MT.js';
|
|
3
|
+
import { cn } from '../../chunk-NZ5WBA4R.js';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { useRef, useState, useCallback, useMemo, useEffect } from 'react';
|
|
6
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
7
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
8
|
+
|
|
9
|
+
var SheetCommitError = class extends Error {
|
|
10
|
+
constructor(message, cells) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.cells = cells;
|
|
13
|
+
this.name = "SheetCommitError";
|
|
14
|
+
}
|
|
15
|
+
cells;
|
|
16
|
+
};
|
|
17
|
+
function SheetGrid({
|
|
18
|
+
rows,
|
|
19
|
+
columns,
|
|
20
|
+
getKey,
|
|
21
|
+
onCommit,
|
|
22
|
+
onUndo,
|
|
23
|
+
onSkipped,
|
|
24
|
+
height = 480,
|
|
25
|
+
rowHeight = 32,
|
|
26
|
+
label,
|
|
27
|
+
className,
|
|
28
|
+
rowHeader,
|
|
29
|
+
footer,
|
|
30
|
+
empty
|
|
31
|
+
}) {
|
|
32
|
+
const scroller = useRef(null);
|
|
33
|
+
const [active, setActive] = useState({ r: 0, c: 0 });
|
|
34
|
+
const [anchor, setAnchor] = useState(null);
|
|
35
|
+
const [editing, setEditingState] = useState(null);
|
|
36
|
+
const editingRef = useRef(null);
|
|
37
|
+
const setEditing = (next) => {
|
|
38
|
+
editingRef.current = next;
|
|
39
|
+
setEditingState(next);
|
|
40
|
+
};
|
|
41
|
+
const [status, setStatus] = useState({});
|
|
42
|
+
const id = React.useId();
|
|
43
|
+
const virtualizer = useVirtualizer({
|
|
44
|
+
count: rows.length,
|
|
45
|
+
getScrollElement: () => scroller.current,
|
|
46
|
+
estimateSize: () => rowHeight,
|
|
47
|
+
overscan: 10,
|
|
48
|
+
// Render the first screen before layout is measured (server render, tests).
|
|
49
|
+
initialRect: { width: 1024, height }
|
|
50
|
+
});
|
|
51
|
+
const textOf = useCallback(
|
|
52
|
+
(row, column) => column.getText ? column.getText(row) : String(row[column.key] ?? ""),
|
|
53
|
+
[]
|
|
54
|
+
);
|
|
55
|
+
const cellKey = (rowKey, column) => `${rowKey}\0${column}`;
|
|
56
|
+
const lead = rowHeader?.width ?? 0;
|
|
57
|
+
const width = useMemo(() => lead + columns.reduce((sum, c) => sum + (c.width ?? 160), 0), [columns, lead]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
setActive((a) => ({ r: Math.min(a.r, Math.max(0, rows.length - 1)), c: Math.min(a.c, Math.max(0, columns.length - 1)) }));
|
|
60
|
+
}, [rows.length, columns.length]);
|
|
61
|
+
const range = useMemo(() => {
|
|
62
|
+
const from = anchor ?? active;
|
|
63
|
+
return {
|
|
64
|
+
r0: Math.min(from.r, active.r),
|
|
65
|
+
r1: Math.max(from.r, active.r),
|
|
66
|
+
c0: Math.min(from.c, active.c),
|
|
67
|
+
c1: Math.max(from.c, active.c)
|
|
68
|
+
};
|
|
69
|
+
}, [anchor, active]);
|
|
70
|
+
const inRange = (r, c) => r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
|
|
71
|
+
const moveTo = useCallback(
|
|
72
|
+
(cell, extend = false) => {
|
|
73
|
+
const next = { r: Math.max(0, Math.min(rows.length - 1, cell.r)), c: Math.max(0, Math.min(columns.length - 1, cell.c)) };
|
|
74
|
+
if (extend) setAnchor((a) => a ?? active);
|
|
75
|
+
else setAnchor(null);
|
|
76
|
+
setActive(next);
|
|
77
|
+
virtualizer.scrollToIndex(next.r, { align: "auto" });
|
|
78
|
+
},
|
|
79
|
+
[rows.length, columns.length, active, virtualizer]
|
|
80
|
+
);
|
|
81
|
+
const commit = useCallback(
|
|
82
|
+
async (changes) => {
|
|
83
|
+
if (!changes.length) return;
|
|
84
|
+
const keys = changes.map((ch) => cellKey(ch.rowKey, ch.column));
|
|
85
|
+
setStatus((s) => ({ ...s, ...Object.fromEntries(keys.map((k) => [k, { saving: true }])) }));
|
|
86
|
+
try {
|
|
87
|
+
await onCommit(changes);
|
|
88
|
+
setStatus((s) => {
|
|
89
|
+
const next = { ...s };
|
|
90
|
+
for (const k of keys) delete next[k];
|
|
91
|
+
return next;
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
setStatus((s) => {
|
|
96
|
+
const next = { ...s };
|
|
97
|
+
for (const k of keys) delete next[k];
|
|
98
|
+
if (error instanceof SheetCommitError && error.cells.length) {
|
|
99
|
+
for (const cell of error.cells) next[cellKey(cell.rowKey, cell.column)] = { error: cell.message };
|
|
100
|
+
} else {
|
|
101
|
+
for (const k of keys) next[k] = { error: message };
|
|
102
|
+
}
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
[onCommit]
|
|
108
|
+
);
|
|
109
|
+
const startEdit = (cell, draft) => {
|
|
110
|
+
const column = columns[cell.c];
|
|
111
|
+
const row = rows[cell.r];
|
|
112
|
+
if (!column?.editable || !row) return;
|
|
113
|
+
setEditing({ cell, rowKey: getKey(row), draft: draft ?? textOf(row, column) });
|
|
114
|
+
};
|
|
115
|
+
const finishEdit = async (move) => {
|
|
116
|
+
const current = editingRef.current;
|
|
117
|
+
if (!current) return;
|
|
118
|
+
const { cell, rowKey, draft } = current;
|
|
119
|
+
const row = rows.find((r) => getKey(r) === rowKey);
|
|
120
|
+
const column = columns[cell.c];
|
|
121
|
+
setEditing(null);
|
|
122
|
+
scroller.current?.focus();
|
|
123
|
+
if (move) moveTo(move);
|
|
124
|
+
if (!row) return;
|
|
125
|
+
if (draft !== textOf(row, column)) {
|
|
126
|
+
await commit([{ row, rowKey, column: column.key, value: draft }]);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const blockChanges = (values, origin) => {
|
|
130
|
+
const changes = [];
|
|
131
|
+
let skipped = 0;
|
|
132
|
+
values.forEach((line, dr) => {
|
|
133
|
+
line.forEach((value, dc) => {
|
|
134
|
+
const row = rows[origin.r + dr];
|
|
135
|
+
const column = columns[origin.c + dc];
|
|
136
|
+
if (!row || !column) return;
|
|
137
|
+
if (!column.editable) {
|
|
138
|
+
skipped++;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (value !== textOf(row, column)) changes.push({ row, rowKey: getKey(row), column: column.key, value });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
if (skipped) onSkipped?.(skipped);
|
|
145
|
+
return changes;
|
|
146
|
+
};
|
|
147
|
+
const onKeyDown = (e) => {
|
|
148
|
+
if (editing) return;
|
|
149
|
+
if (e.target !== e.currentTarget) return;
|
|
150
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
151
|
+
const page = Math.max(1, Math.floor(height / rowHeight) - 1);
|
|
152
|
+
const { r, c } = active;
|
|
153
|
+
const go = (cell) => {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
moveTo(cell, e.shiftKey && e.key !== "Tab");
|
|
156
|
+
};
|
|
157
|
+
switch (e.key) {
|
|
158
|
+
case "ArrowUp":
|
|
159
|
+
return go({ r: mod ? 0 : r - 1, c });
|
|
160
|
+
case "ArrowDown":
|
|
161
|
+
return go({ r: mod ? rows.length - 1 : r + 1, c });
|
|
162
|
+
case "ArrowLeft":
|
|
163
|
+
return go({ r, c: mod ? 0 : c - 1 });
|
|
164
|
+
case "ArrowRight":
|
|
165
|
+
return go({ r, c: mod ? columns.length - 1 : c + 1 });
|
|
166
|
+
case "Tab":
|
|
167
|
+
return go({ r, c: e.shiftKey ? c - 1 : c + 1 });
|
|
168
|
+
case "Home":
|
|
169
|
+
return go({ r: mod ? 0 : r, c: 0 });
|
|
170
|
+
case "End":
|
|
171
|
+
return go({ r: mod ? rows.length - 1 : r, c: columns.length - 1 });
|
|
172
|
+
case "PageUp":
|
|
173
|
+
return go({ r: r - page, c });
|
|
174
|
+
case "PageDown":
|
|
175
|
+
return go({ r: r + page, c });
|
|
176
|
+
case "Enter":
|
|
177
|
+
case "F2":
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
return startEdit(active);
|
|
180
|
+
case "Escape":
|
|
181
|
+
setAnchor(null);
|
|
182
|
+
return;
|
|
183
|
+
case "Delete":
|
|
184
|
+
case "Backspace": {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
const blank = Array.from({ length: range.r1 - range.r0 + 1 }, () => Array.from({ length: range.c1 - range.c0 + 1 }, () => ""));
|
|
187
|
+
void commit(blockChanges(blank, { r: range.r0, c: range.c0 }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
if (mod && (e.key === "z" || e.key === "Z") && !e.shiftKey) {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
onUndo?.();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!mod && !e.altKey && e.key.length === 1) {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
startEdit(active, e.key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const onCopy = (e) => {
|
|
203
|
+
if (editing || e.target !== e.currentTarget || rows.length === 0 || columns.length === 0) return;
|
|
204
|
+
const block = [];
|
|
205
|
+
for (let r = range.r0; r <= range.r1; r++) {
|
|
206
|
+
const line = [];
|
|
207
|
+
for (let c = range.c0; c <= range.c1; c++) line.push(textOf(rows[r], columns[c]));
|
|
208
|
+
block.push(line);
|
|
209
|
+
}
|
|
210
|
+
e.clipboardData.setData("text/plain", toTsv(block));
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
};
|
|
213
|
+
const onPaste = (e) => {
|
|
214
|
+
if (editing || e.target !== e.currentTarget) return;
|
|
215
|
+
const text = e.clipboardData.getData("text/plain");
|
|
216
|
+
if (!text) return;
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
let block = parseTsv(text.replace(/\r?\n$/, ""));
|
|
219
|
+
if (block.length === 1 && block[0].length === 1 && (range.r1 > range.r0 || range.c1 > range.c0)) {
|
|
220
|
+
block = Array.from({ length: range.r1 - range.r0 + 1 }, () => Array.from({ length: range.c1 - range.c0 + 1 }, () => block[0][0]));
|
|
221
|
+
}
|
|
222
|
+
void commit(blockChanges(block, { r: range.r0, c: range.c0 }));
|
|
223
|
+
};
|
|
224
|
+
const items = virtualizer.getVirtualItems();
|
|
225
|
+
const activeCellId = `${id}-cell-${active.r}-${active.c}`;
|
|
226
|
+
return /* @__PURE__ */ jsx("div", { className: cn("overflow-hidden rounded-lg border bg-background", className), "data-testid": "sheet-grid", children: /* @__PURE__ */ jsxs(
|
|
227
|
+
"div",
|
|
228
|
+
{
|
|
229
|
+
ref: scroller,
|
|
230
|
+
role: "grid",
|
|
231
|
+
"aria-label": label,
|
|
232
|
+
"aria-rowcount": rows.length + 1,
|
|
233
|
+
"aria-colcount": columns.length,
|
|
234
|
+
"aria-multiselectable": "true",
|
|
235
|
+
"aria-activedescendant": rows.length ? activeCellId : void 0,
|
|
236
|
+
tabIndex: 0,
|
|
237
|
+
className: "group/grid relative overflow-auto text-sm outline-none",
|
|
238
|
+
style: { height },
|
|
239
|
+
onKeyDown,
|
|
240
|
+
onCopy,
|
|
241
|
+
onPaste,
|
|
242
|
+
children: [
|
|
243
|
+
/* @__PURE__ */ jsxs("div", { role: "row", "aria-rowindex": 1, className: "sticky top-0 z-20 flex border-b bg-muted", style: { width }, children: [
|
|
244
|
+
rowHeader && /* @__PURE__ */ jsx(
|
|
245
|
+
"div",
|
|
246
|
+
{
|
|
247
|
+
role: "presentation",
|
|
248
|
+
className: "sticky left-0 z-10 flex shrink-0 items-center border-r bg-muted px-2 text-xs text-muted-foreground",
|
|
249
|
+
style: { width: rowHeader.width, height: rowHeight },
|
|
250
|
+
children: rowHeader.header
|
|
251
|
+
}
|
|
252
|
+
),
|
|
253
|
+
columns.map((column, c) => /* @__PURE__ */ jsxs(
|
|
254
|
+
"div",
|
|
255
|
+
{
|
|
256
|
+
role: "columnheader",
|
|
257
|
+
"aria-colindex": c + 1,
|
|
258
|
+
className: "flex shrink-0 items-center gap-1.5 border-r px-2 py-1.5 text-xs font-medium text-muted-foreground",
|
|
259
|
+
style: { width: column.width ?? 160, height: rowHeight },
|
|
260
|
+
title: column.readOnlyReason,
|
|
261
|
+
children: [
|
|
262
|
+
column.icon && /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center", "aria-hidden": true, children: column.icon }),
|
|
263
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: column.header })
|
|
264
|
+
]
|
|
265
|
+
},
|
|
266
|
+
column.key
|
|
267
|
+
))
|
|
268
|
+
] }),
|
|
269
|
+
rows.length === 0 && empty && /* @__PURE__ */ jsx("div", { className: "sticky left-0 px-3 py-6 text-sm text-muted-foreground", style: { width: "100%" }, children: empty }),
|
|
270
|
+
/* @__PURE__ */ jsx("div", { style: { height: virtualizer.getTotalSize(), width, position: "relative" }, children: items.map((item) => {
|
|
271
|
+
const row = rows[item.index];
|
|
272
|
+
const rowKey = getKey(row);
|
|
273
|
+
return /* @__PURE__ */ jsxs(
|
|
274
|
+
"div",
|
|
275
|
+
{
|
|
276
|
+
role: "row",
|
|
277
|
+
"aria-rowindex": item.index + 2,
|
|
278
|
+
className: "group/row absolute left-0 flex border-b hover:bg-muted/40",
|
|
279
|
+
style: { top: item.start, height: rowHeight, width },
|
|
280
|
+
children: [
|
|
281
|
+
rowHeader && /* @__PURE__ */ jsx(
|
|
282
|
+
"div",
|
|
283
|
+
{
|
|
284
|
+
role: "presentation",
|
|
285
|
+
className: "sticky left-0 z-10 flex shrink-0 items-center border-r bg-background px-2 text-xs text-muted-foreground group-hover/row:bg-muted",
|
|
286
|
+
style: { width: rowHeader.width },
|
|
287
|
+
onMouseDown: (e) => e.stopPropagation(),
|
|
288
|
+
children: rowHeader.render(row, item.index)
|
|
289
|
+
}
|
|
290
|
+
),
|
|
291
|
+
columns.map((column, c) => {
|
|
292
|
+
const isActive = active.r === item.index && active.c === c;
|
|
293
|
+
const selected = inRange(item.index, c);
|
|
294
|
+
const state = status[cellKey(rowKey, column.key)];
|
|
295
|
+
const isEditing = editing && editing.rowKey === rowKey && editing.cell.c === c;
|
|
296
|
+
return /* @__PURE__ */ jsxs(
|
|
297
|
+
"div",
|
|
298
|
+
{
|
|
299
|
+
id: `${id}-cell-${item.index}-${c}`,
|
|
300
|
+
role: "gridcell",
|
|
301
|
+
"aria-colindex": c + 1,
|
|
302
|
+
"aria-selected": selected,
|
|
303
|
+
"aria-readonly": !column.editable || void 0,
|
|
304
|
+
"aria-invalid": state?.error ? true : void 0,
|
|
305
|
+
"aria-busy": state?.saving || void 0,
|
|
306
|
+
title: state?.error ?? (!column.editable ? column.readOnlyReason : void 0),
|
|
307
|
+
className: cn(
|
|
308
|
+
"relative flex shrink-0 items-center border-r px-2",
|
|
309
|
+
selected && (range.r1 > range.r0 || range.c1 > range.c0) && "bg-primary/5",
|
|
310
|
+
// The active cell shows while the grid has focus, as in a spreadsheet.
|
|
311
|
+
isActive && "group-focus-within/grid:outline group-focus-within/grid:outline-2 group-focus-within/grid:-outline-offset-2 group-focus-within/grid:outline-primary",
|
|
312
|
+
state?.error && "bg-destructive/10",
|
|
313
|
+
column.align === "end" && "justify-end tabular-nums"
|
|
314
|
+
),
|
|
315
|
+
style: { width: column.width ?? 160 },
|
|
316
|
+
onMouseDown: (e) => {
|
|
317
|
+
if (isEditing) return;
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
scroller.current?.focus();
|
|
320
|
+
moveTo({ r: item.index, c }, e.shiftKey);
|
|
321
|
+
},
|
|
322
|
+
onDoubleClick: () => startEdit({ r: item.index, c }),
|
|
323
|
+
children: [
|
|
324
|
+
isEditing ? column.options ? /* @__PURE__ */ jsxs(
|
|
325
|
+
"select",
|
|
326
|
+
{
|
|
327
|
+
autoFocus: true,
|
|
328
|
+
"aria-label": `${column.header}, row ${item.index + 1}`,
|
|
329
|
+
className: "h-full w-full bg-background text-sm outline-none",
|
|
330
|
+
value: editing.draft,
|
|
331
|
+
onChange: (e) => setEditing({ ...editing, draft: e.target.value }),
|
|
332
|
+
onBlur: () => void finishEdit(),
|
|
333
|
+
onKeyDown: (e) => {
|
|
334
|
+
if (e.key === "Enter") void finishEdit({ r: item.index + 1, c });
|
|
335
|
+
if (e.key === "Escape") {
|
|
336
|
+
setEditing(null);
|
|
337
|
+
scroller.current?.focus();
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
children: [
|
|
341
|
+
!column.options.includes(editing.draft) && /* @__PURE__ */ jsx("option", { value: editing.draft, children: editing.draft || "\u2014" }),
|
|
342
|
+
column.options.map((o) => /* @__PURE__ */ jsx("option", { value: o, children: o }, o))
|
|
343
|
+
]
|
|
344
|
+
}
|
|
345
|
+
) : /* @__PURE__ */ jsx(
|
|
346
|
+
"input",
|
|
347
|
+
{
|
|
348
|
+
autoFocus: true,
|
|
349
|
+
"aria-label": `${column.header}, row ${item.index + 1}`,
|
|
350
|
+
className: "h-full w-full bg-background text-sm outline-none",
|
|
351
|
+
value: editing.draft,
|
|
352
|
+
onChange: (e) => setEditing({ ...editing, draft: e.target.value }),
|
|
353
|
+
onBlur: () => void finishEdit(),
|
|
354
|
+
onKeyDown: (e) => {
|
|
355
|
+
if (e.key === "Enter") {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
void finishEdit({ r: item.index + 1, c });
|
|
358
|
+
} else if (e.key === "Tab") {
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
void finishEdit({ r: item.index, c: c + (e.shiftKey ? -1 : 1) });
|
|
361
|
+
} else if (e.key === "Escape") {
|
|
362
|
+
setEditing(null);
|
|
363
|
+
scroller.current?.focus();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
) : /* @__PURE__ */ jsx("span", { className: "truncate", children: column.render ? column.render(row) : textOf(row, column) || /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) }),
|
|
368
|
+
state?.saving && /* @__PURE__ */ jsx(Spinner, { size: "sm", className: "absolute right-1 h-3 w-3", "aria-label": "Saving" })
|
|
369
|
+
]
|
|
370
|
+
},
|
|
371
|
+
column.key
|
|
372
|
+
);
|
|
373
|
+
})
|
|
374
|
+
]
|
|
375
|
+
},
|
|
376
|
+
rowKey
|
|
377
|
+
);
|
|
378
|
+
}) }),
|
|
379
|
+
footer && /* @__PURE__ */ jsx("div", { className: "sticky left-0 border-b", style: { width: Math.max(width, 0) }, children: footer })
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
) });
|
|
383
|
+
}
|
|
384
|
+
function parseTsv(text) {
|
|
385
|
+
const rows = [];
|
|
386
|
+
let row = [];
|
|
387
|
+
let field = "";
|
|
388
|
+
let quoted = false;
|
|
389
|
+
let wasQuoted = false;
|
|
390
|
+
for (let i = 0; i < text.length; i++) {
|
|
391
|
+
const ch = text[i];
|
|
392
|
+
if (quoted) {
|
|
393
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
394
|
+
field += '"';
|
|
395
|
+
i++;
|
|
396
|
+
} else if (ch === '"') {
|
|
397
|
+
quoted = false;
|
|
398
|
+
} else {
|
|
399
|
+
field += ch;
|
|
400
|
+
}
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (ch === '"' && field === "") {
|
|
404
|
+
quoted = true;
|
|
405
|
+
wasQuoted = true;
|
|
406
|
+
} else if (ch === " ") {
|
|
407
|
+
row.push(field);
|
|
408
|
+
field = "";
|
|
409
|
+
wasQuoted = false;
|
|
410
|
+
} else if (ch === "\n" || ch === "\r") {
|
|
411
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
412
|
+
row.push(field);
|
|
413
|
+
rows.push(row);
|
|
414
|
+
row = [];
|
|
415
|
+
field = "";
|
|
416
|
+
wasQuoted = false;
|
|
417
|
+
} else field += ch;
|
|
418
|
+
}
|
|
419
|
+
if (field !== "" || wasQuoted || row.length) {
|
|
420
|
+
row.push(field);
|
|
421
|
+
rows.push(row);
|
|
422
|
+
}
|
|
423
|
+
return rows;
|
|
424
|
+
}
|
|
425
|
+
function tsvField(text) {
|
|
426
|
+
return /[\t\n\r"]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
427
|
+
}
|
|
428
|
+
function toTsv(grid) {
|
|
429
|
+
return grid.map((row) => row.length === 1 && row[0] === "" ? '""' : row.map(tsvField).join(" ")).join("\n");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export { SheetCommitError, SheetGrid, parseTsv, toTsv };
|
|
433
|
+
//# sourceMappingURL=sheet-grid.js.map
|
|
434
|
+
//# sourceMappingURL=sheet-grid.js.map
|