@opencorestack/opengridx 2.0.3 → 2.1.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 (47) hide show
  1. package/CHANGELOG.md +54 -3
  2. package/dist/index.d.ts +50 -2
  3. package/dist/opengridx.css +1 -1
  4. package/dist/opengridx.es.js +3805 -3692
  5. package/dist/opengridx.es.js.map +1 -1
  6. package/dist/opengridx.umd.js +16 -16
  7. package/dist/opengridx.umd.js.map +1 -1
  8. package/docs/API_REFERENCE.md +14 -4
  9. package/docs/components/column-visibility.md +40 -16
  10. package/docs/components/datagrid.md +1 -0
  11. package/docs/customization/slots-api.md +18 -6
  12. package/docs/features/export-guide.md +22 -3
  13. package/docs/features/sorting-pagination.md +2 -0
  14. package/docs/features/tree-data-grouping.md +8 -1
  15. package/docs/features/virtualization.md +66 -17
  16. package/docs/roadmap.md +13 -7
  17. package/lib/components/Cell/Cell.tsx +1 -0
  18. package/lib/components/DataGrid/DataGrid.css +7 -0
  19. package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
  20. package/lib/components/DataGrid/DataGrid.tsx +45 -11
  21. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
  22. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  23. package/lib/components/DataGrid/GridListView.tsx +3 -0
  24. package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
  25. package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
  26. package/lib/components/ListView/ListViewRow.tsx +3 -0
  27. package/lib/components/Row/Row.tsx +10 -0
  28. package/lib/hooks/core/useGridColumns.tsx +8 -3
  29. package/lib/hooks/core/useGridDevWarnings.ts +42 -0
  30. package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
  31. package/lib/hooks/core/useGridScrollSync.ts +62 -15
  32. package/lib/hooks/core/useGridVirtualization.ts +4 -1
  33. package/lib/hooks/core/useGridVisibleRows.ts +3 -2
  34. package/lib/hooks/features/useAggregation.ts +3 -38
  35. package/lib/hooks/useRowGrouping.test.ts +162 -1
  36. package/lib/hooks/useRowGrouping.ts +42 -59
  37. package/lib/hooks/useTreeData.test.ts +83 -0
  38. package/lib/hooks/useTreeData.ts +44 -43
  39. package/lib/index.ts +2 -0
  40. package/lib/types/index.ts +22 -2
  41. package/lib/utils/aggregation/aggregation.test.ts +16 -0
  42. package/lib/utils/aggregation/index.ts +62 -0
  43. package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
  44. package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
  45. package/lib/utils/scroll/index.ts +28 -0
  46. package/llms.txt +22 -13
  47. package/package.json +7 -3
@@ -8,6 +8,8 @@ import { useGridColumns } from '../../hooks/core/useGridColumns';
8
8
  import { useGridVisibleRows } from '../../hooks/core/useGridVisibleRows';
9
9
  import { useGridScrollSync } from '../../hooks/core/useGridScrollSync';
10
10
  import { useGridStateSnapshot } from '../../hooks/core/useGridStateSnapshot';
11
+ import { useGridDevWarnings } from '../../hooks/core/useGridDevWarnings';
12
+ import { scrollRowIntoView } from '../../utils/scroll';
11
13
  import { GridAggregationFooter } from './GridAggregationFooter';
12
14
  import { GridEmptyState } from './GridEmptyState';
13
15
  import { GridErrorOverlay } from './GridErrorOverlay';
@@ -46,6 +48,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
46
48
  rowSelectionModel: propRowSelectionModel,
47
49
  onRowSelectionModelChange: propOnRowSelectionModelChange,
48
50
  onRowClick,
51
+ onRowDoubleClick,
49
52
  onCellClick,
50
53
 
51
54
  filterModel: propFilterModel,
@@ -118,6 +121,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
118
121
  noRowsLabel = 'No Data',
119
122
  localeText,
120
123
  apiRef: propApiRef,
124
+ overscanRowCount = 3,
121
125
  } = props;
122
126
 
123
127
  const effectiveNoRowsLabel = localeText?.noRowsLabel ?? noRowsLabel;
@@ -301,7 +305,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
301
305
  setRowCount
302
306
  } = gridData;
303
307
 
304
- const { scrollTop, scrollLeft, handleScroll } = useGridScrollSync({ onRowsScrollEnd });
308
+ const { scrollTop, scrollLeft, overscanRows, handleScroll } = useGridScrollSync({ onRowsScrollEnd, overscanRowCount });
305
309
 
306
310
  useEffect(() => {
307
311
  if (propApiRef) {
@@ -651,14 +655,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
651
655
  if (!el) return;
652
656
 
653
657
  if (rowIndex !== undefined && rowIndex >= 0) {
654
- const rowTop = rowIndex > 0 ? layout.cumulativeHeights[rowIndex - 1] : 0;
655
- const rowBottom = layout.cumulativeHeights[rowIndex] ?? rowTop;
656
- const { scrollTop, clientHeight } = el;
657
- if (rowTop < scrollTop) {
658
- el.scrollTop = rowTop;
659
- } else if (rowBottom > scrollTop + clientHeight) {
660
- el.scrollTop = rowBottom - clientHeight;
661
- }
658
+ scrollRowIntoView(el, rowIndex, layout.cumulativeHeights, layout.pinnedBottomHeight);
662
659
  }
663
660
 
664
661
  if (colIndex !== undefined && colIndex >= 0) {
@@ -693,6 +690,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
693
690
  checkboxSelection,
694
691
  pinCheckboxColumn,
695
692
  pinExpandColumn,
693
+ overscanRows,
696
694
  });
697
695
 
698
696
 
@@ -777,6 +775,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
777
775
  pageSize: effectivePaginationModel.pageSize,
778
776
  virtualization,
779
777
  viewportRef,
778
+ pinnedTopRowCount: pinnedTopRows.length,
780
779
  });
781
780
 
782
781
  const handleCellClick = useCallback((params: GridCellParams<R>) => {
@@ -809,6 +808,14 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
809
808
  pagination,
810
809
  });
811
810
 
811
+ useGridDevWarnings({
812
+ paginationRequested: propPagination,
813
+ isRowGrouping,
814
+ autoHeight,
815
+ viewportHeight: state.dimensions.viewportHeight,
816
+ renderedRowCount: visibleRows.length,
817
+ totalRowCount: pinnedTopRows.length + (pagination ? paginatedUnpinnedRows.length : sortedUnpinnedRows.length) + pinnedBottomRows.length,
818
+ });
812
819
 
813
820
  const allSelected = rows.length > 0 && selectedRowIds.size === rows.length;
814
821
  const someSelected = selectedRowIds.size > 0 && selectedRowIds.size < rows.length;
@@ -818,6 +825,10 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
818
825
  const containerRef = React.useRef<HTMLDivElement>(null);
819
826
  const standalonePanelRef = React.useRef<HTMLDivElement>(null);
820
827
 
828
+ const NoRowsOverlaySlot = slots?.noRowsOverlay;
829
+ const LoadingOverlaySlot = slots?.loadingOverlay;
830
+ const FooterSlot = slots?.footer;
831
+
821
832
  const toolbarProps = React.useMemo(() => {
822
833
  if (!slots?.toolbar) return null;
823
834
  const reorderHandler = disableColumnReorder
@@ -930,6 +941,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
930
941
  paginationPage: localeText.paginationPage,
931
942
  } : undefined}
932
943
  onRowClick={handleRowClick}
944
+ onRowDoubleClick={onRowDoubleClick}
933
945
  onSelectionChange={handleSelectionChange}
934
946
  onPaginationModelChange={handlePaginationModelChange}
935
947
  />
@@ -1031,7 +1043,11 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1031
1043
 
1032
1044
  {/* Empty State Overlay (Standard View) — showing after header */}
1033
1045
  {!effectiveLoading && !state.dataSource.error && filteredRows.length === 0 && (
1034
- <GridEmptyState noRowsLabel={effectiveNoRowsLabel} width={virtualization.totalWidth} />
1046
+ <GridEmptyState
1047
+ noRowsLabel={effectiveNoRowsLabel}
1048
+ width={virtualization.totalWidth}
1049
+ overlay={NoRowsOverlaySlot ? <NoRowsOverlaySlot {...slotProps?.noRowsOverlay} /> : undefined}
1050
+ />
1035
1051
  )}
1036
1052
 
1037
1053
  <GridPinnedRows<R>
@@ -1041,6 +1057,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1041
1057
  selectedRowIds={selectedRowIds}
1042
1058
  checkboxSelection={checkboxSelection}
1043
1059
  onRowClick={handleRowClick}
1060
+ onRowDoubleClick={onRowDoubleClick}
1044
1061
  onCellClick={handleCellClick}
1045
1062
  onSelectionChange={handleSelectionChange}
1046
1063
  columnWidths={columnWidths}
@@ -1075,6 +1092,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1075
1092
  pinnedRows={pinnedRows}
1076
1093
  selectedRowIds={selectedRowIds}
1077
1094
  onRowClick={handleRowClick}
1095
+ onRowDoubleClick={onRowDoubleClick}
1078
1096
  onCellClick={handleCellClick}
1079
1097
  onSelectionChange={handleSelectionChange}
1080
1098
  columnWidths={columnWidths}
@@ -1096,6 +1114,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1096
1114
  infiniteScrollSkeletonCount={Math.min(effectivePaginationModel.pageSize, 20)}
1097
1115
  unpinnedRowsLength={layout.unpinnedRowsLength}
1098
1116
  rowMetaMap={rowMetaMap}
1117
+ loadingOverlay={LoadingOverlaySlot ? <LoadingOverlaySlot {...slotProps?.loadingOverlay} /> : undefined}
1099
1118
  />
1100
1119
 
1101
1120
  <GridPinnedRows<R>
@@ -1105,6 +1124,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1105
1124
  selectedRowIds={selectedRowIds}
1106
1125
  checkboxSelection={checkboxSelection}
1107
1126
  onRowClick={handleRowClick}
1127
+ onRowDoubleClick={onRowDoubleClick}
1108
1128
  onCellClick={handleCellClick}
1109
1129
  onSelectionChange={handleSelectionChange}
1110
1130
  columnWidths={columnWidths}
@@ -1145,7 +1165,21 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1145
1165
  </div>
1146
1166
  )}
1147
1167
 
1148
- {!listView && pagination && (() => {
1168
+ {!listView && FooterSlot && (
1169
+ <FooterSlot
1170
+ apiRef={gridData.apiRef}
1171
+ aggregationModel={aggregationModel}
1172
+ aggregationResult={hasAggregation ? aggregationResult : null}
1173
+ rowCount={isRowGrouping ? effectiveRows.length : sortedUnpinnedRows.length}
1174
+ pagination={pagination}
1175
+ paginationModel={effectivePaginationModel}
1176
+ pageSizeOptions={pageSizeOptions}
1177
+ onPaginationModelChange={handlePaginationModelChange}
1178
+ {...slotProps?.footer}
1179
+ />
1180
+ )}
1181
+
1182
+ {!listView && !FooterSlot && pagination && (() => {
1149
1183
  const PaginationComponent = slots?.pagination || Pagination;
1150
1184
  return (
1151
1185
  <PaginationComponent
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { render, cleanup } from '@testing-library/react';
3
+ import { useEffect } from 'react';
4
+ import type { ReactNode } from 'react';
5
+ import { DataGrid, useGridApiRef } from '../../index';
6
+ import type { GridColDef } from '../../types';
7
+
8
+ // Runs in real Chromium (vitest browser project). jsdom has no layout engine, so the
9
+ // container-height behaviour these tests pin down cannot be reproduced there.
10
+
11
+ type Row = { id: number; region: string; amount: number };
12
+
13
+ const makeRows = (total: number, northCount: number): Row[] =>
14
+ Array.from({ length: total }, (_, i) => ({ id: i + 1, region: i < northCount ? 'North' : 'South', amount: i }));
15
+
16
+ const columns: GridColDef<Row>[] = [
17
+ { field: 'region', width: 200 },
18
+ { field: 'amount', width: 150 },
19
+ ];
20
+
21
+ // Bounded: a flex child with min-height: 0 cannot grow past the 600px parent.
22
+ const Bounded = ({ children }: { children: ReactNode }) => (
23
+ <div style={{ height: 600, display: 'flex', flexDirection: 'column' }}>
24
+ <div style={{ flex: 1, minHeight: 0 }}>{children}</div>
25
+ </div>
26
+ );
27
+
28
+ // Unbounded: the classic trap — a flex child without min-height: 0 grows to fit its content.
29
+ const Unbounded = ({ children }: { children: ReactNode }) => (
30
+ <div style={{ height: 600, display: 'flex', flexDirection: 'column' }}>
31
+ <div style={{ flex: 1 }}>{children}</div>
32
+ </div>
33
+ );
34
+
35
+ const domRowCount = (container: HTMLElement) => container.querySelectorAll('.ogx__rows [role="row"]').length;
36
+ const firstRow = (container: HTMLElement) => container.querySelector<HTMLElement>('.ogx__rows [role="row"]')!;
37
+
38
+ afterEach(() => {
39
+ cleanup();
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ describe('DataGrid virtualization in a real browser', () => {
44
+ it('virtualizes a large flat dataset in a bounded container', async () => {
45
+ const { container } = render(<Bounded><DataGrid rows={makeRows(30000, 30000)} columns={columns} height="100%" /></Bounded>);
46
+ await expect.poll(() => domRowCount(container)).toBeGreaterThan(0);
47
+ expect(domRowCount(container)).toBeLessThan(60);
48
+ });
49
+
50
+ it('keeps DOM rows bounded after expanding a 25,000-row group', async () => {
51
+ const { container } = render(
52
+ <Bounded><DataGrid rows={makeRows(30000, 25000)} columns={columns} height="100%" rowGroupingModel={['region']} /></Bounded>
53
+ );
54
+ await expect.poll(() => domRowCount(container)).toBe(2);
55
+
56
+ firstRow(container).click();
57
+
58
+ await expect.poll(() => domRowCount(container)).toBeGreaterThan(2);
59
+ expect(domRowCount(container)).toBeLessThan(60);
60
+
61
+ const viewport = container.querySelector<HTMLElement>('.ogx__viewport')!;
62
+ viewport.scrollTop = viewport.scrollHeight / 2;
63
+ viewport.dispatchEvent(new Event('scroll'));
64
+ await expect.poll(() => container.querySelector('.ogx__rows [role="row"]')?.getAttribute('data-rowindex')).not.toBe('0');
65
+ expect(domRowCount(container)).toBeLessThan(80);
66
+ });
67
+
68
+ it('warns in development when an unbounded container defeats virtualization', async () => {
69
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
70
+ const { container } = render(
71
+ <Unbounded><DataGrid rows={makeRows(3000, 2500)} columns={columns} height="100%" rowGroupingModel={['region']} /></Unbounded>
72
+ );
73
+ await expect.poll(() => domRowCount(container)).toBe(2);
74
+ firstRow(container).click();
75
+
76
+ await expect.poll(() => warn.mock.calls.some(([msg]) => String(msg).includes('virtualization is effectively off'))).toBe(true);
77
+ });
78
+
79
+ it('warns in development that pagination is ignored under row grouping', async () => {
80
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
81
+ render(<Bounded><DataGrid rows={makeRows(100, 50)} columns={columns} height="100%" pagination rowGroupingModel={['region']} /></Bounded>);
82
+ await expect.poll(() => warn.mock.calls.some(([msg]) => String(msg).includes('`pagination` is ignored'))).toBe(true);
83
+ });
84
+
85
+ it('positions every scrolled row correctly when rows are pinned to the top', async () => {
86
+ const rowHeight = 52;
87
+ const rows = Array.from({ length: 2000 }, (_, i) => ({ id: i + 1, region: 'r', amount: i }));
88
+ const { container } = render(
89
+ <Bounded>
90
+ <DataGrid rows={rows} columns={[{ field: 'id', width: 100 }, ...columns]} height="100%"
91
+ rowHeight={rowHeight} pinnedRows={{ top: [1, 2] }} />
92
+ </Bounded>
93
+ );
94
+ await expect.poll(() => domRowCount(container)).toBeGreaterThan(0);
95
+
96
+ const viewport = container.querySelector<HTMLElement>('.ogx__viewport')!;
97
+ viewport.scrollTop = rowHeight * 500;
98
+ viewport.dispatchEvent(new Event('scroll'));
99
+ await expect.poll(() => Number(firstRow(container).getAttribute('data-rowindex'))).toBeGreaterThan(100);
100
+
101
+ const virtualContainer = container.querySelector<HTMLElement>('.ogx__virtual-container')!;
102
+ const containerTop = virtualContainer.getBoundingClientRect().top;
103
+ const centerRows = [...virtualContainer.querySelectorAll<HTMLElement>('[role="row"]')];
104
+ expect(centerRows.length).toBeGreaterThan(5);
105
+ for (const rowEl of centerRows) {
106
+ const id = Number(rowEl.querySelector('[data-field="id"]')!.textContent);
107
+ // ids 1 and 2 are pinned, so id N is unpinned index N - 3.
108
+ const expectedTop = (id - 3) * rowHeight;
109
+ expect(Math.round(rowEl.getBoundingClientRect().top - containerTop)).toBe(expectedTop);
110
+ }
111
+
112
+ // The viewport must be filled: the last rendered row reaches past the visible bottom.
113
+ const lastRendered = centerRows[centerRows.length - 1].getBoundingClientRect().bottom;
114
+ expect(lastRendered).toBeGreaterThanOrEqual(viewport.getBoundingClientRect().bottom - 1);
115
+ });
116
+
117
+ describe('keyboard navigation keeps the focused row fully visible', () => {
118
+ const rowHeight = 52;
119
+ const rows = Array.from({ length: 300 }, (_, i) => ({ id: i + 1, region: 'r', amount: i }));
120
+
121
+ const pressArrowDown = async (times: number) => {
122
+ const grid = document.querySelector<HTMLElement>('.ogx__viewport')!;
123
+ for (let i = 0; i < times; i++) {
124
+ grid.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
125
+ await new Promise(r => requestAnimationFrame(() => r(null)));
126
+ }
127
+ };
128
+
129
+ const assertFocusedRowVisible = (container: HTMLElement) => {
130
+ const viewport = container.querySelector<HTMLElement>('.ogx__viewport')!;
131
+ const focused = container.querySelector<HTMLElement>('.ogx__cell--focused')!;
132
+ expect(focused).not.toBeNull();
133
+ const cell = focused.getBoundingClientRect();
134
+ const vp = viewport.getBoundingClientRect();
135
+ const stickyTop = [...container.querySelectorAll<HTMLElement>('.ogx__header-wrap, .ogx__pinned-rows--top')]
136
+ .reduce((max, el) => Math.max(max, el.getBoundingClientRect().bottom), vp.top);
137
+ const stickyBottom = [...container.querySelectorAll<HTMLElement>('.ogx__pinned-rows--bottom')]
138
+ .reduce((min, el) => Math.min(min, el.getBoundingClientRect().top), vp.top + viewport.clientHeight);
139
+ expect(cell.top).toBeGreaterThanOrEqual(stickyTop - 1);
140
+ expect(cell.bottom).toBeLessThanOrEqual(stickyBottom + 1);
141
+ };
142
+
143
+ for (const pinned of [false, true]) {
144
+ it(`after ArrowDown past the bottom edge${pinned ? ' with pinned top rows' : ''}`, async () => {
145
+ const { container } = render(
146
+ <Bounded>
147
+ <DataGrid rows={rows} columns={[{ field: 'id', width: 100 }, ...columns]} height="100%"
148
+ rowHeight={rowHeight} pinnedRows={pinned ? { top: [1, 2] } : undefined} />
149
+ </Bounded>
150
+ );
151
+ await expect.poll(() => domRowCount(container)).toBeGreaterThan(0);
152
+ const target = container.querySelector<HTMLElement>(`.ogx__virtual-container [data-field="id"]`)!;
153
+ target.click();
154
+ await pressArrowDown(20);
155
+ assertFocusedRowVisible(container);
156
+ });
157
+ }
158
+ });
159
+
160
+ it('apiRef.scrollToIndexes brings a row below the fold fully into view', async () => {
161
+ const rows = Array.from({ length: 300 }, (_, i) => ({ id: i + 1, region: 'r', amount: i }));
162
+ const onReady = vi.fn<(apiRef: ReturnType<typeof useGridApiRef>) => void>();
163
+ const Harness = ({ onApi }: { onApi: typeof onReady }) => {
164
+ const apiRef = useGridApiRef();
165
+ useEffect(() => { onApi(apiRef); }, [apiRef, onApi]);
166
+ return <DataGrid apiRef={apiRef} rows={rows} columns={[{ field: 'id', width: 100 }, ...columns]} height="100%" />;
167
+ };
168
+ const { container } = render(<Bounded><Harness onApi={onReady} /></Bounded>);
169
+ await expect.poll(() => domRowCount(container)).toBeGreaterThan(0);
170
+
171
+ onReady.mock.calls[0][0].current.scrollToIndexes({ rowIndex: 40 });
172
+ const viewport = container.querySelector<HTMLElement>('.ogx__viewport')!;
173
+ viewport.dispatchEvent(new Event('scroll'));
174
+ const rowEl = () => [...container.querySelectorAll<HTMLElement>('.ogx__virtual-container [role="row"]')]
175
+ .find(r => r.querySelector('[data-field="id"]')?.textContent === '41');
176
+ await expect.poll(() => Boolean(rowEl())).toBe(true);
177
+ const vp = viewport.getBoundingClientRect();
178
+ const header = container.querySelector<HTMLElement>('.ogx__header-wrap')!.getBoundingClientRect();
179
+ const r = rowEl()!.getBoundingClientRect();
180
+ expect(r.top).toBeGreaterThanOrEqual(header.bottom - 1);
181
+ expect(r.bottom).toBeLessThanOrEqual(vp.top + viewport.clientHeight + 1);
182
+ });
183
+ });
@@ -1,9 +1,15 @@
1
+ import type React from 'react';
2
+
1
3
  interface GridEmptyStateProps {
2
4
  noRowsLabel: string;
3
5
  width: number;
6
+ overlay?: React.ReactNode;
4
7
  }
5
8
 
6
- export function GridEmptyState({ noRowsLabel, width }: GridEmptyStateProps) {
9
+ export function GridEmptyState({ noRowsLabel, width, overlay }: GridEmptyStateProps) {
10
+ if (overlay) {
11
+ return <div className="ogx__empty" style={{ width }}>{overlay}</div>;
12
+ }
7
13
  return (
8
14
  <div className="ogx__empty" style={{ width }}>
9
15
  <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -30,6 +30,7 @@ export interface GridListViewProps<R extends GridRowModel> {
30
30
  paginationSlotProps?: Record<string, unknown>;
31
31
  localeText?: Pick<GridLocaleText, 'paginationRowsPerPage' | 'paginationOf' | 'paginationPage'>;
32
32
  onRowClick: (params: GridRowParams<R>) => void;
33
+ onRowDoubleClick?: (params: GridRowParams<R>) => void;
33
34
  onSelectionChange: (rowId: GridRowId, isSelected: boolean) => void;
34
35
  onPaginationModelChange: (model: GridPaginationModel) => void;
35
36
  }
@@ -53,6 +54,7 @@ export function GridListView<R extends GridRowModel>({
53
54
  paginationSlotProps,
54
55
  localeText,
55
56
  onRowClick,
57
+ onRowDoubleClick,
56
58
  onSelectionChange,
57
59
  onPaginationModelChange,
58
60
  }: GridListViewProps<R>) {
@@ -97,6 +99,7 @@ export function GridListView<R extends GridRowModel>({
97
99
  checkboxSelection={checkboxSelection}
98
100
  rowHeight={rowHeight}
99
101
  onRowClick={(r) => onRowClick({ row: r, id: r.id, rowIndex: idx })}
102
+ onRowDoubleClick={onRowDoubleClick ? (r) => onRowDoubleClick({ row: r, id: r.id, rowIndex: idx }) : undefined}
100
103
  onSelectionChange={onSelectionChange}
101
104
  />
102
105
  ))
@@ -21,6 +21,7 @@ export interface GridPinnedRowsProps<R extends GridRowModel> {
21
21
  selectedRowIds: Set<GridRowId>;
22
22
  checkboxSelection: boolean;
23
23
  onRowClick: (params: GridRowParams<R>) => void;
24
+ onRowDoubleClick?: (params: GridRowParams<R>) => void;
24
25
  onCellClick: (params: GridCellParams<R>) => void;
25
26
  onSelectionChange: (rowId: GridRowId, isSelected: boolean) => void;
26
27
  columnWidths: Record<string, number>;
@@ -47,6 +48,7 @@ export function GridPinnedRows<R extends GridRowModel>({
47
48
  selectedRowIds,
48
49
  checkboxSelection,
49
50
  onRowClick,
51
+ onRowDoubleClick,
50
52
  onCellClick,
51
53
  onSelectionChange,
52
54
  columnWidths,
@@ -78,6 +80,7 @@ export function GridPinnedRows<R extends GridRowModel>({
78
80
  isSelected={selectedRowIds.has(row.id)}
79
81
  checkboxSelection={checkboxSelection}
80
82
  onRowClick={onRowClick}
83
+ onRowDoubleClick={onRowDoubleClick}
81
84
  onCellClick={onCellClick}
82
85
  onSelectionChange={onSelectionChange}
83
86
  columnWidths={columnWidths}
@@ -41,6 +41,7 @@ export interface GridVirtualRowsProps<R extends GridRowModel> {
41
41
  pinnedRows?: GridRowPinning;
42
42
  selectedRowIds: Set<GridRowId>;
43
43
  onRowClick: (params: GridRowParams<R>) => void;
44
+ onRowDoubleClick?: (params: GridRowParams<R>) => void;
44
45
  onCellClick: (params: GridCellParams<R>) => void;
45
46
  onSelectionChange: (rowId: GridRowId, isSelected: boolean) => void;
46
47
  columnWidths: Record<string, number>;
@@ -62,6 +63,7 @@ export interface GridVirtualRowsProps<R extends GridRowModel> {
62
63
  infiniteScrollSkeletonCount: number;
63
64
  unpinnedRowsLength: number;
64
65
  rowMetaMap: Map<GridRowId, GridRowMeta>;
66
+ loadingOverlay?: React.ReactNode;
65
67
  }
66
68
 
67
69
  export function GridVirtualRows<R extends GridRowModel>({
@@ -79,6 +81,7 @@ export function GridVirtualRows<R extends GridRowModel>({
79
81
  pinnedRows,
80
82
  selectedRowIds,
81
83
  onRowClick,
84
+ onRowDoubleClick,
82
85
  onCellClick,
83
86
  onSelectionChange,
84
87
  columnWidths,
@@ -100,6 +103,7 @@ export function GridVirtualRows<R extends GridRowModel>({
100
103
  infiniteScrollSkeletonCount,
101
104
  unpinnedRowsLength,
102
105
  rowMetaMap,
106
+ loadingOverlay,
103
107
  }: GridVirtualRowsProps<R>) {
104
108
  const skeletonColumns: GridColDef<R>[] = baseColumns.length > 0
105
109
  ? baseColumns
@@ -134,7 +138,9 @@ export function GridVirtualRows<R extends GridRowModel>({
134
138
  style={{ transform: `translateY(${offsetTop}px)` }}
135
139
  role="rowgroup"
136
140
  >
137
- {effectiveLoading && visibleRows.length === 0 ? (
141
+ {effectiveLoading && visibleRows.length === 0 && loadingOverlay ? (
142
+ <div className="ogx__loading-overlay" style={{ width: viewportWidth || undefined }}>{loadingOverlay}</div>
143
+ ) : effectiveLoading && visibleRows.length === 0 ? (
138
144
  Array.from({ length: 10 }).map((_, index) => (
139
145
  <SkeletonRow
140
146
  key={`skeleton-${index}`}
@@ -155,6 +161,7 @@ export function GridVirtualRows<R extends GridRowModel>({
155
161
  isSelected={selectedRowIds.has(row.id)}
156
162
  checkboxSelection={checkboxSelection}
157
163
  onRowClick={onRowClick}
164
+ onRowDoubleClick={onRowDoubleClick}
158
165
  onCellClick={onCellClick}
159
166
  onSelectionChange={onSelectionChange}
160
167
  columnWidths={columnWidths}
@@ -9,6 +9,7 @@ export interface ListViewRowProps<R extends GridRowModel = GridRowModel> {
9
9
  checkboxSelection?: boolean;
10
10
  rowHeight?: number;
11
11
  onRowClick?: (row: R) => void;
12
+ onRowDoubleClick?: (row: R) => void;
12
13
  onSelectionChange?: (rowId: GridRowId, isSelected: boolean) => void;
13
14
  }
14
15
 
@@ -20,6 +21,7 @@ export function ListViewRow<R extends GridRowModel = GridRowModel>({
20
21
  checkboxSelection = false,
21
22
  rowHeight,
22
23
  onRowClick,
24
+ onRowDoubleClick,
23
25
  onSelectionChange,
24
26
  }: ListViewRowProps<R>) {
25
27
  const params: GridRenderCellParams<R> = {
@@ -41,6 +43,7 @@ export function ListViewRow<R extends GridRowModel = GridRowModel>({
41
43
  className={`ogx-list-view__row${isSelected ? ' ogx-list-view__row--selected' : ''}`}
42
44
  style={rowHeight ? { minHeight: rowHeight } : undefined}
43
45
  onClick={() => onRowClick?.(row)}
46
+ onDoubleClick={onRowDoubleClick ? () => onRowDoubleClick(row) : undefined}
44
47
  role="row"
45
48
  aria-rowindex={rowIndex + 2}
46
49
  aria-selected={isSelected}
@@ -17,6 +17,7 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
17
17
  isSelected?: boolean;
18
18
  checkboxSelection?: boolean;
19
19
  onRowClick?: (params: GridRowParams<R>) => void;
20
+ onRowDoubleClick?: (params: GridRowParams<R>) => void;
20
21
  onCellClick?: (params: GridCellParams<R>) => void;
21
22
  onSelectionChange?: (rowId: GridRowId, isSelected: boolean) => void;
22
23
  columnWidths?: Record<string, number>;
@@ -62,6 +63,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
62
63
  isSelected = false,
63
64
  checkboxSelection = false,
64
65
  onRowClick,
66
+ onRowDoubleClick,
65
67
  onCellClick,
66
68
  onSelectionChange,
67
69
  columnWidths = {},
@@ -167,6 +169,13 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
167
169
  onRowClick?.({ row, id: row.id, rowIndex });
168
170
  };
169
171
 
172
+ const handleRowDoubleClick = (event: React.MouseEvent) => {
173
+ if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell')) {
174
+ return;
175
+ }
176
+ onRowDoubleClick?.({ row, id: row.id, rowIndex });
177
+ };
178
+
170
179
  const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
171
180
  event.stopPropagation();
172
181
  onSelectionChange?.(row.id, event.target.checked);
@@ -198,6 +207,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
198
207
  <div
199
208
  className={classNames}
200
209
  onClick={handleRowClick}
210
+ onDoubleClick={onRowDoubleClick ? handleRowDoubleClick : undefined}
201
211
  role="row"
202
212
  aria-rowindex={rowIndex + 1}
203
213
  aria-selected={isSelected}
@@ -14,6 +14,11 @@ interface HierarchyHandlers {
14
14
  toggleExpansion: (id: GridRowId) => void;
15
15
  }
16
16
 
17
+ // Injected hierarchy renderers replace the plain-cell path in Cell, so they must
18
+ // render the formatted value themselves or valueFormatter is silently lost.
19
+ const displayValue = <R extends GridRowModel>(cellParams: GridRenderCellParams<R>): React.ReactNode =>
20
+ cellParams.formattedValue ?? (cellParams.value as React.ReactNode);
21
+
17
22
  export interface UseGridColumnsParams<R extends GridRowModel> {
18
23
  activeColumns: GridColDef<R>[];
19
24
  isHierarchyEnabled: boolean;
@@ -83,7 +88,7 @@ export function useGridColumns<R extends GridRowModel>(
83
88
  const descendantCount = meta?.descendantCount;
84
89
  const isGroupRow = Boolean(meta?.isGroupRow);
85
90
 
86
- let content: React.ReactNode = col.renderCell ? col.renderCell(cellParams) : cellParams.value as React.ReactNode;
91
+ let content: React.ReactNode = col.renderCell ? col.renderCell(cellParams) : displayValue(cellParams);
87
92
 
88
93
  if (isTreeData && hasChildren && isGroupRow) {
89
94
  content = (
@@ -137,12 +142,12 @@ export function useGridColumns<R extends GridRowModel>(
137
142
  if (isRowGrouping && hasChildren) {
138
143
  if (col.field === groupingField) return null;
139
144
  if (cellParams.value !== undefined && cellParams.value !== null) {
140
- return col.renderCell ? col.renderCell(cellParams) : cellParams.value;
145
+ return col.renderCell ? col.renderCell(cellParams) : displayValue(cellParams);
141
146
  }
142
147
  return null;
143
148
  }
144
149
 
145
- return col.renderCell ? col.renderCell(cellParams) : cellParams.value;
150
+ return col.renderCell ? col.renderCell(cellParams) : displayValue(cellParams);
146
151
  }
147
152
  };
148
153
  }) as GridColDef<R>[];
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ export interface UseGridDevWarningsParams {
4
+ paginationRequested: boolean;
5
+ isRowGrouping: boolean;
6
+ autoHeight: boolean;
7
+ viewportHeight: number;
8
+ renderedRowCount: number;
9
+ totalRowCount: number;
10
+ }
11
+
12
+ // Below this many rows, rendering everything is harmless, so an unbounded container is not worth a warning.
13
+ const UNBOUNDED_WARN_THRESHOLD = 200;
14
+
15
+ export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
16
+ const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount } = params;
17
+ const warnedPaginationRef = useRef(false);
18
+ const warnedUnboundedRef = useRef(false);
19
+
20
+ useEffect(() => {
21
+ if (process.env.NODE_ENV === 'production' || warnedPaginationRef.current) return;
22
+ if (paginationRequested && isRowGrouping) {
23
+ warnedPaginationRef.current = true;
24
+ console.warn(
25
+ '[OpenGridX] `pagination` is ignored while `rowGroupingModel` is active — all group rows render in one ' +
26
+ 'scrollable view. Give the grid a bounded height so it virtualizes. See docs/features/tree-data-grouping.md.'
27
+ );
28
+ }
29
+ }, [paginationRequested, isRowGrouping]);
30
+
31
+ useEffect(() => {
32
+ if (process.env.NODE_ENV === 'production' || warnedUnboundedRef.current || autoHeight) return;
33
+ if (viewportHeight > 0 && totalRowCount > UNBOUNDED_WARN_THRESHOLD && renderedRowCount >= totalRowCount) {
34
+ warnedUnboundedRef.current = true;
35
+ console.warn(
36
+ `[OpenGridX] The grid viewport is ${Math.round(viewportHeight)}px tall and is rendering all ${totalRowCount} rows, ` +
37
+ 'so row virtualization is effectively off. The grid container has no bounded height: it grows to fit its ' +
38
+ 'content. Give it a fixed height, or if it is a flex child add `min-height: 0`. See docs/features/virtualization.md.'
39
+ );
40
+ }
41
+ }, [autoHeight, viewportHeight, renderedRowCount, totalRowCount]);
42
+ }