@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.
- package/CHANGELOG.md +45 -3
- package/dist/index.d.ts +43 -2
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +3927 -3839
- 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 +13 -4
- package/docs/components/column-visibility.md +40 -16
- 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 +36 -8
- 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 +42 -10
- 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/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 +15 -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,
|
|
@@ -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
|
-
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
+
}
|
|
@@ -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,
|
|
315
|
-
const {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
47
|
-
const
|
|
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)
|