@classytic/fluid 0.4.0 → 0.4.2

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.
@@ -0,0 +1,611 @@
1
+ "use client";
2
+
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { memo, useCallback, useEffect, useMemo, useReducer, useRef } from "react";
5
+ import { useVirtualizer } from "@tanstack/react-virtual";
6
+
7
+ //#region src/components/spreadsheet/spreadsheet-row.tsx
8
+ function SpreadsheetRowInner({ rowId, rowIndex, item, visibleColumns, dispatch, isReadOnly, rowActions, onCellKeyDown, cellAttr = "data-cell", isMobile = false, style, measureRef }) {
9
+ const handleChange = useCallback((fieldOrPatch, value) => {
10
+ if (typeof fieldOrPatch === "string") dispatch({
11
+ type: "UPDATE_CELL",
12
+ rowId,
13
+ field: fieldOrPatch,
14
+ value
15
+ });
16
+ else dispatch({
17
+ type: "UPDATE_ROW",
18
+ rowId,
19
+ patch: fieldOrPatch
20
+ });
21
+ }, [dispatch, rowId]);
22
+ if (isMobile) return /* @__PURE__ */ jsxs("div", {
23
+ className: "rounded-lg border bg-card p-3 space-y-2",
24
+ children: [/* @__PURE__ */ jsx("div", {
25
+ className: "grid grid-cols-12 gap-2",
26
+ children: visibleColumns.map((col) => {
27
+ const CellComponent = col.cell;
28
+ const field = col.field ?? col.id;
29
+ const value = item?.[field];
30
+ return /* @__PURE__ */ jsxs("div", {
31
+ style: { gridColumn: `span ${col.mobileSpan ?? 12}` },
32
+ [cellAttr]: `${rowId}:${col.id}`,
33
+ children: [col.mobileLabel && /* @__PURE__ */ jsx("label", {
34
+ className: "text-xs text-muted-foreground mb-1 block",
35
+ children: col.mobileLabel
36
+ }), /* @__PURE__ */ jsx(CellComponent, {
37
+ value,
38
+ row: item,
39
+ rowId,
40
+ rowIndex,
41
+ column: col,
42
+ onChange: handleChange,
43
+ isReadOnly
44
+ })]
45
+ }, col.id);
46
+ })
47
+ }), rowActions && /* @__PURE__ */ jsx("div", {
48
+ className: "flex justify-end pt-1",
49
+ children: rowActions({
50
+ rowId,
51
+ rowIndex,
52
+ isReadOnly
53
+ })
54
+ })]
55
+ });
56
+ const isVirtual = !!style;
57
+ return /* @__PURE__ */ jsxs("tr", {
58
+ ref: measureRef,
59
+ "data-index": measureRef ? rowIndex : void 0,
60
+ className: "border-b transition-colors hover:bg-muted/30",
61
+ style,
62
+ children: [visibleColumns.map((col) => {
63
+ const CellComponent = col.cell;
64
+ const field = col.field ?? col.id;
65
+ const value = item?.[field];
66
+ return /* @__PURE__ */ jsx("td", {
67
+ className: `px-2 py-1 overflow-hidden ${col.align === "right" ? "text-right" : col.align === "center" ? "text-center" : "text-left"}`,
68
+ style: isVirtual ? {
69
+ flex: `0 0 ${col.width}`,
70
+ minWidth: 0
71
+ } : void 0,
72
+ [cellAttr]: `${rowId}:${col.id}`,
73
+ onKeyDown: onCellKeyDown ? (e) => onCellKeyDown(e, rowId, col.id) : void 0,
74
+ children: /* @__PURE__ */ jsx(CellComponent, {
75
+ value,
76
+ row: item,
77
+ rowId,
78
+ rowIndex,
79
+ column: col,
80
+ onChange: handleChange,
81
+ isReadOnly
82
+ })
83
+ }, col.id);
84
+ }), rowActions && /* @__PURE__ */ jsx("td", {
85
+ className: "px-2 py-1",
86
+ style: isVirtual ? {
87
+ flex: "0 0 8%",
88
+ minWidth: 0
89
+ } : { width: "8%" },
90
+ children: rowActions({
91
+ rowId,
92
+ rowIndex,
93
+ isReadOnly
94
+ })
95
+ })]
96
+ });
97
+ }
98
+ function rowPropsAreEqual(prev, next) {
99
+ return prev.rowId === next.rowId && prev.rowIndex === next.rowIndex && prev.item === next.item && prev.isReadOnly === next.isReadOnly && prev.isMobile === next.isMobile && prev.visibleColumns === next.visibleColumns && prev.measureRef === next.measureRef && prev.style?.transform === next.style?.transform;
100
+ }
101
+ const SpreadsheetRow = memo(SpreadsheetRowInner, rowPropsAreEqual);
102
+
103
+ //#endregion
104
+ //#region src/components/spreadsheet/use-spreadsheet-keyboard.ts
105
+ /**
106
+ * Data attribute used to mark focusable cells for keyboard navigation.
107
+ * Each cell should have: data-cell="rowId:colId"
108
+ */
109
+ const CELL_ATTR = "data-cell";
110
+ function findCell(container, rowId, colId) {
111
+ return container.querySelector(`[${CELL_ATTR}="${rowId}:${colId}"]`);
112
+ }
113
+ function focusCell(cell) {
114
+ const focusable = cell.querySelector("input, select, textarea, button, [tabindex]:not([tabindex=\"-1\"])");
115
+ if (focusable) focusable.focus();
116
+ else cell.focus();
117
+ }
118
+ /**
119
+ * Keyboard navigation for spreadsheet cells.
120
+ *
121
+ * Tab → next cell → wrap to next row
122
+ * Shift+Tab → previous cell → wrap to previous row
123
+ * Enter → commit + move down (same column, next row)
124
+ */
125
+ function useSpreadsheetKeyboard({ orderedIds, columnIds, containerRef, onAddRow }) {
126
+ const orderedIdsRef = useRef(orderedIds);
127
+ const columnIdsRef = useRef(columnIds);
128
+ const onAddRowRef = useRef(onAddRow);
129
+ useEffect(() => {
130
+ orderedIdsRef.current = orderedIds;
131
+ columnIdsRef.current = columnIds;
132
+ onAddRowRef.current = onAddRow;
133
+ }, [
134
+ orderedIds,
135
+ columnIds,
136
+ onAddRow
137
+ ]);
138
+ return {
139
+ handleCellKeyDown: useCallback((e, rowId, colId) => {
140
+ const container = containerRef.current;
141
+ if (!container) return;
142
+ const currentOrderedIds = orderedIdsRef.current;
143
+ const currentColumnIds = columnIdsRef.current;
144
+ const rowIdx = currentOrderedIds.indexOf(rowId);
145
+ const colIdx = currentColumnIds.indexOf(colId);
146
+ if (rowIdx === -1 || colIdx === -1) return;
147
+ let targetRowIdx = rowIdx;
148
+ let targetColIdx = colIdx;
149
+ if (e.key === "Tab") {
150
+ e.preventDefault();
151
+ if (e.shiftKey) {
152
+ targetColIdx--;
153
+ if (targetColIdx < 0) {
154
+ targetRowIdx--;
155
+ targetColIdx = currentColumnIds.length - 1;
156
+ }
157
+ } else {
158
+ targetColIdx++;
159
+ if (targetColIdx >= currentColumnIds.length) {
160
+ targetRowIdx++;
161
+ targetColIdx = 0;
162
+ }
163
+ }
164
+ } else if (e.key === "Enter" && !e.shiftKey) {
165
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
166
+ e.preventDefault();
167
+ targetRowIdx++;
168
+ } else if (e.key === "ArrowDown") {
169
+ e.preventDefault();
170
+ targetRowIdx++;
171
+ } else if (e.key === "ArrowUp") {
172
+ e.preventDefault();
173
+ targetRowIdx--;
174
+ } else if (e.key === "ArrowRight") {
175
+ const active = document.activeElement;
176
+ if (!active || active.tagName !== "INPUT" || active.selectionStart === active.value?.length) {
177
+ e.preventDefault();
178
+ targetColIdx++;
179
+ } else return;
180
+ } else if (e.key === "ArrowLeft") {
181
+ const active = document.activeElement;
182
+ if (!active || active.tagName !== "INPUT" || active.selectionEnd === 0) {
183
+ e.preventDefault();
184
+ targetColIdx--;
185
+ } else return;
186
+ } else return;
187
+ if (targetRowIdx >= currentOrderedIds.length) {
188
+ if (onAddRowRef.current) {
189
+ const lastRowId = currentOrderedIds[currentOrderedIds.length - 1];
190
+ onAddRowRef.current(lastRowId);
191
+ }
192
+ return;
193
+ }
194
+ if (targetRowIdx < 0) return;
195
+ if (targetColIdx < 0 || targetColIdx >= currentColumnIds.length) return;
196
+ const targetRowId = currentOrderedIds[targetRowIdx];
197
+ const targetColId = currentColumnIds[targetColIdx];
198
+ const targetCell = findCell(container, targetRowId, targetColId);
199
+ if (targetCell) requestAnimationFrame(() => focusCell(targetCell));
200
+ }, [containerRef]),
201
+ CELL_ATTR
202
+ };
203
+ }
204
+
205
+ //#endregion
206
+ //#region src/components/spreadsheet/use-spreadsheet-clipboard.ts
207
+ /**
208
+ * Handles copy/pasting from Excel or other spreadsheets natively.
209
+ * Supports matrix pasting (multiple rows and columns at once) via BATCH updates.
210
+ */
211
+ function useSpreadsheetClipboard({ containerRef, orderedIds, columns, dispatch, isReadOnly = false }) {
212
+ const orderedIdsRef = useRef(orderedIds);
213
+ const columnsRef = useRef(columns);
214
+ const isReadOnlyRef = useRef(isReadOnly);
215
+ useEffect(() => {
216
+ orderedIdsRef.current = orderedIds;
217
+ columnsRef.current = columns;
218
+ isReadOnlyRef.current = isReadOnly;
219
+ }, [
220
+ orderedIds,
221
+ columns,
222
+ isReadOnly
223
+ ]);
224
+ useEffect(() => {
225
+ const container = containerRef.current;
226
+ if (!container) return;
227
+ function handlePaste(e) {
228
+ if (isReadOnlyRef.current) return;
229
+ const activeElement = document.activeElement;
230
+ if (!activeElement || !container?.contains(activeElement)) return;
231
+ const cellElement = activeElement.closest("[data-cell]");
232
+ if (!cellElement) return;
233
+ const cellCoord = cellElement.getAttribute("data-cell");
234
+ if (!cellCoord) return;
235
+ const [startRowId, startColId] = cellCoord.split(":");
236
+ const clipboardData = e.clipboardData;
237
+ if (!clipboardData) return;
238
+ const pastedText = clipboardData.getData("text/plain");
239
+ if (!pastedText) return;
240
+ if (!(pastedText.includes(" ") || pastedText.includes("\n"))) return;
241
+ e.preventDefault();
242
+ const currentOrderedIds = orderedIdsRef.current;
243
+ const visibleCols = columnsRef.current.filter((c) => !c.hiddenWhen?.({ isReadOnly: isReadOnlyRef.current }));
244
+ const startRowIdx = currentOrderedIds.indexOf(startRowId);
245
+ const startColIdx = visibleCols.findIndex((c) => c.id === startColId);
246
+ if (startRowIdx === -1 || startColIdx === -1) return;
247
+ const rows = pastedText.replace(/\r?\n$/, "").split(/\r?\n/);
248
+ const batchActions = [];
249
+ rows.forEach((row, rIdx) => {
250
+ const targetRowIdx = startRowIdx + rIdx;
251
+ if (targetRowIdx >= currentOrderedIds.length) return;
252
+ const targetRowId = currentOrderedIds[targetRowIdx];
253
+ row.split(" ").forEach((cellValue, cIdx) => {
254
+ const targetColIdx = startColIdx + cIdx;
255
+ if (targetColIdx >= visibleCols.length) return;
256
+ const col = visibleCols[targetColIdx];
257
+ const field = col.field ?? col.id;
258
+ let value = cellValue;
259
+ if (col.dataType === "number") {
260
+ const num = Number(cellValue);
261
+ value = isNaN(num) ? 0 : num;
262
+ }
263
+ batchActions.push({
264
+ type: "UPDATE_CELL",
265
+ rowId: targetRowId,
266
+ field,
267
+ value
268
+ });
269
+ });
270
+ });
271
+ if (batchActions.length > 0) dispatch({
272
+ type: "BATCH",
273
+ actions: batchActions
274
+ });
275
+ }
276
+ container.addEventListener("paste", handlePaste);
277
+ return () => container.removeEventListener("paste", handlePaste);
278
+ }, [containerRef, dispatch]);
279
+ }
280
+
281
+ //#endregion
282
+ //#region src/components/spreadsheet/spreadsheet-table.tsx
283
+ const VIRTUAL_ROW_BASE = {
284
+ display: "flex",
285
+ alignItems: "stretch",
286
+ position: "absolute",
287
+ top: 0,
288
+ left: 0,
289
+ width: "100%",
290
+ willChange: "transform"
291
+ };
292
+ function SpreadsheetTable({ columns, items, orderedIds, dispatch, isReadOnly = false, footer, mobileFooter, rowActions, className = "", headerClassName, ariaLabel = "Spreadsheet", onAddRow, virtualize = false, estimateRowHeight = 48 }) {
293
+ const containerRef = useRef(null);
294
+ const tableContainerRef = useRef(null);
295
+ const mobileContainerRef = useRef(null);
296
+ const visibleColumns = useMemo(() => columns.filter((col) => !col.hiddenWhen?.({ isReadOnly })), [columns, isReadOnly]);
297
+ const { handleCellKeyDown, CELL_ATTR } = useSpreadsheetKeyboard({
298
+ orderedIds,
299
+ columnIds: useMemo(() => visibleColumns.map((col) => col.id), [visibleColumns]),
300
+ containerRef,
301
+ onAddRow
302
+ });
303
+ useSpreadsheetClipboard({
304
+ containerRef,
305
+ orderedIds,
306
+ columns,
307
+ dispatch,
308
+ isReadOnly
309
+ });
310
+ const getItemKey = useCallback((index) => orderedIds[index] ?? index, [orderedIds]);
311
+ const estimateSize = useCallback(() => estimateRowHeight, [estimateRowHeight]);
312
+ const desktopVirtualizer = useVirtualizer({
313
+ count: orderedIds.length,
314
+ getScrollElement: () => tableContainerRef.current,
315
+ estimateSize,
316
+ measureElement: virtualize ? (el) => el.getBoundingClientRect().height : void 0,
317
+ overscan: 5,
318
+ getItemKey,
319
+ enabled: virtualize
320
+ });
321
+ const mobileVirtualizer = useVirtualizer({
322
+ count: orderedIds.length,
323
+ getScrollElement: () => mobileContainerRef.current,
324
+ estimateSize,
325
+ measureElement: virtualize ? (el) => el.getBoundingClientRect().height : void 0,
326
+ overscan: 3,
327
+ getItemKey,
328
+ enabled: virtualize
329
+ });
330
+ return /* @__PURE__ */ jsxs("div", {
331
+ ref: containerRef,
332
+ className,
333
+ children: [/* @__PURE__ */ jsx("div", {
334
+ ref: tableContainerRef,
335
+ className: "hidden md:block overflow-x-auto",
336
+ style: virtualize ? {
337
+ maxHeight: "600px",
338
+ overflowY: "auto"
339
+ } : void 0,
340
+ children: /* @__PURE__ */ jsxs("table", {
341
+ className: "w-full border-collapse table-fixed",
342
+ style: { minWidth: "800px" },
343
+ "aria-label": ariaLabel,
344
+ children: [
345
+ /* @__PURE__ */ jsx("thead", {
346
+ className: `sticky top-0 z-10 ${headerClassName ?? "bg-muted/50"}`,
347
+ children: /* @__PURE__ */ jsxs("tr", {
348
+ className: "border-b",
349
+ style: virtualize ? {
350
+ display: "flex",
351
+ width: "100%"
352
+ } : void 0,
353
+ children: [visibleColumns.map((col) => /* @__PURE__ */ jsx("th", {
354
+ className: `px-2 py-2 text-xs font-medium text-muted-foreground ${col.align === "right" ? "text-right" : col.align === "center" ? "text-center" : "text-left"}`,
355
+ style: virtualize ? {
356
+ width: col.width,
357
+ flex: `0 0 ${col.width}`,
358
+ minWidth: 0
359
+ } : { width: col.width },
360
+ children: col.header
361
+ }, col.id)), rowActions && /* @__PURE__ */ jsx("th", {
362
+ className: "px-2 py-2",
363
+ style: virtualize ? {
364
+ width: "8%",
365
+ flex: "0 0 8%",
366
+ minWidth: 0
367
+ } : { width: "8%" }
368
+ })]
369
+ })
370
+ }),
371
+ /* @__PURE__ */ jsx("tbody", {
372
+ style: virtualize ? {
373
+ height: `${desktopVirtualizer.getTotalSize()}px`,
374
+ position: "relative"
375
+ } : void 0,
376
+ children: virtualize ? desktopVirtualizer.getVirtualItems().map((virtualRow) => {
377
+ const rowId = orderedIds[virtualRow.index];
378
+ const item = items.get(rowId);
379
+ if (!item) return null;
380
+ return /* @__PURE__ */ jsx(SpreadsheetRow, {
381
+ rowId,
382
+ rowIndex: virtualRow.index,
383
+ item,
384
+ visibleColumns,
385
+ dispatch,
386
+ isReadOnly,
387
+ rowActions,
388
+ onCellKeyDown: handleCellKeyDown,
389
+ cellAttr: CELL_ATTR,
390
+ isMobile: false,
391
+ measureRef: desktopVirtualizer.measureElement,
392
+ style: {
393
+ ...VIRTUAL_ROW_BASE,
394
+ transform: `translateY(${virtualRow.start}px)`
395
+ }
396
+ }, virtualRow.key);
397
+ }) : orderedIds.map((rowId, index) => {
398
+ const item = items.get(rowId);
399
+ if (!item) return null;
400
+ return /* @__PURE__ */ jsx(SpreadsheetRow, {
401
+ rowId,
402
+ rowIndex: index,
403
+ item,
404
+ visibleColumns,
405
+ dispatch,
406
+ isReadOnly,
407
+ rowActions,
408
+ onCellKeyDown: handleCellKeyDown,
409
+ cellAttr: CELL_ATTR,
410
+ isMobile: false
411
+ }, rowId);
412
+ })
413
+ }),
414
+ footer && /* @__PURE__ */ jsx("tfoot", { children: footer })
415
+ ]
416
+ })
417
+ }), /* @__PURE__ */ jsxs("div", {
418
+ ref: mobileContainerRef,
419
+ className: "md:hidden space-y-3",
420
+ style: virtualize ? {
421
+ maxHeight: "600px",
422
+ overflowY: "auto"
423
+ } : void 0,
424
+ children: [/* @__PURE__ */ jsx("div", {
425
+ style: virtualize ? {
426
+ height: `${mobileVirtualizer.getTotalSize()}px`,
427
+ position: "relative"
428
+ } : void 0,
429
+ children: virtualize ? mobileVirtualizer.getVirtualItems().map((virtualRow) => {
430
+ const rowId = orderedIds[virtualRow.index];
431
+ const item = items.get(rowId);
432
+ if (!item) return null;
433
+ return /* @__PURE__ */ jsx(SpreadsheetRow, {
434
+ rowId,
435
+ rowIndex: virtualRow.index,
436
+ item,
437
+ visibleColumns,
438
+ dispatch,
439
+ isReadOnly,
440
+ rowActions,
441
+ cellAttr: CELL_ATTR,
442
+ isMobile: true,
443
+ measureRef: mobileVirtualizer.measureElement,
444
+ style: {
445
+ ...VIRTUAL_ROW_BASE,
446
+ transform: `translateY(${virtualRow.start}px)`
447
+ }
448
+ }, virtualRow.key);
449
+ }) : orderedIds.map((rowId, index) => {
450
+ const item = items.get(rowId);
451
+ if (!item) return null;
452
+ return /* @__PURE__ */ jsx(SpreadsheetRow, {
453
+ rowId,
454
+ rowIndex: index,
455
+ item,
456
+ visibleColumns,
457
+ dispatch,
458
+ isReadOnly,
459
+ rowActions,
460
+ cellAttr: CELL_ATTR,
461
+ isMobile: true
462
+ }, rowId);
463
+ })
464
+ }), mobileFooter && /* @__PURE__ */ jsx("div", {
465
+ className: "rounded-lg border bg-card p-3",
466
+ children: mobileFooter
467
+ })]
468
+ })]
469
+ });
470
+ }
471
+
472
+ //#endregion
473
+ //#region src/components/spreadsheet/use-spreadsheet-store.ts
474
+ function defaultGetId(item) {
475
+ if (item && typeof item === "object") {
476
+ const obj = item;
477
+ const id = obj._id ?? obj.id;
478
+ if (typeof id === "string" && id) return id;
479
+ }
480
+ return crypto.randomUUID();
481
+ }
482
+ function spreadsheetReducer(state, action) {
483
+ switch (action.type) {
484
+ case "SET_ALL": {
485
+ const getId = action.getId ?? defaultGetId;
486
+ const items = /* @__PURE__ */ new Map();
487
+ const orderedIds = [];
488
+ for (const item of action.items) {
489
+ let id = getId(item);
490
+ while (items.has(id)) id = `${id}_${crypto.randomUUID().slice(0, 8)}`;
491
+ items.set(id, item);
492
+ orderedIds.push(id);
493
+ }
494
+ return {
495
+ items,
496
+ orderedIds
497
+ };
498
+ }
499
+ case "UPDATE_CELL": {
500
+ const existing = state.items.get(action.rowId);
501
+ if (!existing) return state;
502
+ const updated = {
503
+ ...existing,
504
+ [action.field]: action.value
505
+ };
506
+ const newItems = new Map(state.items);
507
+ newItems.set(action.rowId, updated);
508
+ return {
509
+ ...state,
510
+ items: newItems
511
+ };
512
+ }
513
+ case "UPDATE_ROW": {
514
+ const existing = state.items.get(action.rowId);
515
+ if (!existing) return state;
516
+ const updated = {
517
+ ...existing,
518
+ ...action.patch
519
+ };
520
+ const newItems = new Map(state.items);
521
+ newItems.set(action.rowId, updated);
522
+ return {
523
+ ...state,
524
+ items: newItems
525
+ };
526
+ }
527
+ case "ADD_ROW": {
528
+ const newId = action.newId ?? crypto.randomUUID();
529
+ const newItems = new Map(state.items);
530
+ newItems.set(newId, action.item);
531
+ const idx = state.orderedIds.indexOf(action.afterRowId);
532
+ const insertAt = idx === -1 ? state.orderedIds.length : idx + 1;
533
+ const newIds = [...state.orderedIds];
534
+ newIds.splice(insertAt, 0, newId);
535
+ return {
536
+ items: newItems,
537
+ orderedIds: newIds
538
+ };
539
+ }
540
+ case "REMOVE_ROW": {
541
+ if (state.orderedIds.length <= 1) return state;
542
+ const newItems = new Map(state.items);
543
+ newItems.delete(action.rowId);
544
+ return {
545
+ items: newItems,
546
+ orderedIds: state.orderedIds.filter((id) => id !== action.rowId)
547
+ };
548
+ }
549
+ case "BATCH": {
550
+ if (action.actions.every((a) => a.type === "UPDATE_CELL")) {
551
+ const newItems = new Map(state.items);
552
+ for (const sub of action.actions) {
553
+ if (sub.type !== "UPDATE_CELL") continue;
554
+ const existing = newItems.get(sub.rowId);
555
+ if (!existing) continue;
556
+ newItems.set(sub.rowId, {
557
+ ...existing,
558
+ [sub.field]: sub.value
559
+ });
560
+ }
561
+ return {
562
+ ...state,
563
+ items: newItems
564
+ };
565
+ }
566
+ let current = state;
567
+ for (const sub of action.actions) current = spreadsheetReducer(current, sub);
568
+ return current;
569
+ }
570
+ default: return state;
571
+ }
572
+ }
573
+ const EMPTY_STATE = {
574
+ items: /* @__PURE__ */ new Map(),
575
+ orderedIds: []
576
+ };
577
+ function useSpreadsheetStore(initialItems, getId) {
578
+ const [state, dispatch] = useReducer(spreadsheetReducer, initialItems, (items) => {
579
+ if (!items || items.length === 0) return EMPTY_STATE;
580
+ return spreadsheetReducer(EMPTY_STATE, {
581
+ type: "SET_ALL",
582
+ items,
583
+ getId
584
+ });
585
+ });
586
+ const stateRef = useRef(state);
587
+ stateRef.current = state;
588
+ /** Get items as an ordered array (for API submission) — stable reference */
589
+ const getOrderedItems = useCallback(() => {
590
+ const { orderedIds, items } = stateRef.current;
591
+ return orderedIds.map((id) => items.get(id)).filter(Boolean);
592
+ }, []);
593
+ /** Convenience: total row count */
594
+ const rowCount = state.orderedIds.length;
595
+ return useMemo(() => ({
596
+ items: state.items,
597
+ orderedIds: state.orderedIds,
598
+ dispatch,
599
+ getOrderedItems,
600
+ rowCount
601
+ }), [
602
+ state.items,
603
+ state.orderedIds,
604
+ dispatch,
605
+ getOrderedItems,
606
+ rowCount
607
+ ]);
608
+ }
609
+
610
+ //#endregion
611
+ export { SpreadsheetRow, SpreadsheetTable, useSpreadsheetKeyboard, useSpreadsheetStore };
package/dist/forms.mjs CHANGED
@@ -1325,7 +1325,7 @@ function TagChoiceInputInternal({ name, label, description, placeholder = "Selec
1325
1325
  }),
1326
1326
  children: [/* @__PURE__ */ jsx("div", {
1327
1327
  className: "flex flex-1 flex-wrap items-center gap-1 px-3 py-1.5 min-h-[2.25rem]",
1328
- children: selectedValues.length > 0 ? selectedValues.map((val) => {
1328
+ children: selectedValues.length > 0 ? selectedValues.map((val, idx) => {
1329
1329
  const item = items.find((i) => i.value === val);
1330
1330
  return /* @__PURE__ */ jsxs(Badge, {
1331
1331
  variant: "secondary",
@@ -1338,7 +1338,7 @@ function TagChoiceInputInternal({ name, label, description, placeholder = "Selec
1338
1338
  "aria-label": `Remove ${item?.label || val}`,
1339
1339
  children: /* @__PURE__ */ jsx(X, { className: "h-3 w-3" })
1340
1340
  })]
1341
- }, val);
1341
+ }, val || `tag-${idx}`);
1342
1342
  }) : /* @__PURE__ */ jsx("span", {
1343
1343
  className: "text-sm text-muted-foreground",
1344
1344
  children: placeholder
@@ -1485,7 +1485,7 @@ function ComboboxInput({ control, name, label, placeholder = "Select...", emptyT
1485
1485
  onValueChangeRef.current = onValueChange;
1486
1486
  const propOnChangeRef = useRef(propOnChange);
1487
1487
  propOnChangeRef.current = propOnChange;
1488
- const fieldRef = useRef();
1488
+ const fieldRef = useRef(void 0);
1489
1489
  const handleValueChange = useCallback((newItem) => {
1490
1490
  const safeValue = newItem?.value || "";
1491
1491
  if (safeValue === currentValueRef.current) return;