@opencorestack/opengridx 3.4.0 → 3.5.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 (82) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -3
  3. package/dist/ai.cjs +2 -1
  4. package/dist/ai.cjs.map +1 -1
  5. package/dist/ai.d.ts +203 -3
  6. package/dist/ai.es.js +2 -1
  7. package/dist/ai.es.js.map +1 -1
  8. package/dist/index.d.ts +248 -4
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +7334 -5906
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -16
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +88 -7
  15. package/docs/README.md +3 -1
  16. package/docs/architecture/datagrid-orchestration.md +11 -5
  17. package/docs/components/filter-panel.md +1 -0
  18. package/docs/components/header.md +4 -1
  19. package/docs/components/toolbar.md +7 -0
  20. package/docs/customization/theming.md +3 -1
  21. package/docs/features/ai-toolkit.md +99 -5
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/fill-handle.md +125 -0
  24. package/docs/features/filtering.md +3 -0
  25. package/docs/features/header-filters.md +138 -0
  26. package/docs/features/keyboard-navigation.md +20 -2
  27. package/docs/features/virtualization.md +5 -5
  28. package/docs/performance.md +212 -1
  29. package/docs/roadmap.md +3 -4
  30. package/lib/ai/agentTools.ts +160 -0
  31. package/lib/ai/index.ts +19 -2
  32. package/lib/ai/promptHandler.ts +109 -0
  33. package/lib/ai/types.ts +89 -25
  34. package/lib/ai/validate.ts +10 -0
  35. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  36. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  37. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  38. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  39. package/lib/components/Cell/Cell.css +28 -0
  40. package/lib/components/Cell/Cell.tsx +6 -2
  41. package/lib/components/DataGrid/DataGrid.tsx +84 -4
  42. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  43. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  44. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  45. package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
  46. package/lib/components/Header/Header.tsx +44 -23
  47. package/lib/components/Header/headerColumnStyle.ts +28 -0
  48. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  49. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  50. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  51. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  52. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  53. package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
  54. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  55. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  56. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  57. package/lib/hooks/core/useGridCellSelection.ts +77 -11
  58. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  59. package/lib/hooks/core/useGridControlledState.ts +27 -0
  60. package/lib/hooks/core/useGridDevWarnings.ts +21 -6
  61. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  62. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  63. package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
  64. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  65. package/lib/hooks/core/useGridVirtualization.ts +19 -0
  66. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  67. package/lib/hooks/features/useGridBatchEdit.ts +2 -2
  68. package/lib/index.ts +3 -0
  69. package/lib/styles/opengridx.css +2 -0
  70. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  71. package/lib/theme/types.ts +2 -0
  72. package/lib/types/index.ts +219 -1
  73. package/lib/utils/aiAssistant.ts +205 -0
  74. package/lib/utils/aiBrand.ts +17 -0
  75. package/lib/utils/cellSelection.ts +25 -5
  76. package/lib/utils/editing/fill.ts +238 -0
  77. package/lib/utils/filtering/index.ts +14 -0
  78. package/lib/utils/focus/index.ts +9 -3
  79. package/lib/utils/headerFilters.ts +189 -0
  80. package/lib/utils/maxElementHeight.ts +53 -0
  81. package/llms.txt +44 -4
  82. package/package.json +3 -2
@@ -0,0 +1,125 @@
1
+ # Fill Handle
2
+
3
+ With [`cellSelection`](cell-selection.md) on, the selected range has a small square on its bottom-right corner: the **fill handle** (v3.5). Drag it down, up, right or left to fill the next cells from the range, as in Excel. **Ctrl/Cmd+D** and **Ctrl/Cmd+R** fill from the keyboard. Every fill is written like a paste, through `valueSetter` and `processRowUpdate`, and is one [undo](undo-redo.md) step. Demo: **Fill Handle** (`/fill-handle`).
4
+
5
+ ```tsx
6
+ import { DataGrid, type GridFillResult } from '@opencorestack/opengridx';
7
+
8
+ <DataGrid
9
+ rows={rows}
10
+ columns={columns} // fills only write editable columns
11
+ cellSelection // the fill handle comes with range selection
12
+ undoRedo // optional: one fill = one undo step
13
+ onFill={(result: GridFillResult) => console.log(result.direction, result.updated, result.skipped)}
14
+ processRowUpdate={saveRow} // called once per filled row
15
+ />
16
+ ```
17
+
18
+ ## Dragging the handle
19
+
20
+ - The handle sits on the bottom-right cell of the range, or on the only selected cell. It is shown while at least one column is `editable` and no editor is open.
21
+ - Drag it beyond the range. The range grows in **one direction**: the side of the range the cell under the pointer lies beyond. When that cell lies beyond both a row edge and a column edge (a diagonal drag), the larger pointer movement wins.
22
+ - Past the edge of the grid the body auto-scrolls, as in a range drag, also over rows and columns that are not rendered yet.
23
+ - Release to fill. Releasing over the original range, pressing Escape or losing the pointer writes nothing and restores the range.
24
+ - Hold **Alt** while releasing to **copy** instead of continuing a series (Excel uses Ctrl for this, which the grid keeps for range shortcuts).
25
+ - The handle has a 24 px hit area and `touch-action: none`, so a touch drag on it fills instead of scrolling the grid.
26
+
27
+ After the fill, the range covers the source and the filled cells.
28
+
29
+ ## What is written
30
+
31
+ Each line along the fill (each column for a fill down or up, each row for a fill right or left) is filled from its source cells, read towards the new cells:
32
+
33
+ | Source cells of the line | Fill |
34
+ | :--- | :--- |
35
+ | One cell | Copies the value |
36
+ | Two or more numbers with an exactly equal step | Continues the series: 2, 4, 6 → 8, 10. Decimals are rounded to the source's decimal places, so 0.1, 0.2 → 0.3, not 0.30000000000000004 |
37
+ | Two or more dates on the same day of the month, an equal number of months apart | Continues by months: 15 Jan, 15 Feb → 15 Mar. The day is clamped to the month's end (31 Jan, 31 Mar → 31 May; 31 Dec, 31 Jan → 28 Feb) |
38
+ | Two or more dates at the same time of day, an equal number of days apart | Continues by days: Mon 5 Oct, Tue 6 Oct → Wed 7 Oct (calendar days, so a daylight-saving change does not break it) |
39
+ | Anything else (text, booleans, unequal steps, mixed types, empty cells) | Repeats the source pattern: A, B → A, B, A |
40
+
41
+ Filling up or left reads the source in that direction: 7, 9 filled up gives 5, 3.
42
+
43
+ Values are typed already, so `valueParser` is not involved: a number copied into a text column stays a number. Synthetic rows (group headers, subtotals, tree auto-parents) in the source are left out of the series.
44
+
45
+ ### `GridColDef.fillValue`
46
+
47
+ A column can decide what a fill writes into its cells:
48
+
49
+ ```ts
50
+ {
51
+ field: 'amount', type: 'number', editable: true,
52
+ // params.value is what the grid would write; return it to keep it.
53
+ fillValue: ({ value, sourceValues, index, direction, copy, row, id }) =>
54
+ typeof value === 'number' ? Math.max(0, Math.round(value)) : value,
55
+ }
56
+ ```
57
+
58
+ | Param | Description |
59
+ | :--- | :--- |
60
+ | `value` | The grid's choice: the next value of the series, the repeated pattern or the copy |
61
+ | `sourceValues` | The line's source values in fill order (the value next to the first filled cell is last) |
62
+ | `index` | Position of the cell among the filled cells of its line, from 0 |
63
+ | `direction` | `'down'`, `'up'`, `'right'` or `'left'` |
64
+ | `copy` | `true` when Alt was held or the fill came from Ctrl/Cmd+D or Ctrl/Cmd+R |
65
+ | `row`, `id`, `field`, `colDef` | The cell being filled |
66
+
67
+ A `fillValue` that throws skips that cell with reason `'invalidValue'` (and warns once in development).
68
+
69
+ ## Keyboard
70
+
71
+ While focus is on a cell of the grid and no editor is open:
72
+
73
+ | Key | Action |
74
+ | :--- | :--- |
75
+ | **Ctrl+D** (Cmd+D) | Copies the range's top row into the rows below it |
76
+ | **Ctrl+R** (Cmd+R) | Copies the range's left column into the columns right of it |
77
+
78
+ The grid takes these keys (`preventDefault`, so no bookmark dialog or page reload) whenever a range or focused cell exists, `cellSelection` is on and `disableFillHandle` is not set. A range of one row (Ctrl+D) or one column (Ctrl+R) has nothing to fill. Inside an editor or a text field you render in a cell, the keys keep their native meaning.
79
+
80
+ ## How it writes
81
+
82
+ A fill is a batch edit, the same path as [paste](clipboard.md):
83
+
84
+ - `valueSetter` (when the column has one) builds each row, and `processRowUpdate` runs **once per row**; rows run in parallel, and a row that throws or rejects is reported to `onProcessRowUpdateError` without stopping the others.
85
+ - All rows that succeeded are stored in one update.
86
+ - With `undoRedo`, the whole fill is **one undo step**.
87
+ - Cells that cannot be written are skipped with a reason:
88
+
89
+ | Reason | Cell |
90
+ | :--- | :--- |
91
+ | `'notEditable'` | The column is not `editable`, `isCellEditable` returned false, the row is synthetic, or a span covers the cell |
92
+ | `'invalidValue'` | The column's `fillValue` threw |
93
+
94
+ `onFill` receives the result plus the direction:
95
+
96
+ ```ts
97
+ interface GridFillResult extends GridBatchEditResult {
98
+ direction: 'down' | 'up' | 'right' | 'left';
99
+ }
100
+ // { updated: [3, 4, 5], failed: [], skipped: [{ id: 3, field: 'owner', reason: 'notEditable' }], direction: 'down' }
101
+ ```
102
+
103
+ ## Opting out
104
+
105
+ `disableFillHandle` hides the handle and leaves Ctrl/Cmd+D and Ctrl/Cmd+R to the browser. A grid without `cellSelection` has no fill handle.
106
+
107
+ ## Styling
108
+
109
+ | Hook | Description |
110
+ | :--- | :--- |
111
+ | `.ogx__cell-fill-handle` | The handle: a 24 × 24 px hit area in the corner of the cell (`cursor: crosshair`, `touch-action: none`). The visible square is its `::after` |
112
+ | `--ogx-fill-handle-color` | The square's colour. Defaults to the range border (`--ogx-range-border`); theme key `grid.fillHandleColor` |
113
+
114
+ ```css
115
+ .my-grid { --ogx-fill-handle-color: #16a34a; }
116
+ .my-grid .ogx__cell-fill-handle::after { width: 9px; height: 9px; }
117
+ ```
118
+
119
+ ## API
120
+
121
+ | Member | Description |
122
+ | :--- | :--- |
123
+ | `disableFillHandle?: boolean` | Hides the handle and turns off Ctrl/Cmd+D and Ctrl/Cmd+R. Default `false` |
124
+ | `onFill?(result: GridFillResult)` | Fired after every fill |
125
+ | `GridColDef.fillValue?(params: GridFillValueParams): unknown` | Decides the value written into a cell of the column |
@@ -7,6 +7,7 @@ OpenGridX provides a robust filtering system with support for both client-side a
7
7
  - **Column Filters**: Specific operators for different data types (string, number, date, etc.).
8
8
  - **Multi-Filter Groups**: Support for `AND`/`OR` logic operators.
9
9
  - **Server-Side Filtering**: Offload complex queries to your backend.
10
+ - **Header Filters** (v3.5): a filter row under the column headers (`headerFilters`) that edits the same model. See [Header Filters](header-filters.md).
10
11
 
11
12
  ---
12
13
 
@@ -121,3 +122,5 @@ The filter panel is accessible through the built-in `GridToolbar`. To use your o
121
122
  ```
122
123
 
123
124
  The `FilterPanel` component is also exported from `@opencorestack/opengridx` if you need to embed it inside a custom layout. See [`<FilterPanel />`](../components/filter-panel.md) for how it edits the model.
125
+
126
+ For a filter control on every column, set `headerFilters`: a row under the headers whose cells own the root items `id: 'header:<field>'` of the same `filterModel`, so the panel, the quick filter and the header row stay in sync. See [Header Filters](header-filters.md).
@@ -0,0 +1,138 @@
1
+ # Header Filters
2
+
3
+ *Since v3.5.0*
4
+
5
+ A filter row under the column headers, as in Excel's AutoFilter row: each column gets a small control (a text box, a number or date box, or a select), an operator button and a clear button. It reads and writes the grid's one `filterModel`, so the [filter panel](../components/filter-panel.md), the quick filter, `apiRef.setFilterModel` and server-side filtering keep working alongside it.
6
+
7
+ ```tsx
8
+ import { DataGrid } from '@opencorestack/opengridx';
9
+
10
+ <DataGrid
11
+ rows={rows}
12
+ columns={columns}
13
+ headerFilters
14
+ height={500}
15
+ />
16
+ ```
17
+
18
+ The row is off unless `headerFilters` is set. Without it the grid renders and behaves exactly as before.
19
+
20
+ ## Controls per column type
21
+
22
+ | Column `type` | Control | Starting operator |
23
+ | :--- | :--- | :--- |
24
+ | `string` (default) | text box (`Input`) | `contains` |
25
+ | `number` | text box (`Input`, decimal keyboard on touch devices) | `=` |
26
+ | `date` | date box (`Input type="date"`) | `is` |
27
+ | `boolean` | select: Any / Yes / No | `is` |
28
+ | `singleSelect` | select of `valueOptions` (Any + every option) | `is` |
29
+ | `image` | none (empty cell) | — |
30
+
31
+ - The **operator button** left of the box shows the current operator as a symbol (`∋` contains, `=`, `≥`, `≠` …) and opens a menu of the operators the column type allows (the same list as the filter panel). `isEmpty` / `isNotEmpty` need no value: the box is replaced by the operator's name.
32
+ - The **clear button** (×) appears while the column has a filter and removes it.
33
+ - Typing is **debounced by 300 ms**, as in the filter panel. Selects apply at once. Typing that is still waiting is committed if the column scrolls out of the render window or the row is turned off.
34
+ - A `singleSelect` column without `valueOptions` gets a text box.
35
+
36
+ ### Column options
37
+
38
+ | `GridColDef` field | Description |
39
+ | :--- | :--- |
40
+ | `headerFilter?: boolean` | `false` leaves the column's cell empty. Columns with `filterable: false` and `type: 'image'` have no control either. |
41
+ | `headerFilterOperator?: GridFilterOperator` | The operator a new filter starts with, instead of the type's default (for example `'>='` for amounts, `'startsWith'` for codes). |
42
+
43
+ ### Grid props
44
+
45
+ | Prop | Type | Default | Description |
46
+ | :--- | :--- | :--- | :--- |
47
+ | `headerFilters` | `boolean` | `false` | Renders the filter row. |
48
+ | `headerFilterHeight` | `number` | `40` | Height of the filter row in pixels (sets `--ogx-header-filter-height`). |
49
+
50
+ ## One shared filter model
51
+
52
+ The row does not keep a model of its own. Each cell **owns one root-level item**, the one with `id: 'header:<field>'`:
53
+
54
+ ```ts
55
+ // After typing "glo" under Customer and choosing "≥ 100" under Total:
56
+ {
57
+ items: [
58
+ { id: 'header:customer', field: 'customer', operator: 'contains', value: 'glo' },
59
+ { id: 'header:total', field: 'total', operator: '>=', value: '100' },
60
+ ],
61
+ }
62
+ ```
63
+
64
+ - A cell adds its item at the end of `items`, updates it in place and removes it when cleared. Every other item and group, `logicOperator` and `quickFilterValues` pass through untouched.
65
+ - Values are stored as the filter panel stores them: typed text as a string (number operators parse it), dates as `YYYY-MM-DD`, booleans as `true` / `false`, `singleSelect` values as the option's `value` (a one-item list for `isAnyOf`).
66
+ - The filter panel shows and edits the same item: it edits the first root item of each column and keeps its `id`, so a header filter changed in the panel is still the header filter.
67
+ - An item with an empty value does not filter. An empty box with the starting operator removes the item; with another operator the item stays, so the chosen operator is kept while the value is retyped.
68
+
69
+ ### Models the row cannot show
70
+
71
+ The row never rewrites a model it cannot represent. A cell shows a read-only **"Custom filter"** label instead of its control when:
72
+
73
+ - the root `logicOperator` is `'or'` (every cell: adding an item would widen the OR);
74
+ - the column's field appears inside a filter group;
75
+ - the column has a root item that is not `header:<field>` (one added by the filter panel, `apiRef`, an AI assistant or your own code), or more than one root item.
76
+
77
+ With a built-in `GridToolbar` in `slots.toolbar`, the label is a button that opens the toolbar's filter panel, where the condition can be edited. Without one it is plain text. Clearing the model (the panel's *Clear all*, `setFilterModel({ items: [] })`) brings the controls back.
78
+
79
+ ## Server-side filtering
80
+
81
+ With `filterMode="server"` the grid does not filter rows itself; header filter changes reach `onFilterModelChange` (or the `dataSource`'s `getRows`) like any other filter change. Items are tagged `header:<field>`, which the server can ignore.
82
+
83
+ ```tsx
84
+ <DataGrid
85
+ rows={pageRows}
86
+ columns={columns}
87
+ headerFilters
88
+ filterMode="server"
89
+ filterModel={filterModel}
90
+ onFilterModelChange={(model) => { setFilterModel(model); refetch(model); }}
91
+ />
92
+ ```
93
+
94
+ ## Keyboard and screen readers
95
+
96
+ The filter row is a header row (`role="row"`) between the column headers and the first data row. Its cells are `role="columnheader"`, labelled **"Filter &lt;header name&gt;"**, with the same `aria-colindex` as their column; `aria-rowcount` and the rows' `aria-rowindex` count it.
97
+
98
+ | Key | On a filter cell |
99
+ | :--- | :--- |
100
+ | ArrowDown on a column header | Moves to that column's filter cell |
101
+ | ArrowDown / ArrowUp | First data row / the column header |
102
+ | ArrowLeft / ArrowRight, Home / End | Along the filter row |
103
+ | Enter or Space | Focuses the cell's control ("Custom filter": opens the filter panel) |
104
+ | Typing a character | Starts a new value in the text box (text and number columns) |
105
+ | Alt+ArrowDown or Ctrl/Cmd+Enter | Opens the operator menu (arrows move, Enter picks, Escape closes) |
106
+ | Delete / Backspace | Clears the column's filter |
107
+
108
+ Inside a control, arrow keys edit the text (or move through a select) and do not move the grid's focus, as in a cell editor. **Escape** returns focus to the filter cell. **Tab** leaves the grid: the grid stays a single Tab stop and every control in the row has `tabIndex={-1}`.
109
+
110
+ ## Layout
111
+
112
+ - The row sits inside the sticky header block (`.ogx__header-wrap`), under any column group rows and the column header row, so it stays at the top while rows scroll.
113
+ - Each cell takes its column's width, flex and sticky offset from the same calculation as the column header, so filter cells line up with their columns with pinned columns, column virtualization and horizontal scrolling.
114
+ - Scrolling a row into view (keyboard navigation, `apiRef.scrollToIndexes`, cell range auto-scroll) measures the sticky header, so rows are never left under the taller header.
115
+ - With `pivotMode` active the row is not rendered: pivot columns are generated and are not the fields the filter model names.
116
+
117
+ ## Styling
118
+
119
+ | Class | Element |
120
+ | :--- | :--- |
121
+ | `ogx__header-filter-row` | The row |
122
+ | `ogx__header-filter-cell` | Every cell, plus `--focused`, `--active` (has a filter), `--custom` ("Custom filter"), `--empty` (no control), `--system` (under the checkbox, expand and reorder columns), `--pinned`, `--pinned-left`, `--pinned-right`, `--pinned-left-last`, `--pinned-right-first` |
123
+ | `ogx__header-filter-operator` | The operator button (`--open` while its menu is open) |
124
+ | `ogx__header-filter-menu` | The operator menu (also `ogx-column-menu`, so it looks like the column menu) |
125
+ | `ogx__header-filter-input-wrapper` / `ogx__header-filter-input` | The `Input` wrapper and its `<input>` |
126
+ | `ogx__header-filter-select` | The boolean and `singleSelect` select |
127
+ | `ogx__header-filter-operator-label` | The operator name shown for `isEmpty` / `isNotEmpty` |
128
+ | `ogx__header-filter-clear` | The clear button |
129
+ | `ogx__header-filter-custom` | The "Custom filter" label or button |
130
+
131
+ The row uses the header's theme variables (`--ogx-grid-header-background`, `--ogx-grid-border-color`, `--ogx-grid-header-text`, `--ogx-grid-header-hover-background`) and the shared input variables, so every built-in theme and dark mode style it without new variables. `--ogx-header-filter-height` holds its height.
132
+
133
+ ## See also
134
+
135
+ - [Filtering & Search](filtering.md) — operators, AND/OR groups and the quick filter
136
+ - [Filter Panel](../components/filter-panel.md)
137
+ - [Keyboard & Accessibility](keyboard-navigation.md)
138
+ - Demo: *Header Filters* (`/header-filters`)
@@ -39,6 +39,9 @@ With [`cellSelection`](cell-selection.md) on (v3.3), on a data cell:
39
39
  | **Escape** | Collapse the range to the anchor (when not editing). |
40
40
  | **Ctrl+V** (Cmd+V) | Paste tab-separated text into the editable cells from the range's top-left cell (v3.4, see [Clipboard](clipboard.md#-paste-from-excel-and-google-sheets-v34)). Off with `disableClipboardPaste`. |
41
41
  | **Delete** / **Backspace** | On a range of more than one cell: empty its editable cells in one edit (v3.4). Off with `disableRangeClear`. |
42
+ | **Ctrl+D** (Cmd+D) | Fill down: copy the range's top row into the rows below it, in one edit (v3.5, see [Fill Handle](fill-handle.md)). The browser's bookmark shortcut does not fire. Off with `disableFillHandle`. |
43
+ | **Ctrl+R** (Cmd+R) | Fill right: copy the range's left column into the columns right of it, in one edit (v3.5). The browser's reload shortcut does not fire. Off with `disableFillHandle`. |
44
+ | **Escape** during a fill-handle drag | Cancel the fill: nothing is written and the range goes back (v3.5). |
42
45
 
43
46
  With `undoRedo` on (v3.4, see [Undo & Redo](undo-redo.md)), while focus is on a cell and no editor is open:
44
47
 
@@ -70,10 +73,25 @@ When an edit ends, focus returns to the edited cell unless you moved it elsewher
70
73
  | **Enter** / **Space** | Sort the column (asc → desc → none), replacing the sort model; with `multiSort`, or with **Shift** held, the column is added to (or updated or removed from) the sort model instead, as a header click or shift-click does. On the select-all header: select or clear all rows. |
71
74
  | **Alt+ArrowDown** or **Ctrl+Enter** (Cmd+Enter) | Open the column menu. |
72
75
  | **Alt+ArrowRight** / **Alt+ArrowLeft** | Widen / narrow the column by 10px (with **Shift**: 50px). Not for `resizable: false` columns. |
73
- | **ArrowDown** | Move to the first row. |
76
+ | **ArrowDown** | Move to the first row; with `headerFilters`, to the column's filter cell. |
74
77
 
75
78
  In the column menu, **ArrowUp** / **ArrowDown** / **Home** / **End** move between items, **Enter** / **Space** choose one, and **Escape** or **Tab** closes the menu. Focus returns to the column header.
76
79
 
80
+ ### Header filter row
81
+
82
+ With `headerFilters` (v3.5, see [Header Filters](header-filters.md)) the filter row sits between the column headers and the first row. Its cells are navigation stops like any other cell:
83
+
84
+ | Key | Action |
85
+ | :--- | :--- |
86
+ | **ArrowDown** / **ArrowUp** | To the first row / back to the column header. Arrow keys from the first row go up to the filter row. |
87
+ | **ArrowLeft** / **ArrowRight**, **Home** / **End** | Along the filter row. |
88
+ | **Enter** / **Space** | Focus the cell's text box or select. On "Custom filter": open the toolbar's filter panel. |
89
+ | Typing a character | Start a new value in the text box (text and number columns). |
90
+ | **Alt+ArrowDown** or **Ctrl+Enter** (Cmd+Enter) | Open the operator menu; it works like the column menu. |
91
+ | **Delete** / **Backspace** | Clear the column's filter. |
92
+
93
+ Inside the text box or select, the arrow keys belong to the control, as in a cell editor. **Escape** returns focus to the filter cell; **Tab** leaves the grid (the controls are not Tab stops).
94
+
77
95
  ### List view
78
96
 
79
97
  In `listView`, focus moves between whole rows instead of cells:
@@ -95,7 +113,7 @@ Keys pressed in content your `renderCell` puts in a row (inputs, buttons) are le
95
113
  | Element | Attributes |
96
114
  | :--- | :--- |
97
115
  | Viewport | `role="grid"`, `aria-rowcount`, `aria-colcount`, `aria-multiselectable` (true when several rows can be selected), `aria-busy`. |
98
- | Header rows | `role="row"` with `aria-rowindex`: column-group rows first, then the column header row. |
116
+ | Header rows | `role="row"` with `aria-rowindex`: column-group rows first, then the column header row, then the header filter row (`headerFilters`), whose cells are `role="columnheader"` labelled "Filter &lt;header&gt;". |
99
117
  | Data rows | `role="row"`, `aria-rowindex` counted across the whole dataset (page 2 continues after page 1; bottom-pinned rows come after every page), `aria-selected`. Tree-data and row-grouping rows add `aria-level`; rows with children add `aria-expanded`. |
100
118
  | Cells and headers | `aria-colindex`, identical for a header and the body cells below it. The system columns (reorder handle, detail-panel toggle, checkbox) come first. |
101
119
  | Sorted headers | Only the primary sort column has `aria-sort="ascending"` / `"descending"`; with several sort keys every sorted header also gets an `aria-description` such as "Sorted descending, sort priority 2 of 2". |
@@ -29,11 +29,11 @@ The grid calculates which rows are visible based on:
29
29
  />
30
30
  ```
31
31
 
32
- **Performance characteristics** (when the grid has a bounded height — see below):
33
- - ✅ Renders ~15-25 rows regardless of total dataset size
34
- - ✅ Constant memory usage
35
- - ✅ Smooth 60fps scrolling
36
- - ⚠️ Not millions of rows. Browsers cap an element's height (33,554,432px in Chromium, about 17.9M px in Firefox) and the grid does no scroll scaling: it lays every row out at its real height, so rows past the cap cannot be scrolled to. The limit is roughly `33,554,432 / rowHeight` rows in Chromium (about 645,000 at the default 52px), and about half that in Firefox (about 340,000). Beyond that, use pagination, or server-side paging / infinite scroll. In development the grid warns when the content passes 17.8M px.
32
+ **Performance characteristics** (when the grid has a bounded height — see below), measured by the [benchmark](../performance.md#benchmarks):
33
+ - ✅ About 19 row elements at rest in an 800 px page at 52 px rows, at most about 86 while scrolling fast, whatever the row count (10k to 1M)
34
+ - ✅ Scrolling keeps up with a 60 Hz display at 1M rows in Chromium (no missed frames) and Firefox; WebKit misses a frame or two on each long jump of the scroll position
35
+ - ✅ The grid itself adds about 10 MB of JS heap at 100k rows and 79 MB at 1M (Chromium), on top of your row data
36
+ - ⚠️ Not millions of rows at the default height. Browsers cap an element's height and the grid does no scroll scaling: it lays every row out at its real height. Chromium and WebKit stop at 33,554,428 px, so rows past about `33,554,428 / rowHeight` cannot be scrolled to (645,277 at the default 52 px; 1M rows fit at 32 px). Firefox's limit is about 17.9M px (about 344,000 rows at 52 px); since 3.5.1 the grid caps its content to the limit it measures, so Firefox scrolls normally up to there (on 3.5.0 and earlier, content past the limit collapsed and the grid did not scroll at all). Beyond that, use pagination, or server-side paging / infinite scroll. In development the grid warns when rows are cut off and names how many can be scrolled to. Measured reach per browser: [Performance → Reach](../performance.md#reach-the-browser-height-ceiling).
37
37
 
38
38
  ### Column Virtualization
39
39
 
@@ -87,7 +87,12 @@ import '@opencorestack/opengridx/styles';
87
87
 
88
88
  ## Know the browser's height limit
89
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`.
90
+ The grid sizes its scroll content to `rowCount × rowHeight` pixels and does not scale scroll positions. Browsers cap an element's height, and the [benchmark](#reach-the-browser-height-ceiling) measures what happens past the cap:
91
+
92
+ - **Chromium and WebKit** clamp the content at 33,554,428 px. Rows below that cannot be scrolled to: at the default 52 px row height that is every row past 645,277.
93
+ - **Firefox** has a lower limit, about 17.9M px, and collapses a taller element instead of clamping it. Up to 3.5.0 that made a grid with more content unscrollable in Firefox (only the first screen of rows was reachable). **Since 3.5.1 the grid measures the browser's limit once and caps its scroll content to it**, so Firefox scrolls normally and reaches the rows above the cap: about 344,000 rows at 52 px, 559,000 at 32 px.
94
+
95
+ In development the grid warns when rows are cut off, naming how many can be scrolled to in that browser. For more rows, use pagination, `paginationMode="server"` or infinite scroll with a `dataSource`, or a smaller `rowHeight` (`density="compact"`, 32 px, keeps 1M rows under the Chromium and WebKit cap).
91
96
 
92
97
  ## Use server-side operations for very large datasets
93
98
 
@@ -116,9 +121,215 @@ Without a `dataSource`, fetch the rows yourself from the change callbacks and co
116
121
  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
122
 
118
123
 
124
+ ## Benchmarks
125
+
126
+ Measured with `npm run bench` (`bench/`), which installs the packed npm package into a production Vite + React 19 app and drives it with Playwright in Chromium, Firefox and WebKit. The numbers below are from one machine (Apple M1, 8 GB RAM): compare them with each other and with your own runs, not with other machines.
127
+
128
+ **In short** (Chromium, median):
129
+
130
+ | Rows | Mount | Sort (number / string) | Filter | Quick filter | Group + sum | Scroll frames over 16.7 ms | JS heap |
131
+ | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
132
+ | 10,000 | 79 ms | 34 / 42 ms | 19 ms | 52 ms | 30 ms | 0 % | 2.9 MB |
133
+ | 100,000 | 89 ms | 102 / 160 ms | 28 ms | 205 ms | 87 ms | 0 % | 10.4 MB |
134
+ | 500,000 | 295 ms | 493 / 641 ms | 59 ms | 958 ms | 331 ms | 0 % | 39.4 MB |
135
+ | 1,000,000 | 688 ms | 1,105 / 1,316 ms | 95 ms | 1,845 ms | 681 ms | 0 % | 79.1 MB |
136
+
137
+ - **Scrolling keeps up with the display at every size.** Chromium missed no frames; Firefox missed 0–2.4 %. The DOM holds about 19 row elements at rest (28 at 32 px) and at most 86 while scrolling fast, whatever the row count. WebKit scrolls steadily without missed frames too, but misses one or two frames each time the scroll position jumps far (the *during the jumps* rows below). Firefox's scroll numbers at 500k and 1M rows are not comparable: the grid cannot scroll there (see Reach).
138
+ - **Sorting, the quick filter and grouping run on the main thread in one piece.** Up to 100k rows they finish in a few frames; at 1M a sort blocks the page for about 1.1–1.3 s in Chromium (up to 2.9 s for a string sort in WebKit), and the first quick-filter keystroke for about 2–3 s. For sorting or searching a million rows interactively, use server-side sorting and filtering.
139
+ - **Reach:** past the browser's height cap rows cannot be scrolled to, and in Firefox the grid stops scrolling altogether. See [Reach](#reach-the-browser-height-ceiling).
140
+
141
+ ### Method
142
+
143
+ - **The app is the built package.** `bench/app` installs the tarball from `npm pack`, like a consumer, and is a production build (no React development checks).
144
+ - **Data:** 12 columns (`id`, two strings, two `singleSelect`, four numbers of which two have a `valueFormatter`, a date, a boolean, a short code), `id` and `name` pinned left. Rows come from a seeded random generator in the page and are generated before any timer starts.
145
+ - **Page:** 1280×800, the grid fills it, headless browsers launched by Playwright, one new browser context per run.
146
+ - **Timing:** each operation is timed from the call (`root.render`, `apiRef.setSortModel`, `setFilterModel`, `setRowGroupingModel` + `setAggregationModel`) to the first painted frame that shows its result: the page waits on `requestAnimationFrame` until the grid's DOM shows the result, then one more frame so that frame has been painted. The time therefore includes React's render and commit, style, layout and paint, and up to one frame of waiting for the next refresh. The method is described in detail at the top of `bench/app/src/harness.tsx`.
147
+ - **Operations:** sort ascending on `amount` (number) and on `name` (string); filter `name contains "nova"` (6 % of rows) and `amount > 5000` (50 %); one quick-filter term over all columns (the first search reads and formats every cell, so this is the first keystroke's cost); group by `region` (8 groups) with `sum` of `amount`. Each is undone before the next.
148
+ - **Scroll:** 5 s driven from `requestAnimationFrame`: 3 s at a constant 2,400 px/s, then a jump to a random position anywhere in the scrollable range every 100 ms. Frame times are the gaps between consecutive animation frames. Headless browsers do not pace frames by a display, so idle frames already vary between about 16.6 and 18 ms (17–21 ms in WebKit); a frame therefore counts as *over 16.7 ms* when it took more than 1.5 refresh intervals (25 ms at 60 Hz, a missed refresh) and as *over 33 ms* above 2.5 intervals (41.7 ms, two or more missed).
149
+ - **Chromium only:** long tasks (`PerformanceObserver('longtask')`; Firefox and WebKit have no such entries) and the JS heap (Chrome DevTools Protocol `Runtime.getHeapUsage` after a forced garbage collection, after mount minus the same page with the rows generated and no grid, so it is what the grid adds on top of your data).
150
+ - **DOM size:** row elements and cells at the end of the scroll test, and row elements after scrolling back to the top and letting the grid settle.
151
+ - **Repeats:** 6 runs per row count and engine; the first is discarded as warm-up, the tables show the median of the other 5 and the min–max range.
152
+ - **Machine load:** the runner records the load average before and after. This machine had a steady load of about 3.5–4 from desktop apps (an IDE, a browser) during the run; nothing else heavy ran.
153
+
154
+ ### Results
155
+
156
+ <!-- bench:start -->
157
+ <!-- Generated by `node bench/report.mjs --write docs/performance.md` from bench/results/2026-10-09-apple-m1-darwin.json. Do not edit by hand. -->
158
+
159
+ - **Machine:** Apple M1, 8 cores, 8 GB RAM, macOS 26.5.1 (arm64)
160
+ - **Browsers:** Chromium 145.0.7632.6, Firefox 146.0.1, WebKit 26.0 (Playwright, headless, 1280×800 page)
161
+ - **Package:** @opencorestack/opengridx 3.5.0 (commit `f2ad5cb`), production build
162
+ - **Date:** 2026-10-09; load average at start 3.88 / 3.73 / 4.04, at end 3.46 / 3.71 / 3.77
163
+ - **Runs:** median of 5 runs after one discarded warm-up run, with the min–max range in brackets
164
+
165
+ ¹ Chromium only: Firefox and WebKit have no long-task entries, and the heap is read through the Chrome DevTools Protocol.
166
+
167
+ #### 10,000 rows, 52 px rows
168
+
169
+ | Metric | Chromium | Firefox | WebKit |
170
+ | :--- | ---: | ---: | ---: |
171
+ | Mount | 79 ms (59–88) | 84 ms (82–85) | 116 ms (110–128) |
172
+ | Sort, number column | 34 ms (28–38) | 44 ms (43–46) | 81 ms (72–85) |
173
+ | Sort, string column | 42 ms (22–45) | 56 ms (36–58) | 83 ms (76–108) |
174
+ | Filter, string `contains` | 19 ms (14–31) | 31 ms (26–40) | 51 ms (47–57) |
175
+ | Filter, number `>` | 18 ms (13–19) | 25 ms (24–28) | 37 ms (34–39) |
176
+ | Quick filter, one term | 52 ms (33–60) | 76 ms (68–83) | 96 ms (84–110) |
177
+ | Group by region + `sum` | 30 ms (22–37) | 41 ms (31–47) | 77 ms (72–126) |
178
+ | Scroll frame time p50 | 16.7 ms | 16.7 ms | 17.0 ms |
179
+ | Scroll frame time p95 | 17.6 ms (17.5–17.6) | 17.6 ms (17.6–18.6) | 45.0 ms (43.0–46.0) |
180
+ | Scroll frame time p99 | 17.6 ms (17.6–17.7) | 33.3 ms (33.3–34.1) | 49.0 ms (47.0–58.0) |
181
+ | Scroll frames over 16.7 ms | 0.0 % (0.0–0.3) | 1.7 % (1.4–4.2) | 8.6 % (7.6–9.7) |
182
+ | Scroll frames over 33 ms | 0.0 % | 0.0 % (0.0–0.7) | 6.5 % (5.4–7.1) |
183
+ | … over 16.7 ms while scrolling steadily | 0.0 % | 0.6 % (0.0–4.7) | 0.0 % |
184
+ | … over 16.7 ms during the jumps | 0.0 % (0.0–0.8) | 3.5 % (2.6–4.3) | 26.1 % (21.9–29.9) |
185
+ | Long tasks during scroll ¹ | 0 | – | – |
186
+ | Longest task during a sort ¹ | none | – | – |
187
+ | JS heap for the grid ¹ | 2.9 MB | – | – |
188
+ | Row elements in the DOM, end of scroll | 76 | 76 | 76 |
189
+ | Cells in the DOM, end of scroll | 912 | 912 | 912 |
190
+ | Row elements in the DOM, at rest | 19 | 19 | 19 |
191
+ | Reach: last row on screen at the bottom | all rows | all rows | all rows |
192
+
193
+ #### 100,000 rows, 52 px rows
194
+
195
+ | Metric | Chromium | Firefox | WebKit |
196
+ | :--- | ---: | ---: | ---: |
197
+ | Mount | 89 ms (86–100) | 138 ms (133–155) | 157 ms (138–204) |
198
+ | Sort, number column | 102 ms (85–119) | 129 ms (120–136) | 159 ms (126–237) |
199
+ | Sort, string column | 160 ms (152–165) | 213 ms (180–224) | 241 ms (211–259) |
200
+ | Filter, string `contains` | 28 ms (20–34) | 42 ms (28–46) | 56 ms (47–109) |
201
+ | Filter, number `>` | 33 ms (27–39) | 39 ms (24–57) | 48 ms (31–60) |
202
+ | Quick filter, one term | 205 ms (193–232) | 348 ms (322–359) | 323 ms (269–340) |
203
+ | Group by region + `sum` | 87 ms (81–91) | 107 ms (99–109) | 118 ms (105–143) |
204
+ | Scroll frame time p50 | 16.7 ms | 16.7 ms | 17.0 ms |
205
+ | Scroll frame time p95 | 17.4 ms (17.3–17.5) | 17.5 ms (17.4–17.6) | 44.0 ms (43.0–45.0) |
206
+ | Scroll frame time p99 | 17.6 ms (17.6–17.7) | 33.3 ms | 48.0 ms (47.0–55.0) |
207
+ | Scroll frames over 16.7 ms | 0.0 % (0.0–0.3) | 2.4 % (1.0–2.7) | 7.9 % (7.1–9.3) |
208
+ | Scroll frames over 33 ms | 0.0 % | 0.0 % (0.0–0.3) | 6.4 % (5.4–7.0) |
209
+ | … over 16.7 ms while scrolling steadily | 0.0 % | 0.6 % (0.0–1.7) | 0.0 % (0.0–0.6) |
210
+ | … over 16.7 ms during the jumps | 0.0 % (0.0–0.8) | 4.3 % (1.7–6.2) | 22.7 % (20.2–26.1) |
211
+ | Long tasks during scroll ¹ | 0 | – | – |
212
+ | Longest task during a sort ¹ | 155 ms (144–160) | – | – |
213
+ | JS heap for the grid ¹ | 10.4 MB | – | – |
214
+ | Row elements in the DOM, end of scroll | 76 | 76 | 76 |
215
+ | Cells in the DOM, end of scroll | 912 | 912 | 912 |
216
+ | Row elements in the DOM, at rest | 19 | 19 | 19 |
217
+ | Reach: last row on screen at the bottom | all rows | all rows | all rows |
218
+
219
+ #### 500,000 rows, 52 px rows
220
+
221
+ | Metric | Chromium | Firefox | WebKit |
222
+ | :--- | ---: | ---: | ---: |
223
+ | Mount | 295 ms (287–307) | 482 ms (473–505) | 402 ms (338–463) |
224
+ | Sort, number column | 493 ms (485–567) | 660 ms (629–936) | 483 ms (413–564) |
225
+ | Sort, string column | 641 ms (600–807) | 1,105 ms (1,045–1,188) | 1,407 ms (1,203–1,568) |
226
+ | Filter, string `contains` | 59 ms (51–67) | 63 ms (56–68) | 75 ms (72–87) |
227
+ | Filter, number `>` | 80 ms (70–111) | 100 ms (92–111) | 89 ms (77–101) |
228
+ | Quick filter, one term | 958 ms (836–976) | 1,565 ms (1,503–1,828) | 1,442 ms (1,275–1,619) |
229
+ | Group by region + `sum` | 331 ms (307–338) | 641 ms (419–675) | 386 ms (351–1,244) |
230
+ | Scroll frame time p50 | 16.7 ms | 16.7 ms | 17.0 ms |
231
+ | Scroll frame time p95 | 17.6 ms (17.5–17.6) | 17.5 ms (17.5–17.6) | 45.0 ms (44.0–46.0) |
232
+ | Scroll frame time p99 | 17.7 ms (17.6–17.7) | 17.6 ms (17.6–17.7) | 49.0 ms (46.0–50.0) |
233
+ | Scroll frames over 16.7 ms | 0.0 % (0.0–0.7) | 0.0 % | 7.7 % (7.6–10.1) |
234
+ | Scroll frames over 33 ms | 0.0 % (0.0–0.3) | 0.0 % | 7.3 % (5.8–7.8) |
235
+ | … over 16.7 ms while scrolling steadily | 0.0 % | 0.0 % | 0.0 % (0.0–1.1) |
236
+ | … over 16.7 ms during the jumps | 0.0 % (0.0–1.8) | 0.0 % | 23.1 % (20.6–28.4) |
237
+ | Long tasks during scroll ¹ | 0 | – | – |
238
+ | Longest task during a sort ¹ | 635 ms (595–799) | – | – |
239
+ | JS heap for the grid ¹ | 39.4 MB | – | – |
240
+ | Row elements in the DOM, end of scroll | 76 | 22 | 76 |
241
+ | Cells in the DOM, end of scroll | 912 | 264 | 912 |
242
+ | Row elements in the DOM, at rest | 19 | 19 | 19 |
243
+ | Reach: last row on screen at the bottom | all rows | 24 (<0.1 %) | all rows |
244
+
245
+ #### 1,000,000 rows, 52 px rows
246
+
247
+ | Metric | Chromium | Firefox | WebKit |
248
+ | :--- | ---: | ---: | ---: |
249
+ | Mount | 688 ms (673–715) | 1,097 ms (1,011–1,503) | 589 ms (576–735) |
250
+ | Sort, number column | 1,105 ms (1,094–1,140) | 1,540 ms (1,477–1,795) | 1,023 ms (956–1,075) |
251
+ | Sort, string column | 1,316 ms (1,227–1,463) | 2,434 ms (2,371–2,716) | 2,913 ms (2,715–3,277) |
252
+ | Filter, string `contains` | 95 ms (87–152) | 100 ms (90–108) | 102 ms (102–112) |
253
+ | Filter, number `>` | 144 ms (129–149) | 192 ms (175–237) | 136 ms (123–242) |
254
+ | Quick filter, one term | 1,845 ms (1,761–1,977) | 3,161 ms (2,920–3,327) | 2,914 ms (2,762–3,272) |
255
+ | Group by region + `sum` | 681 ms (664–703) | 1,754 ms (1,695–2,059) | 764 ms (720–901) |
256
+ | Scroll frame time p50 | 16.7 ms | 16.7 ms | 17.0 ms |
257
+ | Scroll frame time p95 | 17.4 ms (17.4–17.6) | 17.6 ms (17.4–17.6) | 44.0 ms (44.0–46.0) |
258
+ | Scroll frame time p99 | 17.7 ms (17.6–17.7) | 17.6 ms (17.6–17.7) | 46.0 ms (45.0–48.0) |
259
+ | Scroll frames over 16.7 ms | 0.0 % (0.0–0.7) | 0.0 % (0.0–0.3) | 7.2 % (7.1–9.3) |
260
+ | Scroll frames over 33 ms | 0.0 % | 0.0 % | 7.0 % (6.5–7.1) |
261
+ | … over 16.7 ms while scrolling steadily | 0.0 % | 0.0 % (0.0–0.6) | 0.0 % |
262
+ | … over 16.7 ms during the jumps | 0.0 % (0.0–1.7) | 0.0 % | 20.8 % (20.0–28.1) |
263
+ | Long tasks during scroll ¹ | 0 | – | – |
264
+ | Longest task during a sort ¹ | 1,307 ms (1,220–1,455) | – | – |
265
+ | JS heap for the grid ¹ | 79.1 MB | – | – |
266
+ | Row elements in the DOM, end of scroll | 77 | 22 | 77 |
267
+ | Cells in the DOM, end of scroll | 924 | 264 | 924 |
268
+ | Row elements in the DOM, at rest | 19 | 19 | 19 |
269
+ | Reach: last row on screen at the bottom | 645,277 (64.5 %) | 24 (<0.1 %) | 645,277 (64.5 %) |
270
+
271
+ #### 1,000,000 rows, 32 px rows
272
+
273
+ | Metric | Chromium | Firefox | WebKit |
274
+ | :--- | ---: | ---: | ---: |
275
+ | Mount | 674 ms (668–680) | 1,106 ms (1,032–1,329) | 604 ms (585–621) |
276
+ | Sort, number column | 1,143 ms (1,090–1,254) | 1,475 ms (1,462–1,837) | 1,077 ms (986–1,137) |
277
+ | Sort, string column | 1,295 ms (1,236–1,456) | 2,517 ms (2,268–2,675) | 2,758 ms (2,720–2,947) |
278
+ | Filter, string `contains` | 92 ms (91–109) | 99 ms (99–109) | 107 ms (106–121) |
279
+ | Filter, number `>` | 137 ms (133–140) | 206 ms (172–232) | 140 ms (126–161) |
280
+ | Quick filter, one term | 1,818 ms (1,651–1,855) | 2,960 ms (2,910–3,200) | 2,750 ms (2,677–2,776) |
281
+ | Group by region + `sum` | 694 ms (673–758) | 2,071 ms (1,224–2,745) | 749 ms (689–798) |
282
+ | Scroll frame time p50 | 16.7 ms | 16.7 ms | 17.0 ms |
283
+ | Scroll frame time p95 | 17.5 ms (17.5–18.5) | 17.5 ms (17.3–17.5) | 49.0 ms (48.0–49.0) |
284
+ | Scroll frame time p99 | 17.6 ms (17.6–18.7) | 17.6 ms (17.6–17.7) | 52.0 ms (51.0–52.0) |
285
+ | Scroll frames over 16.7 ms | 0.0 % (0.0–0.3) | 0.0 % (0.0–0.3) | 15.5 % (15.4–15.8) |
286
+ | Scroll frames over 33 ms | 0.0 % | 0.0 % | 7.7 % (7.7–7.8) |
287
+ | … over 16.7 ms while scrolling steadily | 0.0 % (0.0–0.6) | 0.0 % (0.0–0.6) | 0.0 % (0.0–0.6) |
288
+ | … over 16.7 ms during the jumps | 0.0 % | 0.0 % | 51.3 % (50.6–53.3) |
289
+ | Long tasks during scroll ¹ | 0 | – | – |
290
+ | Longest task during a sort ¹ | 1,286 ms (1,229–1,436) | – | – |
291
+ | JS heap for the grid ¹ | 79.5 MB | – | – |
292
+ | Row elements in the DOM, end of scroll | 86 | 32 | 86 |
293
+ | Cells in the DOM, end of scroll | 1,032 | 384 | 1,032 |
294
+ | Row elements in the DOM, at rest | 28 | 28 | 28 |
295
+ | Reach: last row on screen at the bottom | all rows | 33 (<0.1 %) | all rows |
296
+
297
+ #### Reach: rows that can be scrolled into view
298
+
299
+ | Rows × row height | Content height | Chromium | Firefox | WebKit |
300
+ | :--- | ---: | ---: | ---: | ---: |
301
+ | 10,000 × 52 px | 520,000 px | all | all | all |
302
+ | 100,000 × 52 px | 5,200,000 px | all | all | all |
303
+ | 500,000 × 52 px | 26,000,000 px | all | 24 (<0.1 %) | all |
304
+ | 1,000,000 × 52 px | 52,000,000 px | 645,277 (64.5 %) | 24 (<0.1 %) | 645,277 (64.5 %) |
305
+ | 1,000,000 × 32 px | 32,000,000 px | all | 33 (<0.1 %) | all |
306
+ <!-- bench:end -->
307
+
308
+ ### Reach: the browser height ceiling
309
+
310
+ The grid makes its scroll area as tall as all the rows: `rowCount × rowHeight` pixels. It does not compress scroll positions, so every row keeps its real offset. Browsers limit how tall an element can be, and the *Reach* rows above report the last row that actually appears on screen after scrolling to the very bottom:
311
+
312
+ - **Chromium and WebKit** stop the element at 33,554,428 px. Everything above that is reachable, everything below is not. At 52 px that is 645,277 rows, so 1M rows at 52 px reach 64.5 %. At 32 px (`density="compact"`), 1M rows need 32,000,000 px and are all reachable.
313
+ - **Firefox** has a lower limit, about 17.9M px, and handles it differently: an element taller than that collapses instead of stopping at the limit. On 3.5.0, which these tables were measured on, the grid then had nothing to scroll: 500k rows at 52 px (26M px) and 1M rows at either height showed only their first screen of rows (24 or 33 rows). **3.5.1 fixes this**: the grid caps its scroll content to the limit it measures, so Firefox reaches about 344,000 rows at 52 px and 559,000 at 32 px, like Chromium and WebKit reach theirs.
314
+
315
+ If you need more rows than this in one scrolling grid, page them (`pagination`), load them from the server (`paginationMode="server"`, or infinite scroll with a `dataSource`), or reduce the row height.
316
+
317
+ ### Rerun
318
+
319
+ ```bash
320
+ npx playwright install chromium firefox webkit # once
321
+ npm run bench # all engines, 10k/100k/500k/1M, about 30 min
322
+ npm run bench -- --quick # 1 run per size, no 500k, a few minutes
323
+ npm run bench -- --rows 100000 --engine firefox # one size, one engine
324
+ node bench/report.mjs # Markdown tables of the newest results file
325
+ ```
326
+
327
+ Results are written to `bench/results/<date>-<machine>.json` with the machine, browser versions and package version. Close other heavy programs first and keep a laptop on power. More options: [Testing → The benchmark](contributing/testing.md#the-benchmark). A weekly CI job runs a quick Chromium pass and keeps the JSON as a build artifact; CI machines are shared and noisy, so it is for spotting trends, not for comparing with these numbers.
328
+
119
329
  ## Related
120
330
 
121
331
  - [Virtualization](./features/virtualization.md)
122
332
  - [Server-Side Data](./features/data-source.md)
123
333
  - [Sorting & Pagination](./features/sorting-pagination.md)
124
334
  - [Infinite Scroll](./features/infinite-scroll.md)
335
+ - [Testing → The benchmark](./contributing/testing.md#the-benchmark)
package/docs/roadmap.md CHANGED
@@ -84,12 +84,12 @@ This document tracks the current status of features in `OpenGridX` and outlines
84
84
  | :--- | :--- | :--- | :--- |
85
85
  | **3.3.0** ✅ | shipped 7 Oct | **Cell range selection** (opt-in `cellSelection` prop): mouse drag, Shift+click, Shift+arrows; Ctrl+C / Cmd+C copies the block as TSV | Selection status bar (count, sum, average of selected cells); wrapped header text; React Compiler compatibility check |
86
86
  | **3.4.0** ✅ | shipped 8 Oct | **Paste from Excel / Google Sheets** into the selection, through `processRowUpdate` (values parsed per column type or `valueParser`, non-editable cells skipped, `onClipboardPaste` summary); **Delete clears a range**; built-in inputs and editors use the shared `Input` / `Checkbox` (`Input variant="cell"`). **Undo / redo** for cell edits and pastes (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y, `apiRef.undo()` / `redo()`) | **AI toolkit, part 1** in a new `@opencorestack/opengridx/ai` entry point: `getGridAiSchema(columns)` (JSON Schema of the filter, sort, grouping, aggregation, pivot and visibility models the columns allow) and `validateGridAiState(json, columns)` (drops unknown fields and operators, coerces values, returns errors) |
87
- | **3.5.0** | 15 Dec | **AI toolkit, part 2**: `aiAssistant={{ onPrompt }}` prompt panel that calls the app's own model, shows the proposed changes as removable chips and applies them with undo; `createGridAgentTools(apiRef)` (plain tool objects for CopilotKit, the Vercel AI SDK or WebMCP) | Header filter row; **fill handle** as a stretch goal (moves to Q1 2027 if 3.3–3.4 run late) |
87
+ | **3.5.0** ✅ | shipped 9 Oct | **AI toolkit, part 2**: `aiAssistant={{ onPrompt }}` Ask AI panel (chips, Undo, voice) that calls the app's own model through `createGridAiPromptHandler`; only validated replies apply; `createGridAgentTools(apiRef, columns)` (plain tool objects for CopilotKit, the Vercel AI SDK, provider tool calling or WebMCP) | **Header filter row** (`headerFilters`); **fill handle** (series, Alt copy, Ctrl/Cmd+D / R); `rowGroupingModel` controlled/uncontrolled pair |
88
88
  | Freeze | 19 Dec – 5 Jan | Bug fixes only | — |
89
89
 
90
90
  ### Across the quarter
91
91
 
92
- * **Published benchmark**: reproducible 100k / 1M-row numbers (scroll frame rate, sort and filter time, memory) in `docs/performance.md`.
92
+ * ✅ **Published benchmark** (done 9 Oct): reproducible 10k / 100k / 500k / 1M-row numbers (scroll frame times, sort, filter and grouping time, memory, the reachable row) in [`docs/performance.md`](performance.md#benchmarks); rerun with `npm run bench`.
93
93
  * **Next.js App Router guide**: using the grid from a `'use client'` component, with the stylesheet in `app/layout.tsx`.
94
94
  * **RTL check**: verify right-to-left layouts and document the result.
95
95
  * **Demo site**: source viewer on all examples (`DocsLayout` with Vite `?raw`) and syntax highlighting, done alongside each release's demo page.
@@ -106,7 +106,7 @@ This document tracks the current status of features in `OpenGridX` and outlines
106
106
 
107
107
  * OpenGridX never calls an AI service and never bundles an AI SDK. The app passes a function that calls whichever model it uses.
108
108
  * By default only column definitions and grid state are sent, never row data. Example values are opt-in per column and documented as real data.
109
- * Model output is always validated against the schema and shown to the user before it changes the grid, with one-click undo.
109
+ * Model output is always validated against the schema (the grid applies only a validated, branded result), and every change it makes is shown as a removable chip with one-click undo.
110
110
  * The schema carries a `schemaVersion`, since apps will cache prompts against it.
111
111
 
112
112
  ### Risks
@@ -118,7 +118,6 @@ This document tracks the current status of features in `OpenGridX` and outlines
118
118
  ## 🔭 Next (Q1 2027)
119
119
 
120
120
  * **Row reordering inside tree data and row groups**, touch-friendly (MUI #4821, 405 👍, not in MUI X).
121
- * **Fill handle**, if it does not make 3.5.0.
122
121
  * **Find & highlight** (Ctrl+F through cells), **right-click context menu**, **Excel-style value (set) filter**.
123
122
  * **Calculated columns** (`[price] * [qty]` defined by users).
124
123
  * **Interactive pivot builder**: drag-and-drop panel for pivot and grouping.