@opencorestack/opengridx 2.0.3 → 2.1.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 (47) hide show
  1. package/CHANGELOG.md +54 -3
  2. package/dist/index.d.ts +50 -2
  3. package/dist/opengridx.css +1 -1
  4. package/dist/opengridx.es.js +3805 -3692
  5. package/dist/opengridx.es.js.map +1 -1
  6. package/dist/opengridx.umd.js +16 -16
  7. package/dist/opengridx.umd.js.map +1 -1
  8. package/docs/API_REFERENCE.md +14 -4
  9. package/docs/components/column-visibility.md +40 -16
  10. package/docs/components/datagrid.md +1 -0
  11. package/docs/customization/slots-api.md +18 -6
  12. package/docs/features/export-guide.md +22 -3
  13. package/docs/features/sorting-pagination.md +2 -0
  14. package/docs/features/tree-data-grouping.md +8 -1
  15. package/docs/features/virtualization.md +66 -17
  16. package/docs/roadmap.md +13 -7
  17. package/lib/components/Cell/Cell.tsx +1 -0
  18. package/lib/components/DataGrid/DataGrid.css +7 -0
  19. package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
  20. package/lib/components/DataGrid/DataGrid.tsx +45 -11
  21. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
  22. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  23. package/lib/components/DataGrid/GridListView.tsx +3 -0
  24. package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
  25. package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
  26. package/lib/components/ListView/ListViewRow.tsx +3 -0
  27. package/lib/components/Row/Row.tsx +10 -0
  28. package/lib/hooks/core/useGridColumns.tsx +8 -3
  29. package/lib/hooks/core/useGridDevWarnings.ts +42 -0
  30. package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
  31. package/lib/hooks/core/useGridScrollSync.ts +62 -15
  32. package/lib/hooks/core/useGridVirtualization.ts +4 -1
  33. package/lib/hooks/core/useGridVisibleRows.ts +3 -2
  34. package/lib/hooks/features/useAggregation.ts +3 -38
  35. package/lib/hooks/useRowGrouping.test.ts +162 -1
  36. package/lib/hooks/useRowGrouping.ts +42 -59
  37. package/lib/hooks/useTreeData.test.ts +83 -0
  38. package/lib/hooks/useTreeData.ts +44 -43
  39. package/lib/index.ts +2 -0
  40. package/lib/types/index.ts +22 -2
  41. package/lib/utils/aggregation/aggregation.test.ts +16 -0
  42. package/lib/utils/aggregation/index.ts +62 -0
  43. package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
  44. package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
  45. package/lib/utils/scroll/index.ts +28 -0
  46. package/llms.txt +22 -13
  47. package/package.json +7 -3
@@ -18,9 +18,10 @@ The main component for displaying and interacting with data.
18
18
  | `rowHeight` | `number` | `52` | Height of each row in pixels. |
19
19
  | `headerHeight` | `number` | `56` | Height of the header row. |
20
20
  | `autoHeight` | `boolean` | `false` | Adjust grid height to match row total. |
21
+ | `overscanRowCount` | `number` | `3` | Minimum rows rendered outside the visible viewport. The grid adapts this upward automatically based on scroll velocity — this prop sets the floor. |
21
22
  | `loading` | `boolean` | `false` | Shows a loading skeleton overlay. |
22
23
  | `checkboxSelection` | `boolean` | `false` | Enable row selection via checkboxes. |
23
- | `pagination` | `boolean` | `false` | Enable the bottom pagination bar. |
24
+ | `pagination` | `boolean` | `false` | Enable the bottom pagination bar. Ignored while `rowGroupingModel` is active (dev-mode warning). |
24
25
  | `paginationMode` | `'client' \| 'server' \| 'infinite'` | `'client'` | How to handle paging. |
25
26
  | `paginationModel` | `GridPaginationModel` | — | Controlled pagination state (`{ page, pageSize }`). |
26
27
  | `onPaginationModelChange` | `(model: GridPaginationModel) => void` | — | Fired when page or page size changes. |
@@ -29,11 +30,12 @@ The main component for displaying and interacting with data.
29
30
  | `height` | `number \| string` | `undefined` | Total height of the grid container. |
30
31
  | `density` | `'compact' \| 'standard' \| 'comfortable'` | `'standard'` | Visual row density. |
31
32
  | `initialState` | `GridInitialState` | `undefined` | Starting state for sorting, filters, etc. |
32
- | `slots` | `GridSlots` | `{}` | Custom component overrides. |
33
+ | `slots` | `GridSlots` | `{}` | Custom component overrides: `toolbar`, `pagination`, `noRowsOverlay`, `loadingOverlay`, `footer`. See [Slots API](customization/slots-api.md). |
33
34
  | `slotProps` | `Record<string, unknown>` | `{}` | Props passed to custom slots. |
34
35
  | `filterModel` | `GridFilterModel` | `undefined` | Active filters. |
35
36
  | `sortModel` | `GridSortItem[]` | `undefined` | Active sorting. |
36
37
  | `onRowClick` | `(params: GridRowParams) => void` | — | Fired when a row is clicked. |
38
+ | `onRowDoubleClick` | `(params: GridRowParams) => void` | — | Fired when a row is double-clicked (v2.1+). Also fires for group rows; not fired on the checkbox, expand icon, drag handle or an open editor. |
37
39
  | `onCellClick` | `(params: GridCellParams) => void` | — | Fired when a cell is clicked. |
38
40
  | `onStateChange` | `(state: GridState) => void` | — | Fired on any internal state update. |
39
41
  | `processRowUpdate` | `(new, old) => R \| Promise<R>` | — | Fired after a cell edit is committed. |
@@ -301,7 +303,7 @@ Defines the behavior and appearance of a single column.
301
303
 
302
304
  | Property | Type | Description |
303
305
  | :--- | :--- | :--- |
304
- | `renderCell` | `(params: GridRenderCellParams) => ReactNode` | Fully custom cell renderer. Receives `value`, `row`, `field`, `rowIndex`, `colIndex`. |
306
+ | `renderCell` | `(params: GridRenderCellParams) => ReactNode` | Fully custom cell renderer. Receives `value`, `formattedValue` (v2.1+, the `valueFormatter` output), `row`, `field`, `colDef`, `rowIndex`, `colIndex`, `rowMeta`. |
305
307
  | `renderHeader` | `(params: GridRenderHeaderParams) => ReactNode` | Custom header cell renderer. Use for icons, sort indicators, or rich headers. |
306
308
  | `renderEditCell` | `(params: GridRenderCellParams) => ReactNode` | Custom editor rendered when the cell enters edit mode. Requires `editable: true`. |
307
309
 
@@ -350,11 +352,19 @@ Defines the behavior and appearance of a single column.
350
352
  | `exportToCsv(rows, cols, options?)` | `void` | Triggers download of CSV file. |
351
353
  | `exportToJson(rows, cols, options?)` | `void` | Triggers download of JSON file. |
352
354
  | `exportToExcel(rows, cols, options?)` | `void` | Basic `.xls` export (zero-dep). |
353
- | `exportToExcelAdvanced(rows, cols, options?)` | `Promise<void>` | Real `.xlsx` export (styled, multi-sheet, lazy-loaded). |
355
+ | `exportToExcelAdvanced(rows, cols, options?)` | `Promise<void>` | Real `.xlsx` export (styled, multi-sheet, lazy-loaded). Accepts `groupedRows` for outlined grouped reports (v2.1+). |
354
356
  | `printGrid(rows, cols, title \| options)` | `void` | Opens browser print dialog. |
355
357
 
356
358
  ---
357
359
 
360
+ ## 🧩 Standalone Components
361
+
362
+ | Component | Description |
363
+ | :--- | :--- |
364
+ | `ColumnVisibilityPanel` | Controlled column show/hide (and optional reorder) list, for use outside the grid (v2.1+). Props: `ColumnVisibilityPanelProps`. See [Column Visibility](components/column-visibility.md). |
365
+
366
+ ---
367
+
358
368
  ## 🎨 Theming & Styling
359
369
 
360
370
  ### `DataGridThemeProvider`
@@ -12,26 +12,50 @@ The `ColumnVisibilityPanel` provides an interactive list for users to toggle the
12
12
 
13
13
  ## 🛠️ Usage
14
14
 
15
- ### Toolbar Integration
16
- The most common way to use the visibility panel is within a dropdown menu in the grid's toolbar.
15
+ The grid's toolbar and header menu already include this panel. Import it yourself (exported since v2.1) only when you want the list somewhere else, such as a sidebar or modal. It is a controlled component: you own the visibility model and pass it in.
17
16
 
18
17
  ```tsx
19
- import { ColumnVisibilityPanel } from '@opencorestack/opengridx';
20
-
21
- // In your Toolbar component
22
- <Dropdown content={<ColumnVisibilityPanel />}>
23
- <button>Columns</button>
24
- </Dropdown>
18
+ import { useMemo, useState } from 'react';
19
+ import { DataGrid, ColumnVisibilityPanel } from '@opencorestack/opengridx';
20
+ import type { GridColDef } from '@opencorestack/opengridx';
21
+
22
+ function Report({ rows, columns }: { rows: Row[]; columns: GridColDef<Row>[] }) {
23
+ const [visibility, setVisibility] = useState<Record<string, boolean>>({});
24
+ const visibleColumns = useMemo(
25
+ () => new Set(columns.filter(c => visibility[c.field] !== false).map(c => c.field)),
26
+ [columns, visibility],
27
+ );
28
+ const setAll = (visible: boolean) =>
29
+ setVisibility(Object.fromEntries(columns.filter(c => c.hideable !== false).map(c => [c.field, visible])));
30
+
31
+ return (
32
+ <div style={{ display: 'flex' }}>
33
+ <aside className="my-column-sidebar">
34
+ <ColumnVisibilityPanel
35
+ columns={columns}
36
+ visibleColumns={visibleColumns}
37
+ onVisibilityChange={(field, isVisible) => setVisibility(v => ({ ...v, [field]: isVisible }))}
38
+ onShowAll={() => setAll(true)}
39
+ onHideAll={() => setAll(false)}
40
+ />
41
+ </aside>
42
+ <DataGrid rows={rows} columns={columns} columnVisibilityModel={visibility} onColumnVisibilityModelChange={setVisibility} />
43
+ </div>
44
+ );
45
+ }
25
46
  ```
26
47
 
27
- ### Direct Integration
28
- You can also render it persistently beside the grid or within a custom modal.
48
+ ### Props (`ColumnVisibilityPanelProps`)
29
49
 
30
- ```tsx
31
- <div className="my-column-sidebar">
32
- <ColumnVisibilityPanel />
33
- </div>
34
- ```
50
+ | Prop | Type | Description |
51
+ | :--- | :--- | :--- |
52
+ | `columns` | `GridColDef[]` | Columns to list |
53
+ | `visibleColumns` | `Set<string>` | Fields currently visible |
54
+ | `onVisibilityChange` | `(field, isVisible) => void` | Toggle one column |
55
+ | `onShowAll` / `onHideAll` | `() => void` | Bulk actions (only `hideable` columns are affected) |
56
+ | `onColumnReorder` | `(fromField, toField) => void` | Optional — enables drag-to-reorder in the list |
57
+ | `onColumnOrderReset` | `() => void` | Optional — shows a reset-order action |
58
+ | `showNonHideableColumns` | `boolean` | Show `hideable: false` columns as disabled rows. Default `false` |
35
59
 
36
60
  ---
37
61
 
@@ -47,7 +71,7 @@ You can also render it persistently beside the grid or within a custom modal.
47
71
 
48
72
  ## 🎨 Controlling Visibility Programmatically
49
73
 
50
- The column visibility panel is an internal component; it cannot be replaced via the slots API. Use the controlled props to drive visibility from outside:
74
+ The built-in panel cannot be replaced through the slots API. To drive visibility from outside, use the controlled props, optionally together with a standalone `ColumnVisibilityPanel` as shown above:
51
75
 
52
76
  ```tsx
53
77
  <DataGrid
@@ -35,6 +35,7 @@ function MyGrid() {
35
35
  | `paginationModel` | `GridPaginationModel` | — | Controlled pagination state (`{ page, pageSize }`). Omit for uncontrolled; use `initialState` to set the initial page/pageSize. |
36
36
  | `pageSizeOptions` | `number[]` | `[10, 25, 50, 100]` | Available page size options. |
37
37
  | `initialState` | `GridInitialState` | `undefined` | Initial configuration (sorting, columns, pagination, etc). |
38
+ | `overscanRowCount` | `number` | `3` | Minimum rows rendered outside the visible viewport. The grid adapts this upward automatically based on scroll velocity — this prop sets the floor. |
38
39
 
39
40
  ## 🔃 Sorting
40
41
 
@@ -113,30 +113,42 @@ function CustomLoader() {
113
113
  ```
114
114
 
115
115
  ### 5. `footer`
116
- Replace the entire footer section (including pagination).
116
+ Replace the entire footer section below the grid, including the default pagination. Rendered whenever the slot is set, including while row grouping is active.
117
117
 
118
118
  **Props received:**
119
119
  ```typescript
120
120
  {
121
- // Any props from slotProps.footer
121
+ apiRef: React.MutableRefObject<GridApi>;
122
+ aggregationModel: GridAggregationModel;
123
+ aggregationResult: GridAggregationResult | null; // null when no aggregationModel
124
+ rowCount: number; // filtered rows (leaf rows when grouping)
125
+ pagination: boolean; // effective: false while row grouping is active
126
+ paginationModel: GridPaginationModel;
127
+ pageSizeOptions: number[];
128
+ onPaginationModelChange: (model: GridPaginationModel) => void;
129
+ // ...plus any props from slotProps.footer
122
130
  }
123
131
  ```
124
132
 
125
- **Example:**
133
+ **Example — a persistent grand-total bar that stays in sync with filtering:**
126
134
  ```tsx
127
- function CustomFooter() {
135
+ function TotalsFooter({ aggregationResult, rowCount }: Record<string, unknown>) {
136
+ const totals = aggregationResult as Record<string, number> | null;
128
137
  return (
129
138
  <div style={{ padding: '12px', borderTop: '1px solid #e0e0e0' }}>
130
- <span>Custom footer content</span>
139
+ {String(rowCount)} rows · Total: {totals?.amount ?? '—'}
131
140
  </div>
132
141
  );
133
142
  }
134
143
 
135
144
  <DataGrid
136
- slots={{ footer: CustomFooter }}
145
+ aggregationModel={{ amount: 'sum' }}
146
+ slots={{ footer: TotalsFooter }}
137
147
  />
138
148
  ```
139
149
 
150
+ > `noRowsOverlay`, `loadingOverlay` and `footer` were declared in the types before v2.1 but never rendered. They are wired from v2.1.
151
+
140
152
  ## Complete Example
141
153
 
142
154
  ```tsx
@@ -47,7 +47,7 @@ exportToCsv(rows, columns, {
47
47
  includeHeaders: true,
48
48
  delimiter: ',',
49
49
  selectedRows: [1, 2, 3], // export only these IDs
50
- aggregationResult: aggResult, // adds SUM/AVG totals row
50
+ aggregationResult: aggResult, // adds two rows: function labels (SUM/AVG), then values
51
51
  aggregationModel: { salary: 'sum' },
52
52
  });
53
53
  ```
@@ -60,7 +60,7 @@ exportToCsv(rows, columns, {
60
60
  | `includeHeaders` | `boolean` | `true` | Include column header row |
61
61
  | `delimiter` | `string` | `','` | Field delimiter |
62
62
  | `selectedRows` | `(string\|number)[]` | — | Export only these row IDs |
63
- | `aggregationResult` | `object \| null` | — | Aggregation values row |
63
+ | `aggregationResult` | `object \| null` | — | Appends two rows after the data: a function-label row (`SUM`, `AVG`…) then a values row |
64
64
  | `aggregationModel` | `object \| null` | — | Labels for aggregation row |
65
65
 
66
66
  ---
@@ -89,7 +89,7 @@ exportToExcel(rows, columns, {
89
89
  | `sheetName` | `string` | `'Sheet1'` | Sheet tab name |
90
90
  | `includeHeaders` | `boolean` | `true` | Include column header row |
91
91
  | `selectedRows` | `(string\|number)[]` | — | Export only these row IDs |
92
- | `aggregationResult` | `object \| null` | — | Aggregation values row |
92
+ | `aggregationResult` | `object \| null` | — | Appends two rows after the data: a function-label row (`SUM`, `AVG`…) then a values row |
93
93
  | `aggregationModel` | `object \| null` | — | Labels for aggregation row |
94
94
 
95
95
  ---
@@ -163,6 +163,9 @@ await exportToExcelAdvanced(rows, columns, {
163
163
  | `aggregationResult` | `object \| null` | — | Aggregation totals |
164
164
  | `aggregationModel` | `object \| null` | — | Aggregation function labels |
165
165
  | `selectedRows` | `(string\|number)[]` | — | IDs for `rows: 'selected'` sheets |
166
+ | `groupedRows` | `GridGroupedExportRow[]` | — | Grouped structure from `apiRef.current.getGroupedExportRows()` (v2.1+). See [Grouped export](#grouped-export-to-advanced-excel) |
167
+ | `groupHeaderFillColor` | `string` | `'#e8eaf6'` | Group-header row fill (grouped export) |
168
+ | `groupSubtotalFillColor` | `string` | `'#f0f4ff'` | Group-subtotal row fill (grouped export) |
166
169
 
167
170
  ### `ExcelSheetDefinition`
168
171
 
@@ -187,6 +190,22 @@ await exportToExcelAdvanced(rows, columns, {
187
190
  | `imageWidth` | `number` | Width of embedded image (px, default: 40) |
188
191
  | `imageHeight`| `number` | Height of embedded image (px, default: 40) |
189
192
 
193
+ ### Grouped export to advanced Excel
194
+
195
+ Since v2.1, pass the grid's grouped structure to keep number formats, styling and summary sheets on grouped reports:
196
+
197
+ ```tsx
198
+ await exportToExcelAdvanced(rows, columns, {
199
+ fileName: 'sales-by-region.xlsx',
200
+ groupedRows: apiRef.current.getGroupedExportRows() ?? undefined,
201
+ aggregationModel,
202
+ aggregationResult: apiRef.current.getAggregationResult(),
203
+ columnStyles: { amount: { numFmt: '$#,##0.00' } },
204
+ });
205
+ ```
206
+
207
+ Sheets with `rows: 'all'` are written in grouped order: a bold group-header row (label from the column's `groupingValueFormatter`, else `"Header: value"`), the leaf rows, a `Subtotal` row per group, and a final `Grand Total`. Rows use Excel's native outlining, so users can collapse groups in Excel. Subtotal and total values are written as numbers so `columnStyles[field].numFmt` applies. A `grand-total` entry replaces the sheet's `includeSummary` rows, so totals are not duplicated. `rows: 'selected'` sheets ignore `groupedRows` and export flat.
208
+
190
209
  ### Summary Sheet
191
210
 
192
211
  Pass `{ type: 'summary', name: 'My Sheet' }` in `sheets` to add a standalone aggregation sheet:
@@ -62,6 +62,8 @@ Disable sorting for specific columns in `GridColDef`:
62
62
 
63
63
  OpenGridX supports standard page-based pagination and infinite scrolling.
64
64
 
65
+ > **Pagination is ignored while row grouping is active.** When `rowGroupingModel` is non-empty the grid renders all groups in one scrollable, virtualized view and does not show the pager, even if `pagination` is set. A development-mode `console.warn` flags this combination. Give a grouped grid a bounded height so virtualization limits what renders; see [Virtualization](./virtualization.md#the-grid-needs-a-bounded-height).
66
+
65
67
  ### Usage
66
68
  ```tsx
67
69
  <DataGrid
@@ -17,10 +17,17 @@ Row grouping allows you to categorize rows based on common column values.
17
17
  ```
18
18
 
19
19
  ### Features
20
- - **Multi-Level Groups**: Nest data as deeply as needed.
20
+ - **Multi-Level Groups**: Nest data as deeply as needed — one level per field in `rowGroupingModel`, in order.
21
21
  - **Aggregation Integration**: Summarize values automatically for each group level.
22
22
  - **Expansion Control**: Control which groups are expanded by default.
23
23
 
24
+ ### Behaviour to know about
25
+
26
+ - **Pagination is ignored** while `rowGroupingModel` is active. All groups render in one scrollable, virtualized view, so the grid needs a bounded height ([Virtualization](./virtualization.md#the-grid-needs-a-bounded-height)). A development-mode warning is logged if you pass `pagination` too.
27
+ - **Expansion state survives data updates** (row grouping and tree data). Groups and nodes the user expanded or collapsed stay that way when `rows` changes: inline edits, live refreshes, new array identities, and lazily loaded server-side children. The state resets to `defaultGroupingExpansionDepth` only when `rowGroupingModel` or `defaultGroupingExpansionDepth` changes value.
28
+ - **Group aggregation uses the same functions as the footer.** `sum`, `avg`, `count`, `min`, `max` and `unique` ignore `null`/`undefined`, so a group of `[10, null, 20]` gives `min` 10, `avg` 15 and `count` 2. `availableAggregationFunctions` on a column is honoured for group rows too.
29
+ - **`valueFormatter` applies to grouped rows** the same as flat rows (fixed in v2.1; earlier versions rendered raw values for every column once grouping was on).
30
+
24
31
  ---
25
32
 
26
33
  ## 🌿 Tree Data
@@ -18,7 +18,7 @@ The grid calculates which rows are visible based on:
18
18
  - Current scroll position
19
19
  - Viewport height
20
20
  - Row height (fixed or variable)
21
- - Overscan buffer (default: 5 rows)
21
+ - Overscan buffer (adaptive, minimum `overscanRowCount`, default 3 rows)
22
22
 
23
23
  ```tsx
24
24
  // Example: 1000 rows, but only ~15 rendered at a time
@@ -29,7 +29,7 @@ The grid calculates which rows are visible based on:
29
29
  />
30
30
  ```
31
31
 
32
- **Performance characteristics:**
32
+ **Performance characteristics** (when the grid has a bounded height — see below):
33
33
  - ✅ Renders ~15-25 rows regardless of total dataset size
34
34
  - ✅ Constant memory usage
35
35
  - ✅ Smooth 60fps scrolling
@@ -37,7 +37,35 @@ The grid calculates which rows are visible based on:
37
37
 
38
38
  ### Column Virtualization
39
39
 
40
- Currently, the DataGrid renders all columns. Column virtualization (rendering only visible columns) is planned for future releases.
40
+ Unpinned columns are virtualized horizontally: only the columns in view plus 6 on each side are rendered, with spacer cells standing in for the rest. Pinned columns are always rendered.
41
+
42
+ ### The grid needs a bounded height
43
+
44
+ Virtualization renders as many rows as fit in the grid's viewport. If the grid's container has no bounded height, the viewport grows to fit **every** row, so every row is rendered. Nothing errors; the grid just stops virtualizing.
45
+
46
+ The most common cause is a flex child that is missing `min-height: 0`. A flex item defaults to `min-height: auto`, which lets it grow to its content:
47
+
48
+ ```tsx
49
+ // ❌ Grows to fit all rows — virtualization is effectively off
50
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
51
+ <div style={{ flex: 1 }}>
52
+ <DataGrid rows={rows} columns={columns} height="100%" />
53
+ </div>
54
+ </div>
55
+
56
+ // ✅ Bounded — only visible rows render
57
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
58
+ <div style={{ flex: 1, minHeight: 0 }}>
59
+ <DataGrid rows={rows} columns={columns} height="100%" />
60
+ </div>
61
+ </div>
62
+ ```
63
+
64
+ `height="100%"` only helps if every ancestor up to a fixed-height element also has a definite height.
65
+
66
+ In development builds the grid logs a `console.warn` when it detects that it is rendering every row of a dataset larger than 200 rows because its viewport is unbounded.
67
+
68
+ Pagination can hide this problem, because a paginated grid only has one page of rows to render. Row grouping switches pagination off (see below), so an unbounded container that looked fine with pagination can freeze the tab once grouping is enabled.
41
69
 
42
70
  ## Configuration
43
71
 
@@ -84,16 +112,37 @@ Disable virtualization for small datasets:
84
112
 
85
113
  ### Overscan Buffer
86
114
 
87
- The overscan buffer determines how many extra rows are rendered outside the viewport. A larger buffer provides smoother scrolling but uses more memory.
115
+ The overscan buffer determines how many extra rows are rendered outside the viewport. The grid adapts this buffer automatically based on scroll velocity so fast scrolls never produce blank flashes.
116
+
117
+ **Adaptive algorithm (`useGridScrollSync`):**
118
+ - Scroll velocity (px/ms) is measured on every scroll event
119
+ - The overscan tier is chosen from the table below and applied immediately
120
+ - 200 ms after scrolling stops the buffer decays back to `overscanRowCount`
121
+
122
+ | Velocity (px/ms) | Overscan rows |
123
+ |---|---|
124
+ | < 0.5 | 3 |
125
+ | 0.5 – 3 | 5 |
126
+ | 3 – 15 | 12 |
127
+ | 15 – 40 | 20 |
128
+ | > 40 | 30 |
129
+
130
+ **Configuring the floor:**
131
+
132
+ ```tsx
133
+ // Never render fewer than 5 rows outside the viewport
134
+ <DataGrid
135
+ rows={rows}
136
+ columns={columns}
137
+ overscanRowCount={5}
138
+ />
139
+ ```
88
140
 
89
- **Current implementation:**
90
- - Default overscan: **5 rows** (above and below viewport)
91
- - Total rendered rows: ~(viewport rows + 10)
141
+ `overscanRowCount` (default `3`) sets the minimum — the adaptive algorithm always produces a value ≥ this floor.
92
142
 
93
143
  **Trade-offs:**
94
- - **Smaller overscan (1-3)**: Less memory, potential flickering during fast scrolling
95
- - **Larger overscan (5-8)**: More memory, smoother fast scrolling
96
- - **Very large overscan (10+)**: Diminishing returns, wasted rendering
144
+ - **Smaller floor (1-3)**: Less idle memory, relies on adaptive headroom for fast scrolls
145
+ - **Larger floor (5-8)**: Pre-renders more rows at rest, trades memory for smoother slow scrolls
97
146
 
98
147
  ### Scroll Performance
99
148
 
@@ -136,7 +185,7 @@ When using detail panels, row heights become variable. The grid automatically:
136
185
 
137
186
  ## Virtualization with Grouping/Tree Data
138
187
 
139
- Row grouping and tree data work seamlessly with virtualization:
188
+ Grouped and tree rows go through the same virtualizer as flat rows. Expanding a group of any size adds its rows to the virtual row list, not to the DOM: in a bounded container, expanding a 25,000-row group renders ~20 DOM rows (verified in Chromium by `DataGrid.virtualization.browser.test.tsx`).
140
189
 
141
190
  ```tsx
142
191
  // Tree data with virtualization
@@ -149,7 +198,7 @@ Row grouping and tree data work seamlessly with virtualization:
149
198
  />
150
199
  ```
151
200
 
152
- **Note:** When groups are collapsed, only visible rows are rendered. Expanding a group dynamically adds rows to the render list.
201
+ **Note:** `pagination` is ignored while `rowGroupingModel` is active, so a grouped grid relies entirely on virtualization to bound what it renders. Make sure its container has a bounded height. A development-mode warning is logged if `pagination` is passed together with `rowGroupingModel`.
153
202
 
154
203
  ## Debugging Virtualization
155
204
 
@@ -171,14 +220,14 @@ You should see console logs only for visible rows + overscan buffer.
171
220
 
172
221
  ## Known Limitations
173
222
 
174
- 1. **Column virtualization** - Not yet implemented (all columns are rendered)
223
+ 1. **Unbounded containers** - Virtualization needs a bounded viewport height (see above)
175
224
  2. **Dynamic row heights** - Requires full dataset scan for accurate scroll calculations
176
225
  3. **Horizontal scrolling** - May show brief flicker with very wide grids
177
226
 
178
227
  ## Future Improvements
179
228
 
180
- - [ ] Column virtualization for grids with 50+ columns
181
- - [ ] Adaptive overscan based on scroll velocity
229
+ - [x] Column virtualization (unpinned columns, 6-column overscan)
230
+ - [x] Adaptive overscan based on scroll velocity (`overscanRowCount` prop, v2.0.4)
182
231
  - [ ] Virtual scrollbar for extremely large datasets (millions of rows)
183
232
  - [ ] Intersection Observer API for better scroll detection
184
233
 
@@ -187,9 +236,9 @@ You should see console logs only for visible rows + overscan buffer.
187
236
  | Feature | OpenGridX | MUI X DataGrid |
188
237
  |---------|--------------|----------------|
189
238
  | Row virtualization | ✅ Yes | ✅ Yes |
190
- | Column virtualization | ❌ Planned | ✅ Yes (Pro) |
239
+ | Column virtualization | ✅ Yes (unpinned columns) | ✅ Yes (Pro) |
191
240
  | Variable row heights | ✅ Yes | ✅ Yes |
192
- | Overscan buffer | 5 rows | 3-8 rows (adaptive) |
241
+ | Overscan buffer | 3–30 rows (adaptive, velocity-based) | 3-8 rows (adaptive) |
193
242
  | Max recommended rows | 100,000+ | 100,000+ |
194
243
 
195
244
  ## Related Documentation
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.
@@ -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`. Runtime shim (underscore-prefixed fields on the row object) maintains backward compatibility; still present as of v2.1.0, deprecated for removal in a future major version — see `docs/architecture/grid-row-meta.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).
@@ -244,6 +244,7 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
244
244
  resetKey={row}
245
245
  renderFn={() => colDef.renderCell!({
246
246
  value,
247
+ formattedValue,
247
248
  row,
248
249
  field: colDef.field,
249
250
  colDef,
@@ -122,6 +122,13 @@
122
122
  color: var(--ogx-color-gray-300);
123
123
  }
124
124
 
125
+ .ogx__loading-overlay {
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ min-height: 200px;
130
+ }
131
+
125
132
  .ogx-error-overlay {
126
133
  position: absolute;
127
134
  top: 0;
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import { DataGrid } from './DataGrid';
4
+ import type { GridColDef } from '../../types';
5
+
6
+ // Regression tests for the OpenGridX 2.0.4 consumer defect report.
7
+
8
+ type Row = { id: number; region: string; amount: number };
9
+
10
+ const ROWS: Row[] = [
11
+ { id: 1, region: 'North', amount: 1200 },
12
+ { id: 2, region: 'North', amount: 800 },
13
+ { id: 3, region: 'South', amount: 450 },
14
+ ];
15
+
16
+ const COLUMNS: GridColDef<Row>[] = [
17
+ { field: 'region', headerName: 'Region', width: 150 },
18
+ { field: 'amount', headerName: 'Amount', width: 150, valueFormatter: ({ value }) => `$${String(value)}` },
19
+ ];
20
+
21
+ describe('D3 — valueFormatter survives row grouping', () => {
22
+ it('renders formatted values without grouping', () => {
23
+ render(<DataGrid rows={ROWS} columns={COLUMNS} />);
24
+ expect(screen.getByText('$1200')).toBeInTheDocument();
25
+ });
26
+
27
+ it('renders formatted values for leaf rows when rowGroupingModel is active', () => {
28
+ render(<DataGrid rows={ROWS} columns={COLUMNS} rowGroupingModel={['region']} defaultGroupingExpansionDepth={-1} />);
29
+ expect(screen.getByText('$1200')).toBeInTheDocument();
30
+ expect(screen.queryByText('1200')).not.toBeInTheDocument();
31
+ });
32
+
33
+ it('passes formattedValue to a consumer renderCell', () => {
34
+ const renderCell = vi.fn(() => null);
35
+ const cols: GridColDef<Row>[] = [COLUMNS[0], { ...COLUMNS[1], renderCell }];
36
+ render(<DataGrid rows={ROWS} columns={cols} />);
37
+ expect(renderCell).toHaveBeenCalledWith(expect.objectContaining({ value: 1200, formattedValue: '$1200' }));
38
+ });
39
+ });
40
+
41
+ describe('D2 — slots that were typed but never rendered', () => {
42
+ it('renders slots.footer with grid state, replacing the default pagination', () => {
43
+ const Footer = vi.fn((props: Record<string, unknown>) => <div data-testid="footer">rows:{String(props.rowCount)} note:{String(props.note)}</div>);
44
+ render(
45
+ <DataGrid
46
+ rows={ROWS}
47
+ columns={COLUMNS}
48
+ pagination
49
+ aggregationModel={{ amount: 'sum' }}
50
+ slots={{ footer: Footer }}
51
+ slotProps={{ footer: { note: 'hi' } }}
52
+ />
53
+ );
54
+ expect(screen.getByTestId('footer')).toHaveTextContent('rows:3 note:hi');
55
+ expect(Footer).toHaveBeenCalledWith(expect.objectContaining({ aggregationResult: { amount: 2450 } }), undefined);
56
+ expect(document.querySelector('.ogx-pagination, .ogx__pagination')).toBeNull();
57
+ });
58
+
59
+ it('renders slots.noRowsOverlay when there are no rows', () => {
60
+ render(<DataGrid rows={[]} columns={COLUMNS} slots={{ noRowsOverlay: () => <div>Nothing here</div> }} />);
61
+ expect(screen.getByText('Nothing here')).toBeInTheDocument();
62
+ });
63
+
64
+ it('renders slots.loadingOverlay while loading with no rows', () => {
65
+ render(<DataGrid rows={[]} columns={COLUMNS} loading slots={{ loadingOverlay: () => <div>Fetching…</div> }} />);
66
+ expect(screen.getByText('Fetching…')).toBeInTheDocument();
67
+ });
68
+ });
69
+
70
+ describe('D6 — onRowDoubleClick', () => {
71
+ it('fires with the row params on double-click', () => {
72
+ const onRowDoubleClick = vi.fn();
73
+ render(<DataGrid rows={ROWS} columns={COLUMNS} onRowDoubleClick={onRowDoubleClick} />);
74
+ fireEvent.doubleClick(screen.getByText('$800'));
75
+ expect(onRowDoubleClick).toHaveBeenCalledWith(expect.objectContaining({ id: 2, row: ROWS[1] }));
76
+ });
77
+
78
+ it('fires in list view as well', () => {
79
+ const onRowDoubleClick = vi.fn();
80
+ render(
81
+ <DataGrid rows={ROWS} columns={COLUMNS} listView
82
+ listViewColumn={{ field: 'card', renderCell: ({ row }) => <span>card-{row.id}</span> }}
83
+ onRowDoubleClick={onRowDoubleClick} />
84
+ );
85
+ fireEvent.doubleClick(screen.getByText('card-3'));
86
+ expect(onRowDoubleClick).toHaveBeenCalledWith(expect.objectContaining({ id: 3, row: ROWS[2] }));
87
+ });
88
+ });
89
+
90
+ describe('Row grouping — expansion survives row edits', () => {
91
+ it('keeps a user-expanded group open when the rows prop changes', () => {
92
+ const { rerender, container } = render(<DataGrid rows={ROWS} columns={COLUMNS} rowGroupingModel={['region']} />);
93
+ const rowCount = () => container.querySelectorAll('.ogx__rows [role="row"]').length;
94
+ expect(rowCount()).toBe(2);
95
+
96
+ fireEvent.click(container.querySelector('.ogx__rows [role="row"]')!);
97
+ expect(rowCount()).toBe(4);
98
+
99
+ rerender(<DataGrid rows={ROWS.map(r => (r.id === 1 ? { ...r, amount: 1300 } : r))} columns={COLUMNS} rowGroupingModel={['region']} />);
100
+ expect(rowCount()).toBe(4);
101
+ expect(screen.getByText('$1300')).toBeInTheDocument();
102
+ });
103
+ });