@opencorestack/opengridx 0.1.8 → 0.1.9
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 +16 -0
- package/dist/opengridx.es.js +3485 -3370
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +11 -11
- package/dist/opengridx.umd.js.map +1 -1
- package/lib/components/Cell/Cell.tsx +53 -55
- package/lib/components/DataGrid/DataGrid.smoke.test.tsx +58 -0
- package/lib/components/DataGrid/DataGrid.tsx +65 -641
- package/lib/components/Row/Row.tsx +39 -38
- package/lib/components/Toolbar/GlobalSearch.tsx +1 -1
- package/lib/components/Toolbar/GridToolbar.tsx +20 -5
- package/lib/components/Toolbar/PivotPanel.tsx +7 -2
- package/lib/components/ui/DragHandleIcon.tsx +1 -1
- package/lib/hooks/core/useDataGrid.ts +2 -2
- package/lib/hooks/core/useGridKeyboardNavigation.ts +371 -0
- package/lib/hooks/core/useLayout.ts +354 -0
- package/lib/hooks/features/useAggregation.test.ts +135 -0
- package/lib/hooks/features/useGridClipboard.ts +1 -1
- package/lib/hooks/features/useGridDataSource.ts +1 -1
- package/lib/state/useGridStateStorage.ts +1 -1
- package/lib/utils/export/index.ts +1 -1
- package/lib/utils/filtering/filtering.test.ts +178 -0
- package/lib/utils/sorting/sorting.test.ts +88 -0
- package/package.json +12 -5
- package/lib/utils/virtualization/index.ts +0 -106
|
@@ -97,6 +97,45 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
97
97
|
const expandCellRef = React.useRef<HTMLDivElement>(null);
|
|
98
98
|
const checkboxCellRef = React.useRef<HTMLDivElement>(null);
|
|
99
99
|
|
|
100
|
+
// All hooks must come before any early returns (Rules of Hooks)
|
|
101
|
+
React.useLayoutEffect(() => {
|
|
102
|
+
if (focusedCellField === '__expand_col__' && expandCellRef.current) {
|
|
103
|
+
expandCellRef.current.focus({ preventScroll: true });
|
|
104
|
+
}
|
|
105
|
+
if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
|
|
106
|
+
checkboxCellRef.current.focus({ preventScroll: true });
|
|
107
|
+
}
|
|
108
|
+
}, [focusedCellField]);
|
|
109
|
+
|
|
110
|
+
const pinnedPositions = React.useMemo(() => {
|
|
111
|
+
return calculatePinnedPositions(
|
|
112
|
+
columns,
|
|
113
|
+
columnWidths,
|
|
114
|
+
pinnedColumns,
|
|
115
|
+
checkboxSelection,
|
|
116
|
+
pinCheckboxColumn,
|
|
117
|
+
hasDetailPanel,
|
|
118
|
+
pinExpandColumn,
|
|
119
|
+
rowReordering
|
|
120
|
+
);
|
|
121
|
+
}, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
|
|
122
|
+
|
|
123
|
+
const isGroupRow = (row as any)._hasChildren === true;
|
|
124
|
+
|
|
125
|
+
const handleCellEditStart = React.useCallback((field: string, value: any) => {
|
|
126
|
+
if (!isGroupRow) {
|
|
127
|
+
onEditStart?.({ id: row.id, field, value });
|
|
128
|
+
}
|
|
129
|
+
}, [isGroupRow, row.id, onEditStart]);
|
|
130
|
+
|
|
131
|
+
const handleCellValueChange = React.useCallback((field: string, newValue: any) => {
|
|
132
|
+
onEditCellValueChange?.({ id: row.id, field, value: newValue });
|
|
133
|
+
}, [onEditCellValueChange, row.id]);
|
|
134
|
+
|
|
135
|
+
const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
|
|
136
|
+
onEditStop?.({ cancel });
|
|
137
|
+
}, [onEditStop]);
|
|
138
|
+
|
|
100
139
|
// ── Skeleton row (shown during infinite-scroll fetch) ─────────────────────
|
|
101
140
|
if ((row as any)._isSkeleton) {
|
|
102
141
|
// Render shimmer placeholders — one per visible column, matching row height
|
|
@@ -117,15 +156,6 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
117
156
|
);
|
|
118
157
|
}
|
|
119
158
|
|
|
120
|
-
React.useLayoutEffect(() => {
|
|
121
|
-
if (focusedCellField === '__expand_col__' && expandCellRef.current) {
|
|
122
|
-
expandCellRef.current.focus({ preventScroll: true });
|
|
123
|
-
}
|
|
124
|
-
if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
|
|
125
|
-
checkboxCellRef.current.focus({ preventScroll: true });
|
|
126
|
-
}
|
|
127
|
-
}, [focusedCellField]);
|
|
128
|
-
|
|
129
159
|
const handleRowClick = (event: React.MouseEvent) => {
|
|
130
160
|
|
|
131
161
|
if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell')) {
|
|
@@ -144,39 +174,10 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
144
174
|
onDetailPanelToggle?.(row.id);
|
|
145
175
|
};
|
|
146
176
|
|
|
147
|
-
const pinnedPositions = React.useMemo(() => {
|
|
148
|
-
return calculatePinnedPositions(
|
|
149
|
-
columns,
|
|
150
|
-
columnWidths,
|
|
151
|
-
pinnedColumns,
|
|
152
|
-
checkboxSelection,
|
|
153
|
-
pinCheckboxColumn,
|
|
154
|
-
hasDetailPanel,
|
|
155
|
-
pinExpandColumn,
|
|
156
|
-
rowReordering
|
|
157
|
-
);
|
|
158
|
-
}, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
|
|
159
|
-
|
|
160
177
|
const rowPinnedPosition = isRowPinned(row.id, pinnedRows);
|
|
161
178
|
const isRowPinnedTop = rowPinnedPosition === 'top';
|
|
162
179
|
const isRowPinnedBottom = rowPinnedPosition === 'bottom';
|
|
163
180
|
|
|
164
|
-
const isGroupRow = (row as any)._hasChildren === true;
|
|
165
|
-
|
|
166
|
-
const handleCellEditStart = React.useCallback((field: string, value: any) => {
|
|
167
|
-
if (!isGroupRow) {
|
|
168
|
-
onEditStart?.({ id: row.id, field, value });
|
|
169
|
-
}
|
|
170
|
-
}, [isGroupRow, row.id, onEditStart]);
|
|
171
|
-
|
|
172
|
-
const handleCellValueChange = React.useCallback((field: string, newValue: any) => {
|
|
173
|
-
onEditCellValueChange?.({ id: row.id, field, value: newValue });
|
|
174
|
-
}, [onEditCellValueChange, row.id]);
|
|
175
|
-
|
|
176
|
-
const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
|
|
177
|
-
onEditStop?.({ cancel });
|
|
178
|
-
}, [onEditStop]);
|
|
179
|
-
|
|
180
181
|
const classNames = [
|
|
181
182
|
'ogx__row',
|
|
182
183
|
isSelected && 'ogx__row--selected',
|
|
@@ -34,7 +34,7 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
34
34
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
35
35
|
// Keep onChange always current without adding it to effect deps
|
|
36
36
|
const onChangeRef = useRef(onChange);
|
|
37
|
-
onChangeRef.current = onChange;
|
|
37
|
+
useLayoutEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
|
38
38
|
// Track the last value we emitted upward
|
|
39
39
|
const lastEmittedRef = useRef(value);
|
|
40
40
|
// Track the last incoming prop value to detect external resets
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
3
|
import type { GridColDef, GridAggregationModel, GridPivotModel, GridFilterModel } from '../../types';
|
|
4
4
|
import { PivotPanel, PivotIcon } from './PivotPanel';
|
|
@@ -87,6 +87,11 @@ function AggregationPanel({
|
|
|
87
87
|
}) {
|
|
88
88
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
89
89
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
90
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
91
|
+
|
|
92
|
+
useLayoutEffect(() => {
|
|
93
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
94
|
+
}, [anchorRef]);
|
|
90
95
|
|
|
91
96
|
const computePos = useCallback((): PanelPosition => {
|
|
92
97
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -185,7 +190,7 @@ function AggregationPanel({
|
|
|
185
190
|
</div>
|
|
186
191
|
);
|
|
187
192
|
|
|
188
|
-
return ReactDOM.createPortal(panel,
|
|
193
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
189
194
|
}
|
|
190
195
|
|
|
191
196
|
function ColumnsPanelWrapper({
|
|
@@ -209,6 +214,11 @@ function ColumnsPanelWrapper({
|
|
|
209
214
|
}) {
|
|
210
215
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
211
216
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
217
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
218
|
+
|
|
219
|
+
useLayoutEffect(() => {
|
|
220
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
221
|
+
}, [anchorRef]);
|
|
212
222
|
|
|
213
223
|
const computePos = useCallback((): PanelPosition => {
|
|
214
224
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -262,7 +272,7 @@ function ColumnsPanelWrapper({
|
|
|
262
272
|
</div>
|
|
263
273
|
);
|
|
264
274
|
|
|
265
|
-
return ReactDOM.createPortal(panel,
|
|
275
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
266
276
|
}
|
|
267
277
|
|
|
268
278
|
function FilterPanelWrapper({
|
|
@@ -284,7 +294,12 @@ function FilterPanelWrapper({
|
|
|
284
294
|
// tearing down and re-registering on every filterModel update,
|
|
285
295
|
// which could fire the close callback during the re-register window.
|
|
286
296
|
const onCloseRef = useRef(onClose);
|
|
287
|
-
onCloseRef.current = onClose;
|
|
297
|
+
useLayoutEffect(() => { onCloseRef.current = onClose; }, [onClose]);
|
|
298
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
299
|
+
|
|
300
|
+
useLayoutEffect(() => {
|
|
301
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
302
|
+
}, [anchorRef]);
|
|
288
303
|
|
|
289
304
|
const computePos = useCallback((): PanelPosition => {
|
|
290
305
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -371,7 +386,7 @@ function FilterPanelWrapper({
|
|
|
371
386
|
</div>
|
|
372
387
|
);
|
|
373
388
|
|
|
374
|
-
return ReactDOM.createPortal(panel,
|
|
389
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
375
390
|
}
|
|
376
391
|
|
|
377
392
|
const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
3
|
import type { GridColDef } from '../../types';
|
|
4
4
|
import type { GridPivotModel, GridPivotAggFn, GridPivotValueField } from '../../types';
|
|
@@ -31,6 +31,11 @@ export interface PivotPanelProps {
|
|
|
31
31
|
export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: PivotPanelProps) {
|
|
32
32
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
33
33
|
const [pos, setPos] = useState({ top: 0, right: 0 });
|
|
34
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
35
|
+
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
38
|
+
}, [anchorRef]);
|
|
34
39
|
|
|
35
40
|
const computePos = useCallback(() => {
|
|
36
41
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -235,5 +240,5 @@ export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: Piv
|
|
|
235
240
|
</div>
|
|
236
241
|
);
|
|
237
242
|
|
|
238
|
-
return ReactDOM.createPortal(panel,
|
|
243
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
239
244
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export type DragHandleIconProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
4
4
|
|
|
5
5
|
export const DragHandleIcon: React.FC<DragHandleIconProps> = ({ className = '', ...props }) => {
|
|
6
6
|
return (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { useReducer, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
2
|
+
import { useReducer, useCallback, useMemo, useRef, useEffect, useLayoutEffect } from 'react';
|
|
3
3
|
import type {
|
|
4
4
|
GridState,
|
|
5
5
|
GridRowModel,
|
|
@@ -259,7 +259,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
259
259
|
});
|
|
260
260
|
|
|
261
261
|
const stateRef = useRef(state);
|
|
262
|
-
stateRef.current = state;
|
|
262
|
+
useLayoutEffect(() => { stateRef.current = state; });
|
|
263
263
|
|
|
264
264
|
// Sync internal state when the external `rows` prop changes
|
|
265
265
|
const prevRowsRef = useRef(rows);
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection } from '../../types';
|
|
3
|
+
import type { GridEditingState } from '../features/useGridEditing';
|
|
4
|
+
|
|
5
|
+
interface ColumnMetrics {
|
|
6
|
+
leftPinnedWidth: number;
|
|
7
|
+
rightPinnedWidth: number;
|
|
8
|
+
unpinnedAccWidths: number[];
|
|
9
|
+
unpinnedCols: { field: string }[];
|
|
10
|
+
totalSpecialsWidth: number;
|
|
11
|
+
pinnedSpecialsWidth: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface VirtualizationSnapshot {
|
|
15
|
+
cumulativeHeights: number[];
|
|
16
|
+
pinnedTopHeight: number;
|
|
17
|
+
columnMetrics: ColumnMetrics | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
|
|
21
|
+
allRenderableRows: R[];
|
|
22
|
+
navigationColumns: { field: string; editable?: boolean; sortable?: boolean }[];
|
|
23
|
+
checkboxSelection: boolean;
|
|
24
|
+
selectedRowIds: Set<GridRowId>;
|
|
25
|
+
handleSelectionChange: (id: GridRowId, selected: boolean) => void;
|
|
26
|
+
handleDetailPanelToggle: (id: GridRowId) => void;
|
|
27
|
+
editingHandlers: {
|
|
28
|
+
editingCell: GridEditingState['editingCell'];
|
|
29
|
+
startCellEdit: (params: { id: GridRowId; field: string; value: unknown }) => void;
|
|
30
|
+
stopCellEdit: (params?: { cancel?: boolean }) => void;
|
|
31
|
+
};
|
|
32
|
+
setKeyboardMode: (mode: boolean) => void;
|
|
33
|
+
sortModel: GridSortItem[];
|
|
34
|
+
handleSort: (field: string, direction: GridSortDirection) => void;
|
|
35
|
+
isCellEditable?: (params: GridCellParams<R>) => boolean;
|
|
36
|
+
pagination: boolean;
|
|
37
|
+
pageSize: number;
|
|
38
|
+
virtualization: VirtualizationSnapshot;
|
|
39
|
+
viewportRef: React.RefObject<HTMLDivElement | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FocusedCell {
|
|
43
|
+
id: GridRowId;
|
|
44
|
+
field: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UseGridKeyboardNavigationReturn {
|
|
48
|
+
focusedCell: FocusedCell | null;
|
|
49
|
+
setFocusedCell: React.Dispatch<React.SetStateAction<FocusedCell | null>>;
|
|
50
|
+
handleFocus: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
51
|
+
handleBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
52
|
+
handleKeyDown: (event: React.KeyboardEvent) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
56
|
+
params: UseGridKeyboardNavigationParams<R>
|
|
57
|
+
): UseGridKeyboardNavigationReturn {
|
|
58
|
+
const {
|
|
59
|
+
allRenderableRows,
|
|
60
|
+
navigationColumns,
|
|
61
|
+
checkboxSelection,
|
|
62
|
+
selectedRowIds,
|
|
63
|
+
handleSelectionChange,
|
|
64
|
+
handleDetailPanelToggle,
|
|
65
|
+
editingHandlers,
|
|
66
|
+
setKeyboardMode,
|
|
67
|
+
sortModel,
|
|
68
|
+
handleSort,
|
|
69
|
+
isCellEditable,
|
|
70
|
+
pagination,
|
|
71
|
+
pageSize,
|
|
72
|
+
virtualization,
|
|
73
|
+
viewportRef,
|
|
74
|
+
} = params;
|
|
75
|
+
|
|
76
|
+
const [focusedCell, setFocusedCell] = useState<FocusedCell | null>(null);
|
|
77
|
+
|
|
78
|
+
const handleFocus = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
|
79
|
+
if (event.target === event.currentTarget) {
|
|
80
|
+
setFocusedCell(prev => {
|
|
81
|
+
if (prev) return prev;
|
|
82
|
+
if (checkboxSelection) {
|
|
83
|
+
return { id: 'HEADER', field: '__checkbox_col__' };
|
|
84
|
+
}
|
|
85
|
+
const firstRow = allRenderableRows[0];
|
|
86
|
+
const firstCol = navigationColumns[0];
|
|
87
|
+
if (firstRow && firstCol) {
|
|
88
|
+
return { id: firstRow.id, field: firstCol.field };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}, [allRenderableRows, navigationColumns, checkboxSelection]);
|
|
94
|
+
|
|
95
|
+
const handleBlur = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
|
96
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
|
|
97
|
+
setFocusedCell(null);
|
|
98
|
+
}
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
|
102
|
+
setKeyboardMode(true);
|
|
103
|
+
|
|
104
|
+
if (!focusedCell) return;
|
|
105
|
+
|
|
106
|
+
const { id, field } = focusedCell;
|
|
107
|
+
const isEditing = Boolean(editingHandlers.editingCell);
|
|
108
|
+
|
|
109
|
+
if (id === 'HEADER' && (event.key === 'Enter' || event.key === ' ')) {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
const col = navigationColumns.find(c => c.field === field);
|
|
112
|
+
if (col && col.sortable !== false) {
|
|
113
|
+
const currentSort = sortModel.find(item => item.field === field);
|
|
114
|
+
let newDirection: GridSortDirection = 'asc';
|
|
115
|
+
if (currentSort) {
|
|
116
|
+
newDirection = currentSort.sort === 'asc' ? 'desc' : null;
|
|
117
|
+
}
|
|
118
|
+
handleSort(field, newDirection);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (event.key === 'Enter') {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
if (isEditing) {
|
|
126
|
+
editingHandlers.stopCellEdit();
|
|
127
|
+
} else {
|
|
128
|
+
const col = navigationColumns.find(c => c.field === field);
|
|
129
|
+
if (col?.editable) {
|
|
130
|
+
const row = allRenderableRows.find(r => r.id === id);
|
|
131
|
+
if (row) {
|
|
132
|
+
editingHandlers.startCellEdit({ id, field, value: (row as R & Record<string, unknown>)[field] });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (event.key === ' ' || event.key === 'Spacebar') {
|
|
140
|
+
if (!isEditing) {
|
|
141
|
+
if (field === '__checkbox_col__') {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
handleSelectionChange(id, !selectedRowIds.has(id));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (field === '__expand_col__') {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
handleDetailPanelToggle(id);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (event.key === 'Escape') {
|
|
155
|
+
if (isEditing) {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
editingHandlers.stopCellEdit({ cancel: true });
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isEditing && event.key !== 'Tab') return;
|
|
163
|
+
|
|
164
|
+
const isHeader = id === 'HEADER';
|
|
165
|
+
const rowIndex = isHeader ? -1 : allRenderableRows.findIndex(r => r.id === id);
|
|
166
|
+
const colIndex = navigationColumns.findIndex(c => c.field === field);
|
|
167
|
+
|
|
168
|
+
if (!isHeader && rowIndex === -1) return;
|
|
169
|
+
if (colIndex === -1) return;
|
|
170
|
+
|
|
171
|
+
const findNextCell = (
|
|
172
|
+
startRow: number,
|
|
173
|
+
startCol: number,
|
|
174
|
+
deltaRow: number,
|
|
175
|
+
deltaCol: number,
|
|
176
|
+
wrapRow: boolean,
|
|
177
|
+
allowHeader = false
|
|
178
|
+
) => {
|
|
179
|
+
let r = startRow + deltaRow;
|
|
180
|
+
let c = startCol + deltaCol;
|
|
181
|
+
|
|
182
|
+
if (wrapRow && deltaCol !== 0) {
|
|
183
|
+
if (deltaCol > 0 && c >= navigationColumns.length) { c = 0; r++; }
|
|
184
|
+
else if (deltaCol < 0 && c < 0) { c = navigationColumns.length - 1; r--; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!wrapRow && (c < 0 || c >= navigationColumns.length)) return null;
|
|
188
|
+
if (r < (allowHeader ? -1 : 0) || r >= allRenderableRows.length) return null;
|
|
189
|
+
|
|
190
|
+
return { r, c };
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const findNextEditable = (
|
|
194
|
+
startRow: number,
|
|
195
|
+
startCol: number,
|
|
196
|
+
deltaRow: number,
|
|
197
|
+
deltaCol: number,
|
|
198
|
+
wrapRow: boolean,
|
|
199
|
+
allowHeader = false
|
|
200
|
+
) => {
|
|
201
|
+
let r = startRow;
|
|
202
|
+
let c = startCol;
|
|
203
|
+
let steps = 0;
|
|
204
|
+
const maxSteps = allRenderableRows.length * navigationColumns.length + navigationColumns.length;
|
|
205
|
+
|
|
206
|
+
while (steps < maxSteps) {
|
|
207
|
+
steps++;
|
|
208
|
+
r += deltaRow;
|
|
209
|
+
c += deltaCol;
|
|
210
|
+
|
|
211
|
+
if (wrapRow && deltaCol !== 0) {
|
|
212
|
+
if (deltaCol > 0) {
|
|
213
|
+
if (c >= navigationColumns.length) { c = 0; r++; }
|
|
214
|
+
} else {
|
|
215
|
+
if (c < 0) { c = navigationColumns.length - 1; r--; }
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
if (c < 0 || c >= navigationColumns.length) return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (r < -1 || r >= allRenderableRows.length) return null;
|
|
222
|
+
|
|
223
|
+
if (r === -1) {
|
|
224
|
+
if (!allowHeader) continue;
|
|
225
|
+
const col = navigationColumns[c];
|
|
226
|
+
const isSortable = col.sortable !== false;
|
|
227
|
+
if (['__checkbox_col__'].includes(col.field) || isSortable) return { r, c };
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const row = allRenderableRows[r];
|
|
232
|
+
const col = navigationColumns[c];
|
|
233
|
+
const isInteractable = ['__checkbox_col__', '__expand_col__', '__reorder_col__'].includes(col.field);
|
|
234
|
+
let cellEditable = col.editable || isInteractable;
|
|
235
|
+
|
|
236
|
+
if (isCellEditable && !isInteractable) {
|
|
237
|
+
try {
|
|
238
|
+
cellEditable = isCellEditable({
|
|
239
|
+
row,
|
|
240
|
+
field: col.field,
|
|
241
|
+
value: (row as R & Record<string, unknown>)[col.field],
|
|
242
|
+
colDef: col as GridColDef<R>,
|
|
243
|
+
rowIndex: r,
|
|
244
|
+
colIndex: c
|
|
245
|
+
});
|
|
246
|
+
} catch {
|
|
247
|
+
// isCellEditable threw — treat cell as non-editable
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (cellEditable) return { r, c };
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
let nextRowIndex = rowIndex;
|
|
257
|
+
let nextColIndex = colIndex;
|
|
258
|
+
let handled = false;
|
|
259
|
+
|
|
260
|
+
if (event.key === 'Tab') {
|
|
261
|
+
const dir = event.shiftKey ? -1 : 1;
|
|
262
|
+
const res = findNextEditable(rowIndex, colIndex, 0, dir, true, false);
|
|
263
|
+
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
264
|
+
} else if (event.key === 'ArrowRight') {
|
|
265
|
+
const res = findNextCell(rowIndex, colIndex, 0, 1, true, true);
|
|
266
|
+
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
267
|
+
} else if (event.key === 'ArrowLeft') {
|
|
268
|
+
const res = findNextCell(rowIndex, colIndex, 0, -1, true, true);
|
|
269
|
+
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
270
|
+
} else if (event.key === 'ArrowDown') {
|
|
271
|
+
const res = findNextCell(rowIndex, colIndex, 1, 0, false, true);
|
|
272
|
+
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
273
|
+
} else if (event.key === 'ArrowUp') {
|
|
274
|
+
const res = findNextCell(rowIndex, colIndex, -1, 0, false, true);
|
|
275
|
+
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
276
|
+
} else if (event.key === 'Home') {
|
|
277
|
+
nextColIndex = 0;
|
|
278
|
+
if (event.ctrlKey || event.metaKey) nextRowIndex = -1;
|
|
279
|
+
handled = true;
|
|
280
|
+
} else if (event.key === 'End') {
|
|
281
|
+
nextColIndex = navigationColumns.length - 1;
|
|
282
|
+
if (event.ctrlKey || event.metaKey) nextRowIndex = allRenderableRows.length - 1;
|
|
283
|
+
handled = true;
|
|
284
|
+
} else if (event.key === 'PageUp') {
|
|
285
|
+
const pg = pagination ? pageSize : 10;
|
|
286
|
+
nextRowIndex = Math.max(-1, nextRowIndex - pg);
|
|
287
|
+
handled = true;
|
|
288
|
+
} else if (event.key === 'PageDown') {
|
|
289
|
+
const pg = pagination ? pageSize : 10;
|
|
290
|
+
nextRowIndex = Math.min(allRenderableRows.length - 1, nextRowIndex + pg);
|
|
291
|
+
handled = true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (handled) {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
|
|
297
|
+
if (isEditing && event.key === 'Tab') {
|
|
298
|
+
editingHandlers.stopCellEdit();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (nextRowIndex >= -1 && nextRowIndex < allRenderableRows.length &&
|
|
302
|
+
nextColIndex >= 0 && nextColIndex < navigationColumns.length) {
|
|
303
|
+
|
|
304
|
+
const nextCol = navigationColumns[nextColIndex];
|
|
305
|
+
|
|
306
|
+
if (nextRowIndex === -1) {
|
|
307
|
+
setFocusedCell({ id: 'HEADER', field: nextCol.field });
|
|
308
|
+
} else {
|
|
309
|
+
setFocusedCell({ id: allRenderableRows[nextRowIndex].id, field: nextCol.field });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const el = viewportRef.current;
|
|
313
|
+
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;
|
|
327
|
+
}
|
|
328
|
+
if (newScrollTop !== scrollTop) el.scrollTop = newScrollTop;
|
|
329
|
+
|
|
330
|
+
if (columnMetrics) {
|
|
331
|
+
const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = columnMetrics;
|
|
332
|
+
const unpinnedIndex = unpinnedCols.findIndex(c => c.field === nextCol.field);
|
|
333
|
+
|
|
334
|
+
if (unpinnedIndex !== -1) {
|
|
335
|
+
const colLeft = totalSpecialsWidth + leftPinnedWidth + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
|
|
336
|
+
const colRight = totalSpecialsWidth + leftPinnedWidth + unpinnedAccWidths[unpinnedIndex];
|
|
337
|
+
const visibleStart = scrollLeft + pinnedSpecialsWidth + leftPinnedWidth;
|
|
338
|
+
const visibleEnd = scrollLeft + clientWidth - rightPinnedWidth;
|
|
339
|
+
|
|
340
|
+
let newScrollLeft = scrollLeft;
|
|
341
|
+
if (colLeft < visibleStart) {
|
|
342
|
+
newScrollLeft = Math.max(0, colLeft - pinnedSpecialsWidth - leftPinnedWidth);
|
|
343
|
+
} else if (colRight > visibleEnd) {
|
|
344
|
+
newScrollLeft = colRight - clientWidth + rightPinnedWidth;
|
|
345
|
+
}
|
|
346
|
+
if (newScrollLeft !== scrollLeft) el.scrollLeft = newScrollLeft;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}, [
|
|
353
|
+
focusedCell,
|
|
354
|
+
allRenderableRows,
|
|
355
|
+
navigationColumns,
|
|
356
|
+
editingHandlers,
|
|
357
|
+
virtualization,
|
|
358
|
+
selectedRowIds,
|
|
359
|
+
handleSelectionChange,
|
|
360
|
+
handleDetailPanelToggle,
|
|
361
|
+
setKeyboardMode,
|
|
362
|
+
sortModel,
|
|
363
|
+
handleSort,
|
|
364
|
+
isCellEditable,
|
|
365
|
+
pagination,
|
|
366
|
+
pageSize,
|
|
367
|
+
viewportRef,
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
return { focusedCell, setFocusedCell, handleFocus, handleBlur, handleKeyDown };
|
|
371
|
+
}
|