@opencorestack/opengridx 3.4.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 (76) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +6 -0
  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 +7104 -5726
  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 +9 -4
  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/roadmap.md +2 -3
  28. package/lib/ai/agentTools.ts +160 -0
  29. package/lib/ai/index.ts +19 -2
  30. package/lib/ai/promptHandler.ts +109 -0
  31. package/lib/ai/types.ts +89 -25
  32. package/lib/ai/validate.ts +10 -0
  33. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  34. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  35. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  36. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  37. package/lib/components/Cell/Cell.css +28 -0
  38. package/lib/components/Cell/Cell.tsx +6 -2
  39. package/lib/components/DataGrid/DataGrid.tsx +76 -3
  40. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  41. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  42. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  43. package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
  44. package/lib/components/Header/Header.tsx +44 -23
  45. package/lib/components/Header/headerColumnStyle.ts +28 -0
  46. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  47. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  48. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  49. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  50. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  51. package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
  52. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  53. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  54. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  55. package/lib/hooks/core/useGridCellSelection.ts +77 -11
  56. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  57. package/lib/hooks/core/useGridControlledState.ts +27 -0
  58. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  59. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  60. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  61. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  62. package/lib/hooks/features/useGridBatchEdit.ts +2 -2
  63. package/lib/index.ts +3 -0
  64. package/lib/styles/opengridx.css +2 -0
  65. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  66. package/lib/theme/types.ts +2 -0
  67. package/lib/types/index.ts +219 -1
  68. package/lib/utils/aiAssistant.ts +205 -0
  69. package/lib/utils/aiBrand.ts +17 -0
  70. package/lib/utils/cellSelection.ts +25 -5
  71. package/lib/utils/editing/fill.ts +238 -0
  72. package/lib/utils/filtering/index.ts +14 -0
  73. package/lib/utils/focus/index.ts +9 -3
  74. package/lib/utils/headerFilters.ts +189 -0
  75. package/llms.txt +44 -4
  76. package/package.json +1 -1
@@ -7,11 +7,13 @@ import type {
7
7
  GridPaginationModel,
8
8
  GridAggregationModel,
9
9
  GridPivotModel,
10
+ GridRowGroupingModel,
10
11
  GridCellSelectionModel,
11
12
  GridCellSelectionChangeDetails,
12
13
  GridCellSelectionReason,
13
14
  } from '../../types';
14
15
  import type { GridInitialState, GridDensityState } from '../../state/types';
16
+ import { EMPTY_ROW_GROUPING_MODEL } from '../../utils/gridModes';
15
17
 
16
18
  export type GridDensity = GridDensityState['density'];
17
19
 
@@ -27,6 +29,9 @@ export interface UseGridControlledStateParams {
27
29
  aggregationModel?: GridAggregationModel;
28
30
  onAggregationModelChange?: (model: GridAggregationModel) => void;
29
31
 
32
+ rowGroupingModel?: GridRowGroupingModel;
33
+ onRowGroupingModelChange?: (model: GridRowGroupingModel) => void;
34
+
30
35
  columnVisibilityModel?: Record<string, boolean>;
31
36
  onColumnVisibilityModelChange?: (model: Record<string, boolean>) => void;
32
37
 
@@ -68,6 +73,11 @@ export interface UseGridControlledStateReturn {
68
73
  aggregationModel: GridAggregationModel;
69
74
  handleAggregationModelChange: (model: GridAggregationModel) => void;
70
75
 
76
+ // row grouping (v3.5: a controlled/uncontrolled pair)
77
+ rowGroupingModel: GridRowGroupingModel;
78
+ /** Sets the internal model when uncontrolled and fires onRowGroupingModelChange. */
79
+ handleRowGroupingModelChange: (model: GridRowGroupingModel) => void;
80
+
71
81
  // column visibility
72
82
  columnVisibilityModel: Record<string, boolean>;
73
83
  handleColumnVisibilityModelChange: (model: Record<string, boolean>) => void;
@@ -131,6 +141,8 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
131
141
  onFilterModelChange,
132
142
  aggregationModel: propAggregationModel,
133
143
  onAggregationModelChange,
144
+ rowGroupingModel: propRowGroupingModel,
145
+ onRowGroupingModelChange,
134
146
  columnVisibilityModel: propColumnVisibilityModel,
135
147
  onColumnVisibilityModelChange,
136
148
  pinnedColumns: propPinnedColumns,
@@ -190,6 +202,18 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
190
202
  onAggregationModelChange?.(model);
191
203
  }, [isAggregationControlled, onAggregationModelChange]);
192
204
 
205
+ // ── Row grouping ─────────────────────────────────────────────────────────
206
+ // A passed rowGroupingModel controls grouping and is used as is (the hierarchy hooks memoise on
207
+ // it); without one the grid keeps its own model, changed through apiRef and the AI assistant.
208
+ const isRowGroupingControlled = propRowGroupingModel !== undefined;
209
+ const [internalRowGroupingModel, setInternalRowGroupingModel] = useState<GridRowGroupingModel>(EMPTY_ROW_GROUPING_MODEL);
210
+ const rowGroupingModel = (isRowGroupingControlled ? propRowGroupingModel : internalRowGroupingModel) || EMPTY_ROW_GROUPING_MODEL;
211
+
212
+ const handleRowGroupingModelChange = useCallback((model: GridRowGroupingModel) => {
213
+ if (!isRowGroupingControlled) setInternalRowGroupingModel(model);
214
+ onRowGroupingModelChange?.(model);
215
+ }, [isRowGroupingControlled, onRowGroupingModelChange]);
216
+
193
217
  // ── Column visibility ────────────────────────────────────────────────────
194
218
  const isColumnVisibilityControlled = propColumnVisibilityModel !== undefined;
195
219
  const [internalColumnVisibilityModel, setInternalColumnVisibilityModel] = useState<Record<string, boolean>>(
@@ -295,6 +319,9 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
295
319
  aggregationModel,
296
320
  handleAggregationModelChange,
297
321
 
322
+ rowGroupingModel,
323
+ handleRowGroupingModelChange,
324
+
298
325
  columnVisibilityModel,
299
326
  handleColumnVisibilityModelChange,
300
327
 
@@ -0,0 +1,323 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { RefObject } from 'react';
3
+ import type {
4
+ GridCellCoordinates,
5
+ GridCellParams,
6
+ GridCellSelectionModel,
7
+ GridColDef,
8
+ GridEditSkipReason,
9
+ GridFillDirection,
10
+ GridFillResult,
11
+ GridRowId,
12
+ GridRowMeta,
13
+ GridRowModel,
14
+ } from '../../types';
15
+ import { attempt } from '../../utils/attempt';
16
+ import { classifyKeyTarget } from '../../utils/focus';
17
+ import { isRangeColumnField } from '../../utils/cellSelection';
18
+ import type { CellRangeRect, GridFillHandlePosition } from '../../utils/cellSelection';
19
+ import { isSyntheticRowId } from '../../utils/syntheticRows';
20
+ import { getCellValue, warnCallbackFailure } from '../../utils/values';
21
+ import { buildEditTargetContext, isEditTargetWritable } from '../../utils/editing/targets';
22
+ import type { GridEditTargetContext } from '../../utils/editing/targets';
23
+ import {
24
+ computeFillValues,
25
+ fillHandleCell,
26
+ fillLines,
27
+ fillRangeCorners,
28
+ fillTargetFromPointer,
29
+ } from '../../utils/editing/fill';
30
+ import type { GridBatchCellEdit, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
31
+ import type { UseGridCellSelectionReturn } from './useGridCellSelection';
32
+
33
+ /** The class of the fill handle square (public from v3.5). */
34
+ export const FILL_HANDLE_CLASS = 'ogx__cell-fill-handle';
35
+
36
+ export interface UseGridFillHandleParams<R extends GridRowModel> {
37
+ /** The grid root: Ctrl/Cmd+D and Ctrl/Cmd+R are listened for here. */
38
+ containerRef: RefObject<HTMLDivElement | null>;
39
+ viewportRef: RefObject<HTMLDivElement | null>;
40
+ selection: UseGridCellSelectionReturn;
41
+ disableFillHandle: boolean;
42
+ /** Whether a cell editor is open: no handle and no shortcuts meanwhile. */
43
+ isEditing: boolean;
44
+ allRenderableRows: R[];
45
+ getRowId: (row: R) => GridRowId;
46
+ /** System columns and the rendered data columns, in render order. */
47
+ navigationColumns: ReadonlyArray<GridColDef<R>>;
48
+ columnIndexMap: Map<string, number>;
49
+ isCellEditable?: (params: GridCellParams<R>) => boolean;
50
+ rowMetaMap: Map<GridRowId, GridRowMeta>;
51
+ getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
52
+ batchEdit: UseGridBatchEditReturn<R>;
53
+ onFill?: (result: GridFillResult) => void;
54
+ }
55
+
56
+ export interface UseGridFillHandleReturn {
57
+ /** Where the rows draw the handle, or null. */
58
+ handle: GridFillHandlePosition | null;
59
+ }
60
+
61
+ interface FillDragState<R extends GridRowModel> {
62
+ source: CellRangeRect;
63
+ /** The source range model, restored when the drag ends where it started or is cancelled. */
64
+ sourceModel: GridCellSelectionModel;
65
+ anchor: { row: number; col: number };
66
+ ctx: GridEditTargetContext<R>;
67
+ x: number;
68
+ y: number;
69
+ scrollLeft: number;
70
+ scrollTop: number;
71
+ target: { rect: CellRangeRect; direction: GridFillDirection } | null;
72
+ }
73
+
74
+ /** 'down', 'right' or null for a keydown: Ctrl/Cmd+D and Ctrl/Cmd+R (also on non-Latin layouts). */
75
+ function fillShortcut(event: KeyboardEvent): 'down' | 'right' | null {
76
+ if (event.altKey || event.shiftKey || !(event.ctrlKey || event.metaKey)) return null;
77
+ const key = (event.key ?? '').toLowerCase();
78
+ const latin = /^[a-z]$/.test(key);
79
+ if (key === 'd' || (!latin && event.code === 'KeyD')) return 'down';
80
+ if (key === 'r' || (!latin && event.code === 'KeyR')) return 'right';
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * The fill handle (v3.5). With `cellSelection` on, the bottom-right cell of the range carries a
86
+ * square; dragging it extends the range in one direction (the larger pointer offset wins, with the
87
+ * range drag's auto-scroll) and fills the new cells from the source range on release: numbers or
88
+ * dates with an equal step continue as a series, anything else repeats, Alt at release copies, and
89
+ * `GridColDef.fillValue` has the last word. Ctrl/Cmd+D fills down from the range's top row and
90
+ * Ctrl/Cmd+R right from its left column. Every fill is one batch edit (source `'fill'`), so one undo
91
+ * step. Runs after useGridUndoRedo.
92
+ */
93
+ export function useGridFillHandle<R extends GridRowModel>(params: UseGridFillHandleParams<R>): UseGridFillHandleReturn {
94
+ const {
95
+ containerRef, viewportRef, selection, disableFillHandle, isEditing, allRenderableRows, getRowId,
96
+ navigationColumns, columnIndexMap, getSpanOrigin,
97
+ } = params;
98
+ const latestRef = useRef(params);
99
+ useLayoutEffect(() => {
100
+ latestRef.current = params;
101
+ });
102
+
103
+ const enabled = selection.enabled && !disableFillHandle;
104
+ const hasEditableColumn = useMemo(() => navigationColumns.some(c => c.editable), [navigationColumns]);
105
+
106
+ // The handle: on the range's bottom-right cell (or the cell whose span covers it).
107
+ const resolved = selection.resolved;
108
+ const handle = useMemo<GridFillHandlePosition | null>(() => {
109
+ if (!enabled || !hasEditableColumn || !resolved) return null;
110
+ const fields = navigationColumns.filter(c => isRangeColumnField(c.field)).map(c => c.field);
111
+ const rowIdAt = (r: number) => {
112
+ const row = allRenderableRows[r];
113
+ return row === undefined ? undefined : getRowId(row);
114
+ };
115
+ const cell = fillHandleCell(resolved, rowIdAt, fields, getSpanOrigin);
116
+ if (!cell) return null;
117
+ const col = columnIndexMap.get(cell.field);
118
+ return col === undefined ? null : { row: cell.row, col };
119
+ }, [enabled, hasEditableColumn, resolved, allRenderableRows, getRowId, navigationColumns, columnIndexMap, getSpanOrigin]);
120
+
121
+ const mountedRef = useRef(true);
122
+ useEffect(() => {
123
+ mountedRef.current = true;
124
+ return () => { mountedRef.current = false; };
125
+ }, []);
126
+
127
+ const getContext = useCallback((): GridEditTargetContext<R> => {
128
+ const p = latestRef.current;
129
+ return buildEditTargetContext(p.allRenderableRows, p.getRowId, p.navigationColumns);
130
+ }, []);
131
+
132
+ /** Selects `rect` with its corners chosen for the fill. */
133
+ const selectRect = useCallback((
134
+ ctx: GridEditTargetContext<R>,
135
+ rect: CellRangeRect,
136
+ direction: GridFillDirection,
137
+ anchor: { row: number; col: number },
138
+ reason: 'pointer' | 'keyboard',
139
+ ) => {
140
+ const corners = fillRangeCorners(rect, direction, anchor);
141
+ const toCell = (p: { row: number; col: number }): GridCellCoordinates => ({ id: ctx.rowIds[p.row], field: ctx.columns[p.col].field });
142
+ latestRef.current.selection.setModel([{ anchor: toCell(corners.anchor), head: toCell(corners.head) }], reason);
143
+ }, []);
144
+
145
+ /** Fills `target` from `source` (display indices) and selects `target`. */
146
+ const fill = useCallback(async (
147
+ ctx: GridEditTargetContext<R>,
148
+ source: CellRangeRect,
149
+ target: CellRangeRect,
150
+ direction: GridFillDirection,
151
+ copy: boolean,
152
+ anchor: { row: number; col: number },
153
+ reason: 'pointer' | 'keyboard',
154
+ ): Promise<GridFillResult> => {
155
+ const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin: spanOrigin } = latestRef.current;
156
+ const rules = { isCellEditable, rowMetaMap, getSpanOrigin: spanOrigin };
157
+ const vertical = direction === 'down' || direction === 'up';
158
+ const rowAt = (r: number): R | undefined => {
159
+ const id = ctx.rowIds[r];
160
+ return id === undefined ? undefined : batchEdit.getRow(id) ?? ctx.rows[r];
161
+ };
162
+ const edits: GridBatchCellEdit[] = [];
163
+ const skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[] = [];
164
+ for (const line of fillLines(source, target, direction)) {
165
+ if (line.targets.length === 0) continue;
166
+ const position = (k: number) => (vertical ? { r: k, c: line.line } : { r: line.line, c: k });
167
+ // The source values in fill order; synthetic rows hold no data and are left out.
168
+ const sourceValues: unknown[] = [];
169
+ for (const k of line.source) {
170
+ const { r, c } = position(k);
171
+ const row = rowAt(r);
172
+ const colDef = ctx.columns[c];
173
+ if (!row || !colDef || isSyntheticRowId(ctx.rowIds[r], rowMetaMap)) continue;
174
+ sourceValues.push(getCellValue(row, colDef.field, colDef));
175
+ }
176
+ if (sourceValues.length === 0) continue;
177
+ const values = computeFillValues(sourceValues, line.targets.length, !copy);
178
+ line.targets.forEach((k, index) => {
179
+ const { r, c } = position(k);
180
+ const row = rowAt(r);
181
+ const colDef = ctx.columns[c];
182
+ const id = ctx.rowIds[r];
183
+ if (!row || !colDef || id === undefined) return;
184
+ if (!isEditTargetWritable(ctx, rules, row, r, c)) {
185
+ skipped.push({ id, field: colDef.field, reason: 'notEditable' });
186
+ return;
187
+ }
188
+ let value = values[index];
189
+ const fillValue = colDef.fillValue;
190
+ if (fillValue) {
191
+ const decided = attempt(() => fillValue.call(colDef, {
192
+ row, id, field: colDef.field, colDef, direction, sourceValues: sourceValues.slice(), index, value, copy,
193
+ }));
194
+ if (!decided.ok) {
195
+ warnCallbackFailure(`fillValue:${colDef.field}`, `fillValue of column "${colDef.field}" threw; the cell was skipped`, decided.error);
196
+ skipped.push({ id, field: colDef.field, reason: 'invalidValue' });
197
+ return;
198
+ }
199
+ value = decided.value;
200
+ }
201
+ edits.push({ id, field: colDef.field, value });
202
+ });
203
+ }
204
+
205
+ // The source and the filled cells become the range.
206
+ selectRect(ctx, target, direction, anchor, reason);
207
+
208
+ const outcome = await batchEdit.applyEdits(edits, 'fill');
209
+ const result: GridFillResult = {
210
+ updated: outcome.result.updated,
211
+ failed: outcome.result.failed,
212
+ skipped: [...skipped, ...outcome.result.skipped],
213
+ direction,
214
+ };
215
+ latestRef.current.onFill?.(result);
216
+ return result;
217
+ }, [selectRect]);
218
+
219
+ // ── Pointer: drag the handle ────────────────────────────────────────────
220
+ const dragRef = useRef<FillDragState<R> | null>(null);
221
+
222
+ const onTrack = useCallback((head: GridCellCoordinates, x: number, y: number) => {
223
+ const drag = dragRef.current;
224
+ const viewport = latestRef.current.viewportRef.current;
225
+ if (!drag || !viewport) return;
226
+ const ctx = drag.ctx;
227
+ const row = ctx.rowIndexById.get(head.id);
228
+ const col = ctx.colIndexByField.get(head.field);
229
+ if (row === undefined || col === undefined) return;
230
+ const dx = x - drag.x + viewport.scrollLeft - drag.scrollLeft;
231
+ const dy = y - drag.y + viewport.scrollTop - drag.scrollTop;
232
+ const target = fillTargetFromPointer(drag.source, { row, col }, dx, dy);
233
+ drag.target = target;
234
+ if (target) selectRect(ctx, target.rect, target.direction, drag.anchor, 'pointer');
235
+ else latestRef.current.selection.setModel(drag.sourceModel, 'pointer');
236
+ }, [selectRect]);
237
+
238
+ const onEnd = useCallback(({ cancelled, altKey }: { cancelled: boolean; altKey: boolean }) => {
239
+ const drag = dragRef.current;
240
+ dragRef.current = null;
241
+ if (!drag || !mountedRef.current) return;
242
+ const ctx = drag.ctx;
243
+ if (cancelled || !drag.target) {
244
+ latestRef.current.selection.setModel(drag.sourceModel, 'pointer');
245
+ return;
246
+ }
247
+ void fill(ctx, drag.source, drag.target.rect, drag.target.direction, altKey, drag.anchor, 'pointer');
248
+ }, [fill]);
249
+
250
+ const dragEnabled = enabled && hasEditableColumn;
251
+ useEffect(() => {
252
+ const viewport = viewportRef.current;
253
+ if (!viewport || !dragEnabled) return;
254
+ const onPointerDown = (event: PointerEvent) => {
255
+ if (event.button !== 0 || !(event.target instanceof Element)) return;
256
+ const handleEl = event.target.closest(`.${FILL_HANDLE_CLASS}`);
257
+ if (!handleEl || handleEl.closest('.ogx__viewport') !== viewport || latestRef.current.isEditing) return;
258
+ const { selection: s } = latestRef.current;
259
+ const { range, resolved: current } = s.getCurrentRange();
260
+ if (!range || !current) return;
261
+ const ctx = getContext();
262
+ const startId = ctx.rowIds[current.bottom];
263
+ const startCol = ctx.columns[current.right];
264
+ if (startId === undefined || !startCol) return;
265
+ // No mousedown follows, so the range drag does not start and focus does not move.
266
+ event.preventDefault();
267
+ const doc = viewport.ownerDocument;
268
+ if (!viewport.contains(doc.activeElement)) viewport.focus({ preventScroll: true });
269
+ // Pointer events keep reaching the grid even after the handle moves to another cell.
270
+ attempt(() => viewport.setPointerCapture(event.pointerId));
271
+ dragRef.current = {
272
+ source: { top: current.top, bottom: current.bottom, left: current.left, right: current.right },
273
+ sourceModel: [range],
274
+ anchor: { row: current.anchorRow, col: current.anchorCol },
275
+ ctx,
276
+ x: event.clientX,
277
+ y: event.clientY,
278
+ scrollLeft: viewport.scrollLeft,
279
+ scrollTop: viewport.scrollTop,
280
+ target: null,
281
+ };
282
+ s.startTrackedDrag({
283
+ start: { id: startId, field: startCol.field },
284
+ x: event.clientX,
285
+ y: event.clientY,
286
+ pointerId: event.pointerId,
287
+ onTrack,
288
+ onEnd,
289
+ });
290
+ };
291
+ viewport.addEventListener('pointerdown', onPointerDown);
292
+ return () => viewport.removeEventListener('pointerdown', onPointerDown);
293
+ }, [viewportRef, dragEnabled, getContext, onTrack, onEnd]);
294
+
295
+ // ── Keyboard: Ctrl/Cmd+D and Ctrl/Cmd+R ────────────────────────────────
296
+ useEffect(() => {
297
+ const root = containerRef.current;
298
+ if (!root || !enabled) return;
299
+ const onKeyDown = (event: KeyboardEvent) => {
300
+ if (event.defaultPrevented) return;
301
+ const shortcut = fillShortcut(event);
302
+ if (!shortcut) return;
303
+ const viewport = latestRef.current.viewportRef.current;
304
+ if (!viewport || latestRef.current.isEditing) return;
305
+ const kind = classifyKeyTarget(event.target, viewport);
306
+ if (kind !== 'grid' && kind !== 'control') return;
307
+ const { range, resolved: current } = latestRef.current.selection.getCurrentRange();
308
+ if (!range || !current) return;
309
+ // The browser's bookmark (Ctrl+D) and reload (Ctrl/Cmd+R) never fire inside the grid.
310
+ event.preventDefault();
311
+ const rect = { top: current.top, bottom: current.bottom, left: current.left, right: current.right };
312
+ const source = shortcut === 'down' ? { ...rect, bottom: rect.top } : { ...rect, right: rect.left };
313
+ if (shortcut === 'down' ? rect.bottom === rect.top : rect.right === rect.left) return;
314
+ void fill(getContext(), source, rect, shortcut, true, { row: current.anchorRow, col: current.anchorCol }, 'keyboard');
315
+ };
316
+ root.addEventListener('keydown', onKeyDown);
317
+ return () => root.removeEventListener('keydown', onKeyDown);
318
+ }, [containerRef, enabled, fill, getContext]);
319
+
320
+ return { handle: isEditing ? null : handle };
321
+ }
322
+
323
+ export type { GridFillHandlePosition };
@@ -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 {
@@ -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
  }