@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
@@ -121,6 +121,8 @@ export interface UseGridCellSelectionReturn {
121
121
  * address the renderable rows and the range columns of `navigationColumns` (`isRangeColumnField`).
122
122
  */
123
123
  getCurrentRange: () => { range: GridCellSelectionModel[number] | null; resolved: ResolvedCellRange | null };
124
+ /** Starts a tracked pointer drag with the range drag's auto-scroll (fill handle). Stable. @since v3.5 */
125
+ startTrackedDrag: (options: GridTrackedDragOptions) => void;
124
126
  }
125
127
 
126
128
  interface DragState {
@@ -130,7 +132,24 @@ interface DragState {
130
132
  y: number;
131
133
  frame: number;
132
134
  moved: boolean;
133
- stop: () => void;
135
+ /** Ends the drag; `cancelled` unless the button was released normally (tracked drags: Escape cancels). */
136
+ stop: (cancelled?: boolean, altKey?: boolean) => void;
137
+ /** A tracked drag (fill handle): told the cell under the pointer each frame instead of moving the range. */
138
+ onTrack?: (head: GridCellCoordinates, x: number, y: number) => void;
139
+ }
140
+
141
+ /** A drag that reuses the range drag's hit testing and auto-scroll without selecting (the fill handle, v3.5). */
142
+ export interface GridTrackedDragOptions {
143
+ /** The cell the drag starts on. */
144
+ start: GridCellCoordinates;
145
+ x: number;
146
+ y: number;
147
+ /** Pointer events of this pointer are followed (touch included), not mouse events. */
148
+ pointerId: number;
149
+ /** Each frame: the cell under the pointer (or nearest it) and the pointer position. */
150
+ onTrack: (head: GridCellCoordinates, x: number, y: number) => void;
151
+ /** Once: released (`cancelled: false`, with the Alt key state) or cancelled. */
152
+ onEnd: (details: { cancelled: boolean; altKey: boolean }) => void;
134
153
  }
135
154
 
136
155
  /** The body cell of `viewport` holding `target`: its row index and field. Not cells of nested grids or detail panels. */
@@ -451,8 +470,10 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
451
470
  latestRef.current.containerRef.current?.classList.add(RANGE_DRAGGING_CLASS);
452
471
  clearTextSelection(viewport.ownerDocument);
453
472
  }
454
- setRange(drag.anchor, head, 'pointer');
473
+ if (!drag.onTrack) setRange(drag.anchor, head, 'pointer');
455
474
  }
475
+ // A tracked drag hears every frame: its target can change while the head stays in one cell.
476
+ if (drag.onTrack) drag.onTrack(drag.head, drag.x, drag.y);
456
477
 
457
478
  if (!allowScroll) return;
458
479
  const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x < box.left || drag.x > box.left + width;
@@ -475,11 +496,22 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
475
496
  runDragFrameRef.current = runDragFrame;
476
497
  }, [runDragFrame]);
477
498
 
478
- const startDrag = useCallback((anchor: GridCellCoordinates, head: GridCellCoordinates, x: number, y: number, doc: Document) => {
499
+ const startDrag = useCallback((
500
+ anchor: GridCellCoordinates,
501
+ head: GridCellCoordinates,
502
+ x: number,
503
+ y: number,
504
+ doc: Document,
505
+ tracked?: Pick<GridTrackedDragOptions, 'pointerId' | 'onTrack' | 'onEnd'>,
506
+ ) => {
479
507
  dragRef.current?.stop();
508
+ // A range drag follows the mouse; a tracked drag (fill handle) follows its pointer, touch included.
509
+ const moveType = tracked ? 'pointermove' : 'mousemove';
510
+ const upType = tracked ? 'pointerup' : 'mouseup';
511
+ const isOwnEvent = (event: MouseEvent) => !tracked || (event as PointerEvent).pointerId === tracked.pointerId;
480
512
  const onMove = (event: MouseEvent) => {
481
513
  const drag = dragRef.current;
482
- if (!drag) return;
514
+ if (!drag || !isOwnEvent(event)) return;
483
515
  if (event.buttons === 0) {
484
516
  // Released outside the window.
485
517
  drag.stop();
@@ -497,12 +529,12 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
497
529
  };
498
530
  const onUp = (event: MouseEvent) => {
499
531
  const drag = dragRef.current;
500
- if (!drag) return;
532
+ if (!drag || !isOwnEvent(event)) return;
501
533
  drag.x = event.clientX;
502
534
  drag.y = event.clientY;
503
535
  runDragFrame(false);
504
536
  const moved = drag.moved;
505
- drag.stop();
537
+ drag.stop(false, event.altKey);
506
538
  if (moved) clearTextSelection(doc);
507
539
  if (moved) {
508
540
  // The click that follows lands on the common ancestor of both cells (often the row):
@@ -511,26 +543,57 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
511
543
  setTimeout(() => { suppressClickRef.current = false; }, 0);
512
544
  }
513
545
  };
546
+ const onCancel = (event: Event) => {
547
+ if (isOwnEvent(event as MouseEvent)) dragRef.current?.stop();
548
+ };
549
+ // Escape abandons a tracked drag (the fill writes nothing).
550
+ const onKeyDown = (event: KeyboardEvent) => {
551
+ if (event.key !== 'Escape' || !dragRef.current) return;
552
+ event.preventDefault();
553
+ event.stopPropagation();
554
+ dragRef.current.stop();
555
+ };
514
556
  const view = doc.defaultView;
515
557
  const onBlur = () => dragRef.current?.stop();
558
+ let ended = false;
516
559
  const drag: DragState = {
517
560
  anchor, head, x, y, frame: 0, moved: false,
518
- stop: () => {
561
+ onTrack: tracked?.onTrack,
562
+ stop: (cancelled = true, altKey = false) => {
519
563
  if (drag.frame) cancelAnimationFrame(drag.frame);
520
564
  drag.frame = 0;
521
- doc.removeEventListener('mousemove', onMove, true);
522
- doc.removeEventListener('mouseup', onUp, true);
565
+ doc.removeEventListener(moveType, onMove, true);
566
+ doc.removeEventListener(upType, onUp, true);
567
+ if (tracked) {
568
+ doc.removeEventListener('pointercancel', onCancel, true);
569
+ doc.removeEventListener('keydown', onKeyDown, true);
570
+ }
523
571
  view?.removeEventListener('blur', onBlur);
524
572
  latestRef.current.containerRef.current?.classList.remove(RANGE_DRAGGING_CLASS);
525
573
  if (dragRef.current === drag) dragRef.current = null;
574
+ if (!ended) {
575
+ ended = true;
576
+ if (tracked) tracked.onEnd({ cancelled, altKey });
577
+ }
526
578
  },
527
579
  };
528
580
  dragRef.current = drag;
529
- doc.addEventListener('mousemove', onMove, true);
530
- doc.addEventListener('mouseup', onUp, true);
581
+ doc.addEventListener(moveType, onMove, true);
582
+ doc.addEventListener(upType, onUp, true);
583
+ if (tracked) {
584
+ doc.addEventListener('pointercancel', onCancel, true);
585
+ doc.addEventListener('keydown', onKeyDown, true);
586
+ }
531
587
  view?.addEventListener('blur', onBlur);
532
588
  }, [runDragFrame]);
533
589
 
590
+ const startTrackedDrag = useCallback((options: GridTrackedDragOptions) => {
591
+ const viewport = latestRef.current.viewportRef.current;
592
+ if (!viewport || !latestRef.current.enabled) return;
593
+ const { start, x, y, pointerId, onTrack, onEnd } = options;
594
+ startDrag(start, start, x, y, viewport.ownerDocument, { pointerId, onTrack, onEnd });
595
+ }, [startDrag]);
596
+
534
597
  const handleMouseDown = useCallback((event: React.MouseEvent<HTMLElement>) => {
535
598
  if (event.button !== 0 || event.defaultPrevented) return;
536
599
  const { context: ctx } = latestRef.current;
@@ -538,6 +601,8 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
538
601
  const viewport = event.currentTarget;
539
602
  const hit = cellFromTarget(event.target, viewport);
540
603
  if (!hit || !isRangeColumnField(hit.field)) return;
604
+ // The fill handle starts its own drag (useGridFillHandle).
605
+ if ((event.target as Element).closest?.('.ogx__cell-fill-handle')) return;
541
606
  // Typing targets and open editors keep the pointer to themselves.
542
607
  if (hit.element.classList.contains('ogx__cell--editing') || isTextEntryElement(event.target as Element)) return;
543
608
  const id = ctx.rowIds[hit.rowIndex];
@@ -626,6 +691,7 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
626
691
  reportFocus,
627
692
  setModel,
628
693
  getCurrentRange: currentRange,
694
+ startTrackedDrag,
629
695
  };
630
696
  }
631
697
 
@@ -16,26 +16,46 @@ export interface UseGridColumnsPanelResult {
16
16
  standalonePanelRef: RefObject<HTMLDivElement | null>;
17
17
  /** The column menu's **Manage columns** action. */
18
18
  openColumnsPanel: () => void;
19
+ /** Passed to the toolbar as `forceFiltersOpen`. */
20
+ toolbarFiltersRequested: boolean;
21
+ /** Passed to the toolbar as `onFiltersPanelClose`. */
22
+ closeToolbarFiltersPanel: () => void;
23
+ /**
24
+ * Opens the toolbar's filter panel (a header filter cell's "Custom filter"); undefined while no
25
+ * `GridToolbar` that can show it is rendered.
26
+ */
27
+ openFiltersPanel: (() => void) | undefined;
19
28
  }
20
29
 
21
30
  /**
22
31
  * Opens the Columns panel for the column menu: in the toolbar when a `GridToolbar` has registered
23
32
  * through `GridToolbarHostContext`, otherwise as the standalone panel, which closes on Escape or a
24
- * mousedown outside it.
33
+ * mousedown outside it. Also opens the toolbar's filter panel for the header filter row, when a
34
+ * `GridToolbar` has registered that it can show it.
25
35
  */
26
36
  export function useGridColumnsPanel(): UseGridColumnsPanelResult {
27
37
  const [toolbarPanelRequested, setToolbarPanelRequested] = useState(false);
28
38
  const [standalonePanelOpen, setStandalonePanelOpen] = useState(false);
29
39
  const standalonePanelRef = useRef<HTMLDivElement>(null);
30
40
  const toolbarHostCountRef = useRef(0);
41
+ // State, not a ref: whether "Custom filter" can open the panel decides how it renders.
42
+ const [filtersHostCount, setFiltersHostCount] = useState(0);
43
+ const [toolbarFiltersRequested, setToolbarFiltersRequested] = useState(false);
31
44
 
32
45
  const toolbarHost = useMemo<GridToolbarHost>(() => ({
33
46
  registerColumnsPanel: () => {
34
47
  toolbarHostCountRef.current += 1;
35
48
  return () => { toolbarHostCountRef.current -= 1; };
36
49
  },
50
+ registerFiltersPanel: () => {
51
+ setFiltersHostCount(count => count + 1);
52
+ return () => setFiltersHostCount(count => count - 1);
53
+ },
37
54
  }), []);
38
55
 
56
+ const requestFiltersPanel = useCallback(() => setToolbarFiltersRequested(true), []);
57
+ const closeToolbarFiltersPanel = useCallback(() => setToolbarFiltersRequested(false), []);
58
+
39
59
  const openColumnsPanel = useCallback(() => {
40
60
  if (toolbarHostCountRef.current > 0) {
41
61
  setToolbarPanelRequested(true);
@@ -73,5 +93,8 @@ export function useGridColumnsPanel(): UseGridColumnsPanelResult {
73
93
  closeStandalonePanel,
74
94
  standalonePanelRef,
75
95
  openColumnsPanel,
96
+ toolbarFiltersRequested,
97
+ closeToolbarFiltersPanel,
98
+ openFiltersPanel: filtersHostCount > 0 ? requestFiltersPanel : undefined,
76
99
  };
77
100
  }
@@ -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
 
@@ -10,8 +10,16 @@ export interface UseGridDevWarningsParams {
10
10
  totalRowCount: number;
11
11
  /** `pinnedRows` is set while tree data or row grouping is active, where it has no effect. */
12
12
  pinnedRowsIgnored?: boolean;
13
- /** Pixel height of the scrollable content. */
13
+ /** Pixel height of the scrollable content, uncapped. */
14
14
  scrollHeight?: number;
15
+ /**
16
+ * The tallest element the browser lays out (`getMaxElementHeight`): `undefined` until measured (the warning
17
+ * waits), `Infinity` where there is no layout engine (the lowest known cap is used).
18
+ */
19
+ maxContentHeight?: number;
20
+ /** Unpinned rows the user can scroll to, and how many unpinned rows there are. */
21
+ reachableRowCount?: number;
22
+ unpinnedRowCount?: number;
15
23
  /** `listView` is set but `listViewColumn` is not, so the grid view is shown. */
16
24
  listViewWithoutColumn?: boolean;
17
25
  /** Number of items the list view renders (0 when the list view is not shown). It does not virtualize. */
@@ -31,7 +39,7 @@ export const LIST_VIEW_WARN_THRESHOLD = 2000;
31
39
  export const MAX_SCROLLABLE_HEIGHT = 17_800_000;
32
40
 
33
41
  export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
34
- const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, listViewWithoutColumn = false, listViewRowCount = 0 } = params;
42
+ const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, maxContentHeight, reachableRowCount, unpinnedRowCount, listViewWithoutColumn = false, listViewRowCount = 0 } = params;
35
43
  const warnedPaginationRef = useRef(false);
36
44
  const warnedUnboundedRef = useRef(false);
37
45
  const warnedPinnedRowsRef = useRef(false);
@@ -67,14 +75,21 @@ export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
67
75
  }, [pinnedRowsIgnored]);
68
76
 
69
77
  useEffect(() => {
70
- if (process.env.NODE_ENV === 'production' || warnedHeightRef.current || scrollHeight <= MAX_SCROLLABLE_HEIGHT) return;
78
+ if (process.env.NODE_ENV === 'production' || warnedHeightRef.current || maxContentHeight === undefined) return;
79
+ const measured = Number.isFinite(maxContentHeight);
80
+ // Once the browser's cap is measured, warn only when rows are actually cut off; before that (and
81
+ // where there is no layout engine) fall back to the lowest known cap.
82
+ if (measured ? scrollHeight <= maxContentHeight : scrollHeight <= MAX_SCROLLABLE_HEIGHT) return;
71
83
  warnedHeightRef.current = true;
84
+ const reach = measured && reachableRowCount !== undefined && unpinnedRowCount !== undefined
85
+ ? `Only the first ${reachableRowCount.toLocaleString('en-US')} of ${unpinnedRowCount.toLocaleString('en-US')} rows can be scrolled to in this browser. `
86
+ : 'The last rows may not be reachable by scrolling. ';
72
87
  console.warn(
73
- `[OpenGridX] The grid content is ${Math.round(scrollHeight)}px tall. Browsers cap element heights ` +
74
- '(about 17.9M px in Firefox, 33.5M px in Chrome and Safari), so the last rows may not be reachable by scrolling. ' +
88
+ `[OpenGridX] The grid content is ${Math.round(scrollHeight)}px tall, more than this browser lays out ` +
89
+ '(about 17.9M px in Firefox, 33.5M px in Chrome and Safari). ' + reach +
75
90
  'Use pagination, server-side or infinite loading, or a smaller rowHeight. See docs/features/virtualization.md.'
76
91
  );
77
- }, [scrollHeight]);
92
+ }, [scrollHeight, maxContentHeight, reachableRowCount, unpinnedRowCount]);
78
93
 
79
94
  useEffect(() => {
80
95
  if (process.env.NODE_ENV === 'production' || warnedPaginationRef.current) return;
@@ -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 };