@jielga/tmdatagrid 0.5.0 → 1.0.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 (62) hide show
  1. package/README.md +25 -2
  2. package/dist/index.d.ts +856 -14
  3. package/dist/index.js +3370 -1546
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.layer.css +1 -0
  7. package/package.json +12 -2
  8. package/skills/columns/SKILL.md +1 -1
  9. package/skills/features/SKILL.md +1 -1
  10. package/skills/getting-started/SKILL.md +1 -1
  11. package/skills/options/SKILL.md +1 -1
  12. package/skills/server-side/SKILL.md +1 -1
  13. package/skills/testing/SKILL.md +198 -0
  14. package/src/tmdatagrid/components/TMDataGrid.module.css +11 -0
  15. package/src/tmdatagrid/components/TMDataGrid.tsx +34 -0
  16. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +256 -0
  17. package/src/tmdatagrid/components/TMDataGridColumnsPanel.tsx +35 -12
  18. package/src/tmdatagrid/components/TMDataGridDetailsColumn.tsx +3 -0
  19. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +62 -0
  20. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +237 -0
  21. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +164 -0
  22. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +77 -62
  23. package/src/tmdatagrid/components/TMDataGridFilterPills.tsx +6 -0
  24. package/src/tmdatagrid/components/TMDataGridFooter.tsx +10 -3
  25. package/src/tmdatagrid/components/TMDataGridGroupColumn.tsx +2 -0
  26. package/src/tmdatagrid/components/TMDataGridHeaderCell.module.css +15 -2
  27. package/src/tmdatagrid/components/TMDataGridHeaderCell.tsx +21 -4
  28. package/src/tmdatagrid/components/TMDataGridRowNumberColumn.tsx +44 -0
  29. package/src/tmdatagrid/components/TMDataGridSearch.tsx +2 -0
  30. package/src/tmdatagrid/components/TMDataGridSelectColumn.tsx +3 -0
  31. package/src/tmdatagrid/components/TMDataGridTable.module.css +206 -0
  32. package/src/tmdatagrid/components/TMDataGridTable.tsx +841 -57
  33. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +12 -7
  34. package/src/tmdatagrid/components/editors/TMDataGridBooleanEditor.tsx +29 -0
  35. package/src/tmdatagrid/components/editors/TMDataGridDateEditor.tsx +57 -0
  36. package/src/tmdatagrid/components/editors/TMDataGridMultiSelectEditor.tsx +45 -0
  37. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +38 -0
  38. package/src/tmdatagrid/components/editors/TMDataGridSelectEditor.tsx +53 -0
  39. package/src/tmdatagrid/components/editors/TMDataGridStringEditor.tsx +31 -0
  40. package/src/tmdatagrid/components/editors/editorShared.ts +48 -0
  41. package/src/tmdatagrid/components/filters/DgAutocompleteFilter.tsx +46 -0
  42. package/src/tmdatagrid/components/filters/DgDateRangeFilter.tsx +43 -0
  43. package/src/tmdatagrid/components/filters/DgRangeSliderFilter.tsx +52 -0
  44. package/src/tmdatagrid/components/filters/DgTriStateFilter.tsx +38 -0
  45. package/src/tmdatagrid/components/filters/TMDataGridFilterValueInput.tsx +122 -0
  46. package/src/tmdatagrid/components/icons.ts +4 -0
  47. package/src/tmdatagrid/core/autosize.ts +1 -1
  48. package/src/tmdatagrid/core/capabilities.ts +37 -1
  49. package/src/tmdatagrid/core/cellNavigation.ts +41 -0
  50. package/src/tmdatagrid/core/columnUtils.ts +31 -6
  51. package/src/tmdatagrid/core/editEngine.ts +857 -0
  52. package/src/tmdatagrid/core/filterControls.ts +45 -0
  53. package/src/tmdatagrid/core/filterOperators.ts +72 -4
  54. package/src/tmdatagrid/core/labels.ts +68 -1
  55. package/src/tmdatagrid/core/labelsSv.ts +24 -1
  56. package/src/tmdatagrid/core/matchHighlight.ts +96 -0
  57. package/src/tmdatagrid/core/persistence.ts +97 -3
  58. package/src/tmdatagrid/core/quickSearch.ts +144 -0
  59. package/src/tmdatagrid/core/rowPinning.ts +37 -0
  60. package/src/tmdatagrid/core/rowSelection.ts +10 -1
  61. package/src/tmdatagrid/index.ts +51 -0
  62. package/src/tmdatagrid/useTMDataGrid.tsx +456 -15
@@ -0,0 +1,857 @@
1
+ import {
2
+ FormApi,
3
+ getBy,
4
+ type AnyFieldApi,
5
+ type AnyFormApi,
6
+ type StandardSchemaV1,
7
+ } from "@tanstack/react-form";
8
+ import { Store } from "@tanstack/store";
9
+ import type { Cell, Column, Row, RowData } from "@tanstack/react-table";
10
+ import type { ComponentType } from "react";
11
+ import type { TMDataGridRowData } from "../TMDataGridContext";
12
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
13
+ import { isControlColumn } from "./columnUtils";
14
+ import type { TMDataGridColumnType } from "./filterOperators";
15
+ import type { TMDataGridSize } from "./sizes";
16
+
17
+ /**
18
+ * How commits happen — one axis, each mode a thin policy over the same
19
+ * engine. See `editMode` on `UseTMDataGridOptions`.
20
+ */
21
+ export type TMDataGridEditMode = "cell" | "cellConfirm" | "row" | "batch";
22
+
23
+ /**
24
+ * One editing row's live form. TanStack Form's `FormApi`, not a wrapper: the
25
+ * engine is a form library, and everything mid-edit — values, dirty state,
26
+ * errors, async validation, submit lifecycle — is form state, read off
27
+ * `form.store` with the same selector idiom as the rest of the grid.
28
+ *
29
+ * The alias erases Form's validator generics the way `TMDataGridRowData`
30
+ * erases the row type: the concrete types lived at the `useTMDataGrid` call.
31
+ */
32
+ export type TMDataGridRowEditForm = AnyFormApi;
33
+
34
+ /** The live field a cell editor binds to — TanStack Form's own `FieldApi`. */
35
+ export type TMDataGridEditField = AnyFieldApi;
36
+
37
+ /**
38
+ * A validator as TanStack Form takes it: a Standard Schema (Zod, Valibot,
39
+ * ArkType…) or a plain function returning an error or nothing. Forwarded
40
+ * untouched — the vocabulary is Form's, not a grid re-implementation.
41
+ */
42
+ export type TMDataGridValidator =
43
+ | StandardSchemaV1<unknown, unknown>
44
+ | ((args: { value: never; fieldApi: never }) => unknown);
45
+
46
+ /**
47
+ * `meta.validate` — per-column field validators. A bare schema or function is
48
+ * shorthand for `{ onChange: it }`; the object form takes every trigger
49
+ * TanStack Form defines, `Async` variants and debounce included.
50
+ */
51
+ export type TMDataGridFieldValidate =
52
+ | TMDataGridValidator
53
+ | {
54
+ onMount?: TMDataGridValidator;
55
+ onChange?: TMDataGridValidator;
56
+ onChangeAsync?: TMDataGridValidator;
57
+ onChangeAsyncDebounceMs?: number;
58
+ onBlur?: TMDataGridValidator;
59
+ onBlurAsync?: TMDataGridValidator;
60
+ onSubmit?: TMDataGridValidator;
61
+ onSubmitAsync?: TMDataGridValidator;
62
+ };
63
+
64
+ /**
65
+ * `rowValidators` — form-level validators for the whole row, where
66
+ * cross-field rules live. Passed into each row form's `validators` untouched;
67
+ * pathed issues land on the matching fields, pathless ones on the row.
68
+ */
69
+ export type TMDataGridRowValidators = {
70
+ onMount?: TMDataGridValidator;
71
+ onChange?: TMDataGridValidator;
72
+ onChangeAsync?: TMDataGridValidator;
73
+ onChangeAsyncDebounceMs?: number;
74
+ onBlur?: TMDataGridValidator;
75
+ onBlurAsync?: TMDataGridValidator;
76
+ onSubmit?: TMDataGridValidator;
77
+ onSubmitAsync?: TMDataGridValidator;
78
+ onSubmitAsyncDebounceMs?: number;
79
+ };
80
+
81
+ /** One field's before/after across a commit. */
82
+ export type TMDataGridEditChange = {
83
+ /** Column the field maps back to, for consumers thinking in columns. */
84
+ columnId: string;
85
+ /** The data path — `getEditFieldName` of the column. */
86
+ field: string;
87
+ previous: unknown;
88
+ next: unknown;
89
+ };
90
+
91
+ export type TMDataGridEditCommitArgs<TData extends RowData> = {
92
+ rowId: string;
93
+ /** The whole row as edited — for the consumer who saves records. */
94
+ value: TData;
95
+ /** The row as it was when editing began. */
96
+ original: TData;
97
+ /** Per-field diff — for the consumer who PATCHes. One entry in cell mode. */
98
+ changes: Array<TMDataGridEditChange>;
99
+ /** Which policy committed. */
100
+ source: TMDataGridEditMode;
101
+ };
102
+
103
+ /** What one open row's form looks like from outside — the cell markers. */
104
+ export type TMDataGridEditRowProjection = {
105
+ /** Field names whose value differs from the original. */
106
+ dirtyFields: ReadonlyArray<string>;
107
+ /** Field names carrying a validation error. */
108
+ errorFields: ReadonlyArray<string>;
109
+ /** A row-level error — a pathless `.refine()`, or a rejected commit. */
110
+ hasRowError: boolean;
111
+ isSubmitting: boolean;
112
+ };
113
+
114
+ /**
115
+ * The grid-facing index of everything mid-edit. A projection synced from the
116
+ * open forms' stores, so body cells subscribe to this one store for their
117
+ * dirty/error markers instead of one store per form. Only the editor host
118
+ * reads a form's store directly — it is mounted for one cell at a time.
119
+ */
120
+ export type TMDataGridEditState = {
121
+ /** The cell whose editor is open; `columnId: null` is a whole row (row mode). */
122
+ active: { rowId: string; columnId: string | null } | null;
123
+ /** Rows with a live form. In cell mode at most one; in batch, many. */
124
+ openRowIds: ReadonlyArray<string>;
125
+ rows: Record<string, TMDataGridEditRowProjection>;
126
+ /** Phase 4 — rows being created, not yet in `data`. */
127
+ newRows: ReadonlyArray<{ tempId: string }>;
128
+ /** Phase 4 — rows marked deleted under batch mode. */
129
+ deletedRowIds: ReadonlyArray<string>;
130
+ };
131
+
132
+ const EMPTY_EDIT_STATE: TMDataGridEditState = {
133
+ active: null,
134
+ openRowIds: [],
135
+ rows: {},
136
+ newRows: [],
137
+ deletedRowIds: [],
138
+ };
139
+
140
+ type ErasedRow = Row<TMDataGridFeatures, TMDataGridRowData>;
141
+ type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
142
+
143
+ /**
144
+ * What a cell editor is handed — deliberately both vocabularies at once. The
145
+ * form side is TanStack Form's real `field` API (`field.state.value`,
146
+ * `field.state.meta.errors`, `field.handleChange`, `field.handleBlur`), so a
147
+ * custom calendar, slider or async combobox binds to it exactly as it would
148
+ * inside any TanStack Form. The table side is where the editor is standing.
149
+ *
150
+ * The built-ins are implemented against this same contract, so
151
+ * `meta.editor` is not a special case — it is the slot the defaults
152
+ * fill, and the exported built-ins can be wrapped instead of replaced.
153
+ */
154
+ export type TMDataGridEditorArgs = {
155
+ /** The live TanStack Form field for this cell. */
156
+ field: TMDataGridEditField;
157
+ /** The whole row form, for the rare editor that reads sibling fields. */
158
+ form: TMDataGridRowEditForm;
159
+ cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
160
+ row: ErasedRow;
161
+ column: ErasedColumn;
162
+ table: TMDataGridTable<TMDataGridRowData>;
163
+ /** What Enter would do — commit the edit. For the editor's own UI. */
164
+ commit: () => Promise<boolean>;
165
+ /** What Escape would do — drop the draft. */
166
+ cancel: () => void;
167
+ size: TMDataGridSize;
168
+ /**
169
+ * Whether this editor should take the focus on mount. True for a single
170
+ * opened cell; in row mode only the row's first editable cell gets it.
171
+ */
172
+ autoFocus: boolean;
173
+ /**
174
+ * Set when typing opened the editor (the Sheets gesture) — the built-ins
175
+ * replace the value with it and keep typing. A custom editor may ignore it.
176
+ */
177
+ seedText?: string;
178
+ };
179
+
180
+ /**
181
+ * `meta.editor` — replaces the built-in editor for this column. Rendered as
182
+ * JSX, never invoked as a bare function, so hooks are legal inside. Define it
183
+ * at module scope: a new identity per render remounts the editor mid-edit.
184
+ */
185
+ export type TMDataGridEditorComponent = ComponentType<TMDataGridEditorArgs>;
186
+
187
+ /** A new row being committed — `onRowAdd`, and `submitAll`'s `added`. */
188
+ export type TMDataGridRowAddArgs<TData extends RowData> = {
189
+ /** The engine's placeholder id; the real id is the consumer's to mint. */
190
+ tempId: string;
191
+ value: TData;
192
+ };
193
+
194
+ /** A deletion — `onRowDelete`. */
195
+ export type TMDataGridRowDeleteArgs<TData extends RowData> = {
196
+ rowId: string;
197
+ row: Row<TMDataGridFeatures, TData>;
198
+ };
199
+
200
+ /** What `submitAll` hands `onEditCommitBatch` — everything pending at once. */
201
+ export type TMDataGridEditCommitBatchArgs<TData extends RowData> = {
202
+ /** Every valid dirty existing row. */
203
+ rows: Array<TMDataGridEditCommitArgs<TData>>;
204
+ /** Every valid new row from the entry block. */
205
+ added: Array<TMDataGridRowAddArgs<TData>>;
206
+ /** Ids marked deleted while the batch accumulated. */
207
+ deleted: Array<string>;
208
+ };
209
+
210
+ /** What the engine reads fresh on every call — see `createEditEngine`. */
211
+ export type TMDataGridEditEngineContext = {
212
+ table: TMDataGridTable<TMDataGridRowData>;
213
+ editMode: TMDataGridEditMode;
214
+ rowValidators?: TMDataGridRowValidators;
215
+ isRowEditable?: (row: ErasedRow) => boolean;
216
+ onEditCommit?: (
217
+ args: TMDataGridEditCommitArgs<TMDataGridRowData>,
218
+ ) => void | Promise<void>;
219
+ onEditCommitBatch?: (
220
+ args: TMDataGridEditCommitBatchArgs<TMDataGridRowData>,
221
+ ) => void | Promise<void>;
222
+ /** Seed values for `addRow`. A function is called per added row. */
223
+ newRowDefaults?: TMDataGridRowData | (() => TMDataGridRowData);
224
+ onRowAdd?: (
225
+ args: TMDataGridRowAddArgs<TMDataGridRowData>,
226
+ ) => void | Promise<void>;
227
+ onRowDelete?: (
228
+ args: TMDataGridRowDeleteArgs<TMDataGridRowData>,
229
+ ) => void | Promise<void>;
230
+ };
231
+
232
+ /**
233
+ * The data path a column edits, or `null` for a column that has none.
234
+ *
235
+ * `accessorKey` is the true path and Form addresses fields by dot-path, so
236
+ * nested rows work for free: `accessorKey: "address.city"` edits
237
+ * `values.address.city`. A column built on `accessorFn` has no path and is
238
+ * not editable unless `meta.editField` names one. TanStack's default column
239
+ * id turns dots into underscores — which is why this starts from
240
+ * `accessorKey`, never from `id`.
241
+ */
242
+ export function getEditFieldName(column: {
243
+ columnDef: { meta?: { editField?: string } };
244
+ }): string | null {
245
+ const fromMeta = column.columnDef.meta?.editField;
246
+ if (fromMeta) return fromMeta;
247
+ const accessorKey = (column.columnDef as Record<string, unknown>)[
248
+ "accessorKey"
249
+ ];
250
+ return typeof accessorKey === "string" ? accessorKey : null;
251
+ }
252
+
253
+ /** What Delete writes into a cell — the type's honest empty value. */
254
+ export function clearedValueForType(type: TMDataGridColumnType): unknown {
255
+ switch (type) {
256
+ case "string":
257
+ return "";
258
+ case "multiSelect":
259
+ return [];
260
+ case "boolean":
261
+ return false;
262
+ default:
263
+ return null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * The engine plus its store — `api.edit`.
269
+ *
270
+ * "One row, one form": `getForm` hands out the same `FormApi` the inline
271
+ * editors write through, so a consumer can render it in a drawer or a detail
272
+ * panel and share values, dirty state and errors with the cells.
273
+ */
274
+ export type TMDataGridEditApi = {
275
+ /** The projection store — subscribe with `useSelector(edit.store, …)`. */
276
+ store: Store<TMDataGridEditState>;
277
+ /** Current snapshot, for reads outside React. */
278
+ readonly state: TMDataGridEditState;
279
+ /** rowId → live form. The source of truth for everything mid-edit. */
280
+ getForm: (rowId: string) => TMDataGridRowEditForm | undefined;
281
+ /**
282
+ * Whether this cell may open an editor: the column maps to a field, nothing
283
+ * switched it off, and the row takes edits at all.
284
+ */
285
+ canEditCell: (row: ErasedRow, column: ErasedColumn) => boolean;
286
+ /** Whether the row takes edits at all — the edit lane's pencil gate. */
287
+ canEditRow: (row: ErasedRow) => boolean;
288
+ /** Opens an editor. `columnId: null` opens the whole row (row mode). */
289
+ begin: (target: { rowId: string; columnId: string | null }) => void;
290
+ /**
291
+ * Commits one row — `form.handleSubmit` under the hood. Resolves `true`
292
+ * when the form is gone: validation passed and `onEditCommit` resolved, or
293
+ * there was nothing to save. `false` keeps the form open with its errors.
294
+ */
295
+ commit: (rowId: string) => Promise<boolean>;
296
+ /** Drops one row's draft. */
297
+ cancel: (rowId: string) => void;
298
+ /**
299
+ * Closes the editor without touching the draft — what blur does under
300
+ * `"cellConfirm"`, where the dirty cell keeps waiting for its ✓.
301
+ */
302
+ deactivate: () => void;
303
+ /** Drops every draft. */
304
+ cancelAll: () => void;
305
+ /** Commits every open row — batch mode's save. `true` when all landed. */
306
+ submitAll: () => Promise<boolean>;
307
+ /** Writes the type's empty value into a cell and commits it — Delete. */
308
+ clearCell: (rowId: string, columnId: string) => Promise<boolean>;
309
+ /**
310
+ * Opens a new entry row (the sticky block under the header) seeded from
311
+ * `newRowDefaults`. Returns its temporary id — a form with no backing row
312
+ * yet. Committing it calls `onRowAdd` (immediate modes) or joins
313
+ * `submitAll`'s `added` (batch).
314
+ */
315
+ addRow: () => string;
316
+ /**
317
+ * Deletes a row: `onRowDelete` straight away under the immediate modes;
318
+ * under batch it toggles the id in `deletedRowIds` — the row renders
319
+ * struck through until `submitAll` reports it. On an uncommitted entry
320
+ * row it just discards the entry.
321
+ */
322
+ deleteRow: (rowId: string) => void;
323
+ /** Whether delete chrome makes sense — the lane's trash gate. */
324
+ canDeleteRows: () => boolean;
325
+ };
326
+
327
+ function clearFormSourcedFieldErrors(form: TMDataGridRowEditForm): void {
328
+ const fieldMeta = form.state.fieldMeta as Record<
329
+ string,
330
+ { errorSourceMap?: Record<string, unknown> }
331
+ >;
332
+ for (const [name, meta] of Object.entries(fieldMeta)) {
333
+ const staleKeys = Object.entries(meta.errorSourceMap ?? {})
334
+ .filter(([, source]) => source === "form")
335
+ .map(([key]) => key);
336
+ if (staleKeys.length === 0) continue;
337
+ form.setFieldMeta(name as never, (previous) => ({
338
+ ...previous,
339
+ errorMap: {
340
+ ...previous.errorMap,
341
+ ...Object.fromEntries(staleKeys.map((key) => [key, undefined])),
342
+ },
343
+ }));
344
+ }
345
+ }
346
+
347
+ /** Arrays compare by element so a multiSelect edit-back-to-same is clean. */
348
+ function sameValue(a: unknown, b: unknown): boolean {
349
+ if (Object.is(a, b)) return true;
350
+ if (Array.isArray(a) && Array.isArray(b)) {
351
+ return a.length === b.length && a.every((entry, i) => Object.is(entry, b[i]));
352
+ }
353
+ if (a instanceof Date && b instanceof Date) {
354
+ return a.getTime() === b.getTime();
355
+ }
356
+ return false;
357
+ }
358
+
359
+ function hasAnyError(errors: ReadonlyArray<unknown>): boolean {
360
+ return errors.some((error) => error !== undefined && error !== null);
361
+ }
362
+
363
+ /**
364
+ * Builds the edit engine. A factory over React so it is headless-testable;
365
+ * `getContext` is read fresh on every call, which is how the engine always
366
+ * sees the latest table, mode and consumer callbacks without being rebuilt.
367
+ *
368
+ * Virtualization, per the plan: nothing here is DOM. A form is a plain object
369
+ * in a map keyed by rowId — scroll its row away and the editor unmounts, the
370
+ * form keeps its values, meta and errors; scroll back and the editor
371
+ * re-mounts over the same form.
372
+ */
373
+ export function createEditEngine(
374
+ getContext: () => TMDataGridEditEngineContext,
375
+ ): TMDataGridEditApi {
376
+ const store = new Store<TMDataGridEditState>(EMPTY_EDIT_STATE);
377
+
378
+ type FormEntry = {
379
+ form: TMDataGridRowEditForm;
380
+ original: TMDataGridRowData;
381
+ /** An entry-block row — a form with no backing row yet. */
382
+ isNew: boolean;
383
+ /** Set by the wrapped onSubmit when the consumer's commit resolved. */
384
+ lastSubmitOk: boolean;
385
+ /** A commit already running — Enter and blur race on the same edit. */
386
+ pendingCommit: Promise<boolean> | null;
387
+ unsubscribe: () => void;
388
+ unmount: () => void;
389
+ };
390
+ const forms = new Map<string, FormEntry>();
391
+ let newRowCounter = 0;
392
+
393
+ /**
394
+ * While `submitAll` runs with an `onEditCommitBatch`, each row's wrapped
395
+ * onSubmit contributes its args here instead of calling `onEditCommit` —
396
+ * validation stays per row (Form's), the consumer call becomes one.
397
+ */
398
+ let batchCollector: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> | null =
399
+ null;
400
+ let batchAddCollector: Array<TMDataGridRowAddArgs<TMDataGridRowData>> | null =
401
+ null;
402
+
403
+ const editableColumns = (): Array<ErasedColumn> =>
404
+ getContext()
405
+ .table.getAllLeafColumns()
406
+ .filter(
407
+ (column) =>
408
+ !isControlColumn(column.id) &&
409
+ column.columnDef.meta?.editable !== false &&
410
+ getEditFieldName(column) !== null,
411
+ );
412
+
413
+ const diff = (
414
+ entry: FormEntry,
415
+ values: TMDataGridRowData,
416
+ ): Array<TMDataGridEditChange> => {
417
+ const changes: Array<TMDataGridEditChange> = [];
418
+ for (const column of editableColumns()) {
419
+ const field = getEditFieldName(column);
420
+ if (field === null) continue;
421
+ const previous: unknown = getBy(entry.original, field);
422
+ const next: unknown = getBy(values, field);
423
+ if (!sameValue(previous, next)) {
424
+ changes.push({ columnId: column.id, field, previous, next });
425
+ }
426
+ }
427
+ return changes;
428
+ };
429
+
430
+ const project = (entry: FormEntry): TMDataGridEditRowProjection => {
431
+ const state = entry.form.state;
432
+ const errorFields = Object.entries(state.fieldMeta)
433
+ .filter(
434
+ ([name, meta]) =>
435
+ // The empty path is a pathless (row-level) issue's landing spot —
436
+ // it belongs to `hasRowError`, not to any cell's marker.
437
+ name !== "" &&
438
+ hasAnyError((meta as { errors: ReadonlyArray<unknown> }).errors),
439
+ )
440
+ .map(([name]) => name);
441
+ return {
442
+ dirtyFields: diff(entry, state.values as TMDataGridRowData).map(
443
+ (change) => change.field,
444
+ ),
445
+ errorFields,
446
+ hasRowError: hasAnyError(state.errors),
447
+ isSubmitting: state.isSubmitting,
448
+ };
449
+ };
450
+
451
+ const publishRow = (rowId: string) => {
452
+ const entry = forms.get(rowId);
453
+ store.setState((prev) => {
454
+ const rows = { ...prev.rows };
455
+ if (entry === undefined) delete rows[rowId];
456
+ else rows[rowId] = project(entry);
457
+ return { ...prev, rows, openRowIds: [...forms.keys()] };
458
+ });
459
+ };
460
+
461
+ const setActive = (active: TMDataGridEditState["active"]) => {
462
+ store.setState((prev) => ({ ...prev, active }));
463
+ };
464
+
465
+ const drop = (rowId: string) => {
466
+ const entry = forms.get(rowId);
467
+ if (entry === undefined) return;
468
+ entry.unsubscribe();
469
+ entry.unmount();
470
+ forms.delete(rowId);
471
+ store.setState((prev) => {
472
+ const rows = { ...prev.rows };
473
+ delete rows[rowId];
474
+ return {
475
+ ...prev,
476
+ rows,
477
+ openRowIds: [...forms.keys()],
478
+ newRows: entry.isNew
479
+ ? prev.newRows.filter((newRow) => newRow.tempId !== rowId)
480
+ : prev.newRows,
481
+ active: prev.active?.rowId === rowId ? null : prev.active,
482
+ };
483
+ });
484
+ };
485
+
486
+ const createForm = (
487
+ rowId: string,
488
+ original: TMDataGridRowData,
489
+ isNew: boolean,
490
+ ): FormEntry => {
491
+ const context = getContext();
492
+ const entry: FormEntry = {
493
+ original,
494
+ isNew,
495
+ lastSubmitOk: false,
496
+ pendingCommit: null,
497
+ unsubscribe: () => {},
498
+ unmount: () => {},
499
+ form: new FormApi({
500
+ defaultValues: original,
501
+ // The consumer's vocabulary is Form's own; the cast erases the
502
+ // validator generics the same way the row type is erased.
503
+ validators: context.rowValidators as never,
504
+ onSubmit: async ({ value }) => {
505
+ // A new row commits whole: it is an add, not a diff against
506
+ // anything that exists.
507
+ if (entry.isNew) {
508
+ const addArgs: TMDataGridRowAddArgs<TMDataGridRowData> = {
509
+ tempId: rowId,
510
+ value: value as TMDataGridRowData,
511
+ };
512
+ if (batchAddCollector !== null) {
513
+ batchAddCollector.push(addArgs);
514
+ entry.lastSubmitOk = true;
515
+ return;
516
+ }
517
+ try {
518
+ await getContext().onRowAdd?.(addArgs);
519
+ entry.lastSubmitOk = true;
520
+ } catch (error) {
521
+ entry.form.setErrorMap({
522
+ onSubmit:
523
+ error instanceof Error ? error.message : String(error),
524
+ } as never);
525
+ }
526
+ return;
527
+ }
528
+ const changes = diff(entry, value as TMDataGridRowData);
529
+ const args: TMDataGridEditCommitArgs<TMDataGridRowData> = {
530
+ rowId,
531
+ value: value as TMDataGridRowData,
532
+ original,
533
+ changes,
534
+ source: getContext().editMode,
535
+ };
536
+ if (batchCollector !== null) {
537
+ batchCollector.push(args);
538
+ entry.lastSubmitOk = true;
539
+ return;
540
+ }
541
+ try {
542
+ await getContext().onEditCommit?.(args);
543
+ entry.lastSubmitOk = true;
544
+ } catch (error) {
545
+ // A rejected save keeps the form open, the message on the row.
546
+ entry.form.setErrorMap({
547
+ onSubmit:
548
+ error instanceof Error ? error.message : String(error),
549
+ } as never);
550
+ }
551
+ },
552
+ }) as TMDataGridRowEditForm,
553
+ };
554
+ entry.unmount = entry.form.mount();
555
+ const subscription = entry.form.store.subscribe(() => publishRow(rowId));
556
+ entry.unsubscribe = () => subscription.unsubscribe();
557
+ forms.set(rowId, entry);
558
+ publishRow(rowId);
559
+ return entry;
560
+ };
561
+
562
+ const canEditRow = (row: ErasedRow): boolean => {
563
+ if (row.getIsGrouped()) return false;
564
+ return getContext().isRowEditable?.(row) !== false;
565
+ };
566
+
567
+ const canEditCell = (row: ErasedRow, column: ErasedColumn): boolean => {
568
+ const context = getContext();
569
+ if (row.getIsGrouped()) return false;
570
+ if (isControlColumn(column.id)) return false;
571
+ if (getEditFieldName(column) === null) return false;
572
+ const editable = column.columnDef.meta?.editable;
573
+ if (editable === false) return false;
574
+ if (typeof editable === "function" && !editable(row)) return false;
575
+ if (context.isRowEditable !== undefined && !context.isRowEditable(row)) {
576
+ return false;
577
+ }
578
+ return true;
579
+ };
580
+
581
+ const getRow = (rowId: string): ErasedRow | undefined => {
582
+ try {
583
+ return getContext().table.getRow(rowId);
584
+ } catch {
585
+ // TanStack throws for an id that is not in the current row model.
586
+ return undefined;
587
+ }
588
+ };
589
+
590
+ const commit = async (rowId: string): Promise<boolean> => {
591
+ const entry = forms.get(rowId);
592
+ if (entry === undefined) return true;
593
+ // Enter and blur race on the same edit; the second caller joins the
594
+ // first's commit instead of submitting the row twice.
595
+ if (entry.pendingCommit !== null) return entry.pendingCommit;
596
+ // A pristine form has nothing to say — drop it without a consumer call.
597
+ // Not for a new row: adding an untouched entry is still an add.
598
+ if (
599
+ !entry.isNew &&
600
+ diff(entry, entry.form.state.values as TMDataGridRowData).length === 0
601
+ ) {
602
+ drop(rowId);
603
+ return true;
604
+ }
605
+ // Field errors the *form's* validator planted are cleared before the
606
+ // submit re-runs it: a field with no mounted editor is never revalidated
607
+ // by Form itself, so a pathed issue would otherwise outlive its fix and
608
+ // block `canSubmit` forever. The validator puts back whatever still holds.
609
+ clearFormSourcedFieldErrors(entry.form);
610
+ entry.lastSubmitOk = false;
611
+ entry.pendingCommit = (async () => {
612
+ try {
613
+ await entry.form.handleSubmit();
614
+ } finally {
615
+ entry.pendingCommit = null;
616
+ }
617
+ if (!entry.lastSubmitOk) return false;
618
+ drop(rowId);
619
+ return true;
620
+ })();
621
+ return entry.pendingCommit;
622
+ };
623
+
624
+ const beginOn = (rowId: string, columnId: string | null) => {
625
+ const row = getRow(rowId);
626
+ if (row === undefined) return;
627
+ if (columnId !== null) {
628
+ const column = getContext().table.getColumn(columnId);
629
+ if (column === undefined || !canEditCell(row, column)) return;
630
+ } else if (getContext().isRowEditable?.(row) === false) {
631
+ return;
632
+ }
633
+ if (!forms.has(rowId)) createForm(rowId, row.original, false);
634
+ setActive({ rowId, columnId });
635
+ };
636
+
637
+ const begin: TMDataGridEditApi["begin"] = ({ rowId, columnId }) => {
638
+ const { editMode } = getContext();
639
+ const openElsewhere = [...forms.keys()].find((id) => id !== rowId);
640
+ // Each mode's policy about the row being left:
641
+ //
642
+ // | Mode | Another row open |
643
+ // | ---- | ---------------- |
644
+ // | cell | committed — Sheets; a failed commit keeps holding the edit |
645
+ // | row | dropped if pristine, otherwise the pencil is refused — a
646
+ // row edit saves explicitly, so leaving must not save quietly |
647
+ // | cellConfirm, batch | accumulates; every draft waits for its ✓ |
648
+ if (openElsewhere !== undefined) {
649
+ if (editMode === "cell") {
650
+ void commit(openElsewhere).then((ok) => {
651
+ if (ok) beginOn(rowId, columnId);
652
+ });
653
+ return;
654
+ }
655
+ if (editMode === "row") {
656
+ const entry = forms.get(openElsewhere);
657
+ const pristine =
658
+ entry !== undefined &&
659
+ diff(entry, entry.form.state.values as TMDataGridRowData).length === 0;
660
+ if (!pristine) return;
661
+ drop(openElsewhere);
662
+ }
663
+ }
664
+ beginOn(rowId, columnId);
665
+ };
666
+
667
+ const cancel = (rowId: string) => {
668
+ drop(rowId);
669
+ };
670
+
671
+ const deactivate = () => {
672
+ setActive(null);
673
+ };
674
+
675
+ const cancelAll = () => {
676
+ for (const rowId of [...forms.keys()]) drop(rowId);
677
+ store.setState((prev) => ({ ...prev, active: null, deletedRowIds: [] }));
678
+ };
679
+
680
+ const addRow = (): string => {
681
+ const context = getContext();
682
+ newRowCounter += 1;
683
+ const tempId = `__new__${newRowCounter}`;
684
+ const defaults =
685
+ typeof context.newRowDefaults === "function"
686
+ ? context.newRowDefaults()
687
+ : (context.newRowDefaults ?? {});
688
+ createForm(tempId, defaults, true);
689
+ store.setState((prev) => ({
690
+ ...prev,
691
+ newRows: [...prev.newRows, { tempId }],
692
+ }));
693
+ return tempId;
694
+ };
695
+
696
+ const deleteRow = (rowId: string) => {
697
+ const entry = forms.get(rowId);
698
+ // Deleting an unsaved entry row is just discarding the entry.
699
+ if (entry?.isNew === true) {
700
+ drop(rowId);
701
+ return;
702
+ }
703
+ const context = getContext();
704
+ if (context.editMode === "batch") {
705
+ // A toggle: the second press unmarks — the mark is a draft too.
706
+ store.setState((prev) => ({
707
+ ...prev,
708
+ deletedRowIds: prev.deletedRowIds.includes(rowId)
709
+ ? prev.deletedRowIds.filter((id) => id !== rowId)
710
+ : [...prev.deletedRowIds, rowId],
711
+ }));
712
+ return;
713
+ }
714
+ const row = getRow(rowId);
715
+ if (row === undefined) return;
716
+ // Confirmation is the consumer's business, in their onRowDelete.
717
+ void context.onRowDelete?.({ rowId, row });
718
+ };
719
+
720
+ const canDeleteRows = (): boolean => {
721
+ const context = getContext();
722
+ if (context.editMode === "batch") {
723
+ return (
724
+ context.onRowDelete !== undefined ||
725
+ context.onEditCommitBatch !== undefined
726
+ );
727
+ }
728
+ return context.onRowDelete !== undefined;
729
+ };
730
+
731
+ /** The pending deletions, reported and cleared by submitAll. */
732
+ const takeDeletedRowIds = (): Array<string> => {
733
+ const deleted = [...store.state.deletedRowIds];
734
+ if (deleted.length > 0) {
735
+ store.setState((prev) => ({ ...prev, deletedRowIds: [] }));
736
+ }
737
+ return deleted;
738
+ };
739
+
740
+ const submitAll = async (): Promise<boolean> => {
741
+ if (getContext().onEditCommitBatch === undefined) {
742
+ // The default: the per-row loop — edits through `onEditCommit`, entry
743
+ // rows through `onRowAdd`, marked deletions through `onRowDelete`.
744
+ const results = await Promise.all(
745
+ [...forms.keys()].map((rowId) => commit(rowId)),
746
+ );
747
+ for (const rowId of takeDeletedRowIds()) {
748
+ const row = getRow(rowId);
749
+ if (row !== undefined) {
750
+ await getContext().onRowDelete?.({ rowId, row });
751
+ }
752
+ }
753
+ return results.every(Boolean);
754
+ }
755
+
756
+ // One consumer call for the lot. Validation stays Form's, per row: rows
757
+ // that fail keep their forms and markers; the valid ones travel
758
+ // together, and only a resolved batch drops them — a rejected save keeps
759
+ // every draft, deletions included.
760
+ const collected: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> = [];
761
+ const added: Array<TMDataGridRowAddArgs<TMDataGridRowData>> = [];
762
+ batchCollector = collected;
763
+ batchAddCollector = added;
764
+ let allValid = true;
765
+ try {
766
+ for (const rowId of [...forms.keys()]) {
767
+ const entry = forms.get(rowId);
768
+ if (entry === undefined) continue;
769
+ if (
770
+ !entry.isNew &&
771
+ diff(entry, entry.form.state.values as TMDataGridRowData).length === 0
772
+ ) {
773
+ drop(rowId);
774
+ continue;
775
+ }
776
+ entry.lastSubmitOk = false;
777
+ await entry.form.handleSubmit();
778
+ if (!entry.lastSubmitOk) allValid = false;
779
+ }
780
+ } finally {
781
+ batchCollector = null;
782
+ batchAddCollector = null;
783
+ }
784
+ const deleted = [...store.state.deletedRowIds];
785
+ if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
786
+ try {
787
+ await getContext().onEditCommitBatch?.({ rows: collected, added, deleted });
788
+ } catch {
789
+ return false;
790
+ }
791
+ for (const args of collected) drop(args.rowId);
792
+ for (const args of added) drop(args.tempId);
793
+ if (deleted.length > 0) {
794
+ store.setState((prev) => ({
795
+ ...prev,
796
+ deletedRowIds: prev.deletedRowIds.filter(
797
+ (id) => !deleted.includes(id),
798
+ ),
799
+ }));
800
+ }
801
+ }
802
+ return allValid;
803
+ };
804
+
805
+ const clearCell = async (
806
+ rowId: string,
807
+ columnId: string,
808
+ ): Promise<boolean> => {
809
+ const context = getContext();
810
+ const row = getRow(rowId);
811
+ const column = context.table.getColumn(columnId);
812
+ if (row === undefined || column === undefined) return false;
813
+ if (!canEditCell(row, column)) return false;
814
+ const field = getEditFieldName(column);
815
+ if (field === null) return false;
816
+ const entry = forms.get(rowId) ?? createForm(rowId, row.original, false);
817
+ entry.form.setFieldValue(
818
+ field as never,
819
+ clearedValueForType(column.columnDef.meta?.type ?? "string") as never,
820
+ );
821
+ return commit(rowId);
822
+ };
823
+
824
+ return {
825
+ store,
826
+ get state() {
827
+ return store.state;
828
+ },
829
+ getForm: (rowId) => forms.get(rowId)?.form,
830
+ canEditCell,
831
+ canEditRow,
832
+ begin,
833
+ commit,
834
+ cancel,
835
+ deactivate,
836
+ cancelAll,
837
+ submitAll,
838
+ clearCell,
839
+ addRow,
840
+ deleteRow,
841
+ canDeleteRows,
842
+ };
843
+ }
844
+
845
+ /**
846
+ * `meta.validate` normalised to the object TanStack Form's `validators`
847
+ * option takes — a bare schema or function is shorthand for `{ onChange }`.
848
+ */
849
+ export function normalizeFieldValidate(
850
+ validate: TMDataGridFieldValidate | undefined,
851
+ ): Record<string, unknown> | undefined {
852
+ if (validate === undefined) return undefined;
853
+ if (typeof validate === "function" || "~standard" in validate) {
854
+ return { onChange: validate };
855
+ }
856
+ return validate as Record<string, unknown>;
857
+ }