@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,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,85 @@ 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, cellIndex) => {
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
+ // Free space goes before the right-pinned section, as in the column header row.
281
+ if (cells[cellIndex - 1]?.pinned !== 'right') style.marginLeft = 'auto';
282
+ }
283
+ if (!cell.isGroup) {
284
+ return (
285
+ <div
286
+ key={cell.key}
287
+ className={`ogx-col-group-cell ogx-col-group-cell--filler${pinnedClass}`}
288
+ style={style}
289
+ aria-hidden="true"
290
+ />
291
+ );
292
+ }
293
+ return (
294
+ <div
295
+ key={cell.key}
296
+ role="columnheader"
297
+ aria-colspan={cell.fields.length}
298
+ aria-colindex={systemColumnCount + cell.firstDataIndex + 1}
299
+ className={`ogx-col-group-cell ogx-col-group-cell--group${pinnedClass}${cell.headerClassName ? ` ${cell.headerClassName}` : ''}`}
300
+ style={style}
301
+ title={cell.label}
302
+ >
299
303
  <span className="ogx-col-group-cell__label">{cell.label}</span>
300
- )}
301
- </div>
302
- ))}
304
+ </div>
305
+ );
306
+ })}
303
307
  </div>
304
308
  );
305
309
  })}
306
310
 
307
311
  {/* ── Column header row ── */}
308
- <div className="ogx__header" role="row">
312
+ <div className="ogx__header" role="row" aria-rowindex={groupDepth + 1}>
309
313
  { }
310
314
  {rowReordering && (
311
315
  <div
312
- className="ogx__header-cell ogx__header-cell--pinned ogx__header-cell--pinned-left"
316
+ className={`ogx__header-cell ogx__header-cell--pinned ogx__header-cell--pinned-left ${isHeaderFocused('__reorder_col__') ? 'ogx__header-cell--focused' : ''}`}
313
317
  role="columnheader"
314
318
  aria-label="Row reorder handle"
319
+ aria-colindex={reorderColIndex}
320
+ data-field="__reorder_col__"
321
+ tabIndex={-1}
315
322
  title="Drag to reorder rows"
316
323
  style={{
317
324
  minWidth: 48,
318
325
  maxWidth: 48,
319
326
  position: 'sticky',
320
327
  left: 0,
321
- zIndex: 4
322
328
  }}
323
329
  />
324
330
  )}
@@ -327,15 +333,17 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
327
333
  {hasDetailPanel && (
328
334
  <div
329
335
  className={`ogx__header-cell ogx__header-cell--expand ${pinExpandColumn ? 'ogx__header-cell--pinned ogx__header-cell--pinned-left' : ''
330
- }`}
336
+ } ${isHeaderFocused('__expand_col__') ? 'ogx__header-cell--focused' : ''}`}
331
337
  role="columnheader"
332
338
  aria-label="Expand row details"
339
+ aria-colindex={expandColIndex}
340
+ data-field="__expand_col__"
341
+ tabIndex={-1}
333
342
  style={{
334
343
  minWidth: 48,
335
344
  maxWidth: 48,
336
345
  position: pinExpandColumn ? 'sticky' : undefined,
337
346
  left: pinExpandColumn ? (rowReordering ? 48 : 0) : undefined,
338
- zIndex: pinExpandColumn ? 5 : undefined
339
347
  }}
340
348
  >
341
349
  { }
@@ -346,25 +354,29 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
346
354
  {checkboxSelection && (
347
355
  <div
348
356
  className={`ogx__header-cell ogx__header-cell--checkbox ${pinCheckboxColumn ? 'ogx__header-cell--pinned ogx__header-cell--pinned-left' : ''
349
- }`}
357
+ } ${isHeaderFocused('__checkbox_col__') ? 'ogx__header-cell--focused' : ''}`}
350
358
  role="columnheader"
359
+ aria-colindex={checkboxColIndex}
360
+ data-field="__checkbox_col__"
351
361
  style={{
352
362
  position: pinCheckboxColumn ? 'sticky' : undefined,
353
363
  left: pinCheckboxColumn ? ((rowReordering ? 48 : 0) + (hasDetailPanel && pinExpandColumn ? 48 : 0)) : undefined,
354
364
 
355
365
  }}
356
366
  >
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
- />
367
+ {/* No select-all without a handler (single-row selection has none). */}
368
+ {onSelectAll && (
369
+ <Checkbox
370
+ id={selectAllId}
371
+ name={selectAllId}
372
+ checked={allSelected}
373
+ indeterminate={someSelected}
374
+ onChange={(e) => onSelectAll(e.target.checked)}
375
+ aria-label={allSelected ? 'Deselect all rows' : 'Select all rows'}
376
+ tabIndex={-1}
377
+ onMouseDown={(e) => e.preventDefault()}
378
+ />
379
+ )}
368
380
  </div>
369
381
  )}
370
382
 
@@ -379,7 +391,18 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
379
391
  );
380
392
  }
381
393
  const isSortable = colDef.sortable !== false;
382
- const isSorted = sortModel.some(item => item.field === colDef.field);
394
+ const sortPosition = sortModel.findIndex(item => item.field === colDef.field);
395
+ const isSorted = sortPosition !== -1;
396
+ const sortDirection = isSorted ? sortModel[sortPosition].sort : null;
397
+ const colIndex = columnIndexMap?.get(colDef.field) ?? index;
398
+ // Only the primary sort column carries aria-sort (WAI-ARIA allows one per grid);
399
+ // with multi-sort every sorted column describes its direction and priority.
400
+ const ariaSort = !isSortable ? undefined
401
+ : sortPosition === 0 ? (sortDirection === 'asc' ? 'ascending' : 'descending')
402
+ : 'none';
403
+ const sortDescription = isSorted && sortModel.length > 1
404
+ ? `Sorted ${sortDirection === 'asc' ? 'ascending' : 'descending'}, sort priority ${sortPosition + 1} of ${sortModel.length}`
405
+ : undefined;
383
406
  const isResizable = colDef.resizable !== false;
384
407
  const pinnedPosition = isColumnPinned(colDef.field, pinnedColumns);
385
408
  const isPinned = pinnedPosition !== null;
@@ -393,9 +416,11 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
393
416
  isSorted && 'ogx__header-cell--sorted',
394
417
  isPinned && `ogx__header-cell--pinned`,
395
418
  isPinned && `ogx__header-cell--pinned-${pinnedPosition}`,
419
+ pinnedPosition === 'left' && colDef.field === pinnedEdges.lastLeft && 'ogx__header-cell--pinned-left-last',
420
+ pinnedPosition === 'right' && colDef.field === pinnedEdges.firstRight && 'ogx__header-cell--pinned-right-first',
396
421
  isDragging && 'ogx__header-cell--dragging',
397
422
  isDragOver && 'ogx__header-cell--drag-over',
398
- (focusedCell?.id === 'HEADER' && focusedCell.field === colDef.field) && 'ogx__header-cell--focused',
423
+ isHeaderFocused(colDef.field) && 'ogx__header-cell--focused',
399
424
  colDef.headerClassName
400
425
  ].filter(Boolean).join(' ');
401
426
 
@@ -424,7 +449,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
424
449
  }
425
450
 
426
451
  const headerContent = colDef.renderHeader
427
- ? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex: index })
452
+ ? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex })
428
453
  : colDef.headerName || colDef.field;
429
454
 
430
455
  const handleHeaderClick = (e: React.MouseEvent) => {
@@ -438,9 +463,10 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
438
463
  }
439
464
  };
440
465
 
441
- const handleDragStart = onDragStart ? onDragStart(colDef.field) : undefined;
442
- const handleDragOver = onDragOver ? onDragOver(colDef.field) : undefined;
443
- const handleDrop = onDrop ? onDrop(colDef.field) : undefined;
466
+ // Pinned columns keep their place: they are neither dragged nor drop targets.
467
+ const handleDragStart = onDragStart && !isPinned ? onDragStart(colDef.field) : undefined;
468
+ const handleDragOver = onDragOver && !isPinned ? onDragOver(colDef.field) : undefined;
469
+ const handleDrop = onDrop && !isPinned ? onDrop(colDef.field) : undefined;
444
470
 
445
471
  return (
446
472
  <div
@@ -450,24 +476,17 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
450
476
  title={colDef.description}
451
477
  onClick={handleHeaderClick}
452
478
  role="columnheader"
453
- draggable={!!onDragStart}
479
+ draggable={!!handleDragStart}
454
480
  onDragStart={handleDragStart}
455
481
  onDragOver={handleDragOver}
456
482
  onDragEnd={onDragEnd}
457
483
  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)}
484
+ onKeyDown={handleHeaderKeyDown(colDef)}
485
+ aria-sort={ariaSort}
486
+ aria-description={sortDescription}
487
+ aria-colindex={systemColumnCount + colIndex + 1}
468
488
  data-field={colDef.field}
469
489
  tabIndex={-1}
470
- ref={(el) => { cellRefs.current[colDef.field] = el; }}
471
490
  >
472
491
  <div className="ogx__header-cell-content">
473
492
  {isDragging && (
@@ -488,6 +507,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
488
507
  {isSortable && getSortIcon(colDef.field)}
489
508
  {!colDef.disableColumnMenu && (
490
509
  <button
510
+ type="button"
491
511
  className="ogx__menu-icon-btn"
492
512
  onClick={handleMenuOpen(colDef)}
493
513
  aria-label={`Open column menu for ${colDef.headerName || colDef.field}`}
@@ -503,10 +523,11 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
503
523
  {isResizable && onColumnResize && (
504
524
  <ColumnResizeHandle
505
525
  field={colDef.field}
506
- currentWidth={effectiveWidth}
526
+ currentWidth={getRenderedColumnWidth(colDef, columnWidths)}
507
527
  onResize={onColumnResize}
508
528
  minWidth={colDef.minWidth}
509
529
  maxWidth={colDef.maxWidth}
530
+ edge={pinnedPosition === 'right' ? 'start' : 'end'}
510
531
  />
511
532
  )}
512
533
  </div>
@@ -517,7 +538,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
517
538
  <ColumnMenu
518
539
  colDef={menuOpenParams.colDef as unknown as GridColDef<GridRowModel>}
519
540
  sortModel={sortModel}
520
- onSort={onSort}
541
+ onSort={onSort || onSortAdd ? handleMenuSort : undefined}
521
542
  onHide={onHideColumn}
522
543
  onPin={onPinColumn}
523
544
  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
+ }