@opencorestack/opengridx 3.4.0 → 3.5.1

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 (82) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -3
  3. package/dist/ai.cjs +2 -1
  4. package/dist/ai.cjs.map +1 -1
  5. package/dist/ai.d.ts +203 -3
  6. package/dist/ai.es.js +2 -1
  7. package/dist/ai.es.js.map +1 -1
  8. package/dist/index.d.ts +248 -4
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +7334 -5906
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -16
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +88 -7
  15. package/docs/README.md +3 -1
  16. package/docs/architecture/datagrid-orchestration.md +11 -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 +3 -1
  21. package/docs/features/ai-toolkit.md +99 -5
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/fill-handle.md +125 -0
  24. package/docs/features/filtering.md +3 -0
  25. package/docs/features/header-filters.md +138 -0
  26. package/docs/features/keyboard-navigation.md +20 -2
  27. package/docs/features/virtualization.md +5 -5
  28. package/docs/performance.md +212 -1
  29. package/docs/roadmap.md +3 -4
  30. package/lib/ai/agentTools.ts +160 -0
  31. package/lib/ai/index.ts +19 -2
  32. package/lib/ai/promptHandler.ts +109 -0
  33. package/lib/ai/types.ts +89 -25
  34. package/lib/ai/validate.ts +10 -0
  35. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  36. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  37. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  38. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  39. package/lib/components/Cell/Cell.css +28 -0
  40. package/lib/components/Cell/Cell.tsx +6 -2
  41. package/lib/components/DataGrid/DataGrid.tsx +84 -4
  42. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  43. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  44. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  45. package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
  46. package/lib/components/Header/Header.tsx +44 -23
  47. package/lib/components/Header/headerColumnStyle.ts +28 -0
  48. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  49. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  50. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  51. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  52. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  53. package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
  54. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  55. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  56. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  57. package/lib/hooks/core/useGridCellSelection.ts +77 -11
  58. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  59. package/lib/hooks/core/useGridControlledState.ts +27 -0
  60. package/lib/hooks/core/useGridDevWarnings.ts +21 -6
  61. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  62. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  63. package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
  64. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  65. package/lib/hooks/core/useGridVirtualization.ts +19 -0
  66. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  67. package/lib/hooks/features/useGridBatchEdit.ts +2 -2
  68. package/lib/index.ts +3 -0
  69. package/lib/styles/opengridx.css +2 -0
  70. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  71. package/lib/theme/types.ts +2 -0
  72. package/lib/types/index.ts +219 -1
  73. package/lib/utils/aiAssistant.ts +205 -0
  74. package/lib/utils/aiBrand.ts +17 -0
  75. package/lib/utils/cellSelection.ts +25 -5
  76. package/lib/utils/editing/fill.ts +238 -0
  77. package/lib/utils/filtering/index.ts +14 -0
  78. package/lib/utils/focus/index.ts +9 -3
  79. package/lib/utils/headerFilters.ts +189 -0
  80. package/lib/utils/maxElementHeight.ts +53 -0
  81. package/llms.txt +44 -4
  82. package/package.json +3 -2
@@ -100,6 +100,11 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
100
100
  * Ctrl/Cmd+A selects every data cell instead of every row.
101
101
  */
102
102
  cellSelection?: GridKeyboardCellSelection;
103
+ /**
104
+ * Whether the header filter row (`headerFilters`) is rendered: ArrowDown on a column header
105
+ * moves to its filter cell, and ArrowDown there into the first row.
106
+ */
107
+ headerFilters?: boolean;
103
108
  }
104
109
 
105
110
  export interface GridKeyboardCellSelection {
@@ -134,14 +139,18 @@ function resolveFocus<R extends GridRowModel>(
134
139
  cell: FocusedCell | null,
135
140
  rows: R[],
136
141
  columns: ReadonlyArray<NavigationColumn<R>>,
137
- getRowId: (row: R) => GridRowId
142
+ getRowId: (row: R) => GridRowId,
143
+ headerFilters: boolean
138
144
  ): { cell: FocusedCell | null; rowIndex: number } {
139
145
  if (!cell || columns.length === 0) return { cell: null, rowIndex: -1 };
140
146
  const fallbackField = (columns.find(c => !isSystemField(c.field)) ?? columns[0]).field;
141
147
  const field = columns.some(c => c.field === cell.field) ? cell.field : fallbackField;
142
148
 
143
149
  if (cell.id === null) {
144
- return { cell: field === cell.field ? cell : { id: null, field }, rowIndex: -1 };
150
+ // A filter cell whose row was turned off falls back to its column header.
151
+ const headerFilter = Boolean(cell.headerFilter) && headerFilters;
152
+ if (field === cell.field && headerFilter === Boolean(cell.headerFilter)) return { cell, rowIndex: -1 };
153
+ return { cell: headerFilter ? { id: null, field, headerFilter } : { id: null, field }, rowIndex: -1 };
145
154
  }
146
155
  const hint = cell.rowIndex;
147
156
  let rowIndex = hint !== undefined && rows[hint] && getRowId(rows[hint]) === cell.id ? hint : rows.findIndex(r => getRowId(r) === cell.id);
@@ -187,14 +196,15 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
187
196
  rowHeight,
188
197
  getSpanOrigin,
189
198
  cellSelection,
199
+ headerFilters = false,
190
200
  } = params;
191
201
 
192
202
  const [storedFocus, setFocusedCell] = useState<FocusedCell | null>(null);
193
203
  const [focusWithin, setFocusWithin] = useState(false);
194
204
 
195
205
  const resolved = useMemo(
196
- () => resolveFocus(storedFocus, allRenderableRows, navigationColumns, getRowId),
197
- [storedFocus, allRenderableRows, navigationColumns, getRowId]
206
+ () => resolveFocus(storedFocus, allRenderableRows, navigationColumns, getRowId, headerFilters),
207
+ [storedFocus, allRenderableRows, navigationColumns, getRowId, headerFilters]
198
208
  );
199
209
  const focusedCell = resolved.cell;
200
210
  const focusedRowIndex = resolved.rowIndex;
@@ -359,10 +369,15 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
359
369
  const isSpace = key === ' ' || key === 'Spacebar';
360
370
  const isEditing = Boolean(editingHandlers.editingCell);
361
371
 
372
+ // Row positions: rows from 0, the column header row at `headerRow`, and the header filter
373
+ // row (when rendered) at -1, between the header and the rows.
374
+ const headerRow = headerFilters ? -2 : -1;
362
375
  const moveTo = (r: number, c: number) => {
363
376
  const col = navigationColumns[c];
364
- if (!col || r < -1 || r >= allRenderableRows.length) return;
365
- setFocusedCell(r === -1 ? { id: null, field: col.field } : { id: getRowId(allRenderableRows[r]), field: col.field, rowIndex: r });
377
+ if (!col || r < headerRow || r >= allRenderableRows.length) return;
378
+ if (r === headerRow) setFocusedCell({ id: null, field: col.field });
379
+ else if (r < 0) setFocusedCell({ id: null, field: col.field, headerFilter: true });
380
+ else setFocusedCell({ id: getRowId(allRenderableRows[r]), field: col.field, rowIndex: r });
366
381
  scrollCellIntoView(r, col.field);
367
382
  };
368
383
 
@@ -377,7 +392,8 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
377
392
  }
378
393
 
379
394
  const { id, field } = focusedCell;
380
- const rowIndex = focusedRowIndex;
395
+ const onHeaderFilter = id === null && Boolean(focusedCell.headerFilter);
396
+ const rowIndex = id !== null ? focusedRowIndex : onHeaderFilter ? -1 : headerRow;
381
397
  const colIndex = navigationColumns.findIndex(c => c.field === field);
382
398
  const systemColumnCount = navigationColumns.filter(c => isSystemField(c.field)).length;
383
399
  const isSyntheticRow = (rowId: GridRowId) => isSyntheticRowId(rowId, rowMetaMap);
@@ -455,6 +471,8 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
455
471
  return;
456
472
  }
457
473
 
474
+ // A filter cell handles Enter, Space and typing itself (HeaderFilterCell).
475
+ if (onHeaderFilter && (key === 'Enter' || isSpace)) return;
458
476
  if (id === null) {
459
477
  if (key === 'Enter' || isSpace) {
460
478
  event.preventDefault();
@@ -533,13 +551,13 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
533
551
  case 'ArrowLeft': return c - 1 < 0 ? { r: r - 1, c: lastCol } : { r, c: c - 1 };
534
552
  case 'ArrowDown': return { r: r + 1, c };
535
553
  case 'ArrowUp': return { r: r - 1, c };
536
- case 'Home': return { r: withModifier ? -1 : r, c: 0 };
554
+ case 'Home': return { r: withModifier ? headerRow : r, c: 0 };
537
555
  case 'End': return { r: withModifier ? lastRow : r, c: lastCol };
538
- case 'PageUp': return { r: Math.max(-1, r - page), c };
556
+ case 'PageUp': return { r: Math.max(headerRow, r - page), c };
539
557
  default: return { r: Math.min(lastRow, r + page), c };
540
558
  }
541
559
  };
542
- const inGrid = ({ r, c }: { r: number; c: number }) => r >= -1 && r <= lastRow && c >= 0 && c <= lastCol;
560
+ const inGrid = ({ r, c }: { r: number; c: number }) => r >= headerRow && r <= lastRow && c >= 0 && c <= lastCol;
543
561
  const spanOriginAt = ({ r, c }: { r: number; c: number }) =>
544
562
  r >= 0 ? getSpanOrigin?.(getRowId(allRenderableRows[r]), navigationColumns[c].field) ?? null : null;
545
563
 
@@ -590,6 +608,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
590
608
  getSpanOrigin,
591
609
  getRowId,
592
610
  cellSelection,
611
+ headerFilters,
593
612
  ]);
594
613
 
595
614
  return {
@@ -0,0 +1,16 @@
1
+ import { useLayoutEffect, useState } from 'react';
2
+ import type { RefObject } from 'react';
3
+ import { getMaxElementHeight } from '../../utils/maxElementHeight';
4
+
5
+ /**
6
+ * The tallest element this browser lays out (see `getMaxElementHeight`), measured once per document after
7
+ * mount: `undefined` until measured, `Infinity` where there is no layout engine (jsdom, SSR).
8
+ */
9
+ export function useGridMaxContentHeight(containerRef: RefObject<HTMLElement | null>): number | undefined {
10
+ const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
11
+ useLayoutEffect(() => {
12
+ const measured = getMaxElementHeight(containerRef.current?.ownerDocument);
13
+ setMaxHeight(prev => (prev === measured ? prev : measured));
14
+ }, [containerRef]);
15
+ return maxHeight;
16
+ }
@@ -46,6 +46,14 @@ export interface UseGridToolbarPropsParams<R extends GridRowModel> {
46
46
  /** `useGridColumnsPanel().toolbarPanelRequested`. */
47
47
  forceColumnsOpen: boolean;
48
48
  onColumnsPanelClose: () => void;
49
+ /** `aiAssistant` is set: the toolbar shows the Ask AI button. */
50
+ aiAssistantEnabled?: boolean;
51
+ aiAssistantOpen?: boolean;
52
+ onAiAssistantToggle?: (trigger: HTMLElement | null) => void;
53
+ aiAssistantLabel?: string;
54
+ /** `useGridColumnsPanel().toolbarFiltersRequested`. */
55
+ forceFiltersOpen: boolean;
56
+ onFiltersPanelClose: () => void;
49
57
  }
50
58
 
51
59
  /** The props the grid passes to `slots.toolbar`, or null when there is no toolbar slot. */
@@ -57,6 +65,8 @@ export function useGridToolbarProps<R extends GridRowModel>(
57
65
  pivotMode, pivotModelProp, onPivotModelChangeProp, pivotModel, onPivotModelChange, filterModel,
58
66
  onFilterModelChange, columnVisibilityModel, onColumnVisibilityModelChange, disableColumnReorder,
59
67
  columnGroupingModel, moveColumn, resetColumnOrder, forceColumnsOpen, onColumnsPanelClose,
68
+ aiAssistantEnabled = false, aiAssistantOpen = false, onAiAssistantToggle, aiAssistantLabel,
69
+ forceFiltersOpen, onFiltersPanelClose,
60
70
  } = params;
61
71
 
62
72
  return useMemo(() => {
@@ -85,6 +95,13 @@ export function useGridToolbarProps<R extends GridRowModel>(
85
95
  onColumnOrderReset: disableColumnReorder ? undefined : resetColumnOrder,
86
96
  forceColumnsOpen,
87
97
  onColumnsPanelClose,
98
+ ...(aiAssistantEnabled && onAiAssistantToggle ? {
99
+ onAiAssistantToggle,
100
+ aiAssistantOpen,
101
+ ...(aiAssistantLabel ? { aiAssistantLabel } : {}),
102
+ } : {}),
103
+ forceFiltersOpen,
104
+ onFiltersPanelClose,
88
105
  ...toolbarSlotProps,
89
106
  };
90
107
  }, [
@@ -93,5 +110,7 @@ export function useGridToolbarProps<R extends GridRowModel>(
93
110
  pivotModelProp, onPivotModelChangeProp, pivotModel, onPivotModelChange,
94
111
  filterModel, onFilterModelChange, columnVisibilityModel,
95
112
  onColumnVisibilityModelChange, forceColumnsOpen, onColumnsPanelClose, toolbarSlotProps, columnGroupingModel,
113
+ aiAssistantEnabled, aiAssistantOpen, onAiAssistantToggle, aiAssistantLabel,
114
+ forceFiltersOpen, onFiltersPanelClose,
96
115
  ]);
97
116
  }
@@ -16,6 +16,11 @@ export interface UseGridVirtualizationParams<R extends GridRowModel> {
16
16
  pinExpandColumn: boolean;
17
17
  /** Adaptive overscan row count from useGridScrollSync. Defaults to 5. */
18
18
  overscanRows?: number;
19
+ /**
20
+ * The tallest element the browser lays out (`getMaxElementHeight`); the scrollable content is capped to
21
+ * it. Defaults to no cap.
22
+ */
23
+ maxContentHeight?: number;
19
24
  }
20
25
 
21
26
  export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel> {
@@ -28,6 +33,11 @@ export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel>
28
33
  offsetTop: number;
29
34
  offsetLeft: number;
30
35
  totalHeight: number;
36
+ /**
37
+ * Height of the unpinned rows' container: their full height, capped so the whole content stays under
38
+ * `maxContentHeight`. Rows below the cap cannot be scrolled to (the dev warning names how many can).
39
+ */
40
+ virtualContainerHeight: number;
31
41
  pinnedTopHeight: number;
32
42
  pinnedBottomHeight: number;
33
43
  totalWidth: number;
@@ -60,6 +70,7 @@ export function useGridVirtualization<R extends GridRowModel>(
60
70
  pinCheckboxColumn,
61
71
  pinExpandColumn,
62
72
  overscanRows = 5,
73
+ maxContentHeight = Infinity,
63
74
  } = params;
64
75
 
65
76
  return useMemo<GridVirtualizationResult<R>>(() => {
@@ -122,6 +133,12 @@ export function useGridVirtualization<R extends GridRowModel>(
122
133
  const hasHorizontalScroll = viewportWidth > 0 && totalWidth > viewportWidth;
123
134
  const scrollbarBuffer = hasHorizontalScroll ? 16 : 0;
124
135
  const totalHeight = pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + scrollbarBuffer + 2;
136
+ // Firefox collapses an element taller than its cap (about 17.9M px) instead of clamping it, which
137
+ // made a large grid unscrollable there; keep the rows' container under the browser's cap.
138
+ const fullContainerHeight = totalHeight - pinnedTopHeight - pinnedBottomHeight;
139
+ const virtualContainerHeight = Number.isFinite(maxContentHeight)
140
+ ? Math.max(0, Math.min(fullContainerHeight, maxContentHeight - pinnedTopHeight - pinnedBottomHeight))
141
+ : fullContainerHeight;
125
142
 
126
143
  let firstRowIndex = 0;
127
144
  let lastRowIndex = unpinnedRowsLength - 1;
@@ -157,6 +174,7 @@ export function useGridVirtualization<R extends GridRowModel>(
157
174
  offsetTop,
158
175
  offsetLeft: 0,
159
176
  totalHeight,
177
+ virtualContainerHeight,
160
178
  pinnedTopHeight,
161
179
  pinnedBottomHeight,
162
180
  totalWidth,
@@ -185,5 +203,6 @@ export function useGridVirtualization<R extends GridRowModel>(
185
203
  pinCheckboxColumn,
186
204
  pinExpandColumn,
187
205
  overscanRows,
206
+ maxContentHeight,
188
207
  ]);
189
208
  }
@@ -0,0 +1,284 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import type { MutableRefObject } from 'react';
3
+ import type {
4
+ GridAiAssistantOptions,
5
+ GridAiAssistantPanelProps,
6
+ GridAiAssistantStatus,
7
+ GridAiHistoryEntry,
8
+ GridAiPromptResult,
9
+ GridAiState,
10
+ GridAiValidationError,
11
+ GridApi,
12
+ GridColDef,
13
+ } from '../../types';
14
+ import { attempt } from '../../utils/attempt';
15
+ import { isGridAiValidated } from '../../utils/aiBrand';
16
+ import {
17
+ AI_PARTS,
18
+ applyGridAiState,
19
+ buildAiChips,
20
+ dropEmptiedParts,
21
+ effectiveState,
22
+ pickAllowedParts,
23
+ removeAiChip,
24
+ snapshotParts,
25
+ } from '../../utils/aiAssistant';
26
+
27
+ export interface UseGridAiAssistantParams {
28
+ apiRef: MutableRefObject<GridApi>;
29
+ /** The `aiAssistant` prop. Without it the hook is inert. */
30
+ options: GridAiAssistantOptions | undefined;
31
+ /** Columns, for the chip labels (header names). */
32
+ columns: readonly GridColDef[];
33
+ onApply?: (result: GridAiPromptResult) => void;
34
+ onError?: (error: unknown) => void;
35
+ }
36
+
37
+ export interface UseGridAiAssistantResult {
38
+ enabled: boolean;
39
+ open: boolean;
40
+ /** Opens the panel; focus returns to `trigger` (default: the focused element) when it closes. */
41
+ openPanel: (trigger?: HTMLElement | null) => void;
42
+ /** Opens the panel, or closes it when it is open. */
43
+ togglePanel: (trigger?: HTMLElement | null) => void;
44
+ closePanel: () => void;
45
+ /** Props of the panel while it is open, else null. */
46
+ panelProps: GridAiAssistantPanelProps | null;
47
+ /** The status line for the grid's live region. */
48
+ announcement: string;
49
+ }
50
+
51
+ const DEFAULT_PLACEHOLDER = 'Ask about this table…';
52
+ const NO_SUGGESTIONS: string[] = [];
53
+ const NO_ERRORS: GridAiValidationError[] = [];
54
+ const NOT_VALIDATED = 'The reply was not validated';
55
+
56
+ interface Applied {
57
+ /** The reply's parts the grid applied (chips are built from it). */
58
+ state: GridAiState;
59
+ /** The same parts as they were before: what Undo restores. */
60
+ previous: GridAiState;
61
+ }
62
+
63
+ /** Whether this browser has speech recognition. The microphone is never asked for here. */
64
+ export function hasSpeechRecognition(): boolean {
65
+ return typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
66
+ }
67
+
68
+ function isAbortError(error: unknown): boolean {
69
+ return typeof error === 'object' && error !== null && (error as { name?: unknown }).name === 'AbortError';
70
+ }
71
+
72
+ function errorText(error: unknown): string {
73
+ return error instanceof Error && error.message ? error.message : 'Unknown error';
74
+ }
75
+
76
+ function changesText(count: number, ignored: number): string {
77
+ const applied = count === 0 ? 'No changes' : `Applied ${count} ${count === 1 ? 'change' : 'changes'}`;
78
+ return ignored > 0 ? `${applied}, ${ignored} ignored` : applied;
79
+ }
80
+
81
+ /** Calls a consumer callback; a throw is warned about (development) and otherwise ignored. */
82
+ function notify<T>(callback: ((value: T) => void) | undefined, value: T, name: string): void {
83
+ if (!callback) return;
84
+ const result = attempt(() => callback(value));
85
+ if (!result.ok && process.env.NODE_ENV !== 'production') console.warn(`[OpenGridX] ${name} threw.`, result.error);
86
+ }
87
+
88
+ /**
89
+ * The `aiAssistant` prompt panel: open state, the request (with Stop), applying a validated reply as
90
+ * one undoable step, chips, Undo, the session's prompts and the status line. Results are applied only
91
+ * when their `state` carries the brand of `validateGridAiState`. Installs `apiRef.openAiAssistant` /
92
+ * `closeAiAssistant`.
93
+ */
94
+ export function useGridAiAssistant(params: UseGridAiAssistantParams): UseGridAiAssistantResult {
95
+ const { apiRef, options, columns } = params;
96
+ const enabled = Boolean(options);
97
+ const [open, setOpen] = useState(false);
98
+ const [status, setStatus] = useState<GridAiAssistantStatus>('idle');
99
+ const [statusText, setStatusText] = useState('');
100
+ const [message, setMessage] = useState<string | null>(null);
101
+ const [errors, setErrors] = useState<GridAiValidationError[]>(NO_ERRORS);
102
+ const [applied, setApplied] = useState<Applied | null>(null);
103
+ const [history, setHistory] = useState<GridAiHistoryEntry[]>([]);
104
+
105
+ const controllerRef = useRef<AbortController | null>(null);
106
+ const returnFocusRef = useRef<HTMLElement | null>(null);
107
+ const latestRef = useRef({ params, applied, history, open });
108
+ useLayoutEffect(() => {
109
+ latestRef.current = { params, applied, history, open };
110
+ });
111
+
112
+ const labelOf = useMemo(() => {
113
+ const byField = new Map(columns.map((col) => [col.field, col.headerName || col.field]));
114
+ return (field: string) => byField.get(field) ?? field;
115
+ }, [columns]);
116
+ const chips = useMemo(() => (applied ? buildAiChips(applied.state, labelOf) : []), [applied, labelOf]);
117
+
118
+ const abortRequest = useCallback(() => {
119
+ const controller = controllerRef.current;
120
+ controllerRef.current = null;
121
+ if (controller) controller.abort();
122
+ }, []);
123
+
124
+ // Abort a running request when the grid unmounts.
125
+ useEffect(() => abortRequest, [abortRequest]);
126
+
127
+ const openPanel = useCallback((trigger?: HTMLElement | null) => {
128
+ if (!latestRef.current.params.options) return;
129
+ const active = typeof document === 'undefined' ? null : document.activeElement;
130
+ returnFocusRef.current = trigger ?? (active instanceof HTMLElement ? active : null);
131
+ setOpen(true);
132
+ }, []);
133
+
134
+ const closePanel = useCallback(() => {
135
+ if (controllerRef.current) {
136
+ abortRequest();
137
+ setStatus('stopped');
138
+ setStatusText('Stopped');
139
+ }
140
+ setOpen(false);
141
+ const target = returnFocusRef.current;
142
+ returnFocusRef.current = null;
143
+ if (target && target.isConnected) target.focus();
144
+ }, [abortRequest]);
145
+
146
+ const togglePanel = useCallback((trigger?: HTMLElement | null) => {
147
+ if (latestRef.current.open) closePanel();
148
+ else openPanel(trigger);
149
+ }, [openPanel, closePanel]);
150
+
151
+ const fail = useCallback((text: string, error: unknown) => {
152
+ setStatus('error');
153
+ setStatusText(text);
154
+ setMessage(null);
155
+ setErrors(NO_ERRORS);
156
+ notify(latestRef.current.params.onError, error, 'onAiAssistantError');
157
+ }, []);
158
+
159
+ const applyResult = useCallback((prompt: string, result: GridAiPromptResult) => {
160
+ const { params: current } = latestRef.current;
161
+ const isObject = typeof result === 'object' && result !== null;
162
+ if (!isObject || !isGridAiValidated(result.state)) {
163
+ if (process.env.NODE_ENV !== 'production') {
164
+ console.warn('[OpenGridX] aiAssistant: the onPrompt result was not applied because its state was not validated. Return the result of createGridAiPromptHandler, or pass the model reply through validateGridAiState (from @opencorestack/opengridx/ai) and return its state.');
165
+ }
166
+ fail(NOT_VALIDATED, new Error(NOT_VALIDATED));
167
+ return;
168
+ }
169
+ const allowed = pickAllowedParts(result.state, current.options?.parts ?? AI_PARTS);
170
+ const ignored = [...(Array.isArray(result.errors) ? result.errors : []), ...allowed.errors];
171
+ const state = dropEmptiedParts(allowed.state, ignored);
172
+ const api = apiRef.current;
173
+ const previous = snapshotParts(state, api.getGridAiState());
174
+ applyGridAiState(api, effectiveState(state, previous));
175
+ setApplied({ state, previous });
176
+ setErrors(ignored);
177
+ setMessage(typeof result.message === 'string' && result.message ? result.message : null);
178
+ setHistory((list) => [...list, { prompt, applied: state }]);
179
+ setStatus('applied');
180
+ setStatusText(changesText(buildAiChips(state, (f) => f).length, ignored.length));
181
+ notify(current.onApply, result, 'onAiAssistantApply');
182
+ }, [apiRef, fail]);
183
+
184
+ const submit = useCallback((text: string) => {
185
+ const prompt = text.trim();
186
+ const { params: current, history: earlier } = latestRef.current;
187
+ const onPrompt = current.options?.onPrompt;
188
+ if (!prompt || !onPrompt) return;
189
+ abortRequest();
190
+ const controller = new AbortController();
191
+ controllerRef.current = controller;
192
+ setStatus('running');
193
+ setStatusText('Thinking…');
194
+ setMessage(null);
195
+ setErrors(NO_ERRORS);
196
+ const context = { currentState: apiRef.current.getGridAiState(), history: earlier, signal: controller.signal };
197
+ const call = attempt(() => onPrompt(prompt, context));
198
+ const pending = call.ok ? Promise.resolve(call.value) : Promise.reject(call.error);
199
+ pending.then(
200
+ (result) => {
201
+ if (controllerRef.current !== controller) return;
202
+ controllerRef.current = null;
203
+ applyResult(prompt, result);
204
+ },
205
+ (error: unknown) => {
206
+ if (controllerRef.current !== controller) return;
207
+ controllerRef.current = null;
208
+ if (controller.signal.aborted || isAbortError(error)) {
209
+ setStatus('stopped');
210
+ setStatusText('Stopped');
211
+ return;
212
+ }
213
+ fail(`The assistant could not answer: ${errorText(error)}`, error);
214
+ },
215
+ );
216
+ }, [apiRef, abortRequest, applyResult, fail]);
217
+
218
+ const stop = useCallback(() => {
219
+ if (!controllerRef.current) return;
220
+ abortRequest();
221
+ setStatus('stopped');
222
+ setStatusText('Stopped');
223
+ }, [abortRequest]);
224
+
225
+ const undo = useCallback(() => {
226
+ const last = latestRef.current.applied;
227
+ if (!last) return;
228
+ applyGridAiState(apiRef.current, last.previous);
229
+ setApplied(null);
230
+ setErrors(NO_ERRORS);
231
+ setMessage(null);
232
+ setStatus('idle');
233
+ setStatusText('Undone');
234
+ }, [apiRef]);
235
+
236
+ const removeChip = useCallback((id: string) => {
237
+ const last = latestRef.current.applied;
238
+ if (!last) return;
239
+ const state = removeAiChip(last.state, id);
240
+ applyGridAiState(apiRef.current, effectiveState(state, last.previous));
241
+ setApplied({ state, previous: last.previous });
242
+ setStatusText('Change removed');
243
+ }, [apiRef]);
244
+
245
+ useLayoutEffect(() => {
246
+ const api = apiRef.current;
247
+ api.openAiAssistant = () => openPanel();
248
+ api.closeAiAssistant = () => {
249
+ if (latestRef.current.open) closePanel();
250
+ };
251
+ }, [apiRef, openPanel, closePanel]);
252
+
253
+ const isOpen = enabled && open;
254
+ const panelProps = useMemo<GridAiAssistantPanelProps | null>(() => {
255
+ if (!isOpen || !options) return null;
256
+ return {
257
+ status,
258
+ statusText,
259
+ message,
260
+ errors,
261
+ chips,
262
+ history,
263
+ suggestions: options.suggestions ?? NO_SUGGESTIONS,
264
+ placeholder: options.placeholder ?? DEFAULT_PLACEHOLDER,
265
+ voiceAvailable: options.voice !== false && hasSpeechRecognition(),
266
+ canUndo: applied !== null,
267
+ submit,
268
+ stop,
269
+ undo,
270
+ removeChip,
271
+ close: closePanel,
272
+ };
273
+ }, [isOpen, options, status, statusText, message, errors, chips, history, applied, submit, stop, undo, removeChip, closePanel]);
274
+
275
+ return {
276
+ enabled,
277
+ open: isOpen,
278
+ openPanel,
279
+ togglePanel,
280
+ closePanel,
281
+ panelProps,
282
+ announcement: enabled ? statusText : '',
283
+ };
284
+ }
@@ -11,8 +11,8 @@ import {
11
11
  } from '../../utils/editing/commit';
12
12
  import type { GridCellChange } from '../../utils/editing/commit';
13
13
 
14
- /** What made a commit: history records `edit`, `paste` and `clear`; `undo` / `redo` replay it. */
15
- export type GridEditSource = 'edit' | 'paste' | 'clear' | 'undo' | 'redo';
14
+ /** What made a commit: history records `edit`, `paste`, `clear` and `fill`; `undo` / `redo` replay it. */
15
+ export type GridEditSource = 'edit' | 'paste' | 'clear' | 'fill' | 'undo' | 'redo';
16
16
 
17
17
  /** One cell to write in a batch edit. */
18
18
  export interface GridBatchCellEdit {
package/lib/index.ts CHANGED
@@ -112,6 +112,9 @@ export type {
112
112
  GridEditSkipReason,
113
113
  GridBatchEditResult,
114
114
  GridClipboardPasteResult,
115
+ GridFillDirection,
116
+ GridFillResult,
117
+ GridFillValueParams,
115
118
  GridBeforeClipboardPasteParams,
116
119
  GridHistoryChangeParams,
117
120
  GridUndoRedoOptions,
@@ -4,6 +4,7 @@
4
4
  @import '../components/ui/Input.css';
5
5
  @import '../components/DataGrid/DataGrid.css';
6
6
  @import '../components/Header/Header.css';
7
+ @import '../components/HeaderFilter/HeaderFilter.css';
7
8
  @import '../components/Row/Row.css';
8
9
  @import '../components/Cell/Cell.css';
9
10
  @import '../components/Cell/GridEditInputCell.css';
@@ -27,3 +28,4 @@
27
28
  @import '../components/ColumnGroupHeader/ColumnGroupHeader.css';
28
29
  @import '../components/Tooltip/Tooltip.css';
29
30
  @import '../components/CellSelectionStats/CellSelectionStats.css';
31
+ @import '../components/AiAssistant/GridAiAssistantPanel.css';
@@ -11,6 +11,7 @@ const primaryDerived: Record<string, string> = {
11
11
  '--ogx-grid-cell-focus-border': 'var(--ogx-color-primary)',
12
12
  '--ogx-range-background': 'color-mix(in srgb, var(--ogx-color-primary) 12%, transparent)',
13
13
  '--ogx-range-border': 'var(--ogx-color-primary)',
14
+ '--ogx-fill-handle-color': 'var(--ogx-range-border)',
14
15
  '--ogx-toolbar-btn-primary-bg': 'var(--ogx-color-primary)',
15
16
  '--ogx-toolbar-btn-primary-hover': 'var(--ogx-color-primary-dark)',
16
17
  '--ogx-toolbar-input-focus-border': 'var(--ogx-color-primary)',
@@ -285,6 +286,7 @@ function themeToCSS(theme: GridTheme): Record<string, string> {
285
286
  set('--ogx-grid-cell-focus-border', g.cellFocusBorder);
286
287
  set('--ogx-range-background', g.rangeBackground);
287
288
  set('--ogx-range-border', g.rangeBorder);
289
+ set('--ogx-fill-handle-color', g.fillHandleColor);
288
290
  set('--ogx-grid-pinned-left-shadow', g.pinnedLeftShadow);
289
291
  set('--ogx-grid-pinned-right-shadow', g.pinnedRightShadow);
290
292
  set('--ogx-checkbox-bg', g.checkboxBg);
@@ -163,6 +163,8 @@ export interface GridThemeGrid {
163
163
  rangeBackground?: string;
164
164
  /** Outline of a selected cell range (`--ogx-range-border`). Default: the primary colour. @since v3.3 */
165
165
  rangeBorder?: string;
166
+ /** The fill handle on the range's corner (`--ogx-fill-handle-color`). Default: the range border colour. @since v3.5 */
167
+ fillHandleColor?: string;
166
168
  pinnedLeftShadow?: string;
167
169
  pinnedRightShadow?: string;
168
170
  checkboxBg?: string;