@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.
Files changed (59) hide show
  1. package/dist/bulkbar.css +3 -0
  2. package/dist/bulkbar.layered.css +3 -0
  3. package/dist/cjs/components/bulkbar/bulkbar.js +1 -1
  4. package/dist/cjs/components/bulkbar/bulkbar.recipe.js +6 -0
  5. package/dist/cjs/components/datatable/datatable.js +65 -5
  6. package/dist/cjs/components/datatable/datatable.recipe.js +2 -0
  7. package/dist/cjs/components/filldoc/filldoc.js +102 -0
  8. package/dist/cjs/components/filldoc/filldoc.recipe.js +211 -0
  9. package/dist/cjs/components/filldoc/index.js +10 -0
  10. package/dist/cjs/components/filldoc/serialize.js +39 -0
  11. package/dist/cjs/components/fillsheet/fillsheet.js +719 -0
  12. package/dist/cjs/components/fillsheet/fillsheet.recipe.js +325 -0
  13. package/dist/cjs/components/fillsheet/index.js +12 -0
  14. package/dist/cjs/components/fillsheet/serialize.js +82 -0
  15. package/dist/cjs/index.js +2 -0
  16. package/dist/cjs/product/recordpanel/recordpanel.js +4 -2
  17. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +8 -0
  18. package/dist/cjs/product/sidebar/sidebar.js +16 -3
  19. package/dist/datatable.css +13 -5
  20. package/dist/datatable.layered.css +13 -5
  21. package/dist/esm/components/bulkbar/bulkbar.js +1 -1
  22. package/dist/esm/components/bulkbar/bulkbar.recipe.js +6 -0
  23. package/dist/esm/components/datatable/datatable.js +65 -5
  24. package/dist/esm/components/datatable/datatable.recipe.js +2 -0
  25. package/dist/esm/components/filldoc/filldoc.js +66 -0
  26. package/dist/esm/components/filldoc/filldoc.recipe.js +208 -0
  27. package/dist/esm/components/filldoc/index.js +3 -0
  28. package/dist/esm/components/filldoc/serialize.js +35 -0
  29. package/dist/esm/components/fillsheet/fillsheet.js +683 -0
  30. package/dist/esm/components/fillsheet/fillsheet.recipe.js +322 -0
  31. package/dist/esm/components/fillsheet/index.js +3 -0
  32. package/dist/esm/components/fillsheet/serialize.js +76 -0
  33. package/dist/esm/index.js +2 -0
  34. package/dist/esm/product/recordpanel/recordpanel.js +4 -2
  35. package/dist/esm/product/recordpanel/recordpanel.recipe.js +8 -0
  36. package/dist/esm/product/sidebar/sidebar.js +17 -4
  37. package/dist/filldoc.css +31 -0
  38. package/dist/filldoc.d.ts +7 -0
  39. package/dist/filldoc.layered.css +33 -0
  40. package/dist/fillsheet.css +29 -0
  41. package/dist/fillsheet.d.ts +7 -0
  42. package/dist/fillsheet.layered.css +31 -0
  43. package/dist/recordpanel.css +6 -0
  44. package/dist/recordpanel.layered.css +6 -0
  45. package/dist/styles.css +84 -5
  46. package/dist/styles.layered.css +84 -5
  47. package/dist/types/components/datatable/datatable.d.ts +7 -1
  48. package/dist/types/components/filldoc/filldoc.d.ts +79 -0
  49. package/dist/types/components/filldoc/filldoc.recipe.d.ts +201 -0
  50. package/dist/types/components/filldoc/index.d.ts +3 -0
  51. package/dist/types/components/filldoc/serialize.d.ts +5 -0
  52. package/dist/types/components/fillsheet/fillsheet.d.ts +165 -0
  53. package/dist/types/components/fillsheet/fillsheet.recipe.d.ts +202 -0
  54. package/dist/types/components/fillsheet/index.d.ts +3 -0
  55. package/dist/types/components/fillsheet/serialize.d.ts +45 -0
  56. package/dist/types/index.d.ts +2 -0
  57. package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
  58. package/dist/types/product/sidebar/sidebar.d.ts +56 -0
  59. 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
+ });