@opencorestack/opengridx 2.0.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +408 -3
  2. package/README.md +93 -72
  3. package/dist/index.d.ts +611 -121
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7992 -5650
  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 -161
  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 +45 -22
  29. package/docs/components/datagrid.md +14 -12
  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 +100 -36
  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 +147 -41
  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 +56 -19
  54. package/docs/features/pinning.md +23 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +66 -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 +44 -9
  60. package/docs/features/virtualization.md +64 -15
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1222 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +16 -10
  65. package/lib/components/Cell/Cell.css +13 -1
  66. package/lib/components/Cell/Cell.tsx +130 -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 +11 -0
  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 +84 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +606 -872
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  79. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  80. package/lib/components/DataGrid/GridListView.tsx +166 -23
  81. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  82. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  83. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  84. package/lib/components/DataGrid/GridPinnedRows.tsx +65 -11
  85. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  86. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  87. package/lib/components/DataGrid/GridVirtualRows.tsx +48 -25
  88. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  89. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  90. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  91. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  92. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  93. package/lib/components/Header/ColumnMenu.tsx +91 -39
  94. package/lib/components/Header/Header.css +3 -3
  95. package/lib/components/Header/Header.tsx +179 -160
  96. package/lib/components/ListView/ListView.css +25 -0
  97. package/lib/components/ListView/ListViewRow.tsx +79 -10
  98. package/lib/components/Pagination/Pagination.tsx +15 -7
  99. package/lib/components/Row/Row.tsx +185 -75
  100. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  101. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  102. package/lib/components/Toolbar/GridToolbar.css +12 -11
  103. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  104. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  105. package/lib/components/Tooltip/Tooltip.css +20 -0
  106. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  107. package/lib/components/ui/Button.tsx +3 -0
  108. package/lib/components/ui/Checkbox.tsx +2 -1
  109. package/lib/components/ui/ExpandIcon.css +0 -9
  110. package/lib/components/ui/ExpandIcon.tsx +4 -1
  111. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  112. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  113. package/lib/hooks/core/useDataGrid.ts +115 -173
  114. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  115. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  116. package/lib/hooks/core/useGridApiRef.ts +15 -3
  117. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  118. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  119. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  120. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  121. package/lib/hooks/core/useGridColumns.tsx +284 -76
  122. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  123. package/lib/hooks/core/useGridControlledState.ts +99 -6
  124. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  125. package/lib/hooks/core/useGridDevWarnings.ts +123 -0
  126. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  127. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  128. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  129. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  130. package/lib/hooks/core/useGridKeyboardNavigation.ts +452 -250
  131. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  132. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  133. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  134. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  135. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  136. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  137. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  138. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  139. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  140. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  141. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  142. package/lib/hooks/core/useGridViewport.ts +38 -0
  143. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  144. package/lib/hooks/core/useGridVisibleRows.ts +20 -7
  145. package/lib/hooks/core/useLayout.ts +215 -195
  146. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  147. package/lib/hooks/core/useStableColumns.ts +34 -0
  148. package/lib/hooks/features/useAggregation.ts +86 -90
  149. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  150. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  151. package/lib/hooks/features/useGridClipboard.ts +133 -73
  152. package/lib/hooks/features/useGridDataSource.ts +354 -128
  153. package/lib/hooks/features/useGridEditing.ts +211 -51
  154. package/lib/hooks/features/useGridPivot.ts +92 -0
  155. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  156. package/lib/hooks/features/useGridSpanning.ts +410 -306
  157. package/lib/hooks/features/usePivot.ts +0 -0
  158. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  159. package/lib/hooks/useColumnReorder.ts +38 -44
  160. package/lib/hooks/useRowGrouping.ts +335 -214
  161. package/lib/hooks/useRowReorder.ts +62 -31
  162. package/lib/hooks/useTreeData.ts +330 -262
  163. package/lib/index.ts +30 -2
  164. package/lib/state/index.ts +1 -0
  165. package/lib/state/types.ts +7 -1
  166. package/lib/state/useGridStateStorage.ts +88 -29
  167. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  168. package/lib/theme/gridThemeContext.ts +5 -0
  169. package/lib/theme/index.ts +5 -0
  170. package/lib/theme/presets.ts +5 -4
  171. package/lib/theme/types.ts +40 -2
  172. package/lib/types/index.ts +311 -108
  173. package/lib/utils/aggregation/index.ts +157 -0
  174. package/lib/utils/aria/index.ts +40 -0
  175. package/lib/utils/columnGroups.ts +110 -0
  176. package/lib/utils/columnLookup.ts +33 -0
  177. package/lib/utils/columnWidth.ts +22 -0
  178. package/lib/utils/dataSource/index.ts +46 -0
  179. package/lib/utils/detailPanel.ts +66 -0
  180. package/lib/utils/dragData.ts +44 -0
  181. package/lib/utils/editing/index.ts +30 -0
  182. package/lib/utils/export/exportShared.ts +197 -0
  183. package/lib/utils/export/exportToExcelAdvanced.ts +344 -121
  184. package/lib/utils/export/exportToPdf.ts +143 -92
  185. package/lib/utils/export/groupLabel.ts +14 -0
  186. package/lib/utils/export/index.ts +278 -272
  187. package/lib/utils/export/pdfText.ts +50 -0
  188. package/lib/utils/filtering/index.ts +277 -148
  189. package/lib/utils/focus/index.ts +115 -0
  190. package/lib/utils/gridApiRows.ts +35 -0
  191. package/lib/utils/gridModes.ts +77 -0
  192. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  193. package/lib/utils/pagination/index.ts +56 -0
  194. package/lib/utils/pinning/index.ts +77 -33
  195. package/lib/utils/pivot/index.ts +368 -0
  196. package/lib/utils/scroll/index.ts +78 -0
  197. package/lib/utils/scroll/scrollEnd.ts +18 -0
  198. package/lib/utils/sorting/index.ts +154 -30
  199. package/lib/utils/syntheticRows.ts +12 -0
  200. package/lib/utils/values.ts +206 -0
  201. package/lib/utils/viewport.ts +14 -0
  202. package/llms.txt +272 -166
  203. package/package.json +18 -6
  204. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  205. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  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/Header/Header.test.tsx +0 -135
  210. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  211. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  212. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  213. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  214. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  215. package/lib/hooks/features/useAggregation.test.ts +0 -175
  216. package/lib/hooks/features/usePivot.test.ts +0 -202
  217. package/lib/hooks/useRowGrouping.test.ts +0 -137
  218. package/lib/utils/export/exportToPdf.test.ts +0 -120
  219. package/lib/utils/export/groupedExport.test.ts +0 -144
  220. package/lib/utils/filtering/filtering.test.ts +0 -178
  221. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -7,16 +7,51 @@ import { DragHandleIcon } from '../ui/DragHandleIcon';
7
7
  import { DetailPanel } from '../DetailPanel/DetailPanel';
8
8
  import type { GridRowModel, GridColDef, GridRowId, GridColumnPinning, GridRowPinning, GridRowParams, GridCellParams, GridDetailPanelHeight, GridRowMeta } from '../../types';
9
9
  import type { CellColSpanInfo, RowSpanningCaches } from '../../hooks/features/useGridSpanning';
10
- import { isColumnPinned, calculatePinnedPositions, isRowPinned } from '../../utils/pinning';
11
-
10
+ import { isColumnPinned, calculatePinnedPositions, getPinnedEdgeFields, isRowPinned } from '../../utils/pinning';
11
+ import { resolveCellEditable } from '../../utils/editing';
12
+ import { tryGetCellValue } from '../../utils/values';
13
+ import { isSyntheticRowId } from '../../utils/syntheticRows';
14
+ import { getRenderedColumnWidth } from '../../utils/columnWidth';
15
+ import { DEFAULT_DETAIL_PANEL_HEIGHT } from '../../utils/detailPanel';
16
+
17
+ /** Sticky system cells (drag handle, detail toggle, checkbox) sit above scrolled, spanned and focused cells. */
18
+ const SYSTEM_CELL_Z_INDEX = 12;
19
+
20
+
21
+ /**
22
+ * Builds the Cell span info for a colSpan origin: the merged width and flex-grow are the sums over
23
+ * the columns it covers, taken from the rendered (layout-resolved) columns. The span never crosses a
24
+ * spacer, so a span cut by the column window only covers what is rendered.
25
+ */
26
+ function mergeColSpan<R extends GridRowModel>(
27
+ columns: GridColDef<R>[],
28
+ originIndex: number,
29
+ colSpan: number,
30
+ columnWidths: Record<string, number>,
31
+ ): CellColSpanInfo {
32
+ let width = 0;
33
+ let flexGrow = 0;
34
+ let covered = 0;
35
+ for (let i = originIndex; i < columns.length && covered < colSpan; i++) {
36
+ const col = columns[i];
37
+ if (col.isSpacer) break;
38
+ width += getRenderedColumnWidth(col, columnWidths);
39
+ flexGrow += col.flex ?? 0;
40
+ covered++;
41
+ }
42
+ return { spannedByColSpan: false, cellProps: { colSpan: covered, width, flexGrow } };
43
+ }
12
44
 
13
45
  export interface RowProps<R extends GridRowModel = GridRowModel> {
14
46
  row: R;
47
+ /** The row's id (from getRowId). Defaults to `row.id`; the grid always passes it. */
48
+ rowId?: GridRowId;
15
49
  columns: GridColDef<R>[];
16
50
  rowIndex: number;
17
51
  isSelected?: boolean;
18
52
  checkboxSelection?: boolean;
19
53
  onRowClick?: (params: GridRowParams<R>) => void;
54
+ onRowDoubleClick?: (params: GridRowParams<R>) => void;
20
55
  onCellClick?: (params: GridCellParams<R>) => void;
21
56
  onSelectionChange?: (rowId: GridRowId, isSelected: boolean) => void;
22
57
  columnWidths?: Record<string, number>;
@@ -28,6 +63,8 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
28
63
  onDetailPanelToggle?: (rowId: GridRowId) => void;
29
64
  detailPanelContent?: React.ReactNode;
30
65
  detailPanelHeight?: GridDetailPanelHeight;
66
+ /** Called with the rendered height of an `'auto'` detail panel whenever it changes. */
67
+ onDetailPanelHeightChange?: (rowId: GridRowId, height: number) => void;
31
68
 
32
69
  pinCheckboxColumn?: boolean;
33
70
  pinExpandColumn?: boolean;
@@ -42,8 +79,11 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
42
79
 
43
80
  editingCell?: { id: GridRowId; field: string; value: unknown; } | null;
44
81
  onEditStart?: (params: { id: GridRowId, field: string, value: unknown }) => void;
45
- onEditStop?: (params?: { cancel?: boolean }) => void;
82
+ /** `id` / `field` name the cell whose editor asked to stop. */
83
+ onEditStop?: (params?: { cancel?: boolean; id?: GridRowId; field?: string }) => void;
46
84
  onEditCellValueChange?: (params: { id: GridRowId, field: string, value: unknown }) => void;
85
+ /** Per-cell editability predicate (`DataGrid.isCellEditable`). */
86
+ isCellEditable?: (params: GridCellParams<R>) => boolean;
47
87
 
48
88
  focusedCellField?: string | null;
49
89
  isFocusVisible?: boolean;
@@ -52,16 +92,22 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
52
92
  rowSpanningCaches?: RowSpanningCaches;
53
93
  rowHeight?: number;
54
94
  rowMeta?: GridRowMeta;
95
+ /** Position of each visible data column in render order, for `colIndex` and `aria-colindex`. */
96
+ columnIndexMap?: Map<string, number>;
97
+ /** 1-based `aria-rowindex` of this row in the whole grid (header rows included). Defaults to `rowIndex + 2`. */
98
+ ariaRowIndex?: number;
55
99
  }
56
100
 
57
101
  export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
58
102
  const {
59
103
  row,
104
+ rowId: rowIdProp,
60
105
  columns,
61
106
  rowIndex,
62
107
  isSelected = false,
63
108
  checkboxSelection = false,
64
109
  onRowClick,
110
+ onRowDoubleClick,
65
111
  onCellClick,
66
112
  onSelectionChange,
67
113
  columnWidths = {},
@@ -71,7 +117,8 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
71
117
  isDetailPanelExpanded = false,
72
118
  onDetailPanelToggle,
73
119
  detailPanelContent,
74
- detailPanelHeight = 200,
120
+ detailPanelHeight = DEFAULT_DETAIL_PANEL_HEIGHT,
121
+ onDetailPanelHeightChange,
75
122
  pinCheckboxColumn = true,
76
123
  pinExpandColumn = true,
77
124
  rowReordering = false,
@@ -86,6 +133,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
86
133
  onEditStart,
87
134
  onEditStop,
88
135
  onEditCellValueChange,
136
+ isCellEditable,
89
137
 
90
138
  focusedCellField,
91
139
  isFocusVisible,
@@ -93,22 +141,16 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
93
141
  colspanMap,
94
142
  rowSpanningCaches,
95
143
  rowHeight = 52,
96
- rowMeta
144
+ rowMeta,
145
+ columnIndexMap,
146
+ ariaRowIndex,
97
147
  } = props;
98
148
 
149
+ const id = rowIdProp ?? row.id;
150
+ // All hooks must come before any early returns (Rules of Hooks).
151
+ // DOM focus is moved onto the focused cell by useGridKeyboardNavigation, not by the row.
99
152
  const checkboxId = useId();
100
- const expandCellRef = React.useRef<HTMLDivElement>(null);
101
- const checkboxCellRef = React.useRef<HTMLDivElement>(null);
102
-
103
- // All hooks must come before any early returns (Rules of Hooks)
104
- React.useLayoutEffect(() => {
105
- if (focusedCellField === '__expand_col__' && expandCellRef.current) {
106
- expandCellRef.current.focus({ preventScroll: true });
107
- }
108
- if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
109
- checkboxCellRef.current.focus({ preventScroll: true });
110
- }
111
- }, [focusedCellField]);
153
+ const detailPanelId = useId();
112
154
 
113
155
  const pinnedPositions = React.useMemo(() => {
114
156
  return calculatePinnedPositions(
@@ -123,21 +165,28 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
123
165
  );
124
166
  }, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
125
167
 
126
- const isGroupRow = rowMeta?.hasChildren === true;
168
+ const pinnedEdges = React.useMemo(() => getPinnedEdgeFields(columns, pinnedColumns), [columns, pinnedColumns]);
127
169
 
170
+ const isGroupRow = rowMeta?.hasChildren === true;
171
+ // Group rows, subtotal rows and auto-created tree parents: not data, so no selection or detail panel.
172
+ const isSyntheticRow = rowMeta?.isGroupRow === true;
173
+ const showDetailPanel = hasDetailPanel && !isSyntheticRow;
174
+ // The pivot Grand Total is synthetic too, but its cells show values like any row.
175
+ const isSelectableRow = !isSyntheticRow && !isSyntheticRowId(id);
176
+
177
+ // Which cells may be edited is decided per cell below (resolveCellEditable), not per row:
178
+ // tree-data parents are real rows and stay editable; synthetic group rows never are.
128
179
  const handleCellEditStart = React.useCallback((field: string, value: unknown) => {
129
- if (!isGroupRow) {
130
- onEditStart?.({ id: row.id, field, value });
131
- }
132
- }, [isGroupRow, row.id, onEditStart]);
180
+ onEditStart?.({ id, field, value });
181
+ }, [id, onEditStart]);
133
182
 
134
183
  const handleCellValueChange = React.useCallback((field: string, newValue: unknown) => {
135
- onEditCellValueChange?.({ id: row.id, field, value: newValue });
136
- }, [onEditCellValueChange, row.id]);
184
+ onEditCellValueChange?.({ id, field, value: newValue });
185
+ }, [onEditCellValueChange, id]);
137
186
 
138
- const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
139
- onEditStop?.({ cancel });
140
- }, [onEditStop]);
187
+ const handleEditStopWrapper = React.useCallback((cancel?: boolean, field?: string) => {
188
+ onEditStop?.({ cancel, id, field });
189
+ }, [onEditStop, id]);
141
190
 
142
191
  // ── Skeleton row (shown during infinite-scroll fetch) ─────────────────────
143
192
  if (row._isSkeleton) {
@@ -159,25 +208,42 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
159
208
  );
160
209
  }
161
210
 
211
+ // `.ogx__cell--editing` covers custom renderEditCell editors, which have no .ogx__edit-cell wrapper.
162
212
  const handleRowClick = (event: React.MouseEvent) => {
163
213
 
164
- if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell')) {
214
+ if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell, .ogx__cell--editing')) {
165
215
  return;
166
216
  }
167
- onRowClick?.({ row, id: row.id, rowIndex });
217
+ onRowClick?.({ row, id, rowIndex });
218
+ };
219
+
220
+ const handleRowDoubleClick = (event: React.MouseEvent) => {
221
+ if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell, .ogx__cell--editing')) {
222
+ return;
223
+ }
224
+ onRowDoubleClick?.({ row, id, rowIndex });
168
225
  };
169
226
 
170
227
  const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
171
228
  event.stopPropagation();
172
- onSelectionChange?.(row.id, event.target.checked);
229
+ onSelectionChange?.(id, event.target.checked);
173
230
  };
174
231
 
175
232
  const handleDetailPanelToggle = (event: React.MouseEvent) => {
176
233
  event.stopPropagation();
177
- onDetailPanelToggle?.(row.id);
234
+ onDetailPanelToggle?.(id);
178
235
  };
179
236
 
180
- const rowPinnedPosition = isRowPinned(row.id, pinnedRows);
237
+ // aria-colindex: system columns first (reorder, expand, checkbox), then the data columns.
238
+ const reorderColIndex = rowReordering ? 1 : 0;
239
+ const expandColIndex = hasDetailPanel ? reorderColIndex + 1 : reorderColIndex;
240
+ const checkboxColIndex = checkboxSelection ? expandColIndex + 1 : expandColIndex;
241
+ const systemColumnCount = checkboxColIndex;
242
+ const dataColumnCount = columnIndexMap?.size ?? columns.filter(c => !c.isSpacer).length;
243
+ const isHierarchyRow = rowMeta !== undefined;
244
+ const hasChildren = rowMeta?.hasChildren === true;
245
+
246
+ const rowPinnedPosition = isRowPinned(id, pinnedRows);
181
247
  const isRowPinnedTop = rowPinnedPosition === 'top';
182
248
  const isRowPinnedBottom = rowPinnedPosition === 'bottom';
183
249
 
@@ -190,7 +256,8 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
190
256
  isRowPinnedBottom && 'ogx__row--pinned-bottom',
191
257
  isDragging && 'ogx__row--dragging',
192
258
  isDragOver && 'ogx__row--drag-over',
193
- isGroupRow && 'ogx__row--group'
259
+ isGroupRow && 'ogx__row--group',
260
+ rowMeta?.isGroupFooter && 'ogx__row--group-footer'
194
261
  ].filter(Boolean).join(' ');
195
262
 
196
263
  return (
@@ -198,12 +265,15 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
198
265
  <div
199
266
  className={classNames}
200
267
  onClick={handleRowClick}
268
+ onDoubleClick={onRowDoubleClick ? handleRowDoubleClick : undefined}
201
269
  role="row"
202
- aria-rowindex={rowIndex + 1}
270
+ aria-rowindex={ariaRowIndex ?? rowIndex + 2}
203
271
  aria-selected={isSelected}
272
+ aria-level={isHierarchyRow ? (rowMeta?.treeDepth ?? 0) + 1 : undefined}
273
+ aria-expanded={hasChildren ? rowMeta?.isExpanded === true : undefined}
204
274
  data-rowindex={rowIndex}
205
- onDragOver={onDragOver ? onDragOver(row.id) : undefined}
206
- onDrop={onDrop ? onDrop(row.id) : undefined}
275
+ onDragOver={onDragOver ? onDragOver(id) : undefined}
276
+ onDrop={onDrop ? onDrop(id) : undefined}
207
277
  style={{
208
278
  maxHeight: `${rowHeight}px`,
209
279
  minHeight: `${rowHeight}px`,
@@ -214,20 +284,23 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
214
284
  { }
215
285
  {rowReordering && (
216
286
  <div
217
- className="ogx__cell ogx__cell--drag-handle"
287
+ className={`ogx__cell ogx__cell--drag-handle ${focusedCellField === '__reorder_col__' ? 'ogx__cell--focused' : ''} ${(focusedCellField === '__reorder_col__' && isFocusVisible) ? 'ogx__cell--focus-visible' : ''}`}
218
288
  role="gridcell"
219
- aria-label="Drag to reorder row"
289
+ aria-label={onDragStart ? 'Drag to reorder row' : undefined}
290
+ aria-colindex={reorderColIndex}
291
+ data-field="__reorder_col__"
292
+ tabIndex={-1}
220
293
  style={{
221
294
  position: 'sticky',
222
295
  left: 0,
223
- zIndex: 4
296
+ zIndex: SYSTEM_CELL_Z_INDEX
224
297
  }}
225
- draggable={true}
226
- onDragStart={onDragStart ? onDragStart(row.id) : undefined}
298
+ draggable={!!onDragStart}
299
+ onDragStart={onDragStart ? onDragStart(id) : undefined}
227
300
  onDragEnd={onDragEnd}
228
301
  onClick={(e) => e.stopPropagation()}
229
302
  >
230
- <DragHandleIcon />
303
+ {onDragStart && <DragHandleIcon />}
231
304
  </div>
232
305
  )}
233
306
 
@@ -238,14 +311,16 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
238
311
  } ${focusedCellField === '__expand_col__' ? 'ogx__cell--focused' : ''} ${(focusedCellField === '__expand_col__' && isFocusVisible) ? 'ogx__cell--focus-visible' : ''
239
312
  }`}
240
313
  role="gridcell"
241
- aria-label={isDetailPanelExpanded ? 'Collapse row details' : 'Expand row details'}
242
- aria-expanded={isDetailPanelExpanded}
314
+ aria-label={showDetailPanel ? (isDetailPanelExpanded ? 'Collapse row details' : 'Expand row details') : undefined}
315
+ aria-expanded={showDetailPanel ? isDetailPanelExpanded : undefined}
316
+ aria-controls={showDetailPanel && isDetailPanelExpanded ? detailPanelId : undefined}
317
+ aria-colindex={expandColIndex}
318
+ data-field="__expand_col__"
243
319
  style={{
244
320
  position: pinExpandColumn ? 'sticky' : undefined,
245
321
  left: pinExpandColumn ? (rowReordering ? 48 : 0) : undefined,
246
- zIndex: pinExpandColumn ? 5 : undefined
322
+ zIndex: pinExpandColumn ? SYSTEM_CELL_Z_INDEX : undefined
247
323
  }}
248
- ref={expandCellRef}
249
324
  tabIndex={-1}
250
325
  onClick={(e) => {
251
326
  e.stopPropagation();
@@ -259,11 +334,14 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
259
334
  });
260
335
  }}
261
336
  >
262
- <ExpandIcon
263
- isExpanded={isDetailPanelExpanded}
264
- onClick={handleDetailPanelToggle}
265
- variant="plus-minus"
266
- />
337
+ {showDetailPanel && (
338
+ <ExpandIcon
339
+ isExpanded={isDetailPanelExpanded}
340
+ onClick={handleDetailPanelToggle}
341
+ variant="plus-minus"
342
+ tabIndex={-1}
343
+ />
344
+ )}
267
345
  </div>
268
346
  )}
269
347
 
@@ -274,12 +352,13 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
274
352
  } ${focusedCellField === '__checkbox_col__' ? 'ogx__cell--focused' : ''} ${(focusedCellField === '__checkbox_col__' && isFocusVisible) ? 'ogx__cell--focus-visible' : ''
275
353
  }`}
276
354
  role="gridcell"
355
+ aria-colindex={checkboxColIndex}
356
+ data-field="__checkbox_col__"
277
357
  style={{
278
358
  position: pinCheckboxColumn ? 'sticky' : undefined,
279
359
  left: pinCheckboxColumn ? ((rowReordering ? 48 : 0) + (hasDetailPanel && pinExpandColumn ? 48 : 0)) : undefined,
280
- zIndex: pinCheckboxColumn ? 11 : undefined
360
+ zIndex: pinCheckboxColumn ? SYSTEM_CELL_Z_INDEX : undefined
281
361
  }}
282
- ref={checkboxCellRef}
283
362
  tabIndex={-1}
284
363
  onClick={(e) => {
285
364
  e.stopPropagation();
@@ -293,21 +372,23 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
293
372
  });
294
373
  }}
295
374
  >
296
- <Checkbox
297
- id={checkboxId}
298
- name={`ogx-select-row-${row.id}`}
299
- checked={isSelected}
300
- onChange={handleCheckboxChange}
301
- onClick={(e) => e.stopPropagation()}
302
- aria-label={isSelected ? `Deselect row ${row.id}` : `Select row ${row.id}`}
303
- tabIndex={-1}
304
- onMouseDown={(e) => e.preventDefault()}
305
- />
375
+ {isSelectableRow && (
376
+ <Checkbox
377
+ id={checkboxId}
378
+ name={`ogx-select-row-${id}`}
379
+ checked={isSelected}
380
+ onChange={handleCheckboxChange}
381
+ onClick={(e) => e.stopPropagation()}
382
+ aria-label={isSelected ? `Deselect row ${id}` : `Select row ${id}`}
383
+ tabIndex={-1}
384
+ onMouseDown={(e) => e.preventDefault()}
385
+ />
386
+ )}
306
387
  </div>
307
388
  )}
308
389
 
309
390
  { }
310
- {columns.map((colDef, colIndex) => {
391
+ {columns.map((colDef, localIndex) => {
311
392
  if (colDef.isSpacer) {
312
393
  return (
313
394
  <div
@@ -317,22 +398,45 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
317
398
  />
318
399
  );
319
400
  }
320
- const value = colDef.valueGetter
321
- ? colDef.valueGetter({ row, field: colDef.field, value: row[colDef.field] })
322
- : row[colDef.field];
401
+ // A synthetic row holds its grouping value and aggregates under the column fields; a
402
+ // valueGetter written for data rows would recompute (or crash) from fields it lacks.
403
+ // A valueGetter that throws is contained to its cell.
404
+ let value: unknown;
405
+ let valueError: unknown;
406
+ if (isSyntheticRow) {
407
+ value = row[colDef.field];
408
+ } else {
409
+ ({ value, error: valueError } = tryGetCellValue(row, colDef.field, colDef));
410
+ }
411
+ const colIndex = columnIndexMap?.get(colDef.field) ?? localIndex;
323
412
 
324
413
  const effectiveWidth = columnWidths[colDef.field] ?? colDef.width;
325
414
 
326
415
  const pinnedPosition = isColumnPinned(colDef.field, pinnedColumns);
327
- const pinnedOffset = pinnedPosition ? pinnedPositions[colDef.field] : undefined;
328
416
 
329
- const isEditing = editingCell?.id === row.id && editingCell?.field === colDef.field;
417
+ const isEditable = Boolean(onEditStart) && resolveCellEditable(
418
+ { row, field: colDef.field, value, colDef, rowIndex, colIndex, rowMeta },
419
+ isCellEditable
420
+ );
421
+ const isEditing = editingCell?.id === id && editingCell?.field === colDef.field;
330
422
 
331
423
  const cellValue = isEditing ? editingCell?.value : value;
332
424
 
333
- const colSpanInfo = colspanMap?.get(row.id)?.[colDef.field];
334
- const rowSpan = rowSpanningCaches?.spannedCells[row.id]?.[colDef.field];
335
- const isHiddenByRowSpan = rowSpanningCaches?.hiddenCells[row.id]?.[colDef.field] || false;
425
+ const storedColSpanInfo = colspanMap?.get(id)?.[colDef.field];
426
+ const colSpanInfo = storedColSpanInfo && !storedColSpanInfo.spannedByColSpan && storedColSpanInfo.cellProps.colSpan > 1
427
+ ? mergeColSpan(columns, colIndex, storedColSpanInfo.cellProps.colSpan, columnWidths)
428
+ : storedColSpanInfo;
429
+ // A merged cell ends at the last column it covers: a right-pinned one is anchored by
430
+ // that column's right edge, and it is the left-pinned edge cell if that column is.
431
+ const lastCoveredField = colSpanInfo && !colSpanInfo.spannedByColSpan
432
+ ? columns[colIndex + colSpanInfo.cellProps.colSpan - 1]?.field ?? colDef.field
433
+ : colDef.field;
434
+ const offsetField = pinnedPosition === 'right' ? lastCoveredField : colDef.field;
435
+ const pinnedOffset = pinnedPosition ? pinnedPositions[offsetField] : undefined;
436
+ const isPinnedEdge = (pinnedPosition === 'left' && lastCoveredField === pinnedEdges.lastLeft)
437
+ || (pinnedPosition === 'right' && colDef.field === pinnedEdges.firstRight);
438
+ const rowSpan = rowSpanningCaches?.spannedCells[id]?.[colDef.field];
439
+ const isHiddenByRowSpan = rowSpanningCaches?.hiddenCells[id]?.[colDef.field] || false;
336
440
 
337
441
  return (
338
442
  <Cell
@@ -342,16 +446,18 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
342
446
  colDef={colDef}
343
447
  rowIndex={rowIndex}
344
448
  colIndex={colIndex}
449
+ ariaColIndex={systemColumnCount + colIndex + 1}
345
450
  isSelected={isSelected}
346
451
  onCellClick={onCellClick}
347
452
  width={effectiveWidth}
348
453
  pinnedPosition={pinnedPosition}
349
454
  pinnedOffset={pinnedOffset}
455
+ isPinnedEdge={isPinnedEdge}
350
456
 
351
457
  isFocused={focusedCellField === colDef.field}
352
458
  isFocusVisible={isFocusVisible}
353
459
 
354
- isEditable={colDef.editable}
460
+ isEditable={isEditable}
355
461
  isEditing={isEditing}
356
462
  onCellEditStart={handleCellEditStart}
357
463
  onEditStop={handleEditStopWrapper}
@@ -361,20 +467,24 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
361
467
  rowSpan={rowSpan}
362
468
  isHiddenByRowSpan={isHiddenByRowSpan}
363
469
  rowMeta={rowMeta}
470
+ valueError={valueError}
364
471
  />
365
472
  );
366
473
  })}
367
474
  </div>
368
475
 
369
476
  { }
370
- {hasDetailPanel && (
477
+ {showDetailPanel && (
371
478
  <DetailPanel
479
+ id={detailPanelId}
480
+ colSpan={systemColumnCount + dataColumnCount}
372
481
  row={row}
373
- rowId={row.id}
482
+ rowId={id}
374
483
  rowIndex={rowIndex}
375
484
  content={detailPanelContent}
376
485
  height={detailPanelHeight}
377
486
  isExpanded={isDetailPanelExpanded}
487
+ onHeightChange={onDetailPanelHeightChange}
378
488
  />
379
489
  )}
380
490
  </>
@@ -31,12 +31,15 @@
31
31
  ─────────────────────────────────────────────────────────────────────────── */
32
32
  .ogx-global-search--expanded {
33
33
  width: 220px;
34
- border-color: var(--ogx-toolbar-input-focus-border, var(--ogx-color-primary, #3b82f6));
34
+ border-color: var(--ogx-toolbar-input-border, var(--ogx-color-gray-300, #cbd5e1));
35
35
  background: var(--ogx-toolbar-input-bg, var(--ogx-grid-background, #fff));
36
- box-shadow: var(--ogx-toolbar-input-focus-shadow, 0 0 0 3px rgba(59, 130, 246, 0.12));
37
36
  cursor: default;
38
37
  overflow: hidden;
39
38
  }
39
+ .ogx-global-search--expanded:focus-within {
40
+ border-color: var(--ogx-toolbar-input-focus-border, var(--ogx-color-primary, #3b82f6));
41
+ box-shadow: var(--ogx-toolbar-input-focus-shadow, 0 0 0 3px rgba(59, 130, 246, 0.12));
42
+ }
40
43
 
41
44
  /* ─── Icon wrapper ────────────────────────────────────────────────────────── */
42
45
  .ogx-global-search__icon-wrapper {
@@ -70,7 +73,7 @@
70
73
  background: transparent;
71
74
  font-family: var(--ogx-font-family, system-ui, sans-serif);
72
75
  font-size: var(--ogx-font-size-sm, 13px);
73
- color: var(--ogx-grid-row-text, #1e293b);
76
+ color: var(--ogx-toolbar-input-text, var(--ogx-grid-row-text, #1e293b));
74
77
  outline: none;
75
78
  opacity: 0;
76
79
  pointer-events: none;
@@ -26,6 +26,11 @@ function CloseIcon() {
26
26
  );
27
27
  }
28
28
 
29
+ /** Search text reduced to its terms, so 'john london ' and 'john london' are the same search. */
30
+ function normalizeSearch(text: string): string {
31
+ return text.trim().split(/\s+/).filter(Boolean).join(' ');
32
+ }
33
+
29
34
  export function GlobalSearch({ value = '', onChange, placeholder = 'Search...', debounceMs = 250 }: GlobalSearchProps) {
30
35
  const [isExpanded, setIsExpanded] = useState(() => value.length > 0);
31
36
  // Fully uncontrolled: localValue is the single source of truth.
@@ -39,37 +44,22 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
39
44
  const lastEmittedRef = useRef(value);
40
45
  // Track the last incoming prop value to detect external resets
41
46
  const lastIncomingValueRef = useRef(value);
42
- // Track whether input was focused BEFORE a re-render so we can restore it
43
- const wasFocusedRef = useRef(false);
44
-
45
- // ── Focus preservation across re-renders ─────────────────────────────────
46
- // When the parent updates filterModel (triggered by our debounce), React
47
- // re-renders the entire tree. Even though the input element stays mounted,
48
- // the browser can lose focus during heavy reconciliation. We detect this
49
- // via useLayoutEffect (runs synchronously after DOM mutations, before paint)
50
- // and immediately restore focus without any visual flicker.
51
- useLayoutEffect(() => {
52
- if (wasFocusedRef.current && document.activeElement !== inputRef.current) {
53
- inputRef.current?.focus();
54
- }
55
- });
47
+
48
+ // The input stays mounted across re-renders, so React keeps its focus; nothing here moves
49
+ // focus except the explicit expand and clear actions.
56
50
 
57
51
  // ── External reset detection ──────────────────────────────────────────────
58
52
  // Only responds when the PARENT programmatically changes the value
59
- // (e.g. "Clear all filters" button) — not from our own typing.
53
+ // (e.g. "Clear all filters" button) — not from our own typing. The parent may hand back our
54
+ // own search with whitespace normalised (the toolbar splits it into terms), which is not a reset.
60
55
  useEffect(() => {
61
56
  if (value === lastIncomingValueRef.current) return;
62
57
  lastIncomingValueRef.current = value;
63
58
 
64
- if (value !== lastEmittedRef.current) {
59
+ if (normalizeSearch(value) !== normalizeSearch(lastEmittedRef.current)) {
65
60
  setLocalValue(value);
66
61
  lastEmittedRef.current = value;
67
- if (!value) {
68
- wasFocusedRef.current = false;
69
- setIsExpanded(false);
70
- } else {
71
- setIsExpanded(true);
72
- }
62
+ setIsExpanded(value.length > 0);
73
63
  }
74
64
  }, [value]);
75
65
 
@@ -84,6 +74,18 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
84
74
  return () => clearTimeout(handler);
85
75
  }, [localValue, debounceMs]);
86
76
 
77
+ // Typing still waiting for the debounce is sent when the search unmounts (the toolbar is
78
+ // hidden, the grid switches view), instead of being dropped.
79
+ const localValueRef = useRef(localValue);
80
+ useLayoutEffect(() => { localValueRef.current = localValue; });
81
+ useEffect(() => () => {
82
+ const pending = localValueRef.current;
83
+ if (pending !== lastEmittedRef.current) {
84
+ lastEmittedRef.current = pending;
85
+ onChangeRef.current(pending);
86
+ }
87
+ }, []);
88
+
87
89
  const handleExpand = useCallback(() => {
88
90
  setIsExpanded(true);
89
91
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -98,22 +100,19 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
98
100
  }, []);
99
101
 
100
102
  const handleFocus = useCallback(() => {
101
- wasFocusedRef.current = true;
102
103
  setIsExpanded(true);
103
104
  }, []);
104
105
 
105
106
  // Blur: defer 150ms to allow focus to move within the container first
106
107
  // (e.g. clicking the clear button) before deciding to collapse.
107
108
  const handleBlur = useCallback(() => {
108
- wasFocusedRef.current = false;
109
109
  setTimeout(() => {
110
110
  if (
111
111
  containerRef.current &&
112
112
  document.activeElement &&
113
113
  containerRef.current.contains(document.activeElement)
114
114
  ) {
115
- // Focus is still inside — mark as focused again and abort
116
- wasFocusedRef.current = true;
115
+ // Focus is still inside the search box — keep it expanded
117
116
  return;
118
117
  }
119
118
  if (!localValue) {
@@ -156,7 +155,7 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
156
155
  autoComplete="off"
157
156
  />
158
157
  {localValue && (
159
- <button className="ogx-global-search__clear" onClick={handleClear} aria-label="Clear search">
158
+ <button type="button" className="ogx-global-search__clear" onClick={handleClear} aria-label="Clear search">
160
159
  <CloseIcon />
161
160
  </button>
162
161
  )}