@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.
- package/CHANGELOG.md +53 -0
- package/README.md +16 -2
- package/dist/ai.cjs +1 -0
- package/dist/ai.cjs.map +1 -0
- package/dist/ai.d.ts +166 -0
- package/dist/ai.es.js +1 -0
- package/dist/ai.es.js.map +1 -0
- package/dist/index.d.ts +321 -3
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +6483 -5291
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -13
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +132 -3
- package/docs/README.md +4 -1
- package/docs/architecture/datagrid-orchestration.md +23 -6
- package/docs/components/header.md +25 -0
- package/docs/customization/theming.md +12 -1
- package/docs/features/ai-toolkit.md +119 -0
- package/docs/features/cell-selection.md +131 -0
- package/docs/features/clipboard.md +90 -0
- package/docs/features/editing-reordering.md +50 -0
- package/docs/features/keyboard-navigation.md +25 -2
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +64 -7
- package/lib/ai/coerce.ts +90 -0
- package/lib/ai/columns.ts +90 -0
- package/lib/ai/index.ts +16 -0
- package/lib/ai/schema.ts +214 -0
- package/lib/ai/types.ts +88 -0
- package/lib/ai/validate.ts +286 -0
- package/lib/components/Cell/Cell.css +35 -0
- package/lib/components/Cell/Cell.tsx +25 -13
- package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
- package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
- package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +123 -3
- package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
- package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
- package/lib/components/FilterPanel/FilterPanel.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +5 -3
- package/lib/components/Header/Header.css +12 -0
- package/lib/components/Header/Header.tsx +17 -4
- package/lib/components/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Row/Row.tsx +38 -1
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/Toolbar/GridToolbar.tsx +10 -4
- package/lib/components/ui/Checkbox.tsx +26 -10
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +14 -1
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridCellSelection.ts +631 -0
- package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
- package/lib/hooks/core/useGridClipboardApi.ts +8 -2
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridControlledState.ts +38 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +8 -4
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useAggregation.ts +8 -6
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridClipboard.ts +47 -2
- package/lib/hooks/features/useGridEditing.ts +39 -51
- package/lib/index.ts +16 -0
- package/lib/styles/base/variables.css +6 -1
- package/lib/styles/opengridx.css +3 -1
- package/lib/theme/DataGridThemeProvider.tsx +4 -0
- package/lib/theme/presets.ts +8 -0
- package/lib/theme/types.ts +4 -0
- package/lib/types/index.ts +254 -0
- package/lib/utils/attempt.ts +14 -0
- package/lib/utils/cellSelection.ts +238 -0
- package/lib/utils/editing/commit.ts +69 -0
- package/lib/utils/editing/targets.ts +109 -0
- package/lib/utils/headerWrap.ts +23 -0
- package/lib/utils/parsing.ts +252 -0
- package/llms.txt +71 -2
- 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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
<
|
|
39
|
+
<Input
|
|
38
40
|
ref={ref}
|
|
41
|
+
variant="cell"
|
|
39
42
|
type="text"
|
|
40
|
-
|
|
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
|
-
<
|
|
60
|
+
<Input
|
|
58
61
|
ref={ref}
|
|
62
|
+
variant="cell"
|
|
59
63
|
type="number"
|
|
60
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
205
|
+
<Input
|
|
200
206
|
ref={ref}
|
|
207
|
+
variant="cell"
|
|
201
208
|
type="date"
|
|
202
|
-
|
|
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;
|