@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.
Files changed (225) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/README.md +95 -72
  3. package/dist/index.d.ts +590 -131
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7891 -5631
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +17 -23
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +348 -166
  10. package/docs/README.md +6 -1
  11. package/docs/architecture/datagrid-orchestration.md +81 -0
  12. package/docs/architecture/grid-row-meta.md +102 -0
  13. package/docs/architecture/use-grid-columns.md +128 -0
  14. package/docs/architecture/use-grid-controlled-state.md +157 -0
  15. package/docs/architecture/use-grid-row-pipeline.md +120 -0
  16. package/docs/architecture/use-grid-scroll-sync.md +111 -0
  17. package/docs/architecture/use-grid-state-snapshot.md +90 -0
  18. package/docs/architecture/use-grid-virtualization.md +115 -0
  19. package/docs/architecture/use-grid-visible-rows.md +85 -0
  20. package/docs/assets/banner-alt.png +0 -0
  21. package/docs/assets/banner.png +0 -0
  22. package/docs/assets/hero.png +0 -0
  23. package/docs/assets/logo.png +0 -0
  24. package/docs/components/aggregation-footer.md +15 -10
  25. package/docs/components/cell.md +16 -6
  26. package/docs/components/column-group-header.md +55 -28
  27. package/docs/components/column-resize.md +17 -13
  28. package/docs/components/column-visibility.md +6 -7
  29. package/docs/components/datagrid.md +15 -13
  30. package/docs/components/empty-state.md +2 -1
  31. package/docs/components/error-overlay.md +12 -7
  32. package/docs/components/filter-panel.md +28 -8
  33. package/docs/components/header.md +28 -9
  34. package/docs/components/pagination.md +14 -8
  35. package/docs/components/row.md +21 -5
  36. package/docs/components/toolbar.md +27 -9
  37. package/docs/components/tooltip.md +16 -7
  38. package/docs/customization/slots-api.md +90 -38
  39. package/docs/customization/theming.md +66 -18
  40. package/docs/features/aggregation-pivot.md +75 -9
  41. package/docs/features/cell-spanning.md +80 -125
  42. package/docs/features/clipboard.md +41 -16
  43. package/docs/features/custom-pagination.md +38 -18
  44. package/docs/features/data-source.md +36 -13
  45. package/docs/features/editing-reordering.md +125 -16
  46. package/docs/features/export-guide.md +126 -39
  47. package/docs/features/filtering.md +44 -6
  48. package/docs/features/infinite-scroll.md +54 -46
  49. package/docs/features/keyboard-navigation.md +83 -0
  50. package/docs/features/list-view.md +58 -5
  51. package/docs/features/loading-states.md +18 -20
  52. package/docs/features/master-detail.md +18 -5
  53. package/docs/features/pdf-export.md +72 -19
  54. package/docs/features/pinning.md +24 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +64 -14
  57. package/docs/features/state-persistence.md +78 -10
  58. package/docs/features/toolbar-customization.md +26 -17
  59. package/docs/features/tree-data-grouping.md +36 -8
  60. package/docs/features/virtualization.md +37 -14
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1233 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +5 -5
  65. package/lib/components/Cell/Cell.css +17 -1
  66. package/lib/components/Cell/Cell.tsx +129 -57
  67. package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
  68. package/lib/components/Cell/GridEditInputCell.tsx +114 -41
  69. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
  70. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +15 -2
  71. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
  72. package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
  73. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
  74. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
  75. package/lib/components/DataGrid/DataGrid.css +85 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +582 -876
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  79. package/lib/components/DataGrid/GridListView.tsx +165 -24
  80. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  81. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  82. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  83. package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
  84. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  85. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  86. package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
  87. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  88. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  89. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  90. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  91. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  92. package/lib/components/Header/ColumnMenu.tsx +91 -39
  93. package/lib/components/Header/Header.css +6 -3
  94. package/lib/components/Header/Header.tsx +181 -160
  95. package/lib/components/ListView/ListView.css +25 -0
  96. package/lib/components/ListView/ListViewRow.tsx +76 -10
  97. package/lib/components/Pagination/Pagination.tsx +15 -7
  98. package/lib/components/Row/Row.tsx +177 -77
  99. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  100. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  101. package/lib/components/Toolbar/GridToolbar.css +12 -11
  102. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  103. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  104. package/lib/components/Tooltip/Tooltip.css +20 -0
  105. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  106. package/lib/components/ui/Button.tsx +3 -0
  107. package/lib/components/ui/Checkbox.tsx +2 -1
  108. package/lib/components/ui/ExpandIcon.css +0 -9
  109. package/lib/components/ui/ExpandIcon.tsx +4 -1
  110. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  111. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  112. package/lib/hooks/core/useDataGrid.ts +115 -173
  113. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  114. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  115. package/lib/hooks/core/useGridApiRef.ts +19 -4
  116. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  117. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  118. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  119. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  120. package/lib/hooks/core/useGridColumns.tsx +279 -76
  121. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  122. package/lib/hooks/core/useGridControlledState.ts +99 -6
  123. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  124. package/lib/hooks/core/useGridDevWarnings.ts +104 -5
  125. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  126. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  127. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  128. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  129. package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
  130. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  131. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  132. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  133. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  134. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  135. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  136. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  137. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  138. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  139. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  140. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  141. package/lib/hooks/core/useGridViewport.ts +38 -0
  142. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  143. package/lib/hooks/core/useGridVisibleRows.ts +17 -5
  144. package/lib/hooks/core/useLayout.ts +215 -195
  145. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  146. package/lib/hooks/core/useStableColumns.ts +34 -0
  147. package/lib/hooks/features/useAggregation.ts +82 -51
  148. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  149. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  150. package/lib/hooks/features/useGridClipboard.ts +133 -73
  151. package/lib/hooks/features/useGridDataSource.ts +354 -128
  152. package/lib/hooks/features/useGridEditing.ts +211 -51
  153. package/lib/hooks/features/useGridPivot.ts +92 -0
  154. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  155. package/lib/hooks/features/useGridSpanning.ts +410 -306
  156. package/lib/hooks/features/usePivot.ts +0 -0
  157. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  158. package/lib/hooks/useColumnReorder.ts +38 -44
  159. package/lib/hooks/useRowGrouping.ts +298 -160
  160. package/lib/hooks/useRowReorder.ts +62 -31
  161. package/lib/hooks/useTreeData.ts +287 -220
  162. package/lib/index.ts +28 -2
  163. package/lib/state/index.ts +1 -0
  164. package/lib/state/types.ts +7 -1
  165. package/lib/state/useGridStateStorage.ts +88 -29
  166. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  167. package/lib/theme/gridThemeContext.ts +5 -0
  168. package/lib/theme/index.ts +5 -0
  169. package/lib/theme/presets.ts +5 -4
  170. package/lib/theme/types.ts +40 -2
  171. package/lib/types/index.ts +313 -116
  172. package/lib/utils/aggregation/index.ts +119 -24
  173. package/lib/utils/aria/index.ts +40 -0
  174. package/lib/utils/columnGroups.ts +110 -0
  175. package/lib/utils/columnLookup.ts +33 -0
  176. package/lib/utils/columnWidth.ts +22 -0
  177. package/lib/utils/dataSource/index.ts +46 -0
  178. package/lib/utils/detailPanel.ts +66 -0
  179. package/lib/utils/dragData.ts +44 -0
  180. package/lib/utils/editing/index.ts +30 -0
  181. package/lib/utils/export/exportShared.ts +197 -0
  182. package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
  183. package/lib/utils/export/exportToPdf.ts +143 -92
  184. package/lib/utils/export/groupLabel.ts +14 -0
  185. package/lib/utils/export/index.ts +278 -272
  186. package/lib/utils/export/pdfText.ts +50 -0
  187. package/lib/utils/filtering/index.ts +277 -148
  188. package/lib/utils/focus/index.ts +115 -0
  189. package/lib/utils/gridApiRows.ts +35 -0
  190. package/lib/utils/gridModes.ts +77 -0
  191. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  192. package/lib/utils/pagination/index.ts +56 -0
  193. package/lib/utils/pinning/index.ts +77 -33
  194. package/lib/utils/pivot/index.ts +368 -0
  195. package/lib/utils/scroll/index.ts +55 -5
  196. package/lib/utils/scroll/scrollEnd.ts +18 -0
  197. package/lib/utils/sorting/index.ts +154 -30
  198. package/lib/utils/syntheticRows.ts +12 -0
  199. package/lib/utils/values.ts +206 -0
  200. package/lib/utils/viewport.ts +14 -0
  201. package/llms.txt +261 -164
  202. package/package.json +13 -5
  203. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  204. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  205. package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
  206. package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
  207. package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
  208. package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
  209. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
  210. package/lib/components/Header/Header.test.tsx +0 -135
  211. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  212. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  213. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  214. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  215. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  216. package/lib/hooks/features/useAggregation.test.ts +0 -175
  217. package/lib/hooks/features/usePivot.test.ts +0 -202
  218. package/lib/hooks/useRowGrouping.test.ts +0 -298
  219. package/lib/hooks/useTreeData.test.ts +0 -83
  220. package/lib/utils/aggregation/aggregation.test.ts +0 -16
  221. package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
  222. package/lib/utils/export/exportToPdf.test.ts +0 -120
  223. package/lib/utils/export/groupedExport.test.ts +0 -144
  224. package/lib/utils/filtering/filtering.test.ts +0 -178
  225. 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 type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection } from '../../types';
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
- navigationColumns: { field: string; editable?: boolean; sortable?: boolean }[];
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
- export interface FocusedCell {
47
- id: GridRowId;
48
- field: string;
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 [focusedCell, setFocusedCell] = useState<FocusedCell | null>(null);
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
- if (event.target === event.currentTarget) {
85
- setFocusedCell(prev => {
86
- if (prev) return prev;
87
- if (checkboxSelection) {
88
- return { id: 'HEADER', field: '__checkbox_col__' };
89
- }
90
- const firstRow = allRenderableRows[0];
91
- const firstCol = navigationColumns[0];
92
- if (firstRow && firstCol) {
93
- return { id: firstRow.id, field: firstCol.field };
94
- }
95
- return null;
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
- }, [allRenderableRows, navigationColumns, checkboxSelection]);
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
- if (!event.currentTarget.contains(event.relatedTarget as Node)) {
102
- setFocusedCell(null);
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
- if (!focusedCell) return;
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 { id, field } = focusedCell;
341
+ const { key } = event;
342
+ const isSpace = key === ' ' || key === 'Spacebar';
112
343
  const isEditing = Boolean(editingHandlers.editingCell);
113
344
 
114
- if (id === 'HEADER' && (event.key === 'Enter' || event.key === ' ')) {
115
- event.preventDefault();
116
- const col = navigationColumns.find(c => c.field === field);
117
- if (col && col.sortable !== false) {
118
- const currentSort = sortModel.find(item => item.field === field);
119
- let newDirection: GridSortDirection = 'asc';
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 (event.key === 'Enter') {
352
+ if (!focusedCell) {
353
+ if (!NAVIGATION_KEYS.has(key)) return;
354
+ const initial = getDefaultFocus();
355
+ if (!initial) return;
129
356
  event.preventDefault();
130
- if (isEditing) {
131
- editingHandlers.stopCellEdit();
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
- if (event.key === ' ' || event.key === 'Spacebar') {
145
- if (!isEditing) {
146
- if (field === '__checkbox_col__') {
147
- event.preventDefault();
148
- handleSelectionChange(id, !selectedRowIds.has(id));
149
- return;
150
- }
151
- if (field === '__expand_col__') {
152
- event.preventDefault();
153
- handleDetailPanelToggle(id);
154
- return;
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 (event.key === 'Escape') {
160
- if (isEditing) {
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
- if (isEditing && event.key !== 'Tab') return;
168
-
169
- const isHeader = id === 'HEADER';
170
- const rowIndex = isHeader ? -1 : allRenderableRows.findIndex(r => r.id === id);
171
- const colIndex = navigationColumns.findIndex(c => c.field === field);
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
- if (wrapRow && deltaCol !== 0) {
217
- if (deltaCol > 0) {
218
- if (c >= navigationColumns.length) { c = 0; r++; }
219
- } else {
220
- if (c < 0) { c = navigationColumns.length - 1; r--; }
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
- if (r === -1) {
229
- if (!allowHeader) continue;
230
- const col = navigationColumns[c];
231
- const isSortable = col.sortable !== false;
232
- if (['__checkbox_col__'].includes(col.field) || isSortable) return { r, c };
233
- continue;
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
- const row = allRenderableRows[r];
237
- const col = navigationColumns[c];
238
- const isInteractable = ['__checkbox_col__', '__expand_col__', '__reorder_col__'].includes(col.field);
239
- let cellEditable = col.editable || isInteractable;
240
-
241
- if (isCellEditable && !isInteractable) {
242
- try {
243
- cellEditable = isCellEditable({
244
- row,
245
- field: col.field,
246
- value: (row as R & Record<string, unknown>)[col.field],
247
- colDef: col as GridColDef<R>,
248
- rowIndex: r,
249
- colIndex: c
250
- });
251
- } catch {
252
- // isCellEditable threw — treat cell as non-editable
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 null;
259
- };
260
-
261
- let nextRowIndex = rowIndex;
262
- let nextColIndex = colIndex;
263
- let handled = false;
264
-
265
- if (event.key === 'Tab') {
266
- const dir = event.shiftKey ? -1 : 1;
267
- const res = findNextEditable(rowIndex, colIndex, 0, dir, true, false);
268
- if (res) { nextRowIndex = res.r; nextColIndex = res.c; handled = true; }
269
- } else if (event.key === 'ArrowRight') {
270
- const res = findNextCell(rowIndex, colIndex, 0, 1, true, true);
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
- if (handled) {
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
- if (isEditing && event.key === 'Tab') {
303
- editingHandlers.stopCellEdit();
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
- if (nextRowIndex >= -1 && nextRowIndex < allRenderableRows.length &&
307
- nextColIndex >= 0 && nextColIndex < navigationColumns.length) {
308
-
309
- const nextCol = navigationColumns[nextColIndex];
310
-
311
- if (nextRowIndex === -1) {
312
- setFocusedCell({ id: 'HEADER', field: nextCol.field });
313
- } else {
314
- setFocusedCell({ id: allRenderableRows[nextRowIndex].id, field: nextCol.field });
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
- viewportRef,
365
- pinnedTopRowCount,
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 { focusedCell, setFocusedCell, handleFocus, handleBlur, handleKeyDown };
563
+ return {
564
+ focusedCell,
565
+ setFocusedCell,
566
+ viewportTabIndex: focusWithin ? -1 : 0,
567
+ ensureGridFocus,
568
+ handleFocus,
569
+ handleBlur,
570
+ handleKeyDown,
571
+ handleMouseDownCapture,
572
+ };
369
573
  }