@opencorestack/opengridx 3.3.0 → 3.5.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 (101) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +10 -1
  3. package/dist/ai.cjs +2 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +366 -0
  6. package/dist/ai.es.js +2 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +396 -6
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +8029 -6090
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -14
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +182 -4
  15. package/docs/README.md +5 -1
  16. package/docs/architecture/datagrid-orchestration.md +19 -5
  17. package/docs/components/filter-panel.md +1 -0
  18. package/docs/components/header.md +4 -1
  19. package/docs/components/toolbar.md +7 -0
  20. package/docs/customization/theming.md +12 -1
  21. package/docs/features/ai-toolkit.md +213 -0
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/clipboard.md +88 -0
  24. package/docs/features/editing-reordering.md +50 -0
  25. package/docs/features/fill-handle.md +125 -0
  26. package/docs/features/filtering.md +3 -0
  27. package/docs/features/header-filters.md +138 -0
  28. package/docs/features/keyboard-navigation.md +31 -2
  29. package/docs/features/undo-redo.md +79 -0
  30. package/docs/roadmap.md +3 -4
  31. package/lib/ai/agentTools.ts +160 -0
  32. package/lib/ai/coerce.ts +90 -0
  33. package/lib/ai/columns.ts +90 -0
  34. package/lib/ai/index.ts +33 -0
  35. package/lib/ai/promptHandler.ts +109 -0
  36. package/lib/ai/schema.ts +214 -0
  37. package/lib/ai/types.ts +152 -0
  38. package/lib/ai/validate.ts +296 -0
  39. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  40. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  41. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  42. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  43. package/lib/components/Cell/Cell.css +33 -0
  44. package/lib/components/Cell/Cell.tsx +6 -2
  45. package/lib/components/Cell/GridEditInputCell.css +12 -4
  46. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  47. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  48. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  49. package/lib/components/DataGrid/DataGrid.tsx +138 -5
  50. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  51. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  52. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  53. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  54. package/lib/components/FilterPanel/FilterPanel.tsx +5 -11
  55. package/lib/components/Header/Header.tsx +44 -23
  56. package/lib/components/Header/headerColumnStyle.ts +28 -0
  57. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  58. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  59. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  60. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  61. package/lib/components/ListView/ListView.css +2 -4
  62. package/lib/components/ListView/ListViewRow.tsx +2 -2
  63. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  64. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  65. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  66. package/lib/components/ui/Checkbox.tsx +16 -5
  67. package/lib/components/ui/Input.css +43 -0
  68. package/lib/components/ui/Input.tsx +24 -5
  69. package/lib/hooks/core/gridApiPlaceholder.ts +16 -1
  70. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  71. package/lib/hooks/core/useDataGrid.ts +27 -0
  72. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  73. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  74. package/lib/hooks/core/useGridCellSelection.ts +107 -13
  75. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  76. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  77. package/lib/hooks/core/useGridControlledState.ts +27 -0
  78. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  79. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  80. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  81. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  82. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  83. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  84. package/lib/hooks/features/useGridEditing.ts +24 -45
  85. package/lib/index.ts +10 -0
  86. package/lib/styles/opengridx.css +4 -1
  87. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  88. package/lib/theme/types.ts +2 -0
  89. package/lib/types/index.ts +343 -1
  90. package/lib/utils/aiAssistant.ts +205 -0
  91. package/lib/utils/aiBrand.ts +17 -0
  92. package/lib/utils/cellSelection.ts +25 -5
  93. package/lib/utils/editing/commit.ts +69 -0
  94. package/lib/utils/editing/fill.ts +238 -0
  95. package/lib/utils/editing/targets.ts +109 -0
  96. package/lib/utils/filtering/index.ts +14 -0
  97. package/lib/utils/focus/index.ts +9 -3
  98. package/lib/utils/headerFilters.ts +189 -0
  99. package/lib/utils/parsing.ts +252 -0
  100. package/llms.txt +97 -3
  101. package/package.json +8 -2
@@ -0,0 +1,189 @@
1
+ import type { GridColDef, GridFilterGroup, GridFilterItem, GridFilterModel, GridFilterOperator } from '../types';
2
+ import { NO_VALUE_OPERATORS, getOperatorsForType, isEmptyFilterValue } from './filtering';
3
+ import { isSystemField } from './focus';
4
+
5
+ /** Prefix of the ids of the filter items the header filter row owns: `header:<field>`. */
6
+ export const HEADER_FILTER_ID_PREFIX = 'header:';
7
+
8
+ /** Default height of the header filter row, in pixels. */
9
+ export const DEFAULT_HEADER_FILTER_HEIGHT = 40;
10
+
11
+ /** Debounce for typed values, the same as the filter panel's. */
12
+ export const HEADER_FILTER_DEBOUNCE_MS = 300;
13
+
14
+ /** The synthetic row-grouping column; it has no header filter. */
15
+ const GROUPING_FIELD = '__group__';
16
+
17
+ type FilterEntry = GridFilterItem | GridFilterGroup;
18
+
19
+ const NO_ENTRIES: FilterEntry[] = [];
20
+
21
+ export function getHeaderFilterItemId(field: string): string {
22
+ return `${HEADER_FILTER_ID_PREFIX}${field}`;
23
+ }
24
+
25
+ function isFilterGroup(entry: FilterEntry): entry is GridFilterGroup {
26
+ return 'items' in entry && Array.isArray(entry.items);
27
+ }
28
+
29
+ /** Whether a column gets a control in the header filter row. */
30
+ export function hasHeaderFilter(col: Pick<GridColDef, 'field' | 'type' | 'filterable' | 'headerFilter' | 'isSpacer'>): boolean {
31
+ if (col.isSpacer || isSystemField(col.field) || col.field === GROUPING_FIELD) return false;
32
+ return col.headerFilter !== false && col.filterable !== false && col.type !== 'image';
33
+ }
34
+
35
+ /** The operator a new header filter starts with: `headerFilterOperator`, else the default for the type. */
36
+ export function getHeaderFilterDefaultOperator(col: Pick<GridColDef, 'type' | 'headerFilterOperator'>): GridFilterOperator {
37
+ if (col.headerFilterOperator) return col.headerFilterOperator;
38
+ switch (col.type) {
39
+ case 'number': return '=';
40
+ case 'date':
41
+ case 'boolean':
42
+ case 'singleSelect':
43
+ return 'is';
44
+ default:
45
+ return 'contains';
46
+ }
47
+ }
48
+
49
+ /** The operators the operator menu offers: the type's operators, with the starting one first if the type does not list it. */
50
+ export function getHeaderFilterOperators(col: Pick<GridColDef, 'type' | 'headerFilterOperator'>): GridFilterOperator[] {
51
+ const operators = getOperatorsForType(col.type);
52
+ const start = getHeaderFilterDefaultOperator(col);
53
+ return operators.includes(start) ? operators : [start, ...operators];
54
+ }
55
+
56
+ /**
57
+ * What a header filter cell shows:
58
+ * - `editable`: the cell owns `item` (the root-level item `header:<field>`), or there is none yet;
59
+ * - `custom`: the model holds a condition on the field the row cannot show (root `logicOperator`
60
+ * `'or'`, another root item on the field, the field inside a nested group). The cell is read-only.
61
+ */
62
+ export type GridHeaderFilterState =
63
+ | { kind: 'editable'; item: GridFilterItem | undefined }
64
+ | { kind: 'custom' };
65
+
66
+ function groupMentionsField(group: GridFilterGroup, field: string): boolean {
67
+ return group.items.some(entry => (isFilterGroup(entry) ? groupMentionsField(entry, field) : entry.field === field));
68
+ }
69
+
70
+ /** Whether a model's root combines its items with OR (with something to combine). */
71
+ export function isOrRootModel(model: GridFilterModel | null | undefined): boolean {
72
+ return (model?.logicOperator ?? 'and') === 'or' && (model?.items?.length ?? 0) > 0;
73
+ }
74
+
75
+ export function getHeaderFilterState(model: GridFilterModel | null | undefined, field: string): GridHeaderFilterState {
76
+ const items = model?.items ?? NO_ENTRIES;
77
+ if (isOrRootModel(model)) return { kind: 'custom' };
78
+ const ownId = getHeaderFilterItemId(field);
79
+ let own: GridFilterItem | undefined;
80
+ for (const entry of items) {
81
+ if (isFilterGroup(entry)) {
82
+ if (groupMentionsField(entry, field)) return { kind: 'custom' };
83
+ continue;
84
+ }
85
+ if (entry.field !== field) continue;
86
+ if (entry.id !== ownId || own) return { kind: 'custom' };
87
+ own = entry;
88
+ }
89
+ return { kind: 'editable', item: own };
90
+ }
91
+
92
+ /**
93
+ * The model with the header filter item for `field` replaced by `item` (added at the end when
94
+ * absent, removed when `item` is null). Every other entry, the logic operator and the quick filter
95
+ * pass through untouched. Returns `model` itself when the cell cannot own the field's filter.
96
+ */
97
+ export function setHeaderFilterItem(model: GridFilterModel | null | undefined, field: string, item: GridFilterItem | null): GridFilterModel {
98
+ const base: GridFilterModel = model ?? { items: [] };
99
+ if (getHeaderFilterState(base, field).kind !== 'editable') return base;
100
+ const ownId = getHeaderFilterItemId(field);
101
+ const items = base.items ?? NO_ENTRIES;
102
+ const index = items.findIndex(entry => !isFilterGroup(entry) && entry.id === ownId);
103
+ const nextItem: GridFilterItem | null = item ? { ...item, id: ownId, field } : null;
104
+ let next: FilterEntry[];
105
+ if (index === -1) {
106
+ if (!nextItem) return base;
107
+ // With no items the logic operator means nothing yet; a leftover 'or' would make the
108
+ // next column's cell read the model as custom, so it starts as 'and'.
109
+ if (items.length === 0 && base.logicOperator === 'or') return { ...base, logicOperator: 'and', items: [nextItem] };
110
+ next = [...items, nextItem];
111
+ } else if (nextItem) {
112
+ next = items.map((entry, i) => (i === index ? nextItem : entry));
113
+ } else {
114
+ next = items.filter((_, i) => i !== index);
115
+ }
116
+ return { ...base, items: next };
117
+ }
118
+
119
+ /**
120
+ * The item a header filter cell stores for an operator and value, or null when it should hold no
121
+ * item: an empty value with the starting operator. An empty value with another operator keeps the
122
+ * item, so the chosen operator stays while the value is retyped (an empty value never filters).
123
+ */
124
+ export function buildHeaderFilterItem(
125
+ col: Pick<GridColDef, 'field' | 'type' | 'headerFilterOperator'>,
126
+ operator: GridFilterOperator,
127
+ value: unknown,
128
+ ): GridFilterItem | null {
129
+ const noValue = NO_VALUE_OPERATORS.has(operator);
130
+ if (!noValue && isEmptyFilterValue(value) && operator === getHeaderFilterDefaultOperator(col)) return null;
131
+ return { id: getHeaderFilterItemId(col.field), field: col.field, operator, value: noValue ? undefined : value };
132
+ }
133
+
134
+ /** Carries a value over to a newly picked operator: lists for isAnyOf, scalars for the rest. */
135
+ export function convertHeaderFilterValue(value: unknown, operator: GridFilterOperator): unknown {
136
+ if (NO_VALUE_OPERATORS.has(operator)) return undefined;
137
+ if (operator === 'isAnyOf') {
138
+ if (value == null || value === '') return [];
139
+ return Array.isArray(value) ? value : [value];
140
+ }
141
+ if (Array.isArray(value)) return value.length > 0 ? value[0] : '';
142
+ return value ?? '';
143
+ }
144
+
145
+ /** Short label shown on the operator button. */
146
+ export const HEADER_FILTER_OPERATOR_SYMBOLS: Record<GridFilterOperator, string> = {
147
+ contains: '∋',
148
+ equals: '=',
149
+ startsWith: 'a…',
150
+ endsWith: '…a',
151
+ isEmpty: '∅',
152
+ isNotEmpty: '!∅',
153
+ isAnyOf: '∈',
154
+ '=': '=',
155
+ '!=': '≠',
156
+ '>': '>',
157
+ '>=': '≥',
158
+ '<': '<',
159
+ '<=': '≤',
160
+ is: '=',
161
+ not: '≠',
162
+ after: '>',
163
+ onOrAfter: '≥',
164
+ before: '<',
165
+ onOrBefore: '≤',
166
+ };
167
+
168
+ /** Readable operator names for the operator menu and labels. */
169
+ export const HEADER_FILTER_OPERATOR_LABELS: Record<GridFilterOperator, string> = {
170
+ contains: 'Contains',
171
+ equals: 'Equals',
172
+ startsWith: 'Starts with',
173
+ endsWith: 'Ends with',
174
+ isEmpty: 'Is empty',
175
+ isNotEmpty: 'Is not empty',
176
+ isAnyOf: 'Is any of',
177
+ '=': 'Equals',
178
+ '!=': 'Does not equal',
179
+ '>': 'Greater than',
180
+ '>=': 'Greater than or equal',
181
+ '<': 'Less than',
182
+ '<=': 'Less than or equal',
183
+ is: 'Is',
184
+ not: 'Is not',
185
+ after: 'After',
186
+ onOrAfter: 'On or after',
187
+ before: 'Before',
188
+ onOrBefore: 'On or before',
189
+ };
@@ -0,0 +1,252 @@
1
+ import type { GridColDef, GridValidRowModel } from '../types';
2
+ import { toDate } from './values';
3
+
4
+ /**
5
+ * Text → typed values: the TSV reader clipboard paste uses, and the per-column-type parsers paste,
6
+ * `GridColDef.valueParser` defaults and the `/ai` validator share. Pure and React-free.
7
+ *
8
+ * Every pattern here is linear (CodeQL scans every push): no adjacent quantifiers that can match the
9
+ * same characters.
10
+ */
11
+
12
+ /** A parsed value, or `{ ok: false }` when the text does not fit the column type. */
13
+ export type GridParsedValue = { ok: true; value: unknown } | { ok: false };
14
+
15
+ const INVALID: GridParsedValue = { ok: false };
16
+ const valid = (value: unknown): GridParsedValue => ({ ok: true, value });
17
+
18
+ /**
19
+ * Splits tab-separated text the way Excel, Google Sheets and LibreOffice write it: tabs between
20
+ * columns, `\r\n`, `\n` or `\r` between rows. A field that starts with a double quote is quoted: it
21
+ * may hold tabs, line breaks and doubled quotes (`""` → `"`), the reverse of `escapeTsvField`. A
22
+ * quote inside an unquoted field is kept as is. A trailing empty line is ignored, so `"a\tb\n"` is one
23
+ * row. Empty text gives no rows.
24
+ */
25
+ export function parseTsv(text: string): string[][] {
26
+ const rows: string[][] = [];
27
+ if (text === '') return rows;
28
+ let row: string[] = [];
29
+ let field = '';
30
+ let i = 0;
31
+ const n = text.length;
32
+ let fieldStart = true;
33
+ while (i < n) {
34
+ const ch = text[i];
35
+ if (fieldStart && ch === '"') {
36
+ // A quoted field: read to the closing quote, `""` being one quote.
37
+ let j = i + 1;
38
+ let value = '';
39
+ let closed = false;
40
+ while (j < n) {
41
+ const q = text[j];
42
+ if (q === '"') {
43
+ if (text[j + 1] === '"') {
44
+ value += '"';
45
+ j += 2;
46
+ continue;
47
+ }
48
+ closed = true;
49
+ j += 1;
50
+ break;
51
+ }
52
+ value += q;
53
+ j += 1;
54
+ }
55
+ if (!closed) {
56
+ // No closing quote: not a quoted field after all, read the quote as text.
57
+ field += ch;
58
+ i += 1;
59
+ fieldStart = false;
60
+ continue;
61
+ }
62
+ field += value;
63
+ i = j;
64
+ fieldStart = false;
65
+ continue;
66
+ }
67
+ fieldStart = false;
68
+ if (ch === '\t') {
69
+ row.push(field);
70
+ field = '';
71
+ fieldStart = true;
72
+ i += 1;
73
+ } else if (ch === '\n' || ch === '\r') {
74
+ row.push(field);
75
+ rows.push(row);
76
+ row = [];
77
+ field = '';
78
+ fieldStart = true;
79
+ i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1;
80
+ } else {
81
+ field += ch;
82
+ i += 1;
83
+ }
84
+ }
85
+ // The last line, unless the text ended with a line break (Excel always adds one).
86
+ if (!fieldStart || row.length > 0 || field !== '') {
87
+ row.push(field);
88
+ rows.push(row);
89
+ }
90
+ return rows;
91
+ }
92
+
93
+ const PLAIN_NUMBER = /^\d+(?:\.\d*)?(?:e[+-]?\d+)?$/i;
94
+ const LEADING_DOT_NUMBER = /^\.\d+(?:e[+-]?\d+)?$/i;
95
+ const GROUPED_NUMBER = /^\d{1,3}(?:,\d{3})+(?:\.\d*)?$/;
96
+ // Currency symbols (Unicode Sc) and every kind of space, including the no-break spaces Excel uses
97
+ // as a thousands separator.
98
+ const STRIPPED_CHARS = /[\p{Sc}\s]/gu;
99
+
100
+ /**
101
+ * A number as spreadsheets copy it: `1234.5`, `1,234.5` (comma thousands groups only), `-12`,
102
+ * `(12)` (accounting negative), `12%` (→ 0.12), `1e3`, with currency symbols and spaces stripped.
103
+ * Empty text is `null`. `1,5` is not accepted (it is ambiguous): use a `valueParser` for
104
+ * decimal-comma locales.
105
+ */
106
+ export function parseNumberText(text: string): GridParsedValue {
107
+ let s = text.replace(STRIPPED_CHARS, '');
108
+ if (s === '') return text.trim() === '' ? valid(null) : INVALID;
109
+ let negative = false;
110
+ if (s.startsWith('(') && s.endsWith(')')) {
111
+ negative = true;
112
+ s = s.slice(1, -1);
113
+ }
114
+ if (s.startsWith('-') || s.startsWith('+') || s.startsWith('−')) {
115
+ if (s[0] !== '+') negative = !negative;
116
+ s = s.slice(1);
117
+ }
118
+ let percent = false;
119
+ if (s.endsWith('%')) {
120
+ percent = true;
121
+ s = s.slice(0, -1);
122
+ }
123
+ if (GROUPED_NUMBER.test(s)) s = s.split(',').join('');
124
+ else if (!PLAIN_NUMBER.test(s) && !LEADING_DOT_NUMBER.test(s)) return INVALID;
125
+ let value = Number(s);
126
+ if (!Number.isFinite(value)) return INVALID;
127
+ if (percent) value /= 100;
128
+ if (negative) value = -value;
129
+ return valid(value);
130
+ }
131
+
132
+ const ISO_DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
133
+ const pad2 = (n: number) => String(n).padStart(2, '0');
134
+ const localDay = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
135
+
136
+ /**
137
+ * A date: ISO `2026-10-07` (read as that local calendar day) or anything `Date.parse` accepts. Empty
138
+ * text is `null`. The result takes the kind of `current` (the value the cell holds now), as the date
139
+ * editor does: a timestamp stays a timestamp, a date string stays a `YYYY-MM-DD` string (an ISO
140
+ * date-time string keeps its time part); otherwise it is a `Date`.
141
+ */
142
+ export function parseDateText(text: string, current?: unknown): GridParsedValue {
143
+ const trimmed = text.trim();
144
+ if (trimmed === '') return valid(null);
145
+ const date = toDate(trimmed);
146
+ if (!date) return INVALID;
147
+ if (typeof current === 'number') return valid(date.getTime());
148
+ if (typeof current === 'string') {
149
+ const day = localDay(date);
150
+ return valid(ISO_DATE_PREFIX.test(current) && current.length > 10 ? day + current.slice(10) : day);
151
+ }
152
+ return valid(date);
153
+ }
154
+
155
+ /** Text read as `true` / `false`, besides the grid's own `Yes` / `No` cell labels. Lower case. */
156
+ const TRUE_WORDS = new Set(['true', 'yes', '1']);
157
+ const FALSE_WORDS = new Set(['false', 'no', '0']);
158
+
159
+ export interface GridBooleanLabels {
160
+ /** Text shown for `true` (the grid shows `Yes`). */
161
+ trueLabel?: string;
162
+ /** Text shown for `false` (the grid shows `No`). */
163
+ falseLabel?: string;
164
+ }
165
+
166
+ /**
167
+ * A boolean: `true`/`false`, `yes`/`no` (the grid's own `Yes` / `No` cell labels), `1`/`0` and any
168
+ * `labels` given, case-insensitive. Empty text is `null`.
169
+ */
170
+ export function parseBooleanText(text: string, labels?: GridBooleanLabels): GridParsedValue {
171
+ const s = text.trim().toLowerCase();
172
+ if (s === '') return valid(null);
173
+ if (labels?.trueLabel !== undefined && s === labels.trueLabel.trim().toLowerCase()) return valid(true);
174
+ if (labels?.falseLabel !== undefined && s === labels.falseLabel.trim().toLowerCase()) return valid(false);
175
+ if (TRUE_WORDS.has(s)) return valid(true);
176
+ if (FALSE_WORDS.has(s)) return valid(false);
177
+ return INVALID;
178
+ }
179
+
180
+ type ValueOption = NonNullable<GridColDef['valueOptions']>[number];
181
+
182
+ /**
183
+ * A `singleSelect` value: the option whose value or label matches the text, case-insensitive (values
184
+ * first). Returns the option's own value, so a numeric option stays a number. Empty text is `null`.
185
+ */
186
+ export function parseSingleSelectText(text: string, options: readonly ValueOption[] | undefined): GridParsedValue {
187
+ const s = text.trim();
188
+ if (s === '') return valid(null);
189
+ if (!options || options.length === 0) return INVALID;
190
+ const lower = s.toLowerCase();
191
+ for (const option of options) {
192
+ const value = typeof option === 'object' ? option.value : option;
193
+ if (String(value).toLowerCase() === lower) return valid(value);
194
+ }
195
+ for (const option of options) {
196
+ if (typeof option === 'object' && option.label.toLowerCase() === lower) return valid(option.value);
197
+ }
198
+ return INVALID;
199
+ }
200
+
201
+ type ParserColumn = Pick<GridColDef, 'type' | 'valueOptions'>;
202
+
203
+ export interface GridParseTextOptions {
204
+ /** The value the cell holds now: a parsed date takes its kind (see `parseDateText`). */
205
+ current?: unknown;
206
+ /** Extra boolean labels, besides `Yes` / `No`. */
207
+ booleanLabels?: GridBooleanLabels;
208
+ }
209
+
210
+ /**
211
+ * Parses cell text by the column `type`, without `valueParser`:
212
+ *
213
+ * | type | accepted |
214
+ * | :--- | :--- |
215
+ * | `string` (default), `image` | the text as is (an empty cell is `''`) |
216
+ * | `number` | `parseNumberText` |
217
+ * | `date` | `parseDateText` |
218
+ * | `boolean` | `parseBooleanText` |
219
+ * | `singleSelect` | `parseSingleSelectText` |
220
+ *
221
+ * An empty cell is `null` for every type but `string` / `image`.
222
+ */
223
+ export function parseValueByType(text: string, colDef: ParserColumn | undefined, options?: GridParseTextOptions): GridParsedValue {
224
+ switch (colDef?.type) {
225
+ case 'number': return parseNumberText(text);
226
+ case 'date': return parseDateText(text, options?.current);
227
+ case 'boolean': return parseBooleanText(text, options?.booleanLabels);
228
+ case 'singleSelect': return parseSingleSelectText(text, colDef.valueOptions);
229
+ default: return valid(text);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Parses the text pasted into one cell: the column's `valueParser` when it has one (a throw, or a
235
+ * returned `undefined`, rejects the text), otherwise `parseValueByType`.
236
+ */
237
+ export function parseCellText<R extends GridValidRowModel>(
238
+ text: string,
239
+ colDef: GridColDef<R>,
240
+ row: R,
241
+ options?: GridParseTextOptions,
242
+ ): GridParsedValue {
243
+ const { valueParser } = colDef;
244
+ if (!valueParser) return parseValueByType(text, colDef as unknown as ParserColumn, options);
245
+ let value: unknown;
246
+ try {
247
+ value = valueParser(text, { row, field: colDef.field, colDef });
248
+ } catch {
249
+ return INVALID;
250
+ }
251
+ return value === undefined ? INVALID : valid(value);
252
+ }
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenGridX — AI Agent Context
2
2
  # Package: @opencorestack/opengridx
3
- # Version: 3.3.0
3
+ # Version: 3.5.0
4
4
  # Docs: https://opencorestack.github.io/OpenGridX/
5
5
  # This file is machine-readable. AI developers: start here.
6
6
 
@@ -97,6 +97,14 @@ export default function App() {
97
97
  | cellSelectionModel | GridCellSelectionModel | Controlled range: readonly { anchor: { id, field }, head: { id, field } }[] (0 or 1 range). Corners are ids/fields: the range follows sorting; cleared ('dataChange') when a corner is no longer displayed |
98
98
  | onCellSelectionModelChange | (model, { reason }) => void | reason: 'pointer' | 'keyboard' | 'selectAll' | 'api' | 'clear' | 'dataChange' |
99
99
  | showCellSelectionStats | boolean | Status bar: Count (non-empty), Sum and Average (numeric) of the range; slots.cellSelectionStats replaces it |
100
+ | disableClipboardPaste | boolean (3.4.0+) | With cellSelection, Ctrl/Cmd+V pastes TSV (Excel/Sheets) into editable cells: one value fills a range; a block goes from the range's top-left (or focused) cell, cut to the range and grid edges; no rows created. Parsed per column type, then valueSetter + processRowUpdate once per row. true = shortcut off (apiRef.pasteText still works) |
101
+ | onClipboardPaste | (result) => void (3.4.0+) | After every paste: { updated: id[], failed: {id, error}[], skipped: {id, field, reason: 'notEditable'/'invalidValue'}[], text } |
102
+ | onBeforeClipboardPaste | ({ text, anchor }) => boolean / string | Return false to cancel, a string to paste instead; a throw cancels |
103
+ | disableRangeClear | boolean (3.4.0+) | With cellSelection, Delete/Backspace on a multi-cell range empties editable cells ('' strings, null otherwise) as one edit; true = off |
104
+ | undoRedo | boolean / { limit } (3.4.0+) | Undo/redo of edits, pastes, range clears (default off, 100 actions). Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y on a cell with no editor open. Recorded only after processRowUpdate succeeds; replayed through valueSetter + processRowUpdate; cells changed since are skipped ('changed', compared by value), removed rows ('missing') |
105
+ | onHistoryChange | ({ canUndo, canRedo, size }) => void | History change (size = undoable actions) |
106
+ | disableFillHandle | boolean (3.5.0+) | With cellSelection, the range's bottom-right cell has a fill handle: drag down/up/right/left (one direction; auto-scroll; touch) fills the new cells from the range — numbers and dates with an exactly equal step continue (2,4 → 6,8; 15 Jan,15 Feb → 15 Mar; 5 Oct,6 Oct → 7 Oct), anything else repeats, one cell copies; Alt at release copies; Escape cancels. Ctrl/Cmd+D fills down from the range's top row, Ctrl/Cmd+R right from its left column. valueSetter + processRowUpdate once per row, one undo step, non-editable/synthetic cells skipped ('notEditable'). true = no handle, keys left to the browser |
107
+ | onFill | (result) => void (3.5.0+) | After every fill: { updated, failed, skipped, direction: 'down'/'up'/'right'/'left' } |
100
108
 
101
109
  ### Sorting
102
110
  | Prop | Type | Description |
@@ -112,6 +120,8 @@ export default function App() {
112
120
  | filterModel | GridFilterModel | Controlled filter state (optional: the grid keeps its own filter model otherwise) |
113
121
  | onFilterModelChange | (model: GridFilterModel) => void | Filter change callback |
114
122
  | filterMode | 'client' / 'server' | 'server': rows are shown unfiltered as given |
123
+ | headerFilters | boolean (default false) | Filter row under the column headers (3.5.0+): Input for string/number/date, Any/Yes/No select for boolean, valueOptions select for singleSelect, operator menu, clear button; 300 ms debounce. Each cell owns the root filterModel item with id 'header:<field>'; root logicOperator 'or', the field inside a group or another root item on the field shows a read-only "Custom filter" (opens the GridToolbar filter panel). Not rendered in pivot mode |
124
+ | headerFilterHeight | number (default 40) | Height of the header filter row (--ogx-header-filter-height) |
115
125
 
116
126
  ### Pagination
117
127
  | Prop | Type | Description |
@@ -163,7 +173,8 @@ export default function App() {
163
173
  ### Grouping & Aggregation
164
174
  | Prop | Type | Description |
165
175
  |------------------------|---------------------------|------------------------------------------|
166
- | rowGroupingModel | string[] | Fields to group by (one nesting level per field). Disables pagination. groupable:false fields are skipped |
176
+ | rowGroupingModel | string[] | Fields to group by (one nesting level per field). Disables pagination. groupable:false fields are skipped. Passing it controls grouping; without it the grid keeps its own model (3.5.0+: apiRef.setRowGroupingModel, AI assistant) |
177
+ | onRowGroupingModelChange | (model: string[]) => void | Grouping changed through apiRef.setRowGroupingModel or the AI assistant (3.5.0+) |
167
178
  | aggregationModel | GridAggregationModel | {field: 'sum'/'avg'/'min'/'max'/'count'/'unique'} |
168
179
  | onAggregationModelChange | (model) => void | Aggregation model changed (toolbar Summaries panel) |
169
180
  | groupingColDef | Partial<GridColDef<R>> | Adds a dedicated `__group__` column pinned left (rowGroupingModel or treeData); no `field` needed; never exported |
@@ -245,6 +256,8 @@ There are no getRowClassName / getCellClassName / theme props: use GridColDef.ce
245
256
  | renderEditCell | (params: GridRenderEditCellParams<R>) => ReactNode | Custom editor: call params.onValueChange(v), params.onCommit(), params.onCancel() |
246
257
  | valueGetter | ({row, field, value}) => unknown | Computed value; used by sort, filter, quick search, aggregation, copy, export. Must be pure (quick filter caches per row object); a throw reads as undefined + dev warning |
247
258
  | valueSetter | ({value, row, field}) => R | Write an edited value back onto the row (editable valueGetter columns) |
259
+ | valueParser | (text, { row, field, colDef }) => unknown | Paste parser for this column (3.4.0+), e.g. "1.234,5" → 1234.5. Throw/undefined skips the cell ('invalidValue'). Default by type: number "1,234.5"/"12%"/"$5"; date ISO or Date.parse (keeps the cell's kind); boolean true/false/yes/no/1/0; singleSelect value or label; empty → null ('' for strings) |
260
+ | fillValue | ({ value, sourceValues, index, direction, copy, row, id, field, colDef }) => unknown | Fill handle (3.5.0+): what a fill writes into this column's cell. value = the grid's choice (series / pattern / copy); return it to keep it. Throw skips the cell ('invalidValue') |
248
261
  | valueFormatter | ({value, row, field}) => string | Format display value (value is `unknown`: cast before calling methods) |
249
262
  | cellClassName | string / (params) => string | CSS class on cells |
250
263
  | headerClassName | string | CSS class on header cell |
@@ -256,6 +269,8 @@ There are no getRowClassName / getCellClassName / theme props: use GridColDef.ce
256
269
  | sortable | boolean (default true) | Enable sorting |
257
270
  | sortComparator | (v1, v2, params1, params2) => number | Custom ascending comparator (3.1.0+). v1/v2 are valueGetter values incl. null/undefined; params { id, field, row, value }. Negated for desc; multi-sort tie-break chain; groups by grouping value; tree siblings; pivot row labels. Throw/NaN = 0. Ignored in server sortingMode |
258
271
  | filterable | boolean (default true) | Enable filtering and quick search |
272
+ | headerFilter | boolean (default true) | false: empty cell in the header filter row (3.5.0+) |
273
+ | headerFilterOperator | GridFilterOperator | Starting header filter operator (default: contains / = / is by type) (3.5.0+) |
259
274
  | resizable | boolean (default true) | Enable resize handle (mouse, touch, Alt+Arrow on header); double-click or Enter on the handle auto-sizes to content (3.1.0+) |
260
275
  | hideable | boolean (default true) | Allow hiding via toolbar / column menu |
261
276
  | pinnable | boolean (default true) | Allow pinning via column menu (does not affect drag-reorder) |
@@ -265,6 +280,7 @@ There are no getRowClassName / getCellClassName / theme props: use GridColDef.ce
265
280
  | groupingValueFormatter | ({field, value}) => string | Group row label (default "field: value") |
266
281
  | aggregable | boolean | false = no aggregation. Summaries panel offers number columns + aggregable: true |
267
282
  | availableAggregationFunctions | string[] | Restrict aggregation functions for this column |
283
+ | aiExamples | unknown[] | Example values put into getGridAiSchema's schema (3.4.0+). SENT TO THE MODEL: real data, opt-in. Primitives only, max 10. Unused by the grid |
268
284
 
269
285
  ---
270
286
 
@@ -298,6 +314,15 @@ apiRef.current.getSortModel() // → GridSortItem[]
298
314
  apiRef.current.setFilterModel(model)
299
315
  apiRef.current.getFilterModel() // → GridFilterModel
300
316
 
317
+ // Model setters (3.5.0+): each fires its change callback, like the UI
318
+ apiRef.current.setSortModel(model)
319
+ apiRef.current.setRowGroupingModel(fields)
320
+ apiRef.current.setAggregationModel(model)
321
+ apiRef.current.setColumnVisibilityModel(model)
322
+ apiRef.current.setPivotModel(model)
323
+ apiRef.current.getGridAiState() // → { filterModel, sortModel, rowGroupingModel, aggregationModel, pivotModel, columnVisibilityModel }
324
+ apiRef.current.openAiAssistant() // opens the aiAssistant panel (no-op without aiAssistant); closeAiAssistant()
325
+
301
326
  // Pagination
302
327
  apiRef.current.setPage(page)
303
328
  apiRef.current.setPageSize(size)
@@ -321,6 +346,14 @@ apiRef.current.getSelectedCells() // → { id, field, value }[]; synthetic
321
346
  apiRef.current.copySelectedCells() // → Promise<void>; TSV, no header line
322
347
  apiRef.current.clearCellSelection()
323
348
 
349
+ // Paste and undo/redo (3.4.0+)
350
+ apiRef.current.pasteText(text, anchor?) // → Promise<GridBatchEditResult>; anchor default: range top-left, else focused cell
351
+ apiRef.current.undo() // → Promise<GridBatchEditResult> (undoRedo)
352
+ apiRef.current.redo()
353
+ apiRef.current.canUndo() // → boolean
354
+ apiRef.current.canRedo()
355
+ apiRef.current.clearHistory()
356
+
324
357
  // Aggregation
325
358
  apiRef.current.getAggregationResult() // → Record<string, unknown> | null (null in pivot mode)
326
359
  apiRef.current.getAggregationModel() // → GridAggregationModel | null
@@ -356,6 +389,22 @@ const processRowUpdate = (newRow: Person) => {
356
389
 
357
390
  Double-click or Enter opens the editor; Enter / Tab / blur commits; Escape cancels. An edit whose row scrolls, pages or filters away is committed, not dropped.
358
391
 
392
+ Custom editors (3.3.1+): build them on the exported `Input` with `variant="cell"` — it fills the cell with no border/background/focus ring, like the built-in editors. `ref` reaches the `<input>` (forwardRef, React 18 + 19), `inputClassName` styles the `<input>` (`className` the wrapper), `startAdornment` / `endAdornment` add a unit, `error` adds an inset error edge and `aria-invalid`. Stop propagation of the Enter/Escape you handle.
393
+
394
+ ```tsx
395
+ import { Input } from '@opencorestack/opengridx';
396
+ function PriceEditor({ value, onValueChange, onCommit, onCancel }: GridRenderEditCellParams) {
397
+ const [text, setText] = useState(String(value ?? ''));
398
+ const ref = useRef<HTMLInputElement>(null);
399
+ useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
400
+ const valid = text.trim() !== '' && Number(text) >= 0;
401
+ return <Input ref={ref} variant="cell" startAdornment="$" error={!valid} value={text}
402
+ onChange={e => { setText(e.target.value); const n = Number(e.target.value); if (e.target.value.trim() !== '' && n >= 0) onValueChange(n); }}
403
+ onKeyDown={e => { if (e.key === 'Enter') { e.stopPropagation(); if (valid) onCommit(); } if (e.key === 'Escape') { e.stopPropagation(); onCancel(); } }}
404
+ onBlur={() => (valid ? onCommit() : onCancel())} />;
405
+ }
406
+ ```
407
+
359
408
  ---
360
409
 
361
410
  ## SERVER-SIDE DATA SOURCE
@@ -524,6 +573,48 @@ await printGrid(rows, columns, 'My Report Title');
524
573
 
525
574
  ---
526
575
 
576
+ ## AI TOOLKIT (@opencorestack/opengridx/ai, 3.4.0+)
577
+
578
+ Separate entry point: no React, no dependencies, ~6.7 kB gzip with the 3.5.0 additions; never loaded by the core import. OpenGridX calls no AI service: the app sends { prompt, schema, currentState } to its own model (structured output) and validates the reply.
579
+
580
+ ```ts
581
+ import { getGridAiSchema, validateGridAiState } from '@opencorestack/opengridx/ai';
582
+
583
+ const schema = getGridAiSchema(columns, { include: ['filter', 'sort', 'grouping', 'aggregation'] });
584
+ const reply = await fetch('/api/grid-assistant', { method: 'POST', body: JSON.stringify({ prompt, schema, currentState }) });
585
+ const { state, errors } = validateGridAiState(await reply.text(), columns);
586
+ if (state.filterModel) setFilterModel(state.filterModel); // apply only the parts present
587
+ if (state.sortModel) setSortModel(state.sortModel);
588
+ ```
589
+
590
+ - getGridAiSchema(columns, { include?, exclude?, descriptions = true, examples = true }) → JSON Schema draft 2020-12 with `schemaVersion: 1`. Parts (option name → key): filter → filterModel (items as oneOf per field, AND/OR groups ≤ 3 levels, quickFilterValues), sort → sortModel, grouping → rowGroupingModel, aggregation → aggregationModel, pivot → pivotModel, columnVisibility → columnVisibilityModel. Fields from filterable / sortable / groupable / hideable / aggregable !== false; operators = the column type's filter operators; aggregation: number (or aggregable: true) columns get sum/avg/count/min/max/unique, others count, limited by availableAggregationFunctions; singleSelect valueOptions become enums. Fields starting with `__` are skipped. Deterministic; reads no rows.
591
+ - validateGridAiState(json | jsonString, columns, { include?, exclude? }) → { state: GridAiState, errors: { path, message }[] }. Drops unknown parts, fields, disallowed fields, wrong operators, enum values outside the list; coerces values like clipboard paste ("1,200"/"$1,200" → 1200, "12%" → 0.12, yes/no/1/0 → boolean, dates → "YYYY-MM-DD", singleSelect by value or label, case-insensitive); removes empty groups; lists capped at 200 entries, errors at 100. Never throws; never reads `__proto__`.
592
+ - Privacy: no row data by default; `aiExamples` and `valueOptions` ARE sent; always apply `state`, never the raw reply.
593
+ - Types (from /ai): GridAiColumn, GridAiPart, GridAiSchema, GridAiSchemaOptions, GridAiState, GridAiValidateOptions, GridAiValidationError, GridAiValidationResult.
594
+
595
+ ### AI toolkit part 2 (3.5.0+): Ask AI panel and agent tools
596
+
597
+ ```tsx
598
+ import { createGridAiPromptHandler, createGridAgentTools } from '@opencorestack/opengridx/ai';
599
+
600
+ const onPrompt = createGridAiPromptHandler({
601
+ columns,
602
+ callModel: async (request, { signal }) => (await fetch('/api/grid-ai', { method: 'POST', body: JSON.stringify(request), signal })).json(),
603
+ });
604
+ <DataGrid columns={columns} rows={rows} slots={{ toolbar: GridToolbar }} aiAssistant={{ onPrompt, suggestions: ['Top 10 by revenue'] }} />
605
+
606
+ const tools = createGridAgentTools(apiRef, columns); // [{ name, description, inputSchema, execute }]
607
+ ```
608
+
609
+ - `aiAssistant` prop: { onPrompt(prompt, { currentState, history, signal }) => Promise<{ state, errors, message? }>, placeholder?, suggestions?, voice? = true, parts? }. GridToolbar shows an "Ask AI" button (localeText.aiAssistantButton); apiRef.openAiAssistant() without a toolbar; slots.aiAssistantPanel replaces the panel (GridAiAssistantPanelProps). Events: onAiAssistantApply(result), onAiAssistantError(error).
610
+ - BRAND RULE: the grid applies a result only if `result.state` carries Symbol.for('opengridx.ai.validated'), set (non-enumerable) by validateGridAiState on the result and its state. Unbranded → not applied, "The reply was not validated" + dev warning. `{ ...result, message }` keeps the brand; `{ ...result.state }` loses it.
611
+ - Applying: immediately, one undoable step (Undo restores the models from before the reply; separate from the cell-edit undoRedo history). Each change is a removable chip; removing one re-applies the reply without it. Parts replace the grid's models, except columnVisibilityModel (merged). Parts outside `parts`, and parts the validator emptied (all entries dropped), are not applied. Voice: mic button only where SpeechRecognition / webkitSpeechRecognition exists; the microphone is requested only on click. Panel: role=dialog "Ask AI", Escape closes and returns focus, status in the grid's live region; Stop aborts `signal`.
612
+ - createGridAiPromptHandler({ columns, callModel, parts?, schemaOptions? }): schema built once; callModel({ prompt, schema, currentState, history }, { signal }); reply = state object | JSON string | text with a ```json fence (surrounding text / a `message` key → result.message; plain text → message only); validated + branded. callModel throw → { state: {}, errors: [{ path: '', message }] }; abort → rejects AbortError.
613
+ - createGridAgentTools(apiRef, columns, { allowRowAccess?, fields?, maxRows? = 20 }): get_grid_state, set_filter, set_sort, set_grouping, set_aggregation, set_column_visibility (merged), clear_filters, and get_row_summary ONLY with allowRowAccess: true (the one tool that sends rows). Inputs validated with validateGridAiState; any error → nothing applied, { ok: false, errors }. execute never throws. Works with the Vercel AI SDK, CopilotKit, OpenAI / Anthropic tool calling, WebMCP.
614
+ - More types (from /ai): GridAiPromptContext, GridAiPromptResult, GridAiHistoryEntry, GridAiAssistantOptions, GridAiAssistantPanelProps, GridAiChip, GridAiModelRequest, GridAiPromptHandlerOptions, GridAgentTool, GridAgentToolResult, GridAgentToolsOptions, GridAgentApi. /ai is ~6.7 kB gzip (budget 8 kB).
615
+
616
+ ---
617
+
527
618
  ## CSS CUSTOMIZATION
528
619
 
529
620
  All CSS variables use the `--ogx-` prefix and are declared on `:root` by the stylesheet. Override them on a wrapper (or use DataGridThemeProvider, which sets the same variables):
@@ -545,8 +636,11 @@ Class names use the `ogx__` BEM prefix, e.g.:
545
636
  - `ogx__row`, `ogx__row--selected`, `ogx__row--even`, `ogx__row--odd`
546
637
  - `ogx__cell`, `ogx__cell--focused`, `ogx__cell--editing`
547
638
  - Cell ranges (3.3.0+): `ogx__cell--range`, `ogx__cell--range-top|bottom|left|right` (inset box-shadow outline), `ogx--range-dragging` on the root during a drag; variables `--ogx-range-background`, `--ogx-range-border` (theme keys `grid.rangeBackground`, `grid.rangeBorder`)
639
+ - Fill handle (3.5.0+): `ogx__cell-fill-handle` (24px hit area in the corner cell, visible square is `::after`), variable `--ogx-fill-handle-color` (defaults to `--ogx-range-border`; theme key `grid.fillHandleColor`)
548
640
  - `ogx__header-cell`, `ogx__header-cell--sorted`, `ogx__header-cell--wrap` (wrapHeaderText, 3.3.0+)
641
+ - Header filter row (3.5.0+): `ogx__header-filter-row`, `ogx__header-filter-cell` (`--focused`, `--active`, `--custom`, `--empty`, `--system`, `--pinned*`), `ogx__header-filter-operator`, `ogx__header-filter-menu`, `ogx__header-filter-input-wrapper` / `ogx__header-filter-input`, `ogx__header-filter-select`, `ogx__header-filter-clear`, `ogx__header-filter-custom`; height variable `--ogx-header-filter-height`
549
642
  - Sticky wrappers: `ogx__sticky-top` (header + top-pinned rows), `ogx__sticky-bottom` (bottom-pinned rows + aggregation footer)
643
+ - Form controls (3.3.1+): the toolbar search, column-panel search, filter value box and text/number/date editors render through `Input`: the `<input>` keeps its class (`ogx-global-search__input`, `ogx-column-visibility-panel__search-input`, `ogx-filter__value-input`, `ogx__edit-input`) inside a `div.ogx-input-wrapper` (`ogx-input-wrapper--cell` for editors) — use descendant selectors (`.ogx-global-search input`), not `> input`. The boolean editor and list-view row checkbox are the shared `Checkbox` (`.ogx-checkbox__box`; hidden `<input>`)
550
644
 
551
645
  ---
552
646
 
@@ -606,7 +700,7 @@ interface GridGetRowsResponse<R = GridRowModel> {
606
700
 
607
701
  ## KEYBOARD
608
702
 
609
- Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, PageUp/PageDown; Enter edits an editable cell, otherwise acts like a row click (toggles group/tree rows); Tab leaves the grid (while editing it moves to the next editable cell); Shift+Space selects the row (Space toggles the checkbox or detail panel when those cells are focused); Ctrl/Cmd+A selects all; Ctrl/Cmd+C copies the selection; Alt+ArrowRight/Left expands/collapses; Enter/Space on a header sorts (Shift adds to the sort); Alt+ArrowDown or Ctrl+Enter on a header opens the column menu; Alt+ArrowLeft/Right on a header resizes it.
703
+ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, PageUp/PageDown; Enter edits an editable cell, otherwise acts like a row click (toggles group/tree rows); Tab leaves the grid (while editing it moves to the next editable cell); Shift+Space selects the row (Space toggles the checkbox or detail panel when those cells are focused); Ctrl/Cmd+A selects all; Ctrl/Cmd+C copies the selection; Alt+ArrowRight/Left expands/collapses; Enter/Space on a header sorts (Shift adds to the sort); Alt+ArrowDown or Ctrl+Enter on a header opens the column menu; Alt+ArrowLeft/Right on a header resizes it. With headerFilters (3.5.0+): ArrowDown on a header moves to its filter cell, ArrowDown again to the first row; Enter/Space or typing focuses the filter control, Escape returns to the cell, Alt+ArrowDown opens the operator menu, Delete clears the filter; controls are not Tab stops (Tab leaves the grid).
610
704
 
611
705
  ---
612
706