@opencorestack/opengridx 0.1.8 → 1.0.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 (67) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/index.d.ts +60 -41
  3. package/dist/opengridx.es.js +5271 -4660
  4. package/dist/opengridx.es.js.map +1 -1
  5. package/dist/opengridx.umd.js +12 -12
  6. package/dist/opengridx.umd.js.map +1 -1
  7. package/docs/README.md +14 -0
  8. package/docs/components/aggregation-footer.md +48 -0
  9. package/docs/components/datagrid.md +3 -0
  10. package/docs/components/empty-state.md +50 -0
  11. package/docs/components/error-overlay.md +40 -0
  12. package/docs/roadmap.md +20 -1
  13. package/lib/components/Cell/Cell.tsx +61 -64
  14. package/lib/components/Cell/GridEditInputCell.tsx +4 -4
  15. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  16. package/lib/components/DataGrid/DataGrid.regression.test.tsx +132 -0
  17. package/lib/components/DataGrid/DataGrid.smoke.test.tsx +58 -0
  18. package/lib/components/DataGrid/DataGrid.tsx +396 -1654
  19. package/lib/components/DataGrid/GridAggregationFooter.tsx +68 -0
  20. package/lib/components/DataGrid/GridEmptyState.tsx +16 -0
  21. package/lib/components/DataGrid/GridErrorOverlay.tsx +33 -0
  22. package/lib/components/DataGrid/GridListView.tsx +116 -0
  23. package/lib/components/DataGrid/GridPinnedRows.tsx +98 -0
  24. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +124 -0
  25. package/lib/components/DataGrid/GridVirtualRows.tsx +202 -0
  26. package/lib/components/FilterPanel/FilterPanel.tsx +18 -10
  27. package/lib/components/Header/ColumnMenu.tsx +2 -2
  28. package/lib/components/Header/Header.tsx +4 -4
  29. package/lib/components/ListView/ListViewRow.tsx +2 -2
  30. package/lib/components/Row/Row.tsx +46 -45
  31. package/lib/components/SkeletonRow/SkeletonRow.tsx +9 -2
  32. package/lib/components/Toolbar/GlobalSearch.tsx +1 -1
  33. package/lib/components/Toolbar/GridToolbar.tsx +26 -5
  34. package/lib/components/Toolbar/PivotPanel.tsx +7 -2
  35. package/lib/components/ui/DragHandleIcon.tsx +1 -1
  36. package/lib/hooks/core/useDataGrid.regression.test.ts +66 -0
  37. package/lib/hooks/core/useDataGrid.ts +15 -35
  38. package/lib/hooks/core/useGridColumns.tsx +228 -0
  39. package/lib/hooks/core/useGridControlledState.ts +192 -0
  40. package/lib/hooks/core/useGridKeyboardNavigation.ts +371 -0
  41. package/lib/hooks/core/useGridRowPipeline.ts +109 -0
  42. package/lib/hooks/core/useGridScrollSync.ts +52 -0
  43. package/lib/hooks/core/useGridStateSnapshot.ts +63 -0
  44. package/lib/hooks/core/useGridVirtualization.ts +186 -0
  45. package/lib/hooks/core/useGridVisibleRows.ts +66 -0
  46. package/lib/hooks/core/useLayout.ts +348 -0
  47. package/lib/hooks/features/useAggregation.test.ts +135 -0
  48. package/lib/hooks/features/useAggregation.ts +7 -7
  49. package/lib/hooks/features/useGridClipboard.ts +1 -1
  50. package/lib/hooks/features/useGridDataSource.ts +2 -2
  51. package/lib/hooks/features/useGridEditing.ts +31 -32
  52. package/lib/hooks/features/usePivot.test.ts +202 -0
  53. package/lib/hooks/features/usePivot.ts +0 -0
  54. package/lib/hooks/useColumnReorder.ts +1 -1
  55. package/lib/hooks/useRowGrouping.ts +14 -14
  56. package/lib/hooks/useTreeData.ts +1 -1
  57. package/lib/state/types.ts +1 -1
  58. package/lib/state/useGridStateStorage.ts +2 -2
  59. package/lib/types/index.ts +48 -32
  60. package/lib/utils/export/exportToExcelAdvanced.ts +14 -14
  61. package/lib/utils/export/index.ts +23 -18
  62. package/lib/utils/filtering/filtering.test.ts +178 -0
  63. package/lib/utils/filtering/index.ts +20 -20
  64. package/lib/utils/sorting/index.ts +1 -1
  65. package/lib/utils/sorting/sorting.test.ts +88 -0
  66. package/package.json +12 -5
  67. package/lib/utils/virtualization/index.ts +0 -106
@@ -1,29 +1,33 @@
1
1
  import React, { useRef, useEffect, useCallback, useMemo, useState } from 'react';
2
- import ReactDOM from 'react-dom';
3
- import { ListViewRow } from '../ListView/ListViewRow';
4
- import type { GridState } from '../../state/types';
2
+ import { useLayout } from '../../hooks/core/useLayout';
3
+ import { useGridKeyboardNavigation } from '../../hooks/core/useGridKeyboardNavigation';
4
+ import { useGridControlledState } from '../../hooks/core/useGridControlledState';
5
+ import { useGridRowPipeline } from '../../hooks/core/useGridRowPipeline';
6
+ import { useGridVirtualization } from '../../hooks/core/useGridVirtualization';
7
+ import { useGridColumns } from '../../hooks/core/useGridColumns';
8
+ import { useGridVisibleRows } from '../../hooks/core/useGridVisibleRows';
9
+ import { useGridScrollSync } from '../../hooks/core/useGridScrollSync';
10
+ import { useGridStateSnapshot } from '../../hooks/core/useGridStateSnapshot';
11
+ import { GridAggregationFooter } from './GridAggregationFooter';
12
+ import { GridEmptyState } from './GridEmptyState';
13
+ import { GridErrorOverlay } from './GridErrorOverlay';
5
14
  import { Header } from '../Header/Header';
6
- import { Row } from '../Row/Row';
7
- import { SkeletonRow } from '../SkeletonRow';
8
15
  import { Pagination } from '../Pagination/Pagination';
9
16
  import { useDataGrid } from '../../hooks/core/useDataGrid';
10
- import { useColumnReorder } from '../../hooks/useColumnReorder';
11
17
  import { useRowReorder } from '../../hooks/useRowReorder';
12
18
  import { useTreeData } from '../../hooks/useTreeData';
13
19
  import { useRowGrouping } from '../../hooks/useRowGrouping';
14
20
  import { useGridEditing } from '../../hooks/features/useGridEditing';
15
21
  import { useGridSpanning } from '../../hooks/features/useGridSpanning';
16
22
  import { useGridDataSource } from '../../hooks/features/useGridDataSource';
17
- import { useAggregation, formatAggregationValue } from '../../hooks/features/useAggregation';
23
+ import { useAggregation } from '../../hooks/features/useAggregation';
18
24
  import { usePivot } from '../../hooks/features/usePivot';
19
25
  import { useGridClipboard } from '../../hooks/features/useGridClipboard';
20
- import { ExpandIcon } from '../ui/ExpandIcon';
21
- import { ColumnVisibilityPanel } from '../ColumnVisibilityPanel/ColumnVisibilityPanel';
22
- import { getPinnedRowGroups, isRowPinned, isColumnPinned } from '../../utils/pinning';
23
- import { sortRows } from '../../utils/sorting';
24
- import { filterRows } from '../../utils/filtering';
25
- 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';
26
+ import { GridListView } from './GridListView';
27
+ import { GridPinnedRows } from './GridPinnedRows';
28
+ import { GridVirtualRows } from './GridVirtualRows';
29
+ import { GridStandaloneColumnPanel } from './GridStandaloneColumnPanel';
30
+ import type { DataGridProps, GridRowModel, GridRowId, GridSortDirection, GridColDef, GridRowParams, GridCellParams, GridDataSource, GridAggregationResult, GridFilterModel, GridTreeNode, GridSortItem } from '../../types';
27
31
 
28
32
  export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridProps<R>) {
29
33
  const {
@@ -112,15 +116,8 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
112
116
  const defaultFilterModel: GridFilterModel = useMemo(() => ({ items: [] }), []);
113
117
  const filterModel = (propFilterModel || defaultFilterModel) as GridFilterModel;
114
118
 
115
- const [internalSortModel, setInternalSortModel] = useState<{ field: string; sort: 'asc' | 'desc' }[]>(
116
- () => initialState?.sorting?.sortModel ?? []
117
- );
118
- const isSortControlled = propSortModel !== undefined;
119
- const sortModel = isSortControlled ? propSortModel : internalSortModel;
120
-
121
119
  const defaultRowGroupingModel = useMemo(() => [], []);
122
120
  const rowGroupingModel = propRowGroupingModel || defaultRowGroupingModel;
123
- const isAggregationControlled = propAggregationModel !== undefined;
124
121
 
125
122
  // ─── Stabilize toolbar component identity ────────────────────────────────
126
123
  // Problem: demos/users often define their toolbar as an inline function
@@ -147,51 +144,46 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
147
144
  const Toolbar = latestToolbarRef.current;
148
145
  return Toolbar ? (Toolbar as (p: typeof props) => React.ReactElement | null)(props) : null;
149
146
  }).current;
150
- const [internalAggregationModel, setInternalAggregationModel] = useState<GridAggregationModel>(
151
- () => propAggregationModel ?? {}
152
- );
153
- const aggregationModel = isAggregationControlled ? propAggregationModel : internalAggregationModel;
154
147
 
155
- const handleAggregationModelChange = useCallback((model: GridAggregationModel) => {
156
- if (!isAggregationControlled) {
157
- setInternalAggregationModel(model);
158
- }
159
- onAggregationModelChange?.(model);
160
- }, [isAggregationControlled, onAggregationModelChange]);
161
-
162
- const [internalColumnVisibilityModel, setInternalColumnVisibilityModel] = useState<Record<string, boolean>>(
163
- () => initialState?.columns?.columnVisibilityModel ?? {}
164
- );
165
- const isColumnVisibilityControlled = propColumnVisibilityModel !== undefined;
166
- const columnVisibilityModel = isColumnVisibilityControlled ? propColumnVisibilityModel : internalColumnVisibilityModel;
148
+ const controlledState = useGridControlledState({
149
+ initialState,
150
+ sortModel: propSortModel,
151
+ onSortModelChange,
152
+ aggregationModel: propAggregationModel,
153
+ onAggregationModelChange,
154
+ columnVisibilityModel: propColumnVisibilityModel,
155
+ onColumnVisibilityModelChange,
156
+ pinnedColumns: propPinnedColumns,
157
+ onPinnedColumnsChange,
158
+ pivotModel: propPivotModel,
159
+ onPivotModelChange,
160
+ paginationModel,
161
+ onPaginationModelChange,
162
+ rowSelectionModel: propRowSelectionModel,
163
+ onRowSelectionModelChange: propOnRowSelectionModelChange,
164
+ });
167
165
 
168
- const handleColumnVisibilityModelChange = useCallback((model: Record<string, boolean>) => {
169
- if (!isColumnVisibilityControlled) {
170
- setInternalColumnVisibilityModel(model);
171
- }
172
- onColumnVisibilityModelChange?.(model);
173
- }, [isColumnVisibilityControlled, onColumnVisibilityModelChange]);
166
+ const {
167
+ sortModel,
168
+ isSortControlled,
169
+ setInternalSortModel,
170
+ aggregationModel,
171
+ handleAggregationModelChange,
172
+ columnVisibilityModel,
173
+ handleColumnVisibilityModelChange,
174
+ pinnedColumns,
175
+ handlePinnedColumnsChange,
176
+ currentPivotModel,
177
+ handlePivotModelChange,
178
+ effectivePaginationModel,
179
+ handlePaginationModelChange,
180
+ selectedRowIds,
181
+ isSelectionControlled,
182
+ setInternalRowSelectionModel,
183
+ } = controlledState;
174
184
 
175
- const [internalPinnedColumns, setInternalPinnedColumns] = useState<GridColumnPinning>(
176
- () => initialState?.columns?.pinnedColumns ?? {}
177
- );
178
- const isPinnedColumnsControlled = propPinnedColumns !== undefined;
179
- const pinnedColumns = isPinnedColumnsControlled ? propPinnedColumns : internalPinnedColumns;
185
+ const onRowSelectionModelChange = propOnRowSelectionModelChange;
180
186
 
181
- const handlePinnedColumnsChange = useCallback((model: GridColumnPinning) => {
182
- if (!isPinnedColumnsControlled) {
183
- setInternalPinnedColumns(model);
184
- }
185
- onPinnedColumnsChange?.(model);
186
- }, [isPinnedColumnsControlled, onPinnedColumnsChange]);
187
-
188
- const EMPTY_PIVOT_MODEL: GridPivotModel = useMemo(() => ({ rowFields: [], columnFields: [], valueFields: [] }), []);
189
- const [internalPivotModel, setInternalPivotModel] = useState<GridPivotModel>(propPivotModel ?? EMPTY_PIVOT_MODEL);
190
- const currentPivotModel = propPivotModel ?? internalPivotModel;
191
- const handlePivotModelChange = useCallback((model: GridPivotModel) => {
192
- setInternalPivotModel(model);
193
- onPivotModelChange?.(model);
194
- }, [onPivotModelChange]);
195
187
  const pivot = usePivot(rows as GridRowModel[], columns as unknown as GridColDef[], currentPivotModel, pivotMode);
196
188
 
197
189
  const activeRows = (pivotMode && pivot.isValid ? pivot.pivotRows : rows) as unknown as R[];
@@ -203,25 +195,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
203
195
  setServerAggregationResults(null);
204
196
  }, [aggregationModel]);
205
197
 
206
- const [internalPaginationModel, setInternalPaginationModel] = useState(
207
- () => initialState?.pagination?.paginationModel ?? paginationModel
208
- );
209
- const isPaginationControlled = props.paginationModel !== undefined;
210
- const effectivePaginationModel = isPaginationControlled ? paginationModel : internalPaginationModel;
211
-
212
- const handlePaginationModelChange = useCallback((newModel: GridPaginationModel) => {
213
- if (!isPaginationControlled) {
214
- setInternalPaginationModel(newModel);
215
- }
216
- onPaginationModelChange?.(newModel);
217
- }, [isPaginationControlled, onPaginationModelChange]);
218
-
219
- const [internalRowSelectionModel, setInternalRowSelectionModel] = useState<GridRowId[]>([]);
220
- const isSelectionControlled = propRowSelectionModel !== undefined;
221
- const rowSelectionModel = isSelectionControlled ? propRowSelectionModel : internalRowSelectionModel;
222
- const selectedRowIds = useMemo(() => new Set(rowSelectionModel), [rowSelectionModel]);
223
- const onRowSelectionModelChange = propOnRowSelectionModelChange;
224
-
225
198
  const viewportRef = useRef<HTMLDivElement>(null);
226
199
  const contentRef = useRef<HTMLDivElement>(null);
227
200
  const gridRef = useRef<HTMLDivElement>(null);
@@ -229,8 +202,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
229
202
  const defaultGetRowId = useCallback((row: R) => row.id, []);
230
203
  const effectiveGetRowId = getRowId || defaultGetRowId;
231
204
 
232
- const [focusedCell, setFocusedCell] = useState<{ id: GridRowId; field: string } | null>(null);
233
-
234
205
  // Keyboard-mode flag: toggled via DOM classname — no React state needed
235
206
  // so the ring appears instantly without a re-render cycle.
236
207
  const setKeyboardMode = useCallback((on: boolean) => {
@@ -241,12 +212,12 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
241
212
  dataSourceRef.current = dataSource;
242
213
 
243
214
  const fetchChildrenRef = useRef<((parentId: GridRowId, groupKeys: string[]) => Promise<void>) | null>(null);
244
- const treeDataRef = useRef<any>(null);
215
+ const treeDataRef = useRef<ReturnType<typeof useTreeData> | null>(null);
245
216
 
246
- const handleNodeExpansion = useCallback((node: any) => {
217
+ const handleNodeExpansion = useCallback((node: GridTreeNode) => {
247
218
  if (dataSourceRef.current && treeData) {
248
219
 
249
- if (node.serverChildrenCount > 0 && node.children.length === 0) {
220
+ if ((node.serverChildrenCount ?? 0) > 0 && (node.children ?? []).length === 0) {
250
221
 
251
222
  const groupKeys = treeDataRef.current?.getNodePath(node.id) || [node.groupingKey];
252
223
  fetchChildrenRef.current?.(node.id, groupKeys);
@@ -268,13 +239,14 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
268
239
  apiRef,
269
240
  setRows,
270
241
  setColumns,
271
- setScroll,
272
242
  setDimensions,
273
243
  setDataSourceLoading,
274
244
  setDataSourceError,
275
245
  setRowCount
276
246
  } = gridData;
277
247
 
248
+ const { scrollTop, scrollLeft, handleScroll } = useGridScrollSync({ onRowsScrollEnd });
249
+
278
250
  useEffect(() => {
279
251
  if (propApiRef) {
280
252
  propApiRef.current = apiRef.current;
@@ -338,7 +310,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
338
310
  const { dispatch } = gridData;
339
311
  const currentRows = Array.from(gridData.state.rows.idRowsLookup.values());
340
312
  const nextRows = currentRows.map(r =>
341
- (r as any).id === (updatedRow as any).id ? updatedRow : r
313
+ r.id === updatedRow.id ? updatedRow : r
342
314
  );
343
315
  dispatch({ type: 'SET_ROWS', payload: nextRows });
344
316
  },
@@ -353,9 +325,8 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
353
325
 
354
326
  const handleRowClick = useCallback((params: GridRowParams<R>) => {
355
327
  const { row, id } = params;
356
- const rowData = row as any;
357
328
 
358
- if (isHierarchyEnabled && rowData._hasChildren) {
329
+ if (isHierarchyEnabled && row._hasChildren) {
359
330
  activeHierarchyHandlers?.toggleExpansion(id);
360
331
  return;
361
332
  }
@@ -363,120 +334,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
363
334
  onRowClick?.(params);
364
335
  }, [isHierarchyEnabled, activeHierarchyHandlers, onRowClick]);
365
336
 
366
- const effectiveColumns = useMemo(() => {
367
- if (!isHierarchyEnabled) return activeColumns;
368
-
369
- return activeColumns.map((col, index) => {
370
-
371
- if (index === 0) {
372
- return {
373
- ...col,
374
- renderCell: (params: GridRenderCellParams<R>) => {
375
- const { _treeDepth, _hasChildren, _isExpanded, _groupingField, _groupingValue, _descendantCount } = params.row as any;
376
- const depth = _treeDepth || 0;
377
- const paddingLeft = depth * 24;
378
-
379
- let content = col.renderCell ? col.renderCell(params) : params.value;
380
-
381
- if (isTreeData && _hasChildren && params.row._isGroupRow) {
382
- content = (
383
- <div className="ogx__group-cell-content">
384
- {params.value}
385
- {_descendantCount !== undefined && _descendantCount > 0 ? ` (${_descendantCount})` : ''}
386
- </div>
387
- );
388
- }
389
-
390
- // For row grouping, override the first column content to show the group label
391
- if (isRowGrouping && _hasChildren && _groupingField) {
392
- content = (
393
- <div className="ogx__group-cell-content">
394
- {col.field === _groupingField ? '' : `${_groupingField}: `}
395
- {String(_groupingValue)}
396
- {_descendantCount !== undefined ? ` (${_descendantCount})` : ''}
397
- </div>
398
- );
399
- }
400
-
401
- return (
402
- <div style={{ display: 'flex', alignItems: 'center', paddingLeft, width: '100%', height: '100%' }}>
403
- <div
404
- style={{
405
- marginRight: 4,
406
- width: 24,
407
- height: 24,
408
- display: 'flex',
409
- alignItems: 'center',
410
- justifyContent: 'center',
411
- cursor: _hasChildren ? 'pointer' : 'default',
412
- flexShrink: 0
413
- }}
414
- >
415
-
416
- {_hasChildren ? (
417
- <div
418
- onClick={(e) => {
419
- e.stopPropagation();
420
- e.preventDefault();
421
- activeHierarchyHandlers?.toggleExpansion(params.row.id);
422
- }}
423
- onMouseDown={(e) => {
424
- e.stopPropagation();
425
- }}
426
- style={{
427
- cursor: 'pointer',
428
- display: 'flex',
429
- alignItems: 'center',
430
- justifyContent: 'center',
431
- pointerEvents: 'auto',
432
- zIndex: 10,
433
- position: 'relative'
434
- }}
435
- >
436
- <ExpandIcon
437
- isExpanded={_isExpanded}
438
- onClick={(e) => {
439
- e.stopPropagation();
440
- e.preventDefault();
441
- activeHierarchyHandlers?.toggleExpansion(params.row.id);
442
- }}
443
- />
444
- </div>
445
- ) : null}
446
- </div>
447
- <div style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
448
- {content}
449
- </div>
450
- </div>
451
- );
452
- }
453
- };
454
- }
455
-
456
- return {
457
- ...col,
458
- renderCell: (params: GridRenderCellParams<R>) => {
459
- const { _hasChildren, _groupingField } = params.row as any;
460
-
461
- if (isRowGrouping && _hasChildren) {
462
-
463
- if (col.field === _groupingField) {
464
- return null;
465
- }
466
-
467
- if (params.value !== undefined && params.value !== null) {
468
- return col.renderCell ? col.renderCell(params) : params.value;
469
- }
470
-
471
- return null;
472
- }
473
-
474
- return col.renderCell ? col.renderCell(params) : params.value;
475
- }
476
- };
477
- }) as GridColDef<R>[];
478
- }, [activeColumns, isHierarchyEnabled, isRowGrouping, activeHierarchyHandlers]);
479
-
480
337
  const dataSourceHandlers = useGridDataSource({
481
338
  dataSource,
482
339
  sortModel,
@@ -499,68 +356,16 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
499
356
  }
500
357
  }, [dataSourceHandlers.fetchChildren]);
501
358
 
502
- const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
503
- () => initialState?.columns?.columnWidths ?? {}
504
- );
505
-
506
- const [internalColumnOrder, setInternalColumnOrder] = useState<string[]>(() =>
507
- initialState?.columns?.columnOrder ?? activeColumns.map(col => col.field)
508
- );
509
-
510
- const effectiveColumnOrder = columnOrder || internalColumnOrder;
511
-
512
359
  useEffect(() => {
513
360
  if (!dataSource) {
514
361
  setRows(activeRows);
515
362
  }
516
363
  }, [activeRows, setRows, dataSource]);
517
364
 
518
- useEffect(() => {
519
- setColumns(activeColumns as any);
520
- }, [activeColumns, setColumns]);
521
-
522
- useEffect(() => {
523
- if (pivotMode) {
524
- setInternalColumnOrder(activeColumns.map(col => col.field));
525
- }
526
- }, [pivotMode, activeColumns]);
527
-
528
- const orderedColumns = useMemo(() => {
529
- if (disableColumnReorder) return effectiveColumns as GridColDef<R>[];
530
-
531
- const orderMap = new Map(effectiveColumnOrder.map((field, index) => [field, index]));
532
- return [...(effectiveColumns as GridColDef<R>[])].sort((a, b) => {
533
- const aIndex = orderMap.get(a.field) ?? effectiveColumns.indexOf(a);
534
- const bIndex = orderMap.get(b.field) ?? effectiveColumns.indexOf(b);
535
- return aIndex - bIndex;
536
- });
537
- }, [effectiveColumns, effectiveColumnOrder, disableColumnReorder]);
538
-
539
- const visibleOrderedColumns = useMemo(() => {
540
- return orderedColumns.filter(col => columnVisibilityModel[col.field] !== false);
541
- }, [orderedColumns, columnVisibilityModel]);
542
-
543
- const columnReorderHandlers = useColumnReorder({
544
- columns: orderedColumns,
545
- onColumnOrderChange: useCallback((params: GridColumnOrderChangeParams) => {
546
- const { oldIndex, targetIndex } = params;
547
-
548
- const newOrder = [...effectiveColumnOrder];
549
- const [movedField] = newOrder.splice(oldIndex, 1);
550
- newOrder.splice(targetIndex, 0, movedField);
551
-
552
- if (!columnOrder) {
553
- setInternalColumnOrder(newOrder);
554
- }
555
-
556
- onColumnOrderChange?.(params);
557
- }, [effectiveColumnOrder, columnOrder, onColumnOrderChange]),
558
- disableColumnReorder,
559
- });
560
-
365
+ // ── Detail panel (hoisted — hasDetailPanel feeds into useGridColumns) ──────
366
+ const hasDetailPanel = Boolean(getDetailPanelContent);
561
367
  const [internalExpandedRowIds, setInternalExpandedRowIds] = useState<Set<GridRowId>>(new Set());
562
368
  const expandedRowIds = controlledExpandedRowIds ?? internalExpandedRowIds;
563
- const hasDetailPanel = Boolean(getDetailPanelContent);
564
369
 
565
370
  const handleDetailPanelToggle = useCallback((rowId: GridRowId) => {
566
371
  const newExpandedRowIds = new Set(expandedRowIds);
@@ -569,45 +374,43 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
569
374
  } else {
570
375
  newExpandedRowIds.add(rowId);
571
376
  }
572
-
573
377
  if (controlledExpandedRowIds === undefined) {
574
378
  setInternalExpandedRowIds(newExpandedRowIds);
575
379
  }
576
380
  onDetailPanelExpandedRowIdsChange?.(newExpandedRowIds);
577
381
  }, [expandedRowIds, controlledExpandedRowIds, onDetailPanelExpandedRowIdsChange]);
578
382
 
579
- const navigationColumns = useMemo(() => {
580
- const cols: { field: string; editable?: boolean }[] = [];
581
- if (rowReordering) cols.push({ field: '__reorder_col__' });
582
- if (hasDetailPanel) cols.push({ field: '__expand_col__' });
583
- if (checkboxSelection) cols.push({ field: '__checkbox_col__' });
584
- return [...cols, ...orderedColumns];
585
- }, [orderedColumns, checkboxSelection, hasDetailPanel, rowReordering]);
586
-
587
- const handleColumnResize = useCallback((field: string, newWidth: number) => {
588
- setColumnWidths(prev => ({ ...prev, [field]: newWidth }));
589
- }, []);
590
-
591
- const onStateChangeRef = useRef(onStateChange);
592
- onStateChangeRef.current = onStateChange;
593
-
594
- useEffect(() => {
595
- if (!onStateChangeRef.current) return;
596
-
597
- const snapshot: GridState = {
598
- sorting: { sortModel: sortModel as { field: string; sort: 'asc' | 'desc' }[] },
599
- filter: { filterModel },
600
- pagination: { paginationModel: effectivePaginationModel },
601
- columns: {
602
- columnWidths,
603
- columnOrder: effectiveColumnOrder,
604
- columnVisibilityModel,
605
- pinnedColumns: pinnedColumns,
606
- },
607
- };
383
+ // ── Column management ─────────────────────────────────────────────────────
384
+ const {
385
+ effectiveColumns,
386
+ orderedColumns,
387
+ visibleOrderedColumns,
388
+ navigationColumns,
389
+ columnWidths,
390
+ effectiveColumnOrder,
391
+ setInternalColumnOrder,
392
+ columnReorderHandlers,
393
+ handleColumnResize,
394
+ } = useGridColumns<R>({
395
+ activeColumns,
396
+ isHierarchyEnabled,
397
+ isRowGrouping,
398
+ isTreeData,
399
+ activeHierarchyHandlers,
400
+ columnVisibilityModel,
401
+ columnOrder,
402
+ onColumnOrderChange,
403
+ disableColumnReorder,
404
+ pivotMode,
405
+ checkboxSelection,
406
+ hasDetailPanel,
407
+ rowReordering,
408
+ initialState,
409
+ setColumns,
410
+ });
608
411
 
609
- onStateChangeRef.current(snapshot);
610
- }, [
412
+ useGridStateSnapshot({
413
+ onStateChange,
611
414
  sortModel,
612
415
  filterModel,
613
416
  effectivePaginationModel,
@@ -615,61 +418,34 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
615
418
  effectiveColumnOrder,
616
419
  columnVisibilityModel,
617
420
  pinnedColumns,
618
- ]);
619
-
620
- const baseRows = useMemo(() => {
621
- return effectiveRows;
622
- }, [effectiveRows]);
623
-
624
- const filteredRows = useMemo(() => {
625
- if (activeHierarchyHandlers) {
626
-
627
- return (activeHierarchyHandlers.getVisibleRows() || []) as R[];
628
- }
629
-
630
- if (filterMode === 'server' && dataSource) {
631
- return baseRows;
632
- }
633
-
634
- return filterRows(baseRows, filterModel);
635
- }, [baseRows, filterModel, activeHierarchyHandlers, filterMode, dataSource]);
636
-
637
- const { top: pinnedTopRows, center: unpinnedRows, bottom: pinnedBottomRows } = useMemo(() => {
638
- if (activeHierarchyHandlers) {
639
-
640
- return { top: [], center: filteredRows, bottom: [] };
641
- }
642
- return getPinnedRowGroups(filteredRows, pinnedRows);
643
- }, [filteredRows, pinnedRows, activeHierarchyHandlers]);
644
-
645
- const sortedUnpinnedRows = useMemo(() => {
646
- if (activeHierarchyHandlers) {
647
- return unpinnedRows;
648
- }
649
-
650
- if (sortingMode === 'server' && dataSource) {
651
- return unpinnedRows;
652
- }
653
-
654
- return sortRows(unpinnedRows, sortModel);
655
- }, [unpinnedRows, sortModel, activeHierarchyHandlers, sortingMode, dataSource]);
656
-
657
- const paginatedUnpinnedRows = useMemo(() => {
658
- if (!pagination) return sortedUnpinnedRows;
659
-
660
- if (paginationMode === 'server' && dataSource) {
661
- return sortedUnpinnedRows;
662
- }
421
+ });
663
422
 
664
- const start = effectivePaginationModel.page * effectivePaginationModel.pageSize;
665
- const end = start + effectivePaginationModel.pageSize;
666
- return sortedUnpinnedRows.slice(start, end);
667
- }, [sortedUnpinnedRows, pagination, effectivePaginationModel.page, effectivePaginationModel.pageSize, paginationMode, dataSource]);
423
+ const rowPipeline = useGridRowPipeline<GridRowModel>({
424
+ effectiveRows: effectiveRows as GridRowModel[],
425
+ activeHierarchyHandlers: activeHierarchyHandlers as { getVisibleRows: () => GridRowModel[] } | null,
426
+ filterMode,
427
+ filterModel,
428
+ dataSource: dataSource as GridDataSource<GridRowModel> | undefined,
429
+ sortModel,
430
+ sortingMode,
431
+ pagination,
432
+ paginationMode,
433
+ effectivePaginationModel,
434
+ pinnedRows,
435
+ isLoading: state.dataSource.loading,
436
+ pageSize: effectivePaginationModel.pageSize,
437
+ });
438
+ const filteredRows = rowPipeline.filteredRows as R[];
439
+ const pinnedTopRows = rowPipeline.pinnedTopRows as R[];
440
+ const pinnedBottomRows = rowPipeline.pinnedBottomRows as R[];
441
+ const sortedUnpinnedRows = rowPipeline.sortedUnpinnedRows as R[];
442
+ const paginatedUnpinnedRows = rowPipeline.paginatedUnpinnedRows as R[];
443
+ const allRenderableRows = rowPipeline.allRenderableRows as R[];
668
444
 
669
445
  useEffect(() => {
670
- gridData.apiRef.current.getVisibleRows = () => sortedUnpinnedRows;
671
- gridData.apiRef.current.getVisibleColumns = () => effectiveColumns as any[];
672
- }, [sortedUnpinnedRows, effectiveColumns, gridData.apiRef]);
446
+ gridData.apiRef.current.getVisibleRows = () => pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
447
+ gridData.apiRef.current.getVisibleColumns = () => effectiveColumns as unknown as GridColDef[];
448
+ }, [sortedUnpinnedRows, paginatedUnpinnedRows, pagination, effectiveColumns, gridData.apiRef]);
673
449
 
674
450
  const rowReorderHandlers = useRowReorder({
675
451
  rows: pagination ? paginatedUnpinnedRows : sortedUnpinnedRows,
@@ -678,29 +454,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
678
454
  rowReordering
679
455
  });
680
456
 
681
- const allRenderableRows = useMemo(() => {
682
-
683
- if (activeHierarchyHandlers) {
684
- return unpinnedRows;
685
- }
686
- const centerRows = pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
687
- const base = [...pinnedTopRows, ...centerRows, ...pinnedBottomRows];
688
-
689
- // Append skeleton rows while an infinite-scroll fetch is in flight
690
- if (paginationMode === 'infinite' && state.dataSource.loading && base.length > 0) {
691
- const skeletonCount = Math.min(effectivePaginationModel.pageSize, 20);
692
- const skeletons = Array.from({ length: skeletonCount }, (_, i) => ({
693
- id: `__skeleton_${i}__`,
694
- _isSkeleton: true,
695
- }));
696
- return [...base, ...skeletons] as typeof base;
697
- }
698
-
699
- return base;
700
- }, [pinnedTopRows, paginatedUnpinnedRows, sortedUnpinnedRows, pinnedBottomRows, pagination,
701
- activeHierarchyHandlers, unpinnedRows, paginationMode, state.dataSource.loading,
702
- effectivePaginationModel.pageSize]);
703
-
704
457
  const spanning = useGridSpanning(
705
458
  allRenderableRows,
706
459
  navigationColumns,
@@ -711,7 +464,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
711
464
  rows: filteredRows,
712
465
  aggregationModel,
713
466
  isServerSide: !!(dataSource && (paginationMode === 'server' || sortingMode === 'server')),
714
- dataSource: dataSource as any,
467
+ dataSource,
715
468
  filterModel,
716
469
  sortModel,
717
470
  serverAggregationResults,
@@ -724,325 +477,19 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
724
477
  gridData.apiRef.current.getAggregationModel = () => hasAggregation ? aggregationModel : null;
725
478
  }, [aggregationResult, aggregationModel, hasAggregation, gridData.apiRef]);
726
479
 
727
- const layout = useMemo(() => {
728
- const baseRowHeight = rowHeight;
729
-
730
- const unpinnedRows = pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
731
- const rowHeights = unpinnedRows.map((row, index) => {
732
- let height = baseRowHeight;
733
- if (expandedRowIds.has(row.id)) {
734
- const detailHeight = getDetailPanelHeight?.({ row, id: row.id, rowIndex: pinnedTopRows.length + index }) ?? 200;
735
- height += typeof detailHeight === 'number' ? detailHeight : parseInt(String(detailHeight), 10) || 200;
736
- }
737
- return height;
738
- });
739
-
740
- const cumulativeHeights = rowHeights.reduce((acc, height, index) => {
741
- acc.push((acc[index - 1] || 0) + height);
742
- return acc;
743
- }, [] as number[]);
744
-
745
- const skeletonCount = (paginationMode === 'infinite' && state.dataSource.loading && (unpinnedRows.length > 0))
746
- ? Math.min(effectivePaginationModel.pageSize, 20)
747
- : 0;
748
- const skeletonRowsHeight = skeletonCount * baseRowHeight;
749
- const unpinnedRowsHeight = (cumulativeHeights[cumulativeHeights.length - 1] || 0) + skeletonRowsHeight;
750
- const pinnedTopHeight = pinnedTopRows.length * baseRowHeight;
751
- const pinnedBottomHeight = pinnedBottomRows.length * baseRowHeight;
752
-
753
- const rawLeftPinnedCols: GridColDef<R>[] = [];
754
- const rawRightPinnedCols: GridColDef<R>[] = [];
755
- const unpinnedCols: GridColDef<R>[] = [];
756
- const unpinnedColWidths: number[] = [];
757
-
758
- visibleOrderedColumns.forEach(col => {
759
- const pinned = isColumnPinned(col.field, pinnedColumns);
760
- if (pinned === 'left') {
761
- rawLeftPinnedCols.push(col);
762
- } else if (pinned === 'right') {
763
- rawRightPinnedCols.push(col);
764
- } else {
765
- unpinnedCols.push(col);
766
- }
767
- });
768
-
769
- const viewportWidth = state.dimensions.viewportWidth || 1000;
770
- const systemColumnsWidth = (checkboxSelection ? 48 : 0) + (hasDetailPanel ? 48 : 0) + (rowReordering ? 48 : 0);
771
-
772
- const getColWidth = (c: GridColDef<R>) => columnWidths[c.field] ?? c.width;
773
-
774
- const parseWidth = (width: number | string | undefined): { type: 'fixed' | 'percentage' | 'auto', value: number } => {
775
- if (width === undefined) return { type: 'auto', value: 0 };
776
- if (typeof width === 'number') return { type: 'fixed', value: width };
777
- if (typeof width === 'string') {
778
- if (width.toLowerCase() === 'auto') return { type: 'auto', value: 0 };
779
- if (width.endsWith('%')) {
780
- const percentage = parseFloat(width);
781
- return { type: 'percentage', value: percentage };
782
- }
783
- const floatVal = parseFloat(width);
784
- if (!isNaN(floatVal)) return { type: 'fixed', value: floatVal };
785
- }
786
- return { type: 'fixed', value: 100 };
787
- };
788
-
789
- const leftWidth = rawLeftPinnedCols.reduce((sum, c) => {
790
- const parsed = parseWidth(getColWidth(c));
791
- return sum + (parsed.type === 'fixed' ? parsed.value : 100);
792
- }, 0);
793
- const rightWidth = rawRightPinnedCols.reduce((sum, c) => {
794
- const parsed = parseWidth(getColWidth(c));
795
- return sum + (parsed.type === 'fixed' ? parsed.value : 100);
796
- }, 0);
797
-
798
- const naturalFlexWidth = unpinnedCols.reduce((sum, col) => {
799
- const hasManualWidth = Object.prototype.hasOwnProperty.call(columnWidths, col.field);
800
- if (!hasManualWidth && col.flex && col.flex > 0) {
801
- const minWidth = col.minWidth ?? 150;
802
- const width = typeof col.width === 'number' ? col.width : 150;
803
- return sum + (minWidth || width);
804
- }
805
- const parsed = parseWidth(getColWidth(col));
806
- if (parsed.type === 'fixed') return sum + parsed.value;
807
- if (parsed.type === 'percentage') return sum + 100;
808
- return sum + (col.minWidth ?? 150);
809
- }, 0);
810
-
811
- const availableWidth = Math.max(
812
- viewportWidth - systemColumnsWidth - leftWidth - rightWidth,
813
- naturalFlexWidth
814
- );
815
-
816
- const fixedWidthCols: Array<{ col: GridColDef<R>, width: number }> = [];
817
- const percentageCols: Array<{ col: GridColDef<R>, percentage: number }> = [];
818
- const flexCols: Array<{ col: GridColDef<R>, flex: number }> = [];
819
- let totalFlexUnits = 0;
820
-
821
- unpinnedCols.forEach(col => {
822
- // If the user has manually resized this column, columnWidths has an explicit
823
- // entry for it. Respect that stored width as fixed-width so the resize sticks,
824
- // even if the column definition originally had flex.
825
- const hasManualWidth = Object.prototype.hasOwnProperty.call(columnWidths, col.field);
826
-
827
- const parsed = parseWidth(getColWidth(col));
828
-
829
- if (!hasManualWidth && col.flex && col.flex > 0) {
830
- flexCols.push({ col, flex: col.flex });
831
- totalFlexUnits += col.flex;
832
- return;
833
- }
834
-
835
- if (parsed.type === 'fixed') {
836
- fixedWidthCols.push({ col, width: parsed.value });
837
- } else if (parsed.type === 'percentage') {
838
- percentageCols.push({ col, percentage: parsed.value });
839
- } else {
840
-
841
- const implicitFlex = 1;
842
- flexCols.push({ col, flex: implicitFlex });
843
- totalFlexUnits += implicitFlex;
844
- }
845
- });
846
-
847
- const fixedWidth = fixedWidthCols.reduce((sum, { width }) => sum + width, 0);
848
-
849
- let remainingSpaceForFlex = Math.max(0, availableWidth - fixedWidth);
850
-
851
- const percentageWidthMap = new Map<string, number>();
852
- percentageCols.forEach(({ col, percentage }) => {
853
- const calculatedWidth = (percentage / 100) * remainingSpaceForFlex;
854
- percentageWidthMap.set(col.field, calculatedWidth);
855
-
856
- remainingSpaceForFlex -= calculatedWidth;
857
- });
858
-
859
- remainingSpaceForFlex = Math.max(0, remainingSpaceForFlex);
860
-
861
- const finalFlexWidths = new Map<string, number>();
862
-
863
- const flexItems = flexCols.map(f => ({
864
- ...f,
865
- minWidth: f.col.minWidth ?? 50,
866
- maxWidth: f.col.maxWidth ?? Infinity,
867
- frozen: false,
868
- computedWidth: 0
869
- }));
870
-
871
- const solveFlexAllocation = () => {
872
-
873
- let iterations = 0;
874
- const maxIterations = flexItems.length * 2;
875
-
876
- while (iterations < maxIterations) {
877
- iterations++;
878
-
879
- const unfrozen = flexItems.filter(f => !f.frozen);
880
-
881
- if (unfrozen.length === 0) break;
882
-
883
- const unfrozenFlexTotal = unfrozen.reduce((sum, f) => sum + f.flex, 0);
884
-
885
- const frozenWidthTotal = flexItems.reduce((sum, f) => f.frozen ? sum + f.computedWidth : sum, 0);
886
- const currentFreeSpace = Math.max(0, remainingSpaceForFlex - frozenWidthTotal);
887
-
888
- if (unfrozenFlexTotal <= 0) {
889
- unfrozen.forEach(f => {
890
- f.computedWidth = f.minWidth;
891
- f.frozen = true;
892
- });
893
- break;
894
- }
895
-
896
- const pixelsPerFlex = currentFreeSpace / unfrozenFlexTotal;
897
-
898
- let totalViolation = 0;
899
- const minViolators: typeof flexItems = [];
900
- const maxViolators: typeof flexItems = [];
901
-
902
- unfrozen.forEach(f => {
903
- const rawWidth = pixelsPerFlex * f.flex;
904
-
905
- if (rawWidth < f.minWidth) {
906
- const diff = f.minWidth - rawWidth;
907
- totalViolation += diff;
908
- minViolators.push(f);
909
- } else if (rawWidth > f.maxWidth) {
910
- const diff = f.maxWidth - rawWidth;
911
- totalViolation += diff;
912
- maxViolators.push(f);
913
- } else {
914
-
915
- f.computedWidth = rawWidth;
916
- }
917
- });
918
-
919
- if (minViolators.length === 0 && maxViolators.length === 0) {
920
- unfrozen.forEach(f => f.frozen = true);
921
- break;
922
- }
923
-
924
- if (totalViolation > 0) {
925
- minViolators.forEach(f => {
926
- f.computedWidth = f.minWidth;
927
- f.frozen = true;
928
- });
929
- } else if (totalViolation < 0) {
930
- maxViolators.forEach(f => {
931
- f.computedWidth = f.maxWidth;
932
- f.frozen = true;
933
- });
934
- } else {
935
-
936
- minViolators.forEach(f => { f.computedWidth = f.minWidth; f.frozen = true; });
937
- maxViolators.forEach(f => { f.computedWidth = f.maxWidth; f.frozen = true; });
938
- }
939
- }
940
- };
941
-
942
- solveFlexAllocation();
943
- flexItems.forEach(f => finalFlexWidths.set(f.col.field, f.computedWidth));
944
-
945
- const computedWidthMap = new Map<string, number>();
946
-
947
- unpinnedCols.forEach(col => {
948
- let computedWidth: number;
949
-
950
- if (finalFlexWidths.has(col.field)) {
951
- computedWidth = finalFlexWidths.get(col.field)!;
952
- } else {
953
- const fixedCol = fixedWidthCols.find(f => f.col.field === col.field);
954
- const pctCol = percentageCols.find(p => p.col.field === col.field);
955
-
956
- if (fixedCol) {
957
- computedWidth = fixedCol.width;
958
- } else if (pctCol) {
959
- const rawPctWidth = percentageWidthMap.get(col.field) || 0;
960
- computedWidth = Math.max(col.minWidth ?? 50, rawPctWidth || 100);
961
- } else {
962
- computedWidth = 100;
963
- }
964
- }
965
-
966
- unpinnedColWidths.push(computedWidth);
967
- computedWidthMap.set(col.field, computedWidth);
968
- });
969
-
970
- const unpinnedColsWithWidth = unpinnedCols.map(col => {
971
- const flexItem = flexItems.find(f => f.col.field === col.field);
972
- return {
973
- ...col,
974
- width: computedWidthMap.get(col.field) ?? (typeof col.width === 'number' ? col.width : 100),
975
- flex: flexItem ? flexItem.flex : col.flex
976
- };
977
- });
978
-
979
- const baseLeftZ = 11;
980
- const leftPinnedCols = rawLeftPinnedCols.map((col, i) => {
981
- const parsed = parseWidth(getColWidth(col));
982
- const numericWidth = parsed.type === 'fixed' ? parsed.value : 100;
983
- return {
984
- ...col,
985
- width: numericWidth,
986
- zIndex: baseLeftZ + i
987
- };
988
- });
989
-
990
- const baseRightZ = 11;
991
- const rightPinnedCols = rawRightPinnedCols.map((col, i) => {
992
- const parsed = parseWidth(getColWidth(col));
993
- const numericWidth = parsed.type === 'fixed' ? parsed.value : 100;
994
- return {
995
- ...col,
996
- width: numericWidth,
997
- zIndex: baseRightZ + (rawRightPinnedCols.length - 1 - i)
998
- };
999
- });
1000
-
1001
- const unpinnedTotalWidth = unpinnedColWidths.reduce((sum, w) => sum + w, 0);
1002
- const totalWidth = leftWidth + unpinnedTotalWidth + rightWidth + systemColumnsWidth;
1003
-
1004
- const unpinnedAccWidths = unpinnedColWidths.reduce((acc, w, i) => {
1005
- acc.push((acc[i - 1] || 0) + w);
1006
- return acc;
1007
- }, [] as number[]);
1008
-
1009
- return {
1010
- rowHeights,
1011
- cumulativeHeights,
1012
- unpinnedRowsHeight,
1013
- pinnedTopHeight,
1014
- pinnedBottomHeight,
1015
- leftPinnedCols,
1016
- rightPinnedCols,
1017
- unpinnedColsWithWidth,
1018
- unpinnedColWidths,
1019
- unpinnedAccWidths,
1020
- totalWidth,
1021
- leftWidth,
1022
- rightWidth,
1023
- unpinnedTotalWidth,
1024
- systemColumnsWidth,
1025
- pinnedTopRowsLength: pinnedTopRows.length,
1026
- pinnedBottomRowsLength: pinnedBottomRows.length,
1027
- unpinnedRowsLength: unpinnedRows.length
1028
- };
1029
- }, [
480
+ const layout = useLayout({
1030
481
  rowHeight,
1031
482
  pagination,
1032
483
  paginatedUnpinnedRows,
1033
484
  sortedUnpinnedRows,
1034
485
  expandedRowIds,
1035
486
  getDetailPanelHeight,
1036
- pinnedTopRows.length,
1037
- pinnedBottomRows.length,
1038
- expandedRowIds,
1039
- getDetailPanelHeight,
1040
- pinnedTopRows.length,
1041
- pinnedBottomRows.length,
487
+ pinnedTopRowsLength: pinnedTopRows.length,
488
+ pinnedBottomRowsLength: pinnedBottomRows.length,
1042
489
  visibleOrderedColumns,
1043
490
  pinnedColumns,
1044
491
  columnWidths,
1045
- state.dimensions.viewportWidth,
492
+ viewportWidth: state.dimensions.viewportWidth || 1000,
1046
493
  checkboxSelection,
1047
494
  hasDetailPanel,
1048
495
  rowReordering,
@@ -1050,152 +497,60 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1050
497
  pinExpandColumn,
1051
498
  autoHeight,
1052
499
  paginationMode,
1053
- state.dataSource.loading,
1054
- effectivePaginationModel.pageSize
1055
- ]);
1056
-
1057
- const virtualization = useMemo(() => {
1058
- const {
1059
- unpinnedAccWidths,
1060
- unpinnedColsWithWidth,
1061
- leftPinnedCols,
1062
- rightPinnedCols,
1063
- totalWidth,
1064
- leftWidth,
1065
- rightWidth,
1066
- unpinnedTotalWidth,
1067
- systemColumnsWidth,
1068
- cumulativeHeights,
1069
- pinnedTopHeight,
1070
- pinnedBottomHeight,
1071
- unpinnedRowsHeight,
1072
- rowHeights,
1073
- unpinnedRowsLength
1074
- } = layout;
1075
-
1076
- const scrollLeft = state.virtualization.scrollLeft;
1077
- const scrollTop = state.virtualization.scrollTop;
1078
- const viewportWidth = state.dimensions.viewportWidth || 1000;
1079
- const viewportHeight = autoHeight ? (pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + 50) : (state.dimensions.viewportHeight || 600);
1080
-
1081
- const overscanCols = 6;
1082
-
1083
- const visibleLocalStart = Math.max(0, scrollLeft - leftWidth);
1084
- const visibleLocalEnd = scrollLeft + viewportWidth - leftWidth;
1085
-
1086
- let firstUnpinnedIndex = 0;
1087
- let lastUnpinnedIndex = unpinnedColsWithWidth.length - 1;
1088
-
1089
- let low = 0, high = unpinnedAccWidths.length - 1;
1090
- while (low <= high) {
1091
- const mid = (low + high) >>> 1;
1092
- if (unpinnedAccWidths[mid] < visibleLocalStart) {
1093
- low = mid + 1;
1094
- } else {
1095
- high = mid - 1;
1096
- }
1097
- }
1098
- firstUnpinnedIndex = Math.max(0, low - overscanCols);
1099
-
1100
- for (let i = firstUnpinnedIndex; i < unpinnedAccWidths.length; i++) {
1101
- if ((i === 0 ? 0 : unpinnedAccWidths[i - 1]) > visibleLocalEnd) {
1102
- lastUnpinnedIndex = Math.min(unpinnedColsWithWidth.length - 1, i + overscanCols);
1103
- break;
1104
- }
1105
- lastUnpinnedIndex = Math.min(unpinnedColsWithWidth.length - 1, i + overscanCols);
1106
- }
1107
-
1108
- const leftSpacerWidth = firstUnpinnedIndex > 0 ? unpinnedAccWidths[firstUnpinnedIndex - 1] : 0;
1109
- const rightSpacerWidth = unpinnedTotalWidth - unpinnedAccWidths[lastUnpinnedIndex];
1110
-
1111
- const virtualColumns: any[] = [
1112
- ...leftPinnedCols,
1113
- ...(leftSpacerWidth > 0 ? [{ field: '__spacer_left__', width: leftSpacerWidth, isSpacer: true }] : []),
1114
- ...unpinnedColsWithWidth.slice(firstUnpinnedIndex, lastUnpinnedIndex + 1),
1115
- ...(rightSpacerWidth > 0 ? [{ field: '__spacer_right__', width: rightSpacerWidth, isSpacer: true }] : []),
1116
- ...rightPinnedCols
1117
- ];
1118
-
1119
- const hasHorizontalScroll = viewportWidth > 0 && totalWidth > viewportWidth;
1120
- const scrollbarBuffer = hasHorizontalScroll ? 16 : 0;
1121
- const totalHeight = pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + scrollbarBuffer + 2;
1122
-
1123
- const overscanRows = 5;
1124
- let firstRowIndex = 0;
1125
- let lastRowIndex = unpinnedRowsLength - 1;
1126
- let offsetTop = 0;
1127
-
1128
- let rLow = 0, rHigh = cumulativeHeights.length - 1;
1129
- while (rLow <= rHigh) {
1130
- const mid = (rLow + rHigh) >>> 1;
1131
- if (cumulativeHeights[mid] < scrollTop) {
1132
- rLow = mid + 1;
1133
- } else {
1134
- rHigh = mid - 1;
1135
- }
1136
- }
1137
- firstRowIndex = Math.max(0, rLow - overscanRows);
1138
- offsetTop = firstRowIndex > 0 ? cumulativeHeights[firstRowIndex - 1] : 0;
500
+ isLoading: state.dataSource.loading,
501
+ pageSize: effectivePaginationModel.pageSize,
502
+ });
1139
503
 
1140
- for (let i = firstRowIndex; i < cumulativeHeights.length; i++) {
1141
- if (cumulativeHeights[i] >= scrollTop + viewportHeight) {
1142
- lastRowIndex = Math.min(unpinnedRowsLength - 1, i + overscanRows);
1143
- break;
504
+ useEffect(() => {
505
+ gridData.apiRef.current.scrollToIndexes = ({ rowIndex, colIndex }) => {
506
+ const el = viewportRef.current;
507
+ if (!el) return;
508
+
509
+ if (rowIndex !== undefined && rowIndex >= 0) {
510
+ const rowTop = rowIndex > 0 ? layout.cumulativeHeights[rowIndex - 1] : 0;
511
+ const rowBottom = layout.cumulativeHeights[rowIndex] ?? rowTop;
512
+ const { scrollTop, clientHeight } = el;
513
+ if (rowTop < scrollTop) {
514
+ el.scrollTop = rowTop;
515
+ } else if (rowBottom > scrollTop + clientHeight) {
516
+ el.scrollTop = rowBottom - clientHeight;
517
+ }
1144
518
  }
1145
- }
1146
519
 
1147
- return {
1148
- renderContext: {
1149
- firstRowIndex,
1150
- lastRowIndex,
1151
- firstColumnIndex: firstUnpinnedIndex,
1152
- lastColumnIndex: lastUnpinnedIndex
1153
- },
1154
- offsetTop,
1155
- offsetLeft: 0,
1156
- totalHeight,
1157
- pinnedTopHeight,
1158
- pinnedBottomHeight,
1159
- totalWidth,
1160
- rowHeights,
1161
- cumulativeHeights,
1162
- virtualColumns,
1163
- columnMetrics: {
1164
- leftPinnedWidth: leftWidth,
1165
- rightPinnedWidth: rightWidth,
1166
- unpinnedAccWidths,
1167
- unpinnedCols: unpinnedColsWithWidth,
1168
- totalSpecialsWidth: systemColumnsWidth,
1169
- pinnedSpecialsWidth: (rowReordering ? 48 : 0) + (hasDetailPanel && pinExpandColumn ? 48 : 0) + (checkboxSelection && pinCheckboxColumn ? 48 : 0)
520
+ if (colIndex !== undefined && colIndex >= 0) {
521
+ // colIndex is an index into all data columns: leftPinned + unpinned + rightPinned
522
+ const allDataCols = [...layout.leftPinnedCols, ...layout.unpinnedColsWithWidth, ...layout.rightPinnedCols];
523
+ const targetCol = allDataCols[colIndex];
524
+ if (!targetCol) return;
525
+ const unpinnedIndex = layout.unpinnedColsWithWidth.findIndex(c => c.field === targetCol.field);
526
+ if (unpinnedIndex === -1) return; // pinned column — always visible, no scroll needed
527
+ const colLocalLeft = unpinnedIndex > 0 ? layout.unpinnedAccWidths[unpinnedIndex - 1] : 0;
528
+ const colLocalRight = layout.unpinnedAccWidths[unpinnedIndex] ?? colLocalLeft;
529
+ const colRight = layout.leftWidth + colLocalRight;
530
+ const { scrollLeft, clientWidth } = el;
531
+ if (colLocalLeft < scrollLeft) {
532
+ el.scrollLeft = colLocalLeft;
533
+ } else if (colRight > scrollLeft + clientWidth) {
534
+ el.scrollLeft = colRight - clientWidth;
535
+ }
1170
536
  }
1171
537
  };
1172
- }, [
1173
- layout,
1174
- state.virtualization.scrollTop,
1175
- state.virtualization.scrollLeft,
1176
- state.dimensions.viewportHeight,
1177
- state.dimensions.viewportWidth,
1178
- autoHeight
1179
- ]);
1180
-
1181
- const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
1182
- const target = event.currentTarget;
1183
- setScroll(target.scrollTop, target.scrollLeft);
1184
-
1185
- if (onRowsScrollEnd) {
1186
- const { scrollTop, scrollHeight, clientHeight } = target;
1187
- const scrollThreshold = 100;
538
+ }, [layout, viewportRef, gridData.apiRef]);
1188
539
 
1189
- if (scrollHeight - scrollTop - clientHeight < scrollThreshold) {
540
+ const virtualization = useGridVirtualization({
541
+ layout,
542
+ scrollTop,
543
+ scrollLeft,
544
+ viewportWidth: state.dimensions.viewportWidth,
545
+ viewportHeight: state.dimensions.viewportHeight,
546
+ autoHeight,
547
+ rowReordering,
548
+ hasDetailPanel,
549
+ checkboxSelection,
550
+ pinCheckboxColumn,
551
+ pinExpandColumn,
552
+ });
1190
553
 
1191
- onRowsScrollEnd({
1192
- visibleTop: scrollTop,
1193
- visibleBottom: scrollTop + clientHeight,
1194
- viewportHeight: clientHeight
1195
- });
1196
- }
1197
- }
1198
- }, [setScroll, onRowsScrollEnd]);
1199
554
 
1200
555
  useEffect(() => {
1201
556
  if (!viewportRef.current) return;
@@ -1227,28 +582,46 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1227
582
  setInternalRowSelectionModel(newSelectionArray);
1228
583
  }
1229
584
  onRowSelectionModelChange?.(newSelectionArray);
1230
- }, [selectedRowIds, isSelectionControlled, onRowSelectionModelChange]);
585
+ }, [selectedRowIds, isSelectionControlled, onRowSelectionModelChange, setInternalRowSelectionModel]);
1231
586
 
1232
587
  const handleSelectAll = useCallback((isSelected: boolean) => {
1233
588
  let newSelection: GridRowId[] = [];
1234
589
  if (isSelected) {
1235
- newSelection = baseRows.map(row => row.id);
590
+ newSelection = effectiveRows.map((row: GridRowModel) => row.id);
1236
591
  }
1237
592
 
1238
593
  if (!isSelectionControlled) {
1239
594
  setInternalRowSelectionModel(newSelection);
1240
595
  }
1241
596
  onRowSelectionModelChange?.(newSelection);
1242
- }, [rows, isSelectionControlled, onRowSelectionModelChange]);
597
+ }, [effectiveRows, isSelectionControlled, onRowSelectionModelChange, setInternalRowSelectionModel]);
1243
598
 
1244
599
  const handleSort = useCallback((field: string, direction: GridSortDirection) => {
1245
600
  const newSortModel = direction ? [{ field, sort: direction }] : [];
1246
601
 
1247
602
  if (!isSortControlled) {
1248
- setInternalSortModel(newSortModel as any);
603
+ setInternalSortModel(newSortModel as GridSortItem[]);
1249
604
  }
1250
- onSortModelChange?.(newSortModel as any);
1251
- }, [isSortControlled, onSortModelChange]);
605
+ onSortModelChange?.(newSortModel as GridSortItem[]);
606
+ }, [isSortControlled, onSortModelChange, setInternalSortModel]);
607
+
608
+ const { focusedCell, setFocusedCell, handleFocus, handleBlur, handleKeyDown } = useGridKeyboardNavigation({
609
+ allRenderableRows,
610
+ navigationColumns,
611
+ checkboxSelection,
612
+ selectedRowIds,
613
+ handleSelectionChange,
614
+ handleDetailPanelToggle,
615
+ editingHandlers,
616
+ setKeyboardMode,
617
+ sortModel,
618
+ handleSort,
619
+ isCellEditable,
620
+ pagination,
621
+ pageSize: effectivePaginationModel.pageSize,
622
+ virtualization,
623
+ viewportRef,
624
+ });
1252
625
 
1253
626
  const handleCellClick = useCallback((params: GridCellParams<R>) => {
1254
627
  setKeyboardMode(false);
@@ -1256,7 +629,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1256
629
 
1257
630
  gridRef.current?.focus({ preventScroll: true });
1258
631
  onCellClick?.(params);
1259
- }, [onCellClick, setKeyboardMode]);
632
+ }, [onCellClick, setKeyboardMode, setFocusedCell]);
1260
633
 
1261
634
  const prevEditingCellRef = useRef(editingHandlers.editingCell);
1262
635
  useEffect(() => {
@@ -1271,376 +644,68 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1271
644
  prevEditingCellRef.current = editingHandlers.editingCell;
1272
645
  }, [editingHandlers.editingCell]);
1273
646
 
1274
- const visibleRows = useMemo(() => {
1275
- const { firstRowIndex, lastRowIndex } = virtualization.renderContext;
1276
-
1277
- const topPinnedCount = pinnedTopRows.length;
1278
-
1279
- const topPinned = pinnedTopRows.map((row, index) => ({ row, rowIndex: index }));
1280
- const bottomPinned = pinnedBottomRows.map((row, index) => ({
1281
- row,
1282
- rowIndex: topPinnedCount + (pagination ? paginatedUnpinnedRows.length : sortedUnpinnedRows.length) + index
1283
- }));
1284
-
1285
- const centerStartIndex = Math.max(0, firstRowIndex - topPinnedCount);
1286
- const centerEndIndex = Math.min(
1287
- pagination ? paginatedUnpinnedRows.length : sortedUnpinnedRows.length,
1288
- lastRowIndex - topPinnedCount + 1
1289
- );
1290
-
1291
- const centerRows = (pagination ? paginatedUnpinnedRows : sortedUnpinnedRows)
1292
- .slice(centerStartIndex, centerEndIndex)
1293
- .map((row, index) => ({
1294
- row,
1295
- rowIndex: topPinnedCount + centerStartIndex + index
1296
- }));
1297
-
1298
- const combined = [...topPinned, ...centerRows, ...bottomPinned];
1299
-
1300
- const seenIds = new Set<GridRowId>();
1301
- const deduplicated = combined.filter(item => {
1302
- if (seenIds.has(item.row.id)) {
1303
- return false;
1304
- }
1305
- seenIds.add(item.row.id);
1306
- return true;
1307
- });
1308
-
1309
- return deduplicated;
1310
- }, [virtualization.renderContext, pinnedTopRows, pinnedBottomRows, paginatedUnpinnedRows, sortedUnpinnedRows, pagination,
1311
- paginationMode, state.dataSource.loading, effectivePaginationModel.pageSize, rowHeight]);
647
+ const visibleRows = useGridVisibleRows<R>({
648
+ renderContext: virtualization.renderContext,
649
+ pinnedTopRows,
650
+ pinnedBottomRows,
651
+ paginatedUnpinnedRows,
652
+ sortedUnpinnedRows,
653
+ pagination,
654
+ });
1312
655
 
1313
656
 
1314
657
  const allSelected = rows.length > 0 && selectedRowIds.size === rows.length;
1315
658
  const someSelected = selectedRowIds.size > 0 && selectedRowIds.size < rows.length;
1316
659
 
1317
- const handleFocus = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
1318
-
1319
- if (event.target === event.currentTarget) {
1320
- setFocusedCell(prev => {
1321
- if (prev) return prev;
1322
-
1323
- if (checkboxSelection) {
1324
- return { id: 'HEADER', field: '__checkbox_col__' };
1325
- }
1326
-
1327
- const firstRow = allRenderableRows[0];
1328
- const firstCol = navigationColumns[0];
1329
- if (firstRow && firstCol) {
1330
- return { id: firstRow.id, field: firstCol.field };
1331
- }
1332
- return null;
1333
- });
1334
- }
1335
- }, [allRenderableRows, navigationColumns]);
1336
-
1337
- const handleBlur = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
1338
-
1339
- if (!event.currentTarget.contains(event.relatedTarget as Node)) {
1340
- setFocusedCell(null);
1341
- }
1342
- }, []);
1343
-
1344
- const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
1345
- setKeyboardMode(true);
1346
-
1347
- if (!focusedCell) return;
1348
-
1349
- const { id, field } = focusedCell;
1350
-
1351
- const isEditing = Boolean(editingHandlers.editingCell);
1352
-
1353
- if (event.key === 'Enter') {
1354
- event.preventDefault();
1355
- if (isEditing) {
1356
- editingHandlers.stopCellEdit();
1357
- } else {
1358
-
1359
- const col = navigationColumns.find(c => c.field === field);
1360
- if (col?.editable) {
1361
- const row = allRenderableRows.find(r => r.id === id);
1362
- if (row) {
1363
- editingHandlers.startCellEdit({
1364
- id,
1365
- field,
1366
- value: (row as any)[field]
1367
- });
1368
- }
1369
- }
1370
- }
1371
- return;
1372
- }
1373
-
1374
- if (event.key === ' ' || event.key === 'Spacebar') {
1375
- if (!isEditing) {
1376
- if (field === '__checkbox_col__') {
1377
- event.preventDefault();
1378
- handleSelectionChange(id, !selectedRowIds.has(id));
1379
- return;
1380
- }
1381
- if (field === '__expand_col__') {
1382
- event.preventDefault();
1383
- handleDetailPanelToggle(id);
1384
- return;
1385
- }
1386
- }
1387
- }
1388
-
1389
- if (id === 'HEADER' && (event.key === 'Enter' || event.key === ' ')) {
1390
- event.preventDefault();
1391
-
1392
- const col = navigationColumns.find(c => c.field === field);
1393
- if (col && (col as any).sortable !== false) {
1394
- const currentSort = sortModel.find(item => item.field === field);
1395
- let newDirection: GridSortDirection = 'asc';
1396
-
1397
- if (currentSort) {
1398
- if (currentSort.sort === 'asc') {
1399
- newDirection = 'desc';
1400
- } else {
1401
- newDirection = null;
1402
- }
1403
- }
1404
- handleSort(field, newDirection);
1405
- }
1406
- return;
1407
- }
1408
-
1409
- if (event.key === 'Escape') {
1410
- if (isEditing) {
1411
- event.preventDefault();
1412
- editingHandlers.stopCellEdit({ cancel: true });
1413
- }
1414
- return;
1415
- }
1416
-
1417
- if (isEditing && event.key !== 'Tab') {
1418
- return;
1419
- }
1420
-
1421
- const isHeader = id === 'HEADER';
1422
- const rowIndex = isHeader ? -1 : allRenderableRows.findIndex(r => r.id === id);
1423
- const colIndex = navigationColumns.findIndex(c => c.field === field);
1424
-
1425
- if (!isHeader && rowIndex === -1) return;
1426
- if (colIndex === -1) return;
1427
-
1428
- // Find the next FOCUSABLE cell (any cell — editability not required for arrow navigation)
1429
- const findNextCell = (
1430
- startRow: number,
1431
- startCol: number,
1432
- deltaRow: number,
1433
- deltaCol: number,
1434
- wrapRow: boolean,
1435
- allowHeader: boolean = false
1436
- ) => {
1437
- let r = startRow + deltaRow;
1438
- let c = startCol + deltaCol;
1439
-
1440
- if (wrapRow && deltaCol !== 0) {
1441
- if (deltaCol > 0 && c >= navigationColumns.length) { c = 0; r++; }
1442
- else if (deltaCol < 0 && c < 0) { c = navigationColumns.length - 1; r--; }
1443
- }
1444
-
1445
- if (!wrapRow && (c < 0 || c >= navigationColumns.length)) return null;
1446
- if (r < (allowHeader ? -1 : 0) || r >= allRenderableRows.length) return null;
1447
-
1448
- return { r, c };
1449
- };
1450
-
1451
- // Find the next EDITABLE cell (for Tab key navigation)
1452
- const findNextEditable = (
1453
- startRow: number,
1454
- startCol: number,
1455
- deltaRow: number,
1456
- deltaCol: number,
1457
- wrapRow: boolean,
1458
- allowHeader: boolean = false
1459
- ) => {
1460
- let r = startRow;
1461
- let c = startCol;
1462
- let steps = 0;
1463
-
1464
- const maxSteps = allRenderableRows.length * navigationColumns.length + navigationColumns.length;
1465
-
1466
- while (steps < maxSteps) {
1467
- steps++;
1468
-
1469
- r += deltaRow;
1470
- c += deltaCol;
1471
-
1472
- if (wrapRow && deltaCol !== 0) {
1473
- if (deltaCol > 0) {
1474
- if (c >= navigationColumns.length) { c = 0; r++; }
1475
- } else {
1476
- if (c < 0) { c = navigationColumns.length - 1; r--; }
1477
- }
1478
- } else {
1479
- if (c < 0 || c >= navigationColumns.length) return null;
1480
- }
1481
-
1482
- if (r < -1 || r >= allRenderableRows.length) return null;
1483
-
1484
- if (r === -1) {
1485
- if (!allowHeader) continue;
1486
- const col = navigationColumns[c];
1487
- const isSortable = (col as any).sortable !== false;
1488
- const isInteractive = ['__checkbox_col__'].includes(col.field) || isSortable;
1489
- if (isInteractive) return { r, c };
1490
- continue;
1491
- }
1492
-
1493
- const row = allRenderableRows[r];
1494
- const col = navigationColumns[c];
1495
-
1496
- const isInteractable = ['__checkbox_col__', '__expand_col__', '__reorder_col__'].includes(col.field);
1497
- let isEditable = col.editable || isInteractable;
1498
-
1499
- if (isCellEditable && !isInteractable) {
1500
- try {
1501
- isEditable = isCellEditable({
1502
- row,
1503
- field: col.field,
1504
- value: (row as any)[col.field],
1505
- colDef: col,
1506
- rowIndex: r,
1507
- colIndex: c
1508
- });
1509
- } catch (e) {
1510
- console.error('Error in isCellEditable:', e);
1511
- }
1512
- }
1513
-
1514
- if (isEditable) return { r, c };
1515
- }
1516
- return null;
1517
- };
1518
-
1519
- let nextRowIndex = rowIndex;
1520
- let nextColIndex = colIndex;
1521
- let handled = false;
1522
-
1523
- if (event.key === 'Tab') {
1524
- // Tab moves to next EDITABLE cell
1525
- const dir = event.shiftKey ? -1 : 1;
1526
- const res = findNextEditable(rowIndex, colIndex, 0, dir, true, false);
1527
- if (res) {
1528
- nextRowIndex = res.r;
1529
- nextColIndex = res.c;
1530
- handled = true;
1531
- }
1532
- } else if (event.key === 'ArrowRight') {
1533
- const res = findNextCell(rowIndex, colIndex, 0, 1, true, true);
1534
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1535
- } else if (event.key === 'ArrowLeft') {
1536
- const res = findNextCell(rowIndex, colIndex, 0, -1, true, true);
1537
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1538
- } else if (event.key === 'ArrowDown') {
1539
- const res = findNextCell(rowIndex, colIndex, 1, 0, false, true);
1540
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1541
- } else if (event.key === 'ArrowUp') {
1542
- const res = findNextCell(rowIndex, colIndex, -1, 0, false, true);
1543
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
1544
- } else if (event.key === 'Home') {
1545
- if (event.ctrlKey || event.metaKey) {
1546
- nextRowIndex = -1;
1547
- nextColIndex = 0;
1548
- } else {
1549
- nextColIndex = 0;
1550
- }
1551
- handled = true;
1552
- } else if (event.key === 'End') {
1553
- if (event.ctrlKey || event.metaKey) {
1554
- nextRowIndex = allRenderableRows.length - 1;
1555
- nextColIndex = navigationColumns.length - 1;
1556
- } else {
1557
- nextColIndex = navigationColumns.length - 1;
1558
- }
1559
- handled = true;
1560
- } else if (event.key === 'PageUp') {
1561
- const pageSize = pagination ? effectivePaginationModel.pageSize : 10;
1562
- nextRowIndex = Math.max(-1, nextRowIndex - pageSize);
1563
- handled = true;
1564
- } else if (event.key === 'PageDown') {
1565
- const pageSize = pagination ? effectivePaginationModel.pageSize : 10;
1566
- nextRowIndex = Math.min(allRenderableRows.length - 1, nextRowIndex + pageSize);
1567
- handled = true;
1568
- }
1569
-
1570
- if (handled) {
1571
- event.preventDefault();
1572
-
1573
- if (isEditing && event.key === 'Tab') {
1574
- editingHandlers.stopCellEdit();
1575
- }
1576
-
1577
- if (nextRowIndex >= -1 && nextRowIndex < allRenderableRows.length &&
1578
- nextColIndex >= 0 && nextColIndex < navigationColumns.length) {
1579
-
1580
- const nextCol = navigationColumns[nextColIndex];
1581
-
1582
- if (nextRowIndex === -1) {
1583
- setFocusedCell({ id: 'HEADER', field: nextCol.field });
1584
- } else {
1585
- const nextRow = allRenderableRows[nextRowIndex];
1586
- setFocusedCell({ id: nextRow.id, field: nextCol.field });
1587
- }
1588
-
1589
- if (viewportRef.current) {
1590
- const { cumulativeHeights, pinnedTopHeight } = virtualization;
1591
- const { clientHeight, clientWidth, scrollTop, scrollLeft } = viewportRef.current;
1592
-
1593
- const rowTop = nextRowIndex === 0 ? 0 : cumulativeHeights[nextRowIndex - 1];
1594
- const rowBottom = cumulativeHeights[nextRowIndex];
1595
- const pinnedOffset = pinnedTopHeight;
1596
- const visibleTop = scrollTop + pinnedOffset;
1597
- const visibleBottom = scrollTop + clientHeight;
1598
-
1599
- let newScrollTop = scrollTop;
1600
- if (rowTop < visibleTop) {
1601
- newScrollTop = Math.max(0, rowTop - pinnedOffset);
1602
- } else if (rowBottom > visibleBottom) {
1603
- newScrollTop = rowBottom - clientHeight;
1604
- }
1605
-
1606
- if (newScrollTop !== scrollTop) {
1607
- viewportRef.current.scrollTop = newScrollTop;
1608
- }
1609
-
1610
- if (virtualization.columnMetrics) {
1611
- const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = virtualization.columnMetrics;
1612
- const field = nextCol.field;
1613
- const unpinnedIndex = unpinnedCols.findIndex((c: any) => c.field === field);
1614
-
1615
- if (unpinnedIndex !== -1) {
1616
- const colLeft = totalSpecialsWidth + leftPinnedWidth + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
1617
- const colRight = totalSpecialsWidth + leftPinnedWidth + unpinnedAccWidths[unpinnedIndex];
1618
-
1619
- const visibleStart = scrollLeft + pinnedSpecialsWidth + leftPinnedWidth;
1620
- const visibleEnd = scrollLeft + clientWidth - rightPinnedWidth;
1621
-
1622
- let newScrollLeft = scrollLeft;
1623
-
1624
- if (colLeft < visibleStart) {
1625
- newScrollLeft = Math.max(0, colLeft - pinnedSpecialsWidth - leftPinnedWidth);
1626
- } else if (colRight > visibleEnd) {
1627
- newScrollLeft = colRight - clientWidth + rightPinnedWidth;
1628
- }
1629
-
1630
- if (newScrollLeft !== scrollLeft) {
1631
- viewportRef.current.scrollLeft = newScrollLeft;
1632
- }
1633
- }
1634
- }
1635
- }
1636
- }
1637
- }
1638
- }, [focusedCell, allRenderableRows, navigationColumns, editingHandlers, virtualization, selectedRowIds, handleSelectionChange, handleDetailPanelToggle]);
1639
-
1640
660
  const hasRowSpanning = React.useMemo(() => effectiveColumns.some(c => !!c.rowSpan), [effectiveColumns]);
1641
661
  const [columnsPanelOpen, setColumnsPanelOpen] = React.useState(false);
1642
662
  const containerRef = React.useRef<HTMLDivElement>(null);
1643
663
 
664
+ const toolbarProps = React.useMemo(() => {
665
+ if (!slots?.toolbar) return null;
666
+ const reorderHandler = disableColumnReorder
667
+ ? undefined
668
+ : (fromField: string, toField: string) => {
669
+ const currentOrder = [...effectiveColumnOrder];
670
+ const fromIdx = currentOrder.indexOf(fromField);
671
+ const toIdx = currentOrder.indexOf(toField);
672
+ if (fromIdx === -1 || toIdx === -1) return;
673
+ const newOrder = [...currentOrder];
674
+ newOrder.splice(fromIdx, 1);
675
+ newOrder.splice(toIdx, 0, fromField);
676
+ if (!columnOrder) setInternalColumnOrder(newOrder);
677
+ const col = effectiveColumns.find(c => c.field === fromField);
678
+ if (col) onColumnOrderChange?.({ oldIndex: fromIdx, targetIndex: toIdx, column: col as unknown as GridColDef });
679
+ };
680
+ return {
681
+ apiRef: gridData.apiRef,
682
+ columns: orderedColumns as unknown as GridColDef[],
683
+ baseColumns: columns as unknown as GridColDef[],
684
+ aggregationModel,
685
+ onAggregationModelChange: handleAggregationModelChange,
686
+ ...(pivotMode || propPivotModel || onPivotModelChange ? {
687
+ pivotModel: currentPivotModel,
688
+ onPivotModelChange: handlePivotModelChange,
689
+ } : {}),
690
+ filterModel,
691
+ onFilterModelChange,
692
+ columnVisibilityModel,
693
+ onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
694
+ onColumnReorder: reorderHandler,
695
+ onColumnOrderReset: disableColumnReorder ? undefined : () => setInternalColumnOrder(columns.map(c => c.field)),
696
+ forceColumnsOpen: columnsPanelOpen,
697
+ onColumnsPanelClose: () => setColumnsPanelOpen(false),
698
+ ...slotProps?.toolbar,
699
+ };
700
+ }, [
701
+ slots?.toolbar, disableColumnReorder, effectiveColumnOrder, orderedColumns, effectiveColumns,
702
+ columnOrder, onColumnOrderChange, setInternalColumnOrder, gridData.apiRef,
703
+ columns, aggregationModel, handleAggregationModelChange, pivotMode,
704
+ propPivotModel, onPivotModelChange, currentPivotModel, handlePivotModelChange,
705
+ filterModel, onFilterModelChange, columnVisibilityModel,
706
+ handleColumnVisibilityModelChange, columnsPanelOpen, slotProps?.toolbar,
707
+ ]);
708
+
1644
709
  // Click-outside handler for standalone column panel
1645
710
  useEffect(() => {
1646
711
  if (!columnsPanelOpen || slots?.toolbar) return;
@@ -1663,188 +728,49 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1663
728
  height: height ?? style?.height,
1664
729
  '--ogx-row-height': `${rowHeight}px`,
1665
730
  '--ogx-header-height': `${headerHeight}px`
1666
- } as any}
731
+ } as unknown as React.CSSProperties}
1667
732
  aria-busy={effectiveLoading}
1668
733
  >
1669
- { }
1670
- {slots?.toolbar && (() => {
1671
- const toolbarProps = {
1672
- apiRef: gridData.apiRef,
1673
- columns: effectiveColumns as unknown as GridColDef[],
1674
- baseColumns: columns as unknown as GridColDef[],
1675
- aggregationModel,
1676
- onAggregationModelChange: handleAggregationModelChange,
1677
-
1678
- ...(pivotMode || propPivotModel || onPivotModelChange ? {
1679
- pivotModel: currentPivotModel,
1680
- onPivotModelChange: handlePivotModelChange,
1681
- } : {}),
1682
-
1683
- filterModel,
1684
- onFilterModelChange,
1685
- columnVisibilityModel,
1686
- onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
1687
-
1688
- onColumnReorder: disableColumnReorder ? undefined : (fromField: string, toField: string) => {
1689
- const currentOrder = [...effectiveColumnOrder];
1690
- const fromIdx = currentOrder.indexOf(fromField);
1691
- const toIdx = currentOrder.indexOf(toField);
1692
- if (fromIdx === -1 || toIdx === -1) return;
1693
- const newOrder = [...currentOrder];
1694
- newOrder.splice(fromIdx, 1);
1695
- newOrder.splice(toIdx, 0, fromField);
1696
- if (!columnOrder) setInternalColumnOrder(newOrder);
1697
- const col = effectiveColumns.find(c => c.field === fromField); if (col) onColumnOrderChange?.({ oldIndex: fromIdx, targetIndex: toIdx, column: col as any });
1698
- },
1699
-
1700
- forceColumnsOpen: columnsPanelOpen,
1701
- onColumnsPanelClose: () => setColumnsPanelOpen(false),
1702
-
1703
- ...slotProps?.toolbar,
1704
- };
1705
- return <StableToolbar {...toolbarProps} />;
1706
- })()}
1707
-
1708
- {/* Fallback standalone column panel when no toolbar is present */}
1709
- {!slots?.toolbar && columnsPanelOpen && ReactDOM.createPortal(
1710
- <div
1711
- id="ogx-standalone-col-panel"
1712
- style={(() => {
1713
- const rect = containerRef.current?.getBoundingClientRect();
1714
- return {
1715
- position: 'fixed',
1716
- top: rect ? rect.top + 8 : 16,
1717
- right: rect ? window.innerWidth - rect.right + 8 : 16,
1718
- zIndex: 9999,
1719
- display: 'inline-block',
1720
- };
1721
- })()}
1722
- >
1723
- <button
1724
- onClick={() => setColumnsPanelOpen(false)}
1725
- style={{
1726
- position: 'absolute',
1727
- top: -12,
1728
- right: -12,
1729
- width: 24,
1730
- height: 24,
1731
- display: 'flex',
1732
- alignItems: 'center',
1733
- justifyContent: 'center',
1734
- background: '#334155',
1735
- color: '#fff',
1736
- border: 'none',
1737
- borderRadius: '50%',
1738
- cursor: 'pointer',
1739
- fontSize: 14,
1740
- lineHeight: 1,
1741
- zIndex: 1,
1742
- boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
1743
- }}
1744
- aria-label="Close"
1745
- >×</button>
1746
- <ColumnVisibilityPanel
1747
- columns={effectiveColumns}
1748
- visibleColumns={new Set(
1749
- effectiveColumns
1750
- .filter(col => columnVisibilityModel[col.field] !== false)
1751
- .map(col => col.field)
1752
- )}
1753
- onVisibilityChange={(field, isVisible) => {
1754
- handleColumnVisibilityModelChange({ ...columnVisibilityModel, [field]: isVisible });
1755
- }}
1756
- onShowAll={() => {
1757
- const next = { ...columnVisibilityModel };
1758
- effectiveColumns.forEach(col => { if (col.hideable !== false) next[col.field] = true; });
1759
- handleColumnVisibilityModelChange(next);
1760
- }}
1761
- onHideAll={() => {
1762
- const next = { ...columnVisibilityModel };
1763
- effectiveColumns.forEach(col => { if (col.hideable !== false) next[col.field] = false; });
1764
- handleColumnVisibilityModelChange(next);
1765
- }}
1766
- onColumnReorder={disableColumnReorder ? undefined : (fromField, toField) => {
1767
- const currentOrder = [...effectiveColumnOrder];
1768
- const fromIdx = currentOrder.indexOf(fromField);
1769
- const toIdx = currentOrder.indexOf(toField);
1770
- if (fromIdx === -1 || toIdx === -1) return;
1771
- const newOrder = [...currentOrder];
1772
- newOrder.splice(fromIdx, 1);
1773
- newOrder.splice(toIdx, 0, fromField);
1774
- if (!columnOrder) {
1775
- setInternalColumnOrder(newOrder);
1776
- }
1777
- onColumnOrderChange?.({ oldIndex: fromIdx, targetIndex: toIdx, column: effectiveColumns.find(c => c.field === fromField) as any });
1778
- }}
1779
- />
1780
- </div>,
1781
- containerRef.current?.closest('.ogx-theme-provider') || document.body
734
+ {toolbarProps && <StableToolbar {...toolbarProps} />}
735
+
736
+ {!slots?.toolbar && (
737
+ <GridStandaloneColumnPanel<R>
738
+ isOpen={columnsPanelOpen}
739
+ containerRef={containerRef}
740
+ effectiveColumns={effectiveColumns}
741
+ columnVisibilityModel={columnVisibilityModel}
742
+ effectiveColumnOrder={effectiveColumnOrder}
743
+ columnOrder={columnOrder}
744
+ disableColumnReorder={disableColumnReorder}
745
+ onClose={() => setColumnsPanelOpen(false)}
746
+ onColumnVisibilityChange={handleColumnVisibilityModelChange}
747
+ onColumnOrderChange={onColumnOrderChange}
748
+ setInternalColumnOrder={setInternalColumnOrder}
749
+ />
1782
750
  )}
1783
751
 
1784
- {/* ═══════════════════════════════════════════════════════════════
1785
- LIST VIEW — single-column card layout
1786
- ═══════════════════════════════════════════════════════════════ */}
1787
752
  {listView && listViewColumn && (
1788
- <div
1789
- className="ogx-list-view"
1790
- role="grid"
1791
- aria-label={ariaLabel || 'Data grid list view'}
1792
- aria-rowcount={allRenderableRows.length + 1}
1793
- >
1794
- {/* Info bar */}
1795
- <div className="ogx-list-view__toolbar">
1796
- <span>
1797
- {filteredRows.length} {filteredRows.length === 1 ? 'item' : 'items'}
1798
- {pagination ? ` · page ${effectivePaginationModel.page + 1} of ${Math.ceil(filteredRows.length / effectivePaginationModel.pageSize) || 1}` : ''}
1799
- {selectedRowIds.size > 0 ? ` · ${selectedRowIds.size} selected` : ''}
1800
- </span>
1801
- </div>
1802
-
1803
- {/* Scrollable rows — pagination stays OUTSIDE this div */}
1804
- <div className="ogx-list-view__rows">
1805
- {allRenderableRows.length === 0 ? (
1806
- <div className="ogx-list-view__empty" aria-live="polite" role="status">
1807
- <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
1808
- <rect x="3" y="3" width="18" height="18" rx="2" />
1809
- <path d="M3 9h18M9 21V9" />
1810
- </svg>
1811
- {noRowsLabel}
1812
- </div>
1813
- ) : (
1814
- allRenderableRows.map((row, idx) => (
1815
- <ListViewRow<R>
1816
- key={row.id}
1817
- row={row as R}
1818
- rowIndex={idx}
1819
- listViewColumn={listViewColumn as any}
1820
- isSelected={selectedRowIds.has(row.id)}
1821
- checkboxSelection={checkboxSelection}
1822
- rowHeight={rowHeight}
1823
- onRowClick={(r) => handleRowClick({ row: r, id: r.id, rowIndex: idx })}
1824
- onSelectionChange={handleSelectionChange}
1825
- />
1826
- ))
1827
- )}
1828
- </div>
1829
-
1830
- {/* Pagination in list view — use filteredRows.length as total since state.pagination.rowCount is only set for server-side */}
1831
- {pagination && (() => {
1832
- const PaginationComponent = slots?.pagination || Pagination;
1833
- const totalRowCount = (paginationMode === 'server' && dataSource)
1834
- ? (state.pagination.rowCount || 0)
1835
- : filteredRows.length;
1836
- const paginationProps = {
1837
- page: effectivePaginationModel.page,
1838
- pageSize: effectivePaginationModel.pageSize,
1839
- rowCount: totalRowCount,
1840
- pageSizeOptions: pageSizeOptions,
1841
- onPageChange: (newPage: number) => handlePaginationModelChange({ ...effectivePaginationModel, page: newPage }),
1842
- onPageSizeChange: (newPageSize: number) => handlePaginationModelChange({ ...effectivePaginationModel, pageSize: newPageSize, page: 0 }),
1843
- ...slotProps?.pagination
1844
- };
1845
- return <PaginationComponent {...paginationProps} />;
1846
- })()}
1847
- </div>
753
+ <GridListView<R>
754
+ ariaLabel={ariaLabel}
755
+ allRenderableRows={allRenderableRows}
756
+ filteredRows={filteredRows}
757
+ pagination={pagination}
758
+ effectivePaginationModel={effectivePaginationModel}
759
+ pageSizeOptions={pageSizeOptions}
760
+ selectedRowIds={selectedRowIds}
761
+ listViewColumn={listViewColumn}
762
+ noRowsLabel={noRowsLabel}
763
+ rowHeight={rowHeight}
764
+ checkboxSelection={checkboxSelection}
765
+ paginationMode={paginationMode}
766
+ dataSource={dataSource}
767
+ serverRowCount={state.pagination.rowCount || 0}
768
+ paginationSlot={slots?.pagination}
769
+ paginationSlotProps={slotProps?.pagination}
770
+ onRowClick={handleRowClick}
771
+ onSelectionChange={handleSelectionChange}
772
+ onPaginationModelChange={handlePaginationModelChange}
773
+ />
1848
774
  )}
1849
775
 
1850
776
  {/* ══════════════════════════════════════════════════════════════
@@ -1886,7 +812,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1886
812
  { }
1887
813
  <Header
1888
814
  columns={virtualization.virtualColumns}
1889
- allColumns={effectiveColumns as any}
815
+ allColumns={effectiveColumns}
1890
816
  columnGroupingModel={columnGroupingModel}
1891
817
  checkboxSelection={checkboxSelection}
1892
818
  allSelected={allSelected}
@@ -1941,299 +867,135 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1941
867
 
1942
868
  {/* Empty State Overlay (Standard View) — showing after header */}
1943
869
  {!effectiveLoading && !state.dataSource.error && filteredRows.length === 0 && (
1944
- <div className="ogx__empty" style={{ width: virtualization.totalWidth }}>
1945
- <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
1946
- <rect x="3" y="3" width="18" height="18" rx="2" />
1947
- <path d="M3 9h18M9 21V9" />
1948
- </svg>
1949
- <span>{noRowsLabel}</span>
1950
- </div>
870
+ <GridEmptyState noRowsLabel={noRowsLabel} width={virtualization.totalWidth} />
1951
871
  )}
1952
872
 
1953
- { }
1954
- {pinnedTopRows.length > 0 && (
1955
- <div className="ogx__pinned-rows ogx__pinned-rows--top" role="rowgroup">
1956
- {pinnedTopRows.map((row, index) => (
1957
- <Row
1958
- key={row.id}
1959
- row={row}
1960
- columns={virtualization.virtualColumns}
1961
- rowIndex={index}
1962
- isSelected={selectedRowIds.has(row.id)}
1963
- checkboxSelection={checkboxSelection}
1964
- onRowClick={handleRowClick}
1965
- onCellClick={handleCellClick}
1966
- onSelectionChange={handleSelectionChange}
1967
- columnWidths={columnWidths}
1968
- pinnedColumns={pinnedColumns}
1969
- pinnedRows={pinnedRows}
1970
-
1971
- hasDetailPanel={hasDetailPanel}
1972
- isDetailPanelExpanded={expandedRowIds.has(row.id)}
1973
- detailPanelContent={getDetailPanelContent ? getDetailPanelContent({ row, id: row.id, rowIndex: index }) : null}
1974
- detailPanelHeight={getDetailPanelHeight?.({ row, id: row.id, rowIndex: index }) || 200}
1975
- onDetailPanelToggle={handleDetailPanelToggle}
1976
- pinCheckboxColumn={pinCheckboxColumn}
1977
- pinExpandColumn={pinExpandColumn}
1978
-
1979
- focusedCellField={focusedCell?.id === row.id ? focusedCell.field : null}
1980
-
1981
- colspanMap={spanning.colspanMap}
1982
- rowSpanningCaches={spanning.rowSpanningState.caches}
1983
- rowHeight={rowHeight}
1984
- />
1985
- ))}
1986
- </div>
1987
- )}
873
+ <GridPinnedRows<R>
874
+ rows={pinnedTopRows}
875
+ position="top"
876
+ columns={virtualization.virtualColumns}
877
+ selectedRowIds={selectedRowIds}
878
+ checkboxSelection={checkboxSelection}
879
+ onRowClick={handleRowClick}
880
+ onCellClick={handleCellClick}
881
+ onSelectionChange={handleSelectionChange}
882
+ columnWidths={columnWidths}
883
+ pinnedColumns={pinnedColumns}
884
+ pinnedRows={pinnedRows}
885
+ hasDetailPanel={hasDetailPanel}
886
+ expandedRowIds={expandedRowIds}
887
+ getDetailPanelContent={getDetailPanelContent}
888
+ getDetailPanelHeight={getDetailPanelHeight}
889
+ onDetailPanelToggle={handleDetailPanelToggle}
890
+ pinCheckboxColumn={pinCheckboxColumn}
891
+ pinExpandColumn={pinExpandColumn}
892
+ focusedCell={focusedCell}
893
+ colspanMap={spanning.colspanMap}
894
+ rowSpanningCaches={spanning.rowSpanningState.caches}
895
+ rowHeight={rowHeight}
896
+ />
1988
897
 
1989
- { }
1990
- <div
1991
- className="ogx__virtual-container"
1992
- style={{
1993
- height: `${virtualization.totalHeight - virtualization.pinnedTopHeight - virtualization.pinnedBottomHeight}px`
1994
- }}
1995
- role="presentation"
1996
- >
1997
- { }
1998
- <div
1999
- className="ogx__rows"
2000
- style={{
2001
- transform: `translateY(${virtualization.renderContext.firstRowIndex * rowHeight}px)`
2002
- }}
2003
- role="rowgroup"
2004
- >
2005
- {effectiveLoading && visibleRows.length === 0 ? (
2006
-
2007
- (() => {
2008
-
2009
- const skeletonColumns = columns.length > 0
2010
- ? columns
2011
- : (() => {
2012
-
2013
- const availableWidth = (state.dimensions.viewportWidth || 1000) -
2014
- (checkboxSelection ? 48 : 0) -
2015
- (getDetailPanelContent ? 48 : 0) -
2016
- (rowReordering ? 48 : 0);
2017
-
2018
- const columnWidth = 150;
2019
- const columnCount = Math.max(1, Math.ceil(availableWidth / columnWidth));
2020
-
2021
- return Array.from({ length: columnCount }, (_, i) => ({
2022
- field: `placeholder_${i}`,
2023
- headerName: '',
2024
- width: columnWidth
2025
- }));
2026
- })();
2027
-
2028
- return Array.from({ length: 10 }).map((_, index) => (
2029
- <SkeletonRow
2030
- key={`skeleton-${index}`}
2031
- columns={skeletonColumns}
2032
- rowHeight={rowHeight}
2033
- checkboxSelection={checkboxSelection}
2034
- hasDetailPanel={!!getDetailPanelContent}
2035
- rowReordering={rowReordering}
2036
- />
2037
- ));
2038
- })()
2039
- ) : (
2040
- visibleRows
2041
- .filter(({ row }) => !isRowPinned(row.id, pinnedRows))
2042
- // Defensive deduplication to prevent React key collision
2043
- .filter((item, index, self) =>
2044
- index === self.findIndex(t => t.row.id === item.row.id)
2045
- )
2046
- .map(({ row, rowIndex: actualIndex }) => {
2047
- return (
2048
- <Row
2049
- key={`${row.id}-${virtualization.totalWidth}`}
2050
- row={row}
2051
- columns={virtualization.virtualColumns}
2052
- rowIndex={actualIndex}
2053
- isSelected={selectedRowIds.has(row.id)}
2054
- checkboxSelection={checkboxSelection}
2055
- onRowClick={handleRowClick}
2056
- onCellClick={handleCellClick}
2057
- onSelectionChange={handleSelectionChange}
2058
- columnWidths={columnWidths}
2059
- pinnedColumns={pinnedColumns}
2060
- pinnedRows={pinnedRows}
2061
- // Detail Panel
2062
- hasDetailPanel={hasDetailPanel}
2063
- isDetailPanelExpanded={expandedRowIds.has(row.id)}
2064
- detailPanelContent={getDetailPanelContent ? getDetailPanelContent({ row, id: row.id, rowIndex: actualIndex }) : null}
2065
- detailPanelHeight={getDetailPanelHeight?.({ row, id: row.id, rowIndex: actualIndex }) || 200}
2066
- onDetailPanelToggle={handleDetailPanelToggle}
2067
- pinCheckboxColumn={pinCheckboxColumn}
2068
- pinExpandColumn={pinExpandColumn}
2069
- // Row Reordering
2070
- rowReordering={rowReordering}
2071
- onDragStart={rowReorderHandlers.onDragStart}
2072
- onDragOver={rowReorderHandlers.onDragOver}
2073
- onDragEnd={rowReorderHandlers.onDragEnd}
2074
- onDrop={rowReorderHandlers.onDrop}
2075
- isDragging={rowReorderHandlers.draggedRowId === row.id}
2076
- isDragOver={rowReorderHandlers.dragOverRowId === row.id}
2077
- // Editing
2078
- editingCell={editingHandlers.editingCell}
2079
- onEditStart={editingHandlers.startCellEdit}
2080
- onEditStop={editingHandlers.stopCellEdit}
2081
- onEditCellValueChange={editingHandlers.setEditCellValue}
2082
- // Focus
2083
- focusedCellField={focusedCell != null && focusedCell.id === row.id ? focusedCell.field : null}
2084
- // Spanning
2085
- colspanMap={spanning.colspanMap}
2086
- rowSpanningCaches={spanning.rowSpanningState.caches}
2087
- rowHeight={rowHeight}
2088
- />
2089
- );
2090
- })
2091
- )}
2092
-
2093
- {/* Infinite Scroll Skeletons (Inline with flow, inside translateY container) */}
2094
- {paginationMode === 'infinite' && state.dataSource.loading && sortedUnpinnedRows.length > 0 && (() => {
2095
- const skeletonCount = Math.min(effectivePaginationModel.pageSize, 20);
2096
- // The virtual container translates its children down by `firstRowIndex * rowHeight`.
2097
- // Since these skeletons are at the VERY BOTTOM of all rows, their actual array index
2098
- // is precisely after the last real row.
2099
- const startIdx = layout.unpinnedRowsLength;
2100
-
2101
- // However, they are rendered INSIDE the translateY block. The real rows handled this nicely
2102
- // because they just layout one after another. Skeletons can do exactly the same, EXCEPT we might
2103
- // need to offset them so they sit at the bottom.
2104
- return (
2105
- <div className="ogx__skeleton-group">
2106
- {Array.from({ length: skeletonCount }).map((_, i) => (
2107
- <Row
2108
- key={`__skeleton_${i}__`}
2109
- row={{ id: `__skeleton_${i}__`, _isSkeleton: true } as any}
2110
- columns={virtualization.virtualColumns}
2111
- rowIndex={startIdx + i}
2112
- rowHeight={rowHeight}
2113
- />
2114
- ))}
2115
- </div>
2116
- );
2117
- })()}
2118
- </div>
2119
-
2120
- </div>
2121
-
2122
- {/* Pinned Bottom Rows */}
2123
- {pinnedBottomRows.length > 0 && (
2124
- <div className="ogx__pinned-rows ogx__pinned-rows--bottom" role="rowgroup">
2125
- {pinnedBottomRows.map((row, index) => (
2126
- <Row
2127
- key={row.id}
2128
- row={row}
2129
- columns={virtualization.virtualColumns}
2130
- rowIndex={index}
2131
- isSelected={selectedRowIds.has(row.id)}
2132
- checkboxSelection={checkboxSelection}
2133
- onRowClick={handleRowClick}
2134
- onCellClick={handleCellClick}
2135
- onSelectionChange={handleSelectionChange}
2136
- columnWidths={columnWidths}
2137
- pinnedColumns={pinnedColumns}
2138
- pinnedRows={pinnedRows}
2139
- // Detail Panel
2140
- hasDetailPanel={hasDetailPanel}
2141
- isDetailPanelExpanded={expandedRowIds.has(row.id)}
2142
- detailPanelContent={getDetailPanelContent ? getDetailPanelContent({ row, id: row.id, rowIndex: index }) : null}
2143
- detailPanelHeight={getDetailPanelHeight?.({ row, id: row.id, rowIndex: index }) || 200}
2144
- onDetailPanelToggle={handleDetailPanelToggle}
2145
- pinCheckboxColumn={pinCheckboxColumn}
2146
- pinExpandColumn={pinExpandColumn}
2147
- // Focus
2148
- focusedCellField={focusedCell?.id === row.id ? focusedCell.field : null}
2149
- // Spanning
2150
- colspanMap={spanning.colspanMap}
2151
- rowSpanningCaches={spanning.rowSpanningState.caches}
2152
- rowHeight={rowHeight}
2153
- />
2154
- ))}
2155
- </div>
2156
- )}
898
+ <GridVirtualRows<R>
899
+ virtualContainerHeight={virtualization.totalHeight - virtualization.pinnedTopHeight - virtualization.pinnedBottomHeight}
900
+ offsetTop={virtualization.offsetTop}
901
+ effectiveLoading={effectiveLoading}
902
+ visibleRows={visibleRows}
903
+ baseColumns={columns as unknown as GridColDef<R>[]}
904
+ virtualColumns={virtualization.virtualColumns}
905
+ viewportWidth={state.dimensions.viewportWidth}
906
+ checkboxSelection={checkboxSelection}
907
+ hasDetailPanel={hasDetailPanel}
908
+ rowReordering={rowReordering}
909
+ rowHeight={rowHeight}
910
+ pinnedRows={pinnedRows}
911
+ selectedRowIds={selectedRowIds}
912
+ onRowClick={handleRowClick}
913
+ onCellClick={handleCellClick}
914
+ onSelectionChange={handleSelectionChange}
915
+ columnWidths={columnWidths}
916
+ pinnedColumns={pinnedColumns}
917
+ expandedRowIds={expandedRowIds}
918
+ getDetailPanelContent={getDetailPanelContent}
919
+ getDetailPanelHeight={getDetailPanelHeight}
920
+ onDetailPanelToggle={handleDetailPanelToggle}
921
+ pinCheckboxColumn={pinCheckboxColumn}
922
+ pinExpandColumn={pinExpandColumn}
923
+ rowReorderHandlers={rowReorderHandlers}
924
+ editingHandlers={editingHandlers}
925
+ focusedCell={focusedCell}
926
+ colspanMap={spanning.colspanMap}
927
+ rowSpanningCaches={spanning.rowSpanningState.caches}
928
+ paginationMode={paginationMode}
929
+ dataSourceLoading={state.dataSource.loading}
930
+ sortedUnpinnedRowCount={sortedUnpinnedRows.length}
931
+ infiniteScrollSkeletonCount={Math.min(effectivePaginationModel.pageSize, 20)}
932
+ unpinnedRowsLength={layout.unpinnedRowsLength}
933
+ />
934
+
935
+ <GridPinnedRows<R>
936
+ rows={pinnedBottomRows}
937
+ position="bottom"
938
+ columns={virtualization.virtualColumns}
939
+ selectedRowIds={selectedRowIds}
940
+ checkboxSelection={checkboxSelection}
941
+ onRowClick={handleRowClick}
942
+ onCellClick={handleCellClick}
943
+ onSelectionChange={handleSelectionChange}
944
+ columnWidths={columnWidths}
945
+ pinnedColumns={pinnedColumns}
946
+ pinnedRows={pinnedRows}
947
+ hasDetailPanel={hasDetailPanel}
948
+ expandedRowIds={expandedRowIds}
949
+ getDetailPanelContent={getDetailPanelContent}
950
+ getDetailPanelHeight={getDetailPanelHeight}
951
+ onDetailPanelToggle={handleDetailPanelToggle}
952
+ pinCheckboxColumn={pinCheckboxColumn}
953
+ pinExpandColumn={pinExpandColumn}
954
+ focusedCell={focusedCell}
955
+ colspanMap={spanning.colspanMap}
956
+ rowSpanningCaches={spanning.rowSpanningState.caches}
957
+ rowHeight={rowHeight}
958
+ />
2157
959
 
2158
960
  {/* Aggregation Footer Row — suppressed in pivot mode because pivot rows
2159
961
  already contain pre-aggregated values with synthetic field keys
2160
962
  (e.g. 'Q1\u001frevenue\u001fsum') that don't match aggregationModel keys. */}
2161
963
  {hasAggregation && !pivotMode && (
2162
- <div
2163
- className="ogx__aggregation-footer"
2164
- role="row"
2165
- aria-label="Aggregation totals"
2166
- aria-live="polite"
2167
- style={{
2168
- minHeight: `${rowHeight}px`
2169
- }}
2170
- >
2171
- {checkboxSelection && (
2172
- <div style={{ width: 48, flexShrink: 0 }} />
2173
- )}
2174
- {hasDetailPanel && (
2175
- <div style={{ width: 48, flexShrink: 0 }} />
2176
- )}
2177
- {rowReordering && (
2178
- <div style={{ width: 48, flexShrink: 0 }} />
2179
- )}
2180
- {orderedColumns.map((col) => {
2181
- const fnName = (aggregationModel as Record<string, string>)[col.field];
2182
- const rawValue = aggregationResult[col.field];
2183
- const colWidth = columnWidths[col.field] ?? (typeof col.width === 'number' ? col.width : 120);
2184
-
2185
- return (
2186
- <div
2187
- key={col.field}
2188
- className="ogx__aggregation-cell"
2189
- role="gridcell"
2190
- style={{
2191
- width: colWidth,
2192
- minWidth: colWidth,
2193
- maxWidth: colWidth,
2194
- textAlign: (col.align as React.CSSProperties['textAlign']) || 'left',
2195
- }}
2196
- >
2197
- {fnName ? (
2198
- <>
2199
- <span className="ogx__aggregation-label">
2200
- {fnName}
2201
- </span>
2202
- <span className="ogx__aggregation-value">
2203
- {formatAggregationValue(rawValue, fnName)}
2204
- </span>
2205
- </>
2206
- ) : null}
2207
- </div>
2208
- );
2209
- })}
2210
- </div>
964
+ <GridAggregationFooter
965
+ columns={orderedColumns as unknown as GridColDef[]}
966
+ aggregationModel={aggregationModel}
967
+ aggregationResult={aggregationResult}
968
+ columnWidths={columnWidths}
969
+ rowHeight={rowHeight}
970
+ checkboxSelection={checkboxSelection}
971
+ hasDetailPanel={hasDetailPanel}
972
+ rowReordering={rowReordering}
973
+ />
2211
974
  )}
2212
975
 
2213
976
  </div>
2214
977
  </div>
2215
978
  )}
2216
979
 
2217
- {/* Pagination — shared between grid and list view when not inside list view block */}
2218
980
  {!listView && pagination && (() => {
2219
981
  const PaginationComponent = slots?.pagination || Pagination;
2220
- const paginationProps = {
2221
- page: effectivePaginationModel.page,
2222
- pageSize: effectivePaginationModel.pageSize,
2223
- rowCount: state.pagination.rowCount,
2224
- pageSizeOptions: pageSizeOptions,
2225
- onPageChange: (newPage: number) => handlePaginationModelChange({ ...effectivePaginationModel, page: newPage }),
2226
- onPageSizeChange: (newPageSize: number) => handlePaginationModelChange({ ...effectivePaginationModel, pageSize: newPageSize, page: 0 }),
2227
- ...slotProps?.pagination
2228
- };
2229
-
2230
- return <PaginationComponent {...paginationProps} />;
982
+ return (
983
+ <PaginationComponent
984
+ page={effectivePaginationModel.page}
985
+ pageSize={effectivePaginationModel.pageSize}
986
+ rowCount={state.pagination.rowCount}
987
+ pageSizeOptions={pageSizeOptions}
988
+ onPageChange={(newPage: number) => handlePaginationModelChange({ ...effectivePaginationModel, page: newPage })}
989
+ onPageSizeChange={(newPageSize: number) => handlePaginationModelChange({ ...effectivePaginationModel, pageSize: newPageSize, page: 0 })}
990
+ {...slotProps?.pagination}
991
+ />
992
+ );
2231
993
  })()}
2232
994
 
2233
995
  {/* Accessibility Live Region */}
2234
996
  <div className="ogx-aria-live-status" role="status" aria-live="polite">
2235
997
  {effectiveLoading ? 'Loading data...' : ''}
2236
- {state.dataSource.error ? `Error: ${state.dataSource.error.message || 'Unknown error'}` : ''}
998
+ {state.dataSource.error ? `Error: ${state.dataSource.error instanceof Error ? state.dataSource.error.message : 'Unknown error'}` : ''}
2237
999
  {!loading && !state.dataSource.error && (
2238
1000
  filteredRows.length === 0
2239
1001
  ? noRowsLabel
@@ -2243,27 +1005,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
2243
1005
  )}
2244
1006
  </div>
2245
1007
 
2246
- {state.dataSource.error && (
2247
- <div className="ogx-error-overlay" aria-live="assertive" role="alert">
2248
- <svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--ogx-color-error)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
2249
- <circle cx="12" cy="12" r="10" />
2250
- <line x1="12" y1="8" x2="12" y2="12" />
2251
- <line x1="12" y1="16" x2="12.01" y2="16" />
2252
- </svg>
2253
- <div className="ogx-error-overlay__title">
2254
- Oops! Something went wrong
2255
- </div>
2256
- <div className="ogx-error-overlay__message">
2257
- {state.dataSource.error.message || 'An unexpected error occurred while loading the data.'}
2258
- </div>
2259
- <button
2260
- onClick={() => window.location.reload()}
2261
- className="ogx-button ogx-button--primary"
2262
- >
2263
- Retry
2264
- </button>
2265
- </div>
2266
- )}
1008
+ <GridErrorOverlay error={state.dataSource.error} />
2267
1009
  </div>
2268
1010
  );
2269
1011
  }