@rovula/ui 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/bundle.css +522 -67
- package/dist/cjs/bundle.js +589 -589
- package/dist/cjs/bundle.js.map +1 -1
- package/dist/cjs/types/components/DataTable/DataTable.d.ts +195 -4
- package/dist/cjs/types/components/DataTable/DataTable.editing.d.ts +20 -0
- package/dist/cjs/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
- package/dist/cjs/types/components/DataTable/DataTable.stories.d.ts +294 -6
- package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +22 -0
- package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
- package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +3 -3
- package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
- package/dist/cjs/types/components/Table/Table.d.ts +33 -3
- package/dist/cjs/types/components/Table/Table.stories.d.ts +86 -4
- package/dist/cjs/types/components/TextInput/TextInput.stories.d.ts +8 -0
- package/dist/cjs/types/components/TextInput/TextInput.styles.d.ts +1 -0
- package/dist/components/DataTable/DataTable.editing.js +385 -0
- package/dist/components/DataTable/DataTable.editing.types.js +1 -0
- package/dist/components/DataTable/DataTable.js +993 -50
- package/dist/components/DataTable/DataTable.stories.js +1137 -25
- package/dist/components/Dropdown/Dropdown.js +8 -6
- package/dist/components/ScrollArea/ScrollArea.js +2 -2
- package/dist/components/ScrollArea/ScrollArea.stories.js +68 -2
- package/dist/components/Table/Table.js +103 -13
- package/dist/components/Table/Table.stories.js +226 -9
- package/dist/components/TextInput/TextInput.js +6 -4
- package/dist/components/TextInput/TextInput.stories.js +8 -0
- package/dist/components/TextInput/TextInput.styles.js +7 -1
- package/dist/esm/bundle.css +522 -67
- package/dist/esm/bundle.js +1545 -1545
- package/dist/esm/bundle.js.map +1 -1
- package/dist/esm/types/components/DataTable/DataTable.d.ts +195 -4
- package/dist/esm/types/components/DataTable/DataTable.editing.d.ts +20 -0
- package/dist/esm/types/components/DataTable/DataTable.editing.types.d.ts +145 -0
- package/dist/esm/types/components/DataTable/DataTable.stories.d.ts +294 -6
- package/dist/esm/types/components/Dropdown/Dropdown.d.ts +22 -0
- package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +4 -0
- package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +3 -3
- package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +4 -0
- package/dist/esm/types/components/Table/Table.d.ts +33 -3
- package/dist/esm/types/components/Table/Table.stories.d.ts +86 -4
- package/dist/esm/types/components/TextInput/TextInput.stories.d.ts +8 -0
- package/dist/esm/types/components/TextInput/TextInput.styles.d.ts +1 -0
- package/dist/index.d.ts +493 -122
- package/dist/src/theme/global.css +775 -96
- package/package.json +14 -2
- package/src/components/DataTable/DataTable.editing.tsx +861 -0
- package/src/components/DataTable/DataTable.editing.types.ts +192 -0
- package/src/components/DataTable/DataTable.stories.tsx +2310 -31
- package/src/components/DataTable/DataTable.test.tsx +696 -0
- package/src/components/DataTable/DataTable.tsx +2275 -94
- package/src/components/Dropdown/Dropdown.tsx +22 -6
- package/src/components/ScrollArea/ScrollArea.stories.tsx +146 -3
- package/src/components/ScrollArea/ScrollArea.tsx +6 -6
- package/src/components/Table/Table.stories.tsx +789 -44
- package/src/components/Table/Table.tsx +306 -28
- package/src/components/TextInput/TextInput.stories.tsx +80 -0
- package/src/components/TextInput/TextInput.styles.ts +7 -1
- package/src/components/TextInput/TextInput.tsx +21 -14
- package/src/test/setup.ts +50 -0
- package/src/theme/global.css +81 -42
- package/src/theme/presets/colors.js +12 -0
- package/src/theme/themes/variable.css +27 -28
- package/src/theme/tokens/baseline.css +2 -1
- package/src/theme/tokens/components/scrollbar.css +9 -4
- package/src/theme/tokens/components/table.css +63 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { ColumnDef, Row, RowData } from "@tanstack/react-table";
|
|
3
|
+
|
|
4
|
+
import TextInput from "@/components/TextInput/TextInput";
|
|
5
|
+
import NumberInput from "@/components/NumberInput";
|
|
6
|
+
import { Checkbox } from "@/components/Checkbox/Checkbox";
|
|
7
|
+
import Dropdown, { type Options } from "@/components/Dropdown/Dropdown";
|
|
8
|
+
import { customInputVariant } from "@/components/Dropdown/Dropdown.styles";
|
|
9
|
+
import { cn } from "@/utils/cn";
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
EditableColumnDef,
|
|
13
|
+
EditDisplayMode,
|
|
14
|
+
EditTrigger,
|
|
15
|
+
EditingState,
|
|
16
|
+
} from "./DataTable.editing.types";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// EditContext — shared between engine and DataTable render layer
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export const EditContext = React.createContext<EditingState | null>(null);
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// useDataTableEditing — manages edit state (row / cell)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function useDataTableEditing<TData extends RowData>(opts: {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
editDisplayMode: EditDisplayMode;
|
|
31
|
+
editTrigger: EditTrigger;
|
|
32
|
+
editableColumnIds: string[];
|
|
33
|
+
}): EditingState {
|
|
34
|
+
const { enabled, editDisplayMode, editTrigger, editableColumnIds } = opts;
|
|
35
|
+
|
|
36
|
+
const [editingRowId, setEditingRowId] = useState<string | null>(null);
|
|
37
|
+
const [editingCell, setEditingCell] = useState<{
|
|
38
|
+
rowId: string;
|
|
39
|
+
columnId: string;
|
|
40
|
+
} | null>(null);
|
|
41
|
+
const tabMovingRef = React.useRef(false);
|
|
42
|
+
|
|
43
|
+
const requestRowEdit = useCallback((rowId: string) => {
|
|
44
|
+
setEditingRowId(rowId);
|
|
45
|
+
setEditingCell(null);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const requestCellEdit = useCallback((rowId: string, columnId: string) => {
|
|
49
|
+
setEditingCell({ rowId, columnId });
|
|
50
|
+
setEditingRowId(null);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const clearEdit = useCallback(() => {
|
|
54
|
+
setEditingRowId(null);
|
|
55
|
+
setEditingCell(null);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const onFieldBlur = useCallback(
|
|
59
|
+
(_rowId: string, _columnId: string) => {
|
|
60
|
+
if (tabMovingRef.current) return;
|
|
61
|
+
if (editDisplayMode === "cell") {
|
|
62
|
+
setEditingCell(null);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
[editDisplayMode],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const moveCellEdit = useCallback(
|
|
69
|
+
(
|
|
70
|
+
rowId: string,
|
|
71
|
+
fromColumnId: string,
|
|
72
|
+
delta: -1 | 1,
|
|
73
|
+
): "moved" | "exited" => {
|
|
74
|
+
const idx = editableColumnIds.indexOf(fromColumnId);
|
|
75
|
+
if (idx < 0) {
|
|
76
|
+
setEditingCell(null);
|
|
77
|
+
return "exited";
|
|
78
|
+
}
|
|
79
|
+
const next = idx + delta;
|
|
80
|
+
if (next < 0 || next >= editableColumnIds.length) {
|
|
81
|
+
setEditingCell(null);
|
|
82
|
+
return "exited";
|
|
83
|
+
}
|
|
84
|
+
tabMovingRef.current = true;
|
|
85
|
+
setEditingCell({ rowId, columnId: editableColumnIds[next]! });
|
|
86
|
+
requestAnimationFrame(() => {
|
|
87
|
+
tabMovingRef.current = false;
|
|
88
|
+
});
|
|
89
|
+
return "moved";
|
|
90
|
+
},
|
|
91
|
+
[editableColumnIds],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Esc exits row edit
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!enabled || editDisplayMode !== "row" || !editingRowId) return;
|
|
97
|
+
const onKey = (e: KeyboardEvent) => {
|
|
98
|
+
if (e.key === "Escape") setEditingRowId(null);
|
|
99
|
+
};
|
|
100
|
+
document.addEventListener("keydown", onKey);
|
|
101
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
102
|
+
}, [enabled, editDisplayMode, editingRowId]);
|
|
103
|
+
|
|
104
|
+
return useMemo<EditingState>(
|
|
105
|
+
() => ({
|
|
106
|
+
editingRowId,
|
|
107
|
+
editingCell,
|
|
108
|
+
editDisplayMode,
|
|
109
|
+
editTrigger,
|
|
110
|
+
requestRowEdit,
|
|
111
|
+
requestCellEdit,
|
|
112
|
+
clearEdit,
|
|
113
|
+
onFieldBlur,
|
|
114
|
+
moveCellEdit,
|
|
115
|
+
}),
|
|
116
|
+
[
|
|
117
|
+
editingRowId,
|
|
118
|
+
editingCell,
|
|
119
|
+
editDisplayMode,
|
|
120
|
+
editTrigger,
|
|
121
|
+
requestRowEdit,
|
|
122
|
+
requestCellEdit,
|
|
123
|
+
clearEdit,
|
|
124
|
+
onFieldBlur,
|
|
125
|
+
moveCellEdit,
|
|
126
|
+
],
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// resolveEditableColumns — wraps EditableColumnDef[] → ColumnDef[]
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
const EDIT_SELECT_PH_PREFIX = "__edit_ph__";
|
|
135
|
+
|
|
136
|
+
function editPlaceholderOption(label: string): Options {
|
|
137
|
+
return { value: `${EDIT_SELECT_PH_PREFIX}:${label}`, label };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isPlaceholderOption(opt: Options | null | undefined) {
|
|
141
|
+
return opt?.value.startsWith(`${EDIT_SELECT_PH_PREFIX}:`) ?? false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type ResolveOpts<TData extends RowData> = {
|
|
145
|
+
editing: EditingState;
|
|
146
|
+
onCellCommit?: (
|
|
147
|
+
rowId: string,
|
|
148
|
+
columnId: string,
|
|
149
|
+
patch: Partial<TData>,
|
|
150
|
+
) => void;
|
|
151
|
+
alwaysEditing?: (row: Row<TData>) => boolean;
|
|
152
|
+
enableCellTabTraversal: boolean;
|
|
153
|
+
editableColumnIds: string[];
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function isEditingCell<TData extends RowData>(
|
|
157
|
+
editing: EditingState,
|
|
158
|
+
row: Row<TData>,
|
|
159
|
+
columnId: string,
|
|
160
|
+
): boolean {
|
|
161
|
+
const rowId =
|
|
162
|
+
typeof (row.original as Record<string, unknown>).id === "string"
|
|
163
|
+
? ((row.original as Record<string, unknown>).id as string)
|
|
164
|
+
: row.id;
|
|
165
|
+
if (editing.editDisplayMode === "row") return editing.editingRowId === rowId;
|
|
166
|
+
return (
|
|
167
|
+
editing.editingCell?.rowId === rowId &&
|
|
168
|
+
editing.editingCell.columnId === columnId
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function getRowId<TData extends RowData>(row: Row<TData>): string {
|
|
173
|
+
const orig = row.original as Record<string, unknown>;
|
|
174
|
+
return typeof orig.id === "string" ? orig.id : row.id;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function coerceValue(variant: string | undefined, rawValue: string): unknown {
|
|
178
|
+
if (variant === "number") {
|
|
179
|
+
const n = Number(rawValue);
|
|
180
|
+
return Number.isNaN(n) ? 0 : n;
|
|
181
|
+
}
|
|
182
|
+
if (variant === "checkbox") {
|
|
183
|
+
return rawValue === "true";
|
|
184
|
+
}
|
|
185
|
+
return rawValue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function buildPatch<TData extends RowData>(
|
|
189
|
+
col: EditableColumnDef<TData>,
|
|
190
|
+
row: Row<TData>,
|
|
191
|
+
rawValue: string,
|
|
192
|
+
columnId: string,
|
|
193
|
+
): Partial<TData> {
|
|
194
|
+
if (col.onCommit) {
|
|
195
|
+
return col.onCommit(row, rawValue);
|
|
196
|
+
}
|
|
197
|
+
return { [columnId]: coerceValue(col.editVariant, rawValue) } as Partial<TData>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function resolveEditableColumns<TData extends RowData>(
|
|
201
|
+
columns: EditableColumnDef<TData>[],
|
|
202
|
+
opts: ResolveOpts<TData>,
|
|
203
|
+
): ColumnDef<TData, unknown>[] {
|
|
204
|
+
const {
|
|
205
|
+
editing,
|
|
206
|
+
onCellCommit,
|
|
207
|
+
alwaysEditing,
|
|
208
|
+
enableCellTabTraversal,
|
|
209
|
+
editableColumnIds,
|
|
210
|
+
} = opts;
|
|
211
|
+
|
|
212
|
+
return columns.map((col) => {
|
|
213
|
+
const colEditing = col.enableEditing;
|
|
214
|
+
if (colEditing == null && col.editVariant == null) {
|
|
215
|
+
return col as ColumnDef<TData, unknown>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const columnId =
|
|
219
|
+
(col as { accessorKey?: string }).accessorKey ??
|
|
220
|
+
(col as { id?: string }).id ??
|
|
221
|
+
"";
|
|
222
|
+
|
|
223
|
+
const originalCell = (col as ColumnDef<TData, unknown>).cell;
|
|
224
|
+
|
|
225
|
+
const wrappedCol: ColumnDef<TData, unknown> = {
|
|
226
|
+
...(col as ColumnDef<TData, unknown>),
|
|
227
|
+
cell: (cellProps) => {
|
|
228
|
+
const { row } = cellProps;
|
|
229
|
+
const rowId = getRowId(row);
|
|
230
|
+
|
|
231
|
+
const canEdit =
|
|
232
|
+
typeof colEditing === "function"
|
|
233
|
+
? colEditing(row)
|
|
234
|
+
: colEditing !== false;
|
|
235
|
+
|
|
236
|
+
if (!canEdit) {
|
|
237
|
+
const always = alwaysEditing?.(row) ?? false;
|
|
238
|
+
const isRowActive =
|
|
239
|
+
always ||
|
|
240
|
+
editing.editingRowId === rowId ||
|
|
241
|
+
editing.editingCell?.rowId === rowId;
|
|
242
|
+
|
|
243
|
+
const showDisabledField =
|
|
244
|
+
always || (col.editShowDisabledField && isRowActive);
|
|
245
|
+
|
|
246
|
+
if (showDisabledField && (col.editVariant || colEditing != null)) {
|
|
247
|
+
return <DisabledEditCell row={row} col={col} columnId={columnId} />;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (col.displayCell) {
|
|
251
|
+
return col.displayCell(row);
|
|
252
|
+
}
|
|
253
|
+
if (originalCell) {
|
|
254
|
+
return typeof originalCell === "function"
|
|
255
|
+
? originalCell(cellProps)
|
|
256
|
+
: originalCell;
|
|
257
|
+
}
|
|
258
|
+
const value = (row.original as Record<string, unknown>)[columnId];
|
|
259
|
+
return <>{String(value ?? "")}</>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const always = alwaysEditing?.(row) ?? false;
|
|
263
|
+
const active = always || isEditingCell(editing, row, columnId);
|
|
264
|
+
|
|
265
|
+
if (!active) {
|
|
266
|
+
const previewContent = col.displayCell
|
|
267
|
+
? col.displayCell(row)
|
|
268
|
+
: String((row.original as Record<string, unknown>)[columnId] ?? "");
|
|
269
|
+
|
|
270
|
+
const activate = () => {
|
|
271
|
+
if (editing.editDisplayMode === "row")
|
|
272
|
+
editing.requestRowEdit(rowId);
|
|
273
|
+
else editing.requestCellEdit(rowId, columnId);
|
|
274
|
+
};
|
|
275
|
+
const handler =
|
|
276
|
+
editing.editTrigger === "click"
|
|
277
|
+
? { onClick: activate }
|
|
278
|
+
: { onDoubleClick: activate };
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div
|
|
282
|
+
className={cn(
|
|
283
|
+
"-mx-1 flex min-h-[32px] w-full min-w-0 cursor-pointer items-center rounded-sm px-1",
|
|
284
|
+
"hover:bg-table-c-hover/35",
|
|
285
|
+
)}
|
|
286
|
+
{...handler}
|
|
287
|
+
>
|
|
288
|
+
{previewContent}
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Edit mode ──
|
|
294
|
+
const variant = col.editVariant ?? "text";
|
|
295
|
+
const autoFocus = !always && editing.editDisplayMode === "cell";
|
|
296
|
+
const errorMsg = col.editError?.(row);
|
|
297
|
+
|
|
298
|
+
const commitAndBlur = (value: string) => {
|
|
299
|
+
const patch = buildPatch(col, row, value, columnId);
|
|
300
|
+
onCellCommit?.(rowId, columnId, patch);
|
|
301
|
+
if (!always) editing.onFieldBlur(rowId, columnId);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const handleTab = enableCellTabTraversal
|
|
305
|
+
? (
|
|
306
|
+
e: React.KeyboardEvent<HTMLInputElement>,
|
|
307
|
+
commitFn: () => void,
|
|
308
|
+
) => {
|
|
309
|
+
if (e.key !== "Tab") return;
|
|
310
|
+
|
|
311
|
+
if (editing.editDisplayMode === "row") {
|
|
312
|
+
const idx = editableColumnIds.indexOf(columnId);
|
|
313
|
+
if (idx < 0) return;
|
|
314
|
+
const nextIdx = idx + (e.shiftKey ? -1 : 1);
|
|
315
|
+
if (nextIdx < 0 || nextIdx >= editableColumnIds.length) return;
|
|
316
|
+
const nextColId = editableColumnIds[nextIdx]!;
|
|
317
|
+
|
|
318
|
+
commitFn();
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
requestAnimationFrame(() => {
|
|
321
|
+
const el = document.querySelector<HTMLElement>(
|
|
322
|
+
`[data-edit-cell="${rowId}:${nextColId}"] input, [data-edit-cell="${rowId}:${nextColId}"] button`,
|
|
323
|
+
);
|
|
324
|
+
el?.focus();
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
commitFn();
|
|
330
|
+
if (
|
|
331
|
+
editing.moveCellEdit(rowId, columnId, e.shiftKey ? -1 : 1) ===
|
|
332
|
+
"moved"
|
|
333
|
+
) {
|
|
334
|
+
e.preventDefault();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
: undefined;
|
|
338
|
+
|
|
339
|
+
if (variant === "select") {
|
|
340
|
+
return (
|
|
341
|
+
<div data-edit-cell={`${rowId}:${columnId}`}>
|
|
342
|
+
<EditCellSelect
|
|
343
|
+
row={row}
|
|
344
|
+
columnId={columnId}
|
|
345
|
+
col={col}
|
|
346
|
+
autoFocus={autoFocus}
|
|
347
|
+
keepOpenAfterSelect={
|
|
348
|
+
editing.editDisplayMode === "row" || always
|
|
349
|
+
}
|
|
350
|
+
onCommit={(label) => {
|
|
351
|
+
const currentValue = String(
|
|
352
|
+
(row.original as Record<string, unknown>)[columnId] ?? "",
|
|
353
|
+
);
|
|
354
|
+
if (label.trim() === currentValue.trim()) return;
|
|
355
|
+
const patch = buildPatch(col, row, label, columnId);
|
|
356
|
+
onCellCommit?.(rowId, columnId, patch);
|
|
357
|
+
}}
|
|
358
|
+
onBlurField={
|
|
359
|
+
editing.editDisplayMode === "row"
|
|
360
|
+
? undefined
|
|
361
|
+
: () => {
|
|
362
|
+
if (!always) editing.onFieldBlur(rowId, columnId);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
onKeyDown={
|
|
366
|
+
handleTab
|
|
367
|
+
? (e) =>
|
|
368
|
+
handleTab(
|
|
369
|
+
e as React.KeyboardEvent<HTMLInputElement>,
|
|
370
|
+
() => {},
|
|
371
|
+
)
|
|
372
|
+
: undefined
|
|
373
|
+
}
|
|
374
|
+
errorMessage={errorMsg}
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (variant === "number") {
|
|
381
|
+
return (
|
|
382
|
+
<div data-edit-cell={`${rowId}:${columnId}`}>
|
|
383
|
+
<EditCellNumber
|
|
384
|
+
columnId={columnId}
|
|
385
|
+
row={row}
|
|
386
|
+
col={col}
|
|
387
|
+
autoFocus={autoFocus}
|
|
388
|
+
errorMessage={errorMsg}
|
|
389
|
+
onBlur={(val) => commitAndBlur(val)}
|
|
390
|
+
onKeyDown={
|
|
391
|
+
handleTab
|
|
392
|
+
? (e) =>
|
|
393
|
+
handleTab(e, () => {
|
|
394
|
+
const v = (
|
|
395
|
+
e.target as HTMLInputElement
|
|
396
|
+
).value.trim();
|
|
397
|
+
const patch = buildPatch(col, row, v, columnId);
|
|
398
|
+
onCellCommit?.(rowId, columnId, patch);
|
|
399
|
+
})
|
|
400
|
+
: undefined
|
|
401
|
+
}
|
|
402
|
+
/>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (variant === "checkbox") {
|
|
408
|
+
return (
|
|
409
|
+
<div data-edit-cell={`${rowId}:${columnId}`}>
|
|
410
|
+
<EditCellCheckbox
|
|
411
|
+
columnId={columnId}
|
|
412
|
+
row={row}
|
|
413
|
+
col={col}
|
|
414
|
+
errorMessage={errorMsg}
|
|
415
|
+
onCommit={(checked) => {
|
|
416
|
+
const patch = buildPatch(
|
|
417
|
+
col,
|
|
418
|
+
row,
|
|
419
|
+
String(checked),
|
|
420
|
+
columnId,
|
|
421
|
+
);
|
|
422
|
+
onCellCommit?.(rowId, columnId, patch);
|
|
423
|
+
}}
|
|
424
|
+
onKeyDown={
|
|
425
|
+
handleTab
|
|
426
|
+
? (e) =>
|
|
427
|
+
handleTab(
|
|
428
|
+
e as React.KeyboardEvent<HTMLInputElement>,
|
|
429
|
+
() => {},
|
|
430
|
+
)
|
|
431
|
+
: undefined
|
|
432
|
+
}
|
|
433
|
+
/>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (variant === "custom" && col.editCustomCell) {
|
|
439
|
+
const blur = () => {
|
|
440
|
+
if (!always) editing.onFieldBlur(rowId, columnId);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<div data-edit-cell={`${rowId}:${columnId}`}>
|
|
445
|
+
{col.editCustomCell(row, {
|
|
446
|
+
commit: (rawValue: string) => {
|
|
447
|
+
const patch = buildPatch(col, row, rawValue, columnId);
|
|
448
|
+
onCellCommit?.(rowId, columnId, patch);
|
|
449
|
+
},
|
|
450
|
+
blur,
|
|
451
|
+
columnId,
|
|
452
|
+
autoFocus,
|
|
453
|
+
errorMessage: errorMsg,
|
|
454
|
+
onKeyDown: handleTab
|
|
455
|
+
? (e) =>
|
|
456
|
+
handleTab(
|
|
457
|
+
e as React.KeyboardEvent<HTMLInputElement>,
|
|
458
|
+
() => {},
|
|
459
|
+
)
|
|
460
|
+
: undefined,
|
|
461
|
+
})}
|
|
462
|
+
</div>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// variant === "text"
|
|
467
|
+
return (
|
|
468
|
+
<div data-edit-cell={`${rowId}:${columnId}`}>
|
|
469
|
+
<EditCellText
|
|
470
|
+
columnId={columnId}
|
|
471
|
+
row={row}
|
|
472
|
+
placeholder={col.editTextProps?.placeholder ?? ""}
|
|
473
|
+
autoFocus={autoFocus}
|
|
474
|
+
errorMessage={errorMsg}
|
|
475
|
+
onBlur={(e) => commitAndBlur(e.currentTarget.value.trim())}
|
|
476
|
+
onKeyDown={
|
|
477
|
+
handleTab
|
|
478
|
+
? (e) =>
|
|
479
|
+
handleTab(e, () => {
|
|
480
|
+
const v = (e.target as HTMLInputElement).value.trim();
|
|
481
|
+
const patch = buildPatch(col, row, v, columnId);
|
|
482
|
+
onCellCommit?.(rowId, columnId, patch);
|
|
483
|
+
})
|
|
484
|
+
: undefined
|
|
485
|
+
}
|
|
486
|
+
/>
|
|
487
|
+
</div>
|
|
488
|
+
);
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
return wrappedCol;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// Built-in cell editors
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
function EditCellText<TData extends RowData>({
|
|
501
|
+
columnId,
|
|
502
|
+
row,
|
|
503
|
+
placeholder,
|
|
504
|
+
autoFocus,
|
|
505
|
+
errorMessage,
|
|
506
|
+
onBlur,
|
|
507
|
+
onKeyDown,
|
|
508
|
+
}: {
|
|
509
|
+
columnId: string;
|
|
510
|
+
row: Row<TData>;
|
|
511
|
+
placeholder: string;
|
|
512
|
+
autoFocus: boolean;
|
|
513
|
+
errorMessage?: string;
|
|
514
|
+
onBlur: React.FocusEventHandler<HTMLInputElement>;
|
|
515
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
|
516
|
+
}) {
|
|
517
|
+
const value = String(
|
|
518
|
+
(row.original as Record<string, unknown>)[columnId] ?? "",
|
|
519
|
+
);
|
|
520
|
+
return (
|
|
521
|
+
<TextInput
|
|
522
|
+
size="sm"
|
|
523
|
+
variant="outline"
|
|
524
|
+
rounded="normal"
|
|
525
|
+
fullwidth
|
|
526
|
+
isFloatingLabel={false}
|
|
527
|
+
label=""
|
|
528
|
+
required={false}
|
|
529
|
+
hasClearIcon={false}
|
|
530
|
+
keepFooterSpace={false}
|
|
531
|
+
defaultValue={value}
|
|
532
|
+
placeholder={placeholder}
|
|
533
|
+
aria-label={placeholder || columnId}
|
|
534
|
+
autoFocus={autoFocus}
|
|
535
|
+
error={!!errorMessage}
|
|
536
|
+
errorMessage={errorMessage}
|
|
537
|
+
onBlur={onBlur}
|
|
538
|
+
onKeyDown={onKeyDown}
|
|
539
|
+
className="rounded-md px-2 py-2 typography-small2"
|
|
540
|
+
/>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function EditCellSelect<TData extends RowData>({
|
|
545
|
+
row,
|
|
546
|
+
columnId,
|
|
547
|
+
col,
|
|
548
|
+
autoFocus,
|
|
549
|
+
onCommit,
|
|
550
|
+
onBlurField,
|
|
551
|
+
onKeyDown,
|
|
552
|
+
keepOpenAfterSelect,
|
|
553
|
+
errorMessage,
|
|
554
|
+
}: {
|
|
555
|
+
row: Row<TData>;
|
|
556
|
+
columnId: string;
|
|
557
|
+
col: EditableColumnDef<TData>;
|
|
558
|
+
autoFocus: boolean;
|
|
559
|
+
onCommit: (label: string) => void;
|
|
560
|
+
onBlurField?: () => void;
|
|
561
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
|
562
|
+
keepOpenAfterSelect?: boolean;
|
|
563
|
+
errorMessage?: string;
|
|
564
|
+
}) {
|
|
565
|
+
const displayValue = String(
|
|
566
|
+
(row.original as Record<string, unknown>)[columnId] ?? "",
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const rawOptions =
|
|
570
|
+
typeof col.editSelectProps?.options === "function"
|
|
571
|
+
? col.editSelectProps.options(row)
|
|
572
|
+
: col.editSelectProps?.options ?? [];
|
|
573
|
+
|
|
574
|
+
const options = useMemo<Options[]>(() => {
|
|
575
|
+
const ph = col.editSelectProps?.placeholder ?? columnId;
|
|
576
|
+
return [editPlaceholderOption(ph), ...rawOptions];
|
|
577
|
+
}, [rawOptions, col.editSelectProps?.placeholder, columnId]);
|
|
578
|
+
|
|
579
|
+
const selected = useMemo(() => {
|
|
580
|
+
const cur = displayValue.trim();
|
|
581
|
+
if (!cur) return options[0]!;
|
|
582
|
+
return options.find((o) => o.label === cur) ?? options[0]!;
|
|
583
|
+
}, [displayValue, options]);
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
<div>
|
|
587
|
+
<Dropdown
|
|
588
|
+
id={`edit-dd-${columnId}-${row.id}`}
|
|
589
|
+
options={options}
|
|
590
|
+
value={selected}
|
|
591
|
+
onSelect={(opt) => {
|
|
592
|
+
if (isPlaceholderOption(opt)) return;
|
|
593
|
+
onCommit(opt.label);
|
|
594
|
+
}}
|
|
595
|
+
size="sm"
|
|
596
|
+
variant="outline"
|
|
597
|
+
rounded="normal"
|
|
598
|
+
fullwidth
|
|
599
|
+
required={false}
|
|
600
|
+
isFloatingLabel={false}
|
|
601
|
+
label=""
|
|
602
|
+
keepFooterSpace={false}
|
|
603
|
+
segmentedInput
|
|
604
|
+
autoFocus={autoFocus}
|
|
605
|
+
onBlur={onBlurField}
|
|
606
|
+
onKeyDown={onKeyDown}
|
|
607
|
+
keepOpenAfterSelect={keepOpenAfterSelect}
|
|
608
|
+
portal
|
|
609
|
+
error={!!errorMessage}
|
|
610
|
+
errorMessage={errorMessage}
|
|
611
|
+
className={cn(
|
|
612
|
+
"rounded-md",
|
|
613
|
+
customInputVariant({ size: "sm" }),
|
|
614
|
+
displayValue.trim().length > 0 && "font-medium",
|
|
615
|
+
)}
|
|
616
|
+
/>
|
|
617
|
+
</div>
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// EditCellNumber — inline number editor
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
function EditCellNumber<TData extends RowData>({
|
|
626
|
+
columnId,
|
|
627
|
+
row,
|
|
628
|
+
col,
|
|
629
|
+
autoFocus,
|
|
630
|
+
errorMessage,
|
|
631
|
+
onBlur,
|
|
632
|
+
onKeyDown,
|
|
633
|
+
}: {
|
|
634
|
+
columnId: string;
|
|
635
|
+
row: Row<TData>;
|
|
636
|
+
col: EditableColumnDef<TData>;
|
|
637
|
+
autoFocus: boolean;
|
|
638
|
+
errorMessage?: string;
|
|
639
|
+
onBlur: (value: string) => void;
|
|
640
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
|
641
|
+
}) {
|
|
642
|
+
const rawValue = (row.original as Record<string, unknown>)[columnId];
|
|
643
|
+
const numericValue =
|
|
644
|
+
rawValue === "" || rawValue == null ? undefined : Number(rawValue);
|
|
645
|
+
const np = col.editNumberProps;
|
|
646
|
+
|
|
647
|
+
return (
|
|
648
|
+
<NumberInput
|
|
649
|
+
size="sm"
|
|
650
|
+
variant="outline"
|
|
651
|
+
rounded="normal"
|
|
652
|
+
fullwidth
|
|
653
|
+
isFloatingLabel={false}
|
|
654
|
+
label=""
|
|
655
|
+
required={false}
|
|
656
|
+
hasClearIcon={false}
|
|
657
|
+
keepFooterSpace={false}
|
|
658
|
+
defaultValue={numericValue}
|
|
659
|
+
placeholder={np?.placeholder ?? columnId}
|
|
660
|
+
aria-label={np?.placeholder || columnId}
|
|
661
|
+
autoFocus={autoFocus}
|
|
662
|
+
min={np?.min}
|
|
663
|
+
max={np?.max}
|
|
664
|
+
step={np?.step}
|
|
665
|
+
precision={np?.precision}
|
|
666
|
+
allowDecimal={np?.allowDecimal}
|
|
667
|
+
allowNegative={np?.allowNegative}
|
|
668
|
+
hideControls
|
|
669
|
+
error={!!errorMessage}
|
|
670
|
+
errorMessage={errorMessage}
|
|
671
|
+
onBlur={(e) => onBlur(e.currentTarget.value.trim())}
|
|
672
|
+
onKeyDown={onKeyDown}
|
|
673
|
+
className="rounded-md px-2 py-2 typography-small2"
|
|
674
|
+
/>
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// EditCellCheckbox — inline checkbox editor
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
function EditCellCheckbox<TData extends RowData>({
|
|
683
|
+
columnId,
|
|
684
|
+
row,
|
|
685
|
+
col,
|
|
686
|
+
errorMessage,
|
|
687
|
+
onCommit,
|
|
688
|
+
onKeyDown,
|
|
689
|
+
}: {
|
|
690
|
+
columnId: string;
|
|
691
|
+
row: Row<TData>;
|
|
692
|
+
col: EditableColumnDef<TData>;
|
|
693
|
+
errorMessage?: string;
|
|
694
|
+
onCommit: (checked: boolean) => void;
|
|
695
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
|
696
|
+
}) {
|
|
697
|
+
const rawValue = (row.original as Record<string, unknown>)[columnId];
|
|
698
|
+
const checked = rawValue === true || rawValue === "true";
|
|
699
|
+
const label = col.editCheckboxProps?.label;
|
|
700
|
+
|
|
701
|
+
return (
|
|
702
|
+
<div className="flex flex-col gap-0.5">
|
|
703
|
+
<div className="flex items-center gap-2 min-h-[32px]">
|
|
704
|
+
<Checkbox
|
|
705
|
+
checked={checked}
|
|
706
|
+
onCheckedChange={(val) => {
|
|
707
|
+
onCommit(val === true);
|
|
708
|
+
}}
|
|
709
|
+
onKeyDown={
|
|
710
|
+
onKeyDown
|
|
711
|
+
? (e) =>
|
|
712
|
+
onKeyDown(
|
|
713
|
+
e as unknown as React.KeyboardEvent<HTMLInputElement>,
|
|
714
|
+
)
|
|
715
|
+
: undefined
|
|
716
|
+
}
|
|
717
|
+
aria-label={label || columnId}
|
|
718
|
+
/>
|
|
719
|
+
{label && (
|
|
720
|
+
<span className="typography-small2 text-text-contrast-high select-none">
|
|
721
|
+
{label}
|
|
722
|
+
</span>
|
|
723
|
+
)}
|
|
724
|
+
</div>
|
|
725
|
+
{errorMessage && (
|
|
726
|
+
<span className="typography-small3 text-error-500">{errorMessage}</span>
|
|
727
|
+
)}
|
|
728
|
+
</div>
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
// Disabled cell editor — shown in alwaysEditing rows when canEdit is false
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
|
|
736
|
+
function DisabledEditCell<TData extends RowData>({
|
|
737
|
+
row,
|
|
738
|
+
col,
|
|
739
|
+
columnId,
|
|
740
|
+
}: {
|
|
741
|
+
row: Row<TData>;
|
|
742
|
+
col: EditableColumnDef<TData>;
|
|
743
|
+
columnId: string;
|
|
744
|
+
}) {
|
|
745
|
+
const variant = col.editVariant ?? "text";
|
|
746
|
+
|
|
747
|
+
if (variant === "select") {
|
|
748
|
+
const rawOptions =
|
|
749
|
+
typeof col.editSelectProps?.options === "function"
|
|
750
|
+
? col.editSelectProps.options(row)
|
|
751
|
+
: col.editSelectProps?.options ?? [];
|
|
752
|
+
|
|
753
|
+
const ph = col.editSelectProps?.placeholder ?? columnId;
|
|
754
|
+
const options = [editPlaceholderOption(ph), ...rawOptions];
|
|
755
|
+
const displayValue = String(
|
|
756
|
+
(row.original as Record<string, unknown>)[columnId] ?? "",
|
|
757
|
+
);
|
|
758
|
+
const selected =
|
|
759
|
+
options.find((o) => o.label === displayValue.trim()) ?? options[0]!;
|
|
760
|
+
|
|
761
|
+
return (
|
|
762
|
+
<Dropdown
|
|
763
|
+
id={`edit-dd-disabled-${columnId}-${row.id}`}
|
|
764
|
+
options={options}
|
|
765
|
+
value={selected}
|
|
766
|
+
onSelect={() => {}}
|
|
767
|
+
size="sm"
|
|
768
|
+
variant="outline"
|
|
769
|
+
rounded="normal"
|
|
770
|
+
fullwidth
|
|
771
|
+
required={false}
|
|
772
|
+
isFloatingLabel={false}
|
|
773
|
+
label=""
|
|
774
|
+
keepFooterSpace={false}
|
|
775
|
+
segmentedInput
|
|
776
|
+
disabled
|
|
777
|
+
className={cn("rounded-md", customInputVariant({ size: "sm" }))}
|
|
778
|
+
/>
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (variant === "number") {
|
|
783
|
+
const rawValue = (row.original as Record<string, unknown>)[columnId];
|
|
784
|
+
const numericValue =
|
|
785
|
+
rawValue === "" || rawValue == null ? undefined : Number(rawValue);
|
|
786
|
+
return (
|
|
787
|
+
<NumberInput
|
|
788
|
+
size="sm"
|
|
789
|
+
variant="outline"
|
|
790
|
+
rounded="normal"
|
|
791
|
+
fullwidth
|
|
792
|
+
isFloatingLabel={false}
|
|
793
|
+
label=""
|
|
794
|
+
required={false}
|
|
795
|
+
hasClearIcon={false}
|
|
796
|
+
keepFooterSpace={false}
|
|
797
|
+
defaultValue={numericValue}
|
|
798
|
+
disabled
|
|
799
|
+
hideControls
|
|
800
|
+
className="rounded-md px-2 py-2 typography-small2"
|
|
801
|
+
/>
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (variant === "checkbox") {
|
|
806
|
+
const rawValue = (row.original as Record<string, unknown>)[columnId];
|
|
807
|
+
const checked = rawValue === true || rawValue === "true";
|
|
808
|
+
return (
|
|
809
|
+
<div className="flex items-center gap-2 min-h-[32px]">
|
|
810
|
+
<Checkbox checked={checked} disabled />
|
|
811
|
+
{col.editCheckboxProps?.label && (
|
|
812
|
+
<span className="typography-small2 text-text-g-contrast-medium select-none">
|
|
813
|
+
{col.editCheckboxProps.label}
|
|
814
|
+
</span>
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const value = String(
|
|
821
|
+
(row.original as Record<string, unknown>)[columnId] ?? "",
|
|
822
|
+
);
|
|
823
|
+
const placeholder = col.editTextProps?.placeholder ?? "";
|
|
824
|
+
|
|
825
|
+
return (
|
|
826
|
+
<TextInput
|
|
827
|
+
size="sm"
|
|
828
|
+
variant="outline"
|
|
829
|
+
rounded="normal"
|
|
830
|
+
fullwidth
|
|
831
|
+
isFloatingLabel={false}
|
|
832
|
+
label=""
|
|
833
|
+
required={false}
|
|
834
|
+
hasClearIcon={false}
|
|
835
|
+
keepFooterSpace={false}
|
|
836
|
+
defaultValue={value}
|
|
837
|
+
placeholder={placeholder}
|
|
838
|
+
aria-label={placeholder || columnId}
|
|
839
|
+
disabled
|
|
840
|
+
className="rounded-md px-2 py-2 typography-small2"
|
|
841
|
+
/>
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
// Utility: detect editable column ids from column defs
|
|
847
|
+
// ---------------------------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
export function detectEditableColumnIds<TData extends RowData>(
|
|
850
|
+
columns: EditableColumnDef<TData>[],
|
|
851
|
+
): string[] {
|
|
852
|
+
return columns
|
|
853
|
+
.filter((col) => col.enableEditing != null || col.editVariant != null)
|
|
854
|
+
.map(
|
|
855
|
+
(col) =>
|
|
856
|
+
(col as { accessorKey?: string }).accessorKey ??
|
|
857
|
+
(col as { id?: string }).id ??
|
|
858
|
+
"",
|
|
859
|
+
)
|
|
860
|
+
.filter(Boolean);
|
|
861
|
+
}
|