@opencorestack/opengridx 3.2.2 → 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 (88) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +16 -2
  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 +321 -3
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +6483 -5291
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -13
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +132 -3
  15. package/docs/README.md +4 -1
  16. package/docs/architecture/datagrid-orchestration.md +23 -6
  17. package/docs/components/header.md +25 -0
  18. package/docs/customization/theming.md +12 -1
  19. package/docs/features/ai-toolkit.md +119 -0
  20. package/docs/features/cell-selection.md +131 -0
  21. package/docs/features/clipboard.md +90 -0
  22. package/docs/features/editing-reordering.md +50 -0
  23. package/docs/features/keyboard-navigation.md +25 -2
  24. package/docs/features/undo-redo.md +79 -0
  25. package/docs/roadmap.md +64 -7
  26. package/lib/ai/coerce.ts +90 -0
  27. package/lib/ai/columns.ts +90 -0
  28. package/lib/ai/index.ts +16 -0
  29. package/lib/ai/schema.ts +214 -0
  30. package/lib/ai/types.ts +88 -0
  31. package/lib/ai/validate.ts +286 -0
  32. package/lib/components/Cell/Cell.css +35 -0
  33. package/lib/components/Cell/Cell.tsx +25 -13
  34. package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
  35. package/lib/components/Cell/GridEditInputCell.css +12 -4
  36. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  37. package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
  38. package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
  39. package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
  40. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  41. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  42. package/lib/components/DataGrid/DataGrid.tsx +123 -3
  43. package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
  44. package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
  45. package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
  46. package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
  47. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  48. package/lib/components/FilterPanel/FilterPanel.tsx +5 -3
  49. package/lib/components/Header/Header.css +12 -0
  50. package/lib/components/Header/Header.tsx +17 -4
  51. package/lib/components/ListView/ListView.css +2 -4
  52. package/lib/components/ListView/ListViewRow.tsx +2 -2
  53. package/lib/components/Row/Row.tsx +38 -1
  54. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  55. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  56. package/lib/components/Toolbar/GridToolbar.tsx +10 -4
  57. package/lib/components/ui/Checkbox.tsx +26 -10
  58. package/lib/components/ui/Input.css +43 -0
  59. package/lib/components/ui/Input.tsx +24 -5
  60. package/lib/hooks/core/gridApiPlaceholder.ts +14 -1
  61. package/lib/hooks/core/useDataGrid.ts +27 -0
  62. package/lib/hooks/core/useGridCellSelection.ts +631 -0
  63. package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
  64. package/lib/hooks/core/useGridClipboardApi.ts +8 -2
  65. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  66. package/lib/hooks/core/useGridControlledState.ts +38 -0
  67. package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
  68. package/lib/hooks/core/useGridRowInteractions.ts +8 -4
  69. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  70. package/lib/hooks/features/useAggregation.ts +8 -6
  71. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  72. package/lib/hooks/features/useGridClipboard.ts +47 -2
  73. package/lib/hooks/features/useGridEditing.ts +39 -51
  74. package/lib/index.ts +16 -0
  75. package/lib/styles/base/variables.css +6 -1
  76. package/lib/styles/opengridx.css +3 -1
  77. package/lib/theme/DataGridThemeProvider.tsx +4 -0
  78. package/lib/theme/presets.ts +8 -0
  79. package/lib/theme/types.ts +4 -0
  80. package/lib/types/index.ts +254 -0
  81. package/lib/utils/attempt.ts +14 -0
  82. package/lib/utils/cellSelection.ts +238 -0
  83. package/lib/utils/editing/commit.ts +69 -0
  84. package/lib/utils/editing/targets.ts +109 -0
  85. package/lib/utils/headerWrap.ts +23 -0
  86. package/lib/utils/parsing.ts +252 -0
  87. package/llms.txt +71 -2
  88. package/package.json +14 -4
@@ -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
+ }
@@ -28,6 +28,36 @@
28
28
  .ogx__cell--editable {
29
29
  cursor: text;
30
30
  }
31
+ /*
32
+ * Cell range selection (cellSelection, v3.3). The tint is a background image, so it also covers
33
+ * pinned cells' own background; the outline is an inset box-shadow, so the layout never shifts.
34
+ */
35
+ .ogx__cell--range {
36
+ background-image: linear-gradient(var(--ogx-range-background), var(--ogx-range-background));
37
+ box-shadow:
38
+ var(--ogx-range-edge-top, 0 0 0 0 transparent),
39
+ var(--ogx-range-edge-bottom, 0 0 0 0 transparent),
40
+ var(--ogx-range-edge-left, 0 0 0 0 transparent),
41
+ var(--ogx-range-edge-right, 0 0 0 0 transparent);
42
+ }
43
+ .ogx__cell--range-top {
44
+ --ogx-range-edge-top: inset 0 2px 0 0 var(--ogx-range-border);
45
+ }
46
+ .ogx__cell--range-bottom {
47
+ --ogx-range-edge-bottom: inset 0 -2px 0 0 var(--ogx-range-border);
48
+ }
49
+ .ogx__cell--range-left {
50
+ --ogx-range-edge-left: inset 2px 0 0 0 var(--ogx-range-border);
51
+ }
52
+ .ogx__cell--range-right {
53
+ --ogx-range-edge-right: inset -2px 0 0 0 var(--ogx-range-border);
54
+ }
55
+ /* While a pointer drag selects cells, the browser must not select text as well. */
56
+ .ogx--range-dragging,
57
+ .ogx--range-dragging * {
58
+ -webkit-user-select: none;
59
+ user-select: none;
60
+ }
31
61
  .ogx__cell--editing {
32
62
  padding: 0;
33
63
  overflow: visible;
@@ -36,6 +66,11 @@
36
66
  outline-offset: -2px;
37
67
  background-color: var(--ogx-grid-background);
38
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
+ }
39
74
  .ogx__cell--align-left {
40
75
  justify-content: flex-start;
41
76
  text-align: left;
@@ -6,6 +6,8 @@ import type { CellColSpanInfo } from '../../hooks/features/useGridSpanning';
6
6
  import { GridEditInputCell } from './GridEditInputCell';
7
7
  import { CellErrorBoundary } from './CellErrorBoundary';
8
8
  import { formatValueByType } from '../../utils/values';
9
+ import { attempt } from '../../utils/attempt';
10
+ import { RANGE_BOTTOM, RANGE_CELL, RANGE_LEFT, RANGE_RIGHT, RANGE_TOP } from '../../utils/cellSelection';
9
11
 
10
12
  export interface CellProps<R extends GridRowModel = GridRowModel> {
11
13
  onCellClick?: (params: GridCellParams<R>) => void;
@@ -46,6 +48,11 @@ export interface CellProps<R extends GridRowModel = GridRowModel> {
46
48
  rowMeta?: GridRowMeta;
47
49
  /** The error the column's valueGetter threw for this cell (computed by Row); shown as a cell error. */
48
50
  valueError?: unknown;
51
+ /**
52
+ * Place of the cell in the selected cell range (`cellSelection`), as bit flags: in range 1, top
53
+ * edge 2, bottom 4, left 8, right 16. 0 or absent: not in a range. @since v3.3
54
+ */
55
+ rangeFlags?: number;
49
56
  }
50
57
 
51
58
  interface FormattedValue {
@@ -82,7 +89,8 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
82
89
  rowSpan: rowSpanProp,
83
90
  isHiddenByRowSpan,
84
91
  rowMeta,
85
- valueError
92
+ valueError,
93
+ rangeFlags = 0,
86
94
  } = props;
87
95
 
88
96
  // All hooks must come before any early returns (Rules of Hooks)
@@ -99,12 +107,12 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
99
107
  // formatter, written for data rows, is only called for the values they do hold.
100
108
  const isSyntheticRow = rowMeta?.isGroupRow === true;
101
109
  const { formattedValue, error: formatError } = React.useMemo<FormattedValue>(() => {
102
- if (colDef.valueFormatter && !(isSyntheticRow && value == null)) {
103
- try {
104
- return { formattedValue: colDef.valueFormatter({ value, row, field: colDef.field }) };
105
- } catch (error) {
106
- return { formattedValue: '', error: error ?? new Error('valueFormatter failed') };
107
- }
110
+ const formatter = colDef.valueFormatter;
111
+ if (formatter && !(isSyntheticRow && value == null)) {
112
+ const result = attempt(() => formatter({ value, row, field: colDef.field }));
113
+ return result.ok
114
+ ? { formattedValue: result.value }
115
+ : { formattedValue: '', error: result.error ?? new Error('valueFormatter failed') };
108
116
  }
109
117
 
110
118
  // Without a formatter the column type decides: locale date, Yes / No, singleSelect label.
@@ -113,12 +121,10 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
113
121
 
114
122
  const resolvedCellClassName = React.useMemo(() => {
115
123
  if (!colDef.cellClassName) return '';
116
- if (typeof colDef.cellClassName === 'function') {
117
- try {
118
- return colDef.cellClassName({ value, formattedValue, row, field: colDef.field, colDef, rowIndex, colIndex, rowMeta }) || '';
119
- } catch {
120
- return '';
121
- }
124
+ const getClassName = colDef.cellClassName;
125
+ if (typeof getClassName === 'function') {
126
+ const result = attempt(() => getClassName({ value, formattedValue, row, field: colDef.field, colDef, rowIndex, colIndex, rowMeta }));
127
+ return (result.ok && result.value) || '';
122
128
  }
123
129
  return colDef.cellClassName;
124
130
  }, [colDef, value, formattedValue, row, rowIndex, colIndex, rowMeta]);
@@ -224,6 +230,11 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
224
230
  isPinned && isPinnedEdge && `ogx__cell--pinned-${pinnedPosition}-${pinnedPosition === 'left' ? 'last' : 'first'}`,
225
231
  isEditing && 'ogx__cell--editing',
226
232
  (rowSpanProp && rowSpanProp > 1) && 'ogx__cell--spanned',
233
+ (rangeFlags & RANGE_CELL) !== 0 && 'ogx__cell--range',
234
+ (rangeFlags & RANGE_TOP) !== 0 && 'ogx__cell--range-top',
235
+ (rangeFlags & RANGE_BOTTOM) !== 0 && 'ogx__cell--range-bottom',
236
+ (rangeFlags & RANGE_LEFT) !== 0 && 'ogx__cell--range-left',
237
+ (rangeFlags & RANGE_RIGHT) !== 0 && 'ogx__cell--range-right',
227
238
  resolvedCellClassName
228
239
  ].filter(Boolean).join(' ');
229
240
 
@@ -266,6 +277,7 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
266
277
  data-colindex={colIndex}
267
278
  {...(colSpan > 1 ? { 'aria-colspan': colSpan } : {})}
268
279
  {...(rowSpan > 1 ? { 'aria-rowspan': rowSpan } : {})}
280
+ {...((rangeFlags & RANGE_CELL) !== 0 ? { 'aria-selected': true } : {})}
269
281
  >
270
282
  <div className="ogx__cell-content">
271
283
  {isEditing && (onCellValueChange || onValueChange) && onEditStop ? (
@@ -3,7 +3,10 @@ import React from 'react';
3
3
  // Must live at module scope (not inline) so React sees a stable component type.
4
4
  // Calling renderFn() here puts the throw one level BELOW CellErrorBoundary,
5
5
  // which is required — a component cannot catch errors thrown in its own render().
6
+ // `renderFn` runs the consumer's renderCell, which may call hooks, so it must run on every render:
7
+ // 'use no memo' stops React Compiler from memoizing the call (see GridToolbarSlot's InlineToolbar).
6
8
  function CellRenderTarget({ renderFn }: { renderFn: () => React.ReactNode }) {
9
+ 'use no memo';
7
10
  return <>{renderFn()}</>;
8
11
  }
9
12
 
@@ -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)); }}
@@ -0,0 +1,31 @@
1
+ /* Status bar of showCellSelectionStats (v3.3). The area keeps its height while no range is selected. */
2
+ .ogx__cell-selection-stats-area {
3
+ flex: 0 0 auto;
4
+ min-height: 32px;
5
+ border-top: var(--ogx-border-width-thin, 1px) solid var(--ogx-grid-border-color);
6
+ background: var(--ogx-grid-header-background);
7
+ color: var(--ogx-grid-header-text);
8
+ font-size: var(--ogx-font-size-sm);
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: flex-end;
12
+ box-sizing: border-box;
13
+ }
14
+ .ogx__cell-selection-stats {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: var(--ogx-spacing-lg, 16px);
18
+ padding: 4px 16px;
19
+ }
20
+ .ogx__cell-selection-stat {
21
+ display: inline-flex;
22
+ gap: 6px;
23
+ white-space: nowrap;
24
+ }
25
+ .ogx__cell-selection-stat-label {
26
+ opacity: 0.75;
27
+ }
28
+ .ogx__cell-selection-stat-value {
29
+ font-weight: 600;
30
+ font-variant-numeric: tabular-nums;
31
+ }
@@ -0,0 +1,32 @@
1
+ import { formatAggregationValue } from '../../utils/aggregation';
2
+ import type { GridCellSelectionStatsSlotProps } from '../../types';
3
+
4
+ export type CellSelectionStatsProps = GridCellSelectionStatsSlotProps & Record<string, unknown>;
5
+
6
+ /**
7
+ * The status bar of `showCellSelectionStats`: count of non-empty cells, and the sum and average of
8
+ * the numeric cells of the selected range. Replace it with `slots.cellSelectionStats`, or wrap it.
9
+ * @since v3.3
10
+ */
11
+ export function CellSelectionStats({ count, sum, average }: CellSelectionStatsProps) {
12
+ return (
13
+ <div className="ogx__cell-selection-stats">
14
+ <span className="ogx__cell-selection-stat">
15
+ <span className="ogx__cell-selection-stat-label">Count</span>
16
+ <span className="ogx__cell-selection-stat-value" data-stat="count">{count.toLocaleString()}</span>
17
+ </span>
18
+ {sum !== null && (
19
+ <span className="ogx__cell-selection-stat">
20
+ <span className="ogx__cell-selection-stat-label">Sum</span>
21
+ <span className="ogx__cell-selection-stat-value" data-stat="sum">{formatAggregationValue(sum, 'sum')}</span>
22
+ </span>
23
+ )}
24
+ {average !== null && (
25
+ <span className="ogx__cell-selection-stat">
26
+ <span className="ogx__cell-selection-stat-label">Average</span>
27
+ <span className="ogx__cell-selection-stat-value" data-stat="average">{formatAggregationValue(average, 'avg')}</span>
28
+ </span>
29
+ )}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,24 @@
1
+ import type React from 'react';
2
+ import { CellSelectionStats } from './CellSelectionStats';
3
+ import type { GridCellSelectionStats } from '../../hooks/core/useGridCellSelection';
4
+ import type { GridApi, GridCellSelectionStatsSlotProps } from '../../types';
5
+
6
+ interface GridCellSelectionStatsAreaProps {
7
+ stats: GridCellSelectionStats | null;
8
+ apiRef: React.MutableRefObject<GridApi>;
9
+ slot?: React.ComponentType<GridCellSelectionStatsSlotProps & Record<string, unknown>>;
10
+ slotProps?: Partial<GridCellSelectionStatsSlotProps> & Record<string, unknown>;
11
+ }
12
+
13
+ /**
14
+ * The strip under the viewport that holds the status bar. It keeps its height while one cell or
15
+ * none is selected, so the viewport does not resize each time a range starts or ends.
16
+ */
17
+ export function GridCellSelectionStatsArea({ stats, apiRef, slot, slotProps }: GridCellSelectionStatsAreaProps) {
18
+ const Component = slot ?? CellSelectionStats;
19
+ return (
20
+ <div className="ogx__cell-selection-stats-area" aria-hidden={stats ? undefined : true}>
21
+ {stats && <Component apiRef={apiRef} {...stats} {...slotProps} />}
22
+ </div>
23
+ );
24
+ }
@@ -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;