@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,296 @@
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 { brandGridAiValidated } from '../utils/aiBrand';
18
+ import { coerceBoolean, coerceValue } from './coerce';
19
+ import {
20
+ ALL_PARTS,
21
+ MAX_FILTER_DEPTH,
22
+ NO_VALUE_OPS,
23
+ PART_KEYS,
24
+ PIVOT_FNS,
25
+ aggregationFnsOf,
26
+ hasOwn,
27
+ isFilterable,
28
+ isGroupable,
29
+ isHideable,
30
+ isPlainObject,
31
+ isSortable,
32
+ operatorsOf,
33
+ selectedParts,
34
+ usableColumns,
35
+ } from './columns';
36
+ import type { GridAiColumn, GridAiPart, GridAiState, GridAiValidateOptions, GridAiValidationError, GridAiValidationResult } from './types';
37
+
38
+ /** Longest list read from the model output; the rest is dropped with one error. */
39
+ const MAX_LIST = 200;
40
+ /** Errors past this many are not listed, so a reply full of junk still gives a readable list. */
41
+ const MAX_ERRORS = 100;
42
+
43
+ type Allowed = (col: GridAiColumn) => boolean;
44
+ type FilterNode = GridFilterItem | GridFilterGroup;
45
+
46
+ interface Ctx {
47
+ errors: GridAiValidationError[];
48
+ byField: Map<string, GridAiColumn>;
49
+ }
50
+
51
+ function fail(ctx: Ctx, path: string, message: string): null {
52
+ if (ctx.errors.length < MAX_ERRORS) ctx.errors.push({ path, message });
53
+ return null;
54
+ }
55
+
56
+ /** A short, safe rendering of a value for an error message. */
57
+ function show(value: unknown): string {
58
+ if (typeof value === 'string') return JSON.stringify(value.slice(0, 60));
59
+ return value === null || typeof value !== 'object' ? String(value) : Array.isArray(value) ? 'a list' : 'an object';
60
+ }
61
+
62
+ function lower(value: unknown): string | null {
63
+ return typeof value === 'string' ? value.trim().toLowerCase() : null;
64
+ }
65
+
66
+ /** Own enumerable keys of the input, without `__proto__`. */
67
+ function keysOf(obj: Record<string, unknown>): string[] {
68
+ return Object.keys(obj).filter((key) => key !== '__proto__');
69
+ }
70
+
71
+ function column(ctx: Ctx, value: unknown, path: string, allowed: Allowed, what: string): GridAiColumn | null {
72
+ const col = typeof value === 'string' ? ctx.byField.get(value) : undefined;
73
+ if (!col) return fail(ctx, path, `Unknown field ${show(value)}`);
74
+ return allowed(col) ? col : fail(ctx, path, `Field ${show(value)} cannot be ${what}`);
75
+ }
76
+
77
+ /** The first MAX_LIST entries of an array; a single value is read as a one-entry list. */
78
+ function list(ctx: Ctx, value: unknown, path: string): unknown[] {
79
+ if (value == null) return [];
80
+ const items = Array.isArray(value) ? value : [value];
81
+ if (items.length > MAX_LIST) fail(ctx, path, `Only the first ${MAX_LIST} entries are read`);
82
+ return items.slice(0, MAX_LIST);
83
+ }
84
+
85
+ /** The choice `value` names, case-insensitively, in the choice's own spelling. */
86
+ function oneOf(ctx: Ctx, value: unknown, path: string, choices: readonly string[]): string | null {
87
+ const v = lower(value);
88
+ return choices.find((c) => c.toLowerCase() === v) ?? fail(ctx, path, `${show(value)} is not one of ${choices.join(', ')}`);
89
+ }
90
+
91
+ /** One filter value, coerced; dates become `YYYY-MM-DD` as the filter panel writes them. */
92
+ function filterValue(ctx: Ctx, raw: unknown, col: GridAiColumn, path: string): { v: unknown } | null {
93
+ const result = coerceValue(raw, col);
94
+ if (!result.ok) return fail(ctx, path, `${show(raw)} is not a valid ${col.type ?? 'string'} value`);
95
+ const v = result.value instanceof Date ? toLocalDateString(result.value) : result.value;
96
+ return v == null || v === '' ? fail(ctx, path, 'Missing value') : { v };
97
+ }
98
+
99
+ function filterItem(ctx: Ctx, raw: Record<string, unknown>, path: string): GridFilterItem | null {
100
+ const col = column(ctx, raw.field, `${path}.field`, isFilterable, 'filtered');
101
+ if (!col) return null;
102
+ const operator = oneOf(ctx, raw.operator, `${path}.operator`, operatorsOf(col));
103
+ if (operator === null) return null;
104
+ const item: GridFilterItem = { field: col.field, operator: operator as GridFilterOperator };
105
+ if (NO_VALUE_OPS.includes(item.operator)) return item;
106
+ const rawValue = hasOwn(raw, 'value') ? raw.value : undefined;
107
+ if (item.operator === 'isAnyOf') {
108
+ const values: unknown[] = [];
109
+ list(ctx, rawValue, `${path}.value`).forEach((entry, i) => {
110
+ const r = filterValue(ctx, entry, col, `${path}.value[${i}]`);
111
+ if (r && !values.includes(r.v)) values.push(r.v);
112
+ });
113
+ if (values.length === 0) return fail(ctx, `${path}.value`, 'No valid values');
114
+ item.value = values;
115
+ return item;
116
+ }
117
+ if (Array.isArray(rawValue)) return fail(ctx, `${path}.value`, 'Expected one value, not a list');
118
+ const r = filterValue(ctx, rawValue, col, `${path}.value`);
119
+ if (!r) return null;
120
+ item.value = r.v;
121
+ return item;
122
+ }
123
+
124
+ function logic(ctx: Ctx, raw: unknown, path: string): 'and' | 'or' | null {
125
+ return raw === undefined ? null : (oneOf(ctx, raw, path, ['and', 'or']) as 'and' | 'or' | null);
126
+ }
127
+
128
+ function filterItems(ctx: Ctx, raw: unknown, path: string, depth: number): FilterNode[] {
129
+ const out: FilterNode[] = [];
130
+ list(ctx, raw, path).forEach((entry, i) => {
131
+ const at = `${path}[${i}]`;
132
+ if (!isPlainObject(entry)) {
133
+ fail(ctx, at, `Expected a filter, got ${show(entry)}`);
134
+ } else if (hasOwn(entry, 'items') || hasOwn(entry, 'logicOperator')) {
135
+ if (depth >= MAX_FILTER_DEPTH) {
136
+ fail(ctx, at, `Groups nest at most ${MAX_FILTER_DEPTH} levels`);
137
+ return;
138
+ }
139
+ const items = filterItems(ctx, entry.items, `${at}.items`, depth + 1);
140
+ if (items.length === 0) fail(ctx, at, 'Empty group removed');
141
+ else out.push({ logicOperator: logic(ctx, entry.logicOperator, `${at}.logicOperator`) ?? 'and', items });
142
+ } else {
143
+ const item = filterItem(ctx, entry, at);
144
+ if (item) out.push(item);
145
+ }
146
+ });
147
+ return out;
148
+ }
149
+
150
+ function filterModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridFilterModel {
151
+ const model: GridFilterModel = {};
152
+ if (hasOwn(raw, 'items')) model.items = filterItems(ctx, raw.items, `${path}.items`, 0);
153
+ const op = logic(ctx, raw.logicOperator, `${path}.logicOperator`);
154
+ if (op) model.logicOperator = op;
155
+ if (hasOwn(raw, 'quickFilterValues')) {
156
+ model.quickFilterValues = [];
157
+ list(ctx, raw.quickFilterValues, `${path}.quickFilterValues`).forEach((entry, i) => {
158
+ const word = typeof entry === 'string' || typeof entry === 'number' ? String(entry).trim() : null;
159
+ if (word === null) fail(ctx, `${path}.quickFilterValues[${i}]`, `Expected text, got ${show(entry)}`);
160
+ else if (word !== '') model.quickFilterValues?.push(word);
161
+ });
162
+ }
163
+ return model;
164
+ }
165
+
166
+ function sortModel(ctx: Ctx, raw: unknown, path: string): GridSortItem[] {
167
+ const out: GridSortItem[] = [];
168
+ list(ctx, raw, path).forEach((entry, i) => {
169
+ const at = `${path}[${i}]`;
170
+ if (!isPlainObject(entry)) return fail(ctx, at, `Expected { field, sort }, got ${show(entry)}`);
171
+ const col = column(ctx, entry.field, `${at}.field`, isSortable, 'sorted');
172
+ const sort = col && oneOf(ctx, entry.sort, `${at}.sort`, ['asc', 'desc']);
173
+ if (!col || !sort) return;
174
+ if (out.some((s) => s.field === col.field)) fail(ctx, at, `${show(col.field)} is sorted twice`);
175
+ else out.push({ field: col.field, sort: sort as 'asc' | 'desc' });
176
+ });
177
+ return out;
178
+ }
179
+
180
+ function fieldList(ctx: Ctx, raw: unknown, path: string, allowed: Allowed, what: string): string[] {
181
+ const out: string[] = [];
182
+ list(ctx, raw, path).forEach((entry, i) => {
183
+ const col = column(ctx, entry, `${path}[${i}]`, allowed, what);
184
+ if (col && !out.includes(col.field)) out.push(col.field);
185
+ });
186
+ return out;
187
+ }
188
+
189
+ const canAggregate: Allowed = (col) => aggregationFnsOf(col).length > 0;
190
+ const canPivot: Allowed = (col) => aggregationFnsOf(col, PIVOT_FNS).length > 0;
191
+
192
+ function aggregationModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridAggregationModel {
193
+ const model: GridAggregationModel = {};
194
+ for (const key of keysOf(raw)) {
195
+ // 'none' is how the grid spells "no aggregation": nothing to set.
196
+ if (lower(raw[key]) === 'none') continue;
197
+ const col = column(ctx, key, `${path}.${key}`, canAggregate, 'aggregated');
198
+ const fn = col && oneOf(ctx, raw[key], `${path}.${key}`, aggregationFnsOf(col));
199
+ if (col && fn) model[col.field] = fn;
200
+ }
201
+ return model;
202
+ }
203
+
204
+ function pivotModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridPivotModel {
205
+ const valueFields: GridPivotValueField[] = [];
206
+ list(ctx, raw.valueFields, `${path}.valueFields`).forEach((entry, i) => {
207
+ const at = `${path}.valueFields[${i}]`;
208
+ if (!isPlainObject(entry)) return fail(ctx, at, `Expected { field, aggFn }, got ${show(entry)}`);
209
+ const col = column(ctx, entry.field, `${at}.field`, canPivot, 'a pivot value');
210
+ const aggFn = col && (oneOf(ctx, entry.aggFn, `${at}.aggFn`, aggregationFnsOf(col, PIVOT_FNS)) as GridPivotValueField['aggFn'] | null);
211
+ if (col && aggFn && !valueFields.some((v) => v.field === col.field && v.aggFn === aggFn)) valueFields.push({ field: col.field, aggFn });
212
+ });
213
+ return {
214
+ rowFields: fieldList(ctx, raw.rowFields, `${path}.rowFields`, isGroupable, 'a pivot label'),
215
+ columnFields: fieldList(ctx, raw.columnFields, `${path}.columnFields`, isGroupable, 'a pivot label'),
216
+ valueFields,
217
+ };
218
+ }
219
+
220
+ function visibilityModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridColumnVisibilityModel {
221
+ const model: GridColumnVisibilityModel = {};
222
+ for (const key of keysOf(raw)) {
223
+ const col = column(ctx, key, `${path}.${key}`, isHideable, 'hidden');
224
+ const visible = coerceBoolean(raw[key]);
225
+ if (!col) continue;
226
+ if (visible.ok && visible.value !== null) model[col.field] = visible.value;
227
+ else fail(ctx, `${path}.${key}`, `Expected true or false, got ${show(raw[key])}`);
228
+ }
229
+ return model;
230
+ }
231
+
232
+ function readPart(ctx: Ctx, part: GridAiPart, raw: unknown, path: string, state: GridAiState): void {
233
+ if (part === 'sort') state.sortModel = sortModel(ctx, raw, path);
234
+ else if (part === 'grouping') state.rowGroupingModel = fieldList(ctx, raw, path, isGroupable, 'grouped');
235
+ else if (!isPlainObject(raw)) fail(ctx, path, `Expected an object, got ${show(raw)}`);
236
+ else if (part === 'filter') state.filterModel = filterModel(ctx, raw, path);
237
+ else if (part === 'aggregation') state.aggregationModel = aggregationModel(ctx, raw, path);
238
+ else if (part === 'pivot') state.pivotModel = pivotModel(ctx, raw, path);
239
+ else state.columnVisibilityModel = visibilityModel(ctx, raw, path);
240
+ }
241
+
242
+ /**
243
+ * Checks a model's reply against the columns and returns the state that is safe to apply.
244
+ * `json` is an object or a JSON string (bad JSON gives one error and an empty state). Unknown
245
+ * parts, fields, operators and enum values are dropped, each with an error; values are coerced to
246
+ * the column type (`"1,200"` → 1200, a date → `"YYYY-MM-DD"`); filter groups left empty are
247
+ * removed. Never throws, and never returns a field the columns do not allow.
248
+ *
249
+ * The result and its `state` carry the brand `Symbol.for('opengridx.ai.validated')` (not enumerable):
250
+ * the grid's `aiAssistant` applies only a branded state. Spreading the result keeps `state` branded.
251
+ * @since v3.4
252
+ */
253
+ export function validateGridAiState<C extends GridAiColumn>(
254
+ json: unknown,
255
+ columns: readonly C[],
256
+ options: GridAiValidateOptions = {},
257
+ ): GridAiValidationResult {
258
+ const result = validate(json, columns, options);
259
+ brandGridAiValidated(result.state);
260
+ return brandGridAiValidated(result);
261
+ }
262
+
263
+ function validate(json: unknown, columns: readonly GridAiColumn[], options: GridAiValidateOptions): GridAiValidationResult {
264
+ const ctx: Ctx = { errors: [], byField: new Map() };
265
+ const state: GridAiState = {};
266
+ try {
267
+ for (const col of usableColumns(columns)) ctx.byField.set(col.field, col);
268
+ let input = json;
269
+ if (typeof json === 'string') {
270
+ try {
271
+ input = JSON.parse(json) as unknown;
272
+ } catch {
273
+ fail(ctx, '', 'Not valid JSON');
274
+ return { state, errors: ctx.errors };
275
+ }
276
+ }
277
+ if (!isPlainObject(input)) {
278
+ fail(ctx, '', `Expected an object, got ${show(input)}`);
279
+ return { state, errors: ctx.errors };
280
+ }
281
+ const allowed = selectedParts(options);
282
+ for (const key of keysOf(input)) {
283
+ const part = ALL_PARTS.find((p) => PART_KEYS[p] === key);
284
+ if (key === 'schemaVersion') {
285
+ if (input[key] !== 1) fail(ctx, key, `Unsupported schemaVersion ${show(input[key])}`);
286
+ } else if (!part) fail(ctx, key, 'Unknown part');
287
+ else if (!allowed.includes(part)) fail(ctx, key, 'Part not allowed');
288
+ else readPart(ctx, part, input[key], key, state);
289
+ }
290
+ } catch {
291
+ // Defensive only: a getter on a hand-built input object could throw.
292
+ fail(ctx, '', 'Could not read the input');
293
+ return { state: {}, errors: ctx.errors };
294
+ }
295
+ return { state, errors: ctx.errors };
296
+ }
@@ -0,0 +1,26 @@
1
+ import type React from 'react';
2
+ import type { GridAiAssistantPanelProps, GridSlots } from '../../types';
3
+ import { GridAiAssistantPanel } from './GridAiAssistantPanel';
4
+
5
+ interface GridAiAssistantAreaProps {
6
+ panelProps: GridAiAssistantPanelProps;
7
+ slot: GridSlots['aiAssistantPanel'];
8
+ slotProps: Record<string, unknown> | undefined;
9
+ }
10
+
11
+ /**
12
+ * Anchors the `aiAssistant` panel (or `slots.aiAssistantPanel`) under the toolbar, over the grid.
13
+ * Escape anywhere in it closes the panel.
14
+ */
15
+ export function GridAiAssistantArea({ panelProps, slot: Slot, slotProps }: GridAiAssistantAreaProps) {
16
+ const handleKeyDown = (event: React.KeyboardEvent) => {
17
+ if (event.key !== 'Escape') return;
18
+ event.stopPropagation();
19
+ panelProps.close();
20
+ };
21
+ return (
22
+ <div className="ogx-ai-anchor" onKeyDown={handleKeyDown}>
23
+ {Slot ? <Slot {...panelProps} {...slotProps} /> : <GridAiAssistantPanel {...panelProps} />}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,171 @@
1
+ /* aiAssistant panel (v3.5): anchored under the toolbar, over the grid body. */
2
+ .ogx-ai-anchor {
3
+ position: relative;
4
+ height: 0;
5
+ z-index: 20;
6
+ }
7
+ .ogx-ai-panel {
8
+ position: absolute;
9
+ top: 6px;
10
+ right: 8px;
11
+ width: min(480px, calc(100% - 16px));
12
+ max-height: var(--ogx-ai-panel-max-height, 420px);
13
+ overflow: auto;
14
+ box-sizing: border-box;
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 10px;
18
+ padding: 12px;
19
+ background: var(--ogx-overlay-background, var(--ogx-grid-background));
20
+ color: var(--ogx-grid-row-text);
21
+ border: 1px solid var(--ogx-overlay-border, var(--ogx-grid-border-color));
22
+ border-radius: var(--ogx-border-radius-lg, 8px);
23
+ box-shadow: var(--ogx-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
24
+ font-size: var(--ogx-font-size-sm);
25
+ }
26
+ .ogx-ai-panel__header {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ }
31
+ .ogx-ai-panel__title {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 6px;
35
+ font-weight: 600;
36
+ color: var(--ogx-color-primary);
37
+ }
38
+ .ogx-ai-panel__close {
39
+ min-width: 28px;
40
+ font-size: 18px;
41
+ line-height: 1;
42
+ }
43
+ .ogx-ai-panel__form {
44
+ display: flex;
45
+ gap: 6px;
46
+ align-items: center;
47
+ }
48
+ .ogx-ai-panel__input {
49
+ flex: 1 1 0;
50
+ min-width: 0;
51
+ }
52
+ .ogx-ai-panel__form > .ogx-button {
53
+ flex: 0 0 auto;
54
+ }
55
+ .ogx-ai-panel__mic {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ }
60
+ .ogx-ai-panel__mic--on {
61
+ color: var(--ogx-color-error);
62
+ border-color: var(--ogx-color-error);
63
+ }
64
+ .ogx-ai-panel__suggestions {
65
+ display: flex;
66
+ flex-wrap: wrap;
67
+ gap: 6px;
68
+ }
69
+ .ogx-ai-panel__suggestion {
70
+ padding: 3px 10px;
71
+ border: 1px solid var(--ogx-border-color, var(--ogx-grid-border-color));
72
+ border-radius: var(--ogx-border-radius-round, 999px);
73
+ background: var(--ogx-grid-header-background);
74
+ color: inherit;
75
+ font: inherit;
76
+ cursor: pointer;
77
+ }
78
+ .ogx-ai-panel__suggestion:hover:not(:disabled) {
79
+ background: var(--ogx-grid-row-hover-background);
80
+ }
81
+ .ogx-ai-panel__suggestion:disabled {
82
+ opacity: 0.5;
83
+ cursor: default;
84
+ }
85
+ .ogx-ai-panel__suggestion:focus-visible,
86
+ .ogx-ai-chip__remove:focus-visible,
87
+ .ogx-ai-panel__history-item:focus-visible {
88
+ outline: 2px solid var(--ogx-color-primary);
89
+ outline-offset: 1px;
90
+ }
91
+ .ogx-ai-panel__status {
92
+ color: var(--ogx-color-gray-600, #4b5563);
93
+ }
94
+ .ogx-ai-panel__status--error {
95
+ color: var(--ogx-color-error);
96
+ }
97
+ .ogx-ai-panel__message {
98
+ margin: 0;
99
+ white-space: pre-wrap;
100
+ }
101
+ .ogx-ai-panel__changes {
102
+ display: flex;
103
+ align-items: flex-start;
104
+ gap: 8px;
105
+ }
106
+ .ogx-ai-panel__chips {
107
+ display: flex;
108
+ flex-wrap: wrap;
109
+ gap: 6px;
110
+ flex: 1 1 auto;
111
+ margin: 0;
112
+ padding: 0;
113
+ list-style: none;
114
+ }
115
+ .ogx-ai-chip {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ gap: 4px;
119
+ padding: 2px 4px 2px 10px;
120
+ border-radius: var(--ogx-border-radius-round, 999px);
121
+ background: var(--ogx-color-primary-light, #e0e7ff);
122
+ color: var(--ogx-color-primary-dark, #1e3a8a);
123
+ }
124
+ .ogx-ai-chip__remove {
125
+ width: 20px;
126
+ height: 20px;
127
+ padding: 0;
128
+ border: none;
129
+ border-radius: 50%;
130
+ background: transparent;
131
+ color: inherit;
132
+ font-size: 14px;
133
+ line-height: 1;
134
+ cursor: pointer;
135
+ }
136
+ .ogx-ai-chip__remove:hover {
137
+ background: rgba(0, 0, 0, 0.08);
138
+ }
139
+ .ogx-ai-panel__errors {
140
+ margin: 0;
141
+ padding-left: 18px;
142
+ color: var(--ogx-color-warning, #b45309);
143
+ }
144
+ .ogx-ai-panel__history-title {
145
+ font-weight: 600;
146
+ opacity: 0.7;
147
+ margin-bottom: 2px;
148
+ }
149
+ .ogx-ai-panel__history ul {
150
+ margin: 0;
151
+ padding: 0;
152
+ list-style: none;
153
+ }
154
+ .ogx-ai-panel__history-item {
155
+ padding: 2px 0;
156
+ border: none;
157
+ background: transparent;
158
+ color: var(--ogx-color-primary);
159
+ font: inherit;
160
+ text-align: left;
161
+ cursor: pointer;
162
+ }
163
+ .ogx-toolbar__ai-btn {
164
+ width: auto;
165
+ gap: 4px;
166
+ padding: 0 10px;
167
+ font: inherit;
168
+ font-size: var(--ogx-font-size-sm);
169
+ font-weight: 600;
170
+ color: var(--ogx-color-primary);
171
+ }
@@ -0,0 +1,145 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import type { GridAiAssistantPanelProps } from '../../types';
3
+ import { Button } from '../ui/Button';
4
+ import { Input } from '../ui/Input';
5
+ import { useSpeechInput } from './useSpeechInput';
6
+
7
+ /** The sparkle of the Ask AI button and the panel title. */
8
+ export function AiSparkleIcon() {
9
+ return (
10
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" aria-hidden="true" focusable="false">
11
+ <path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" />
12
+ <path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z" />
13
+ </svg>
14
+ );
15
+ }
16
+
17
+ function MicIcon() {
18
+ return (
19
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" focusable="false">
20
+ <rect x="9" y="3" width="6" height="11" rx="3" />
21
+ <path d="M5 11a7 7 0 0 0 14 0M12 18v3" />
22
+ </svg>
23
+ );
24
+ }
25
+
26
+ /**
27
+ * The built-in `aiAssistant` panel: prompt input (Enter sends), suggestions, microphone where the
28
+ * browser has speech recognition, Stop while a request runs, the reply, the applied changes as
29
+ * removable chips with Undo, what was ignored, and the session's earlier prompts.
30
+ */
31
+ export function GridAiAssistantPanel(props: GridAiAssistantPanelProps) {
32
+ const {
33
+ status, statusText, message, errors, chips, history, suggestions, placeholder, voiceAvailable, canUndo,
34
+ submit, stop, undo, removeChip, close,
35
+ } = props;
36
+ const [text, setText] = useState('');
37
+ const inputRef = useRef<HTMLInputElement>(null);
38
+ const speech = useSpeechInput(setText);
39
+ const running = status === 'running';
40
+
41
+ useEffect(() => {
42
+ if (inputRef.current) inputRef.current.focus();
43
+ }, []);
44
+
45
+ const send = (prompt: string) => {
46
+ setText(prompt);
47
+ submit(prompt);
48
+ };
49
+
50
+ // The control a click acts on goes away (Stop, a chip, Undo): focus stays in the panel.
51
+ const thenFocusInput = (action: () => void) => () => {
52
+ action();
53
+ if (inputRef.current) inputRef.current.focus();
54
+ };
55
+
56
+ const handleSubmit = (event: React.FormEvent) => {
57
+ event.preventDefault();
58
+ if (!running) submit(text);
59
+ };
60
+
61
+ return (
62
+ <div className="ogx-ai-panel" role="dialog" aria-label="Ask AI">
63
+ <div className="ogx-ai-panel__header">
64
+ <span className="ogx-ai-panel__title"><AiSparkleIcon />Ask AI</span>
65
+ <Button size="small" className="ogx-ai-panel__close" aria-label="Close" onClick={close}>×</Button>
66
+ </div>
67
+
68
+ <form className="ogx-ai-panel__form" onSubmit={handleSubmit}>
69
+ <Input
70
+ ref={inputRef}
71
+ className="ogx-ai-panel__input"
72
+ value={text}
73
+ placeholder={placeholder}
74
+ aria-label="Prompt"
75
+ autoComplete="off"
76
+ onChange={(event) => setText(event.target.value)}
77
+ />
78
+ {voiceAvailable && (
79
+ <Button
80
+ size="small"
81
+ variant="outlined"
82
+ className={`ogx-ai-panel__mic${speech.listening ? ' ogx-ai-panel__mic--on' : ''}`}
83
+ aria-label={speech.listening ? 'Stop dictation' : 'Dictate a prompt'}
84
+ aria-pressed={speech.listening}
85
+ onClick={speech.toggle}
86
+ >
87
+ <MicIcon />
88
+ </Button>
89
+ )}
90
+ {/* Separate keys: one reused <button> would turn into the submit button during the Stop click and submit again. */}
91
+ {running
92
+ ? <Button key="stop" size="small" variant="outlined" onClick={thenFocusInput(stop)}>Stop</Button>
93
+ : <Button key="ask" size="small" variant="contained" color="primary" type="submit" disabled={text.trim() === ''}>Ask</Button>}
94
+ </form>
95
+
96
+ {suggestions.length > 0 && (
97
+ <div className="ogx-ai-panel__suggestions" aria-label="Suggestions" role="group">
98
+ {suggestions.map((suggestion) => (
99
+ <button key={suggestion} type="button" className="ogx-ai-panel__suggestion" disabled={running} onClick={() => send(suggestion)}>
100
+ {suggestion}
101
+ </button>
102
+ ))}
103
+ </div>
104
+ )}
105
+
106
+ {statusText && <div className={`ogx-ai-panel__status ogx-ai-panel__status--${status}`}>{statusText}</div>}
107
+ {message && <p className="ogx-ai-panel__message">{message}</p>}
108
+
109
+ {(chips.length > 0 || canUndo) && (
110
+ <div className="ogx-ai-panel__changes">
111
+ <ul className="ogx-ai-panel__chips" aria-label="Applied changes">
112
+ {chips.map((chip) => (
113
+ <li key={chip.id} className="ogx-ai-chip" data-part={chip.part}>
114
+ <span className="ogx-ai-chip__label">{chip.label}</span>
115
+ <button type="button" className="ogx-ai-chip__remove" aria-label={`Remove ${chip.label}`} onClick={thenFocusInput(() => removeChip(chip.id))}>×</button>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ {canUndo && <Button size="small" variant="outlined" className="ogx-ai-panel__undo" onClick={thenFocusInput(undo)}>Undo</Button>}
120
+ </div>
121
+ )}
122
+
123
+ {errors.length > 0 && (
124
+ <ul className="ogx-ai-panel__errors" aria-label="Ignored">
125
+ {errors.map((error, i) => (
126
+ <li key={`${error.path}:${i}`}>{error.path ? `Ignored: ${error.message}` : error.message}</li>
127
+ ))}
128
+ </ul>
129
+ )}
130
+
131
+ {history.length > 0 && (
132
+ <div className="ogx-ai-panel__history">
133
+ <div className="ogx-ai-panel__history-title">Earlier</div>
134
+ <ul>
135
+ {history.map((entry, i) => (
136
+ <li key={`${i}:${entry.prompt}`}>
137
+ <button type="button" className="ogx-ai-panel__history-item" onClick={() => setText(entry.prompt)}>{entry.prompt}</button>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+ </div>
142
+ )}
143
+ </div>
144
+ );
145
+ }