@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
@@ -1,8 +1,11 @@
1
1
  import React, { useId } from 'react';
2
2
  import { Checkbox } from '../ui/Checkbox';
3
3
  import { ColumnResizeHandle } from '../ColumnResizeHandle/ColumnResizeHandle';
4
- import type { GridColDef, GridRowModel, GridSortDirection, GridColumnPinning, GridAggregationModel, GridColumnGroup, GridColumnGroupingModel } from '../../types';
5
- import { isColumnPinned, calculatePinnedPositions } from '../../utils/pinning';
4
+ import { clampResizeWidth } from '../ColumnResizeHandle/clampResizeWidth';
5
+ import type { GridColDef, GridRowModel, GridSortDirection, GridColumnPinning, GridAggregationModel, GridColumnGroupingModel } from '../../types';
6
+ import { isColumnPinned, calculatePinnedPositions, getPinnedEdgeFields } from '../../utils/pinning';
7
+ import { getRenderedColumnWidth } from '../../utils/columnWidth';
8
+ import { buildColumnGroupRow, getColumnGroupDepth, getColumnGroupPaths } from '../../utils/columnGroups';
6
9
  import { ColumnMenu } from './ColumnMenu';
7
10
 
8
11
 
@@ -23,92 +26,14 @@ function SwapIcon() {
23
26
  }
24
27
 
25
28
 
26
- // ─── Group header helpers ─────────────────────────────────────────────────────
27
-
28
- /** Collect all leaf field names from a group node (recursive). */
29
- function collectGroupFields(group: GridColumnGroup): string[] {
30
- const out: string[] = [];
31
- for (const child of group.children) {
32
- if (typeof child === 'string') out.push(child);
33
- else out.push(...collectGroupFields(child));
34
- }
35
- return out;
36
- }
37
-
38
- /** Max nesting depth of the group tree (1 = single level). */
39
- function groupTreeDepth(groups: GridColumnGroupingModel): number {
40
- let d = 0;
41
- function walk(g: GridColumnGroup, depth: number) {
42
- d = Math.max(d, depth);
43
- for (const c of g.children) if (typeof c !== 'string') walk(c, depth + 1);
44
- }
45
- groups.forEach(g => walk(g, 1));
46
- return d;
47
- }
48
-
49
- interface GroupCell { key: string; label: string; width: number; isGroup: boolean; }
50
-
51
- /**
52
- * Builds the flat array of GroupCell descriptors for one row (level).
53
- * level 0 = top-most group row.
54
- * allFields is the ORDERED list of all visible columns (including spacers).
55
- */
56
- function buildGroupRow(
57
- groups: GridColumnGroupingModel,
58
- allFields: { field: string; width: number }[],
59
- level: number,
60
- ): GroupCell[] {
61
- // Map each field → the group that owns it at `level`
62
- function findAtLevel(g: GridColumnGroup, field: string, cur: number): GridColumnGroup | null {
63
- if (!collectGroupFields(g).includes(field)) return null;
64
- if (cur === level) return g;
65
- for (const child of g.children) {
66
- if (typeof child !== 'string') {
67
- const found = findAtLevel(child, field, cur + 1);
68
- if (found) return found;
69
- }
70
- }
71
- return null;
72
- }
73
-
74
- const fieldGroup = new Map<string, GridColumnGroup | null>();
75
- for (const f of allFields) {
76
- let found: GridColumnGroup | null = null;
77
- for (const g of groups) {
78
- found = findAtLevel(g, f.field, 0);
79
- if (found) break;
80
- }
81
- fieldGroup.set(f.field, found);
82
- }
83
-
84
- const cells: GroupCell[] = [];
85
- let i = 0;
86
- while (i < allFields.length) {
87
- const item = allFields[i];
88
- const group = fieldGroup.get(item.field) ?? null;
89
- const w = item.width;
90
-
91
- if (!group) {
92
- cells.push({ key: `fg-${item.field}-${level}`, label: '', width: w, isGroup: false });
93
- i++;
94
- } else {
95
- let totalW = 0;
96
- let j = i;
97
- while (j < allFields.length && fieldGroup.get(allFields[j].field) === group) {
98
- totalW += allFields[j].width;
99
- j++;
100
- }
101
- cells.push({ key: `${group.groupId}-${level}`, label: group.headerName, width: totalW, isGroup: true });
102
- i = j;
103
- }
104
- }
105
- return cells;
106
- }
107
-
108
29
  // ─── Props ────────────────────────────────────────────────────────────────────
109
30
 
110
31
  export interface HeaderProps<R extends GridRowModel = GridRowModel> {
111
32
  columns: GridColDef<R>[];
33
+ /**
34
+ * Every rendered data column in render order (left-pinned, unpinned, right-pinned) with its
35
+ * resolved width. Column group header rows are laid out over these; defaults to `columns`.
36
+ */
112
37
  allColumns?: GridColDef<R>[];
113
38
  columnGroupingModel?: GridColumnGroupingModel;
114
39
  checkboxSelection?: boolean;
@@ -132,13 +57,16 @@ export interface HeaderProps<R extends GridRowModel = GridRowModel> {
132
57
  onDragOver?: (field: string) => (event: React.DragEvent) => void;
133
58
  onDragEnd?: () => void;
134
59
  onDrop?: (targetField: string) => (event: React.DragEvent) => void;
135
- focusedCell?: { id: string | number; field: string } | null;
60
+ /** The grid's focus position; `id: null` means a header cell is focused. */
61
+ focusedCell?: { id: string | number | null; field: string } | null;
136
62
  onHeaderClick?: (field: string) => void;
137
63
  onSortAdd?: (field: string, direction: GridSortDirection) => void;
138
64
  multiSort?: boolean;
139
65
  onHideColumn?: (field: string) => void;
140
66
  onPinColumn?: (field: string, side: 'left' | 'right' | null) => void;
141
67
  onManageColumns?: () => void;
68
+ /** Position of each visible data column in render order, for `colIndex` and `aria-colindex`. */
69
+ columnIndexMap?: Map<string, number>;
142
70
  }
143
71
 
144
72
  export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps<R>) {
@@ -173,10 +101,10 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
173
101
  onHideColumn,
174
102
  onPinColumn,
175
103
  onManageColumns,
104
+ columnIndexMap,
176
105
  } = props;
177
106
 
178
107
  const selectAllId = useId();
179
- const cellRefs = React.useRef<Record<string, HTMLElement | null>>({});
180
108
  const [menuOpenParams, setMenuOpenParams] = React.useState<{ colDef: GridColDef<R>; anchorEl: HTMLElement } | null>(null);
181
109
 
182
110
  const handleMenuOpen = (colDef: GridColDef<R>) => (event: React.MouseEvent) => {
@@ -184,18 +112,38 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
184
112
  setMenuOpenParams({ colDef, anchorEl: event.currentTarget as HTMLElement });
185
113
  };
186
114
 
187
- const handleMenuClose = () => {
115
+ const handleMenuClose = React.useCallback(() => {
188
116
  setMenuOpenParams(null);
117
+ }, []);
118
+
119
+ // Alt+ArrowDown or Ctrl/Cmd+Enter on a focused header cell opens its column menu.
120
+ // DOM focus is moved onto the focused header cell by useGridKeyboardNavigation.
121
+ const handleHeaderKeyDown = (colDef: GridColDef<R>) => (event: React.KeyboardEvent<HTMLDivElement>) => {
122
+ // Alt+ArrowLeft / Alt+ArrowRight resize the column (Shift for bigger steps).
123
+ const resizes = event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight');
124
+ if (resizes && event.target === event.currentTarget) {
125
+ if (colDef.resizable === false || !onColumnResize) return;
126
+ event.preventDefault();
127
+ event.stopPropagation();
128
+ const width = getRenderedColumnWidth(colDef, columnWidths);
129
+ const step = (event.shiftKey ? 50 : 10) * (event.key === 'ArrowRight' ? 1 : -1);
130
+ onColumnResize(colDef.field, clampResizeWidth(width + step, width, colDef.minWidth, colDef.maxWidth));
131
+ return;
132
+ }
133
+ const opensMenu = (event.altKey && event.key === 'ArrowDown') || ((event.ctrlKey || event.metaKey) && event.key === 'Enter');
134
+ if (!opensMenu || colDef.disableColumnMenu || event.target !== event.currentTarget) return;
135
+ event.preventDefault();
136
+ event.stopPropagation();
137
+ const anchor = event.currentTarget.querySelector<HTMLElement>('.ogx__menu-icon-btn') ?? event.currentTarget;
138
+ setMenuOpenParams({ colDef, anchorEl: anchor });
189
139
  };
190
140
 
191
- React.useLayoutEffect(() => {
192
- if (focusedCell?.id === 'HEADER') {
193
- const element = cellRefs.current[focusedCell.field];
194
- if (element) {
195
- element.focus();
196
- }
197
- }
198
- }, [focusedCell]);
141
+ const isHeaderFocused = (field: string) => focusedCell?.id === null && focusedCell.field === field;
142
+
143
+ // aria-colindex: system columns first (reorder, expand, checkbox), then the data columns.
144
+ const reorderColIndex = rowReordering ? 1 : 0;
145
+ const expandColIndex = hasDetailPanel ? reorderColIndex + 1 : reorderColIndex;
146
+ const checkboxColIndex = checkboxSelection ? expandColIndex + 1 : expandColIndex;
199
147
 
200
148
  const handleColumnClick = (colDef: GridColDef<R>) => (e: React.MouseEvent) => {
201
149
  if (colDef.sortable === false) return;
@@ -218,6 +166,19 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
218
166
  }
219
167
  };
220
168
 
169
+ // Column menu: act on this column only. "Unsort" removes just this key, and picking a direction
170
+ // for a column that is already sorted keeps the other keys. A new sort replaces the model
171
+ // unless multiSort is on, matching a plain header click.
172
+ const handleMenuSort = (field: string, direction: GridSortDirection) => {
173
+ const isSorted = sortModel.some(item => item.field === field);
174
+ const keepOtherKeys = direction === null || isSorted || multiSort;
175
+ if (onSortAdd && (keepOtherKeys || !onSort)) {
176
+ onSortAdd(field, direction);
177
+ } else {
178
+ onSort?.(field, direction);
179
+ }
180
+ };
181
+
221
182
  const getSortIcon = (field: string) => {
222
183
  const sortItem = sortModel.find(item => item.field === field);
223
184
  if (!sortItem) return null;
@@ -246,16 +207,23 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
246
207
  );
247
208
  }, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
248
209
 
249
- const groupDepth = columnGroupingModel ? groupTreeDepth(columnGroupingModel) : 0;
210
+ const pinnedEdges = React.useMemo(() => getPinnedEdgeFields(columns, pinnedColumns), [columns, pinnedColumns]);
250
211
 
251
- // All non-spacer fields in order (for group-span math) with their effective physical width
252
- const allCols = allColumns ?? columns;
253
- const allLeafFields = allCols
254
- .filter(c => !c.isSpacer)
255
- .map(c => ({
212
+ const groupDepth = getColumnGroupDepth(columnGroupingModel);
213
+ const groupPaths = React.useMemo(() => getColumnGroupPaths(columnGroupingModel), [columnGroupingModel]);
214
+
215
+ // Group rows are laid out over every rendered data column (not just the horizontal render
216
+ // window), in render order and with the layout-resolved widths, so they line up with the headers.
217
+ const groupColumns = React.useMemo(() => {
218
+ if (groupDepth === 0) return [];
219
+ return (allColumns ?? columns).map(c => ({
256
220
  field: c.field,
257
- width: columnWidths[c.field] ?? c.width ?? 100
221
+ width: getRenderedColumnWidth(c, columnWidths),
222
+ flex: c.isSpacer ? 0 : c.flex,
223
+ pinned: c.isSpacer ? null : isColumnPinned(c.field, pinnedColumns),
224
+ isSpacer: c.isSpacer,
258
225
  }));
226
+ }, [groupDepth, allColumns, columns, columnWidths, pinnedColumns]);
259
227
 
260
228
  // --- CHECKBOX / DETAIL / REORDER prefix width (for group filler) ---
261
229
  const CHECKBOX_W = 48;
@@ -265,6 +233,12 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
265
233
  (rowReordering ? REORDER_W : 0) +
266
234
  (hasDetailPanel ? EXPAND_W : 0) +
267
235
  (checkboxSelection ? CHECKBOX_W : 0);
236
+ // The prefix filler sticks with the system columns when all of them are pinned.
237
+ const isPrefixPinned =
238
+ (rowReordering || hasDetailPanel || checkboxSelection) &&
239
+ (!hasDetailPanel || pinExpandColumn) &&
240
+ (!checkboxSelection || pinCheckboxColumn);
241
+ const systemColumnCount = (rowReordering ? 1 : 0) + (hasDetailPanel ? 1 : 0) + (checkboxSelection ? 1 : 0);
268
242
 
269
243
  return (
270
244
  // Outer sticky wrapper — both group rows AND column header row live here
@@ -272,53 +246,83 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
272
246
  <div className="ogx__header-wrap">
273
247
  {/* ── Group header rows ── */}
274
248
  {groupDepth > 0 && Array.from({ length: groupDepth }, (_, level) => {
275
- const row = buildGroupRow(
276
- columnGroupingModel!,
277
- allLeafFields,
278
- level,
279
- );
249
+ const cells = buildColumnGroupRow(groupColumns, groupPaths, level);
280
250
  return (
281
- <div key={`ogx-grp-${level}`} className="ogx-col-group-row" role="row">
251
+ <div key={`ogx-grp-${level}`} className="ogx-col-group-row" role="row" aria-rowindex={level + 1}>
282
252
  {/* Filler for checkbox / reorder / detailPanel prefix */}
283
253
  {prefixWidth > 0 && (
284
254
  <div
285
- className="ogx-col-group-cell ogx-col-group-cell--filler"
286
- style={{ width: prefixWidth, minWidth: prefixWidth, flexShrink: 0 }}
287
- role="columnheader"
255
+ className={`ogx-col-group-cell ogx-col-group-cell--filler${isPrefixPinned ? ' ogx-col-group-cell--pinned ogx-col-group-cell--pinned-left' : ''}`}
256
+ style={{
257
+ width: prefixWidth,
258
+ minWidth: prefixWidth,
259
+ flexShrink: 0,
260
+ position: isPrefixPinned ? 'sticky' : undefined,
261
+ left: isPrefixPinned ? 0 : undefined,
262
+ }}
263
+ aria-hidden="true"
288
264
  />
289
265
  )}
290
- {row.map(cell => (
291
- <div
292
- key={cell.key}
293
- role="columnheader"
294
- className={`ogx-col-group-cell ${cell.isGroup ? 'ogx-col-group-cell--group' : 'ogx-col-group-cell--filler'}`}
295
- style={{ width: cell.width, minWidth: cell.width, flexShrink: 0 }}
296
- title={cell.isGroup ? cell.label : undefined}
297
- >
298
- {cell.isGroup && (
266
+ {cells.map(cell => {
267
+ const pinnedClass = cell.pinned ? ` ogx-col-group-cell--pinned ogx-col-group-cell--pinned-${cell.pinned}` : '';
268
+ const style: React.CSSProperties = {
269
+ width: cell.width,
270
+ minWidth: cell.width,
271
+ flexShrink: 0,
272
+ flexGrow: cell.flexGrow,
273
+ };
274
+ if (cell.pinned === 'left') {
275
+ style.position = 'sticky';
276
+ style.left = pinnedPositions[cell.fields[0]];
277
+ } else if (cell.pinned === 'right') {
278
+ style.position = 'sticky';
279
+ style.right = pinnedPositions[cell.fields[cell.fields.length - 1]];
280
+ }
281
+ if (!cell.isGroup) {
282
+ return (
283
+ <div
284
+ key={cell.key}
285
+ className={`ogx-col-group-cell ogx-col-group-cell--filler${pinnedClass}`}
286
+ style={style}
287
+ aria-hidden="true"
288
+ />
289
+ );
290
+ }
291
+ return (
292
+ <div
293
+ key={cell.key}
294
+ role="columnheader"
295
+ aria-colspan={cell.fields.length}
296
+ aria-colindex={systemColumnCount + cell.firstDataIndex + 1}
297
+ className={`ogx-col-group-cell ogx-col-group-cell--group${pinnedClass}${cell.headerClassName ? ` ${cell.headerClassName}` : ''}`}
298
+ style={style}
299
+ title={cell.label}
300
+ >
299
301
  <span className="ogx-col-group-cell__label">{cell.label}</span>
300
- )}
301
- </div>
302
- ))}
302
+ </div>
303
+ );
304
+ })}
303
305
  </div>
304
306
  );
305
307
  })}
306
308
 
307
309
  {/* ── Column header row ── */}
308
- <div className="ogx__header" role="row">
310
+ <div className="ogx__header" role="row" aria-rowindex={groupDepth + 1}>
309
311
  { }
310
312
  {rowReordering && (
311
313
  <div
312
- className="ogx__header-cell ogx__header-cell--pinned ogx__header-cell--pinned-left"
314
+ className={`ogx__header-cell ogx__header-cell--pinned ogx__header-cell--pinned-left ${isHeaderFocused('__reorder_col__') ? 'ogx__header-cell--focused' : ''}`}
313
315
  role="columnheader"
314
316
  aria-label="Row reorder handle"
317
+ aria-colindex={reorderColIndex}
318
+ data-field="__reorder_col__"
319
+ tabIndex={-1}
315
320
  title="Drag to reorder rows"
316
321
  style={{
317
322
  minWidth: 48,
318
323
  maxWidth: 48,
319
324
  position: 'sticky',
320
325
  left: 0,
321
- zIndex: 4
322
326
  }}
323
327
  />
324
328
  )}
@@ -327,15 +331,17 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
327
331
  {hasDetailPanel && (
328
332
  <div
329
333
  className={`ogx__header-cell ogx__header-cell--expand ${pinExpandColumn ? 'ogx__header-cell--pinned ogx__header-cell--pinned-left' : ''
330
- }`}
334
+ } ${isHeaderFocused('__expand_col__') ? 'ogx__header-cell--focused' : ''}`}
331
335
  role="columnheader"
332
336
  aria-label="Expand row details"
337
+ aria-colindex={expandColIndex}
338
+ data-field="__expand_col__"
339
+ tabIndex={-1}
333
340
  style={{
334
341
  minWidth: 48,
335
342
  maxWidth: 48,
336
343
  position: pinExpandColumn ? 'sticky' : undefined,
337
344
  left: pinExpandColumn ? (rowReordering ? 48 : 0) : undefined,
338
- zIndex: pinExpandColumn ? 5 : undefined
339
345
  }}
340
346
  >
341
347
  { }
@@ -346,25 +352,29 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
346
352
  {checkboxSelection && (
347
353
  <div
348
354
  className={`ogx__header-cell ogx__header-cell--checkbox ${pinCheckboxColumn ? 'ogx__header-cell--pinned ogx__header-cell--pinned-left' : ''
349
- }`}
355
+ } ${isHeaderFocused('__checkbox_col__') ? 'ogx__header-cell--focused' : ''}`}
350
356
  role="columnheader"
357
+ aria-colindex={checkboxColIndex}
358
+ data-field="__checkbox_col__"
351
359
  style={{
352
360
  position: pinCheckboxColumn ? 'sticky' : undefined,
353
361
  left: pinCheckboxColumn ? ((rowReordering ? 48 : 0) + (hasDetailPanel && pinExpandColumn ? 48 : 0)) : undefined,
354
362
 
355
363
  }}
356
364
  >
357
- <Checkbox
358
- id={selectAllId}
359
- name={selectAllId}
360
- checked={allSelected}
361
- indeterminate={someSelected}
362
- onChange={(e) => onSelectAll?.(e.target.checked)}
363
- aria-label={allSelected ? 'Deselect all rows' : 'Select all rows'}
364
- tabIndex={focusedCell?.id === 'HEADER' && focusedCell.field === '__checkbox_col__' ? 0 : -1}
365
- onMouseDown={(e) => e.preventDefault()}
366
- inputRef={(el) => { cellRefs.current['__checkbox_col__'] = el; }}
367
- />
365
+ {/* No select-all without a handler (single-row selection has none). */}
366
+ {onSelectAll && (
367
+ <Checkbox
368
+ id={selectAllId}
369
+ name={selectAllId}
370
+ checked={allSelected}
371
+ indeterminate={someSelected}
372
+ onChange={(e) => onSelectAll(e.target.checked)}
373
+ aria-label={allSelected ? 'Deselect all rows' : 'Select all rows'}
374
+ tabIndex={-1}
375
+ onMouseDown={(e) => e.preventDefault()}
376
+ />
377
+ )}
368
378
  </div>
369
379
  )}
370
380
 
@@ -379,7 +389,18 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
379
389
  );
380
390
  }
381
391
  const isSortable = colDef.sortable !== false;
382
- const isSorted = sortModel.some(item => item.field === colDef.field);
392
+ const sortPosition = sortModel.findIndex(item => item.field === colDef.field);
393
+ const isSorted = sortPosition !== -1;
394
+ const sortDirection = isSorted ? sortModel[sortPosition].sort : null;
395
+ const colIndex = columnIndexMap?.get(colDef.field) ?? index;
396
+ // Only the primary sort column carries aria-sort (WAI-ARIA allows one per grid);
397
+ // with multi-sort every sorted column describes its direction and priority.
398
+ const ariaSort = !isSortable ? undefined
399
+ : sortPosition === 0 ? (sortDirection === 'asc' ? 'ascending' : 'descending')
400
+ : 'none';
401
+ const sortDescription = isSorted && sortModel.length > 1
402
+ ? `Sorted ${sortDirection === 'asc' ? 'ascending' : 'descending'}, sort priority ${sortPosition + 1} of ${sortModel.length}`
403
+ : undefined;
383
404
  const isResizable = colDef.resizable !== false;
384
405
  const pinnedPosition = isColumnPinned(colDef.field, pinnedColumns);
385
406
  const isPinned = pinnedPosition !== null;
@@ -393,9 +414,11 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
393
414
  isSorted && 'ogx__header-cell--sorted',
394
415
  isPinned && `ogx__header-cell--pinned`,
395
416
  isPinned && `ogx__header-cell--pinned-${pinnedPosition}`,
417
+ pinnedPosition === 'left' && colDef.field === pinnedEdges.lastLeft && 'ogx__header-cell--pinned-left-last',
418
+ pinnedPosition === 'right' && colDef.field === pinnedEdges.firstRight && 'ogx__header-cell--pinned-right-first',
396
419
  isDragging && 'ogx__header-cell--dragging',
397
420
  isDragOver && 'ogx__header-cell--drag-over',
398
- (focusedCell?.id === 'HEADER' && focusedCell.field === colDef.field) && 'ogx__header-cell--focused',
421
+ isHeaderFocused(colDef.field) && 'ogx__header-cell--focused',
399
422
  colDef.headerClassName
400
423
  ].filter(Boolean).join(' ');
401
424
 
@@ -424,7 +447,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
424
447
  }
425
448
 
426
449
  const headerContent = colDef.renderHeader
427
- ? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex: index })
450
+ ? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex })
428
451
  : colDef.headerName || colDef.field;
429
452
 
430
453
  const handleHeaderClick = (e: React.MouseEvent) => {
@@ -438,9 +461,10 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
438
461
  }
439
462
  };
440
463
 
441
- const handleDragStart = onDragStart ? onDragStart(colDef.field) : undefined;
442
- const handleDragOver = onDragOver ? onDragOver(colDef.field) : undefined;
443
- const handleDrop = onDrop ? onDrop(colDef.field) : undefined;
464
+ // Pinned columns keep their place: they are neither dragged nor drop targets.
465
+ const handleDragStart = onDragStart && !isPinned ? onDragStart(colDef.field) : undefined;
466
+ const handleDragOver = onDragOver && !isPinned ? onDragOver(colDef.field) : undefined;
467
+ const handleDrop = onDrop && !isPinned ? onDrop(colDef.field) : undefined;
444
468
 
445
469
  return (
446
470
  <div
@@ -450,24 +474,17 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
450
474
  title={colDef.description}
451
475
  onClick={handleHeaderClick}
452
476
  role="columnheader"
453
- draggable={!!onDragStart}
477
+ draggable={!!handleDragStart}
454
478
  onDragStart={handleDragStart}
455
479
  onDragOver={handleDragOver}
456
480
  onDragEnd={onDragEnd}
457
481
  onDrop={handleDrop}
458
- aria-sort={
459
- isSortable
460
- ? (isSorted
461
- ? sortModel.find(item => item.field === colDef.field)?.sort === 'asc'
462
- ? 'ascending'
463
- : 'descending'
464
- : 'none')
465
- : undefined
466
- }
467
- aria-colindex={index + 1 + (checkboxSelection ? 1 : 0)}
482
+ onKeyDown={handleHeaderKeyDown(colDef)}
483
+ aria-sort={ariaSort}
484
+ aria-description={sortDescription}
485
+ aria-colindex={systemColumnCount + colIndex + 1}
468
486
  data-field={colDef.field}
469
487
  tabIndex={-1}
470
- ref={(el) => { cellRefs.current[colDef.field] = el; }}
471
488
  >
472
489
  <div className="ogx__header-cell-content">
473
490
  {isDragging && (
@@ -488,6 +505,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
488
505
  {isSortable && getSortIcon(colDef.field)}
489
506
  {!colDef.disableColumnMenu && (
490
507
  <button
508
+ type="button"
491
509
  className="ogx__menu-icon-btn"
492
510
  onClick={handleMenuOpen(colDef)}
493
511
  aria-label={`Open column menu for ${colDef.headerName || colDef.field}`}
@@ -503,10 +521,11 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
503
521
  {isResizable && onColumnResize && (
504
522
  <ColumnResizeHandle
505
523
  field={colDef.field}
506
- currentWidth={effectiveWidth}
524
+ currentWidth={getRenderedColumnWidth(colDef, columnWidths)}
507
525
  onResize={onColumnResize}
508
526
  minWidth={colDef.minWidth}
509
527
  maxWidth={colDef.maxWidth}
528
+ edge={pinnedPosition === 'right' ? 'start' : 'end'}
510
529
  />
511
530
  )}
512
531
  </div>
@@ -517,7 +536,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
517
536
  <ColumnMenu
518
537
  colDef={menuOpenParams.colDef as unknown as GridColDef<GridRowModel>}
519
538
  sortModel={sortModel}
520
- onSort={onSort}
539
+ onSort={onSort || onSortAdd ? handleMenuSort : undefined}
521
540
  onHide={onHideColumn}
522
541
  onPin={onPinColumn}
523
542
  pinnedColumns={pinnedColumns}
@@ -31,6 +31,15 @@
31
31
  background: var(--ogx-color-gray-50, #f8fafc);
32
32
  }
33
33
 
34
+ /* Keyboard focus (roving tab stop between list rows). */
35
+ .ogx-list-view__row:focus {
36
+ outline: none;
37
+ }
38
+ .ogx-list-view__row:focus-visible {
39
+ outline: 2px solid var(--ogx-color-primary);
40
+ outline-offset: -2px;
41
+ }
42
+
34
43
  .ogx-list-view__row--selected {
35
44
  background: var(--ogx-selection-bg, rgba(59, 130, 246, 0.08));
36
45
  }
@@ -40,6 +49,13 @@
40
49
  }
41
50
 
42
51
  /* ─── Checkbox column inside list row ────────────────────────────────────── */
52
+ .ogx-list-view__expand {
53
+ display: flex;
54
+ align-items: center;
55
+ flex-shrink: 0;
56
+ min-width: 32px;
57
+ }
58
+
43
59
  .ogx-list-view__checkbox {
44
60
  display: flex;
45
61
  align-items: center;
@@ -110,3 +126,12 @@
110
126
  display: none;
111
127
  }
112
128
  }
129
+
130
+ /* Loading with no rows yet: a progress bar, or slots.loadingOverlay centred. */
131
+ .ogx-list-view__loading {
132
+ position: relative;
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ min-height: 120px;
137
+ }