@opencorestack/opengridx 0.1.7 → 0.1.9

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.
@@ -1,4 +1,6 @@
1
1
  import React, { useRef, useEffect, useCallback, useMemo, useState } from 'react';
2
+ import { useLayout } from '../../hooks/core/useLayout';
3
+ import { useGridKeyboardNavigation } from '../../hooks/core/useGridKeyboardNavigation';
2
4
  import ReactDOM from 'react-dom';
3
5
  import { ListViewRow } from '../ListView/ListViewRow';
4
6
  import type { GridState } from '../../state/types';
@@ -19,11 +21,10 @@ import { usePivot } from '../../hooks/features/usePivot';
19
21
  import { useGridClipboard } from '../../hooks/features/useGridClipboard';
20
22
  import { ExpandIcon } from '../ui/ExpandIcon';
21
23
  import { ColumnVisibilityPanel } from '../ColumnVisibilityPanel/ColumnVisibilityPanel';
22
- import { getPinnedRowGroups, isRowPinned, isColumnPinned } from '../../utils/pinning';
24
+ import { getPinnedRowGroups, isRowPinned } from '../../utils/pinning';
23
25
  import { sortRows } from '../../utils/sorting';
24
26
  import { filterRows } from '../../utils/filtering';
25
27
  import type { DataGridProps, GridRowModel, GridRowId, GridSortDirection, GridColumnOrderChangeParams, GridColDef, GridRenderCellParams, GridRowParams, GridCellParams, GridPaginationModel, GridDataSource, GridAggregationResult, GridAggregationModel, GridPivotModel, GridColumnPinning, GridFilterModel } from '../../types';
26
- import '../../styles/opengridx.css';
27
28
 
28
29
  export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridProps<R>) {
29
30
  const {
@@ -123,16 +124,30 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
123
124
  const isAggregationControlled = propAggregationModel !== undefined;
124
125
 
125
126
  // ─── Stabilize toolbar component identity ────────────────────────────────
126
- // If the consumer passes an inline arrow function as slots.toolbar (e.g.
127
- // `slots={{ toolbar: (p) => <GridToolbar {...p}/> }}`), a new function is
128
- // created on every parent render. React reconciles by component *identity*,
129
- // so a changed reference means it unmounts the old toolbar and mounts a new
130
- // one — destroying focus, replaying animations, etc.
131
- // useMemo with an empty dep-array gives React a stable identity. If the
132
- // caller genuinely changes slots.toolbar (e.g. swaps to a different UI),
133
- // the new value IS used; we just don't thrash for incidental re-renders.
134
- // eslint-disable-next-line react-hooks/exhaustive-deps
135
- const StableToolbar = useMemo(() => slots?.toolbar, [slots?.toolbar]);
127
+ // Problem: demos/users often define their toolbar as an inline function
128
+ // INSIDE their component body, e.g.:
129
+ // const MyToolbar = (props) => <GridToolbar {...props} /> // inside render!
130
+ // This creates a NEW function reference on every parent re-render. React
131
+ // reconciles by component *identity*, so a new reference = unmount old
132
+ // toolbar + mount fresh one = all toolbar state (search expansion, filter
133
+ // open, typed text) is destroyed on every keystroke.
134
+ //
135
+ // Fix: a stable wrapper component created ONCE via useRef. Its identity
136
+ // never changes, so React keeps it mounted. It reads the latest toolbar
137
+ // from a separate ref that is updated on every render, so the rendered
138
+ // output is always current — zero stale closures.
139
+ const latestToolbarRef = useRef(slots?.toolbar);
140
+ latestToolbarRef.current = slots?.toolbar;
141
+ // The wrapper itself has a stable identity (created once via useRef).
142
+ // We call latestToolbarRef.current(props) as a PLAIN FUNCTION — not via
143
+ // React.createElement — so React never sees a changing component type.
144
+ // The elements the toolbar function returns are reconciled normally, so
145
+ // GridToolbar's internal state (filterOpen, search expansion) is preserved
146
+ // even when the toolbar is defined as an inline function inside the parent.
147
+ const StableToolbar = useRef((props: Record<string, unknown>) => {
148
+ const Toolbar = latestToolbarRef.current;
149
+ return Toolbar ? (Toolbar as (p: typeof props) => React.ReactElement | null)(props) : null;
150
+ }).current;
136
151
  const [internalAggregationModel, setInternalAggregationModel] = useState<GridAggregationModel>(
137
152
  () => propAggregationModel ?? {}
138
153
  );
@@ -215,8 +230,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
215
230
  const defaultGetRowId = useCallback((row: R) => row.id, []);
216
231
  const effectiveGetRowId = getRowId || defaultGetRowId;
217
232
 
218
- const [focusedCell, setFocusedCell] = useState<{ id: GridRowId; field: string } | null>(null);
219
-
220
233
  // Keyboard-mode flag: toggled via DOM classname — no React state needed
221
234
  // so the ring appears instantly without a re-render cycle.
222
235
  const setKeyboardMode = useCallback((on: boolean) => {
@@ -710,325 +723,19 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
710
723
  gridData.apiRef.current.getAggregationModel = () => hasAggregation ? aggregationModel : null;
711
724
  }, [aggregationResult, aggregationModel, hasAggregation, gridData.apiRef]);
712
725
 
713
- const layout = useMemo(() => {
714
- const baseRowHeight = rowHeight;
715
-
716
- const unpinnedRows = pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
717
- const rowHeights = unpinnedRows.map((row, index) => {
718
- let height = baseRowHeight;
719
- if (expandedRowIds.has(row.id)) {
720
- const detailHeight = getDetailPanelHeight?.({ row, id: row.id, rowIndex: pinnedTopRows.length + index }) ?? 200;
721
- height += typeof detailHeight === 'number' ? detailHeight : parseInt(String(detailHeight), 10) || 200;
722
- }
723
- return height;
724
- });
725
-
726
- const cumulativeHeights = rowHeights.reduce((acc, height, index) => {
727
- acc.push((acc[index - 1] || 0) + height);
728
- return acc;
729
- }, [] as number[]);
730
-
731
- const skeletonCount = (paginationMode === 'infinite' && state.dataSource.loading && (unpinnedRows.length > 0))
732
- ? Math.min(effectivePaginationModel.pageSize, 20)
733
- : 0;
734
- const skeletonRowsHeight = skeletonCount * baseRowHeight;
735
- const unpinnedRowsHeight = (cumulativeHeights[cumulativeHeights.length - 1] || 0) + skeletonRowsHeight;
736
- const pinnedTopHeight = pinnedTopRows.length * baseRowHeight;
737
- const pinnedBottomHeight = pinnedBottomRows.length * baseRowHeight;
738
-
739
- const rawLeftPinnedCols: GridColDef<R>[] = [];
740
- const rawRightPinnedCols: GridColDef<R>[] = [];
741
- const unpinnedCols: GridColDef<R>[] = [];
742
- const unpinnedColWidths: number[] = [];
743
-
744
- visibleOrderedColumns.forEach(col => {
745
- const pinned = isColumnPinned(col.field, pinnedColumns);
746
- if (pinned === 'left') {
747
- rawLeftPinnedCols.push(col);
748
- } else if (pinned === 'right') {
749
- rawRightPinnedCols.push(col);
750
- } else {
751
- unpinnedCols.push(col);
752
- }
753
- });
754
-
755
- const viewportWidth = state.dimensions.viewportWidth || 1000;
756
- const systemColumnsWidth = (checkboxSelection ? 48 : 0) + (hasDetailPanel ? 48 : 0) + (rowReordering ? 48 : 0);
757
-
758
- const getColWidth = (c: GridColDef<R>) => columnWidths[c.field] ?? c.width;
759
-
760
- const parseWidth = (width: number | string | undefined): { type: 'fixed' | 'percentage' | 'auto', value: number } => {
761
- if (width === undefined) return { type: 'auto', value: 0 };
762
- if (typeof width === 'number') return { type: 'fixed', value: width };
763
- if (typeof width === 'string') {
764
- if (width.toLowerCase() === 'auto') return { type: 'auto', value: 0 };
765
- if (width.endsWith('%')) {
766
- const percentage = parseFloat(width);
767
- return { type: 'percentage', value: percentage };
768
- }
769
- const floatVal = parseFloat(width);
770
- if (!isNaN(floatVal)) return { type: 'fixed', value: floatVal };
771
- }
772
- return { type: 'fixed', value: 100 };
773
- };
774
-
775
- const leftWidth = rawLeftPinnedCols.reduce((sum, c) => {
776
- const parsed = parseWidth(getColWidth(c));
777
- return sum + (parsed.type === 'fixed' ? parsed.value : 100);
778
- }, 0);
779
- const rightWidth = rawRightPinnedCols.reduce((sum, c) => {
780
- const parsed = parseWidth(getColWidth(c));
781
- return sum + (parsed.type === 'fixed' ? parsed.value : 100);
782
- }, 0);
783
-
784
- const naturalFlexWidth = unpinnedCols.reduce((sum, col) => {
785
- const hasManualWidth = Object.prototype.hasOwnProperty.call(columnWidths, col.field);
786
- if (!hasManualWidth && col.flex && col.flex > 0) {
787
- const minWidth = col.minWidth ?? 150;
788
- const width = typeof col.width === 'number' ? col.width : 150;
789
- return sum + (minWidth || width);
790
- }
791
- const parsed = parseWidth(getColWidth(col));
792
- if (parsed.type === 'fixed') return sum + parsed.value;
793
- if (parsed.type === 'percentage') return sum + 100;
794
- return sum + (col.minWidth ?? 150);
795
- }, 0);
796
-
797
- const availableWidth = Math.max(
798
- viewportWidth - systemColumnsWidth - leftWidth - rightWidth,
799
- naturalFlexWidth
800
- );
801
-
802
- const fixedWidthCols: Array<{ col: GridColDef<R>, width: number }> = [];
803
- const percentageCols: Array<{ col: GridColDef<R>, percentage: number }> = [];
804
- const flexCols: Array<{ col: GridColDef<R>, flex: number }> = [];
805
- let totalFlexUnits = 0;
806
-
807
- unpinnedCols.forEach(col => {
808
- // If the user has manually resized this column, columnWidths has an explicit
809
- // entry for it. Respect that stored width as fixed-width so the resize sticks,
810
- // even if the column definition originally had flex.
811
- const hasManualWidth = Object.prototype.hasOwnProperty.call(columnWidths, col.field);
812
-
813
- const parsed = parseWidth(getColWidth(col));
814
-
815
- if (!hasManualWidth && col.flex && col.flex > 0) {
816
- flexCols.push({ col, flex: col.flex });
817
- totalFlexUnits += col.flex;
818
- return;
819
- }
820
-
821
- if (parsed.type === 'fixed') {
822
- fixedWidthCols.push({ col, width: parsed.value });
823
- } else if (parsed.type === 'percentage') {
824
- percentageCols.push({ col, percentage: parsed.value });
825
- } else {
826
-
827
- const implicitFlex = 1;
828
- flexCols.push({ col, flex: implicitFlex });
829
- totalFlexUnits += implicitFlex;
830
- }
831
- });
832
-
833
- const fixedWidth = fixedWidthCols.reduce((sum, { width }) => sum + width, 0);
834
-
835
- let remainingSpaceForFlex = Math.max(0, availableWidth - fixedWidth);
836
-
837
- const percentageWidthMap = new Map<string, number>();
838
- percentageCols.forEach(({ col, percentage }) => {
839
- const calculatedWidth = (percentage / 100) * remainingSpaceForFlex;
840
- percentageWidthMap.set(col.field, calculatedWidth);
841
-
842
- remainingSpaceForFlex -= calculatedWidth;
843
- });
844
-
845
- remainingSpaceForFlex = Math.max(0, remainingSpaceForFlex);
846
-
847
- const finalFlexWidths = new Map<string, number>();
848
-
849
- const flexItems = flexCols.map(f => ({
850
- ...f,
851
- minWidth: f.col.minWidth ?? 50,
852
- maxWidth: f.col.maxWidth ?? Infinity,
853
- frozen: false,
854
- computedWidth: 0
855
- }));
856
-
857
- const solveFlexAllocation = () => {
858
-
859
- let iterations = 0;
860
- const maxIterations = flexItems.length * 2;
861
-
862
- while (iterations < maxIterations) {
863
- iterations++;
864
-
865
- const unfrozen = flexItems.filter(f => !f.frozen);
866
-
867
- if (unfrozen.length === 0) break;
868
-
869
- const unfrozenFlexTotal = unfrozen.reduce((sum, f) => sum + f.flex, 0);
870
-
871
- const frozenWidthTotal = flexItems.reduce((sum, f) => f.frozen ? sum + f.computedWidth : sum, 0);
872
- const currentFreeSpace = Math.max(0, remainingSpaceForFlex - frozenWidthTotal);
873
-
874
- if (unfrozenFlexTotal <= 0) {
875
- unfrozen.forEach(f => {
876
- f.computedWidth = f.minWidth;
877
- f.frozen = true;
878
- });
879
- break;
880
- }
881
-
882
- const pixelsPerFlex = currentFreeSpace / unfrozenFlexTotal;
883
-
884
- let totalViolation = 0;
885
- const minViolators: typeof flexItems = [];
886
- const maxViolators: typeof flexItems = [];
887
-
888
- unfrozen.forEach(f => {
889
- const rawWidth = pixelsPerFlex * f.flex;
890
-
891
- if (rawWidth < f.minWidth) {
892
- const diff = f.minWidth - rawWidth;
893
- totalViolation += diff;
894
- minViolators.push(f);
895
- } else if (rawWidth > f.maxWidth) {
896
- const diff = f.maxWidth - rawWidth;
897
- totalViolation += diff;
898
- maxViolators.push(f);
899
- } else {
900
-
901
- f.computedWidth = rawWidth;
902
- }
903
- });
904
-
905
- if (minViolators.length === 0 && maxViolators.length === 0) {
906
- unfrozen.forEach(f => f.frozen = true);
907
- break;
908
- }
909
-
910
- if (totalViolation > 0) {
911
- minViolators.forEach(f => {
912
- f.computedWidth = f.minWidth;
913
- f.frozen = true;
914
- });
915
- } else if (totalViolation < 0) {
916
- maxViolators.forEach(f => {
917
- f.computedWidth = f.maxWidth;
918
- f.frozen = true;
919
- });
920
- } else {
921
-
922
- minViolators.forEach(f => { f.computedWidth = f.minWidth; f.frozen = true; });
923
- maxViolators.forEach(f => { f.computedWidth = f.maxWidth; f.frozen = true; });
924
- }
925
- }
926
- };
927
-
928
- solveFlexAllocation();
929
- flexItems.forEach(f => finalFlexWidths.set(f.col.field, f.computedWidth));
930
-
931
- const computedWidthMap = new Map<string, number>();
932
-
933
- unpinnedCols.forEach(col => {
934
- let computedWidth: number;
935
-
936
- if (finalFlexWidths.has(col.field)) {
937
- computedWidth = finalFlexWidths.get(col.field)!;
938
- } else {
939
- const fixedCol = fixedWidthCols.find(f => f.col.field === col.field);
940
- const pctCol = percentageCols.find(p => p.col.field === col.field);
941
-
942
- if (fixedCol) {
943
- computedWidth = fixedCol.width;
944
- } else if (pctCol) {
945
- const rawPctWidth = percentageWidthMap.get(col.field) || 0;
946
- computedWidth = Math.max(col.minWidth ?? 50, rawPctWidth || 100);
947
- } else {
948
- computedWidth = 100;
949
- }
950
- }
951
-
952
- unpinnedColWidths.push(computedWidth);
953
- computedWidthMap.set(col.field, computedWidth);
954
- });
955
-
956
- const unpinnedColsWithWidth = unpinnedCols.map(col => {
957
- const flexItem = flexItems.find(f => f.col.field === col.field);
958
- return {
959
- ...col,
960
- width: computedWidthMap.get(col.field) ?? (typeof col.width === 'number' ? col.width : 100),
961
- flex: flexItem ? flexItem.flex : col.flex
962
- };
963
- });
964
-
965
- const baseLeftZ = 11;
966
- const leftPinnedCols = rawLeftPinnedCols.map((col, i) => {
967
- const parsed = parseWidth(getColWidth(col));
968
- const numericWidth = parsed.type === 'fixed' ? parsed.value : 100;
969
- return {
970
- ...col,
971
- width: numericWidth,
972
- zIndex: baseLeftZ + i
973
- };
974
- });
975
-
976
- const baseRightZ = 11;
977
- const rightPinnedCols = rawRightPinnedCols.map((col, i) => {
978
- const parsed = parseWidth(getColWidth(col));
979
- const numericWidth = parsed.type === 'fixed' ? parsed.value : 100;
980
- return {
981
- ...col,
982
- width: numericWidth,
983
- zIndex: baseRightZ + (rawRightPinnedCols.length - 1 - i)
984
- };
985
- });
986
-
987
- const unpinnedTotalWidth = unpinnedColWidths.reduce((sum, w) => sum + w, 0);
988
- const totalWidth = leftWidth + unpinnedTotalWidth + rightWidth + systemColumnsWidth;
989
-
990
- const unpinnedAccWidths = unpinnedColWidths.reduce((acc, w, i) => {
991
- acc.push((acc[i - 1] || 0) + w);
992
- return acc;
993
- }, [] as number[]);
994
-
995
- return {
996
- rowHeights,
997
- cumulativeHeights,
998
- unpinnedRowsHeight,
999
- pinnedTopHeight,
1000
- pinnedBottomHeight,
1001
- leftPinnedCols,
1002
- rightPinnedCols,
1003
- unpinnedColsWithWidth,
1004
- unpinnedColWidths,
1005
- unpinnedAccWidths,
1006
- totalWidth,
1007
- leftWidth,
1008
- rightWidth,
1009
- unpinnedTotalWidth,
1010
- systemColumnsWidth,
1011
- pinnedTopRowsLength: pinnedTopRows.length,
1012
- pinnedBottomRowsLength: pinnedBottomRows.length,
1013
- unpinnedRowsLength: unpinnedRows.length
1014
- };
1015
- }, [
726
+ const layout = useLayout({
1016
727
  rowHeight,
1017
728
  pagination,
1018
729
  paginatedUnpinnedRows,
1019
730
  sortedUnpinnedRows,
1020
731
  expandedRowIds,
1021
732
  getDetailPanelHeight,
1022
- pinnedTopRows.length,
1023
- pinnedBottomRows.length,
1024
- expandedRowIds,
1025
- getDetailPanelHeight,
1026
- pinnedTopRows.length,
1027
- pinnedBottomRows.length,
733
+ pinnedTopRowsLength: pinnedTopRows.length,
734
+ pinnedBottomRowsLength: pinnedBottomRows.length,
1028
735
  visibleOrderedColumns,
1029
736
  pinnedColumns,
1030
737
  columnWidths,
1031
- state.dimensions.viewportWidth,
738
+ viewportWidth: state.dimensions.viewportWidth || 1000,
1032
739
  checkboxSelection,
1033
740
  hasDetailPanel,
1034
741
  rowReordering,
@@ -1036,9 +743,45 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1036
743
  pinExpandColumn,
1037
744
  autoHeight,
1038
745
  paginationMode,
1039
- state.dataSource.loading,
1040
- effectivePaginationModel.pageSize
1041
- ]);
746
+ isLoading: state.dataSource.loading,
747
+ pageSize: effectivePaginationModel.pageSize,
748
+ });
749
+
750
+ useEffect(() => {
751
+ gridData.apiRef.current.scrollToIndexes = ({ rowIndex, colIndex }) => {
752
+ const el = viewportRef.current;
753
+ if (!el) return;
754
+
755
+ if (rowIndex !== undefined && rowIndex >= 0) {
756
+ const rowTop = rowIndex > 0 ? layout.cumulativeHeights[rowIndex - 1] : 0;
757
+ const rowBottom = layout.cumulativeHeights[rowIndex] ?? rowTop;
758
+ const { scrollTop, clientHeight } = el;
759
+ if (rowTop < scrollTop) {
760
+ el.scrollTop = rowTop;
761
+ } else if (rowBottom > scrollTop + clientHeight) {
762
+ el.scrollTop = rowBottom - clientHeight;
763
+ }
764
+ }
765
+
766
+ if (colIndex !== undefined && colIndex >= 0) {
767
+ // colIndex is an index into all data columns: leftPinned + unpinned + rightPinned
768
+ const allDataCols = [...layout.leftPinnedCols, ...layout.unpinnedColsWithWidth, ...layout.rightPinnedCols];
769
+ const targetCol = allDataCols[colIndex];
770
+ if (!targetCol) return;
771
+ const unpinnedIndex = layout.unpinnedColsWithWidth.findIndex(c => c.field === targetCol.field);
772
+ if (unpinnedIndex === -1) return; // pinned column — always visible, no scroll needed
773
+ const colLocalLeft = unpinnedIndex > 0 ? layout.unpinnedAccWidths[unpinnedIndex - 1] : 0;
774
+ const colLocalRight = layout.unpinnedAccWidths[unpinnedIndex] ?? colLocalLeft;
775
+ const colRight = layout.leftWidth + colLocalRight;
776
+ const { scrollLeft, clientWidth } = el;
777
+ if (colLocalLeft < scrollLeft) {
778
+ el.scrollLeft = colLocalLeft;
779
+ } else if (colRight > scrollLeft + clientWidth) {
780
+ el.scrollLeft = colRight - clientWidth;
781
+ }
782
+ }
783
+ };
784
+ }, [layout, viewportRef, gridData.apiRef]);
1042
785
 
1043
786
  const virtualization = useMemo(() => {
1044
787
  const {
@@ -1236,13 +979,31 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1236
979
  onSortModelChange?.(newSortModel as any);
1237
980
  }, [isSortControlled, onSortModelChange]);
1238
981
 
982
+ const { focusedCell, setFocusedCell, handleFocus, handleBlur, handleKeyDown } = useGridKeyboardNavigation({
983
+ allRenderableRows,
984
+ navigationColumns,
985
+ checkboxSelection,
986
+ selectedRowIds,
987
+ handleSelectionChange,
988
+ handleDetailPanelToggle,
989
+ editingHandlers,
990
+ setKeyboardMode,
991
+ sortModel,
992
+ handleSort,
993
+ isCellEditable,
994
+ pagination,
995
+ pageSize: effectivePaginationModel.pageSize,
996
+ virtualization,
997
+ viewportRef,
998
+ });
999
+
1239
1000
  const handleCellClick = useCallback((params: GridCellParams<R>) => {
1240
1001
  setKeyboardMode(false);
1241
1002
  setFocusedCell({ id: params.row.id, field: params.field });
1242
1003
 
1243
1004
  gridRef.current?.focus({ preventScroll: true });
1244
1005
  onCellClick?.(params);
1245
- }, [onCellClick, setKeyboardMode]);
1006
+ }, [onCellClick, setKeyboardMode, setFocusedCell]);
1246
1007
 
1247
1008
  const prevEditingCellRef = useRef(editingHandlers.editingCell);
1248
1009
  useEffect(() => {
@@ -1300,329 +1061,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1300
1061
  const allSelected = rows.length > 0 && selectedRowIds.size === rows.length;
1301
1062
  const someSelected = selectedRowIds.size > 0 && selectedRowIds.size < rows.length;
1302
1063
 
1303
- const handleFocus = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
1304
-
1305
- if (event.target === event.currentTarget) {
1306
- setFocusedCell(prev => {
1307
- if (prev) return prev;
1308
-
1309
- if (checkboxSelection) {
1310
- return { id: 'HEADER', field: '__checkbox_col__' };
1311
- }
1312
-
1313
- const firstRow = allRenderableRows[0];
1314
- const firstCol = navigationColumns[0];
1315
- if (firstRow && firstCol) {
1316
- return { id: firstRow.id, field: firstCol.field };
1317
- }
1318
- return null;
1319
- });
1320
- }
1321
- }, [allRenderableRows, navigationColumns]);
1322
-
1323
- const handleBlur = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
1324
-
1325
- if (!event.currentTarget.contains(event.relatedTarget as Node)) {
1326
- setFocusedCell(null);
1327
- }
1328
- }, []);
1329
-
1330
- const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
1331
- setKeyboardMode(true);
1332
-
1333
- if (!focusedCell) return;
1334
-
1335
- const { id, field } = focusedCell;
1336
-
1337
- const isEditing = Boolean(editingHandlers.editingCell);
1338
-
1339
- if (event.key === 'Enter') {
1340
- event.preventDefault();
1341
- if (isEditing) {
1342
- editingHandlers.stopCellEdit();
1343
- } else {
1344
-
1345
- const col = navigationColumns.find(c => c.field === field);
1346
- if (col?.editable) {
1347
- const row = allRenderableRows.find(r => r.id === id);
1348
- if (row) {
1349
- editingHandlers.startCellEdit({
1350
- id,
1351
- field,
1352
- value: (row as any)[field]
1353
- });
1354
- }
1355
- }
1356
- }
1357
- return;
1358
- }
1359
-
1360
- if (event.key === ' ' || event.key === 'Spacebar') {
1361
- if (!isEditing) {
1362
- if (field === '__checkbox_col__') {
1363
- event.preventDefault();
1364
- handleSelectionChange(id, !selectedRowIds.has(id));
1365
- return;
1366
- }
1367
- if (field === '__expand_col__') {
1368
- event.preventDefault();
1369
- handleDetailPanelToggle(id);
1370
- return;
1371
- }
1372
- }
1373
- }
1374
-
1375
- if (id === 'HEADER' && (event.key === 'Enter' || event.key === ' ')) {
1376
- event.preventDefault();
1377
-
1378
- const col = navigationColumns.find(c => c.field === field);
1379
- if (col && (col as any).sortable !== false) {
1380
- const currentSort = sortModel.find(item => item.field === field);
1381
- let newDirection: GridSortDirection = 'asc';
1382
-
1383
- if (currentSort) {
1384
- if (currentSort.sort === 'asc') {
1385
- newDirection = 'desc';
1386
- } else {
1387
- newDirection = null;
1388
- }
1389
- }
1390
- handleSort(field, newDirection);
1391
- }
1392
- return;
1393
- }
1394
-
1395
- if (event.key === 'Escape') {
1396
- if (isEditing) {
1397
- event.preventDefault();
1398
- editingHandlers.stopCellEdit({ cancel: true });
1399
- }
1400
- return;
1401
- }
1402
-
1403
- if (isEditing && event.key !== 'Tab') {
1404
- return;
1405
- }
1406
-
1407
- const isHeader = id === 'HEADER';
1408
- const rowIndex = isHeader ? -1 : allRenderableRows.findIndex(r => r.id === id);
1409
- const colIndex = navigationColumns.findIndex(c => c.field === field);
1410
-
1411
- if (!isHeader && rowIndex === -1) return;
1412
- if (colIndex === -1) return;
1413
-
1414
- // Find the next FOCUSABLE cell (any cell — editability not required for arrow navigation)
1415
- const findNextCell = (
1416
- startRow: number,
1417
- startCol: number,
1418
- deltaRow: number,
1419
- deltaCol: number,
1420
- wrapRow: boolean,
1421
- allowHeader: boolean = false
1422
- ) => {
1423
- let r = startRow + deltaRow;
1424
- let c = startCol + deltaCol;
1425
-
1426
- if (wrapRow && deltaCol !== 0) {
1427
- if (deltaCol > 0 && c >= navigationColumns.length) { c = 0; r++; }
1428
- else if (deltaCol < 0 && c < 0) { c = navigationColumns.length - 1; r--; }
1429
- }
1430
-
1431
- if (!wrapRow && (c < 0 || c >= navigationColumns.length)) return null;
1432
- if (r < (allowHeader ? -1 : 0) || r >= allRenderableRows.length) return null;
1433
-
1434
- return { r, c };
1435
- };
1436
-
1437
- // Find the next EDITABLE cell (for Tab key navigation)
1438
- const findNextEditable = (
1439
- startRow: number,
1440
- startCol: number,
1441
- deltaRow: number,
1442
- deltaCol: number,
1443
- wrapRow: boolean,
1444
- allowHeader: boolean = false
1445
- ) => {
1446
- let r = startRow;
1447
- let c = startCol;
1448
- let steps = 0;
1449
-
1450
- const maxSteps = allRenderableRows.length * navigationColumns.length + navigationColumns.length;
1451
-
1452
- while (steps < maxSteps) {
1453
- steps++;
1454
-
1455
- r += deltaRow;
1456
- c += deltaCol;
1457
-
1458
- if (wrapRow && deltaCol !== 0) {
1459
- if (deltaCol > 0) {
1460
- if (c >= navigationColumns.length) { c = 0; r++; }
1461
- } else {
1462
- if (c < 0) { c = navigationColumns.length - 1; r--; }
1463
- }
1464
- } else {
1465
- if (c < 0 || c >= navigationColumns.length) return null;
1466
- }
1467
-
1468
- if (r < -1 || r >= allRenderableRows.length) return null;
1469
-
1470
- if (r === -1) {
1471
- if (!allowHeader) continue;
1472
- const col = navigationColumns[c];
1473
- const isSortable = (col as any).sortable !== false;
1474
- const isInteractive = ['__checkbox_col__'].includes(col.field) || isSortable;
1475
- if (isInteractive) return { r, c };
1476
- continue;
1477
- }
1478
-
1479
- const row = allRenderableRows[r];
1480
- const col = navigationColumns[c];
1481
-
1482
- const isInteractable = ['__checkbox_col__', '__expand_col__', '__reorder_col__'].includes(col.field);
1483
- let isEditable = col.editable || isInteractable;
1484
-
1485
- if (isCellEditable && !isInteractable) {
1486
- try {
1487
- isEditable = isCellEditable({
1488
- row,
1489
- field: col.field,
1490
- value: (row as any)[col.field],
1491
- colDef: col,
1492
- rowIndex: r,
1493
- colIndex: c
1494
- });
1495
- } catch (e) {
1496
- console.error('Error in isCellEditable:', e);
1497
- }
1498
- }
1499
-
1500
- if (isEditable) return { r, c };
1501
- }
1502
- return null;
1503
- };
1504
-
1505
- let nextRowIndex = rowIndex;
1506
- let nextColIndex = colIndex;
1507
- let handled = false;
1508
-
1509
- if (event.key === 'Tab') {
1510
- // Tab moves to next EDITABLE cell
1511
- const dir = event.shiftKey ? -1 : 1;
1512
- const res = findNextEditable(rowIndex, colIndex, 0, dir, true, false);
1513
- if (res) {
1514
- nextRowIndex = res.r;
1515
- nextColIndex = res.c;
1516
- handled = true;
1517
- }
1518
- } else if (event.key === 'ArrowRight') {
1519
- const res = findNextCell(rowIndex, colIndex, 0, 1, true, true);
1520
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1521
- } else if (event.key === 'ArrowLeft') {
1522
- const res = findNextCell(rowIndex, colIndex, 0, -1, true, true);
1523
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1524
- } else if (event.key === 'ArrowDown') {
1525
- const res = findNextCell(rowIndex, colIndex, 1, 0, false, true);
1526
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1527
- } else if (event.key === 'ArrowUp') {
1528
- const res = findNextCell(rowIndex, colIndex, -1, 0, false, true);
1529
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1530
- } else if (event.key === 'Home') {
1531
- if (event.ctrlKey || event.metaKey) {
1532
- nextRowIndex = -1;
1533
- nextColIndex = 0;
1534
- } else {
1535
- nextColIndex = 0;
1536
- }
1537
- handled = true;
1538
- } else if (event.key === 'End') {
1539
- if (event.ctrlKey || event.metaKey) {
1540
- nextRowIndex = allRenderableRows.length - 1;
1541
- nextColIndex = navigationColumns.length - 1;
1542
- } else {
1543
- nextColIndex = navigationColumns.length - 1;
1544
- }
1545
- handled = true;
1546
- } else if (event.key === 'PageUp') {
1547
- const pageSize = pagination ? effectivePaginationModel.pageSize : 10;
1548
- nextRowIndex = Math.max(-1, nextRowIndex - pageSize);
1549
- handled = true;
1550
- } else if (event.key === 'PageDown') {
1551
- const pageSize = pagination ? effectivePaginationModel.pageSize : 10;
1552
- nextRowIndex = Math.min(allRenderableRows.length - 1, nextRowIndex + pageSize);
1553
- handled = true;
1554
- }
1555
-
1556
- if (handled) {
1557
- event.preventDefault();
1558
-
1559
- if (isEditing && event.key === 'Tab') {
1560
- editingHandlers.stopCellEdit();
1561
- }
1562
-
1563
- if (nextRowIndex >= -1 && nextRowIndex < allRenderableRows.length &&
1564
- nextColIndex >= 0 && nextColIndex < navigationColumns.length) {
1565
-
1566
- const nextCol = navigationColumns[nextColIndex];
1567
-
1568
- if (nextRowIndex === -1) {
1569
- setFocusedCell({ id: 'HEADER', field: nextCol.field });
1570
- } else {
1571
- const nextRow = allRenderableRows[nextRowIndex];
1572
- setFocusedCell({ id: nextRow.id, field: nextCol.field });
1573
- }
1574
-
1575
- if (viewportRef.current) {
1576
- const { cumulativeHeights, pinnedTopHeight } = virtualization;
1577
- const { clientHeight, clientWidth, scrollTop, scrollLeft } = viewportRef.current;
1578
-
1579
- const rowTop = nextRowIndex === 0 ? 0 : cumulativeHeights[nextRowIndex - 1];
1580
- const rowBottom = cumulativeHeights[nextRowIndex];
1581
- const pinnedOffset = pinnedTopHeight;
1582
- const visibleTop = scrollTop + pinnedOffset;
1583
- const visibleBottom = scrollTop + clientHeight;
1584
-
1585
- let newScrollTop = scrollTop;
1586
- if (rowTop < visibleTop) {
1587
- newScrollTop = Math.max(0, rowTop - pinnedOffset);
1588
- } else if (rowBottom > visibleBottom) {
1589
- newScrollTop = rowBottom - clientHeight;
1590
- }
1591
-
1592
- if (newScrollTop !== scrollTop) {
1593
- viewportRef.current.scrollTop = newScrollTop;
1594
- }
1595
-
1596
- if (virtualization.columnMetrics) {
1597
- const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = virtualization.columnMetrics;
1598
- const field = nextCol.field;
1599
- const unpinnedIndex = unpinnedCols.findIndex((c: any) => c.field === field);
1600
-
1601
- if (unpinnedIndex !== -1) {
1602
- const colLeft = totalSpecialsWidth + leftPinnedWidth + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
1603
- const colRight = totalSpecialsWidth + leftPinnedWidth + unpinnedAccWidths[unpinnedIndex];
1604
-
1605
- const visibleStart = scrollLeft + pinnedSpecialsWidth + leftPinnedWidth;
1606
- const visibleEnd = scrollLeft + clientWidth - rightPinnedWidth;
1607
-
1608
- let newScrollLeft = scrollLeft;
1609
-
1610
- if (colLeft < visibleStart) {
1611
- newScrollLeft = Math.max(0, colLeft - pinnedSpecialsWidth - leftPinnedWidth);
1612
- } else if (colRight > visibleEnd) {
1613
- newScrollLeft = colRight - clientWidth + rightPinnedWidth;
1614
- }
1615
-
1616
- if (newScrollLeft !== scrollLeft) {
1617
- viewportRef.current.scrollLeft = newScrollLeft;
1618
- }
1619
- }
1620
- }
1621
- }
1622
- }
1623
- }
1624
- }, [focusedCell, allRenderableRows, navigationColumns, editingHandlers, virtualization, selectedRowIds, handleSelectionChange, handleDetailPanelToggle]);
1625
-
1626
1064
  const hasRowSpanning = React.useMemo(() => effectiveColumns.some(c => !!c.rowSpan), [effectiveColumns]);
1627
1065
  const [columnsPanelOpen, setColumnsPanelOpen] = React.useState(false);
1628
1066
  const containerRef = React.useRef<HTMLDivElement>(null);
@@ -1653,10 +1091,11 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1653
1091
  aria-busy={effectiveLoading}
1654
1092
  >
1655
1093
  { }
1656
- {StableToolbar && (() => {
1094
+ {slots?.toolbar && (() => {
1657
1095
  const toolbarProps = {
1658
1096
  apiRef: gridData.apiRef,
1659
- columns: effectiveColumns,
1097
+ columns: effectiveColumns as unknown as GridColDef[],
1098
+ baseColumns: columns as unknown as GridColDef[],
1660
1099
  aggregationModel,
1661
1100
  onAggregationModelChange: handleAggregationModelChange,
1662
1101
 
@@ -1667,7 +1106,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1667
1106
 
1668
1107
  filterModel,
1669
1108
  onFilterModelChange,
1670
-
1671
1109
  columnVisibilityModel,
1672
1110
  onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
1673
1111
 
@@ -2141,8 +1579,10 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
2141
1579
  </div>
2142
1580
  )}
2143
1581
 
2144
- {/* Aggregation Footer Row */}
2145
- {hasAggregation && (
1582
+ {/* Aggregation Footer Row — suppressed in pivot mode because pivot rows
1583
+ already contain pre-aggregated values with synthetic field keys
1584
+ (e.g. 'Q1\u001frevenue\u001fsum') that don't match aggregationModel keys. */}
1585
+ {hasAggregation && !pivotMode && (
2146
1586
  <div
2147
1587
  className="ogx__aggregation-footer"
2148
1588
  role="row"