@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
@@ -0,0 +1,157 @@
1
+ import { useCallback, useLayoutEffect, useRef } from 'react';
2
+ import type { GridColDef, GridColumnPinning, GridFilterItem, GridFilterModel } from '../../types';
3
+ import { isColumnPinned, SYSTEM_COLUMN_WIDTH } from '../../utils/pinning';
4
+ import { CHECKBOX_FIELD, EXPAND_FIELD, REORDER_FIELD } from '../../utils/focus';
5
+ import { getHeaderFilterState, hasHeaderFilter, setHeaderFilterItem } from '../../utils/headerFilters';
6
+ import { getHeaderColumnStyle } from '../Header/headerColumnStyle';
7
+ import { HeaderFilterCell } from './HeaderFilterCell';
8
+
9
+ export interface HeaderFilterRowProps {
10
+ /** The rendered columns of the column header row (render window with spacers), in render order. */
11
+ columns: GridColDef[];
12
+ columnWidths: Record<string, number>;
13
+ pinnedColumns?: GridColumnPinning;
14
+ /** Sticky offsets of the pinned columns (`calculatePinnedPositions`). */
15
+ pinnedPositions: Record<string, number>;
16
+ pinnedEdges: { lastLeft: string | null; firstRight: string | null };
17
+ rowReordering: boolean;
18
+ hasDetailPanel: boolean;
19
+ checkboxSelection: boolean;
20
+ pinExpandColumn: boolean;
21
+ pinCheckboxColumn: boolean;
22
+ columnIndexMap?: Map<string, number>;
23
+ /** `aria-rowindex` of the row: right after the column header row. */
24
+ ariaRowIndex: number;
25
+ filterModel?: GridFilterModel;
26
+ onFilterModelChange?: (model: GridFilterModel) => void;
27
+ onOpenFilterPanel?: () => void;
28
+ /** Field of the focused filter cell, or null. */
29
+ focusedField: string | null;
30
+ }
31
+
32
+ function SystemFilterCell({ field, ariaColIndex, sticky, left, focused }: {
33
+ field: string;
34
+ ariaColIndex: number;
35
+ sticky: boolean;
36
+ left: number;
37
+ focused: boolean;
38
+ }) {
39
+ return (
40
+ <div
41
+ className={[
42
+ 'ogx__header-filter-cell',
43
+ 'ogx__header-filter-cell--system',
44
+ sticky && 'ogx__header-filter-cell--pinned ogx__header-filter-cell--pinned-left',
45
+ focused && 'ogx__header-filter-cell--focused',
46
+ ].filter(Boolean).join(' ')}
47
+ style={{
48
+ width: SYSTEM_COLUMN_WIDTH,
49
+ minWidth: SYSTEM_COLUMN_WIDTH,
50
+ maxWidth: SYSTEM_COLUMN_WIDTH,
51
+ position: sticky ? 'sticky' : undefined,
52
+ left: sticky ? left : undefined,
53
+ }}
54
+ role="columnheader"
55
+ aria-colindex={ariaColIndex}
56
+ data-field={field}
57
+ tabIndex={-1}
58
+ />
59
+ );
60
+ }
61
+
62
+ /**
63
+ * The header filter row (`headerFilters`): one cell per rendered column, laid out like the column
64
+ * header row above it, so pinned and virtualized columns line up. It edits the shared filter model
65
+ * through the root-level items it owns (`header:<field>`).
66
+ */
67
+ export function HeaderFilterRow(props: HeaderFilterRowProps) {
68
+ const {
69
+ columns, columnWidths, pinnedColumns, pinnedPositions, pinnedEdges, rowReordering, hasDetailPanel,
70
+ checkboxSelection, pinExpandColumn, pinCheckboxColumn, columnIndexMap, ariaRowIndex, filterModel,
71
+ onFilterModelChange, onOpenFilterPanel, focusedField,
72
+ } = props;
73
+
74
+ // Cells commit from timers and unmount cleanups: they read the newest model, and two commits
75
+ // before the next render build on each other.
76
+ const latestRef = useRef({ filterModel, onFilterModelChange });
77
+ useLayoutEffect(() => {
78
+ latestRef.current = { filterModel, onFilterModelChange };
79
+ });
80
+ const handleItemChange = useCallback((field: string, item: GridFilterItem | null) => {
81
+ const { filterModel: current, onFilterModelChange: emit } = latestRef.current;
82
+ if (!emit) return;
83
+ const next = setHeaderFilterItem(current, field, item);
84
+ if (next === current) return;
85
+ latestRef.current = { filterModel: next, onFilterModelChange: emit };
86
+ emit(next);
87
+ }, []);
88
+
89
+ const reorderColIndex = rowReordering ? 1 : 0;
90
+ const expandColIndex = hasDetailPanel ? reorderColIndex + 1 : reorderColIndex;
91
+ const checkboxColIndex = checkboxSelection ? expandColIndex + 1 : expandColIndex;
92
+ const systemColumnCount = checkboxColIndex;
93
+ const expandLeft = rowReordering ? SYSTEM_COLUMN_WIDTH : 0;
94
+ const checkboxLeft = expandLeft + (hasDetailPanel && pinExpandColumn ? SYSTEM_COLUMN_WIDTH : 0);
95
+
96
+ return (
97
+ <div className="ogx__header-filter-row" role="row" aria-rowindex={ariaRowIndex}>
98
+ {rowReordering && (
99
+ <SystemFilterCell field={REORDER_FIELD} ariaColIndex={reorderColIndex} sticky left={0} focused={focusedField === REORDER_FIELD} />
100
+ )}
101
+ {hasDetailPanel && (
102
+ <SystemFilterCell field={EXPAND_FIELD} ariaColIndex={expandColIndex} sticky={pinExpandColumn} left={expandLeft} focused={focusedField === EXPAND_FIELD} />
103
+ )}
104
+ {checkboxSelection && (
105
+ <SystemFilterCell field={CHECKBOX_FIELD} ariaColIndex={checkboxColIndex} sticky={pinCheckboxColumn} left={checkboxLeft} focused={focusedField === CHECKBOX_FIELD} />
106
+ )}
107
+ {columns.map((colDef, index) => {
108
+ if (colDef.isSpacer) {
109
+ return (
110
+ <div
111
+ key={colDef.field}
112
+ style={{ width: colDef.width, minWidth: colDef.width, flexShrink: 0 }}
113
+ aria-hidden="true"
114
+ />
115
+ );
116
+ }
117
+ const pinnedPosition = isColumnPinned(colDef.field, pinnedColumns);
118
+ const style = getHeaderColumnStyle(colDef, columnWidths, pinnedPosition, pinnedPosition ? pinnedPositions[colDef.field] : undefined);
119
+ const ariaColIndex = systemColumnCount + (columnIndexMap?.get(colDef.field) ?? index) + 1;
120
+ const className = [
121
+ pinnedPosition && 'ogx__header-filter-cell--pinned',
122
+ pinnedPosition && `ogx__header-filter-cell--pinned-${pinnedPosition}`,
123
+ pinnedPosition === 'left' && colDef.field === pinnedEdges.lastLeft && 'ogx__header-filter-cell--pinned-left-last',
124
+ pinnedPosition === 'right' && colDef.field === pinnedEdges.firstRight && 'ogx__header-filter-cell--pinned-right-first',
125
+ focusedField === colDef.field && 'ogx__header-filter-cell--focused',
126
+ ].filter(Boolean).join(' ');
127
+
128
+ if (!hasHeaderFilter(colDef)) {
129
+ return (
130
+ <div
131
+ key={colDef.field}
132
+ className={`ogx__header-filter-cell ogx__header-filter-cell--empty ${className}`}
133
+ style={style}
134
+ role="columnheader"
135
+ aria-label={`No filter for ${colDef.headerName || colDef.field}`}
136
+ aria-colindex={ariaColIndex}
137
+ data-field={colDef.field}
138
+ tabIndex={-1}
139
+ />
140
+ );
141
+ }
142
+ return (
143
+ <HeaderFilterCell
144
+ key={colDef.field}
145
+ colDef={colDef}
146
+ state={getHeaderFilterState(filterModel, colDef.field)}
147
+ style={style}
148
+ className={className}
149
+ ariaColIndex={ariaColIndex}
150
+ onItemChange={handleItemChange}
151
+ onOpenFilterPanel={onOpenFilterPanel}
152
+ />
153
+ );
154
+ })}
155
+ </div>
156
+ );
157
+ }
@@ -10,6 +10,7 @@ import { GlobalSearch } from './GlobalSearch';
10
10
  import { ColumnVisibilityPanel } from '../ColumnVisibilityPanel/ColumnVisibilityPanel';
11
11
  import { FilterPanel } from '../FilterPanel/FilterPanel';
12
12
  import { GridTooltip } from '../Tooltip/Tooltip';
13
+ import { AiSparkleIcon } from '../AiAssistant/GridAiAssistantPanel';
13
14
 
14
15
  /** Props passed to a custom toolbar button renderer. */
15
16
  export interface ToolbarButtonRenderProps {
@@ -48,6 +49,10 @@ export interface GridToolbarProps {
48
49
  onColumnOrderReset?: () => void;
49
50
  forceColumnsOpen?: boolean;
50
51
  onColumnsPanelClose?: () => void;
52
+ /** Opens the filter panel when it turns true (the grid passes it for the header filter row). @since v3.5 */
53
+ forceFiltersOpen?: boolean;
54
+ /** Called whenever the filter panel closes. @since v3.5 */
55
+ onFiltersPanelClose?: () => void;
51
56
  children?: React.ReactNode;
52
57
  rightContent?: React.ReactNode;
53
58
  style?: React.CSSProperties;
@@ -80,6 +85,16 @@ export interface GridToolbarProps {
80
85
  className?: string;
81
86
  /** Show columns with `hideable: false` in the Columns panel as disabled rows. Default: false. */
82
87
  showNonHideableColumns?: boolean;
88
+ /**
89
+ * Opens or closes the `aiAssistant` panel. The grid passes it when `aiAssistant` is set; the
90
+ * **Ask AI** button is shown only with it. Receives the button, which gets focus back on close.
91
+ * @since v3.5
92
+ */
93
+ onAiAssistantToggle?: (trigger: HTMLElement | null) => void;
94
+ /** Whether the `aiAssistant` panel is open. @since v3.5 */
95
+ aiAssistantOpen?: boolean;
96
+ /** Label of the Ask AI button (`localeText.aiAssistantButton`). Default: `"Ask AI"`. @since v3.5 */
97
+ aiAssistantLabel?: string;
83
98
  }
84
99
 
85
100
  const BUILT_IN_AGGREGATION_FUNCTIONS = Object.keys(AGGREGATION_FUNCTIONS) as BuiltInAggFn[];
@@ -492,6 +507,8 @@ export function GridToolbar({
492
507
  onColumnOrderReset,
493
508
  forceColumnsOpen,
494
509
  onColumnsPanelClose,
510
+ forceFiltersOpen,
511
+ onFiltersPanelClose,
495
512
  children,
496
513
  rightContent,
497
514
  style,
@@ -502,6 +519,9 @@ export function GridToolbar({
502
519
  renderQuickFilter,
503
520
  className,
504
521
  showNonHideableColumns,
522
+ onAiAssistantToggle,
523
+ aiAssistantOpen = false,
524
+ aiAssistantLabel = 'Ask AI',
505
525
  }: GridToolbarProps) {
506
526
  // At most one panel is open. Every way the Columns panel closes (its button, a custom
507
527
  // button, another panel opening, click-outside, Escape) reports onColumnsPanelClose, so the
@@ -514,6 +534,7 @@ export function GridToolbar({
514
534
 
515
535
  const switchPanel = (next: ToolbarPanel | null) => {
516
536
  if (openPanel === 'columns' && next !== 'columns') onColumnsPanelClose?.();
537
+ if (openPanel === 'filters' && next !== 'filters') onFiltersPanelClose?.();
517
538
  setOpenPanel(next);
518
539
  };
519
540
  const togglePanel = (panel: ToolbarPanel) => switchPanel(openPanel === panel ? null : panel);
@@ -526,6 +547,12 @@ export function GridToolbar({
526
547
  setSeenForceColumnsOpen(forceColumnsOpen);
527
548
  if (forceColumnsOpen) setOpenPanel('columns');
528
549
  }
550
+ // forceFiltersOpen turning true opens the filter panel, the same way.
551
+ const [seenForceFiltersOpen, setSeenForceFiltersOpen] = useState(forceFiltersOpen);
552
+ if (seenForceFiltersOpen !== forceFiltersOpen) {
553
+ setSeenForceFiltersOpen(forceFiltersOpen);
554
+ if (forceFiltersOpen && onFilterModelChange) setOpenPanel('filters');
555
+ }
529
556
 
530
557
  // Tell the grid this toolbar shows the Columns panel for the column menu; it can only do so
531
558
  // when the grid's forceColumnsOpen reaches it and the Columns panel is enabled.
@@ -535,8 +562,14 @@ export function GridToolbar({
535
562
  if (!toolbarHost || !hostsColumnsPanel) return undefined;
536
563
  return toolbarHost.registerColumnsPanel();
537
564
  }, [toolbarHost, hostsColumnsPanel]);
565
+ const hostsFiltersPanel = forceFiltersOpen !== undefined && Boolean(onFilterModelChange);
566
+ useEffect(() => {
567
+ if (!toolbarHost || !hostsFiltersPanel) return undefined;
568
+ return toolbarHost.registerFiltersPanel();
569
+ }, [toolbarHost, hostsFiltersPanel]);
538
570
 
539
571
  const aggButtonRef = useRef<HTMLButtonElement>(null);
572
+ const aiButtonRef = useRef<HTMLButtonElement>(null);
540
573
  const pivotButtonRef = useRef<HTMLButtonElement>(null);
541
574
  const colsButtonRef = useRef<HTMLButtonElement>(null);
542
575
  const filterButtonRef = useRef<HTMLButtonElement>(null);
@@ -642,6 +675,24 @@ export function GridToolbar({
642
675
 
643
676
  {/* Right: built-in buttons */}
644
677
  <div className="ogx-toolbar__actions">
678
+ {onAiAssistantToggle && (
679
+ <div className="ogx-toolbar__dropdown-wrapper" style={{ marginRight: 4 }}>
680
+ <button
681
+ type="button"
682
+ ref={aiButtonRef}
683
+ className={`ogx-toolbar__icon-btn ogx-toolbar__ai-btn${aiAssistantOpen ? ' ogx-toolbar__icon-btn--active' : ''}`}
684
+ aria-haspopup="dialog"
685
+ aria-expanded={aiAssistantOpen}
686
+ onClick={() => {
687
+ switchPanel(null);
688
+ onAiAssistantToggle(aiButtonRef.current);
689
+ }}
690
+ >
691
+ <AiSparkleIcon />
692
+ {aiAssistantLabel}
693
+ </button>
694
+ </div>
695
+ )}
645
696
  {onFilterModelChange && (
646
697
  <div className="ogx-toolbar__dropdown-wrapper" style={{ marginRight: 4 }}>
647
698
  {renderQuickFilter
@@ -45,5 +45,13 @@ export function createGridApiPlaceholder<R extends GridValidRowModel = GridRowMo
45
45
  canUndo: () => false,
46
46
  canRedo: () => false,
47
47
  clearHistory: noop,
48
+ setSortModel: noop,
49
+ setRowGroupingModel: noop,
50
+ setAggregationModel: noop,
51
+ setColumnVisibilityModel: noop,
52
+ setPivotModel: noop,
53
+ getGridAiState: () => ({}),
54
+ openAiAssistant: noop,
55
+ closeAiAssistant: noop,
48
56
  };
49
57
  }
@@ -9,6 +9,11 @@ import { createContext } from 'react';
9
9
  export interface GridToolbarHost {
10
10
  /** Registers a toolbar that can show the Columns panel. Returns the function that unregisters it. */
11
11
  registerColumnsPanel: () => () => void;
12
+ /**
13
+ * Registers a toolbar that can show the filter panel when the header filter row asks for it
14
+ * (through `forceFiltersOpen`). Returns the function that unregisters it.
15
+ */
16
+ registerFiltersPanel: () => () => void;
12
17
  }
13
18
 
14
19
  export const GridToolbarHostContext = createContext<GridToolbarHost | null>(null);
@@ -1,9 +1,13 @@
1
1
  import { useLayoutEffect, useRef } from 'react';
2
2
  import type {
3
+ GridAggregationModel,
3
4
  GridApi,
4
5
  GridColDef,
6
+ GridColumnVisibilityModel,
5
7
  GridFilterModel,
6
8
  GridPaginationModel,
9
+ GridPivotModel,
10
+ GridRowGroupingModel,
7
11
  GridRowId,
8
12
  GridRowModel,
9
13
  GridSortItem,
@@ -45,6 +49,16 @@ export interface UseGridApiMethodsParams {
45
49
  getAllFilteredRows: () => GridRowModel[];
46
50
  /** Columns on screen, in display order. */
47
51
  visibleColumns: GridColDef[];
52
+
53
+ // v3.5: the remaining models, for the setters and getGridAiState.
54
+ rowGroupingModel: GridRowGroupingModel;
55
+ onRowGroupingModelChange: (model: GridRowGroupingModel) => void;
56
+ aggregationModel: GridAggregationModel;
57
+ onAggregationModelChange: (model: GridAggregationModel) => void;
58
+ columnVisibilityModel: GridColumnVisibilityModel;
59
+ onColumnVisibilityModelChange: (model: GridColumnVisibilityModel) => void;
60
+ pivotModel: GridPivotModel;
61
+ onPivotModelChange: (model: GridPivotModel) => void;
48
62
  }
49
63
 
50
64
  /**
@@ -111,5 +125,30 @@ export function useGridApiMethods(params: UseGridApiMethodsParams): void {
111
125
  api.getVisibleRows = () => latest().getVisibleRows();
112
126
  api.getAllFilteredRows = () => latest().getAllFilteredRows();
113
127
  api.getVisibleColumns = () => latest().visibleColumns;
128
+
129
+ api.setSortModel = (model) => {
130
+ latestRef.current = { ...latest(), sortModel: model };
131
+ latest().onSortModelChange(model);
132
+ };
133
+ api.setRowGroupingModel = (model) => {
134
+ latestRef.current = { ...latest(), rowGroupingModel: model };
135
+ latest().onRowGroupingModelChange(model);
136
+ };
137
+ api.setAggregationModel = (model) => {
138
+ latestRef.current = { ...latest(), aggregationModel: model };
139
+ latest().onAggregationModelChange(model);
140
+ };
141
+ api.setColumnVisibilityModel = (model) => {
142
+ latestRef.current = { ...latest(), columnVisibilityModel: model };
143
+ latest().onColumnVisibilityModelChange(model);
144
+ };
145
+ api.setPivotModel = (model) => {
146
+ latestRef.current = { ...latest(), pivotModel: model };
147
+ latest().onPivotModelChange(model);
148
+ };
149
+ api.getGridAiState = () => {
150
+ const { filterModel, sortModel, rowGroupingModel, aggregationModel, pivotModel, columnVisibilityModel } = latest();
151
+ return { filterModel, sortModel, rowGroupingModel, aggregationModel, pivotModel, columnVisibilityModel };
152
+ };
114
153
  }, [apiRef]);
115
154
  }
@@ -5,6 +5,8 @@ import type { GridColumnGroupingModel, GridPaginationModel } from '../../types';
5
5
 
6
6
  export interface UseGridAriaRowsParams {
7
7
  columnGroupingModel: GridColumnGroupingModel | undefined;
8
+ /** Whether the header filter row (`headerFilters`) is rendered: one more header row. */
9
+ headerFilterRow?: boolean;
8
10
  pinnedTopCount: number;
9
11
  pinnedBottomCount: number;
10
12
  pagination: boolean;
@@ -19,13 +21,13 @@ export interface UseGridAriaRowsParams {
19
21
  /** `aria-rowcount` and the `aria-rowindex` bases of the pinned and centre rows. */
20
22
  export function useGridAriaRows(params: UseGridAriaRowsParams): GridAriaRowLayout {
21
23
  const {
22
- columnGroupingModel, pinnedTopCount, pinnedBottomCount, pagination, paginationMode, paginationModel,
24
+ columnGroupingModel, headerFilterRow = false, pinnedTopCount, pinnedBottomCount, pagination, paginationMode, paginationModel,
23
25
  paginationRowCount, paginatedUnpinnedRowCount, sortedUnpinnedRowCount,
24
26
  } = params;
25
27
  const { page, pageSize } = paginationModel;
26
28
 
27
29
  return useMemo(() => getAriaRowLayout({
28
- headerRowCount: 1 + getColumnGroupDepth(columnGroupingModel),
30
+ headerRowCount: 1 + getColumnGroupDepth(columnGroupingModel) + (headerFilterRow ? 1 : 0),
29
31
  pinnedTopCount,
30
32
  pinnedBottomCount,
31
33
  pageRowCount: pagination ? paginatedUnpinnedRowCount : sortedUnpinnedRowCount,
@@ -33,6 +35,6 @@ export function useGridAriaRows(params: UseGridAriaRowsParams): GridAriaRowLayou
33
35
  ? Math.max(paginationRowCount, paginatedUnpinnedRowCount)
34
36
  : sortedUnpinnedRowCount,
35
37
  pageOffset: pagination ? page * pageSize : 0,
36
- }), [columnGroupingModel, pinnedTopCount, pinnedBottomCount, pagination, paginatedUnpinnedRowCount,
38
+ }), [columnGroupingModel, headerFilterRow, pinnedTopCount, pinnedBottomCount, pagination, paginatedUnpinnedRowCount,
37
39
  sortedUnpinnedRowCount, paginationMode, paginationRowCount, page, pageSize]);
38
40
  }
@@ -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
  }