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