@opencorestack/opengridx 3.0.0 → 3.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 (42) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +10 -6
  3. package/dist/index.d.ts +51 -8
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +4056 -3957
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +14 -14
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +15 -8
  10. package/docs/architecture/datagrid-orchestration.md +1 -0
  11. package/docs/components/column-resize.md +14 -2
  12. package/docs/components/datagrid.md +1 -1
  13. package/docs/customization/theming.md +1 -1
  14. package/docs/features/list-view.md +1 -1
  15. package/docs/features/pdf-export.md +16 -0
  16. package/docs/features/pinning.md +1 -0
  17. package/docs/features/sorting-pagination.md +26 -0
  18. package/docs/features/virtualization.md +11 -9
  19. package/docs/migration/v2-to-v3.md +13 -2
  20. package/docs/roadmap.md +1 -1
  21. package/lib/components/Cell/Cell.css +4 -0
  22. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +5 -3
  23. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +12 -3
  24. package/lib/components/DataGrid/DataGrid.css +8 -0
  25. package/lib/components/DataGrid/DataGrid.tsx +9 -2
  26. package/lib/components/DataGrid/GridListView.tsx +2 -1
  27. package/lib/components/Header/Header.css +3 -0
  28. package/lib/components/Header/Header.tsx +7 -1
  29. package/lib/hooks/core/gridApiPlaceholder.ts +4 -2
  30. package/lib/hooks/core/useGridApiRef.ts +7 -4
  31. package/lib/hooks/core/useGridColumnAutosize.ts +52 -0
  32. package/lib/hooks/core/useGridDevWarnings.ts +23 -5
  33. package/lib/hooks/core/useGridRowPipeline.ts +2 -2
  34. package/lib/hooks/useRowGrouping.ts +20 -3
  35. package/lib/hooks/useTreeData.ts +8 -1
  36. package/lib/index.ts +1 -0
  37. package/lib/types/index.ts +46 -7
  38. package/lib/utils/columnAutosize.ts +56 -0
  39. package/lib/utils/pivot/index.ts +11 -2
  40. package/lib/utils/sorting/index.ts +77 -24
  41. package/llms.txt +12 -7
  42. package/package.json +1 -1
@@ -27,7 +27,7 @@ The main component for displaying and interacting with data.
27
27
  | `onPaginationModelChange` | `(model: GridPaginationModel) => void` | — | Fired when page or page size changes. |
28
28
  | `pageSizeOptions` | `number[]` | `[10, 25, 50, 100]` | Available page size options. The uncontrolled default page size is 100 when offered, else the first option. |
29
29
  | `rowCount` | `number` | — | Server total for `paginationMode="server"` when you fetch pages yourself (read on every render). With a `dataSource`, the response's `rowCount` is used and this is only the fallback before the first response. |
30
- | `height` | `number \| string` | `undefined` | Total height of the grid container. |
30
+ | `height` | `number \| string` | `undefined` | Total height of the grid. Without it the grid fills its container (`height: 100%`, or `flex: 1 1 auto; min-height: 0` as a flex item; v3.0.1+), and grows to fit every row in an auto-height container. |
31
31
  | `density` | `'compact' \| 'standard' \| 'comfortable'` | `'standard'` | Visual row density: compact = the theme's `grid.rowHeightCompact` or 32 px, standard = `rowHeight`, comfortable = the theme's `grid.rowHeightComfortable` or 72 px. |
32
32
  | `initialState` | `GridInitialState` | `undefined` | Starting state (sort, filter, pagination, columns, density), read on mount. Seeds the grid's uncontrolled state; see [State Persistence](features/state-persistence.md). |
33
33
  | `slots` | `GridSlots` | — | Custom component overrides: `toolbar`, `pagination`, `noRowsOverlay`, `loadingOverlay`, `footer`. See [`GridSlots`](#gridslots-and-gridslotprops) and the [Slots API](customization/slots-api.md). |
@@ -156,7 +156,7 @@ The main component for displaying and interacting with data.
156
156
 
157
157
  | Prop | Type | Default | Description |
158
158
  | :--- | :--- | :--- | :--- |
159
- | `apiRef` | `React.MutableRefObject<GridApi>` | — | Reactive ref to the imperative API. Create with `useGridApiRef()`. |
159
+ | `apiRef` | `React.MutableRefObject<GridApi<R>> \| React.MutableRefObject<GridApi>` | — | Reactive ref to the imperative API. Create with `useGridApiRef()`, or `useGridApiRef<Row>()` for typed row getters (v3.0.1+). |
160
160
 
161
161
  #### List View
162
162
 
@@ -197,7 +197,7 @@ const columns: GridColDef<Employee>[] = [
197
197
  - A `GridColDef<Row>` for a type-alias row (or an interface that extends `GridRowModel`) can be used where a `GridColDef` is expected: the row callbacks are declared as methods, so they are checked bivariantly.
198
198
  - `params.value` stays `unknown`; read typed values from `params.row`, or render `params.formattedValue`.
199
199
  - The export functions, `usePivot` and `useAggregation` accept the same typed or untyped columns, and their options' `getRowId` takes your row type.
200
- - `apiRef` methods (`getRow`, `getAllRows` …) return `GridRowModel`: the API object is not generic.
200
+ - `apiRef` row getters (`getRow`, `getAllRows`, `getVisibleRows`, `getAllFilteredRows`) return your row type when the ref is created with `useGridApiRef<Row>()` (v3.0.1+); a plain `useGridApiRef()` returns `GridRowModel`.
201
201
  - For the component's props type, use `DataGridProps<R>`. `React.ComponentProps<typeof DataGrid>` resolves to the untyped-columns overload.
202
202
 
203
203
  ### `GridSlots` and `GridSlotProps`
@@ -279,9 +279,9 @@ Access these methods via the `apiRef` prop.
279
279
 
280
280
  | Method | Return | Description |
281
281
  | :--- | :--- | :--- |
282
- | `getRow(id)` | `GridRowModel \| null` | Get row data by ID (your row object, unchanged). |
283
- | `getAllRows()` | `GridRowModel[]` | Get all loaded rows. |
284
- | `getVisibleRows()` | `GridRowModel[]` | Get the rows on screen after filtering/sorting: pinned-top rows, the current page (or all rows without pagination), pinned-bottom rows. Under row grouping and tree data this is the visible hierarchy, group rows included. |
282
+ | `getRow(id)` | `R \| null` | Get row data by ID (your row object, unchanged). `R` is the type passed to `useGridApiRef<R>()`, `GridRowModel` by default. |
283
+ | `getAllRows()` | `R[]` | Get all loaded rows. |
284
+ | `getVisibleRows()` | `R[]` | Get the rows on screen after filtering/sorting: pinned-top rows, the current page (or all rows without pagination), pinned-bottom rows. Under row grouping and tree data this is the visible hierarchy, group rows included. |
285
285
  | `getColumn(field)` | `GridColDef \| null` | Get column definition by field. |
286
286
  | `getVisibleColumns()` | `GridColDef[]` | Get the columns on screen, in render order (hidden columns excluded): left-pinned, unpinned, right-pinned, as the header shows them (v3.0+; before, pinned columns kept their unpinned position). |
287
287
  | `selectRow(id, isSelected = true)` | `void` | Set selection for a single row. Fires `onRowSelectionModelChange`, unless nothing changes (selecting a selected row, deselecting an unselected one; v3.0+). Ids of rows the grid made (group headers, subtotals, auto-created tree parents, the pivot Grand Total) are ignored, as they are for the checkbox, click and keyboard (v3.0+). |
@@ -294,10 +294,12 @@ Access these methods via the `apiRef` prop.
294
294
  | `setPage(page)` | `void` | Change current page (0-indexed). Fires `onPaginationModelChange`. |
295
295
  | `setPageSize(pageSize)` | `void` | Change the page size and go to page 0. Fires `onPaginationModelChange`. |
296
296
  | `scrollToIndexes(params)` | `void` | Scroll to specific row/column index. Uses the current layout, also when called from a layout effect right after an update (v3.0+). |
297
+ | `autosizeColumn(field)` | `void` | Fit a column to its header and the cells in the current render window, clamped to `minWidth` (default 50px) / `maxWidth`; same as double-clicking its resize handle. No-op for `resizable: false` or a column that is not rendered. A flex column gets a fixed width (v3.1.0+). |
298
+ | `autosizeColumns(fields?)` | `void` | `autosizeColumn` for several columns; every column when `fields` is omitted (v3.1.0+). |
297
299
  | `getAllColumns()` | `GridColDef[]` | Get all defined columns. |
298
300
  | `getAggregationResult()` | `Record<string, unknown> \| null` | Get current aggregation results (`null` without an `aggregationModel`, and in pivot mode). Live in a parent's `useLayoutEffect` on mount and current after every update (v3.0+; before, it was installed in a passive effect). The same holds for `getAggregationModel()` and `getGroupedExportRows()`. |
299
301
  | `getAggregationModel()` | `GridAggregationModel \| null` | Get the active aggregation configuration. |
300
- | `getAllFilteredRows()` | `GridRowModel[]` | Get every row that passes the filter, sorted, regardless of pagination, including pinned rows. Under row grouping and tree data it returns the data rows (no group rows) in hierarchy order with every group expanded, ordered like the screen (also when sorting by the hierarchy column; v3.0+). With tree data and a filter, ancestors shown only to give a match its context are left out: the result is the set select-all and the aggregation footer act on (v3.0+). Use for full-dataset exports. |
302
+ | `getAllFilteredRows()` | `R[]` | Get every row that passes the filter, sorted, regardless of pagination, including pinned rows. Under row grouping and tree data it returns the data rows (no group rows) in hierarchy order with every group expanded, ordered like the screen (also when sorting by the hierarchy column; v3.0+). With tree data and a filter, ancestors shown only to give a match its context are left out: the result is the set select-all and the aggregation footer act on (v3.0+). Use for full-dataset exports. |
301
303
  | `getGroupedExportRows()` | `GridGroupedExportRow[] \| null` | Get a flat ordered list reflecting the active row-grouping tree (group-header, leaf, subtotal, grand-total), sorted and filtered like the screen. Collapsed groups are included with their rows, and subtotals are computed over each group's exported rows. Group-header and subtotal entries carry `groupLabel`, the label the grid shows (v3.0+). Returns `null` when row grouping is not active. |
302
304
  | `copySelectedRows()` | `Promise<void>` | Copy every selected row that passes the filter (other pages, collapsed groups and pinned rows included) as TSV, with the visible columns in screen order. Resolves without writing when no selected row is found; rejects when the clipboard write fails. |
303
305
 
@@ -391,8 +393,9 @@ Span values are floored; `Infinity` means "to the end"; `NaN`, `0` and negative
391
393
  | Property | Type | Default | Description |
392
394
  | :--- | :--- | :--- | :--- |
393
395
  | `sortable` | `boolean` | `true` | Enable/disable sorting for this column. |
396
+ | `sortComparator` | `(v1, v2, params1, params2) => number` | — | Custom ascending comparator for client-side sorting (v3.1.0+). `v1`/`v2` are `valueGetter` values **including `null`/`undefined`**; `params` are `GridSortCellParams` `{ id, field, row, value }`. Negated for `desc`, chained in multi-sort, used for group ordering under row grouping, tree-data siblings and pivot row labels. Throw / `NaN` reads as 0 (dev warning once). Ignored with `sortingMode="server"`. |
394
397
  | `filterable` | `boolean` | `true` | Enable/disable filtering for this column. `false` also removes it from the quick-filter search. |
395
- | `resizable` | `boolean` | `true` | Allow the user to drag-resize this column. |
398
+ | `resizable` | `boolean` | `true` | Allow the user to drag-resize this column; double-click the handle (or Enter on it) to auto-size to content (v3.1.0+). |
396
399
  | `hideable` | `boolean` | `true` | Allow the user to hide this column from the UI. `false` removes it from the Columns panel (unless `showNonHideableColumns`) and removes **Hide Column** from its column menu. |
397
400
  | `pinnable` | `boolean` | `true` | Allow this column to be pinned via the UI. `false` removes the pin actions from its column menu. Does not affect drag-reordering (v3.0+). |
398
401
  | `disableColumnMenu` | `boolean` | `false` | Hide the column header kebab/context menu. |
@@ -476,6 +479,10 @@ const apiRef = useGridApiRef();
476
479
  // Then call methods imperiously
477
480
  apiRef.current.scrollToIndexes({ rowIndex: 0 });
478
481
  apiRef.current.setFilterModel({ items: [] });
482
+
483
+ // Typed row getters (v3.0.1+)
484
+ const invoiceApi = useGridApiRef<Invoice>();
485
+ const invoice = invoiceApi.current.getRow(42); // Invoice | null
479
486
  ```
480
487
 
481
488
  See the [Imperative API (`GridApi`)](#%EF%B8%8F-imperative-api-gridapi) table above for the full method list.
@@ -56,6 +56,7 @@ in an effect, so **keep the call order when you move or add a hook**. In particu
56
56
  | 29 | `useGridSpanning` | `hooks/features/useGridSpanning.ts` | Row/column spans |
57
57
  | 30 | `useGridClipboardApi` | `hooks/core/useGridClipboardApi.ts` | Ctrl/Cmd+C and `copySelectedRows` |
58
58
  | 31 | `useGridScrollToIndexesApi` | `hooks/core/useGridScrollToIndexesApi.ts` | `scrollToIndexes` |
59
+ | 31a | `useGridColumnAutosize` | `hooks/core/useGridColumnAutosize.ts` | `autosizeColumn` / `autosizeColumns` on the API, and the resize handle's auto-size handler (v3.1) |
59
60
  | 32 | `useGridViewport` | `hooks/core/useGridViewport.ts` | Scroll sync, viewport measurement, viewport callback ref |
60
61
  | 33 | `useGridVirtualization` | `hooks/core/useGridVirtualization.ts` | Render window |
61
62
  | 34 | `useGridSortHandlers` | `hooks/core/useGridHeaderHandlers.ts` | Header sort / shift-sort |
@@ -3,12 +3,13 @@
3
3
  OpenGridX supports dynamic column resizing, allowing users to adjust the width of any column on the fly for better visibility of data. This is facilitated by the `ColumnResizeHandle`, an internal component (not exported) that `<Header />` renders invisibly on the edge of each resizable column header.
4
4
 
5
5
  ## 📑 Overview
6
- - **Where the handle is**: on the right edge of each resizable column header. Right-pinned columns are anchored to the right edge of the grid and grow leftwards, so their handle is on their **left** edge, and the edge you grab follows the pointer.
6
+ - **Where the handle is**: on the right edge of each resizable column header. Right-pinned columns are anchored to the right edge of the grid and grow leftwards, so their handle is on their **left** edge, and the edge you grab follows the pointer. The handle is an 8px strip just inside the column, against that edge, and all of it can be grabbed (v3.0.1+; before, the neighbouring header cell clipped half of it).
7
7
  - **Mouse, touch and pen**: the handle uses pointer events with pointer capture, so a resize keeps following the pointer outside the header and works on tablets and phones.
8
8
  - **Keyboard**: with a column header focused, **Alt+ArrowRight** / **Alt+ArrowLeft** widen / narrow the column by 10px (hold **Shift** for 50px). The handle is a `role="separator"` that reports the width in `aria-valuenow` (and `aria-valuemin` / `aria-valuemax`).
9
9
  - **Constraints**: `minWidth` and `maxWidth` from your `GridColDef` apply. Without `minWidth` a column can be narrowed to 50px; a column that is already narrower (a 30px icon column) is never snapped up to 50px. Without `maxWidth` there is no upper limit.
10
10
  - **Clicks are not resizes**: pressing and releasing the handle without moving it changes nothing (a flex column stays flex), and ending a resize with the pointer over the header never sorts the column.
11
- - **Double-click** does nothing (it is swallowed, so it does not sort). There is no auto-size to content.
11
+ - **Double-click to auto-size** (v3.1.0+): double-clicking the handle fits the column to its content — the header (title, sort icon, menu button) and the cells rendered right now, i.e. the rows and pinned rows in the current virtualization window, not the whole data set. The width is clamped to `minWidth` (default 50px) and `maxWidth`; a flex column gets a fixed width, like after a manual drag. The double-click never sorts. With the handle focused, **Enter** does the same. Columns with `resizable: false` have no handle and are never auto-sized.
12
+ - **Programmatic auto-size**: `apiRef.current.autosizeColumn(field)` and `apiRef.current.autosizeColumns(fields?)` (every column when `fields` is omitted) use the same measurement. The new width lands in `columnWidths` state like a drag, so `onStateChange` reports it; there is no dedicated resize callback.
12
13
 
13
14
  ---
14
15
 
@@ -34,6 +35,17 @@ const columns: GridColDef[] = [
34
35
  ];
35
36
  ```
36
37
 
38
+ ### Auto-size to content
39
+
40
+ ```tsx
41
+ const apiRef = useGridApiRef();
42
+
43
+ <DataGrid apiRef={apiRef} rows={rows} columns={columns} />
44
+ <button onClick={() => apiRef.current.autosizeColumns()}>Fit all columns</button>
45
+ ```
46
+
47
+ Measurement reads the DOM (each cell's `.ogx__cell-content` laid out at `max-content`, plus the cell's padding and borders), so only rendered rows count: scroll first, or turn off virtualization for a small grid, if off-screen rows are wider. Cells that span several columns (`colSpan`) are skipped.
48
+
37
49
  ---
38
50
 
39
51
  ## ⚙️ How it Works
@@ -25,7 +25,7 @@ function MyGrid() {
25
25
  | `rows` | `R[]` | — | **Required.** The dataset to display (`[]` when a `dataSource` supplies the rows). |
26
26
  | `columns` | `GridColDef<R>[]` | — | **Required.** Column definitions. |
27
27
  | `getRowId` | `(row: R) => GridRowId` | `row.id` | Unique ID for each row. Only keys the grid's store; row objects are never copied or given an `id` (v3.0+). Duplicate ids keep the first row and log a development warning. |
28
- | `height` | `number \| string` | `undefined` | Height of the grid container. |
28
+ | `height` | `number \| string` | `undefined` | Height of the grid. Without it the grid fills its container (v3.0.1+); the container needs a bounded height for virtualization. See [Virtualization](../features/virtualization.md#the-grid-needs-a-bounded-height). |
29
29
  | `loading` | `boolean` | `false` | Displays a loading skeleton/shimmer. |
30
30
  | `density` | `'compact' \| 'standard' \| 'comfortable'` | `'standard'` | Row height preset: compact = 32 px, standard = `rowHeight`, comfortable = 72 px. Inside a `DataGridThemeProvider`, the theme's `grid.rowHeightCompact` / `rowHeightStandard` / `rowHeightComfortable` replace these defaults (an explicit `rowHeight` still wins for standard). |
31
31
  | `checkboxSelection` | `boolean` | `false` | Enable row checkboxes. |
@@ -46,7 +46,7 @@ How the provider builds the variables (v3.0+):
46
46
  - **Toolbar tokens follow the grid surface.** Unless you set `toolbar.*`, the toolbar uses the header background, header text and grid border colours.
47
47
  - **Row and header heights reach the layout.** `grid.rowHeightStandard` / `grid.headerHeight` (and `rowHeightCompact` / `rowHeightComfortable` with `density`) are read by the grid itself, which virtualizes from them. They must be pixel values (`'36px'` or `'36'`); other units are ignored. The `rowHeight` / `headerHeight` props win over the theme.
48
48
 
49
- > **Sizing:** the wrapper `div` (`.ogx-theme-provider`) is a normal auto-height block. If the grid uses `height="100%"`, pass `style={{ height: '100%' }}` (or a `className` that sets a height) to the provider too. Otherwise the wrapper grows to fit every row and row virtualization is effectively off. See [Virtualization → The grid needs a bounded height](../features/virtualization.md#the-grid-needs-a-bounded-height).
49
+ > **Sizing:** the wrapper `div` (`.ogx-theme-provider`) is a normal auto-height block. A grid without a `height` prop (or with `height="100%"`) fills its parent, which here is the wrapper, so inside a bounded container pass `style={{ height: '100%' }}` (or a `className` that sets a height) to the provider too. Otherwise the wrapper grows to fit every row and row virtualization is effectively off. See [Virtualization → The grid needs a bounded height](../features/virtualization.md#the-grid-needs-a-bounded-height).
50
50
 
51
51
  ---
52
52
 
@@ -78,7 +78,7 @@ List view pages exactly like the grid view: with `pagination`, one page of the (
78
78
 
79
79
  `onRowsScrollEnd` fires when the list is scrolled to within 100px of its bottom, so the [infinite scroll](./infinite-scroll.md) pattern works in list view too. As in the grid view, it fires once per arrival: it fires again only after the list leaves that zone or the row count changes (for example, when the next page has loaded).
80
80
 
81
- The list view is **not virtualized**: it renders every row it shows (one page with `pagination`, otherwise all rows). Use pagination for large datasets. `slots.footer` is rendered below the list (in place of its pagination controls); the aggregation footer row is not shown in list view.
81
+ The list view is **not virtualized**: it renders every row it shows (one page with `pagination`, otherwise all rows). Use pagination for large datasets: rendering tens of thousands of cards freezes the page, and in development the grid warns when the list view renders more than 2,000 items (v3.0.1+). `slots.footer` is rendered below the list (in place of its pagination controls); the aggregation footer row is not shown in list view.
82
82
 
83
83
  ## ♿ Accessibility
84
84
 
@@ -10,6 +10,14 @@ Generate a styled PDF report from grid data using `exportToPdf()`.
10
10
  npm install jspdf jspdf-autotable
11
11
  ```
12
12
 
13
+ > **Currency symbols such as `₹` print as `?` by default.** The built-in Helvetica font only covers Latin-1. Pass the [`font` option](#non-latin-text-and-unicode-fonts) with a Unicode `.ttf`:
14
+ >
15
+ > ```ts
16
+ > await exportToPdf(rows, columns, { fileName: 'invoices', font: { name: 'NotoSans', data: notoSansBase64 } });
17
+ > ```
18
+
19
+ > **Large datasets:** PDF generation runs on the main thread, and time and file size grow with every row: 50,000 grouped rows took 10–16 s (the page is unresponsive meanwhile) and produced a file of about 120 MB. For more than a few thousand rows, export CSV (`exportToCsv`) or `.xlsx` (`exportToExcelAdvanced`) instead, or export a filtered subset.
20
+
13
21
  ## Basic Usage
14
22
 
15
23
  ```tsx
@@ -134,6 +142,14 @@ System columns (`__check__`, `__actions__`) and spacer columns (`isSpacer: true`
134
142
 
135
143
  ## Troubleshooting
136
144
 
145
+ **`₹` or other characters print as `?`**
146
+
147
+ Pass the `font` option with a Unicode TrueType font. See [Non-Latin text](#non-latin-text-and-unicode-fonts).
148
+
149
+ **Export is slow or the file is huge**
150
+
151
+ PDF is not suited to tens of thousands of rows (see Installation above). Use CSV or `.xlsx` for large exports.
152
+
137
153
  **Error: "exportToPdf requires 'jspdf' and 'jspdf-autotable'"**
138
154
 
139
155
  Run: `npm install jspdf jspdf-autotable`
@@ -20,6 +20,7 @@ Columns can be pinned to the left or right edges of the grid. Pinned columns rem
20
20
 
21
21
  ### Order and widths
22
22
  - Pinned columns render in the order of `pinnedColumns.left` / `pinnedColumns.right`, not in column order. Pinning from the column menu appends the column to the end of its side, so it lands next to the scrolling columns.
23
+ - When the columns are narrower than the grid, right-pinned columns sit at the grid's right edge, and the free space is between the scrolling columns and them (v3.0.1+).
23
24
  - A pinned column is sized exactly as it would be unpinned: numbers, percentages, `flex` and `'auto'` all apply, clamped to `minWidth` / `maxWidth`. The sticky offsets use those widths, so pinned columns never overlap.
24
25
 
25
26
  ### Column Configuration
@@ -48,6 +48,32 @@ Each active sort column shows a numbered priority badge (`1`, `2`, `3`…) next
48
48
  - A column that mixes kinds of value (numbers and strings, say) is ordered by kind first — numbers, dates, booleans, strings — so the order does not depend on the input order.
49
49
  - Ties keep the rows' original order.
50
50
 
51
+ ### Custom comparator: `sortComparator` (v3.1.0+)
52
+
53
+ Give a column `sortComparator(v1, v2, params1, params2)` to replace the built-in comparison for that column. Return a negative number when `v1` sorts before `v2` **in ascending order**, a positive number when after, `0` when equal.
54
+
55
+ ```tsx
56
+ const SIZE_ORDER: Record<string, number> = { S: 0, M: 1, L: 2, XL: 3 };
57
+
58
+ const columns: GridColDef<Product>[] = [
59
+ {
60
+ field: 'size',
61
+ sortComparator: (a, b) => (SIZE_ORDER[String(a)] ?? 99) - (SIZE_ORDER[String(b)] ?? 99),
62
+ },
63
+ ];
64
+ ```
65
+
66
+ - `v1` / `v2` are the cell values **after `valueGetter`** (the same values the built-in sort reads). `params1` / `params2` are `{ id, field, row, value }` (`GridSortCellParams`), for tie-breaking on other fields of the row.
67
+ - **The comparator sees every value, including `null` and `undefined`**, so you decide where empty values go. The built-in "empty values last in asc" rule does not apply to a column with a comparator. (Why: a custom order often has its own idea of "empty" — `'N/A'`, `0`, a sentinel — and hiding nulls from the comparator would make those impossible to place consistently with real nulls.)
68
+ - **Direction**: write the ascending order only. For `desc` the grid negates the result, so values the comparator puts last in asc (nulls, typically) come first in desc — the same as the built-in behaviour.
69
+ - **Multi-sort**: the comparator is one key in the chain; when it returns `0` the next sort key decides, then the original row order.
70
+ - **Row grouping**: sorting by the grouping column (or by the grouping/label column) orders the groups by their grouping value using the grouping column's comparator; for a group row, `params.row` is the synthetic group row. Leaf rows inside a group use the sorted column's comparator.
71
+ - **Tree data**: siblings are sorted with the comparator at every level. Auto-created parents (no row of their own) are compared by label with the built-in comparison.
72
+ - **Pivot**: sorting a pivot row-label column uses that source column's comparator on the label values.
73
+ - Everything that reuses the pipeline order uses it too: `apiRef.getAllFilteredRows()`, exports, clipboard.
74
+ - A comparator that throws, or returns `NaN`, reads as `0` (equal) with a one-time dev warning per column; the grid does not crash.
75
+ - **Ignored with `sortingMode="server"`**: the server sorts, the grid shows `rows` as given.
76
+
51
77
  ```tsx
52
78
  // Controlled multi-sort — set programmatically or drive from UI with multiSort:
53
79
  const [sortModel, setSortModel] = useState<GridSortItem[]>([
@@ -53,37 +53,39 @@ Without the stylesheet the viewport does not scroll, so the grid renders every r
53
53
 
54
54
  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.
55
55
 
56
- 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:
56
+ **Without a `height` prop the grid fills its container** (since 3.0.1): its root gets `height: 100%` and, as a flex item, `flex: 1 1 auto; min-height: 0`. So it fills a sized block parent, or the free space of a flex column, and virtualizes. In a container with no definite height, `100%` resolves to `auto` and the grid grows to fit its rows, exactly as before. Passing `height` (a number or any CSS length) sets the height instead; `autoHeight` always grows to fit every row.
57
+
58
+ The most common cause of an unbounded container 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:
57
59
 
58
60
  ```tsx
59
- // ❌ Grows to fit all rows — virtualization is effectively off
61
+ // ❌ The wrapper grows to fit all rows — virtualization is effectively off
60
62
  <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
61
63
  <div style={{ flex: 1 }}>
62
- <DataGrid rows={rows} columns={columns} height="100%" />
64
+ <DataGrid rows={rows} columns={columns} />
63
65
  </div>
64
66
  </div>
65
67
 
66
68
  // ✅ Bounded — only visible rows render
67
69
  <div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
68
70
  <div style={{ flex: 1, minHeight: 0 }}>
69
- <DataGrid rows={rows} columns={columns} height="100%" />
71
+ <DataGrid rows={rows} columns={columns} />
70
72
  </div>
71
73
  </div>
72
74
  ```
73
75
 
74
- `height="100%"` only helps if every ancestor up to a fixed-height element also has a definite height.
76
+ Every flex (or grid) item between the grid and the element with a fixed height (`100vh`, `600px`, …) needs `min-height: 0`. The grid itself can also be the flex item: `<div style={{ display: 'flex', flexDirection: 'column', height: 600 }}><Toolbar /><DataGrid … /></div>` gives the grid the space left after the toolbar.
75
77
 
76
- **`DataGridThemeProvider` counts as an ancestor.** It renders a plain `<div class="ogx-theme-provider">` around the grid, and that wrapper is auto-height like any other `div`. When you use `height="100%"` inside it, give the wrapper a height as well:
78
+ **`DataGridThemeProvider` counts as an ancestor.** It renders a plain `<div class="ogx-theme-provider">` around the grid, and that wrapper is auto-height like any other `div`. Inside a bounded container, give the wrapper a height as well:
77
79
 
78
80
  ```tsx
79
81
  <div style={{ flex: 1, minHeight: 0 }}>
80
82
  <DataGridThemeProvider theme={theme} style={{ height: '100%' }}>
81
- <DataGrid rows={rows} columns={columns} height="100%" />
83
+ <DataGrid rows={rows} columns={columns} />
82
84
  </DataGridThemeProvider>
83
85
  </div>
84
86
  ```
85
87
 
86
- 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.
88
+ In development builds the grid logs a `console.warn` when it is rendering every row of a dataset larger than 200 rows and its viewport is at least as tall as those rows, that is, when its container really is unbounded. The warning names the fixes above.
87
89
 
88
90
  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.
89
91
 
@@ -205,7 +207,7 @@ When using detail panels, row heights become variable. The grid automatically:
205
207
 
206
208
  ## Virtualization with Grouping/Tree Data
207
209
 
208
- 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`).
210
+ 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, Firefox and WebKit by `DataGrid.virtualization.browser.test.tsx`).
209
211
 
210
212
  ```tsx
211
213
  // Tree data with virtualization
@@ -53,6 +53,8 @@ import '@opencorestack/opengridx/styles';
53
53
 
54
54
  Without it the grid is unstyled and its viewport has no bounded height, so **every row renders** (virtualization is effectively off). If your v2 app looked fine, you already have this import. In development, 3.0.0 logs a warning when the stylesheet is not loaded.
55
55
 
56
+ > **3.0.1 note — height.** Without a `height` prop the grid now fills its container (root class `ogx--fill`: `height: 100%`, and as a flex item `flex: 1 1 auto; min-height: 0`). In 3.0.0 the root was auto-height, so inside a bounded flex layout it grew to fit every row. The container still needs a bounded height (a definite height, or `min-height: 0` on every flex / grid ancestor up to the sized one); in an auto-height container the grid still grows to fit. An explicit `height`, `style.height` or `autoHeight` is never stretched. The unbounded-container dev warning is only logged when the viewport really is as tall as its rows.
57
+
56
58
  ### Know the size limit
57
59
 
58
60
  A single scrolling grid tops out at about 645,000 rows at the default 52px row height in Chromium (the browser's maximum element height). Above that, use pagination or a `dataSource`. The grid logs a dev warning when content is taller than browsers can scroll.
@@ -282,7 +284,7 @@ Multi-sort:
282
284
 
283
285
  **Who is affected:** apps with snapshot tests of sorted output, or server code that expects the old client order.
284
286
 
285
- **Fix it:** update the snapshots. There is no per-column comparator; to sort by a different key, return that key from a `valueGetter` and format it for display with `valueFormatter` (or sort on the server with `sortingMode="server"`):
287
+ **Fix it:** update the snapshots. Since **v3.1.0** a column can take a custom `sortComparator(v1, v2, params1, params2)` (see `docs/features/sorting-pagination.md`), which is the simplest way to get a custom order. On 3.0.x there is no per-column comparator; to sort by a different key, return that key from a `valueGetter` and format it for display with `valueFormatter` (or sort on the server with `sortingMode="server"`):
286
288
 
287
289
  ```tsx
288
290
  import type { GridColDef, GridRowModel } from '@opencorestack/opengridx';
@@ -427,6 +429,8 @@ Pruning applies when the grid owns the rows: no `dataSource`, client pagination
427
429
 
428
430
  **Fix it:** call the setter and let your change callback update controlled state; remove duplicate `setState` calls.
429
431
 
432
+ > **3.0.1 note — typed row getters.** `useGridApiRef<MyRow>()` returns a `GridApi<MyRow>` whose `getRow`, `getAllRows`, `getVisibleRows` and `getAllFilteredRows` return `MyRow`, so the casts v2 needed can go. `useGridApiRef()` without a type argument and `DataGridProps.apiRef` with an untyped ref still compile.
433
+
430
434
  ---
431
435
 
432
436
  ## 11. Editing
@@ -714,6 +718,8 @@ Keep `getRows` stable (`useCallback` or module scope) if you do not want a refet
714
718
  | Sticky header and pinned rows | `.ogx__pinned-rows--top` / `--bottom` were sticky | wrapped in `div.ogx__sticky-top` / `div.ogx__sticky-bottom` (see [§24](#24-dom-and-css-class-changes)) |
715
719
  | z-index of sticky body system cells | 4 / 5 / 11 | 12; header drag / expand cells have no inline z-index |
716
720
  | Returning from list view | scroll reset | scroll position restored |
721
+ | Right-pinned columns when columns are narrower than the grid | followed the last unpinned column | at the grid's right edge (3.0.1; free space sits before the right-pinned section) |
722
+ | No `height` prop | auto height (3.0.0) | fills the container, `ogx--fill` (3.0.1; see [Before you start](#import-the-stylesheet)) |
717
723
 
718
724
  **Who is affected:** apps with `onRowsScrollEnd` loaders that relied on repeated firing, apps with custom z-index or sticky CSS, and apps whose pinned columns were pinned out of column order.
719
725
 
@@ -751,6 +757,7 @@ export function Pinned({ rows, columns }: { rows: GridRowModel[]; columns: GridC
751
757
  | Click on the resize handle without moving | resized / sorted | nothing |
752
758
  | Resize events | mouse events on `document` | pointer events with capture on the handle (touch and pen work) |
753
759
  | Right-pinned column resize | right edge | left edge (`ogx-column-resize-handle--start`); drag left to widen |
760
+ | Resize handle position | straddled the cell border (`right: -4px`); half was clipped, 3–4px grabbable | inside its own header cell against the resizing edge (`right: 0` / `left: 0`), all 8px grabbable (3.0.1) |
754
761
  | `dragstart` data types | `text/plain` | `application/x-ogx-row` / `application/x-ogx-column` plus `text/plain` |
755
762
  | Toolbar, pivot and standalone panels | portal mounted in render | mounted one layout effect later (invisible to users; matters for tests) |
756
763
 
@@ -967,6 +974,8 @@ CSS: `.ogx-col-group-row` no longer has `overflow: hidden`; new `ogx-col-group-c
967
974
  | Tree data and grouping | no expand control | expand chevron and depth indent (`.ogx-list-view__expand`) |
968
975
  | Summary and paging with a `dataSource` | loaded rows | server total |
969
976
  | `onRowsScrollEnd` | never fired | fires once per arrival at the bottom, like the grid view |
977
+ | Large lists | not virtualized | still not virtualized; 3.0.1 warns in development above 2,000 rendered items — use pagination |
978
+ | Tab in Firefox | — | lands on the focused row, not the rows container (3.0.1, `tabIndex={-1}` on `.ogx-list-view__rows`) |
970
979
 
971
980
  ---
972
981
 
@@ -1002,6 +1011,7 @@ CSS: `.ogx-col-group-row` no longer has `overflow: hidden`; new `ogx-col-group-c
1002
1011
  | `ogx__row--group-footer` | subtotal rows in `'footer'` position |
1003
1012
  | `ogx__cell-image` | default `<img>` for `type: 'image'` |
1004
1013
  | `ogx-column-resize-handle--start` | resize handle on the left edge (right-pinned columns) |
1014
+ | `ogx--fill` (3.0.1) | grid root when no `height`, `style.height` or `autoHeight` is set |
1005
1015
  | `ogx-col-group-cell--pinned`, `ogx-col-group-cell--pinned-left`, `ogx-col-group-cell--pinned-right` | pinned column-group cells |
1006
1016
  | `ogx-filter__hidden-note`, `ogx-filter__value-multiselect` | filter panel |
1007
1017
  | `ogx-list-view__expand`, `ogx-list-view__loading` | list view |
@@ -1019,7 +1029,8 @@ CSS: `.ogx-col-group-row` no longer has `overflow: hidden`; new `ogx-col-group-c
1019
1029
  | `.ogx-global-search--expanded` | shows the input border while expanded and unfocused; the primary border and focus shadow moved to `:focus-within` |
1020
1030
  | `.ogx__cell--focused`, `.ogx__header-cell--focused`, `.ogx__header-cell--focus-visible` | outline colour reads `--ogx-grid-cell-focus-border` (falls back to `--ogx-color-primary`) |
1021
1031
  | `.ogx__header-cell--drag-over` | background `--ogx-color-primary-light` (was the undefined `--ogx-color-blue-50`) |
1022
- | `.ogx-column-resize-handle` | `touch-action: none`; a `:focus-visible` line colour |
1032
+ | `.ogx-column-resize-handle` | `touch-action: none`; a `:focus-visible` line colour; 3.0.1: `right: 0` (was `-4px`), `justify-content: flex-end`; `--start` is `left: 0`, `flex-start` |
1033
+ | `.ogx__header-cell--pinned-right-first`, `.ogx__cell--pinned-right-first`, `.ogx__aggregation-cell--pinned-right-first`, first right-pinned `.ogx-col-group-cell` (3.0.1) | `margin-left: auto`; `.ogx__content` width is `max(100%, <total>px)` when right-pinned columns exist |
1023
1034
  | `.ogx-list-view__row` | `:focus-visible` outline (rows are focusable) |
1024
1035
  | `.ogx-toolbar` and its buttons / chips, `.ogx-global-search`, scrollbars, filter-panel delete button | read the `--ogx-toolbar-*`, `--ogx-scrollbar-*` and `--ogx-overlay-item-danger-*` variables the theme provider sets (the filter-panel delete button used `--ogx-toolbar-btn-danger-*`); fallbacks match the v2 colours except the danger-button hover |
1025
1036
 
package/docs/roadmap.md CHANGED
@@ -90,7 +90,7 @@ This document tracks the current status of features in `OpenGridX` and outlines
90
90
  - `slots.footer`, `slots.noRowsOverlay` and `slots.loadingOverlay` are rendered (they were typed but ignored).
91
91
  - New `onRowDoubleClick`; `ColumnVisibilityPanel` exported; `exportToExcelAdvanced` accepts `groupedRows`.
92
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.
93
+ - Real-browser test project (Vitest browser mode + Playwright; Chromium, Firefox and WebKit), `npm run test:browser`; the npm publish workflow now runs unit and browser tests before publishing.
94
94
  - Already shipped, previously listed as upcoming: npm publishing (since 0.1.0), native PDF export (v1.2.1), GitHub Pages deployment.
95
95
 
96
96
  ### Library Hardening *(completed 2026-09-02)*
@@ -58,6 +58,10 @@
58
58
  .ogx__cell--pinned-right {
59
59
  border-left: 1px solid var(--ogx-grid-border-color);
60
60
  }
61
+ /* When the columns are narrower than the grid, the free space goes before the right-pinned section. */
62
+ .ogx__cell--pinned-right-first {
63
+ margin-left: auto;
64
+ }
61
65
  .ogx:not(.ogx--row-spanning) .ogx__row:hover .ogx__cell--pinned-left,
62
66
  .ogx:not(.ogx--row-spanning) .ogx__row:hover .ogx__cell--pinned-right {
63
67
  background-color: var(--ogx-grid-row-hover-background);
@@ -1,14 +1,15 @@
1
1
  .ogx-column-resize-handle {
2
2
  position: absolute;
3
3
  top: 0;
4
- right: -4px;
4
+ /* Inside the header cell: the cell clips overflow, so a handle straddling the border lost its outer half. */
5
+ right: 0;
5
6
  width: 8px;
6
7
  height: 100%;
7
8
  cursor: col-resize;
8
9
  z-index: 10;
9
10
  display: flex;
10
11
  align-items: center;
11
- justify-content: center;
12
+ justify-content: flex-end;
12
13
  user-select: none;
13
14
  /* Touch and pen drags resize the column instead of scrolling the grid. */
14
15
  touch-action: none;
@@ -16,7 +17,8 @@
16
17
  /* Right-pinned columns grow leftwards: their handle sits on the left edge. */
17
18
  .ogx-column-resize-handle--start {
18
19
  right: auto;
19
- left: -4px;
20
+ left: 0;
21
+ justify-content: flex-start;
20
22
  }
21
23
  .ogx-column-resize-handle:focus-visible .ogx-column-resize-handle__line {
22
24
  background: var(--ogx-color-primary);
@@ -6,6 +6,8 @@ export interface ColumnResizeHandleProps {
6
6
  field: string;
7
7
  currentWidth: number;
8
8
  onResize: (field: string, newWidth: number) => void;
9
+ /** Fits the column to its content: called on double-click and on Enter. */
10
+ onAutosize?: (field: string) => void;
9
11
  /** Defaults to 50 (the layout's default minimum), or the current width if that is smaller. */
10
12
  minWidth?: number;
11
13
  /** Defaults to no limit. */
@@ -43,7 +45,7 @@ function suppressNextClick(): void {
43
45
  }
44
46
 
45
47
  export function ColumnResizeHandle(props: ColumnResizeHandleProps) {
46
- const { field, currentWidth, onResize, minWidth, maxWidth, edge = 'end' } = props;
48
+ const { field, currentWidth, onResize, onAutosize, minWidth, maxWidth, edge = 'end' } = props;
47
49
  const [isDragging, setIsDragging] = useState(false);
48
50
  const activeRef = useRef<ActiveResize | null>(null);
49
51
 
@@ -103,17 +105,24 @@ export function ColumnResizeHandle(props: ColumnResizeHandleProps) {
103
105
  }, [field, onResize, widthAt]);
104
106
 
105
107
  const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
108
+ if (event.key === 'Enter' && onAutosize) {
109
+ event.preventDefault();
110
+ event.stopPropagation();
111
+ onAutosize(field);
112
+ return;
113
+ }
106
114
  if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
107
115
  event.preventDefault();
108
116
  event.stopPropagation();
109
117
  const step = (event.shiftKey ? 50 : 10) * (event.key === 'ArrowRight' ? 1 : -1);
110
118
  onResize(field, clampResizeWidth(currentWidth + step, currentWidth, minWidth, maxWidth));
111
- }, [field, currentWidth, onResize, minWidth, maxWidth]);
119
+ }, [field, currentWidth, onResize, onAutosize, minWidth, maxWidth]);
112
120
 
113
121
  const handleDoubleClick = useCallback((event: React.MouseEvent) => {
114
122
  event.preventDefault();
115
123
  event.stopPropagation();
116
- }, []);
124
+ onAutosize?.(field);
125
+ }, [field, onAutosize]);
117
126
 
118
127
  const classNames = [
119
128
  'ogx-column-resize-handle',
@@ -88,6 +88,13 @@
88
88
  justify-content: center;
89
89
  z-index: var(--ogx-z-index-modal);
90
90
  }
91
+ /* No height prop: fill the container, as a block child of a sized box (height: 100%) or as a
92
+ flex item (flex: 1 with min-height: 0). In an auto-height parent 100% resolves to auto. */
93
+ .ogx--fill {
94
+ height: 100%;
95
+ flex: 1 1 auto;
96
+ min-height: 0;
97
+ }
91
98
  .ogx--auto-height {
92
99
  height: auto;
93
100
  }
@@ -274,6 +281,7 @@
274
281
  box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.1);
275
282
  }
276
283
  .ogx__aggregation-cell--pinned-right-first {
284
+ margin-left: auto;
277
285
  overflow: visible;
278
286
  }
279
287
  .ogx__aggregation-cell--pinned-right-first::before {
@@ -51,6 +51,7 @@ import { useStableColumns } from '../../hooks/core/useStableColumns';
51
51
  import { useGridAggregationApi } from '../../hooks/core/useGridAggregationApi';
52
52
  import { useGridClipboardApi } from '../../hooks/core/useGridClipboardApi';
53
53
  import { useGridScrollToIndexesApi } from '../../hooks/core/useGridScrollToIndexesApi';
54
+ import { useGridColumnAutosize } from '../../hooks/core/useGridColumnAutosize';
54
55
  import { useGridSortHandlers, useGridColumnMenuHandlers } from '../../hooks/core/useGridHeaderHandlers';
55
56
  import { useGridKeyboardMode, useGridPointerFocusHandlers } from '../../hooks/core/useGridFocusHandlers';
56
57
  import { getPaginationRowCount, pickPaginationLocaleText } from '../../utils/pagination';
@@ -500,6 +501,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
500
501
  disableKeyboardShortcut: disableClipboardCopy,
501
502
  });
502
503
  useGridScrollToIndexesApi<R>({ apiRef, viewportRef, layout, rowHeight: effectiveRowHeight });
504
+ const handleColumnAutosize = useGridColumnAutosize<R>({ apiRef, containerRef, columns: orderedColumns, onColumnResize: handleColumnResize });
503
505
 
504
506
  const { scrollTop, scrollLeft, overscanRows, handleScroll, setViewportElement } = useGridViewport({
505
507
  onRowsScrollEnd, overscanRowCount, rowCount: layout.unpinnedRowsLength, autoHeight, setDimensions, viewportRef,
@@ -595,6 +597,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
595
597
  pinnedRowsIgnored: isHierarchyEnabled && Boolean(pinnedRows?.top?.length || pinnedRows?.bottom?.length),
596
598
  scrollHeight: virtualization.totalHeight,
597
599
  listViewWithoutColumn: listViewRequested && !listViewColumn,
600
+ listViewRowCount: listView && listViewColumn ? allRenderableRows.length : 0,
598
601
  });
599
602
 
600
603
  const { allSelected, someSelected } = rowSelection;
@@ -660,7 +663,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
660
663
  return (
661
664
  <div
662
665
  ref={containerRef}
663
- className={['ogx', className, autoHeight && 'ogx--auto-height', hasRowSpanning && 'ogx--row-spanning', listView && 'ogx--list-view'].filter(Boolean).join(' ')}
666
+ className={['ogx', className, autoHeight && 'ogx--auto-height', !autoHeight && height === undefined && style?.height === undefined && 'ogx--fill', hasRowSpanning && 'ogx--row-spanning', listView && 'ogx--list-view'].filter(Boolean).join(' ')}
664
667
  style={{
665
668
  ...style,
666
669
  height: height ?? style?.height,
@@ -755,7 +758,10 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
755
758
  <div
756
759
  className="ogx__content"
757
760
  style={{
758
- width: virtualization.totalWidth
761
+ // With right-pinned columns the content spans at least the viewport, so they sit at its right edge.
762
+ width: effectivePinnedColumns?.right?.length
763
+ ? `max(100%, ${virtualization.totalWidth}px)`
764
+ : virtualization.totalWidth
759
765
  }}
760
766
  role="presentation"
761
767
  >
@@ -775,6 +781,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
775
781
  multiSort={multiSort}
776
782
 
777
783
  onColumnResize={handleColumnResize}
784
+ onColumnAutosize={handleColumnAutosize}
778
785
  columnWidths={columnWidths}
779
786
  pinnedColumns={effectivePinnedColumns}
780
787
 
@@ -202,7 +202,8 @@ export function GridListView<R extends GridRowModel>({
202
202
  </span>
203
203
  </div>
204
204
 
205
- <div className="ogx-list-view__rows" onScroll={handleScroll} onKeyDown={handleKeyDown} ref={rowsRef}>
205
+ {/* tabIndex -1: Firefox otherwise makes this scroll container a Tab stop of its own; the rows carry the roving tab stop. */}
206
+ <div className="ogx-list-view__rows" tabIndex={-1} onScroll={handleScroll} onKeyDown={handleKeyDown} ref={rowsRef}>
206
207
  {allRenderableRows.length === 0 && loading ? (
207
208
  // The grid's live region announces loading; the list shows no "No Data" meanwhile.
208
209
  <div className="ogx-list-view__loading">
@@ -148,6 +148,9 @@
148
148
  .ogx__header-cell--pinned-left {
149
149
  box-shadow: var(--ogx-grid-pinned-left-shadow, 4px 0 12px -4px rgba(0, 0, 0, 0.05));
150
150
  }
151
+ .ogx__header-cell--pinned-right-first {
152
+ margin-left: auto;
153
+ }
151
154
  .ogx__header-cell--pinned-right {
152
155
  box-shadow: var(--ogx-grid-pinned-right-shadow, -4px 0 12px -4px rgba(0, 0, 0, 0.05));
153
156
  }
@@ -43,6 +43,8 @@ export interface HeaderProps<R extends GridRowModel = GridRowModel> {
43
43
  onSort?: (field: string, direction: GridSortDirection) => void;
44
44
  sortModel?: Array<{ field: string; sort: 'asc' | 'desc' }>;
45
45
  onColumnResize?: (field: string, newWidth: number) => void;
46
+ /** Fits a column to its content (double-click or Enter on its resize handle). */
47
+ onColumnAutosize?: (field: string) => void;
46
48
  columnWidths?: Record<string, number>;
47
49
  pinnedColumns?: GridColumnPinning;
48
50
  hasDetailPanel?: boolean;
@@ -81,6 +83,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
81
83
  onSort,
82
84
  sortModel = [],
83
85
  onColumnResize,
86
+ onColumnAutosize,
84
87
  columnWidths = {},
85
88
  pinnedColumns,
86
89
  hasDetailPanel = false,
@@ -263,7 +266,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
263
266
  aria-hidden="true"
264
267
  />
265
268
  )}
266
- {cells.map(cell => {
269
+ {cells.map((cell, cellIndex) => {
267
270
  const pinnedClass = cell.pinned ? ` ogx-col-group-cell--pinned ogx-col-group-cell--pinned-${cell.pinned}` : '';
268
271
  const style: React.CSSProperties = {
269
272
  width: cell.width,
@@ -277,6 +280,8 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
277
280
  } else if (cell.pinned === 'right') {
278
281
  style.position = 'sticky';
279
282
  style.right = pinnedPositions[cell.fields[cell.fields.length - 1]];
283
+ // Free space goes before the right-pinned section, as in the column header row.
284
+ if (cells[cellIndex - 1]?.pinned !== 'right') style.marginLeft = 'auto';
280
285
  }
281
286
  if (!cell.isGroup) {
282
287
  return (
@@ -523,6 +528,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
523
528
  field={colDef.field}
524
529
  currentWidth={getRenderedColumnWidth(colDef, columnWidths)}
525
530
  onResize={onColumnResize}
531
+ onAutosize={onColumnAutosize}
526
532
  minWidth={colDef.minWidth}
527
533
  maxWidth={colDef.maxWidth}
528
534
  edge={pinnedPosition === 'right' ? 'start' : 'end'}