@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
@@ -0,0 +1,286 @@
1
+ // validateGridAiState: the safety layer between a model's reply and the grid. Anything the columns
2
+ // do not allow is dropped with an error; values are coerced to the column type. Never throws.
3
+ // Only own keys of the input are read (never `__proto__`), and every key written to the output is a
4
+ // column field, so nothing in the reply can reach Object.prototype or the output's prototype.
5
+ import type {
6
+ GridAggregationModel,
7
+ GridColumnVisibilityModel,
8
+ GridFilterGroup,
9
+ GridFilterItem,
10
+ GridFilterModel,
11
+ GridFilterOperator,
12
+ GridPivotModel,
13
+ GridPivotValueField,
14
+ GridSortItem,
15
+ } from '../types';
16
+ import { toLocalDateString } from '../utils/values';
17
+ import { coerceBoolean, coerceValue } from './coerce';
18
+ import {
19
+ ALL_PARTS,
20
+ MAX_FILTER_DEPTH,
21
+ NO_VALUE_OPS,
22
+ PART_KEYS,
23
+ PIVOT_FNS,
24
+ aggregationFnsOf,
25
+ hasOwn,
26
+ isFilterable,
27
+ isGroupable,
28
+ isHideable,
29
+ isPlainObject,
30
+ isSortable,
31
+ operatorsOf,
32
+ selectedParts,
33
+ usableColumns,
34
+ } from './columns';
35
+ import type { GridAiColumn, GridAiPart, GridAiState, GridAiValidateOptions, GridAiValidationError, GridAiValidationResult } from './types';
36
+
37
+ /** Longest list read from the model output; the rest is dropped with one error. */
38
+ const MAX_LIST = 200;
39
+ /** Errors past this many are not listed, so a reply full of junk still gives a readable list. */
40
+ const MAX_ERRORS = 100;
41
+
42
+ type Allowed = (col: GridAiColumn) => boolean;
43
+ type FilterNode = GridFilterItem | GridFilterGroup;
44
+
45
+ interface Ctx {
46
+ errors: GridAiValidationError[];
47
+ byField: Map<string, GridAiColumn>;
48
+ }
49
+
50
+ function fail(ctx: Ctx, path: string, message: string): null {
51
+ if (ctx.errors.length < MAX_ERRORS) ctx.errors.push({ path, message });
52
+ return null;
53
+ }
54
+
55
+ /** A short, safe rendering of a value for an error message. */
56
+ function show(value: unknown): string {
57
+ if (typeof value === 'string') return JSON.stringify(value.slice(0, 60));
58
+ return value === null || typeof value !== 'object' ? String(value) : Array.isArray(value) ? 'a list' : 'an object';
59
+ }
60
+
61
+ function lower(value: unknown): string | null {
62
+ return typeof value === 'string' ? value.trim().toLowerCase() : null;
63
+ }
64
+
65
+ /** Own enumerable keys of the input, without `__proto__`. */
66
+ function keysOf(obj: Record<string, unknown>): string[] {
67
+ return Object.keys(obj).filter((key) => key !== '__proto__');
68
+ }
69
+
70
+ function column(ctx: Ctx, value: unknown, path: string, allowed: Allowed, what: string): GridAiColumn | null {
71
+ const col = typeof value === 'string' ? ctx.byField.get(value) : undefined;
72
+ if (!col) return fail(ctx, path, `Unknown field ${show(value)}`);
73
+ return allowed(col) ? col : fail(ctx, path, `Field ${show(value)} cannot be ${what}`);
74
+ }
75
+
76
+ /** The first MAX_LIST entries of an array; a single value is read as a one-entry list. */
77
+ function list(ctx: Ctx, value: unknown, path: string): unknown[] {
78
+ if (value == null) return [];
79
+ const items = Array.isArray(value) ? value : [value];
80
+ if (items.length > MAX_LIST) fail(ctx, path, `Only the first ${MAX_LIST} entries are read`);
81
+ return items.slice(0, MAX_LIST);
82
+ }
83
+
84
+ /** The choice `value` names, case-insensitively, in the choice's own spelling. */
85
+ function oneOf(ctx: Ctx, value: unknown, path: string, choices: readonly string[]): string | null {
86
+ const v = lower(value);
87
+ return choices.find((c) => c.toLowerCase() === v) ?? fail(ctx, path, `${show(value)} is not one of ${choices.join(', ')}`);
88
+ }
89
+
90
+ /** One filter value, coerced; dates become `YYYY-MM-DD` as the filter panel writes them. */
91
+ function filterValue(ctx: Ctx, raw: unknown, col: GridAiColumn, path: string): { v: unknown } | null {
92
+ const result = coerceValue(raw, col);
93
+ if (!result.ok) return fail(ctx, path, `${show(raw)} is not a valid ${col.type ?? 'string'} value`);
94
+ const v = result.value instanceof Date ? toLocalDateString(result.value) : result.value;
95
+ return v == null || v === '' ? fail(ctx, path, 'Missing value') : { v };
96
+ }
97
+
98
+ function filterItem(ctx: Ctx, raw: Record<string, unknown>, path: string): GridFilterItem | null {
99
+ const col = column(ctx, raw.field, `${path}.field`, isFilterable, 'filtered');
100
+ if (!col) return null;
101
+ const operator = oneOf(ctx, raw.operator, `${path}.operator`, operatorsOf(col));
102
+ if (operator === null) return null;
103
+ const item: GridFilterItem = { field: col.field, operator: operator as GridFilterOperator };
104
+ if (NO_VALUE_OPS.includes(item.operator)) return item;
105
+ const rawValue = hasOwn(raw, 'value') ? raw.value : undefined;
106
+ if (item.operator === 'isAnyOf') {
107
+ const values: unknown[] = [];
108
+ list(ctx, rawValue, `${path}.value`).forEach((entry, i) => {
109
+ const r = filterValue(ctx, entry, col, `${path}.value[${i}]`);
110
+ if (r && !values.includes(r.v)) values.push(r.v);
111
+ });
112
+ if (values.length === 0) return fail(ctx, `${path}.value`, 'No valid values');
113
+ item.value = values;
114
+ return item;
115
+ }
116
+ if (Array.isArray(rawValue)) return fail(ctx, `${path}.value`, 'Expected one value, not a list');
117
+ const r = filterValue(ctx, rawValue, col, `${path}.value`);
118
+ if (!r) return null;
119
+ item.value = r.v;
120
+ return item;
121
+ }
122
+
123
+ function logic(ctx: Ctx, raw: unknown, path: string): 'and' | 'or' | null {
124
+ return raw === undefined ? null : (oneOf(ctx, raw, path, ['and', 'or']) as 'and' | 'or' | null);
125
+ }
126
+
127
+ function filterItems(ctx: Ctx, raw: unknown, path: string, depth: number): FilterNode[] {
128
+ const out: FilterNode[] = [];
129
+ list(ctx, raw, path).forEach((entry, i) => {
130
+ const at = `${path}[${i}]`;
131
+ if (!isPlainObject(entry)) {
132
+ fail(ctx, at, `Expected a filter, got ${show(entry)}`);
133
+ } else if (hasOwn(entry, 'items') || hasOwn(entry, 'logicOperator')) {
134
+ if (depth >= MAX_FILTER_DEPTH) {
135
+ fail(ctx, at, `Groups nest at most ${MAX_FILTER_DEPTH} levels`);
136
+ return;
137
+ }
138
+ const items = filterItems(ctx, entry.items, `${at}.items`, depth + 1);
139
+ if (items.length === 0) fail(ctx, at, 'Empty group removed');
140
+ else out.push({ logicOperator: logic(ctx, entry.logicOperator, `${at}.logicOperator`) ?? 'and', items });
141
+ } else {
142
+ const item = filterItem(ctx, entry, at);
143
+ if (item) out.push(item);
144
+ }
145
+ });
146
+ return out;
147
+ }
148
+
149
+ function filterModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridFilterModel {
150
+ const model: GridFilterModel = {};
151
+ if (hasOwn(raw, 'items')) model.items = filterItems(ctx, raw.items, `${path}.items`, 0);
152
+ const op = logic(ctx, raw.logicOperator, `${path}.logicOperator`);
153
+ if (op) model.logicOperator = op;
154
+ if (hasOwn(raw, 'quickFilterValues')) {
155
+ model.quickFilterValues = [];
156
+ list(ctx, raw.quickFilterValues, `${path}.quickFilterValues`).forEach((entry, i) => {
157
+ const word = typeof entry === 'string' || typeof entry === 'number' ? String(entry).trim() : null;
158
+ if (word === null) fail(ctx, `${path}.quickFilterValues[${i}]`, `Expected text, got ${show(entry)}`);
159
+ else if (word !== '') model.quickFilterValues?.push(word);
160
+ });
161
+ }
162
+ return model;
163
+ }
164
+
165
+ function sortModel(ctx: Ctx, raw: unknown, path: string): GridSortItem[] {
166
+ const out: GridSortItem[] = [];
167
+ list(ctx, raw, path).forEach((entry, i) => {
168
+ const at = `${path}[${i}]`;
169
+ if (!isPlainObject(entry)) return fail(ctx, at, `Expected { field, sort }, got ${show(entry)}`);
170
+ const col = column(ctx, entry.field, `${at}.field`, isSortable, 'sorted');
171
+ const sort = col && oneOf(ctx, entry.sort, `${at}.sort`, ['asc', 'desc']);
172
+ if (!col || !sort) return;
173
+ if (out.some((s) => s.field === col.field)) fail(ctx, at, `${show(col.field)} is sorted twice`);
174
+ else out.push({ field: col.field, sort: sort as 'asc' | 'desc' });
175
+ });
176
+ return out;
177
+ }
178
+
179
+ function fieldList(ctx: Ctx, raw: unknown, path: string, allowed: Allowed, what: string): string[] {
180
+ const out: string[] = [];
181
+ list(ctx, raw, path).forEach((entry, i) => {
182
+ const col = column(ctx, entry, `${path}[${i}]`, allowed, what);
183
+ if (col && !out.includes(col.field)) out.push(col.field);
184
+ });
185
+ return out;
186
+ }
187
+
188
+ const canAggregate: Allowed = (col) => aggregationFnsOf(col).length > 0;
189
+ const canPivot: Allowed = (col) => aggregationFnsOf(col, PIVOT_FNS).length > 0;
190
+
191
+ function aggregationModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridAggregationModel {
192
+ const model: GridAggregationModel = {};
193
+ for (const key of keysOf(raw)) {
194
+ // 'none' is how the grid spells "no aggregation": nothing to set.
195
+ if (lower(raw[key]) === 'none') continue;
196
+ const col = column(ctx, key, `${path}.${key}`, canAggregate, 'aggregated');
197
+ const fn = col && oneOf(ctx, raw[key], `${path}.${key}`, aggregationFnsOf(col));
198
+ if (col && fn) model[col.field] = fn;
199
+ }
200
+ return model;
201
+ }
202
+
203
+ function pivotModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridPivotModel {
204
+ const valueFields: GridPivotValueField[] = [];
205
+ list(ctx, raw.valueFields, `${path}.valueFields`).forEach((entry, i) => {
206
+ const at = `${path}.valueFields[${i}]`;
207
+ if (!isPlainObject(entry)) return fail(ctx, at, `Expected { field, aggFn }, got ${show(entry)}`);
208
+ const col = column(ctx, entry.field, `${at}.field`, canPivot, 'a pivot value');
209
+ const aggFn = col && (oneOf(ctx, entry.aggFn, `${at}.aggFn`, aggregationFnsOf(col, PIVOT_FNS)) as GridPivotValueField['aggFn'] | null);
210
+ if (col && aggFn && !valueFields.some((v) => v.field === col.field && v.aggFn === aggFn)) valueFields.push({ field: col.field, aggFn });
211
+ });
212
+ return {
213
+ rowFields: fieldList(ctx, raw.rowFields, `${path}.rowFields`, isGroupable, 'a pivot label'),
214
+ columnFields: fieldList(ctx, raw.columnFields, `${path}.columnFields`, isGroupable, 'a pivot label'),
215
+ valueFields,
216
+ };
217
+ }
218
+
219
+ function visibilityModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridColumnVisibilityModel {
220
+ const model: GridColumnVisibilityModel = {};
221
+ for (const key of keysOf(raw)) {
222
+ const col = column(ctx, key, `${path}.${key}`, isHideable, 'hidden');
223
+ const visible = coerceBoolean(raw[key]);
224
+ if (!col) continue;
225
+ if (visible.ok && visible.value !== null) model[col.field] = visible.value;
226
+ else fail(ctx, `${path}.${key}`, `Expected true or false, got ${show(raw[key])}`);
227
+ }
228
+ return model;
229
+ }
230
+
231
+ function readPart(ctx: Ctx, part: GridAiPart, raw: unknown, path: string, state: GridAiState): void {
232
+ if (part === 'sort') state.sortModel = sortModel(ctx, raw, path);
233
+ else if (part === 'grouping') state.rowGroupingModel = fieldList(ctx, raw, path, isGroupable, 'grouped');
234
+ else if (!isPlainObject(raw)) fail(ctx, path, `Expected an object, got ${show(raw)}`);
235
+ else if (part === 'filter') state.filterModel = filterModel(ctx, raw, path);
236
+ else if (part === 'aggregation') state.aggregationModel = aggregationModel(ctx, raw, path);
237
+ else if (part === 'pivot') state.pivotModel = pivotModel(ctx, raw, path);
238
+ else state.columnVisibilityModel = visibilityModel(ctx, raw, path);
239
+ }
240
+
241
+ /**
242
+ * Checks a model's reply against the columns and returns the state that is safe to apply.
243
+ * `json` is an object or a JSON string (bad JSON gives one error and an empty state). Unknown
244
+ * parts, fields, operators and enum values are dropped, each with an error; values are coerced to
245
+ * the column type (`"1,200"` → 1200, a date → `"YYYY-MM-DD"`); filter groups left empty are
246
+ * removed. Never throws, and never returns a field the columns do not allow.
247
+ * @since v3.4
248
+ */
249
+ export function validateGridAiState<C extends GridAiColumn>(
250
+ json: unknown,
251
+ columns: readonly C[],
252
+ options: GridAiValidateOptions = {},
253
+ ): GridAiValidationResult {
254
+ const ctx: Ctx = { errors: [], byField: new Map() };
255
+ const state: GridAiState = {};
256
+ try {
257
+ for (const col of usableColumns(columns)) ctx.byField.set(col.field, col);
258
+ let input = json;
259
+ if (typeof json === 'string') {
260
+ try {
261
+ input = JSON.parse(json) as unknown;
262
+ } catch {
263
+ fail(ctx, '', 'Not valid JSON');
264
+ return { state, errors: ctx.errors };
265
+ }
266
+ }
267
+ if (!isPlainObject(input)) {
268
+ fail(ctx, '', `Expected an object, got ${show(input)}`);
269
+ return { state, errors: ctx.errors };
270
+ }
271
+ const allowed = selectedParts(options);
272
+ for (const key of keysOf(input)) {
273
+ const part = ALL_PARTS.find((p) => PART_KEYS[p] === key);
274
+ if (key === 'schemaVersion') {
275
+ if (input[key] !== 1) fail(ctx, key, `Unsupported schemaVersion ${show(input[key])}`);
276
+ } else if (!part) fail(ctx, key, 'Unknown part');
277
+ else if (!allowed.includes(part)) fail(ctx, key, 'Part not allowed');
278
+ else readPart(ctx, part, input[key], key, state);
279
+ }
280
+ } catch {
281
+ // Defensive only: a getter on a hand-built input object could throw.
282
+ fail(ctx, '', 'Could not read the input');
283
+ return { state: {}, errors: ctx.errors };
284
+ }
285
+ return { state, errors: ctx.errors };
286
+ }
@@ -66,6 +66,11 @@
66
66
  outline-offset: -2px;
67
67
  background-color: var(--ogx-grid-background);
68
68
  }
69
+ /* An <Input variant="cell"> editor (the built-in text/number/date ones, or a renderEditCell) takes
70
+ the full cell height, so its error edge and click area cover the cell. */
71
+ .ogx__cell--editing > .ogx__cell-content:has(.ogx-input-wrapper--cell) {
72
+ align-self: stretch;
73
+ }
69
74
  .ogx__cell--align-left {
70
75
  justify-content: flex-start;
71
76
  text-align: left;
@@ -28,6 +28,15 @@
28
28
  outline: none;
29
29
  }
30
30
 
31
+ /* The built-in editors render through <Input variant="cell">: keep their own font, colour and
32
+ padding over the variant's inherited ones (this file loads after Input.css). */
33
+ .ogx-input-wrapper--cell .ogx__edit-input {
34
+ padding: 0 10px;
35
+ font-family: var(--ogx-font-family, inherit);
36
+ font-size: var(--ogx-grid-cell-font-size, 14px);
37
+ color: var(--ogx-grid-row-text, #1e293b);
38
+ }
39
+
31
40
  /* ── Number input — hide spinners ────────────────────────────────────── */
32
41
  .ogx__edit-input--number::-webkit-inner-spin-button,
33
42
  .ogx__edit-input--number::-webkit-outer-spin-button {
@@ -54,9 +63,8 @@
54
63
  outline: 2px solid var(--ogx-color-primary, #2563eb);
55
64
  outline-offset: -2px;
56
65
  }
57
- .ogx__edit-checkbox {
58
- width: 18px;
59
- height: 18px;
66
+ /* The editor is the shared Checkbox: `.ogx__edit-checkbox` sits on its visually hidden <input>,
67
+ the drawn box is `.ogx-checkbox__box`. */
68
+ .ogx__edit-boolean .ogx-checkbox-wrapper {
60
69
  cursor: pointer;
61
- accent-color: var(--ogx-color-primary, #2563eb);
62
70
  }
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import type { KeyboardEvent } from 'react';
3
3
  import type { GridColDef, GridRenderEditCellParams } from '../../types';
4
+ import { Input } from '../ui/Input';
5
+ import { Checkbox } from '../ui/Checkbox';
4
6
 
5
7
  export type GridEditInputCellProps = GridRenderEditCellParams;
6
8
 
@@ -34,10 +36,11 @@ function TextEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEd
34
36
  useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
35
37
 
36
38
  return (
37
- <input
39
+ <Input
38
40
  ref={ref}
41
+ variant="cell"
39
42
  type="text"
40
- className="ogx__edit-input"
43
+ inputClassName="ogx__edit-input"
41
44
  aria-label={editorLabel(colDef)}
42
45
  value={local}
43
46
  onChange={e => { setLocal(e.target.value); onValueChange(e.target.value); }}
@@ -54,10 +57,11 @@ function NumberEditor({ value, colDef, onValueChange, onCommit, onCancel }: Grid
54
57
  useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
55
58
 
56
59
  return (
57
- <input
60
+ <Input
58
61
  ref={ref}
62
+ variant="cell"
59
63
  type="number"
60
- className="ogx__edit-input ogx__edit-input--number"
64
+ inputClassName="ogx__edit-input ogx__edit-input--number"
61
65
  aria-label={editorLabel(colDef)}
62
66
  value={local}
63
67
  onChange={e => {
@@ -83,16 +87,17 @@ function BooleanEditor({ value, colDef, onValueChange, onCommit, onCancel }: Gri
83
87
  };
84
88
 
85
89
  return (
86
- <div className="ogx__edit-boolean">
87
- <input
90
+ // Safari does not focus checkboxes on click: mousedown moves focus to the cell, the blur
91
+ // commits the unchanged value and the editor unmounts before the click toggles it. The
92
+ // pointer lands on the drawn box (or around it), not the hidden input, so the whole editor
93
+ // keeps focus where it is.
94
+ <div className="ogx__edit-boolean" onMouseDown={(e) => e.preventDefault()}>
95
+ <Checkbox
88
96
  ref={ref}
89
- type="checkbox"
90
- className="ogx__edit-checkbox"
97
+ inputClassName="ogx__edit-checkbox"
91
98
  aria-label={editorLabel(colDef)}
92
99
  checked={!!value}
93
100
  onChange={handleChange}
94
- // Safari does not focus checkboxes on click: mousedown moves focus to the cell, the
95
- // blur commits the unchanged value and the editor unmounts before the click toggles it.
96
101
  onMouseDown={(e) => e.preventDefault()}
97
102
  onBlur={onCommit}
98
103
  onKeyDown={makeKeyDownHandler(onCommit, onCancel, false)}
@@ -126,6 +131,7 @@ function SelectEditor({ value, colDef, onValueChange, onCommit, onCancel }: Grid
126
131
  const showEmptyChoice = selectedIndex === -1;
127
132
 
128
133
  return (
134
+ // Raw <select>: there is no shared Select component yet.
129
135
  <select
130
136
  ref={ref}
131
137
  className="ogx__edit-select"
@@ -196,10 +202,11 @@ function DateEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEd
196
202
  useEffect(() => { ref.current?.focus(); }, []);
197
203
 
198
204
  return (
199
- <input
205
+ <Input
200
206
  ref={ref}
207
+ variant="cell"
201
208
  type="date"
202
- className="ogx__edit-input"
209
+ inputClassName="ogx__edit-input"
203
210
  aria-label={editorLabel(colDef)}
204
211
  value={local}
205
212
  onChange={e => { setLocal(e.target.value); onValueChange(fromDateInputValue(e.target.value, initialValue)); }}
@@ -30,6 +30,8 @@
30
30
  }
31
31
 
32
32
  .ogx-column-visibility-panel__search-input {
33
+ padding: revert;
34
+ line-height: revert;
33
35
  border: none;
34
36
  background: none;
35
37
  outline: none;
@@ -39,6 +41,18 @@
39
41
  flex: 1;
40
42
  }
41
43
 
44
+ /* The search box renders through the shared Input: the container draws the box. */
45
+ .ogx-column-visibility-panel__search-container .ogx-input-wrapper {
46
+ flex: 1;
47
+ border: none;
48
+ border-radius: 0;
49
+ background: transparent;
50
+ transition: none;
51
+ }
52
+ .ogx-column-visibility-panel__search-container .ogx-input-wrapper:focus-within {
53
+ box-shadow: none;
54
+ }
55
+
42
56
  .ogx-column-visibility-panel__search-input::placeholder {
43
57
  color: var(--ogx-overlay-text, var(--ogx-color-gray-400));
44
58
  opacity: 0.6;
@@ -1,5 +1,6 @@
1
1
  import { useState, useMemo, useRef, useId } from 'react';
2
2
  import { Checkbox } from '../ui/Checkbox';
3
+ import { Input } from '../ui/Input';
3
4
  import type { GridColDef, GridRowModel, GridValidRowModel } from '../../types';
4
5
 
5
6
 
@@ -115,9 +116,9 @@ export function ColumnVisibilityPanel<R extends GridValidRowModel = GridRowModel
115
116
  <div className="ogx-column-visibility-panel__search-section">
116
117
  <div className="ogx-column-visibility-panel__search-container">
117
118
  <SearchIcon />
118
- <input
119
+ <Input
119
120
  autoComplete="off"
120
- className="ogx-column-visibility-panel__search-input"
121
+ inputClassName="ogx-column-visibility-panel__search-input"
121
122
  placeholder="Search"
122
123
  value={searchQuery}
123
124
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -27,6 +27,9 @@ import { useDataGrid } from '../../hooks/core/useDataGrid';
27
27
  import { useRowReorder } from '../../hooks/useRowReorder';
28
28
  import { useGridHierarchy } from '../../hooks/core/useGridHierarchy';
29
29
  import { useGridEditing } from '../../hooks/features/useGridEditing';
30
+ import { useGridBatchEdit } from '../../hooks/features/useGridBatchEdit';
31
+ import { useGridClipboardPaste } from '../../hooks/core/useGridClipboardPaste';
32
+ import { useGridUndoRedo, useGridEditCommitChannel, UNDO_HISTORY_LIMIT } from '../../hooks/core/useGridUndoRedo';
30
33
  import { useGridSpanning } from '../../hooks/features/useGridSpanning';
31
34
  import { useGridSpanRowWindow, useGridSpanColumnWindow } from '../../hooks/features/useGridSpanRenderWindow';
32
35
  import { useColumnGroupReorderGuard } from '../../hooks/features/useColumnGroupReorderGuard';
@@ -90,6 +93,12 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
90
93
  cellSelectionModel: propCellSelectionModel,
91
94
  onCellSelectionModelChange,
92
95
  showCellSelectionStats = false,
96
+ disableClipboardPaste = false,
97
+ disableRangeClear = false,
98
+ onClipboardPaste,
99
+ onBeforeClipboardPaste,
100
+ undoRedo = false,
101
+ onHistoryChange,
93
102
  rowSelectionModel: propRowSelectionModel,
94
103
  onRowSelectionModelChange: propOnRowSelectionModelChange,
95
104
  onRowClick,
@@ -271,7 +280,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
271
280
  syncRows: !dataSource,
272
281
  });
273
282
  const {
274
- state, apiRef, setRows, replaceRow, setColumns, setDimensions, setDataSourceLoading, setDataSourceError,
283
+ state, apiRef, setRows, replaceRows, setColumns, setDimensions, setDataSourceLoading, setDataSourceError,
275
284
  setRowCount
276
285
  } = gridData;
277
286
 
@@ -295,6 +304,18 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
295
304
  getAggregationPosition, columnLookup, filterMode, sortingMode,
296
305
  });
297
306
 
307
+ // Commits (single edits and batch edits) reach the undo/redo history created further down.
308
+ const editCommitChannel = useGridEditCommitChannel();
309
+ // Batch edits (paste, range clear, undo, redo): one processRowUpdate per row, one store update.
310
+ const batchEdit = useGridBatchEdit<R>({
311
+ rowsLookup: state.rows.idRowsLookup as unknown as ReadonlyMap<GridRowId, R>,
312
+ columns: activeColumns,
313
+ processRowUpdate,
314
+ onProcessRowUpdateError,
315
+ replaceRows: replaceRows as unknown as (rows: ReadonlyMap<GridRowId, R>) => void,
316
+ onCommitted: editCommitChannel.emit,
317
+ });
318
+
298
319
  const editingHandlers = useGridEditing({
299
320
  rows: effectiveRows,
300
321
  getRowId: getRowIdOf,
@@ -306,8 +327,9 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
306
327
  // after an awaited processRowUpdate, by which time the rows prop (or a dataSource
307
328
  // fetch) may have replaced the rows this render saw. The returned row is stored as is
308
329
  // (the grid never writes an id onto it), even if it does not carry its key itself.
309
- replaceRow(rowId, updatedRow);
330
+ batchEdit.storeRow(rowId, updatedRow);
310
331
  },
332
+ onCommitted: (changes) => editCommitChannel.emit(changes, 'edit'),
311
333
  });
312
334
 
313
335
  // The selection without ids of removed rows. Only client-owned rows are pruned: a server page
@@ -590,6 +612,44 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
590
612
  containerRef,
591
613
  });
592
614
 
615
+ // Ctrl/Cmd+V and Delete on a range (cellSelection); apiRef.pasteText.
616
+ useGridClipboardPaste<R>({
617
+ apiRef, containerRef, viewportRef,
618
+ selection: cellSelection,
619
+ disableClipboardPaste, disableRangeClear,
620
+ focusedCell,
621
+ allRenderableRows,
622
+ getRowId: getRowIdOf,
623
+ navigationColumns: navigationColumns as unknown as GridColDef<R>[],
624
+ isCellEditable,
625
+ rowMetaMap,
626
+ getSpanOrigin: spanning.getSpanOrigin,
627
+ isEditing: editingHandlers.editingCell !== null,
628
+ batchEdit,
629
+ onClipboardPaste,
630
+ onBeforeClipboardPaste,
631
+ });
632
+
633
+ // Undo / redo (undoRedo): history of committed edits, keys and apiRef methods.
634
+ useGridUndoRedo<R>({
635
+ apiRef,
636
+ enabled: Boolean(undoRedo),
637
+ limit: (typeof undoRedo === 'object' && undoRedo.limit) || UNDO_HISTORY_LIMIT,
638
+ onHistoryChange,
639
+ channel: editCommitChannel,
640
+ batchEdit,
641
+ isEditing: editingHandlers.editingCell !== null,
642
+ containerRef, viewportRef,
643
+ selection: cellSelection,
644
+ setFocusedCell,
645
+ allRenderableRows,
646
+ getRowId: getRowIdOf,
647
+ navigationColumns: navigationColumns as unknown as GridColDef<R>[],
648
+ layout,
649
+ rowHeight: effectiveRowHeight,
650
+ pinnedTopRowCount: pinnedTopRows.length,
651
+ });
652
+
593
653
  const { handleHideColumn, handlePinColumn } = useGridColumnMenuHandlers({
594
654
  columnVisibilityModel,
595
655
  onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
@@ -115,11 +115,25 @@
115
115
  border-radius: var(--ogx-border-radius-sm);
116
116
  font-family: var(--ogx-font-family);
117
117
  font-size: var(--ogx-font-size-xs);
118
+ line-height: revert;
118
119
  background: var(--ogx-grid-background);
119
120
  color: var(--ogx-grid-row-text);
120
121
  outline: none;
121
122
  transition: border-color 0.15s, box-shadow 0.15s;
122
123
  }
124
+ /* The text/date value box renders through the shared Input: the <input> keeps its own border,
125
+ so the wrapper draws nothing and only takes the input's place in the row. */
126
+ .ogx-filter__controls .ogx-input-wrapper {
127
+ flex: 1;
128
+ min-width: 60px;
129
+ border: none;
130
+ border-radius: 0;
131
+ background: transparent;
132
+ transition: none;
133
+ }
134
+ .ogx-filter__controls .ogx-input-wrapper:focus-within {
135
+ box-shadow: none;
136
+ }
123
137
  .ogx-filter__value-input::placeholder {
124
138
  color: var(--ogx-overlay-text, var(--ogx-color-gray-400));
125
139
  opacity: 0.6;
@@ -8,6 +8,7 @@ import type {
8
8
  } from '../../types';
9
9
  import { getOperatorsForType, NO_VALUE_OPERATORS } from '../../utils/filtering';
10
10
  import { toLocalDateString } from '../../utils/values';
11
+ import { Input } from '../ui/Input';
11
12
 
12
13
 
13
14
  export interface FilterPanelProps {
@@ -251,10 +252,10 @@ const FilterRow: React.FC<{
251
252
  );
252
253
  } else if (showValue) {
253
254
  valueControl = (
254
- <input
255
+ <Input
255
256
  id={`${filterId}-val`}
256
257
  name={`filter-val-${col.field}`}
257
- className={valueClassName}
258
+ inputClassName={valueClassName}
258
259
  type={col.type === 'date' ? 'date' : 'text'}
259
260
  value={draft ?? toInputText(item?.value, col)}
260
261
  onChange={(e) => setDraft(e.target.value)}
@@ -65,11 +65,9 @@
65
65
  border-right: var(--ogx-border-width-thin) solid var(--ogx-grid-border-color);
66
66
  }
67
67
 
68
- .ogx-list-view__checkbox input[type="checkbox"] {
69
- width: 16px;
70
- height: 16px;
68
+ /* The row checkbox is the shared Checkbox (its <input> is visually hidden). */
69
+ .ogx-list-view__checkbox .ogx-checkbox-wrapper {
71
70
  cursor: pointer;
72
- accent-color: var(--ogx-color-primary);
73
71
  }
74
72
 
75
73
  /* ─── Cell content area ───────────────────────────────────────────────────── */
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { formatValueByType, getCellValue, getFormattedValue } from '../../utils/values';
3
3
  import { ExpandIcon } from '../ui/ExpandIcon';
4
+ import { Checkbox } from '../ui/Checkbox';
4
5
  import { CellErrorBoundary } from '../Cell/CellErrorBoundary';
5
6
  import { isSyntheticRowId } from '../../utils/syntheticRows';
6
7
  import type { GridColDef, GridRowModel, GridRowId, GridRowMeta, GridListViewColDef, GridRenderCellParams } from '../../types';
@@ -92,8 +93,7 @@ export function ListViewRow<R extends GridRowModel = GridRowModel>({
92
93
  >
93
94
  {checkboxSelection && (
94
95
  <div className="ogx-list-view__checkbox" onClick={(e) => e.stopPropagation()}>
95
- {!isSyntheticRow && <input
96
- type="checkbox"
96
+ {!isSyntheticRow && <Checkbox
97
97
  checked={isSelected}
98
98
  onChange={handleCheckboxChange}
99
99
  aria-label={`Select row ${id}`}
@@ -74,6 +74,7 @@
74
74
  font-family: var(--ogx-font-family, system-ui, sans-serif);
75
75
  font-size: var(--ogx-font-size-sm, 13px);
76
76
  color: var(--ogx-toolbar-input-text, var(--ogx-grid-row-text, #1e293b));
77
+ line-height: revert;
77
78
  outline: none;
78
79
  opacity: 0;
79
80
  pointer-events: none;
@@ -88,6 +89,21 @@
88
89
  font-size: var(--ogx-font-size-sm, 13px);
89
90
  }
90
91
 
92
+ /* The input renders through the shared Input: its wrapper is the flex item and the container
93
+ draws the box, so the wrapper has none of its own. */
94
+ .ogx-global-search .ogx-input-wrapper {
95
+ flex: 1 1 auto;
96
+ min-width: 0;
97
+ height: 100%;
98
+ border: none;
99
+ border-radius: 0;
100
+ background: transparent;
101
+ transition: none;
102
+ }
103
+ .ogx-global-search .ogx-input-wrapper:focus-within {
104
+ box-shadow: none;
105
+ }
106
+
91
107
  /* ─── Clear button ────────────────────────────────────────────────────────── */
92
108
  .ogx-global-search__clear {
93
109
  position: absolute;