@opencorestack/opengridx 2.1.0 → 3.0.1
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 +399 -0
- package/README.md +95 -72
- package/dist/index.d.ts +590 -131
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7891 -5631
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +17 -23
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +348 -166
- package/docs/README.md +6 -1
- package/docs/architecture/datagrid-orchestration.md +81 -0
- package/docs/architecture/grid-row-meta.md +102 -0
- package/docs/architecture/use-grid-columns.md +128 -0
- package/docs/architecture/use-grid-controlled-state.md +157 -0
- package/docs/architecture/use-grid-row-pipeline.md +120 -0
- package/docs/architecture/use-grid-scroll-sync.md +111 -0
- package/docs/architecture/use-grid-state-snapshot.md +90 -0
- package/docs/architecture/use-grid-virtualization.md +115 -0
- package/docs/architecture/use-grid-visible-rows.md +85 -0
- package/docs/assets/banner-alt.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/hero.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/components/aggregation-footer.md +15 -10
- package/docs/components/cell.md +16 -6
- package/docs/components/column-group-header.md +55 -28
- package/docs/components/column-resize.md +17 -13
- package/docs/components/column-visibility.md +6 -7
- package/docs/components/datagrid.md +15 -13
- package/docs/components/empty-state.md +2 -1
- package/docs/components/error-overlay.md +12 -7
- package/docs/components/filter-panel.md +28 -8
- package/docs/components/header.md +28 -9
- package/docs/components/pagination.md +14 -8
- package/docs/components/row.md +21 -5
- package/docs/components/toolbar.md +27 -9
- package/docs/components/tooltip.md +16 -7
- package/docs/customization/slots-api.md +90 -38
- package/docs/customization/theming.md +66 -18
- package/docs/features/aggregation-pivot.md +75 -9
- package/docs/features/cell-spanning.md +80 -125
- package/docs/features/clipboard.md +41 -16
- package/docs/features/custom-pagination.md +38 -18
- package/docs/features/data-source.md +36 -13
- package/docs/features/editing-reordering.md +125 -16
- package/docs/features/export-guide.md +126 -39
- package/docs/features/filtering.md +44 -6
- package/docs/features/infinite-scroll.md +54 -46
- package/docs/features/keyboard-navigation.md +83 -0
- package/docs/features/list-view.md +58 -5
- package/docs/features/loading-states.md +18 -20
- package/docs/features/master-detail.md +18 -5
- package/docs/features/pdf-export.md +72 -19
- package/docs/features/pinning.md +24 -5
- package/docs/features/selection.md +50 -10
- package/docs/features/sorting-pagination.md +64 -14
- package/docs/features/state-persistence.md +78 -10
- package/docs/features/toolbar-customization.md +26 -17
- package/docs/features/tree-data-grouping.md +36 -8
- package/docs/features/virtualization.md +37 -14
- package/docs/migration/v1-to-v2.md +115 -0
- package/docs/migration/v2-to-v3.md +1233 -0
- package/docs/performance.md +124 -0
- package/docs/roadmap.md +5 -5
- package/lib/components/Cell/Cell.css +17 -1
- package/lib/components/Cell/Cell.tsx +129 -57
- package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
- package/lib/components/Cell/GridEditInputCell.tsx +114 -41
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +15 -2
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
- package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
- package/lib/components/DataGrid/DataGrid.css +85 -12
- package/lib/components/DataGrid/DataGrid.tsx +582 -876
- package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
- package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
- package/lib/components/DataGrid/GridListView.tsx +165 -24
- package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
- package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
- package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
- package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
- package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
- package/lib/components/DetailPanel/DetailPanel.css +0 -6
- package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
- package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
- package/lib/components/FilterPanel/FilterPanel.css +13 -2
- package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
- package/lib/components/Header/ColumnMenu.tsx +91 -39
- package/lib/components/Header/Header.css +6 -3
- package/lib/components/Header/Header.tsx +181 -160
- package/lib/components/ListView/ListView.css +25 -0
- package/lib/components/ListView/ListViewRow.tsx +76 -10
- package/lib/components/Pagination/Pagination.tsx +15 -7
- package/lib/components/Row/Row.tsx +177 -77
- package/lib/components/Toolbar/GlobalSearch.css +6 -3
- package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
- package/lib/components/Toolbar/GridToolbar.css +12 -11
- package/lib/components/Toolbar/GridToolbar.tsx +133 -89
- package/lib/components/Toolbar/PivotPanel.tsx +89 -49
- package/lib/components/Tooltip/Tooltip.css +20 -0
- package/lib/components/Tooltip/Tooltip.tsx +68 -35
- package/lib/components/ui/Button.tsx +3 -0
- package/lib/components/ui/Checkbox.tsx +2 -1
- package/lib/components/ui/ExpandIcon.css +0 -9
- package/lib/components/ui/ExpandIcon.tsx +4 -1
- package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
- package/lib/hooks/core/useDataGrid.ts +115 -173
- package/lib/hooks/core/useGridAggregationApi.ts +72 -0
- package/lib/hooks/core/useGridApiMethods.ts +115 -0
- package/lib/hooks/core/useGridApiRef.ts +19 -4
- package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
- package/lib/hooks/core/useGridAriaRows.ts +38 -0
- package/lib/hooks/core/useGridClipboardApi.ts +38 -0
- package/lib/hooks/core/useGridColumnLookup.ts +30 -0
- package/lib/hooks/core/useGridColumns.tsx +279 -76
- package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
- package/lib/hooks/core/useGridControlledState.ts +99 -6
- package/lib/hooks/core/useGridDetailPanel.ts +51 -0
- package/lib/hooks/core/useGridDevWarnings.ts +104 -5
- package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
- package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
- package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
- package/lib/hooks/core/useGridHierarchy.ts +81 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
- package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
- package/lib/hooks/core/useGridPageCorrection.ts +55 -0
- package/lib/hooks/core/useGridRowIdOf.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +81 -0
- package/lib/hooks/core/useGridRowPipeline.ts +69 -39
- package/lib/hooks/core/useGridRowSelection.ts +91 -0
- package/lib/hooks/core/useGridScrollSync.ts +80 -32
- package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
- package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
- package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
- package/lib/hooks/core/useGridToolbarProps.ts +97 -0
- package/lib/hooks/core/useGridViewport.ts +38 -0
- package/lib/hooks/core/useGridViewportSize.ts +26 -0
- package/lib/hooks/core/useGridVisibleRows.ts +17 -5
- package/lib/hooks/core/useLayout.ts +215 -195
- package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
- package/lib/hooks/core/useStableColumns.ts +34 -0
- package/lib/hooks/features/useAggregation.ts +82 -51
- package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
- package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
- package/lib/hooks/features/useGridClipboard.ts +133 -73
- package/lib/hooks/features/useGridDataSource.ts +354 -128
- package/lib/hooks/features/useGridEditing.ts +211 -51
- package/lib/hooks/features/useGridPivot.ts +92 -0
- package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
- package/lib/hooks/features/useGridSpanning.ts +410 -306
- package/lib/hooks/features/usePivot.ts +0 -0
- package/lib/hooks/features/useServerTreeChildren.ts +50 -0
- package/lib/hooks/useColumnReorder.ts +38 -44
- package/lib/hooks/useRowGrouping.ts +298 -160
- package/lib/hooks/useRowReorder.ts +62 -31
- package/lib/hooks/useTreeData.ts +287 -220
- package/lib/index.ts +28 -2
- package/lib/state/index.ts +1 -0
- package/lib/state/types.ts +7 -1
- package/lib/state/useGridStateStorage.ts +88 -29
- package/lib/theme/DataGridThemeProvider.tsx +143 -38
- package/lib/theme/gridThemeContext.ts +5 -0
- package/lib/theme/index.ts +5 -0
- package/lib/theme/presets.ts +5 -4
- package/lib/theme/types.ts +40 -2
- package/lib/types/index.ts +313 -116
- package/lib/utils/aggregation/index.ts +119 -24
- package/lib/utils/aria/index.ts +40 -0
- package/lib/utils/columnGroups.ts +110 -0
- package/lib/utils/columnLookup.ts +33 -0
- package/lib/utils/columnWidth.ts +22 -0
- package/lib/utils/dataSource/index.ts +46 -0
- package/lib/utils/detailPanel.ts +66 -0
- package/lib/utils/dragData.ts +44 -0
- package/lib/utils/editing/index.ts +30 -0
- package/lib/utils/export/exportShared.ts +197 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
- package/lib/utils/export/exportToPdf.ts +143 -92
- package/lib/utils/export/groupLabel.ts +14 -0
- package/lib/utils/export/index.ts +278 -272
- package/lib/utils/export/pdfText.ts +50 -0
- package/lib/utils/filtering/index.ts +277 -148
- package/lib/utils/focus/index.ts +115 -0
- package/lib/utils/gridApiRows.ts +35 -0
- package/lib/utils/gridModes.ts +77 -0
- package/lib/utils/grouping/groupedExportRows.ts +91 -0
- package/lib/utils/pagination/index.ts +56 -0
- package/lib/utils/pinning/index.ts +77 -33
- package/lib/utils/pivot/index.ts +368 -0
- package/lib/utils/scroll/index.ts +55 -5
- package/lib/utils/scroll/scrollEnd.ts +18 -0
- package/lib/utils/sorting/index.ts +154 -30
- package/lib/utils/syntheticRows.ts +12 -0
- package/lib/utils/values.ts +206 -0
- package/lib/utils/viewport.ts +14 -0
- package/llms.txt +261 -164
- package/package.json +13 -5
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
- package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
- package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
- package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
- package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
- package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
- package/lib/components/Header/Header.test.tsx +0 -135
- package/lib/components/Pagination/Pagination.test.tsx +0 -64
- package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
- package/lib/hooks/core/useGridControlledState.test.ts +0 -86
- package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
- package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
- package/lib/hooks/features/useAggregation.test.ts +0 -175
- package/lib/hooks/features/usePivot.test.ts +0 -202
- package/lib/hooks/useRowGrouping.test.ts +0 -298
- package/lib/hooks/useTreeData.test.ts +0 -83
- package/lib/utils/aggregation/aggregation.test.ts +0 -16
- package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
- package/lib/utils/export/exportToPdf.test.ts +0 -120
- package/lib/utils/export/groupedExport.test.ts +0 -144
- package/lib/utils/filtering/filtering.test.ts +0 -178
- package/lib/utils/sorting/sorting.test.ts +0 -88
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react';
|
|
1
|
+
import { useState, useCallback, useMemo, useRef, useLayoutEffect } from 'react';
|
|
2
2
|
import { scrollRowIntoView } from '../../utils/scroll';
|
|
3
|
-
import
|
|
3
|
+
import { resolveCellEditable } from '../../utils/editing';
|
|
4
|
+
import { getCellValue } from '../../utils/values';
|
|
5
|
+
import { isSyntheticRowId } from '../../utils/syntheticRows';
|
|
6
|
+
import {
|
|
7
|
+
CHECKBOX_FIELD,
|
|
8
|
+
EXPAND_FIELD,
|
|
9
|
+
REORDER_FIELD,
|
|
10
|
+
classifyKeyTarget,
|
|
11
|
+
findFocusTarget,
|
|
12
|
+
focusedCellFromElement,
|
|
13
|
+
isSameCell,
|
|
14
|
+
isSystemField,
|
|
15
|
+
} from '../../utils/focus';
|
|
16
|
+
import type { FocusedCell } from '../../utils/focus';
|
|
17
|
+
import type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection, GridRowParams, GridRowMeta } from '../../types';
|
|
4
18
|
import type { GridEditingState } from '../features/useGridEditing';
|
|
5
19
|
|
|
20
|
+
export type { FocusedCell } from '../../utils/focus';
|
|
21
|
+
|
|
6
22
|
interface ColumnMetrics {
|
|
7
23
|
leftPinnedWidth: number;
|
|
8
24
|
rightPinnedWidth: number;
|
|
@@ -19,9 +35,12 @@ interface VirtualizationSnapshot {
|
|
|
19
35
|
columnMetrics: ColumnMetrics | null;
|
|
20
36
|
}
|
|
21
37
|
|
|
38
|
+
type NavigationColumn<R extends GridRowModel> = Pick<GridColDef<R>, 'field' | 'editable' | 'sortable' | 'valueGetter'>;
|
|
39
|
+
|
|
22
40
|
export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
|
|
23
41
|
allRenderableRows: R[];
|
|
24
|
-
|
|
42
|
+
/** Focusable columns in render order: system columns first, then the visible data columns. */
|
|
43
|
+
navigationColumns: ReadonlyArray<NavigationColumn<R>>;
|
|
25
44
|
checkboxSelection: boolean;
|
|
26
45
|
selectedRowIds: Set<GridRowId>;
|
|
27
46
|
handleSelectionChange: (id: GridRowId, selected: boolean) => void;
|
|
@@ -34,6 +53,12 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
|
|
|
34
53
|
setKeyboardMode: (mode: boolean) => void;
|
|
35
54
|
sortModel: GridSortItem[];
|
|
36
55
|
handleSort: (field: string, direction: GridSortDirection) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Adds, updates or removes one sort key and keeps the others. Enter/Space on a header use it
|
|
58
|
+
* when `multiSort` is on or Shift is held, as a header click does.
|
|
59
|
+
*/
|
|
60
|
+
handleSortAdd?: (field: string, direction: GridSortDirection) => void;
|
|
61
|
+
multiSort?: boolean;
|
|
37
62
|
isCellEditable?: (params: GridCellParams<R>) => boolean;
|
|
38
63
|
pagination: boolean;
|
|
39
64
|
pageSize: number;
|
|
@@ -41,19 +66,75 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
|
|
|
41
66
|
viewportRef: React.RefObject<HTMLDivElement | null>;
|
|
42
67
|
/** Number of top-pinned rows at the start of allRenderableRows. */
|
|
43
68
|
pinnedTopRowCount?: number;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
/** Resolves a row's id (getRowId); defaults to `row.id`. */
|
|
70
|
+
getRowId?: (row: R) => GridRowId;
|
|
71
|
+
/** Hierarchy metadata. Synthetic group rows are never edited or selected from the keyboard. */
|
|
72
|
+
rowMetaMap?: Map<GridRowId, GridRowMeta>;
|
|
73
|
+
/** Enter on a non-editable cell: the same handler a row click runs. */
|
|
74
|
+
onRowActivate?: (params: GridRowParams<R>) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Tree data / row grouping: expands or collapses a row. Enter on a non-editable cell of any row
|
|
77
|
+
* with children toggles it (a tree-data parent that is a real row included, whose click selects it
|
|
78
|
+
* instead), and Alt+ArrowRight / Alt+ArrowLeft expand / collapse it.
|
|
79
|
+
*/
|
|
80
|
+
toggleRowExpansion?: (id: GridRowId) => void;
|
|
81
|
+
/** Ctrl/Cmd+A. */
|
|
82
|
+
handleSelectAll?: (selected: boolean) => void;
|
|
83
|
+
/** Whether rows can be selected from the keyboard (Shift+Space). */
|
|
84
|
+
rowSelectionEnabled?: boolean;
|
|
85
|
+
/** Whether several rows can be selected at once (Ctrl/Cmd+A). */
|
|
86
|
+
multipleRowSelectionEnabled?: boolean;
|
|
87
|
+
pinCheckboxColumn?: boolean;
|
|
88
|
+
pinExpandColumn?: boolean;
|
|
89
|
+
/** Height of a row without its detail panel, for scroll-into-view. */
|
|
90
|
+
rowHeight?: number;
|
|
91
|
+
/**
|
|
92
|
+
* For a cell covered by a colSpan or rowSpan, the origin cell of that span (`null` otherwise).
|
|
93
|
+
* Navigation skips the cells of the focused span and lands on span origins.
|
|
94
|
+
*/
|
|
95
|
+
getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
|
|
49
96
|
}
|
|
50
97
|
|
|
51
98
|
export interface UseGridKeyboardNavigationReturn {
|
|
99
|
+
/** The focus position, validated against the current rows and columns. */
|
|
52
100
|
focusedCell: FocusedCell | null;
|
|
53
101
|
setFocusedCell: React.Dispatch<React.SetStateAction<FocusedCell | null>>;
|
|
102
|
+
/** tabIndex for the viewport: it stops being a tab stop while focus is inside the grid. */
|
|
103
|
+
viewportTabIndex: number;
|
|
104
|
+
/** Moves DOM focus into the grid (for pointer interactions) unless it is already there. */
|
|
105
|
+
ensureGridFocus: () => void;
|
|
54
106
|
handleFocus: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
55
107
|
handleBlur: (event: React.FocusEvent<HTMLDivElement>) => void;
|
|
56
108
|
handleKeyDown: (event: React.KeyboardEvent) => void;
|
|
109
|
+
handleMouseDownCapture: () => void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const defaultGetRowId = <R extends GridRowModel>(row: R): GridRowId => row.id;
|
|
113
|
+
|
|
114
|
+
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']);
|
|
115
|
+
|
|
116
|
+
/** Keeps a stored focus position valid when its row or column is no longer rendered. */
|
|
117
|
+
function resolveFocus<R extends GridRowModel>(
|
|
118
|
+
cell: FocusedCell | null,
|
|
119
|
+
rows: R[],
|
|
120
|
+
columns: ReadonlyArray<NavigationColumn<R>>,
|
|
121
|
+
getRowId: (row: R) => GridRowId
|
|
122
|
+
): { cell: FocusedCell | null; rowIndex: number } {
|
|
123
|
+
if (!cell || columns.length === 0) return { cell: null, rowIndex: -1 };
|
|
124
|
+
const fallbackField = (columns.find(c => !isSystemField(c.field)) ?? columns[0]).field;
|
|
125
|
+
const field = columns.some(c => c.field === cell.field) ? cell.field : fallbackField;
|
|
126
|
+
|
|
127
|
+
if (cell.id === null) {
|
|
128
|
+
return { cell: field === cell.field ? cell : { id: null, field }, rowIndex: -1 };
|
|
129
|
+
}
|
|
130
|
+
const hint = cell.rowIndex;
|
|
131
|
+
let rowIndex = hint !== undefined && rows[hint] && getRowId(rows[hint]) === cell.id ? hint : rows.findIndex(r => getRowId(r) === cell.id);
|
|
132
|
+
if (rowIndex === -1) {
|
|
133
|
+
if (rows.length === 0) return { cell: { id: null, field }, rowIndex: -1 };
|
|
134
|
+
rowIndex = Math.min(Math.max(hint ?? 0, 0), rows.length - 1);
|
|
135
|
+
return { cell: { id: getRowId(rows[rowIndex]), field, rowIndex }, rowIndex };
|
|
136
|
+
}
|
|
137
|
+
return { cell: field === cell.field ? cell : { ...cell, field }, rowIndex };
|
|
57
138
|
}
|
|
58
139
|
|
|
59
140
|
export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
@@ -70,300 +151,423 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
70
151
|
setKeyboardMode,
|
|
71
152
|
sortModel,
|
|
72
153
|
handleSort,
|
|
154
|
+
handleSortAdd,
|
|
155
|
+
multiSort = false,
|
|
73
156
|
isCellEditable,
|
|
74
157
|
pagination,
|
|
75
158
|
pageSize,
|
|
76
159
|
virtualization,
|
|
77
160
|
viewportRef,
|
|
78
161
|
pinnedTopRowCount = 0,
|
|
162
|
+
getRowId = defaultGetRowId,
|
|
163
|
+
rowMetaMap,
|
|
164
|
+
onRowActivate,
|
|
165
|
+
toggleRowExpansion,
|
|
166
|
+
handleSelectAll,
|
|
167
|
+
rowSelectionEnabled = true,
|
|
168
|
+
multipleRowSelectionEnabled = true,
|
|
169
|
+
pinCheckboxColumn = true,
|
|
170
|
+
pinExpandColumn = true,
|
|
171
|
+
rowHeight,
|
|
172
|
+
getSpanOrigin,
|
|
79
173
|
} = params;
|
|
80
174
|
|
|
81
|
-
const [
|
|
175
|
+
const [storedFocus, setFocusedCell] = useState<FocusedCell | null>(null);
|
|
176
|
+
const [focusWithin, setFocusWithin] = useState(false);
|
|
177
|
+
|
|
178
|
+
const resolved = useMemo(
|
|
179
|
+
() => resolveFocus(storedFocus, allRenderableRows, navigationColumns, getRowId),
|
|
180
|
+
[storedFocus, allRenderableRows, navigationColumns, getRowId]
|
|
181
|
+
);
|
|
182
|
+
const focusedCell = resolved.cell;
|
|
183
|
+
const focusedRowIndex = resolved.rowIndex;
|
|
184
|
+
|
|
185
|
+
// Whether this grid held DOM focus as of the last focus event. Focus lost because the focused
|
|
186
|
+
// node was unmounted produces no event React can see, so this stays true in that case.
|
|
187
|
+
const ownsFocusRef = useRef(false);
|
|
188
|
+
// Set while the grid moves focus to its own viewport, so handleFocus does not treat it as entry.
|
|
189
|
+
const internalFocusRef = useRef(false);
|
|
190
|
+
// Set by a mouse press, so a focus entry it causes does not turn on the keyboard focus ring.
|
|
191
|
+
const pointerDownRef = useRef(false);
|
|
192
|
+
const lastSyncedRef = useRef<FocusedCell | null>(null);
|
|
193
|
+
|
|
194
|
+
const focusViewport = useCallback((viewport: HTMLElement) => {
|
|
195
|
+
internalFocusRef.current = true;
|
|
196
|
+
viewport.focus({ preventScroll: true });
|
|
197
|
+
internalFocusRef.current = false;
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
const ensureGridFocus = useCallback(() => {
|
|
201
|
+
const viewport = viewportRef.current;
|
|
202
|
+
if (viewport && !viewport.contains(viewport.ownerDocument.activeElement)) focusViewport(viewport);
|
|
203
|
+
}, [viewportRef, focusViewport]);
|
|
204
|
+
|
|
205
|
+
const firstDataColumn = useMemo(
|
|
206
|
+
() => navigationColumns.find(c => !isSystemField(c.field)) ?? navigationColumns[0],
|
|
207
|
+
[navigationColumns]
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const getDefaultFocus = useCallback((): FocusedCell | null => {
|
|
211
|
+
if (checkboxSelection) return { id: null, field: CHECKBOX_FIELD };
|
|
212
|
+
if (!firstDataColumn) return null;
|
|
213
|
+
const firstRow = allRenderableRows[0];
|
|
214
|
+
return firstRow ? { id: getRowId(firstRow), field: firstDataColumn.field, rowIndex: 0 } : { id: null, field: firstDataColumn.field };
|
|
215
|
+
}, [checkboxSelection, firstDataColumn, allRenderableRows, getRowId]);
|
|
216
|
+
|
|
217
|
+
const scrollCellIntoView = useCallback((rowIndex: number, field: string) => {
|
|
218
|
+
const el = viewportRef.current;
|
|
219
|
+
if (!el) return;
|
|
220
|
+
const { cumulativeHeights, pinnedBottomHeight, columnMetrics } = virtualization;
|
|
221
|
+
|
|
222
|
+
// allRenderableRows = pinned top + center + pinned bottom; pinned rows are sticky and always visible.
|
|
223
|
+
if (rowIndex >= 0) {
|
|
224
|
+
scrollRowIntoView(el, rowIndex - pinnedTopRowCount, cumulativeHeights, pinnedBottomHeight, rowHeight);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (isSystemField(field)) {
|
|
228
|
+
// System columns sit before every data column; only unpinned ones can scroll away.
|
|
229
|
+
const isPinned = field === REORDER_FIELD
|
|
230
|
+
|| (field === EXPAND_FIELD && pinExpandColumn)
|
|
231
|
+
|| (field === CHECKBOX_FIELD && pinCheckboxColumn);
|
|
232
|
+
if (!isPinned && el.scrollLeft > 0) el.scrollLeft = 0;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!columnMetrics) return;
|
|
236
|
+
|
|
237
|
+
const { clientWidth, scrollLeft } = el;
|
|
238
|
+
const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = columnMetrics;
|
|
239
|
+
const unpinnedIndex = unpinnedCols.findIndex(c => c.field === field);
|
|
240
|
+
if (unpinnedIndex === -1) return;
|
|
241
|
+
|
|
242
|
+
const colLeft = totalSpecialsWidth + leftPinnedWidth + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
|
|
243
|
+
const colRight = totalSpecialsWidth + leftPinnedWidth + unpinnedAccWidths[unpinnedIndex];
|
|
244
|
+
const visibleStart = scrollLeft + pinnedSpecialsWidth + leftPinnedWidth;
|
|
245
|
+
const visibleEnd = scrollLeft + clientWidth - rightPinnedWidth;
|
|
246
|
+
|
|
247
|
+
let newScrollLeft = scrollLeft;
|
|
248
|
+
if (colLeft < visibleStart) {
|
|
249
|
+
newScrollLeft = Math.max(0, colLeft - pinnedSpecialsWidth - leftPinnedWidth);
|
|
250
|
+
} else if (colRight > visibleEnd) {
|
|
251
|
+
newScrollLeft = colRight - clientWidth + rightPinnedWidth;
|
|
252
|
+
}
|
|
253
|
+
if (newScrollLeft !== scrollLeft) el.scrollLeft = newScrollLeft;
|
|
254
|
+
}, [viewportRef, virtualization, pinnedTopRowCount, rowHeight, pinCheckboxColumn, pinExpandColumn]);
|
|
255
|
+
|
|
256
|
+
// Keep DOM focus on the element of the focused cell. This covers keyboard moves, a cell that
|
|
257
|
+
// mounts later (scrolled into the render window), the end of an edit, and a focused cell that
|
|
258
|
+
// unmounts (focus is parked on the viewport so keys keep working). Focus is never taken from
|
|
259
|
+
// an element outside the grid, nor from content inside the grid unless the focus position moved.
|
|
260
|
+
useLayoutEffect(() => {
|
|
261
|
+
const viewport = viewportRef.current;
|
|
262
|
+
const moved = !isSameCell(lastSyncedRef.current, focusedCell);
|
|
263
|
+
lastSyncedRef.current = focusedCell;
|
|
264
|
+
if (!viewport || !focusedCell) return;
|
|
265
|
+
|
|
266
|
+
const doc = viewport.ownerDocument;
|
|
267
|
+
const active = doc.activeElement;
|
|
268
|
+
let takeFocus: boolean;
|
|
269
|
+
if (active === viewport) takeFocus = true;
|
|
270
|
+
else if (!active || active === doc.body) takeFocus = ownsFocusRef.current;
|
|
271
|
+
else if (viewport.contains(active)) takeFocus = moved;
|
|
272
|
+
else takeFocus = false;
|
|
273
|
+
if (!takeFocus) return;
|
|
274
|
+
|
|
275
|
+
const target = findFocusTarget(viewport, focusedCell, focusedRowIndex);
|
|
276
|
+
if (target) {
|
|
277
|
+
if (!target.contains(active)) target.focus({ preventScroll: true });
|
|
278
|
+
} else if (active !== viewport) {
|
|
279
|
+
focusViewport(viewport);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const handleMouseDownCapture = useCallback(() => {
|
|
284
|
+
pointerDownRef.current = true;
|
|
285
|
+
setKeyboardMode(false);
|
|
286
|
+
}, [setKeyboardMode]);
|
|
82
287
|
|
|
83
288
|
const handleFocus = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
289
|
+
const viewport = event.currentTarget;
|
|
290
|
+
const related = event.relatedTarget;
|
|
291
|
+
const fromOutside = !(related instanceof Node && viewport.contains(related));
|
|
292
|
+
const byPointer = pointerDownRef.current;
|
|
293
|
+
pointerDownRef.current = false;
|
|
294
|
+
ownsFocusRef.current = true;
|
|
295
|
+
setFocusWithin(true);
|
|
296
|
+
if (internalFocusRef.current) return;
|
|
297
|
+
|
|
298
|
+
if (event.target === viewport) {
|
|
299
|
+
if (!fromOutside) return;
|
|
300
|
+
// Tabbing (or programmatic focus) into the grid: go back to the last focused cell.
|
|
301
|
+
if (!byPointer) setKeyboardMode(true);
|
|
302
|
+
if (focusedCell) {
|
|
303
|
+
const target = findFocusTarget(viewport, focusedCell, focusedRowIndex);
|
|
304
|
+
if (target) target.focus({ preventScroll: true });
|
|
305
|
+
else scrollCellIntoView(focusedRowIndex, focusedCell.field);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const initial = getDefaultFocus();
|
|
309
|
+
if (initial) {
|
|
310
|
+
setFocusedCell(prev => prev ?? initial);
|
|
311
|
+
scrollCellIntoView(initial.rowIndex ?? -1, initial.field);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
97
314
|
}
|
|
98
|
-
|
|
315
|
+
|
|
316
|
+
// Focus landed inside the grid (a click, Tab onto a control in a cell, the window being
|
|
317
|
+
// re-activated): the focus position follows it.
|
|
318
|
+
const hit = focusedCellFromElement(event.target, viewport, allRenderableRows, getRowId);
|
|
319
|
+
if (hit && !isSameCell(hit, focusedCell)) setFocusedCell(hit);
|
|
320
|
+
}, [focusedCell, focusedRowIndex, getDefaultFocus, scrollCellIntoView, setKeyboardMode, allRenderableRows, getRowId]);
|
|
99
321
|
|
|
100
322
|
const handleBlur = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
323
|
+
const next = event.relatedTarget;
|
|
324
|
+
if (next instanceof Node && event.currentTarget.contains(next)) return;
|
|
325
|
+
// The focus position is kept so that returning to the grid restores it; only the ring is hidden.
|
|
326
|
+
ownsFocusRef.current = false;
|
|
327
|
+
pointerDownRef.current = false;
|
|
328
|
+
setFocusWithin(false);
|
|
329
|
+
setKeyboardMode(false);
|
|
330
|
+
}, [setKeyboardMode]);
|
|
105
331
|
|
|
106
332
|
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
|
333
|
+
const viewport = event.currentTarget as HTMLElement;
|
|
334
|
+
const targetKind = classifyKeyTarget(event.target, viewport);
|
|
335
|
+
if (targetKind === 'foreign') return;
|
|
107
336
|
setKeyboardMode(true);
|
|
108
337
|
|
|
109
|
-
|
|
338
|
+
// While an IME is composing, Enter / Escape / arrows confirm or navigate candidates.
|
|
339
|
+
if (event.nativeEvent?.isComposing || event.keyCode === 229) return;
|
|
110
340
|
|
|
111
|
-
const {
|
|
341
|
+
const { key } = event;
|
|
342
|
+
const isSpace = key === ' ' || key === 'Spacebar';
|
|
112
343
|
const isEditing = Boolean(editingHandlers.editingCell);
|
|
113
344
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (currentSort) {
|
|
121
|
-
newDirection = currentSort.sort === 'asc' ? 'desc' : null;
|
|
122
|
-
}
|
|
123
|
-
handleSort(field, newDirection);
|
|
124
|
-
}
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
345
|
+
const moveTo = (r: number, c: number) => {
|
|
346
|
+
const col = navigationColumns[c];
|
|
347
|
+
if (!col || r < -1 || r >= allRenderableRows.length) return;
|
|
348
|
+
setFocusedCell(r === -1 ? { id: null, field: col.field } : { id: getRowId(allRenderableRows[r]), field: col.field, rowIndex: r });
|
|
349
|
+
scrollCellIntoView(r, col.field);
|
|
350
|
+
};
|
|
127
351
|
|
|
128
|
-
if (
|
|
352
|
+
if (!focusedCell) {
|
|
353
|
+
if (!NAVIGATION_KEYS.has(key)) return;
|
|
354
|
+
const initial = getDefaultFocus();
|
|
355
|
+
if (!initial) return;
|
|
129
356
|
event.preventDefault();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} else {
|
|
133
|
-
const col = navigationColumns.find(c => c.field === field);
|
|
134
|
-
if (col?.editable) {
|
|
135
|
-
const row = allRenderableRows.find(r => r.id === id);
|
|
136
|
-
if (row) {
|
|
137
|
-
editingHandlers.startCellEdit({ id, field, value: (row as R & Record<string, unknown>)[field] });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
357
|
+
setFocusedCell(initial);
|
|
358
|
+
scrollCellIntoView(initial.rowIndex ?? -1, initial.field);
|
|
141
359
|
return;
|
|
142
360
|
}
|
|
143
361
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
362
|
+
const { id, field } = focusedCell;
|
|
363
|
+
const rowIndex = focusedRowIndex;
|
|
364
|
+
const colIndex = navigationColumns.findIndex(c => c.field === field);
|
|
365
|
+
const systemColumnCount = navigationColumns.filter(c => isSystemField(c.field)).length;
|
|
366
|
+
const isSyntheticRow = (rowId: GridRowId) => isSyntheticRowId(rowId, rowMetaMap);
|
|
367
|
+
// Same rule and starting value as double-click (see Row / resolveCellEditable).
|
|
368
|
+
const isEditableCell = (row: R, r: number, c: number) => {
|
|
369
|
+
const colDef = navigationColumns[c] as GridColDef<R>;
|
|
370
|
+
if (isSystemField(colDef.field)) return false;
|
|
371
|
+
return resolveCellEditable({
|
|
372
|
+
row,
|
|
373
|
+
field: colDef.field,
|
|
374
|
+
value: getCellValue(row, colDef.field, colDef),
|
|
375
|
+
colDef,
|
|
376
|
+
rowIndex: r,
|
|
377
|
+
colIndex: c - systemColumnCount,
|
|
378
|
+
rowMeta: rowMetaMap?.get(getRowId(row)),
|
|
379
|
+
}, isCellEditable);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// Next editable data cell in reading order, for Tab while editing.
|
|
383
|
+
const findNextEditable = (deltaCol: number) => {
|
|
384
|
+
let r = rowIndex;
|
|
385
|
+
let c = colIndex;
|
|
386
|
+
const maxSteps = (allRenderableRows.length + 1) * navigationColumns.length;
|
|
387
|
+
for (let steps = 0; steps < maxSteps; steps++) {
|
|
388
|
+
c += deltaCol;
|
|
389
|
+
if (c >= navigationColumns.length) { c = 0; r++; }
|
|
390
|
+
else if (c < 0) { c = navigationColumns.length - 1; r--; }
|
|
391
|
+
if (r < 0 || r >= allRenderableRows.length) return null;
|
|
392
|
+
|
|
393
|
+
const row = allRenderableRows[r];
|
|
394
|
+
// Cells covered by a span are not stops; their origin is.
|
|
395
|
+
if (getSpanOrigin?.(getRowId(row), navigationColumns[c].field)) continue;
|
|
396
|
+
if (isEditableCell(row, r, c)) return { r, c };
|
|
156
397
|
}
|
|
157
|
-
|
|
398
|
+
return null;
|
|
399
|
+
};
|
|
158
400
|
|
|
159
|
-
if (
|
|
160
|
-
if (
|
|
401
|
+
if (isEditing) {
|
|
402
|
+
if (key === 'Enter') {
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
editingHandlers.stopCellEdit();
|
|
405
|
+
} else if (key === 'Escape') {
|
|
161
406
|
event.preventDefault();
|
|
162
407
|
editingHandlers.stopCellEdit({ cancel: true });
|
|
408
|
+
} else if (key === 'Tab' && id !== null) {
|
|
409
|
+
// No next editable cell: the browser moves focus on and the editor commits on blur.
|
|
410
|
+
const next = findNextEditable(event.shiftKey ? -1 : 1);
|
|
411
|
+
if (next) {
|
|
412
|
+
event.preventDefault();
|
|
413
|
+
editingHandlers.stopCellEdit();
|
|
414
|
+
moveTo(next.r, next.c);
|
|
415
|
+
}
|
|
163
416
|
}
|
|
164
417
|
return;
|
|
165
418
|
}
|
|
166
419
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!isHeader && rowIndex === -1) return;
|
|
174
|
-
if (colIndex === -1) return;
|
|
175
|
-
|
|
176
|
-
const findNextCell = (
|
|
177
|
-
startRow: number,
|
|
178
|
-
startCol: number,
|
|
179
|
-
deltaRow: number,
|
|
180
|
-
deltaCol: number,
|
|
181
|
-
wrapRow: boolean,
|
|
182
|
-
allowHeader = false
|
|
183
|
-
) => {
|
|
184
|
-
let r = startRow + deltaRow;
|
|
185
|
-
let c = startCol + deltaCol;
|
|
186
|
-
|
|
187
|
-
if (wrapRow && deltaCol !== 0) {
|
|
188
|
-
if (deltaCol > 0 && c >= navigationColumns.length) { c = 0; r++; }
|
|
189
|
-
else if (deltaCol < 0 && c < 0) { c = navigationColumns.length - 1; r--; }
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (!wrapRow && (c < 0 || c >= navigationColumns.length)) return null;
|
|
193
|
-
if (r < (allowHeader ? -1 : 0) || r >= allRenderableRows.length) return null;
|
|
194
|
-
|
|
195
|
-
return { r, c };
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const findNextEditable = (
|
|
199
|
-
startRow: number,
|
|
200
|
-
startCol: number,
|
|
201
|
-
deltaRow: number,
|
|
202
|
-
deltaCol: number,
|
|
203
|
-
wrapRow: boolean,
|
|
204
|
-
allowHeader = false
|
|
205
|
-
) => {
|
|
206
|
-
let r = startRow;
|
|
207
|
-
let c = startCol;
|
|
208
|
-
let steps = 0;
|
|
209
|
-
const maxSteps = allRenderableRows.length * navigationColumns.length + navigationColumns.length;
|
|
210
|
-
|
|
211
|
-
while (steps < maxSteps) {
|
|
212
|
-
steps++;
|
|
213
|
-
r += deltaRow;
|
|
214
|
-
c += deltaCol;
|
|
420
|
+
// Tab is not captured outside edit mode: the grid is a single tab stop and Tab leaves it
|
|
421
|
+
// (or reaches focusable content inside cells and detail panels).
|
|
422
|
+
if (key === 'Tab' || key === 'Escape') return;
|
|
423
|
+
// Enter and Space activate a control rendered inside a cell.
|
|
424
|
+
if (targetKind === 'control' && (key === 'Enter' || isSpace)) return;
|
|
215
425
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
} else {
|
|
223
|
-
if (c < 0 || c >= navigationColumns.length) return null;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (r < -1 || r >= allRenderableRows.length) return null;
|
|
426
|
+
if ((event.ctrlKey || event.metaKey) && (key === 'a' || key === 'A')) {
|
|
427
|
+
if (!rowSelectionEnabled || !multipleRowSelectionEnabled || !handleSelectAll) return;
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
handleSelectAll(true);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
227
432
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
433
|
+
if (id === null) {
|
|
434
|
+
if (key === 'Enter' || isSpace) {
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
if (field === CHECKBOX_FIELD) {
|
|
437
|
+
// Same as clicking the select-all checkbox.
|
|
438
|
+
findFocusTarget(viewport, focusedCell, -1)?.click();
|
|
439
|
+
return;
|
|
234
440
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
441
|
+
const col = navigationColumns[colIndex];
|
|
442
|
+
if (!col || isSystemField(field) || col.sortable === false) return;
|
|
443
|
+
const currentSort = sortModel.find(item => item.field === field);
|
|
444
|
+
const direction: GridSortDirection = currentSort ? (currentSort.sort === 'asc' ? 'desc' : null) : 'asc';
|
|
445
|
+
if ((multiSort || event.shiftKey) && handleSortAdd) handleSortAdd(field, direction);
|
|
446
|
+
else handleSort(field, direction);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
} else if (key === 'Enter' || isSpace) {
|
|
450
|
+
event.preventDefault();
|
|
451
|
+
const row = allRenderableRows[rowIndex];
|
|
452
|
+
if (field === CHECKBOX_FIELD) {
|
|
453
|
+
handleSelectionChange(id, !selectedRowIds.has(id));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (field === EXPAND_FIELD) {
|
|
457
|
+
if (!event.shiftKey) handleDetailPanelToggle(id);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (isSpace) {
|
|
461
|
+
// Shift+Space selects the row; plain Space only must not scroll the viewport.
|
|
462
|
+
if (event.shiftKey && rowSelectionEnabled && !isSyntheticRow(id)) {
|
|
463
|
+
handleSelectionChange(id, !selectedRowIds.has(id));
|
|
254
464
|
}
|
|
255
|
-
|
|
256
|
-
if (cellEditable) return { r, c };
|
|
465
|
+
return;
|
|
257
466
|
}
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
272
|
-
} else if (event.key === 'ArrowLeft') {
|
|
273
|
-
const res = findNextCell(rowIndex, colIndex, 0, -1, true, true);
|
|
274
|
-
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
275
|
-
} else if (event.key === 'ArrowDown') {
|
|
276
|
-
const res = findNextCell(rowIndex, colIndex, 1, 0, false, true);
|
|
277
|
-
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
278
|
-
} else if (event.key === 'ArrowUp') {
|
|
279
|
-
const res = findNextCell(rowIndex, colIndex, -1, 0, false, true);
|
|
280
|
-
if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
|
|
281
|
-
} else if (event.key === 'Home') {
|
|
282
|
-
nextColIndex = 0;
|
|
283
|
-
if (event.ctrlKey || event.metaKey) nextRowIndex = -1;
|
|
284
|
-
handled = true;
|
|
285
|
-
} else if (event.key === 'End') {
|
|
286
|
-
nextColIndex = navigationColumns.length - 1;
|
|
287
|
-
if (event.ctrlKey || event.metaKey) nextRowIndex = allRenderableRows.length - 1;
|
|
288
|
-
handled = true;
|
|
289
|
-
} else if (event.key === 'PageUp') {
|
|
290
|
-
const pg = pagination ? pageSize : 10;
|
|
291
|
-
nextRowIndex = Math.max(-1, nextRowIndex - pg);
|
|
292
|
-
handled = true;
|
|
293
|
-
} else if (event.key === 'PageDown') {
|
|
294
|
-
const pg = pagination ? pageSize : 10;
|
|
295
|
-
nextRowIndex = Math.min(allRenderableRows.length - 1, nextRowIndex + pg);
|
|
296
|
-
handled = true;
|
|
467
|
+
if (field === REORDER_FIELD) return;
|
|
468
|
+
if (isEditableCell(row, rowIndex, colIndex)) {
|
|
469
|
+
editingHandlers.startCellEdit({ id, field, value: getCellValue(row, field, navigationColumns[colIndex] as GridColDef<R>) });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// A row with children toggles, so keyboard users can expand a tree-data parent even
|
|
473
|
+
// though clicking it selects it. Others: the keyboard equivalent of clicking the row.
|
|
474
|
+
if (toggleRowExpansion && rowMetaMap?.get(id)?.hasChildren) {
|
|
475
|
+
toggleRowExpansion(id);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
onRowActivate?.({ row, id, rowIndex });
|
|
479
|
+
return;
|
|
297
480
|
}
|
|
298
481
|
|
|
299
|
-
|
|
482
|
+
// Treegrid shortcuts: Alt+ArrowRight expands, Alt+ArrowLeft collapses the focused row.
|
|
483
|
+
if (event.altKey && (key === 'ArrowRight' || key === 'ArrowLeft') && toggleRowExpansion && id !== null) {
|
|
300
484
|
event.preventDefault();
|
|
485
|
+
const meta = rowMetaMap?.get(id);
|
|
486
|
+
if (meta?.hasChildren && Boolean(meta.isExpanded) !== (key === 'ArrowRight')) toggleRowExpansion(id);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
301
489
|
|
|
302
|
-
|
|
303
|
-
|
|
490
|
+
if (!NAVIGATION_KEYS.has(key)) return;
|
|
491
|
+
// Prevent native scrolling even when the focus cannot move (grid edges).
|
|
492
|
+
event.preventDefault();
|
|
493
|
+
|
|
494
|
+
const lastRow = allRenderableRows.length - 1;
|
|
495
|
+
const lastCol = navigationColumns.length - 1;
|
|
496
|
+
const withModifier = event.ctrlKey || event.metaKey;
|
|
497
|
+
const page = pagination ? pageSize : 10;
|
|
498
|
+
|
|
499
|
+
const step = (r: number, c: number): { r: number; c: number } => {
|
|
500
|
+
switch (key) {
|
|
501
|
+
case 'ArrowRight': return c + 1 > lastCol ? { r: r + 1, c: 0 } : { r, c: c + 1 };
|
|
502
|
+
case 'ArrowLeft': return c - 1 < 0 ? { r: r - 1, c: lastCol } : { r, c: c - 1 };
|
|
503
|
+
case 'ArrowDown': return { r: r + 1, c };
|
|
504
|
+
case 'ArrowUp': return { r: r - 1, c };
|
|
505
|
+
case 'Home': return { r: withModifier ? -1 : r, c: 0 };
|
|
506
|
+
case 'End': return { r: withModifier ? lastRow : r, c: lastCol };
|
|
507
|
+
case 'PageUp': return { r: Math.max(-1, r - page), c };
|
|
508
|
+
default: return { r: Math.min(lastRow, r + page), c };
|
|
304
509
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const el = viewportRef.current;
|
|
318
|
-
if (el) {
|
|
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);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (columnMetrics) {
|
|
328
|
-
const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = columnMetrics;
|
|
329
|
-
const unpinnedIndex = unpinnedCols.findIndex(c => c.field === nextCol.field);
|
|
330
|
-
|
|
331
|
-
if (unpinnedIndex !== -1) {
|
|
332
|
-
const colLeft = totalSpecialsWidth + leftPinnedWidth + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
|
|
333
|
-
const colRight = totalSpecialsWidth + leftPinnedWidth + unpinnedAccWidths[unpinnedIndex];
|
|
334
|
-
const visibleStart = scrollLeft + pinnedSpecialsWidth + leftPinnedWidth;
|
|
335
|
-
const visibleEnd = scrollLeft + clientWidth - rightPinnedWidth;
|
|
336
|
-
|
|
337
|
-
let newScrollLeft = scrollLeft;
|
|
338
|
-
if (colLeft < visibleStart) {
|
|
339
|
-
newScrollLeft = Math.max(0, colLeft - pinnedSpecialsWidth - leftPinnedWidth);
|
|
340
|
-
} else if (colRight > visibleEnd) {
|
|
341
|
-
newScrollLeft = colRight - clientWidth + rightPinnedWidth;
|
|
342
|
-
}
|
|
343
|
-
if (newScrollLeft !== scrollLeft) el.scrollLeft = newScrollLeft;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
510
|
+
};
|
|
511
|
+
const inGrid = ({ r, c }: { r: number; c: number }) => r >= -1 && r <= lastRow && c >= 0 && c <= lastCol;
|
|
512
|
+
const spanOriginAt = ({ r, c }: { r: number; c: number }) =>
|
|
513
|
+
r >= 0 ? getSpanOrigin?.(getRowId(allRenderableRows[r]), navigationColumns[c].field) ?? null : null;
|
|
514
|
+
|
|
515
|
+
let next = step(rowIndex, colIndex);
|
|
516
|
+
// Arrow keys step over the cells of the focused span (its own merged area) ...
|
|
517
|
+
if (key.startsWith('Arrow')) {
|
|
518
|
+
for (let origin = inGrid(next) ? spanOriginAt(next) : null;
|
|
519
|
+
origin && origin.rowId === id && origin.field === field;
|
|
520
|
+
origin = inGrid(next) ? spanOriginAt(next) : null) {
|
|
521
|
+
next = step(next.r, next.c);
|
|
347
522
|
}
|
|
348
523
|
}
|
|
524
|
+
if (!inGrid(next)) return;
|
|
525
|
+
// ... then land on the origin of whatever span covers the target cell.
|
|
526
|
+
const origin = spanOriginAt(next);
|
|
527
|
+
if (origin) {
|
|
528
|
+
const originRow = allRenderableRows.findIndex(r => getRowId(r) === origin.rowId);
|
|
529
|
+
const originCol = navigationColumns.findIndex(c => c.field === origin.field);
|
|
530
|
+
if (originRow !== -1 && originCol !== -1) next = { r: originRow, c: originCol };
|
|
531
|
+
}
|
|
532
|
+
if (next.r === rowIndex && next.c === colIndex) return;
|
|
533
|
+
moveTo(next.r, next.c);
|
|
349
534
|
}, [
|
|
350
535
|
focusedCell,
|
|
536
|
+
focusedRowIndex,
|
|
351
537
|
allRenderableRows,
|
|
352
538
|
navigationColumns,
|
|
353
539
|
editingHandlers,
|
|
354
|
-
virtualization,
|
|
355
540
|
selectedRowIds,
|
|
356
541
|
handleSelectionChange,
|
|
357
542
|
handleDetailPanelToggle,
|
|
358
543
|
setKeyboardMode,
|
|
359
544
|
sortModel,
|
|
360
545
|
handleSort,
|
|
546
|
+
handleSortAdd,
|
|
547
|
+
multiSort,
|
|
361
548
|
isCellEditable,
|
|
362
549
|
pagination,
|
|
363
550
|
pageSize,
|
|
364
|
-
|
|
365
|
-
|
|
551
|
+
rowMetaMap,
|
|
552
|
+
onRowActivate,
|
|
553
|
+
toggleRowExpansion,
|
|
554
|
+
handleSelectAll,
|
|
555
|
+
rowSelectionEnabled,
|
|
556
|
+
multipleRowSelectionEnabled,
|
|
557
|
+
getDefaultFocus,
|
|
558
|
+
scrollCellIntoView,
|
|
559
|
+
getSpanOrigin,
|
|
560
|
+
getRowId,
|
|
366
561
|
]);
|
|
367
562
|
|
|
368
|
-
return {
|
|
563
|
+
return {
|
|
564
|
+
focusedCell,
|
|
565
|
+
setFocusedCell,
|
|
566
|
+
viewportTabIndex: focusWithin ? -1 : 0,
|
|
567
|
+
ensureGridFocus,
|
|
568
|
+
handleFocus,
|
|
569
|
+
handleBlur,
|
|
570
|
+
handleKeyDown,
|
|
571
|
+
handleMouseDownCapture,
|
|
572
|
+
};
|
|
369
573
|
}
|