@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
@@ -0,0 +1,124 @@
1
+ # Performance Best Practices
2
+
3
+ Tips for getting the most out of OpenGridX with large datasets.
4
+
5
+ ## Stabilize column definitions
6
+
7
+ The most common cause of unnecessary re-renders is a new column array being created on every render.
8
+
9
+ ```tsx
10
+ // ✅ Good: stable reference
11
+ const columns = useMemo<GridColDef[]>(() => [
12
+ { field: 'id', headerName: 'ID', width: 70 },
13
+ { field: 'name', headerName: 'Name', width: 200 },
14
+ ], []);
15
+
16
+ // ❌ Bad: new array on every render → column layout is recomputed every render
17
+ const columns = [
18
+ { field: 'id', headerName: 'ID', width: 70 },
19
+ { field: 'name', headerName: 'Name', width: 200 },
20
+ ];
21
+ ```
22
+
23
+ The same applies to the `rows` prop — if you derive rows from server data, wrap the derivation in `useMemo`.
24
+
25
+ ## Keep cell renderers lightweight
26
+
27
+ `renderCell` is called for every visible cell on every render. Expensive work inside it multiplies with overscan.
28
+
29
+ ```tsx
30
+ // ✅ Good: memoized sub-component
31
+ const StatusBadge = React.memo(({ value }: { value: string }) => (
32
+ <span className={`badge badge--${value}`}>{value}</span>
33
+ ));
34
+
35
+ { field: 'status', renderCell: (p) => <StatusBadge value={String(p.value)} /> }
36
+
37
+ // ❌ Bad: inline object / new function reference every render
38
+ { field: 'status', renderCell: (p) => <span style={{ color: 'red' }}>{String(p.value)}</span> }
39
+ ```
40
+
41
+ ## Use fixed row heights when possible
42
+
43
+ Every row has the same height (`rowHeight` or the `density` preset); there is no per-row height callback. Expanded detail panels add their own height, which the grid folds into a cumulative-height table, so many expanded panels (and `'auto'` panels, which are measured after they render) cost more than plain rows.
44
+
45
+ ```tsx
46
+ // Fixed height — fastest
47
+ <DataGrid rows={rows} columns={columns} rowHeight={48} />
48
+
49
+ // Variable height — necessary for detail panels, but adds cost
50
+ <DataGrid rows={rows} columns={columns} getDetailPanelHeight={() => 200} />
51
+ ```
52
+
53
+ ## Choose the right density
54
+
55
+ `density="compact"` and `density="comfortable"` replace `rowHeight` with a preset (32 px / 72 px, or the theme's `grid.rowHeightCompact` / `grid.rowHeightComfortable`); `density="standard"` uses `rowHeight`. Compact rows fit more rows in the viewport.
56
+
57
+ ```tsx
58
+ <DataGrid density="compact" /> // 32 px rows — more rows in viewport
59
+ <DataGrid density="standard" /> // rowHeight (default 52 px)
60
+ <DataGrid density="comfortable" /> // 72 px rows
61
+ ```
62
+
63
+ ## Tune the overscan buffer
64
+
65
+ `overscanRowCount` (default `3`) is the minimum rows rendered outside the viewport. The grid raises it automatically based on scroll velocity. Raise the floor only if you still see blank flashes at the default.
66
+
67
+ ```tsx
68
+ // Raise the floor for very fast scrolling environments
69
+ <DataGrid overscanRowCount={8} rows={rows} columns={columns} />
70
+ ```
71
+
72
+ See [Virtualization](./features/virtualization.md) for the full adaptive-overscan details.
73
+
74
+ ## Avoid `autoHeight` on large datasets
75
+
76
+ `autoHeight` sizes the grid to all of its rows, so every row is rendered. Only use it when the dataset is small (< 100 rows), or together with `pagination`.
77
+
78
+ ## Give the grid a bounded height, and import the stylesheet
79
+
80
+ Virtualization only works when the viewport has a fixed height: pass `height`, or put the grid in a container with a definite height (a flex child needs `min-height: 0`). In an unbounded container the viewport grows to fit every row and every row is rendered; a dev-mode warning reports this.
81
+
82
+ The same happens when the stylesheet is missing, because the viewport's `overflow: auto` comes from it (a dev-mode warning reports a missing stylesheet too). Import it once in your app:
83
+
84
+ ```tsx
85
+ import '@opencorestack/opengridx/styles';
86
+ ```
87
+
88
+ ## Know the browser's height limit
89
+
90
+ The grid sizes its scroll content to `rowCount × rowHeight` pixels and does not scale scroll positions. Browsers cap an element's height (Chromium at 33,554,432 px, Firefox lower), so at the default 52 px row height rows past roughly 645,000 cannot be scrolled to. A dev-mode warning reports it. For more rows, use pagination, `paginationMode="server"` or infinite scroll with a `dataSource`.
91
+
92
+ ## Use server-side operations for very large datasets
93
+
94
+ Client-side filtering, sorting, and pagination load the full dataset into JS. For very large datasets, prefer server-side modes. The simplest way is a `dataSource`: the grid calls `getRows` with the page range, sort model and filter model, and shows the response (see [Server-Side Data](./features/data-source.md)).
95
+
96
+ Without a `dataSource`, fetch the rows yourself from the change callbacks and control the models:
97
+
98
+ ```tsx
99
+ <DataGrid
100
+ rows={pageRows} // the current page, already filtered and sorted by the server
101
+ columns={columns}
102
+ filterMode="server"
103
+ sortingMode="server"
104
+ pagination
105
+ paginationMode="server"
106
+ rowCount={totalRows} // server total
107
+ paginationModel={paginationModel}
108
+ onPaginationModelChange={setPaginationModel} // refetch in an effect on these models
109
+ sortModel={sortModel}
110
+ onSortModelChange={setSortModel}
111
+ filterModel={filterModel}
112
+ onFilterModelChange={setFilterModel}
113
+ />
114
+ ```
115
+
116
+ In server modes the grid shows `rows` as given: it does not re-filter, re-sort or slice them (v3.0+; earlier versions did unless a `dataSource` was set).
117
+
118
+
119
+ ## Related
120
+
121
+ - [Virtualization](./features/virtualization.md)
122
+ - [Server-Side Data](./features/data-source.md)
123
+ - [Sorting & Pagination](./features/sorting-pagination.md)
124
+ - [Infinite Scroll](./features/infinite-scroll.md)
package/docs/roadmap.md CHANGED
@@ -5,7 +5,7 @@ This document tracks the current status of features in `OpenGridX` and outlines
5
5
  ## ✅ Implemented Features
6
6
 
7
7
  ### Core
8
- * **Virtualization**: High-performance row virtualization for handling large datasets. Column virtualization is planned (currently all columns are rendered).
8
+ * **Virtualization**: Row virtualization plus horizontal virtualization of unpinned columns. Requires a bounded grid height (see `docs/features/virtualization.md`).
9
9
  * **Sorting**: Client-side and server-side multi-column sorting.
10
10
  * **Filtering**: Client-side filtering with support for various operators.
11
11
  * **Pagination**: Client-side and server-side pagination with customizable page sizes.
@@ -42,9 +42,9 @@ This document tracks the current status of features in `OpenGridX` and outlines
42
42
 
43
43
  ### Accessibility & Clipboard *(completed 2026-03-03)*
44
44
  * **ARIA roles**: `role="grid"`, `role="row"`, `role="gridcell"`, `role="columnheader"` on correct elements.
45
- * **ARIA attributes**: `aria-sort`, `aria-selected`, `aria-label`, `aria-expanded`, `aria-readonly`, `aria-haspopup` throughout.
46
- * **Keyboard navigation**: Tab / Enter / Escape / Arrow keys / Home / End / PageUp / PageDown.
47
- * **`aria-rowcount` and `aria-colcount`**: Accurate counts including system columns.
45
+ * **ARIA attributes**: `aria-sort`, `aria-selected`, `aria-label`, `aria-expanded`, `aria-readonly`, `aria-haspopup` throughout. v3.0 adds `aria-level` / `aria-expanded` on hierarchy rows, `aria-multiselectable`, and `aria-sort` on the primary sort column only.
46
+ * **Keyboard navigation**: Enter / Escape / Arrow keys / Home / End / PageUp / PageDown, Shift+Space and Ctrl+A selection, Alt+ArrowDown for the column menu; the grid is a single Tab stop (v3.0). See [Keyboard & Accessibility](features/keyboard-navigation.md).
47
+ * **`aria-rowcount` / `aria-rowindex` and `aria-colcount` / `aria-colindex`**: Count header rows and system columns, number rows across pages and pinned rows, and match between header and body cells (corrected in v3.0).
48
48
  * **Clipboard**: Full TSV-formatted copy support (Ctrl+C / Cmd+C) for seamless integration with Excel/Google Sheets.
49
49
 
50
50
  ### Advanced Excel Export *(completed 2026-03-03)*
@@ -74,21 +74,27 @@ This document tracks the current status of features in `OpenGridX` and outlines
74
74
 
75
75
  ### Advanced Data Features
76
76
  * **Interactive Pivot Builder**: Drag-and-drop UI for users to dynamically create pivot tables.
77
- * **Native PDF Reporting**: Automated PDF generation with customizable layouts.
78
77
  * **Advanced Charts Integration**: Inline sparklines and trend visualization.
79
78
 
80
- ### DX & Publishing
81
- * **npm publish**: Finalize `package.json` metadata, `README.md`, and publish to npm registry.
82
-
83
79
  ### Demo Site
84
80
  * **Full source viewer coverage**: Migrate all 32 examples to `DocsLayout` using Vite `?raw` auto-loading (eliminates string-literal source maintenance). See `docs/superpowers/plans/2026-07-29-demo-app-refactor.md`.
85
81
  * **Syntax highlighting**: Prism.js token-based highlighting in the source viewer.
86
- * **GitHub Pages deployment**: CI/CD via GitHub Actions (`base: '/OpenGridX/'` already configured in `vite.config.js`).
87
82
 
88
83
  ## ✅ Implemented Features (Recent)
89
84
 
85
+ ### Consumer defect fixes *(v2.1.0, 2026-09-23)*
86
+ - Row-grouping subtotals now use the shared aggregation functions (nulls ignored, `unique` supported, `availableAggregationFunctions` honoured); `min`/`max` no longer overflow the call stack on very large datasets.
87
+ - Group and tree-data expansion state survives row updates, including server-side lazily loaded children.
88
+ - Rows are positioned correctly when rows are pinned to the top; keyboard navigation and `scrollToIndexes` keep the target row fully visible below the sticky header.
89
+ - `valueFormatter` applies under row grouping; `formattedValue` is passed to `renderCell`.
90
+ - `slots.footer`, `slots.noRowsOverlay` and `slots.loadingOverlay` are rendered (they were typed but ignored).
91
+ - New `onRowDoubleClick`; `ColumnVisibilityPanel` exported; `exportToExcelAdvanced` accepts `groupedRows`.
92
+ - Dev-mode warnings for an unbounded grid container and for `pagination` with row grouping.
93
+ - Real-browser test project (Vitest browser mode + Playwright/Chromium), `npm run test:browser`; the npm publish workflow now runs unit and browser tests before publishing.
94
+ - Already shipped, previously listed as upcoming: npm publishing (since 0.1.0), native PDF export (v1.2.1), GitHub Pages deployment.
95
+
90
96
  ### Library Hardening *(completed 2026-09-02)*
91
- - **`GridRowMeta`**: Hierarchy metadata (`hasChildren`, `treeDepth`, `groupingField`, `groupingValue`, `descendantCount`, `isExpanded`, `isGroupRow`) moved from `GridRowModel` into a separate `Map<GridRowId, GridRowMeta>`. Exposed as `params.rowMeta` in `renderCell`. Runtime shim maintains backward compatibility; removed in v2.0.
97
+ - **`GridRowMeta`**: Hierarchy metadata (`hasChildren`, `treeDepth`, `groupingField`, `groupingValue`, `descendantCount`, `isExpanded`, `isGroupRow`) moved from `GridRowModel` into a separate `Map<GridRowId, GridRowMeta>`. Exposed as `params.rowMeta` in `renderCell`. The runtime shim (underscore-prefixed fields on copied row objects) was kept through v2.x and removed in v3.0 — see `docs/migration/v2-to-v3.md`.
92
98
  - **`CellErrorBoundary`**: Custom `renderCell` errors are now caught per-cell. Failing cells show a `⚠` indicator with the error as a tooltip; the rest of the grid renders normally.
93
99
  - **`GridLocaleText` / `localeText` prop**: All user-visible pagination strings (`paginationRowsPerPage`, `paginationOf`, `paginationPage`, `noRowsLabel`) are now overrideable for internationalisation.
94
100
  - **Core hook tests**: `useGridRowPipeline`, `useGridControlledState`, `useGridKeyboardNavigation` now have full test coverage (25 new tests).
@@ -22,7 +22,7 @@
22
22
  }
23
23
  /* Show ring when keyboard navigation is active */
24
24
  .ogx--kb .ogx__cell--focused {
25
- outline: 2px solid var(--ogx-color-primary, #2563eb);
25
+ outline: 2px solid var(--ogx-grid-cell-focus-border, var(--ogx-color-primary, #2563eb));
26
26
  outline-offset: -2px;
27
27
  }
28
28
  .ogx__cell--editable {
@@ -70,6 +70,11 @@
70
70
  .ogx:not(.ogx--row-spanning) .ogx__row--selected:hover .ogx__cell--pinned-right {
71
71
  background-color: var(--ogx-grid-row-selected-hover-background);
72
72
  }
73
+ /* Section edge cells show a shadow outside their box; the content stays clipped by .ogx__cell-content. */
74
+ .ogx__cell--pinned-left-last,
75
+ .ogx__cell--pinned-right-first {
76
+ overflow: visible;
77
+ }
73
78
  .ogx__cell--pinned-left-last::after {
74
79
  content: '';
75
80
  position: absolute;
@@ -101,6 +106,13 @@
101
106
  white-space: nowrap;
102
107
  width: 100%;
103
108
  }
109
+ /* Default rendering of a type: 'image' column: the image fits the row height. */
110
+ .ogx__cell-image {
111
+ display: block;
112
+ max-width: 100%;
113
+ max-height: calc(var(--ogx-row-height) - 8px);
114
+ object-fit: contain;
115
+ }
104
116
  .ogx__cell--spanned {
105
117
  background-color: var(--ogx-grid-background);
106
118
  z-index: 10;
@@ -5,6 +5,7 @@ import type { CellColSpanInfo } from '../../hooks/features/useGridSpanning';
5
5
 
6
6
  import { GridEditInputCell } from './GridEditInputCell';
7
7
  import { CellErrorBoundary } from './CellErrorBoundary';
8
+ import { formatValueByType } from '../../utils/values';
8
9
 
9
10
  export interface CellProps<R extends GridRowModel = GridRowModel> {
10
11
  onCellClick?: (params: GridCellParams<R>) => void;
@@ -15,25 +16,41 @@ export interface CellProps<R extends GridRowModel = GridRowModel> {
15
16
  colDef: GridColDef<R>;
16
17
  rowIndex: number;
17
18
  colIndex: number;
19
+ /** 1-based `aria-colindex` (system columns included). Defaults to `colIndex + 1`. */
20
+ ariaColIndex?: number;
18
21
  isSelected?: boolean;
19
22
  onClick?: (event: React.MouseEvent) => void;
20
23
  width?: number;
21
24
  pinnedPosition?: GridPinnedPosition | null;
22
25
  pinnedOffset?: number;
26
+ /**
27
+ * The cell is the last left-pinned or first right-pinned cell of its row: it gets
28
+ * `ogx__cell--pinned-left-last` / `ogx__cell--pinned-right-first` (the section edge shadow).
29
+ */
30
+ isPinnedEdge?: boolean;
23
31
 
24
32
  isFocused?: boolean;
25
33
  isFocusVisible?: boolean;
26
34
 
35
+ /** Whether this cell may enter edit mode (column, row and `isCellEditable` already resolved). */
27
36
  isEditable?: boolean;
28
37
  isEditing?: boolean;
29
38
  onEditStart?: () => void;
30
- onEditStop?: (cancel?: boolean) => void;
39
+ /** Ends this cell's edit. `field` identifies the cell, so a late call cannot end another cell's edit. */
40
+ onEditStop?: (cancel?: boolean, field?: string) => void;
31
41
  onValueChange?: (newValue: unknown) => void;
32
42
 
33
43
  colSpanInfo?: CellColSpanInfo;
34
44
  rowSpan?: number;
35
45
  isHiddenByRowSpan?: boolean;
36
46
  rowMeta?: GridRowMeta;
47
+ /** The error the column's valueGetter threw for this cell (computed by Row); shown as a cell error. */
48
+ valueError?: unknown;
49
+ }
50
+
51
+ interface FormattedValue {
52
+ formattedValue: string;
53
+ error?: unknown;
37
54
  }
38
55
 
39
56
  function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
@@ -46,11 +63,13 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
46
63
  colDef,
47
64
  rowIndex,
48
65
  colIndex,
66
+ ariaColIndex,
49
67
  isSelected,
50
68
  onClick,
51
69
  width,
52
70
  pinnedPosition,
53
71
  pinnedOffset,
72
+ isPinnedEdge,
54
73
  isFocused,
55
74
  isFocusVisible,
56
75
  isEditable,
@@ -62,7 +81,8 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
62
81
  colSpanInfo,
63
82
  rowSpan: rowSpanProp,
64
83
  isHiddenByRowSpan,
65
- rowMeta
84
+ rowMeta,
85
+ valueError
66
86
  } = props;
67
87
 
68
88
  // All hooks must come before any early returns (Rules of Hooks)
@@ -74,47 +94,85 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
74
94
  }
75
95
  }, [onCellValueChange, onValueChange, colDef.field]);
76
96
 
77
- const formattedValue = React.useMemo(() => {
78
- if (colDef.valueFormatter) {
79
- return colDef.valueFormatter({
80
- value,
81
- row,
82
- field: colDef.field
83
- });
84
- }
85
-
86
- if (value === null || value === undefined) {
87
- return '';
97
+ // A valueFormatter that throws is contained to its cell (shown as a cell error), like renderCell.
98
+ // Synthetic rows (group, subtotal, auto-created tree parent) have no value in most columns: the
99
+ // formatter, written for data rows, is only called for the values they do hold.
100
+ const isSyntheticRow = rowMeta?.isGroupRow === true;
101
+ const { formattedValue, error: formatError } = React.useMemo<FormattedValue>(() => {
102
+ if (colDef.valueFormatter && !(isSyntheticRow && value == null)) {
103
+ try {
104
+ return { formattedValue: colDef.valueFormatter({ value, row, field: colDef.field }) };
105
+ } catch (error) {
106
+ return { formattedValue: '', error: error ?? new Error('valueFormatter failed') };
107
+ }
88
108
  }
89
109
 
90
- return String(value);
91
- }, [value, row, colDef]);
110
+ // Without a formatter the column type decides: locale date, Yes / No, singleSelect label.
111
+ return { formattedValue: formatValueByType(value, colDef) };
112
+ }, [value, row, colDef, isSyntheticRow]);
92
113
 
93
114
  const resolvedCellClassName = React.useMemo(() => {
94
115
  if (!colDef.cellClassName) return '';
95
116
  if (typeof colDef.cellClassName === 'function') {
96
- return colDef.cellClassName({ value, row, field: colDef.field, colDef, rowIndex, colIndex }) || '';
117
+ try {
118
+ return colDef.cellClassName({ value, formattedValue, row, field: colDef.field, colDef, rowIndex, colIndex, rowMeta }) || '';
119
+ } catch {
120
+ return '';
121
+ }
97
122
  }
98
123
  return colDef.cellClassName;
99
- }, [colDef, value, row, rowIndex, colIndex]);
124
+ }, [colDef, value, formattedValue, row, rowIndex, colIndex, rowMeta]);
100
125
 
101
- const cellRef = React.useRef<HTMLDivElement>(null);
126
+ const cellError = valueError ?? formatError;
102
127
 
103
- React.useLayoutEffect(() => {
104
- if (isFocused && cellRef.current) {
105
- cellRef.current.focus({ preventScroll: true });
106
- }
107
- }, [isFocused]);
128
+ const field = colDef.field;
129
+ const handleCommit = React.useCallback(() => { onEditStop?.(false, field); }, [onEditStop, field]);
130
+ const handleCancel = React.useCallback(() => { onEditStop?.(true, field); }, [onEditStop, field]);
131
+
132
+ // A failed renderCell is retried when the row, the renderer or the value changes.
133
+ const errorResetKey = React.useMemo(() => [row, colDef.renderCell, value], [row, colDef.renderCell, value]);
134
+
135
+ // A cell that unmounts while editing (scrolled out of the render window, filtered or paged away)
136
+ // gets no blur, so its edit would be left open with no editor. Commit it instead, as blur would.
137
+ // Deferred to a microtask so React StrictMode's simulated unmount + remount does not commit.
138
+ const editorMountedRef = React.useRef(false);
139
+ React.useEffect(() => {
140
+ if (!isEditing || !onEditStop) return;
141
+ editorMountedRef.current = true;
142
+ return () => {
143
+ editorMountedRef.current = false;
144
+ queueMicrotask(() => {
145
+ if (!editorMountedRef.current) onEditStop(false, field);
146
+ });
147
+ };
148
+ }, [isEditing, onEditStop, field]);
108
149
 
109
150
  if (colSpanInfo?.spannedByColSpan) {
110
151
  return null;
111
152
  }
112
153
 
154
+ const colSpan = colSpanInfo?.cellProps.colSpan ?? 1;
155
+ const isColSpanOrigin = colSpan > 1;
156
+ // A merged cell takes the summed widths of the columns it covers (computed by Row from the
157
+ // resolved layout widths); the origin column's own min/max width does not apply to it.
158
+ const cellWidth = (isColSpanOrigin ? colSpanInfo?.cellProps.width : undefined) ?? width ?? colDef.width ?? 100;
159
+ const sizeStyle: React.CSSProperties = {
160
+ width: 'var(--width)',
161
+ '--width': typeof cellWidth === 'number' ? `${cellWidth}px` : cellWidth,
162
+ flexGrow: (isColSpanOrigin ? colSpanInfo?.cellProps.flexGrow : undefined) ?? colDef.flex ?? 0,
163
+ flexShrink: 0,
164
+ flexBasis: 'auto',
165
+ boxSizing: 'border-box',
166
+ minWidth: isColSpanOrigin ? undefined : colDef.minWidth,
167
+ maxWidth: isColSpanOrigin ? undefined : colDef.maxWidth,
168
+ } as React.CSSProperties;
169
+
113
170
  if (isHiddenByRowSpan) {
171
+ // Keeps the covered column's slot so the rest of the row stays under its headers.
114
172
  return (
115
173
  <div
116
174
  className="ogx__cell ogx__cell--hidden"
117
- style={{ width: width ?? colDef.width ?? 100 }}
175
+ style={sizeStyle}
118
176
  role="presentation"
119
177
  data-field={colDef.field}
120
178
  data-colindex={colIndex}
@@ -123,6 +181,9 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
123
181
  }
124
182
 
125
183
  const handleClick = (e: React.MouseEvent) => {
184
+ // Clicks inside an open editor (placing the caret, opening a select) belong to the editor:
185
+ // treating them as cell clicks would move focus to the grid and commit the edit.
186
+ if (isEditing) return;
126
187
  if (onCellClick) {
127
188
  onCellClick({
128
189
  row,
@@ -136,17 +197,19 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
136
197
  onClick?.(e);
137
198
  };
138
199
 
139
- const handleDoubleClick = (e: React.MouseEvent) => {
140
- if (isEditable && (onEditStart || onCellEditStart)) {
141
- e.stopPropagation();
142
- if (onCellEditStart) {
143
- onCellEditStart(colDef.field, value);
144
- } else {
145
- onEditStart?.();
146
- }
200
+ // The double-click is left to bubble so the row's onRowDoubleClick still fires.
201
+ const handleDoubleClick = () => {
202
+ // Double-clicking inside an open editor (to select a word) must not restart the edit.
203
+ if (isEditing || !isEditable) return;
204
+ if (onCellEditStart) {
205
+ onCellEditStart(colDef.field, value);
206
+ } else {
207
+ onEditStart?.();
147
208
  }
148
209
  };
149
210
 
211
+ const renderEditCell = colDef.renderEditCell;
212
+
150
213
  const isPinned = pinnedPosition !== null && pinnedPosition !== undefined;
151
214
 
152
215
  const classNames = [
@@ -158,24 +221,16 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
158
221
  colDef.type && `ogx__cell--type-${colDef.type}`,
159
222
  isPinned && 'ogx__cell--pinned',
160
223
  isPinned && `ogx__cell--pinned-${pinnedPosition}`,
224
+ isPinned && isPinnedEdge && `ogx__cell--pinned-${pinnedPosition}-${pinnedPosition === 'left' ? 'last' : 'first'}`,
161
225
  isEditing && 'ogx__cell--editing',
162
226
  (rowSpanProp && rowSpanProp > 1) && 'ogx__cell--spanned',
163
227
  resolvedCellClassName
164
228
  ].filter(Boolean).join(' ');
165
229
 
166
- const colSpan = colSpanInfo?.cellProps?.colSpan || 1;
167
230
  const rowSpan = rowSpanProp || 1;
168
- const cellWidth = colSpanInfo?.cellProps?.width ?? width ?? colDef.width ?? 100;
169
231
 
170
232
  const style: React.CSSProperties = {
171
- width: 'var(--width)',
172
- '--width': typeof cellWidth === 'number' ? `${cellWidth}px` : cellWidth,
173
- flexGrow: colDef.flex ?? 0,
174
- flexShrink: 0,
175
- flexBasis: colDef.flex ? 'auto' : 'auto',
176
- boxSizing: 'border-box',
177
- minWidth: colDef.minWidth,
178
- maxWidth: colDef.maxWidth,
233
+ ...sizeStyle,
179
234
  position: isPinned ? 'sticky' : undefined,
180
235
  zIndex: colDef.zIndex ?? (isPinned ? 3 : undefined),
181
236
  padding: isEditing ? 0 : undefined
@@ -199,13 +254,12 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
199
254
 
200
255
  return (
201
256
  <div
202
- ref={cellRef}
203
257
  className={classNames}
204
258
  style={style}
205
259
  onClick={handleClick}
206
260
  onDoubleClick={handleDoubleClick}
207
261
  role="gridcell"
208
- aria-colindex={colIndex + 1}
262
+ aria-colindex={ariaColIndex ?? colIndex + 1}
209
263
  aria-readonly={!isEditable || undefined}
210
264
  tabIndex={-1}
211
265
  data-field={colDef.field}
@@ -215,35 +269,52 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
215
269
  >
216
270
  <div className="ogx__cell-content">
217
271
  {isEditing && (onCellValueChange || onValueChange) && onEditStop ? (
218
- colDef.renderEditCell ? (
219
- colDef.renderEditCell({
220
- value,
221
- row,
222
- field: colDef.field,
223
- colDef,
224
- rowIndex,
225
- colIndex,
226
- rowMeta,
227
- })
272
+ renderEditCell ? (
273
+ <CellErrorBoundary
274
+ field={colDef.field}
275
+ resetKey={row}
276
+ renderFn={() => renderEditCell({
277
+ value,
278
+ formattedValue,
279
+ row,
280
+ field: colDef.field,
281
+ colDef,
282
+ rowIndex,
283
+ colIndex,
284
+ rowMeta,
285
+ onValueChange: handleValueChange,
286
+ onCommit: handleCommit,
287
+ onCancel: handleCancel,
288
+ })}
289
+ />
228
290
  ) : (
229
291
  <GridEditInputCell
230
292
  value={value}
293
+ formattedValue={formattedValue}
231
294
  row={row}
232
295
  field={colDef.field}
233
296
  colDef={colDef as unknown as GridColDef}
234
297
  rowIndex={rowIndex}
235
298
  colIndex={colIndex}
299
+ rowMeta={rowMeta}
236
300
  onValueChange={handleValueChange}
237
- onCommit={() => onEditStop()}
238
- onCancel={() => onEditStop(true)}
301
+ onCommit={handleCommit}
302
+ onCancel={handleCancel}
239
303
  />
240
304
  )
241
- ) : colDef.renderCell ? (
305
+ ) : cellError !== undefined ? (
242
306
  <CellErrorBoundary
243
307
  field={colDef.field}
244
308
  resetKey={row}
309
+ renderFn={() => { throw cellError; }}
310
+ />
311
+ ) : colDef.renderCell ? (
312
+ <CellErrorBoundary
313
+ field={colDef.field}
314
+ resetKey={errorResetKey}
245
315
  renderFn={() => colDef.renderCell!({
246
316
  value,
317
+ formattedValue,
247
318
  row,
248
319
  field: colDef.field,
249
320
  colDef,
@@ -252,6 +323,8 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
252
323
  rowMeta,
253
324
  })}
254
325
  />
326
+ ) : colDef.type === 'image' && typeof value === 'string' && value !== '' ? (
327
+ <img className="ogx__cell-image" src={value} alt={colDef.headerName ?? colDef.field} loading="lazy" />
255
328
  ) : (
256
329
  formattedValue
257
330
  )}
@@ -10,9 +10,17 @@ function CellRenderTarget({ renderFn }: { renderFn: () => React.ReactNode }) {
10
10
  interface CellErrorBoundaryProps {
11
11
  renderFn: () => React.ReactNode;
12
12
  field: string;
13
+ /** The error is cleared when this changes. An array is compared element by element. */
13
14
  resetKey?: unknown;
14
15
  }
15
16
 
17
+ function sameResetKey(a: unknown, b: unknown): boolean {
18
+ if (Array.isArray(a) && Array.isArray(b)) {
19
+ return a.length === b.length && a.every((item, i) => Object.is(item, b[i]));
20
+ }
21
+ return Object.is(a, b);
22
+ }
23
+
16
24
  interface CellErrorBoundaryState {
17
25
  hasError: boolean;
18
26
  error: Error | null;
@@ -29,13 +37,11 @@ export class CellErrorBoundary extends React.Component<CellErrorBoundaryProps, C
29
37
  props: CellErrorBoundaryProps,
30
38
  state: CellErrorBoundaryState
31
39
  ): Partial<CellErrorBoundaryState> | null {
32
- if (state.hasError && props.resetKey !== state.resetKey) {
40
+ if (sameResetKey(props.resetKey, state.resetKey)) return null;
41
+ if (state.hasError) {
33
42
  return { hasError: false, error: null, resetKey: props.resetKey };
34
43
  }
35
- if (props.resetKey !== state.resetKey) {
36
- return { resetKey: props.resetKey };
37
- }
38
- return null;
44
+ return { resetKey: props.resetKey };
39
45
  }
40
46
 
41
47
  static getDerivedStateFromError(error: Error): Partial<CellErrorBoundaryState> {
@@ -53,7 +59,7 @@ export class CellErrorBoundary extends React.Component<CellErrorBoundaryProps, C
53
59
  return (
54
60
  <div
55
61
  className="ogx__cell-error"
56
- role="status"
62
+ role="img"
57
63
  aria-label={`Error in cell: ${this.props.field}`}
58
64
  title={this.state.error?.message ?? 'Render error'}
59
65
  >