@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.
- package/CHANGELOG.md +54 -3
- package/dist/index.d.ts +50 -2
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +3805 -3692
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +14 -4
- package/docs/components/column-visibility.md +40 -16
- package/docs/components/datagrid.md +1 -0
- package/docs/customization/slots-api.md +18 -6
- package/docs/features/export-guide.md +22 -3
- package/docs/features/sorting-pagination.md +2 -0
- package/docs/features/tree-data-grouping.md +8 -1
- package/docs/features/virtualization.md +66 -17
- package/docs/roadmap.md +13 -7
- package/lib/components/Cell/Cell.tsx +1 -0
- package/lib/components/DataGrid/DataGrid.css +7 -0
- package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
- package/lib/components/DataGrid/DataGrid.tsx +45 -11
- package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
- package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
- package/lib/components/DataGrid/GridListView.tsx +3 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
- package/lib/components/ListView/ListViewRow.tsx +3 -0
- package/lib/components/Row/Row.tsx +10 -0
- package/lib/hooks/core/useGridColumns.tsx +8 -3
- package/lib/hooks/core/useGridDevWarnings.ts +42 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
- package/lib/hooks/core/useGridScrollSync.ts +62 -15
- package/lib/hooks/core/useGridVirtualization.ts +4 -1
- package/lib/hooks/core/useGridVisibleRows.ts +3 -2
- package/lib/hooks/features/useAggregation.ts +3 -38
- package/lib/hooks/useRowGrouping.test.ts +162 -1
- package/lib/hooks/useRowGrouping.ts +42 -59
- package/lib/hooks/useTreeData.test.ts +83 -0
- package/lib/hooks/useTreeData.ts +44 -43
- package/lib/index.ts +2 -0
- package/lib/types/index.ts +22 -2
- package/lib/utils/aggregation/aggregation.test.ts +16 -0
- package/lib/utils/aggregation/index.ts +62 -0
- package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
- package/lib/utils/scroll/index.ts +28 -0
- package/llms.txt +22 -13
- 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
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|