@opencorestack/opengridx 2.0.4 → 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 (44) hide show
  1. package/CHANGELOG.md +45 -3
  2. package/dist/index.d.ts +43 -2
  3. package/dist/opengridx.css +1 -1
  4. package/dist/opengridx.es.js +3927 -3839
  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 +13 -4
  9. package/docs/components/column-visibility.md +40 -16
  10. package/docs/customization/slots-api.md +18 -6
  11. package/docs/features/export-guide.md +22 -3
  12. package/docs/features/sorting-pagination.md +2 -0
  13. package/docs/features/tree-data-grouping.md +8 -1
  14. package/docs/features/virtualization.md +36 -8
  15. package/docs/roadmap.md +13 -7
  16. package/lib/components/Cell/Cell.tsx +1 -0
  17. package/lib/components/DataGrid/DataGrid.css +7 -0
  18. package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
  19. package/lib/components/DataGrid/DataGrid.tsx +42 -10
  20. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
  21. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  22. package/lib/components/DataGrid/GridListView.tsx +3 -0
  23. package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
  24. package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
  25. package/lib/components/ListView/ListViewRow.tsx +3 -0
  26. package/lib/components/Row/Row.tsx +10 -0
  27. package/lib/hooks/core/useGridColumns.tsx +8 -3
  28. package/lib/hooks/core/useGridDevWarnings.ts +42 -0
  29. package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
  30. package/lib/hooks/core/useGridVisibleRows.ts +3 -2
  31. package/lib/hooks/features/useAggregation.ts +3 -38
  32. package/lib/hooks/useRowGrouping.test.ts +162 -1
  33. package/lib/hooks/useRowGrouping.ts +42 -59
  34. package/lib/hooks/useTreeData.test.ts +83 -0
  35. package/lib/hooks/useTreeData.ts +44 -43
  36. package/lib/index.ts +2 -0
  37. package/lib/types/index.ts +15 -2
  38. package/lib/utils/aggregation/aggregation.test.ts +16 -0
  39. package/lib/utils/aggregation/index.ts +62 -0
  40. package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
  41. package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
  42. package/lib/utils/scroll/index.ts +28 -0
  43. package/llms.txt +22 -13
  44. 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,
@@ -652,14 +655,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
652
655
  if (!el) return;
653
656
 
654
657
  if (rowIndex !== undefined && rowIndex >= 0) {
655
- const rowTop = rowIndex > 0 ? layout.cumulativeHeights[rowIndex - 1] : 0;
656
- const rowBottom = layout.cumulativeHeights[rowIndex] ?? rowTop;
657
- const { scrollTop, clientHeight } = el;
658
- if (rowTop < scrollTop) {
659
- el.scrollTop = rowTop;
660
- } else if (rowBottom > scrollTop + clientHeight) {
661
- el.scrollTop = rowBottom - clientHeight;
662
- }
658
+ scrollRowIntoView(el, rowIndex, layout.cumulativeHeights, layout.pinnedBottomHeight);
663
659
  }
664
660
 
665
661
  if (colIndex !== undefined && colIndex >= 0) {
@@ -779,6 +775,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
779
775
  pageSize: effectivePaginationModel.pageSize,
780
776
  virtualization,
781
777
  viewportRef,
778
+ pinnedTopRowCount: pinnedTopRows.length,
782
779
  });
783
780
 
784
781
  const handleCellClick = useCallback((params: GridCellParams<R>) => {
@@ -811,6 +808,14 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
811
808
  pagination,
812
809
  });
813
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
+ });
814
819
 
815
820
  const allSelected = rows.length > 0 && selectedRowIds.size === rows.length;
816
821
  const someSelected = selectedRowIds.size > 0 && selectedRowIds.size < rows.length;
@@ -820,6 +825,10 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
820
825
  const containerRef = React.useRef<HTMLDivElement>(null);
821
826
  const standalonePanelRef = React.useRef<HTMLDivElement>(null);
822
827
 
828
+ const NoRowsOverlaySlot = slots?.noRowsOverlay;
829
+ const LoadingOverlaySlot = slots?.loadingOverlay;
830
+ const FooterSlot = slots?.footer;
831
+
823
832
  const toolbarProps = React.useMemo(() => {
824
833
  if (!slots?.toolbar) return null;
825
834
  const reorderHandler = disableColumnReorder
@@ -932,6 +941,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
932
941
  paginationPage: localeText.paginationPage,
933
942
  } : undefined}
934
943
  onRowClick={handleRowClick}
944
+ onRowDoubleClick={onRowDoubleClick}
935
945
  onSelectionChange={handleSelectionChange}
936
946
  onPaginationModelChange={handlePaginationModelChange}
937
947
  />
@@ -1033,7 +1043,11 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1033
1043
 
1034
1044
  {/* Empty State Overlay (Standard View) — showing after header */}
1035
1045
  {!effectiveLoading && !state.dataSource.error && filteredRows.length === 0 && (
1036
- <GridEmptyState noRowsLabel={effectiveNoRowsLabel} width={virtualization.totalWidth} />
1046
+ <GridEmptyState
1047
+ noRowsLabel={effectiveNoRowsLabel}
1048
+ width={virtualization.totalWidth}
1049
+ overlay={NoRowsOverlaySlot ? <NoRowsOverlaySlot {...slotProps?.noRowsOverlay} /> : undefined}
1050
+ />
1037
1051
  )}
1038
1052
 
1039
1053
  <GridPinnedRows<R>
@@ -1043,6 +1057,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1043
1057
  selectedRowIds={selectedRowIds}
1044
1058
  checkboxSelection={checkboxSelection}
1045
1059
  onRowClick={handleRowClick}
1060
+ onRowDoubleClick={onRowDoubleClick}
1046
1061
  onCellClick={handleCellClick}
1047
1062
  onSelectionChange={handleSelectionChange}
1048
1063
  columnWidths={columnWidths}
@@ -1077,6 +1092,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1077
1092
  pinnedRows={pinnedRows}
1078
1093
  selectedRowIds={selectedRowIds}
1079
1094
  onRowClick={handleRowClick}
1095
+ onRowDoubleClick={onRowDoubleClick}
1080
1096
  onCellClick={handleCellClick}
1081
1097
  onSelectionChange={handleSelectionChange}
1082
1098
  columnWidths={columnWidths}
@@ -1098,6 +1114,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1098
1114
  infiniteScrollSkeletonCount={Math.min(effectivePaginationModel.pageSize, 20)}
1099
1115
  unpinnedRowsLength={layout.unpinnedRowsLength}
1100
1116
  rowMetaMap={rowMetaMap}
1117
+ loadingOverlay={LoadingOverlaySlot ? <LoadingOverlaySlot {...slotProps?.loadingOverlay} /> : undefined}
1101
1118
  />
1102
1119
 
1103
1120
  <GridPinnedRows<R>
@@ -1107,6 +1124,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1107
1124
  selectedRowIds={selectedRowIds}
1108
1125
  checkboxSelection={checkboxSelection}
1109
1126
  onRowClick={handleRowClick}
1127
+ onRowDoubleClick={onRowDoubleClick}
1110
1128
  onCellClick={handleCellClick}
1111
1129
  onSelectionChange={handleSelectionChange}
1112
1130
  columnWidths={columnWidths}
@@ -1147,7 +1165,21 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
1147
1165
  </div>
1148
1166
  )}
1149
1167
 
1150
- {!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 && (() => {
1151
1183
  const PaginationComponent = slots?.pagination || Pagination;
1152
1184
  return (
1153
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
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState, useCallback } from 'react';
2
+ import { scrollRowIntoView } from '../../utils/scroll';
2
3
  import type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection } from '../../types';
3
4
  import type { GridEditingState } from '../features/useGridEditing';
4
5
 
@@ -14,6 +15,7 @@ interface ColumnMetrics {
14
15
  interface VirtualizationSnapshot {
15
16
  cumulativeHeights: number[];
16
17
  pinnedTopHeight: number;
18
+ pinnedBottomHeight: number;
17
19
  columnMetrics: ColumnMetrics | null;
18
20
  }
19
21
 
@@ -37,6 +39,8 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
37
39
  pageSize: number;
38
40
  virtualization: VirtualizationSnapshot;
39
41
  viewportRef: React.RefObject<HTMLDivElement | null>;
42
+ /** Number of top-pinned rows at the start of allRenderableRows. */
43
+ pinnedTopRowCount?: number;
40
44
  }
41
45
 
42
46
  export interface FocusedCell {
@@ -71,6 +75,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
71
75
  pageSize,
72
76
  virtualization,
73
77
  viewportRef,
78
+ pinnedTopRowCount = 0,
74
79
  } = params;
75
80
 
76
81
  const [focusedCell, setFocusedCell] = useState<FocusedCell | null>(null);
@@ -311,21 +316,13 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
311
316
 
312
317
  const el = viewportRef.current;
313
318
  if (el) {
314
- const { cumulativeHeights, pinnedTopHeight, columnMetrics } = virtualization;
315
- const { clientHeight, clientWidth, scrollTop, scrollLeft } = el;
316
-
317
- const rowTop = nextRowIndex <= 0 ? 0 : cumulativeHeights[nextRowIndex - 1];
318
- const rowBottom = cumulativeHeights[nextRowIndex];
319
- const visibleTop = scrollTop + pinnedTopHeight;
320
- const visibleBottom = scrollTop + clientHeight;
321
-
322
- let newScrollTop = scrollTop;
323
- if (rowTop < visibleTop) {
324
- newScrollTop = Math.max(0, rowTop - pinnedTopHeight);
325
- } else if (rowBottom > visibleBottom) {
326
- newScrollTop = rowBottom - clientHeight;
319
+ const { cumulativeHeights, pinnedBottomHeight, columnMetrics } = virtualization;
320
+ const { clientWidth, scrollLeft } = el;
321
+
322
+ // allRenderableRows = pinned top + center + pinned bottom; pinned rows are sticky and always visible.
323
+ if (nextRowIndex >= 0) {
324
+ scrollRowIntoView(el, nextRowIndex - pinnedTopRowCount, cumulativeHeights, pinnedBottomHeight);
327
325
  }
328
- if (newScrollTop !== scrollTop) el.scrollTop = newScrollTop;
329
326
 
330
327
  if (columnMetrics) {
331
328
  const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = columnMetrics;
@@ -365,6 +362,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
365
362
  pagination,
366
363
  pageSize,
367
364
  viewportRef,
365
+ pinnedTopRowCount,
368
366
  ]);
369
367
 
370
368
  return { focusedCell, setFocusedCell, handleFocus, handleBlur, handleKeyDown };
@@ -43,8 +43,9 @@ export function useGridVisibleRows<R extends GridRowModel>(
43
43
  rowIndex: topPinnedCount + centerRows.length + index,
44
44
  }));
45
45
 
46
- const centerStartIndex = Math.max(0, firstRowIndex - topPinnedCount);
47
- const centerEndIndex = Math.min(centerRows.length, lastRowIndex - topPinnedCount + 1);
46
+ // renderContext indices come from the unpinned-row layout, so they index centerRows directly.
47
+ const centerStartIndex = Math.max(0, firstRowIndex);
48
+ const centerEndIndex = Math.min(centerRows.length, lastRowIndex + 1);
48
49
 
49
50
  const centerVisible = centerRows
50
51
  .slice(centerStartIndex, centerEndIndex)