@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,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import type {
3
3
  GridFilterModel,
4
4
  GridFilterItem,
@@ -30,34 +30,42 @@ const FilterRow: React.FC<{
30
30
  const currentOperator = item?.operator ?? operators[0];
31
31
 
32
32
  // Local input state to prevent full filter-panel re-render on every keystroke
33
- const [localValue, setLocalValue] = useState(item?.value ?? '');
33
+ const [localValue, setLocalValue] = useState<string>(item?.value != null ? String(item.value) : '');
34
34
  const onChangeRef = useRef(onChange);
35
- onChangeRef.current = onChange;
35
+ const itemRef = useRef(item);
36
+ const colFieldRef = useRef(col.field);
37
+ const operatorRef = useRef(currentOperator);
38
+ useLayoutEffect(() => {
39
+ onChangeRef.current = onChange;
40
+ itemRef.current = item;
41
+ colFieldRef.current = col.field;
42
+ operatorRef.current = currentOperator;
43
+ });
36
44
 
37
45
  // Sync when the external item changes (e.g. operator change, clear)
38
46
  const prevItemValue = useRef(item?.value);
39
47
  useEffect(() => {
40
48
  if (item?.value !== prevItemValue.current) {
41
49
  prevItemValue.current = item?.value;
42
- setLocalValue(item?.value ?? '');
50
+ setLocalValue(item?.value != null ? String(item.value) : '');
43
51
  }
44
52
  }, [item?.value]);
45
53
 
46
54
  // Debounce propagating the value upward (300ms)
47
55
  useEffect(() => {
48
56
  const handler = setTimeout(() => {
49
- const currentValue = item?.value ?? '';
57
+ const currentItem = itemRef.current;
58
+ const currentValue = currentItem?.value ?? '';
50
59
  if (localValue !== currentValue) {
51
60
  onChangeRef.current({
52
- id: item?.id ?? `${col.field}-${Date.now()}`,
53
- field: col.field,
54
- operator: currentOperator as GridFilterOperator,
61
+ id: currentItem?.id ?? `${colFieldRef.current}-${Date.now()}`,
62
+ field: colFieldRef.current,
63
+ operator: operatorRef.current as GridFilterOperator,
55
64
  value: localValue,
56
65
  });
57
66
  }
58
67
  }, 300);
59
68
  return () => clearTimeout(handler);
60
- // eslint-disable-next-line react-hooks/exhaustive-deps
61
69
  }, [localValue]);
62
70
 
63
71
  const handleOperatorChange = (op: string) => {
@@ -158,7 +166,7 @@ const FilterRow: React.FC<{
158
166
  // ── Root panel ────────────────────────────────────────────────────────────────
159
167
 
160
168
  export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns, onFilterModelChange }) => {
161
- const items: GridFilterItem[] = (filterModel.items ?? []) as GridFilterItem[];
169
+ const items = useMemo<GridFilterItem[]>(() => (filterModel.items ?? []) as GridFilterItem[], [filterModel.items]);
162
170
  const logicOperator = filterModel.logicOperator ?? 'and';
163
171
 
164
172
  const handleItemChange = useCallback((field: string, newItem: GridFilterItem | null) => {
@@ -1,10 +1,10 @@
1
1
  import React, { useRef, useEffect, useState } from 'react';
2
2
  import ReactDOM from 'react-dom';
3
- import type { GridColDef, GridSortDirection, GridColumnPinning } from '../../types';
3
+ import type { GridColDef, GridRowModel, GridSortDirection, GridColumnPinning } from '../../types';
4
4
 
5
5
 
6
6
  interface ColumnMenuProps {
7
- colDef: GridColDef<any>;
7
+ colDef: GridColDef<GridRowModel>;
8
8
  sortModel?: Array<{ field: string; sort: 'asc' | 'desc' }>;
9
9
  onSort?: (field: string, direction: GridSortDirection) => void;
10
10
  onHide?: (field: string) => void;
@@ -236,7 +236,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
236
236
  // All non-spacer fields in order (for group-span math) with their effective physical width
237
237
  const allCols = allColumns ?? columns;
238
238
  const allLeafFields = allCols
239
- .filter(c => !(c as any).isSpacer)
239
+ .filter(c => !c.isSpacer)
240
240
  .map(c => ({
241
241
  field: c.field,
242
242
  width: columnWidths[c.field] ?? c.width ?? 100
@@ -354,7 +354,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
354
354
  )}
355
355
 
356
356
  {columns.map((colDef, index) => {
357
- if ((colDef as any).isSpacer) {
357
+ if (colDef.isSpacer) {
358
358
  return (
359
359
  <div
360
360
  key={colDef.field}
@@ -409,7 +409,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
409
409
  }
410
410
 
411
411
  const headerContent = colDef.renderHeader
412
- ? colDef.renderHeader({ field: colDef.field, colDef: colDef as any, colIndex: index })
412
+ ? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex: index })
413
413
  : colDef.headerName || colDef.field;
414
414
 
415
415
  const handleHeaderClick = (e: React.MouseEvent) => {
@@ -499,7 +499,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
499
499
 
500
500
  {menuOpenParams && (
501
501
  <ColumnMenu
502
- colDef={menuOpenParams.colDef}
502
+ colDef={menuOpenParams.colDef as unknown as GridColDef<GridRowModel>}
503
503
  sortModel={sortModel}
504
504
  onSort={onSort}
505
505
  onHide={onHideColumn}
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { GridRowModel, GridRowId, GridListViewColDef, GridRenderCellParams } from '../../types';
2
+ import type { GridColDef, GridRowModel, GridRowId, GridListViewColDef, GridRenderCellParams } from '../../types';
3
3
 
4
4
  export interface ListViewRowProps<R extends GridRowModel = GridRowModel> {
5
5
  row: R;
@@ -26,7 +26,7 @@ export function ListViewRow<R extends GridRowModel = GridRowModel>({
26
26
  row,
27
27
  value: undefined,
28
28
  field: listViewColumn.field,
29
- colDef: { field: listViewColumn.field } as any,
29
+ colDef: { field: listViewColumn.field } as unknown as GridColDef<R>,
30
30
  rowIndex,
31
31
  colIndex: 0,
32
32
  };
@@ -40,10 +40,10 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
40
40
  isDragging?: boolean;
41
41
  isDragOver?: boolean;
42
42
 
43
- editingCell?: { id: GridRowId; field: string; value: any; } | null;
44
- onEditStart?: (params: { id: GridRowId, field: string, value: any }) => void;
43
+ editingCell?: { id: GridRowId; field: string; value: unknown; } | null;
44
+ onEditStart?: (params: { id: GridRowId, field: string, value: unknown }) => void;
45
45
  onEditStop?: (params?: { cancel?: boolean }) => void;
46
- onEditCellValueChange?: (params: { id: GridRowId, field: string, value: any }) => void;
46
+ onEditCellValueChange?: (params: { id: GridRowId, field: string, value: unknown }) => void;
47
47
 
48
48
  focusedCellField?: string | null;
49
49
  isFocusVisible?: boolean;
@@ -97,8 +97,47 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
97
97
  const expandCellRef = React.useRef<HTMLDivElement>(null);
98
98
  const checkboxCellRef = React.useRef<HTMLDivElement>(null);
99
99
 
100
+ // All hooks must come before any early returns (Rules of Hooks)
101
+ React.useLayoutEffect(() => {
102
+ if (focusedCellField === '__expand_col__' && expandCellRef.current) {
103
+ expandCellRef.current.focus({ preventScroll: true });
104
+ }
105
+ if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
106
+ checkboxCellRef.current.focus({ preventScroll: true });
107
+ }
108
+ }, [focusedCellField]);
109
+
110
+ const pinnedPositions = React.useMemo(() => {
111
+ return calculatePinnedPositions(
112
+ columns,
113
+ columnWidths,
114
+ pinnedColumns,
115
+ checkboxSelection,
116
+ pinCheckboxColumn,
117
+ hasDetailPanel,
118
+ pinExpandColumn,
119
+ rowReordering
120
+ );
121
+ }, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
122
+
123
+ const isGroupRow = row._hasChildren === true;
124
+
125
+ const handleCellEditStart = React.useCallback((field: string, value: unknown) => {
126
+ if (!isGroupRow) {
127
+ onEditStart?.({ id: row.id, field, value });
128
+ }
129
+ }, [isGroupRow, row.id, onEditStart]);
130
+
131
+ const handleCellValueChange = React.useCallback((field: string, newValue: unknown) => {
132
+ onEditCellValueChange?.({ id: row.id, field, value: newValue });
133
+ }, [onEditCellValueChange, row.id]);
134
+
135
+ const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
136
+ onEditStop?.({ cancel });
137
+ }, [onEditStop]);
138
+
100
139
  // ── Skeleton row (shown during infinite-scroll fetch) ─────────────────────
101
- if ((row as any)._isSkeleton) {
140
+ if (row._isSkeleton) {
102
141
  // Render shimmer placeholders — one per visible column, matching row height
103
142
  const cellCount = Math.max(columns.length, 5);
104
143
  return (
@@ -117,15 +156,6 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
117
156
  );
118
157
  }
119
158
 
120
- React.useLayoutEffect(() => {
121
- if (focusedCellField === '__expand_col__' && expandCellRef.current) {
122
- expandCellRef.current.focus({ preventScroll: true });
123
- }
124
- if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
125
- checkboxCellRef.current.focus({ preventScroll: true });
126
- }
127
- }, [focusedCellField]);
128
-
129
159
  const handleRowClick = (event: React.MouseEvent) => {
130
160
 
131
161
  if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell')) {
@@ -144,39 +174,10 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
144
174
  onDetailPanelToggle?.(row.id);
145
175
  };
146
176
 
147
- const pinnedPositions = React.useMemo(() => {
148
- return calculatePinnedPositions(
149
- columns,
150
- columnWidths,
151
- pinnedColumns,
152
- checkboxSelection,
153
- pinCheckboxColumn,
154
- hasDetailPanel,
155
- pinExpandColumn,
156
- rowReordering
157
- );
158
- }, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
159
-
160
177
  const rowPinnedPosition = isRowPinned(row.id, pinnedRows);
161
178
  const isRowPinnedTop = rowPinnedPosition === 'top';
162
179
  const isRowPinnedBottom = rowPinnedPosition === 'bottom';
163
180
 
164
- const isGroupRow = (row as any)._hasChildren === true;
165
-
166
- const handleCellEditStart = React.useCallback((field: string, value: any) => {
167
- if (!isGroupRow) {
168
- onEditStart?.({ id: row.id, field, value });
169
- }
170
- }, [isGroupRow, row.id, onEditStart]);
171
-
172
- const handleCellValueChange = React.useCallback((field: string, newValue: any) => {
173
- onEditCellValueChange?.({ id: row.id, field, value: newValue });
174
- }, [onEditCellValueChange, row.id]);
175
-
176
- const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
177
- onEditStop?.({ cancel });
178
- }, [onEditStop]);
179
-
180
181
  const classNames = [
181
182
  'ogx__row',
182
183
  isSelected && 'ogx__row--selected',
@@ -249,7 +250,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
249
250
  row,
250
251
  field: '__expand_col__',
251
252
  value: isDetailPanelExpanded,
252
- colDef: { field: '__expand_col__', width: 48 } as any,
253
+ colDef: { field: '__expand_col__', width: 48 } as unknown as GridColDef<R>,
253
254
  rowIndex,
254
255
  colIndex: -1
255
256
  });
@@ -283,7 +284,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
283
284
  row,
284
285
  field: '__checkbox_col__',
285
286
  value: isSelected,
286
- colDef: { field: '__checkbox_col__', width: 48 } as any,
287
+ colDef: { field: '__checkbox_col__', width: 48 } as unknown as GridColDef<R>,
287
288
  rowIndex,
288
289
  colIndex: -1
289
290
  });
@@ -304,7 +305,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
304
305
 
305
306
  { }
306
307
  {columns.map((colDef, colIndex) => {
307
- if ((colDef as any).isSpacer) {
308
+ if (colDef.isSpacer) {
308
309
  return (
309
310
  <div
310
311
  key={colDef.field}
@@ -1,8 +1,15 @@
1
1
  import React from 'react';
2
+ import type { GridColDef } from '../../types';
2
3
 
4
+ interface SkeletonColumn extends GridColDef {
5
+ left?: number;
6
+ right?: number;
7
+ pinned?: 'left' | 'right';
8
+ zIndex?: number;
9
+ }
3
10
 
4
11
  interface SkeletonRowProps {
5
- columns: any[];
12
+ columns: SkeletonColumn[];
6
13
  rowHeight: number;
7
14
  checkboxSelection?: boolean;
8
15
  hasDetailPanel?: boolean;
@@ -54,7 +61,7 @@ export const SkeletonRow: React.FC<SkeletonRowProps> = ({
54
61
 
55
62
  { }
56
63
  {columns.map((col, index) => {
57
- const width = col.width || 150;
64
+ const width = typeof col.width === 'number' ? col.width : 150;
58
65
  const skeletonWidth = Math.min(width * 0.7, width - 20);
59
66
 
60
67
  return (
@@ -34,7 +34,7 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
34
34
  const containerRef = useRef<HTMLDivElement>(null);
35
35
  // Keep onChange always current without adding it to effect deps
36
36
  const onChangeRef = useRef(onChange);
37
- onChangeRef.current = onChange;
37
+ useLayoutEffect(() => { onChangeRef.current = onChange; }, [onChange]);
38
38
  // Track the last value we emitted upward
39
39
  const lastEmittedRef = useRef(value);
40
40
  // Track the last incoming prop value to detect external resets
@@ -1,4 +1,4 @@
1
- import React, { useState, useRef, useEffect, useCallback } from 'react';
1
+ import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
2
2
  import ReactDOM from 'react-dom';
3
3
  import type { GridColDef, GridAggregationModel, GridPivotModel, GridFilterModel } from '../../types';
4
4
  import { PivotPanel, PivotIcon } from './PivotPanel';
@@ -23,6 +23,7 @@ export interface GridToolbarProps {
23
23
  columnVisibilityModel?: Record<string, boolean>;
24
24
  onColumnVisibilityModelChange?: (model: Record<string, boolean>) => void;
25
25
  onColumnReorder?: (fromField: string, toField: string) => void;
26
+ onColumnOrderReset?: () => void;
26
27
  forceColumnsOpen?: boolean;
27
28
  onColumnsPanelClose?: () => void;
28
29
  children?: React.ReactNode;
@@ -87,6 +88,11 @@ function AggregationPanel({
87
88
  }) {
88
89
  const panelRef = useRef<HTMLDivElement>(null);
89
90
  const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
91
+ const [portalContainer, setPortalContainer] = useState<Element>(document.body);
92
+
93
+ useLayoutEffect(() => {
94
+ setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
95
+ }, [anchorRef]);
90
96
 
91
97
  const computePos = useCallback((): PanelPosition => {
92
98
  if (!anchorRef.current) return { top: 0, right: 0 };
@@ -185,7 +191,7 @@ function AggregationPanel({
185
191
  </div>
186
192
  );
187
193
 
188
- return ReactDOM.createPortal(panel, anchorRef.current?.closest('.ogx-theme-provider') || document.body);
194
+ return ReactDOM.createPortal(panel, portalContainer);
189
195
  }
190
196
 
191
197
  function ColumnsPanelWrapper({
@@ -196,6 +202,7 @@ function ColumnsPanelWrapper({
196
202
  onShowAll,
197
203
  onHideAll,
198
204
  onColumnReorder,
205
+ onColumnOrderReset,
199
206
  onClose
200
207
  }: {
201
208
  anchorRef: React.RefObject<HTMLButtonElement | null>;
@@ -205,10 +212,16 @@ function ColumnsPanelWrapper({
205
212
  onShowAll: () => void;
206
213
  onHideAll: () => void;
207
214
  onColumnReorder?: (fromField: string, toField: string) => void;
215
+ onColumnOrderReset?: () => void;
208
216
  onClose: () => void;
209
217
  }) {
210
218
  const panelRef = useRef<HTMLDivElement>(null);
211
219
  const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
220
+ const [portalContainer, setPortalContainer] = useState<Element>(document.body);
221
+
222
+ useLayoutEffect(() => {
223
+ setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
224
+ }, [anchorRef]);
212
225
 
213
226
  const computePos = useCallback((): PanelPosition => {
214
227
  if (!anchorRef.current) return { top: 0, right: 0 };
@@ -258,11 +271,12 @@ function ColumnsPanelWrapper({
258
271
  onShowAll={onShowAll}
259
272
  onHideAll={onHideAll}
260
273
  onColumnReorder={onColumnReorder}
274
+ onColumnOrderReset={onColumnOrderReset}
261
275
  />
262
276
  </div>
263
277
  );
264
278
 
265
- return ReactDOM.createPortal(panel, anchorRef.current?.closest('.ogx-theme-provider') || document.body);
279
+ return ReactDOM.createPortal(panel, portalContainer);
266
280
  }
267
281
 
268
282
  function FilterPanelWrapper({
@@ -284,7 +298,12 @@ function FilterPanelWrapper({
284
298
  // tearing down and re-registering on every filterModel update,
285
299
  // which could fire the close callback during the re-register window.
286
300
  const onCloseRef = useRef(onClose);
287
- onCloseRef.current = onClose;
301
+ useLayoutEffect(() => { onCloseRef.current = onClose; }, [onClose]);
302
+ const [portalContainer, setPortalContainer] = useState<Element>(document.body);
303
+
304
+ useLayoutEffect(() => {
305
+ setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
306
+ }, [anchorRef]);
288
307
 
289
308
  const computePos = useCallback((): PanelPosition => {
290
309
  if (!anchorRef.current) return { top: 0, right: 0 };
@@ -371,7 +390,7 @@ function FilterPanelWrapper({
371
390
  </div>
372
391
  );
373
392
 
374
- return ReactDOM.createPortal(panel, anchorRef.current?.closest('.ogx-theme-provider') || document.body);
393
+ return ReactDOM.createPortal(panel, portalContainer);
375
394
  }
376
395
 
377
396
  const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
@@ -388,6 +407,7 @@ export function GridToolbar({
388
407
  columnVisibilityModel = {},
389
408
  onColumnVisibilityModelChange,
390
409
  onColumnReorder,
410
+ onColumnOrderReset,
391
411
  forceColumnsOpen,
392
412
  onColumnsPanelClose,
393
413
  children,
@@ -538,6 +558,7 @@ export function GridToolbar({
538
558
  onShowAll={handleShowAllColumns}
539
559
  onHideAll={handleHideAllColumns}
540
560
  onColumnReorder={onColumnReorder}
561
+ onColumnOrderReset={onColumnOrderReset}
541
562
  onClose={() => { setColsOpen(false); onColumnsPanelClose?.(); }}
542
563
  />
543
564
  )}
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import ReactDOM from 'react-dom';
3
3
  import type { GridColDef } from '../../types';
4
4
  import type { GridPivotModel, GridPivotAggFn, GridPivotValueField } from '../../types';
@@ -31,6 +31,11 @@ export interface PivotPanelProps {
31
31
  export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: PivotPanelProps) {
32
32
  const panelRef = useRef<HTMLDivElement>(null);
33
33
  const [pos, setPos] = useState({ top: 0, right: 0 });
34
+ const [portalContainer, setPortalContainer] = useState<Element>(document.body);
35
+
36
+ useLayoutEffect(() => {
37
+ setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
38
+ }, [anchorRef]);
34
39
 
35
40
  const computePos = useCallback(() => {
36
41
  if (!anchorRef.current) return { top: 0, right: 0 };
@@ -235,5 +240,5 @@ export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: Piv
235
240
  </div>
236
241
  );
237
242
 
238
- return ReactDOM.createPortal(panel, anchorRef.current?.closest('.ogx-theme-provider') || document.body);
243
+ return ReactDOM.createPortal(panel, portalContainer);
239
244
  }
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
 
3
- export interface DragHandleIconProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { }
3
+ export type DragHandleIconProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
4
4
 
5
5
  export const DragHandleIcon: React.FC<DragHandleIconProps> = ({ className = '', ...props }) => {
6
6
  return (
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Regression tests for bugs fixed in the useDataGrid reducer.
3
+ *
4
+ * Each describe block names the bug and the commit that fixed it, so a
5
+ * future failure tells you exactly what regressed and where to look.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { renderHook, act } from '@testing-library/react';
10
+ import { useDataGrid } from './useDataGrid';
11
+ import type { GridColDef } from '../../types';
12
+
13
+ const COLS: GridColDef[] = [
14
+ { field: 'id', headerName: 'ID', width: 80 },
15
+ { field: 'name', headerName: 'Name', width: 150 },
16
+ { field: 'age', headerName: 'Age', width: 100 },
17
+ ];
18
+
19
+ const ROWS = [
20
+ { id: 1, name: 'Alice', age: 30 },
21
+ { id: 2, name: 'Bob', age: 25 },
22
+ ];
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Bug: SET_COLUMNS reducer was hardcoding columnVisibilityModel: {} on every
26
+ // column update, silently wiping any hidden columns.
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ describe('SET_COLUMNS preserves columnVisibilityModel', () => {
29
+ it('keeps hidden columns hidden after setColumns is called', () => {
30
+ const { result } = renderHook(() =>
31
+ useDataGrid({
32
+ rows: ROWS,
33
+ columns: COLS,
34
+ columnVisibilityModel: { age: false },
35
+ })
36
+ );
37
+
38
+ // Confirm the initial visibility model was applied.
39
+ expect(result.current.state.columns.columnVisibilityModel).toEqual({ age: false });
40
+
41
+ // Simulate a column update (e.g. a width change coming from a resize handler).
42
+ const updatedCols: GridColDef[] = COLS.map(c =>
43
+ c.field === 'name' ? { ...c, width: 200 } : c
44
+ );
45
+
46
+ act(() => {
47
+ result.current.setColumns(updatedCols);
48
+ });
49
+
50
+ // The visibility model must survive the column update — not reset to {}.
51
+ expect(result.current.state.columns.columnVisibilityModel).toEqual({ age: false });
52
+ });
53
+
54
+ it('does not gain a visibility entry for columns that were already visible', () => {
55
+ const { result } = renderHook(() =>
56
+ useDataGrid({ rows: ROWS, columns: COLS })
57
+ );
58
+
59
+ act(() => {
60
+ result.current.setColumns(COLS);
61
+ });
62
+
63
+ // No spurious entries: an empty model means all columns are visible.
64
+ expect(result.current.state.columns.columnVisibilityModel).toEqual({});
65
+ });
66
+ });
@@ -1,7 +1,7 @@
1
1
 
2
- import { useReducer, useCallback, useMemo, useRef, useEffect } from 'react';
2
+ import { useReducer, useCallback, useState, useRef, useEffect, useLayoutEffect } from 'react';
3
3
  import type {
4
- GridState,
4
+ GridInternalState as GridState,
5
5
  GridRowModel,
6
6
  GridColDef,
7
7
  GridRowId,
@@ -19,27 +19,25 @@ type GridAction =
19
19
  | { type: 'SET_FILTER_MODEL'; payload: GridFilterModel }
20
20
  | { type: 'SET_PAGINATION_MODEL'; payload: GridPaginationModel }
21
21
  | { type: 'SET_SELECTION'; payload: Set<GridRowId> }
22
- | { type: 'SET_SCROLL'; payload: { scrollTop: number; scrollLeft: number } }
23
22
  | { type: 'SET_DIMENSIONS'; payload: { viewportWidth: number; viewportHeight: number } }
24
23
  | { type: 'SET_DATASOURCE_LOADING'; payload: boolean }
25
- | { type: 'SET_DATASOURCE_ERROR'; payload: any }
24
+ | { type: 'SET_DATASOURCE_ERROR'; payload: unknown }
26
25
  | { type: 'SET_ROW_COUNT'; payload: number };
27
26
 
28
- function createInitialState(rows: GridRowModel[], columns: GridColDef[], columnVisibilityModel: GridColumnVisibilityModel = {}): GridState {
27
+ function createInitialState<R extends GridRowModel>(rows: R[], columns: GridColDef<R>[], columnVisibilityModel: GridColumnVisibilityModel = {}): GridState {
29
28
  const idRowsLookup = new Map<GridRowId, GridRowModel>();
30
29
  const allRows: GridRowId[] = [];
31
30
 
32
31
  rows.forEach(row => {
33
- const id = row.id;
34
- idRowsLookup.set(id, row);
35
- allRows.push(id);
32
+ idRowsLookup.set(row.id, row);
33
+ allRows.push(row.id);
36
34
  });
37
35
 
38
36
  const columnLookup = new Map<string, GridColDef>();
39
37
  const orderedFields: string[] = [];
40
38
 
41
39
  columns.forEach(col => {
42
- columnLookup.set(col.field, col);
40
+ columnLookup.set(col.field, col as unknown as GridColDef);
43
41
  orderedFields.push(col.field);
44
42
  });
45
43
 
@@ -49,7 +47,7 @@ function createInitialState(rows: GridRowModel[], columns: GridColDef[], columnV
49
47
  allRows
50
48
  },
51
49
  columns: {
52
- all: columns,
50
+ all: columns as unknown as GridColDef[],
53
51
  lookup: columnLookup,
54
52
  orderedFields,
55
53
  columnVisibilityModel
@@ -78,8 +76,6 @@ function createInitialState(rows: GridRowModel[], columns: GridColDef[], columnV
78
76
  firstColumnIndex: 0,
79
77
  lastColumnIndex: 0
80
78
  },
81
- scrollTop: 0,
82
- scrollLeft: 0
83
79
  },
84
80
  dimensions: {
85
81
  rowHeight: 52,
@@ -137,7 +133,7 @@ function gridReducer(state: GridState, action: GridAction): GridState {
137
133
  all: action.payload,
138
134
  lookup: columnLookup,
139
135
  orderedFields,
140
- columnVisibilityModel: {}
136
+ columnVisibilityModel: state.columns.columnVisibilityModel
141
137
  }
142
138
  };
143
139
  }
@@ -169,16 +165,6 @@ function gridReducer(state: GridState, action: GridAction): GridState {
169
165
  selection: { selectedRows: action.payload }
170
166
  };
171
167
 
172
- case 'SET_SCROLL':
173
- return {
174
- ...state,
175
- virtualization: {
176
- ...state.virtualization,
177
- scrollTop: action.payload.scrollTop,
178
- scrollLeft: action.payload.scrollLeft
179
- }
180
- };
181
-
182
168
  case 'SET_DIMENSIONS':
183
169
  return {
184
170
  ...state,
@@ -234,18 +220,17 @@ export interface UseDataGridParams<R extends GridRowModel = GridRowModel> {
234
220
  export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDataGridParams<R>) {
235
221
  const { rows, columns, rowHeight = 52, headerHeight = 56, columnVisibilityModel, initialState: propInitialState } = params;
236
222
 
237
- const internalInitialState = useMemo(
238
- () => createInitialState(rows, columns as any, columnVisibilityModel),
239
- []
223
+ const [internalInitialState] = useState(() =>
224
+ createInitialState(rows, columns, columnVisibilityModel)
240
225
  );
241
226
 
242
227
  const [state, dispatch] = useReducer(gridReducer, {
243
228
  ...internalInitialState,
244
229
  sorting: propInitialState?.sorting || internalInitialState.sorting,
245
230
  filter: propInitialState?.filter || internalInitialState.filter,
246
- dataSource: (propInitialState?.dataSource
231
+ dataSource: propInitialState?.dataSource
247
232
  ? { ...internalInitialState.dataSource, ...propInitialState.dataSource }
248
- : internalInitialState.dataSource) as any,
233
+ : internalInitialState.dataSource,
249
234
  pagination: {
250
235
  ...internalInitialState.pagination,
251
236
  ...(propInitialState?.pagination || {}),
@@ -259,7 +244,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
259
244
  });
260
245
 
261
246
  const stateRef = useRef(state);
262
- stateRef.current = state;
247
+ useLayoutEffect(() => { stateRef.current = state; });
263
248
 
264
249
  // Sync internal state when the external `rows` prop changes
265
250
  const prevRowsRef = useRef(rows);
@@ -294,10 +279,6 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
294
279
  dispatch({ type: 'SET_SELECTION', payload: selectedRows });
295
280
  }, []);
296
281
 
297
- const setScroll = useCallback((scrollTop: number, scrollLeft: number) => {
298
- dispatch({ type: 'SET_SCROLL', payload: { scrollTop, scrollLeft } });
299
- }, []);
300
-
301
282
  const setDimensions = useCallback((viewportWidth: number, viewportHeight: number) => {
302
283
  dispatch({ type: 'SET_DIMENSIONS', payload: { viewportWidth, viewportHeight } });
303
284
  }, []);
@@ -306,7 +287,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
306
287
  dispatch({ type: 'SET_DATASOURCE_LOADING', payload: loading });
307
288
  }, []);
308
289
 
309
- const setDataSourceError = useCallback((error: any) => {
290
+ const setDataSourceError = useCallback((error: unknown) => {
310
291
  dispatch({ type: 'SET_DATASOURCE_ERROR', payload: error });
311
292
  }, []);
312
293
 
@@ -383,7 +364,6 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
383
364
  setFilterModel,
384
365
  setPaginationModel,
385
366
  setSelection,
386
- setScroll,
387
367
  setDimensions,
388
368
  setDataSourceLoading,
389
369
  setDataSourceError,