@opencorestack/opengridx 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +4 -1
  3. package/dist/ai.cjs +1 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +166 -0
  6. package/dist/ai.es.js +1 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +148 -2
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +6084 -5523
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +17 -15
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +98 -1
  15. package/docs/README.md +3 -1
  16. package/docs/architecture/datagrid-orchestration.md +10 -1
  17. package/docs/customization/theming.md +9 -0
  18. package/docs/features/ai-toolkit.md +119 -0
  19. package/docs/features/clipboard.md +88 -0
  20. package/docs/features/editing-reordering.md +50 -0
  21. package/docs/features/keyboard-navigation.md +11 -0
  22. package/docs/features/undo-redo.md +79 -0
  23. package/docs/roadmap.md +1 -1
  24. package/lib/ai/coerce.ts +90 -0
  25. package/lib/ai/columns.ts +90 -0
  26. package/lib/ai/index.ts +16 -0
  27. package/lib/ai/schema.ts +214 -0
  28. package/lib/ai/types.ts +88 -0
  29. package/lib/ai/validate.ts +286 -0
  30. package/lib/components/Cell/Cell.css +5 -0
  31. package/lib/components/Cell/GridEditInputCell.css +12 -4
  32. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  33. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  34. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  35. package/lib/components/DataGrid/DataGrid.tsx +62 -2
  36. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  37. package/lib/components/FilterPanel/FilterPanel.tsx +3 -2
  38. package/lib/components/ListView/ListView.css +2 -4
  39. package/lib/components/ListView/ListViewRow.tsx +2 -2
  40. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  41. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  42. package/lib/components/ui/Checkbox.tsx +16 -5
  43. package/lib/components/ui/Input.css +43 -0
  44. package/lib/components/ui/Input.tsx +24 -5
  45. package/lib/hooks/core/gridApiPlaceholder.ts +8 -1
  46. package/lib/hooks/core/useDataGrid.ts +27 -0
  47. package/lib/hooks/core/useGridCellSelection.ts +30 -2
  48. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  49. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  50. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  51. package/lib/hooks/features/useGridEditing.ts +24 -45
  52. package/lib/index.ts +7 -0
  53. package/lib/styles/opengridx.css +2 -1
  54. package/lib/types/index.ts +124 -0
  55. package/lib/utils/editing/commit.ts +69 -0
  56. package/lib/utils/editing/targets.ts +109 -0
  57. package/lib/utils/parsing.ts +252 -0
  58. package/llms.txt +55 -1
  59. package/package.json +8 -2
@@ -178,6 +178,14 @@ export interface GridColDef<R extends GridValidRowModel = GridRowModel> {
178
178
  * @since v3.0
179
179
  */
180
180
  valueSetter?(params: GridValueSetterParams<R>): R;
181
+ /**
182
+ * Turns pasted text into this column's value (clipboard paste), instead of the default parser for
183
+ * the column `type` (see `docs/features/clipboard.md`). Use it for locales such as `1.234,5` or for
184
+ * custom codes. Throw (or return `undefined`) to reject the text: the cell is skipped with reason
185
+ * `'invalidValue'`. The value then goes through `valueSetter` and `processRowUpdate` like an edit.
186
+ * @since v3.4
187
+ */
188
+ valueParser?(text: string, params: GridValueParserParams<R>): unknown;
181
189
  /**
182
190
  * Custom ascending comparator for client-side sorting by this column, used instead of the built-in
183
191
  * type-aware comparison. `v1` / `v2` are the cell values (`valueGetter` applied) and **include
@@ -240,6 +248,16 @@ export interface GridColDef<R extends GridValidRowModel = GridRowModel> {
240
248
  * @default true
241
249
  */
242
250
  exportable?: boolean;
251
+
252
+ /**
253
+ * Example values for this column, put into the schema from `getGridAiSchema`
254
+ * (`@opencorestack/opengridx/ai`) to help a language model write filters ("Acme" → `customer`).
255
+ * **These values are sent to the model.** The grid never reads row data for the schema; only what
256
+ * you list here leaves the app, so list nothing private. Strings, numbers and booleans are used
257
+ * (at most 10); other values are ignored. Not used by the grid itself.
258
+ * @since v3.4
259
+ */
260
+ aiExamples?: unknown[];
243
261
  }
244
262
 
245
263
  /** Column definition used exclusively in List View mode. */
@@ -300,6 +318,14 @@ export interface GridValueGetterParams<R extends GridValidRowModel = GridRowMode
300
318
  value: unknown;
301
319
  }
302
320
 
321
+ /** Parameters passed to `GridColDef.valueParser`. @since v3.4 */
322
+ export interface GridValueParserParams<R extends GridValidRowModel = GridRowModel> {
323
+ /** The row the text is pasted into, as stored now. */
324
+ row: R;
325
+ field: string;
326
+ colDef: GridColDef<R>;
327
+ }
328
+
303
329
  /**
304
330
  * Parameters passed to the `valueSetter` function.
305
331
  * @since v3.0
@@ -803,6 +829,54 @@ export interface GridRowScrollEndParams {
803
829
  viewportHeight: number;
804
830
  }
805
831
 
832
+ /**
833
+ * Why a cell of a batch edit (paste, range clear, undo, redo) was not written:
834
+ * - `'notEditable'`: the column is not `editable`, `isCellEditable` returned false, or the row is a
835
+ * synthetic row (group header, subtotal, tree auto-parent, pivot Grand Total);
836
+ * - `'invalidValue'`: the pasted text does not parse for the column type (or `valueParser` rejected it);
837
+ * - `'changed'`: undo/redo only, the cell no longer holds the value the history recorded;
838
+ * - `'missing'`: undo/redo only, the row is gone.
839
+ * @since v3.4
840
+ */
841
+ export type GridEditSkipReason = 'notEditable' | 'invalidValue' | 'changed' | 'missing';
842
+
843
+ /** What a batch edit (paste, range clear, undo, redo) did. @since v3.4 */
844
+ export interface GridBatchEditResult {
845
+ /** Rows whose `processRowUpdate` succeeded and that were stored, in the order they were first touched. */
846
+ updated: GridRowId[];
847
+ /** Rows whose `valueSetter` or `processRowUpdate` threw or rejected (also reported to `onProcessRowUpdateError`). */
848
+ failed: { id: GridRowId; error: unknown }[];
849
+ /** Cells that were not written, with the reason. */
850
+ skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[];
851
+ }
852
+
853
+ /** `onClipboardPaste` details: the batch edit result plus the text that was pasted. @since v3.4 */
854
+ export interface GridClipboardPasteResult extends GridBatchEditResult {
855
+ text: string;
856
+ }
857
+
858
+ /** `onBeforeClipboardPaste` params. @since v3.4 */
859
+ export interface GridBeforeClipboardPasteParams {
860
+ /** The clipboard text (`text/plain`). */
861
+ text: string;
862
+ /** The cell the paste starts from (the range's top-left cell, or the focused cell). */
863
+ anchor: GridCellCoordinates | null;
864
+ }
865
+
866
+ /** `onHistoryChange` params. @since v3.4 */
867
+ export interface GridHistoryChangeParams {
868
+ canUndo: boolean;
869
+ canRedo: boolean;
870
+ /** Number of actions that can be undone. */
871
+ size: number;
872
+ }
873
+
874
+ /** `undoRedo` options. @since v3.4 */
875
+ export interface GridUndoRedoOptions {
876
+ /** How many actions are kept; the oldest is dropped beyond it. Default: 100. */
877
+ limit?: number;
878
+ }
879
+
806
880
  /** Overrideable user-visible strings for internationalisation. All fields are optional; defaults match the built-in English strings. */
807
881
  export interface GridLocaleText {
808
882
  /** Label before the rows-per-page select. Default: `"Rows per page:"` */
@@ -939,6 +1013,39 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
939
1013
  */
940
1014
  showCellSelectionStats?: boolean;
941
1015
 
1016
+ /**
1017
+ * With `cellSelection` on, Ctrl/Cmd+V pastes tab-separated text (Excel, Google Sheets) into the
1018
+ * editable cells from the range's top-left cell (or the focused cell), through `valueSetter` and
1019
+ * `processRowUpdate`. `true` turns the shortcut off; `apiRef.pasteText()` still works. Default: false.
1020
+ * @since v3.4
1021
+ */
1022
+ disableClipboardPaste?: boolean;
1023
+ /**
1024
+ * Fired after every paste with the result (`updated` rows, `failed` rows, `skipped` cells and why)
1025
+ * and the pasted text. @since v3.4
1026
+ */
1027
+ onClipboardPaste?: (result: GridClipboardPasteResult) => void;
1028
+ /**
1029
+ * Called before a paste is applied. Return `false` to cancel it, or a string to paste instead of the
1030
+ * clipboard text. A throw cancels the paste. @since v3.4
1031
+ */
1032
+ onBeforeClipboardPaste?: (params: GridBeforeClipboardPasteParams) => boolean | string;
1033
+ /**
1034
+ * With `cellSelection` on, Delete / Backspace on a range of more than one cell empties its editable
1035
+ * cells (`null`, or `''` in string columns) as one edit. `true` turns that off. Default: false.
1036
+ * @since v3.4
1037
+ */
1038
+ disableRangeClear?: boolean;
1039
+ /**
1040
+ * Undo and redo of cell edits, pastes and range clears: Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl+Y while
1041
+ * focus is in the grid and no editor is open, and `apiRef.undo()` / `redo()`. Undo and redo write
1042
+ * through `valueSetter` and `processRowUpdate`, like an edit. `{ limit }` sets how many actions are
1043
+ * kept (default 100). Default: false. @since v3.4
1044
+ */
1045
+ undoRedo?: boolean | GridUndoRedoOptions;
1046
+ /** Fired when what can be undone or redone changes (`undoRedo`). @since v3.4 */
1047
+ onHistoryChange?: (params: GridHistoryChangeParams) => void;
1048
+
942
1049
  /** Loading state. With no rows the body shows skeleton rows (or `slots.loadingOverlay`); with rows, they stay and a progress bar (or `slots.loadingOverlay`) is shown over them. */
943
1050
  loading?: boolean;
944
1051
  /** Visual density of the grid. */
@@ -1328,4 +1435,21 @@ export interface GridApi<R extends GridValidRowModel = GridRowModel> {
1328
1435
  * copy; rejects when the clipboard write fails. @since v3.3
1329
1436
  */
1330
1437
  copySelectedCells: () => Promise<void>;
1438
+
1439
+ /**
1440
+ * Pastes tab-separated text as Ctrl/Cmd+V does: from `anchor` (default: the range's top-left cell,
1441
+ * else the focused cell), with `onBeforeClipboardPaste` and `onClipboardPaste`. Works with
1442
+ * `disableClipboardPaste`. Resolves once every row's `processRowUpdate` has settled. @since v3.4
1443
+ */
1444
+ pasteText: (text: string, anchor?: GridCellCoordinates) => Promise<GridBatchEditResult>;
1445
+ /** Undoes the last action (`undoRedo`). Resolves with what was written. @since v3.4 */
1446
+ undo: () => Promise<GridBatchEditResult>;
1447
+ /** Redoes the last undone action (`undoRedo`). @since v3.4 */
1448
+ redo: () => Promise<GridBatchEditResult>;
1449
+ /** Whether there is an action to undo. @since v3.4 */
1450
+ canUndo: () => boolean;
1451
+ /** Whether there is an undone action to redo. @since v3.4 */
1452
+ canRedo: () => boolean;
1453
+ /** Forgets every action (undo and redo). @since v3.4 */
1454
+ clearHistory: () => void;
1331
1455
  }
@@ -0,0 +1,69 @@
1
+ import type { GridColDef, GridRowId, GridRowModel } from '../../types';
2
+
3
+ /**
4
+ * The steps every committed edit shares: a single-cell edit (`useGridEditing`) and a batch edit
5
+ * (`useGridBatchEdit`: paste, range clear, undo, redo) build the row the same way and report errors
6
+ * the same way.
7
+ */
8
+
9
+ /** A misuse of the editing contract (e.g. processRowUpdate returned nothing), as opposed to a consumer rejection. */
10
+ export class OpenGridXEditError extends Error {}
11
+
12
+ /** One committed cell change: the value before and the value stored after (both read through `getCellValue`). */
13
+ export interface GridCellChange {
14
+ id: GridRowId;
15
+ field: string;
16
+ before: unknown;
17
+ after: unknown;
18
+ }
19
+
20
+ /** Cell values compare equal: dates by time, anything else with `Object.is`. */
21
+ export function isSameValue(a: unknown, b: unknown): boolean {
22
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
23
+ return Object.is(a, b);
24
+ }
25
+
26
+ export function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
27
+ return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function';
28
+ }
29
+
30
+ /**
31
+ * Sends an edit failure to `onProcessRowUpdateError`. Without one, a misuse of the contract is logged
32
+ * in development; a consumer rejection is the consumer's own and stays silent.
33
+ */
34
+ export function reportEditError(error: unknown, onError: ((error: unknown) => void) | undefined): void {
35
+ if (onError) {
36
+ onError(error);
37
+ } else if (process.env.NODE_ENV !== 'production' && error instanceof OpenGridXEditError) {
38
+ console.error(error.message);
39
+ }
40
+ }
41
+
42
+ /** The error for a `processRowUpdate` that resolved to something other than a row object. */
43
+ export function invalidProcessedRowError(processed: unknown, outcome: string): OpenGridXEditError {
44
+ return new OpenGridXEditError(
45
+ `[OpenGridX] processRowUpdate must return the updated row object (or a Promise of it); ` +
46
+ `it returned ${processed === null ? 'null' : typeof processed}. ${outcome}`
47
+ );
48
+ }
49
+
50
+ /** The row with one cell changed: through the column's `valueSetter`, else `row[field] = value`. */
51
+ export function buildEditedRow<R extends GridRowModel>(
52
+ row: R,
53
+ cell: { field: string; value: unknown },
54
+ colDef: GridColDef<R> | undefined,
55
+ warnedFields: Set<string>
56
+ ): R {
57
+ if (colDef?.valueSetter) {
58
+ return colDef.valueSetter({ value: cell.value, row, field: cell.field });
59
+ }
60
+ if (colDef?.valueGetter && process.env.NODE_ENV !== 'production' && !warnedFields.has(cell.field)) {
61
+ warnedFields.add(cell.field);
62
+ console.warn(
63
+ `[OpenGridX] Column "${cell.field}" is editable and has a valueGetter but no valueSetter. ` +
64
+ `The edited value was written to row["${cell.field}"], which the valueGetter may ignore, so the cell ` +
65
+ 'can keep showing the old value. Add a valueSetter that maps the value back onto the row.'
66
+ );
67
+ }
68
+ return { ...row, [cell.field]: cell.value };
69
+ }
@@ -0,0 +1,109 @@
1
+ import type { GridCellCoordinates, GridCellParams, GridColDef, GridRowId, GridRowMeta, GridRowModel } from '../../types';
2
+ import { isRangeColumnField } from '../cellSelection';
3
+ import type { CellRangeRect } from '../cellSelection';
4
+ import { isSystemField } from '../focus';
5
+ import { isSyntheticRowId } from '../syntheticRows';
6
+ import { resolveCellEditable } from './index';
7
+ import { getCellValue } from '../values';
8
+
9
+ /**
10
+ * Where batch edits (paste, range clear, undo/redo) land: the renderable rows and the range columns
11
+ * (data columns, no system or grouping column) in display order, the same coordinates as a cell range.
12
+ */
13
+ export interface GridEditTargetContext<R extends GridRowModel> {
14
+ rows: readonly R[];
15
+ rowIds: GridRowId[];
16
+ rowIndexById: Map<GridRowId, number>;
17
+ /** Range columns in screen order. */
18
+ columns: GridColDef<R>[];
19
+ colIndexByField: Map<string, number>;
20
+ /** Number of system columns before the data columns (for `isCellEditable`'s `colIndex`). */
21
+ dataColumnOffset: number;
22
+ }
23
+
24
+ export function buildEditTargetContext<R extends GridRowModel>(
25
+ rows: readonly R[],
26
+ getRowId: (row: R) => GridRowId,
27
+ navigationColumns: ReadonlyArray<GridColDef<R>>,
28
+ ): GridEditTargetContext<R> {
29
+ const rowIds = rows.map(getRowId);
30
+ const rowIndexById = new Map<GridRowId, number>();
31
+ rowIds.forEach((id, index) => { if (!rowIndexById.has(id)) rowIndexById.set(id, index); });
32
+ const columns = navigationColumns.filter(c => isRangeColumnField(c.field));
33
+ const colIndexByField = new Map<string, number>();
34
+ columns.forEach((c, index) => { colIndexByField.set(c.field, index); });
35
+ const dataColumnOffset = navigationColumns.filter(c => isSystemField(c.field)).length;
36
+ return { rows, rowIds, rowIndexById, columns, colIndexByField, dataColumnOffset };
37
+ }
38
+
39
+ export interface GridEditTargetRules<R extends GridRowModel> {
40
+ isCellEditable?: (params: GridCellParams<R>) => boolean;
41
+ rowMetaMap?: ReadonlyMap<GridRowId, GridRowMeta>;
42
+ /** For a cell covered by a span, the origin cell (`null` otherwise). */
43
+ getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
44
+ }
45
+
46
+ /**
47
+ * Whether a batch edit may write the cell at display position (r, c): a data row (not synthetic),
48
+ * not covered by a span whose origin is another cell, and editable by the same rule as double-click.
49
+ */
50
+ export function isEditTargetWritable<R extends GridRowModel>(
51
+ ctx: GridEditTargetContext<R>,
52
+ rules: GridEditTargetRules<R>,
53
+ row: R,
54
+ r: number,
55
+ c: number,
56
+ ): boolean {
57
+ const id = ctx.rowIds[r];
58
+ const colDef = ctx.columns[c];
59
+ if (!colDef || isSyntheticRowId(id, rules.rowMetaMap)) return false;
60
+ const origin = rules.getSpanOrigin?.(id, colDef.field);
61
+ if (origin && (origin.rowId !== id || origin.field !== colDef.field)) return false;
62
+ return resolveCellEditable({
63
+ row,
64
+ field: colDef.field,
65
+ value: getCellValue(row, colDef.field, colDef),
66
+ colDef,
67
+ rowIndex: r,
68
+ colIndex: ctx.dataColumnOffset + c,
69
+ rowMeta: rules.rowMetaMap?.get(id),
70
+ }, rules.isCellEditable);
71
+ }
72
+
73
+ /** The rectangle around the displayed cells among `cells`, or null when none is displayed. */
74
+ export function boundingTargetRect<R extends GridRowModel>(
75
+ ctx: GridEditTargetContext<R>,
76
+ cells: ReadonlyArray<{ id: GridRowId; field: string }>,
77
+ ): CellRangeRect | null {
78
+ let top = Infinity;
79
+ let bottom = -Infinity;
80
+ let left = Infinity;
81
+ let right = -Infinity;
82
+ for (const cell of cells) {
83
+ const r = ctx.rowIndexById.get(cell.id);
84
+ const c = ctx.colIndexByField.get(cell.field);
85
+ if (r === undefined || c === undefined) continue;
86
+ if (r < top) top = r;
87
+ if (r > bottom) bottom = r;
88
+ if (c < left) left = c;
89
+ if (c > right) right = c;
90
+ }
91
+ return top === Infinity ? null : { top, bottom, left, right };
92
+ }
93
+
94
+ /** The two corner cells of a rectangle. */
95
+ export function rectCorners<R extends GridRowModel>(
96
+ ctx: GridEditTargetContext<R>,
97
+ rect: CellRangeRect,
98
+ ): { anchor: GridCellCoordinates; head: GridCellCoordinates } {
99
+ return {
100
+ anchor: { id: ctx.rowIds[rect.top], field: ctx.columns[rect.left].field },
101
+ head: { id: ctx.rowIds[rect.bottom], field: ctx.columns[rect.right].field },
102
+ };
103
+ }
104
+
105
+ /** Columns whose empty value is `''` rather than `null`. */
106
+ export function isTextColumn(colDef: Pick<GridColDef, 'type'> | undefined): boolean {
107
+ const type = colDef?.type;
108
+ return type === undefined || type === 'string' || type === 'image';
109
+ }
@@ -0,0 +1,252 @@
1
+ import type { GridColDef, GridValidRowModel } from '../types';
2
+ import { toDate } from './values';
3
+
4
+ /**
5
+ * Text → typed values: the TSV reader clipboard paste uses, and the per-column-type parsers paste,
6
+ * `GridColDef.valueParser` defaults and the `/ai` validator share. Pure and React-free.
7
+ *
8
+ * Every pattern here is linear (CodeQL scans every push): no adjacent quantifiers that can match the
9
+ * same characters.
10
+ */
11
+
12
+ /** A parsed value, or `{ ok: false }` when the text does not fit the column type. */
13
+ export type GridParsedValue = { ok: true; value: unknown } | { ok: false };
14
+
15
+ const INVALID: GridParsedValue = { ok: false };
16
+ const valid = (value: unknown): GridParsedValue => ({ ok: true, value });
17
+
18
+ /**
19
+ * Splits tab-separated text the way Excel, Google Sheets and LibreOffice write it: tabs between
20
+ * columns, `\r\n`, `\n` or `\r` between rows. A field that starts with a double quote is quoted: it
21
+ * may hold tabs, line breaks and doubled quotes (`""` → `"`), the reverse of `escapeTsvField`. A
22
+ * quote inside an unquoted field is kept as is. A trailing empty line is ignored, so `"a\tb\n"` is one
23
+ * row. Empty text gives no rows.
24
+ */
25
+ export function parseTsv(text: string): string[][] {
26
+ const rows: string[][] = [];
27
+ if (text === '') return rows;
28
+ let row: string[] = [];
29
+ let field = '';
30
+ let i = 0;
31
+ const n = text.length;
32
+ let fieldStart = true;
33
+ while (i < n) {
34
+ const ch = text[i];
35
+ if (fieldStart && ch === '"') {
36
+ // A quoted field: read to the closing quote, `""` being one quote.
37
+ let j = i + 1;
38
+ let value = '';
39
+ let closed = false;
40
+ while (j < n) {
41
+ const q = text[j];
42
+ if (q === '"') {
43
+ if (text[j + 1] === '"') {
44
+ value += '"';
45
+ j += 2;
46
+ continue;
47
+ }
48
+ closed = true;
49
+ j += 1;
50
+ break;
51
+ }
52
+ value += q;
53
+ j += 1;
54
+ }
55
+ if (!closed) {
56
+ // No closing quote: not a quoted field after all, read the quote as text.
57
+ field += ch;
58
+ i += 1;
59
+ fieldStart = false;
60
+ continue;
61
+ }
62
+ field += value;
63
+ i = j;
64
+ fieldStart = false;
65
+ continue;
66
+ }
67
+ fieldStart = false;
68
+ if (ch === '\t') {
69
+ row.push(field);
70
+ field = '';
71
+ fieldStart = true;
72
+ i += 1;
73
+ } else if (ch === '\n' || ch === '\r') {
74
+ row.push(field);
75
+ rows.push(row);
76
+ row = [];
77
+ field = '';
78
+ fieldStart = true;
79
+ i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1;
80
+ } else {
81
+ field += ch;
82
+ i += 1;
83
+ }
84
+ }
85
+ // The last line, unless the text ended with a line break (Excel always adds one).
86
+ if (!fieldStart || row.length > 0 || field !== '') {
87
+ row.push(field);
88
+ rows.push(row);
89
+ }
90
+ return rows;
91
+ }
92
+
93
+ const PLAIN_NUMBER = /^\d+(?:\.\d*)?(?:e[+-]?\d+)?$/i;
94
+ const LEADING_DOT_NUMBER = /^\.\d+(?:e[+-]?\d+)?$/i;
95
+ const GROUPED_NUMBER = /^\d{1,3}(?:,\d{3})+(?:\.\d*)?$/;
96
+ // Currency symbols (Unicode Sc) and every kind of space, including the no-break spaces Excel uses
97
+ // as a thousands separator.
98
+ const STRIPPED_CHARS = /[\p{Sc}\s]/gu;
99
+
100
+ /**
101
+ * A number as spreadsheets copy it: `1234.5`, `1,234.5` (comma thousands groups only), `-12`,
102
+ * `(12)` (accounting negative), `12%` (→ 0.12), `1e3`, with currency symbols and spaces stripped.
103
+ * Empty text is `null`. `1,5` is not accepted (it is ambiguous): use a `valueParser` for
104
+ * decimal-comma locales.
105
+ */
106
+ export function parseNumberText(text: string): GridParsedValue {
107
+ let s = text.replace(STRIPPED_CHARS, '');
108
+ if (s === '') return text.trim() === '' ? valid(null) : INVALID;
109
+ let negative = false;
110
+ if (s.startsWith('(') && s.endsWith(')')) {
111
+ negative = true;
112
+ s = s.slice(1, -1);
113
+ }
114
+ if (s.startsWith('-') || s.startsWith('+') || s.startsWith('−')) {
115
+ if (s[0] !== '+') negative = !negative;
116
+ s = s.slice(1);
117
+ }
118
+ let percent = false;
119
+ if (s.endsWith('%')) {
120
+ percent = true;
121
+ s = s.slice(0, -1);
122
+ }
123
+ if (GROUPED_NUMBER.test(s)) s = s.split(',').join('');
124
+ else if (!PLAIN_NUMBER.test(s) && !LEADING_DOT_NUMBER.test(s)) return INVALID;
125
+ let value = Number(s);
126
+ if (!Number.isFinite(value)) return INVALID;
127
+ if (percent) value /= 100;
128
+ if (negative) value = -value;
129
+ return valid(value);
130
+ }
131
+
132
+ const ISO_DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
133
+ const pad2 = (n: number) => String(n).padStart(2, '0');
134
+ const localDay = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
135
+
136
+ /**
137
+ * A date: ISO `2026-10-07` (read as that local calendar day) or anything `Date.parse` accepts. Empty
138
+ * text is `null`. The result takes the kind of `current` (the value the cell holds now), as the date
139
+ * editor does: a timestamp stays a timestamp, a date string stays a `YYYY-MM-DD` string (an ISO
140
+ * date-time string keeps its time part); otherwise it is a `Date`.
141
+ */
142
+ export function parseDateText(text: string, current?: unknown): GridParsedValue {
143
+ const trimmed = text.trim();
144
+ if (trimmed === '') return valid(null);
145
+ const date = toDate(trimmed);
146
+ if (!date) return INVALID;
147
+ if (typeof current === 'number') return valid(date.getTime());
148
+ if (typeof current === 'string') {
149
+ const day = localDay(date);
150
+ return valid(ISO_DATE_PREFIX.test(current) && current.length > 10 ? day + current.slice(10) : day);
151
+ }
152
+ return valid(date);
153
+ }
154
+
155
+ /** Text read as `true` / `false`, besides the grid's own `Yes` / `No` cell labels. Lower case. */
156
+ const TRUE_WORDS = new Set(['true', 'yes', '1']);
157
+ const FALSE_WORDS = new Set(['false', 'no', '0']);
158
+
159
+ export interface GridBooleanLabels {
160
+ /** Text shown for `true` (the grid shows `Yes`). */
161
+ trueLabel?: string;
162
+ /** Text shown for `false` (the grid shows `No`). */
163
+ falseLabel?: string;
164
+ }
165
+
166
+ /**
167
+ * A boolean: `true`/`false`, `yes`/`no` (the grid's own `Yes` / `No` cell labels), `1`/`0` and any
168
+ * `labels` given, case-insensitive. Empty text is `null`.
169
+ */
170
+ export function parseBooleanText(text: string, labels?: GridBooleanLabels): GridParsedValue {
171
+ const s = text.trim().toLowerCase();
172
+ if (s === '') return valid(null);
173
+ if (labels?.trueLabel !== undefined && s === labels.trueLabel.trim().toLowerCase()) return valid(true);
174
+ if (labels?.falseLabel !== undefined && s === labels.falseLabel.trim().toLowerCase()) return valid(false);
175
+ if (TRUE_WORDS.has(s)) return valid(true);
176
+ if (FALSE_WORDS.has(s)) return valid(false);
177
+ return INVALID;
178
+ }
179
+
180
+ type ValueOption = NonNullable<GridColDef['valueOptions']>[number];
181
+
182
+ /**
183
+ * A `singleSelect` value: the option whose value or label matches the text, case-insensitive (values
184
+ * first). Returns the option's own value, so a numeric option stays a number. Empty text is `null`.
185
+ */
186
+ export function parseSingleSelectText(text: string, options: readonly ValueOption[] | undefined): GridParsedValue {
187
+ const s = text.trim();
188
+ if (s === '') return valid(null);
189
+ if (!options || options.length === 0) return INVALID;
190
+ const lower = s.toLowerCase();
191
+ for (const option of options) {
192
+ const value = typeof option === 'object' ? option.value : option;
193
+ if (String(value).toLowerCase() === lower) return valid(value);
194
+ }
195
+ for (const option of options) {
196
+ if (typeof option === 'object' && option.label.toLowerCase() === lower) return valid(option.value);
197
+ }
198
+ return INVALID;
199
+ }
200
+
201
+ type ParserColumn = Pick<GridColDef, 'type' | 'valueOptions'>;
202
+
203
+ export interface GridParseTextOptions {
204
+ /** The value the cell holds now: a parsed date takes its kind (see `parseDateText`). */
205
+ current?: unknown;
206
+ /** Extra boolean labels, besides `Yes` / `No`. */
207
+ booleanLabels?: GridBooleanLabels;
208
+ }
209
+
210
+ /**
211
+ * Parses cell text by the column `type`, without `valueParser`:
212
+ *
213
+ * | type | accepted |
214
+ * | :--- | :--- |
215
+ * | `string` (default), `image` | the text as is (an empty cell is `''`) |
216
+ * | `number` | `parseNumberText` |
217
+ * | `date` | `parseDateText` |
218
+ * | `boolean` | `parseBooleanText` |
219
+ * | `singleSelect` | `parseSingleSelectText` |
220
+ *
221
+ * An empty cell is `null` for every type but `string` / `image`.
222
+ */
223
+ export function parseValueByType(text: string, colDef: ParserColumn | undefined, options?: GridParseTextOptions): GridParsedValue {
224
+ switch (colDef?.type) {
225
+ case 'number': return parseNumberText(text);
226
+ case 'date': return parseDateText(text, options?.current);
227
+ case 'boolean': return parseBooleanText(text, options?.booleanLabels);
228
+ case 'singleSelect': return parseSingleSelectText(text, colDef.valueOptions);
229
+ default: return valid(text);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Parses the text pasted into one cell: the column's `valueParser` when it has one (a throw, or a
235
+ * returned `undefined`, rejects the text), otherwise `parseValueByType`.
236
+ */
237
+ export function parseCellText<R extends GridValidRowModel>(
238
+ text: string,
239
+ colDef: GridColDef<R>,
240
+ row: R,
241
+ options?: GridParseTextOptions,
242
+ ): GridParsedValue {
243
+ const { valueParser } = colDef;
244
+ if (!valueParser) return parseValueByType(text, colDef as unknown as ParserColumn, options);
245
+ let value: unknown;
246
+ try {
247
+ value = valueParser(text, { row, field: colDef.field, colDef });
248
+ } catch {
249
+ return INVALID;
250
+ }
251
+ return value === undefined ? INVALID : valid(value);
252
+ }