@crisp-ui-kit/crisp 0.34.0 → 0.36.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/dist/bulkbar.css +3 -0
- package/dist/bulkbar.layered.css +3 -0
- package/dist/cjs/components/bulkbar/bulkbar.js +1 -1
- package/dist/cjs/components/bulkbar/bulkbar.recipe.js +6 -0
- package/dist/cjs/components/datatable/datatable.js +65 -5
- package/dist/cjs/components/datatable/datatable.recipe.js +2 -0
- package/dist/cjs/components/filldoc/filldoc.js +102 -0
- package/dist/cjs/components/filldoc/filldoc.recipe.js +211 -0
- package/dist/cjs/components/filldoc/index.js +10 -0
- package/dist/cjs/components/filldoc/serialize.js +39 -0
- package/dist/cjs/components/fillsheet/fillsheet.js +719 -0
- package/dist/cjs/components/fillsheet/fillsheet.recipe.js +325 -0
- package/dist/cjs/components/fillsheet/index.js +12 -0
- package/dist/cjs/components/fillsheet/serialize.js +82 -0
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/product/recordpanel/recordpanel.js +4 -2
- package/dist/cjs/product/recordpanel/recordpanel.recipe.js +8 -0
- package/dist/cjs/product/sidebar/sidebar.js +16 -3
- package/dist/datatable.css +13 -5
- package/dist/datatable.layered.css +13 -5
- package/dist/esm/components/bulkbar/bulkbar.js +1 -1
- package/dist/esm/components/bulkbar/bulkbar.recipe.js +6 -0
- package/dist/esm/components/datatable/datatable.js +65 -5
- package/dist/esm/components/datatable/datatable.recipe.js +2 -0
- package/dist/esm/components/filldoc/filldoc.js +66 -0
- package/dist/esm/components/filldoc/filldoc.recipe.js +208 -0
- package/dist/esm/components/filldoc/index.js +3 -0
- package/dist/esm/components/filldoc/serialize.js +35 -0
- package/dist/esm/components/fillsheet/fillsheet.js +683 -0
- package/dist/esm/components/fillsheet/fillsheet.recipe.js +322 -0
- package/dist/esm/components/fillsheet/index.js +3 -0
- package/dist/esm/components/fillsheet/serialize.js +76 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/product/recordpanel/recordpanel.js +4 -2
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +8 -0
- package/dist/esm/product/sidebar/sidebar.js +17 -4
- package/dist/filldoc.css +31 -0
- package/dist/filldoc.d.ts +7 -0
- package/dist/filldoc.layered.css +33 -0
- package/dist/fillsheet.css +29 -0
- package/dist/fillsheet.d.ts +7 -0
- package/dist/fillsheet.layered.css +31 -0
- package/dist/recordpanel.css +6 -0
- package/dist/recordpanel.layered.css +6 -0
- package/dist/styles.css +84 -5
- package/dist/styles.layered.css +84 -5
- package/dist/types/components/datatable/datatable.d.ts +7 -1
- package/dist/types/components/filldoc/filldoc.d.ts +79 -0
- package/dist/types/components/filldoc/filldoc.recipe.d.ts +201 -0
- package/dist/types/components/filldoc/index.d.ts +3 -0
- package/dist/types/components/filldoc/serialize.d.ts +5 -0
- package/dist/types/components/fillsheet/fillsheet.d.ts +165 -0
- package/dist/types/components/fillsheet/fillsheet.recipe.d.ts +202 -0
- package/dist/types/components/fillsheet/index.d.ts +3 -0
- package/dist/types/components/fillsheet/serialize.d.ts +45 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
- package/dist/types/product/sidebar/sidebar.d.ts +56 -0
- package/package.json +11 -1
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cx, isRtlElement } from "../../core/index.js";
|
|
5
|
+
import { Select } from "../select/index.js";
|
|
6
|
+
import { fillsheetRecipe } from "./fillsheet.recipe.js";
|
|
7
|
+
import { toHTML, toTSV } from "./serialize.js";
|
|
8
|
+
const DEFAULT_W = 128;
|
|
9
|
+
// The fms iOS detail row is 45pt tall, and this is the same row: 12px of
|
|
10
|
+
// padding, a 20px line, 12px of padding, rounded up to its 45.
|
|
11
|
+
const DEFAULT_H = 45;
|
|
12
|
+
const MIN_W = 48;
|
|
13
|
+
// The same 45. A row cannot be dragged shorter than one properly-inset line;
|
|
14
|
+
// below that it only clips its own text, which is not a smaller row, it is a
|
|
15
|
+
// broken one.
|
|
16
|
+
const MIN_H = 45;
|
|
17
|
+
/**
|
|
18
|
+
* A select inside a cell.
|
|
19
|
+
*
|
|
20
|
+
* `Select` owns its own open state and has no `open` prop, so a cell that
|
|
21
|
+
* merely rendered one would leave you looking at a closed trigger — the
|
|
22
|
+
* select-then-click-again this component spent a long time getting rid of.
|
|
23
|
+
* The trigger is clicked once on mount so the list is already down by the time
|
|
24
|
+
* the cell opens.
|
|
25
|
+
*/
|
|
26
|
+
function SelectCell({ options, value, onCommit, onCancel, }) {
|
|
27
|
+
const ref = React.useRef(null);
|
|
28
|
+
const opened = React.useRef(false);
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
if (opened.current)
|
|
31
|
+
return;
|
|
32
|
+
opened.current = true;
|
|
33
|
+
ref.current?.querySelector('[role="combobox"]')?.click();
|
|
34
|
+
}, []);
|
|
35
|
+
return (_jsx("div", { ref: ref, className: "crisp-fillsheet-editor crisp-fillsheet-select", onKeyDown: (e) => {
|
|
36
|
+
e.stopPropagation();
|
|
37
|
+
if (e.key === "Escape")
|
|
38
|
+
onCancel();
|
|
39
|
+
}, children: _jsx(Select, { options: options, value: value, onValueChange: onCommit }) }));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Size a textarea to its own content.
|
|
43
|
+
*
|
|
44
|
+
* `height: auto` first so the box can SHRINK: `scrollHeight` never reports
|
|
45
|
+
* less than the element's current height, so measuring without resetting
|
|
46
|
+
* makes a cell that grows but never comes back when you delete a line.
|
|
47
|
+
*/
|
|
48
|
+
function autoGrow(el) {
|
|
49
|
+
if (!el)
|
|
50
|
+
return;
|
|
51
|
+
el.style.height = "auto";
|
|
52
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
53
|
+
}
|
|
54
|
+
/** Grow (or trim) a grid to exactly rows x cols, keeping what is already there. */
|
|
55
|
+
function shape(grid, rows, cols) {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (let r = 0; r < rows; r++) {
|
|
58
|
+
const row = grid[r] ?? [];
|
|
59
|
+
const next = [];
|
|
60
|
+
for (let c = 0; c < cols; c++)
|
|
61
|
+
next.push(row[c] ?? "");
|
|
62
|
+
out.push(next);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const rect = (a, b) => ({
|
|
67
|
+
r0: Math.min(a.r, b.r),
|
|
68
|
+
r1: Math.max(a.r, b.r),
|
|
69
|
+
c0: Math.min(a.c, b.c),
|
|
70
|
+
c1: Math.max(a.c, b.c),
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* A sheet of cells you can select, type into, resize and drag-fill.
|
|
74
|
+
*
|
|
75
|
+
* It comes in two modes, because two different people hold a form: in `edit`
|
|
76
|
+
* you build it, dragging columns and rows to size; in `fill` you only fill it
|
|
77
|
+
* in, and the structure is locked. Typing and drag-filling work in both --
|
|
78
|
+
* what `fill` withholds is the shape, not the content.
|
|
79
|
+
*
|
|
80
|
+
* Tab off the last cell appends a row, which is the gesture a word processor
|
|
81
|
+
* uses and the reason its tables are quick to fill: your hands are already on
|
|
82
|
+
* the keyboard, and the row you want is the one after the one you just
|
|
83
|
+
* finished. It grows in both modes -- a row is content, not structure.
|
|
84
|
+
*
|
|
85
|
+
* One click puts you in a cell, in both modes. A word processor places the
|
|
86
|
+
* caret where you clicked; there is no select-then-click-again step.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately NOT built on Table or DataTable. Those take rows of records and
|
|
89
|
+
* own the layout; this owns neither. A cell here is addressed by (row, column)
|
|
90
|
+
* and carries text, its column can be any width and its row any height, and
|
|
91
|
+
* the grid is CSS `grid` rather than `<table>` precisely so those two axes are
|
|
92
|
+
* independent.
|
|
93
|
+
*
|
|
94
|
+
* It is also deliberately bare: cells and hairlines, nothing else. No A/B/C
|
|
95
|
+
* lettering, no numbered row gutter, no header row, and no prop that turns
|
|
96
|
+
* any of them on. Those are what make a grid read as spreadsheet SOFTWARE,
|
|
97
|
+
* and a boxed-in cell is the loudest of them -- so a cell carries one rule
|
|
98
|
+
* underneath and nothing else. A consumer that wants headings puts them
|
|
99
|
+
* above the sheet, where they are content rather than chrome.
|
|
100
|
+
*/
|
|
101
|
+
export const FillSheet = React.forwardRef(function FillSheet({ columns, rows, value, defaultValue, onValueChange, readOnly = false, columnWidth = DEFAULT_W, rowHeight = DEFAULT_H, mode = "edit", label, surface = "panel", className, style, ...rest }, forwardedRef) {
|
|
102
|
+
const cols = React.useMemo(() => typeof columns === "number"
|
|
103
|
+
? Array.from({ length: columns }, () => ({}))
|
|
104
|
+
: columns, [columns]);
|
|
105
|
+
const colCount = cols.length;
|
|
106
|
+
// Seeded from `defaultValue` directly, NOT reshaped to a row count
|
|
107
|
+
// computed ahead of it. The row count has to be derived FROM whichever
|
|
108
|
+
// grid is live, because an uncontrolled sheet grows by appending to this
|
|
109
|
+
// state -- and the first version measured `defaultValue.length` instead,
|
|
110
|
+
// so a Tab that appended a sixth row was immediately trimmed back to five
|
|
111
|
+
// by `shape()`. The sheet could not grow at all unless it was controlled.
|
|
112
|
+
const [internal, setInternal] = React.useState(() => (defaultValue ?? []).map((row) => row.slice()));
|
|
113
|
+
const rowCount = Math.max(rows ?? 0, (value ?? internal).length, 1);
|
|
114
|
+
const grid = React.useMemo(() => shape(value ?? internal, rowCount, colCount), [value, internal, rowCount, colCount]);
|
|
115
|
+
const commit = React.useCallback((next) => {
|
|
116
|
+
if (value === undefined)
|
|
117
|
+
setInternal(next);
|
|
118
|
+
onValueChange?.(next);
|
|
119
|
+
}, [value, onValueChange]);
|
|
120
|
+
const [widths, setWidths] = React.useState(() => cols.map((c) => c.width ?? columnWidth));
|
|
121
|
+
const [heights, setHeights] = React.useState(() => Array.from({ length: rowCount }, () => rowHeight));
|
|
122
|
+
// Column and row counts are props: a sheet that grows has to grow its
|
|
123
|
+
// tracks too, or the new ones render at width `undefined`.
|
|
124
|
+
React.useEffect(() => {
|
|
125
|
+
setWidths((w) => w.length === colCount
|
|
126
|
+
? w
|
|
127
|
+
: Array.from({ length: colCount }, (_, i) => w[i] ?? cols[i]?.width ?? columnWidth));
|
|
128
|
+
}, [colCount, cols, columnWidth]);
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
setHeights((h) => h.length === rowCount
|
|
131
|
+
? h
|
|
132
|
+
: Array.from({ length: rowCount }, (_, i) => h[i] ?? rowHeight));
|
|
133
|
+
}, [rowCount, rowHeight]);
|
|
134
|
+
const [anchor, setAnchor] = React.useState(null);
|
|
135
|
+
const [focus, setFocus] = React.useState(null);
|
|
136
|
+
const [editing, setEditing] = React.useState(null);
|
|
137
|
+
const [draft, setDraft] = React.useState("");
|
|
138
|
+
/**
|
|
139
|
+
* Was the last thing that moved the selection a KEY?
|
|
140
|
+
*
|
|
141
|
+
* Tracked explicitly because `:focus-visible` cannot answer it here. The
|
|
142
|
+
* cell press calls `rootRef.focus()`, and Chrome treats a programmatic
|
|
143
|
+
* focus as keyboard-ish -- so `:focus-visible` matched on a plain mouse
|
|
144
|
+
* press and the indicator came back exactly where it was not wanted.
|
|
145
|
+
* Measured, not assumed: `sheet.matches(":focus-visible")` returns true
|
|
146
|
+
* mid-click.
|
|
147
|
+
*/
|
|
148
|
+
const [keyNav, setKeyNav] = React.useState(false);
|
|
149
|
+
const rootRef = React.useRef(null);
|
|
150
|
+
const setRefs = React.useCallback((node) => {
|
|
151
|
+
rootRef.current = node;
|
|
152
|
+
if (typeof forwardedRef === "function")
|
|
153
|
+
forwardedRef(node);
|
|
154
|
+
else if (forwardedRef)
|
|
155
|
+
forwardedRef.current = node;
|
|
156
|
+
}, [forwardedRef]);
|
|
157
|
+
const sel = anchor && focus ? rect(anchor, focus) : null;
|
|
158
|
+
// Does the selection cover more than one cell? Only then is it worth
|
|
159
|
+
// painting -- see the recipe. The cell attributes stay the same either
|
|
160
|
+
// way; this only decides whether the tint applies.
|
|
161
|
+
const multiSelect = !!sel && (sel.r1 > sel.r0 || sel.c1 > sel.c0);
|
|
162
|
+
const isReadOnly = React.useCallback((c) => readOnly || !!cols[c]?.readOnly, [readOnly, cols]);
|
|
163
|
+
// --- selection ---------------------------------------------------------
|
|
164
|
+
const cellFromPoint = (x, y) => {
|
|
165
|
+
const el = document
|
|
166
|
+
.elementFromPoint(x, y)
|
|
167
|
+
?.closest("[data-r][data-c]");
|
|
168
|
+
if (!el || !rootRef.current?.contains(el))
|
|
169
|
+
return null;
|
|
170
|
+
return { r: Number(el.dataset.r), c: Number(el.dataset.c) };
|
|
171
|
+
};
|
|
172
|
+
const dragRef = React.useRef(null);
|
|
173
|
+
/**
|
|
174
|
+
* Did the pointer travel far enough between press and release to be a
|
|
175
|
+
* drag rather than a click?
|
|
176
|
+
*
|
|
177
|
+
* A DISTANCE, not a boolean. The first version flipped a flag on any
|
|
178
|
+
* `mousemove` at all, which is correct for a synthetic click and wrong
|
|
179
|
+
* for every real one: a hand moves a pixel or two between pressing and
|
|
180
|
+
* releasing, so real clicks were being read as drags and the cell never
|
|
181
|
+
* opened. The tests could not see it, because a dispatched click moves
|
|
182
|
+
* nothing.
|
|
183
|
+
*/
|
|
184
|
+
const pressRef = React.useRef(null);
|
|
185
|
+
const draggedRef = React.useRef(false);
|
|
186
|
+
// Roughly the platform drag threshold. Small enough that a deliberate
|
|
187
|
+
// drag is never mistaken for a click, large enough to absorb a hand.
|
|
188
|
+
const DRAG_SLOP = 4;
|
|
189
|
+
const markIfDragged = (x, y) => {
|
|
190
|
+
const p = pressRef.current;
|
|
191
|
+
if (!p) {
|
|
192
|
+
draggedRef.current = true;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (Math.abs(x - p.x) > DRAG_SLOP || Math.abs(y - p.y) > DRAG_SLOP) {
|
|
196
|
+
draggedRef.current = true;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const onCellPointerDown = (e, cell) => {
|
|
200
|
+
// `isPrimary === false` and not `!isPrimary`: the second finger of a
|
|
201
|
+
// pinch must be ignored, but environments that do not populate the
|
|
202
|
+
// field at all should not have every press thrown away.
|
|
203
|
+
if (e.button !== 0 || e.isPrimary === false)
|
|
204
|
+
return;
|
|
205
|
+
setEditing(null);
|
|
206
|
+
if (e.shiftKey && anchor) {
|
|
207
|
+
setFocus(cell);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
setAnchor(cell);
|
|
211
|
+
setFocus(cell);
|
|
212
|
+
}
|
|
213
|
+
// A TOUCH press selects but never starts a range drag. Dragging a
|
|
214
|
+
// finger across cells and scrolling the page are the same gesture, and
|
|
215
|
+
// the only way to tell them apart is `touch-action: none`, which would
|
|
216
|
+
// mean the page could not be scrolled by dragging over the sheet at all
|
|
217
|
+
// -- a much worse trade on a phone than not having drag-select there.
|
|
218
|
+
// Tapping a cell opens it, which is the flow that matters on touch.
|
|
219
|
+
setKeyNav(false); // a pointer put us here, so draw nothing
|
|
220
|
+
if (e.pointerType !== "touch") {
|
|
221
|
+
dragRef.current = "range";
|
|
222
|
+
draggedRef.current = false;
|
|
223
|
+
pressRef.current = { x: e.clientX, y: e.clientY };
|
|
224
|
+
}
|
|
225
|
+
rootRef.current?.focus({ preventScroll: true });
|
|
226
|
+
};
|
|
227
|
+
// --- fill ---------------------------------------------------------
|
|
228
|
+
/**
|
|
229
|
+
* Write the source rectangle over the swept one, tiling it — the same rule
|
|
230
|
+
* spreadsheets use, so dragging a two-cell pattern repeats the pair rather
|
|
231
|
+
* than stamping the last value. Only cells OUTSIDE the source are touched.
|
|
232
|
+
*/
|
|
233
|
+
const applyFill = React.useCallback((to, source) => {
|
|
234
|
+
const from = source ?? sel;
|
|
235
|
+
if (!from)
|
|
236
|
+
return;
|
|
237
|
+
const { r0, r1, c0, c1 } = from;
|
|
238
|
+
// One axis at a time, whichever was dragged further — a fill that
|
|
239
|
+
// spread diagonally would have no defined source for the corner block.
|
|
240
|
+
const dRow = to.r < r0 ? r0 - to.r : to.r - r1;
|
|
241
|
+
const dCol = to.c < c0 ? c0 - to.c : to.c - c1;
|
|
242
|
+
const vertical = dRow >= dCol;
|
|
243
|
+
const tr0 = vertical ? Math.min(r0, to.r) : r0;
|
|
244
|
+
const tr1 = vertical ? Math.max(r1, to.r) : r1;
|
|
245
|
+
const tc0 = vertical ? c0 : Math.min(c0, to.c);
|
|
246
|
+
const tc1 = vertical ? c1 : Math.max(c1, to.c);
|
|
247
|
+
const srcRows = r1 - r0 + 1;
|
|
248
|
+
const srcCols = c1 - c0 + 1;
|
|
249
|
+
const next = grid.map((row) => row.slice());
|
|
250
|
+
let touched = false;
|
|
251
|
+
for (let r = tr0; r <= tr1; r++) {
|
|
252
|
+
for (let c = tc0; c <= tc1; c++) {
|
|
253
|
+
if (r >= r0 && r <= r1 && c >= c0 && c <= c1)
|
|
254
|
+
continue;
|
|
255
|
+
if (isReadOnly(c))
|
|
256
|
+
continue;
|
|
257
|
+
// `+ srcN` before the modulo: filling upward gives a negative
|
|
258
|
+
// offset, and JS `%` keeps the sign.
|
|
259
|
+
const sr = r0 + ((((r - r0) % srcRows) + srcRows) % srcRows);
|
|
260
|
+
const sc = c0 + ((((c - c0) % srcCols) + srcCols) % srcCols);
|
|
261
|
+
next[r][c] = grid[sr][sc];
|
|
262
|
+
touched = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (touched)
|
|
266
|
+
commit(next);
|
|
267
|
+
setAnchor({ r: tr0, c: tc0 });
|
|
268
|
+
setFocus({ r: tr1, c: tc1 });
|
|
269
|
+
}, [sel, grid, commit, isReadOnly]);
|
|
270
|
+
// One window listener pair for both drags, attached unconditionally.
|
|
271
|
+
//
|
|
272
|
+
// The alternative guards on `dragRef.current` so an idle sheet holds no
|
|
273
|
+
// listeners. That version does work, but only because of a chain of
|
|
274
|
+
// incidental React timing: mousedown sets the ref, the discrete event
|
|
275
|
+
// flushes, this effect re-runs and attaches before any mousemove arrives,
|
|
276
|
+
// and nothing afterwards re-renders to detach it. Every link there is a
|
|
277
|
+
// detail of how React schedules, not a property of this component -- and a
|
|
278
|
+
// mutation test cannot tell the two versions apart, so nothing would catch
|
|
279
|
+
// it breaking. Two idle listeners buy independence from all of it.
|
|
280
|
+
//
|
|
281
|
+
// No dependency array on purpose: `applyFill` and the state it closes over
|
|
282
|
+
// change every render, and a stale closure here would fill from the
|
|
283
|
+
// previous selection.
|
|
284
|
+
React.useEffect(() => {
|
|
285
|
+
const move = (e) => {
|
|
286
|
+
if (!dragRef.current)
|
|
287
|
+
return;
|
|
288
|
+
markIfDragged(e.clientX, e.clientY);
|
|
289
|
+
const cell = cellFromPoint(e.clientX, e.clientY);
|
|
290
|
+
if (!cell)
|
|
291
|
+
return;
|
|
292
|
+
setFocus(cell);
|
|
293
|
+
};
|
|
294
|
+
const up = () => {
|
|
295
|
+
dragRef.current = null;
|
|
296
|
+
};
|
|
297
|
+
window.addEventListener("pointermove", move);
|
|
298
|
+
window.addEventListener("pointerup", up);
|
|
299
|
+
window.addEventListener("pointercancel", up);
|
|
300
|
+
return () => {
|
|
301
|
+
window.removeEventListener("pointermove", move);
|
|
302
|
+
window.removeEventListener("pointerup", up);
|
|
303
|
+
window.removeEventListener("pointercancel", up);
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
// --- resize ------------------------------------------------------------
|
|
307
|
+
/** Shared by both axes: track a pointer delta into a track size. */
|
|
308
|
+
const startResize = (e, from, min, pick, set, index) => {
|
|
309
|
+
if (e.button !== 0 || e.isPrimary === false)
|
|
310
|
+
return;
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
e.stopPropagation();
|
|
313
|
+
const origin = pick(e.nativeEvent);
|
|
314
|
+
pressRef.current = null; // a boundary drag is never a click on a cell
|
|
315
|
+
const move = (ev) => {
|
|
316
|
+
// the same flag the range drag uses: whatever click event follows
|
|
317
|
+
// this must not be read as "open the cell I started on"
|
|
318
|
+
draggedRef.current = true;
|
|
319
|
+
set((sizes) => {
|
|
320
|
+
const next = sizes.slice();
|
|
321
|
+
next[index] = Math.max(min, from + (pick(ev) - origin));
|
|
322
|
+
return next;
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
const up = () => {
|
|
326
|
+
window.removeEventListener("pointermove", move);
|
|
327
|
+
window.removeEventListener("pointerup", up);
|
|
328
|
+
window.removeEventListener("pointercancel", up);
|
|
329
|
+
};
|
|
330
|
+
window.addEventListener("pointermove", move);
|
|
331
|
+
window.addEventListener("pointerup", up);
|
|
332
|
+
window.addEventListener("pointercancel", up);
|
|
333
|
+
};
|
|
334
|
+
const startColResize = (e, index) => startResize(e, widths[index] ?? columnWidth, MIN_W, (ev) => ev.clientX, setWidths, index);
|
|
335
|
+
const startRowResize = (e, index) => startResize(e, heights[index] ?? rowHeight, MIN_H, (ev) => ev.clientY, setHeights, index);
|
|
336
|
+
// --- editing -----------------------------------------------------------
|
|
337
|
+
// Mirrors `editing`, but cleared SYNCHRONOUSLY, which is what makes
|
|
338
|
+
// `closeEditor` idempotent. Committing on Enter unmounts the textarea, the
|
|
339
|
+
// unmount fires blur, and blur calls `closeEditor` again -- with `editing`
|
|
340
|
+
// still set in that closure, so the same edit was committed twice and
|
|
341
|
+
// `onValueChange` fired twice for one keystroke. Anything counting changes
|
|
342
|
+
// (an undo stack, a dirty flag, a save) would have seen both.
|
|
343
|
+
const editingRef = React.useRef(null);
|
|
344
|
+
const openEditor = (cell, seed) => {
|
|
345
|
+
if (isReadOnly(cell.c))
|
|
346
|
+
return;
|
|
347
|
+
editingRef.current = cell;
|
|
348
|
+
setEditing(cell);
|
|
349
|
+
setDraft(seed ?? grid[cell.r]?.[cell.c] ?? "");
|
|
350
|
+
};
|
|
351
|
+
const closeEditor = (write) => {
|
|
352
|
+
const cell = editingRef.current;
|
|
353
|
+
if (!cell)
|
|
354
|
+
return; // already closed; this is the unmount's blur
|
|
355
|
+
editingRef.current = null;
|
|
356
|
+
if (write) {
|
|
357
|
+
const next = grid.map((row) => row.slice());
|
|
358
|
+
next[cell.r][cell.c] = draft;
|
|
359
|
+
commit(next);
|
|
360
|
+
}
|
|
361
|
+
setEditing(null);
|
|
362
|
+
rootRef.current?.focus({ preventScroll: true });
|
|
363
|
+
};
|
|
364
|
+
/**
|
|
365
|
+
* Append an empty row and land on the start of it.
|
|
366
|
+
*
|
|
367
|
+
* Word's gesture, and the reason it is Tab rather than a hover control: in
|
|
368
|
+
* a table you are filling in, your hands are already on the keyboard and
|
|
369
|
+
* the row you want is the one after the one you just finished. It goes
|
|
370
|
+
* through `commit`, so a controlled sheet grows through `onValueChange`
|
|
371
|
+
* like every other edit, and an uncontrolled one just grows.
|
|
372
|
+
*/
|
|
373
|
+
const appendRow = () => {
|
|
374
|
+
if (readOnly)
|
|
375
|
+
return;
|
|
376
|
+
const next = [...grid.map((row) => row.slice()), cols.map(() => "")];
|
|
377
|
+
commit(next);
|
|
378
|
+
setAnchor({ r: next.length - 1, c: 0 });
|
|
379
|
+
setFocus({ r: next.length - 1, c: 0 });
|
|
380
|
+
};
|
|
381
|
+
/** Tab off the last cell grows the sheet instead of going nowhere. */
|
|
382
|
+
const atLastCell = (cell) => cell.r === rowCount - 1 && cell.c === colCount - 1;
|
|
383
|
+
const move = (dr, dc, extend) => {
|
|
384
|
+
setKeyNav(true); // only `move` runs from the keyboard
|
|
385
|
+
// The FIRST arrow lands on the first cell rather than stepping off it.
|
|
386
|
+
// Moving from an implicit {0,0} means an arrow pressed with nothing
|
|
387
|
+
// selected skips r0c0 entirely and starts you on the second cell, which
|
|
388
|
+
// is a cell nobody asked for.
|
|
389
|
+
if (!focus) {
|
|
390
|
+
const start = { r: 0, c: 0 };
|
|
391
|
+
setFocus(start);
|
|
392
|
+
if (!extend)
|
|
393
|
+
setAnchor(start);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const from = focus;
|
|
397
|
+
const to = {
|
|
398
|
+
r: Math.min(rowCount - 1, Math.max(0, from.r + dr)),
|
|
399
|
+
c: Math.min(colCount - 1, Math.max(0, from.c + dc)),
|
|
400
|
+
};
|
|
401
|
+
setFocus(to);
|
|
402
|
+
if (!extend)
|
|
403
|
+
setAnchor(to);
|
|
404
|
+
};
|
|
405
|
+
const onKeyDown = (e) => {
|
|
406
|
+
if (editing)
|
|
407
|
+
return;
|
|
408
|
+
const cur = focus ?? { r: 0, c: 0 };
|
|
409
|
+
// Copy the selection as TSV -- the format every spreadsheet and every
|
|
410
|
+
// word processor's table speaks, so a range lands in Excel, Sheets or
|
|
411
|
+
// Numbers as cells rather than as one blob of text.
|
|
412
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "c" || e.key === "C")) {
|
|
413
|
+
if (!sel)
|
|
414
|
+
return;
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
const block = grid
|
|
417
|
+
.slice(sel.r0, sel.r1 + 1)
|
|
418
|
+
.map((row) => row.slice(sel.c0, sel.c1 + 1));
|
|
419
|
+
const text = toTSV(block);
|
|
420
|
+
// TWO flavours. The plain text is what a spreadsheet reads; the HTML
|
|
421
|
+
// `<table>` is what Word, Google Docs, Pages and every rich-text
|
|
422
|
+
// editor read -- given only tabs they paste a paragraph of tabs, so
|
|
423
|
+
// without this a sheet cannot land in a document as a table at all.
|
|
424
|
+
const html = toHTML(block, {
|
|
425
|
+
columns: cols.slice(sel.c0, sel.c1 + 1),
|
|
426
|
+
});
|
|
427
|
+
const write = navigator.clipboard?.write;
|
|
428
|
+
if (write && typeof ClipboardItem !== "undefined") {
|
|
429
|
+
write
|
|
430
|
+
.call(navigator.clipboard, [
|
|
431
|
+
new ClipboardItem({
|
|
432
|
+
"text/plain": new Blob([text], { type: "text/plain" }),
|
|
433
|
+
"text/html": new Blob([html], { type: "text/html" }),
|
|
434
|
+
}),
|
|
435
|
+
])
|
|
436
|
+
// `write` needs a secure context and the clipboard permission;
|
|
437
|
+
// plain text is always available, so fall back rather than
|
|
438
|
+
// leaving the copy silently doing nothing.
|
|
439
|
+
.catch(() => navigator.clipboard?.writeText(text));
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
navigator.clipboard?.writeText(text);
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Fill down, the way a spreadsheet does it, with no handle to find. The
|
|
447
|
+
// sheet is meant to be plain, and a square parked in a cell corner is
|
|
448
|
+
// the one mark that could not be made to sit quietly: always visible it
|
|
449
|
+
// is clutter, revealed on hover it appears exactly when you reach for
|
|
450
|
+
// the cell.
|
|
451
|
+
//
|
|
452
|
+
// Handled BEFORE the switch, not as a case in it. As a case, the guard
|
|
453
|
+
// for "no modifier held" has to `break`, and `break` leaves the switch
|
|
454
|
+
// without reaching `default` -- so typing a plain d was swallowed and
|
|
455
|
+
// never opened the cell.
|
|
456
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "d" || e.key === "D")) {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
// No single-cell guard: `applyFill` already writes nothing when the
|
|
459
|
+
// target is entirely inside the source, which is exactly what one
|
|
460
|
+
// selected cell is. A guard here looked like a safety check and was
|
|
461
|
+
// unreachable -- a mutation removing it could not be made to fail.
|
|
462
|
+
if (!sel || readOnly)
|
|
463
|
+
return;
|
|
464
|
+
// source is the FIRST row of the selection; it fills over the rest
|
|
465
|
+
return applyFill({ r: sel.r1, c: sel.c1 }, { r0: sel.r0, r1: sel.r0, c0: sel.c0, c1: sel.c1 });
|
|
466
|
+
}
|
|
467
|
+
switch (e.key) {
|
|
468
|
+
case "ArrowUp":
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
return move(-1, 0, e.shiftKey);
|
|
471
|
+
case "ArrowDown":
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
return move(1, 0, e.shiftKey);
|
|
474
|
+
// The arrows follow what the user SEES: under RTL column 0 renders
|
|
475
|
+
// rightmost, so ArrowLeft steps forward and ArrowRight steps back.
|
|
476
|
+
// Tab is NOT mirrored -- it follows reading order, which already runs
|
|
477
|
+
// right-to-left there, so column 0 -> 1 is correct either way.
|
|
478
|
+
case "ArrowLeft":
|
|
479
|
+
case "ArrowRight": {
|
|
480
|
+
e.preventDefault();
|
|
481
|
+
const rtl = isRtlElement(e.currentTarget);
|
|
482
|
+
const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
|
|
483
|
+
return move(0, forward ? 1 : -1, e.shiftKey);
|
|
484
|
+
}
|
|
485
|
+
case "Tab":
|
|
486
|
+
e.preventDefault();
|
|
487
|
+
if (!e.shiftKey && atLastCell(cur))
|
|
488
|
+
return appendRow();
|
|
489
|
+
return move(0, e.shiftKey ? -1 : 1, false);
|
|
490
|
+
case "Enter":
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
if (!anchor)
|
|
493
|
+
setAnchor(cur);
|
|
494
|
+
return openEditor(cur);
|
|
495
|
+
case "Escape":
|
|
496
|
+
e.preventDefault();
|
|
497
|
+
// Tab moves between cells here, which is the word-processor
|
|
498
|
+
// behaviour and the thing that makes a form quick to fill -- but it
|
|
499
|
+
// means Tab can never carry focus OUT, and a grid you cannot leave
|
|
500
|
+
// with the keyboard is a trap. Escape is the way out: once to drop
|
|
501
|
+
// the selection, again to leave the sheet entirely.
|
|
502
|
+
if (!anchor && !focus) {
|
|
503
|
+
rootRef.current?.blur();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
setAnchor(null);
|
|
507
|
+
return setFocus(null);
|
|
508
|
+
case "Backspace":
|
|
509
|
+
case "Delete": {
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
if (!sel || readOnly)
|
|
512
|
+
return;
|
|
513
|
+
const next = grid.map((row) => row.slice());
|
|
514
|
+
for (let r = sel.r0; r <= sel.r1; r++)
|
|
515
|
+
for (let c = sel.c0; c <= sel.c1; c++)
|
|
516
|
+
if (!isReadOnly(c))
|
|
517
|
+
next[r][c] = "";
|
|
518
|
+
return commit(next);
|
|
519
|
+
}
|
|
520
|
+
default:
|
|
521
|
+
// Typing replaces, the way it does in every spreadsheet: the first
|
|
522
|
+
// character opens the editor and becomes its whole contents.
|
|
523
|
+
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
524
|
+
e.preventDefault();
|
|
525
|
+
openEditor(cur, e.key);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
// --- render ------------------------------------------------------------
|
|
530
|
+
const gridStyle = {
|
|
531
|
+
"--crisp-fillsheet-cols": widths.map((w) => `${w}px`).join(" "),
|
|
532
|
+
// `minmax(h, auto)` and not a fixed track: a narrowed column wraps its
|
|
533
|
+
// text, and the row has to be free to grow to hold it. The dragged
|
|
534
|
+
// height is the floor, never the ceiling.
|
|
535
|
+
"--crisp-fillsheet-rows": heights
|
|
536
|
+
.map((h) => `minmax(${h}px, auto)`)
|
|
537
|
+
.join(" "),
|
|
538
|
+
...style,
|
|
539
|
+
};
|
|
540
|
+
return (_jsx("div", { ...rest, ref: setRefs, role: "grid", "aria-label": label, "data-multi": multiSelect ? "" : undefined, "data-keynav": keyNav ? "" : undefined, "aria-rowcount": rowCount, "aria-colcount": colCount, tabIndex: 0, className: cx(fillsheetRecipe({ surface }), className), style: gridStyle, onKeyDown: (e) => {
|
|
541
|
+
rest.onKeyDown?.(e);
|
|
542
|
+
if (!e.defaultPrevented)
|
|
543
|
+
onKeyDown(e);
|
|
544
|
+
}, onPaste: (e) => {
|
|
545
|
+
rest.onPaste?.(e);
|
|
546
|
+
if (e.defaultPrevented || editing || readOnly || !anchor)
|
|
547
|
+
return;
|
|
548
|
+
const text = e.clipboardData.getData("text/plain");
|
|
549
|
+
if (!text)
|
|
550
|
+
return;
|
|
551
|
+
e.preventDefault();
|
|
552
|
+
// TSV in, the same shape copy puts out, so a range pasted from a
|
|
553
|
+
// spreadsheet arrives as CELLS. Splitting on tabs is what makes
|
|
554
|
+
// this a paste TARGET rather than a box that takes one blob of
|
|
555
|
+
// text -- which is what the docs have been claiming it is.
|
|
556
|
+
const rows = text
|
|
557
|
+
.replace(/\r\n?/g, "\n")
|
|
558
|
+
.replace(/\n$/, "")
|
|
559
|
+
.split("\n")
|
|
560
|
+
.map((line) => line.split("\t"));
|
|
561
|
+
const next = grid.map((row) => row.slice());
|
|
562
|
+
let lastR = anchor.r;
|
|
563
|
+
let lastC = anchor.c;
|
|
564
|
+
for (let i = 0; i < rows.length; i++) {
|
|
565
|
+
const r = anchor.r + i;
|
|
566
|
+
if (r >= rowCount)
|
|
567
|
+
break; // a paste never grows the sheet silently
|
|
568
|
+
for (let j = 0; j < rows[i].length; j++) {
|
|
569
|
+
const c = anchor.c + j;
|
|
570
|
+
if (c >= colCount || isReadOnly(c))
|
|
571
|
+
continue;
|
|
572
|
+
next[r][c] = rows[i][j];
|
|
573
|
+
lastR = Math.max(lastR, r);
|
|
574
|
+
lastC = Math.max(lastC, c);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
commit(next);
|
|
578
|
+
setFocus({ r: lastR, c: lastC });
|
|
579
|
+
}, onFocus: (e) => {
|
|
580
|
+
rest.onFocus?.(e);
|
|
581
|
+
// Tabbing into the sheet lands on a cell, the way it does in every
|
|
582
|
+
// sheet -- and it is what lets the root drop its own focus ring
|
|
583
|
+
// without leaving a keyboard user with nothing to look at.
|
|
584
|
+
//
|
|
585
|
+
// Two guards, both load-bearing. `e.target === e.currentTarget`
|
|
586
|
+
// because this bubbles from the cell editor. And `!dragRef.current`
|
|
587
|
+
// because a CLICK also focuses the root: `onCellMouseDown` sets the
|
|
588
|
+
// anchor and then calls focus(), so this handler runs inside the
|
|
589
|
+
// same event with `anchor` still reading its pre-click value of
|
|
590
|
+
// null -- and would queue {0,0} AFTER the clicked cell, throwing the
|
|
591
|
+
// selection to A1 on every single click. The drag ref is already set
|
|
592
|
+
// by then, so it is the one thing here that is not stale.
|
|
593
|
+
if (e.target === e.currentTarget && !anchor && !dragRef.current) {
|
|
594
|
+
setAnchor({ r: 0, c: 0 });
|
|
595
|
+
setFocus({ r: 0, c: 0 });
|
|
596
|
+
}
|
|
597
|
+
}, children: grid.map((row, r) => (_jsx("div", { role: "row", "aria-rowindex": r + 1, style: { display: "contents" }, children: row.map((raw, c) => {
|
|
598
|
+
// What the CLOSED cell shows. A select renders its option's
|
|
599
|
+
// label, everything else its own text; either way what is
|
|
600
|
+
// STORED is the raw string, which is what keeps fill, clear and
|
|
601
|
+
// tiling from needing to know a cell's type at all.
|
|
602
|
+
const col = cols[c];
|
|
603
|
+
const opts = typeof col?.options === "function"
|
|
604
|
+
? col.options(r)
|
|
605
|
+
: col?.options;
|
|
606
|
+
const text = col?.format?.(raw) ??
|
|
607
|
+
opts?.find((o) => o.value === raw)?.label ??
|
|
608
|
+
raw;
|
|
609
|
+
const selected = sel?.r0 === r && sel?.c0 === c;
|
|
610
|
+
const inRange = sel && r >= sel.r0 && r <= sel.r1 && c >= sel.c0 && c <= sel.c1;
|
|
611
|
+
const isEditing = editing?.r === r && editing?.c === c;
|
|
612
|
+
return (_jsxs("div", { role: "gridcell", "aria-colindex": c + 1, "aria-selected": inRange ? true : undefined, "aria-readonly": isReadOnly(c) || undefined, className: "crisp-fillsheet-cell", "data-r": r, "data-c": c, "data-align": cols[c]?.align, "data-selected": selected ? "" : undefined, "data-editing": isEditing ? "" : undefined, "data-range": inRange ? "" : undefined, onPointerDown: (e) => onCellPointerDown(e, { r, c }), onClick: () => {
|
|
613
|
+
// ONE click puts you in the cell. Both modes, no
|
|
614
|
+
// select-then-click-again: a word processor places the
|
|
615
|
+
// caret where you clicked, and nobody double-clicks a
|
|
616
|
+
// paper form.
|
|
617
|
+
//
|
|
618
|
+
// This was gated to `fill` at first, on the theory that in
|
|
619
|
+
// `edit` a click might be reaching for a boundary to drag.
|
|
620
|
+
// It is not: the resize grabs stop the event themselves,
|
|
621
|
+
// and mark the drag so the mouseup that ends one cannot
|
|
622
|
+
// open anything. The gate only made editing a form you
|
|
623
|
+
// were building take twice as many clicks.
|
|
624
|
+
if (!draggedRef.current)
|
|
625
|
+
openEditor({ r, c });
|
|
626
|
+
}, onDoubleClick: () => openEditor({ r, c }), children: [isEditing && opts ? (_jsx(SelectCell, { options: opts, value: draft, onCommit: (v) => {
|
|
627
|
+
const next = grid.map((r2) => r2.slice());
|
|
628
|
+
next[r][c] = v;
|
|
629
|
+
commit(next);
|
|
630
|
+
editingRef.current = null;
|
|
631
|
+
setEditing(null);
|
|
632
|
+
rootRef.current?.focus({ preventScroll: true });
|
|
633
|
+
}, onCancel: () => closeEditor(false) })) : isEditing ? (_jsx("textarea", { className: "crisp-fillsheet-editor",
|
|
634
|
+
// biome-ignore lint/a11y/noAutofocus: the editor only exists because the user opened it
|
|
635
|
+
autoFocus: true, rows: 1, value: draft, ref: autoGrow, onFocus: (e) => {
|
|
636
|
+
autoGrow(e.currentTarget);
|
|
637
|
+
// Put the caret at the END. A focused textarea starts
|
|
638
|
+
// at position 0, so clicking a filled cell dropped you
|
|
639
|
+
// in front of the text you were coming to add to.
|
|
640
|
+
//
|
|
641
|
+
// In `onFocus` and not a ref callback: an inline ref
|
|
642
|
+
// is a new function every render, so it re-runs on
|
|
643
|
+
// every keystroke and would drag the caret back to the
|
|
644
|
+
// end mid-word. This fires once, when autoFocus lands.
|
|
645
|
+
const end = e.currentTarget.value.length;
|
|
646
|
+
e.currentTarget.setSelectionRange(end, end);
|
|
647
|
+
}, onChange: (e) => {
|
|
648
|
+
autoGrow(e.currentTarget);
|
|
649
|
+
setDraft(e.target.value);
|
|
650
|
+
}, onBlur: () => closeEditor(true), onKeyDown: (e) => {
|
|
651
|
+
e.stopPropagation();
|
|
652
|
+
// A prose column inverts the pair: Enter breaks the
|
|
653
|
+
// line and the modifier commits, which is what every
|
|
654
|
+
// app with a multi-line composer does. Everywhere
|
|
655
|
+
// else Enter commits, because a form is filled far
|
|
656
|
+
// more often than it is written in paragraphs.
|
|
657
|
+
const prose = !!cols[c]?.multiline;
|
|
658
|
+
// Outside a prose column ⌘Enter commits too, rather
|
|
659
|
+
// than being a dead key: it is the submit chord
|
|
660
|
+
// everywhere else, so it should never do nothing.
|
|
661
|
+
const commitChord = prose
|
|
662
|
+
? e.metaKey || e.ctrlKey
|
|
663
|
+
: !e.shiftKey;
|
|
664
|
+
if (e.key === "Enter" && commitChord) {
|
|
665
|
+
e.preventDefault();
|
|
666
|
+
closeEditor(true);
|
|
667
|
+
move(1, 0, false);
|
|
668
|
+
}
|
|
669
|
+
else if (e.key === "Escape") {
|
|
670
|
+
e.preventDefault();
|
|
671
|
+
closeEditor(false);
|
|
672
|
+
}
|
|
673
|
+
else if (e.key === "Tab") {
|
|
674
|
+
e.preventDefault();
|
|
675
|
+
closeEditor(true);
|
|
676
|
+
if (!e.shiftKey && atLastCell({ r, c }))
|
|
677
|
+
appendRow();
|
|
678
|
+
else
|
|
679
|
+
move(0, e.shiftKey ? -1 : 1, false);
|
|
680
|
+
}
|
|
681
|
+
} })) : (text), mode === "edit" && (_jsx("span", { className: "crisp-fillsheet-colresize", onPointerDown: (e) => startColResize(e, c), "aria-hidden": "true" })), mode === "edit" && (_jsx("span", { className: "crisp-fillsheet-rowresize", onPointerDown: (e) => startRowResize(e, r), "aria-hidden": "true" }))] }, c));
|
|
682
|
+
}) }, r))) }));
|
|
683
|
+
});
|