@opencorestack/opengridx 3.0.1 → 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.
@@ -294,6 +294,8 @@ 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. |
@@ -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. |
@@ -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 |
@@ -8,7 +8,8 @@ OpenGridX supports dynamic column resizing, allowing users to adjust the width o
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
@@ -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[]>([
@@ -284,7 +284,7 @@ Multi-sort:
284
284
 
285
285
  **Who is affected:** apps with snapshot tests of sorted output, or server code that expects the old client order.
286
286
 
287
- **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"`):
288
288
 
289
289
  ```tsx
290
290
  import type { GridColDef, GridRowModel } from '@opencorestack/opengridx';
@@ -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',
@@ -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,
@@ -779,6 +781,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
779
781
  multiSort={multiSort}
780
782
 
781
783
  onColumnResize={handleColumnResize}
784
+ onColumnAutosize={handleColumnAutosize}
782
785
  columnWidths={columnWidths}
783
786
  pinnedColumns={effectivePinnedColumns}
784
787
 
@@ -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,
@@ -525,6 +528,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
525
528
  field={colDef.field}
526
529
  currentWidth={getRenderedColumnWidth(colDef, columnWidths)}
527
530
  onResize={onColumnResize}
531
+ onAutosize={onColumnAutosize}
528
532
  minWidth={colDef.minWidth}
529
533
  maxWidth={colDef.maxWidth}
530
534
  edge={pinnedPosition === 'right' ? 'start' : 'end'}
@@ -29,6 +29,8 @@ export function createGridApiPlaceholder<R extends GridValidRowModel = GridRowMo
29
29
  setPage: noop,
30
30
  setPageSize: noop,
31
31
  scrollToIndexes: noop,
32
+ autosizeColumn: noop,
33
+ autosizeColumns: noop,
32
34
  copySelectedRows: () => Promise.resolve(),
33
35
  };
34
36
  }
@@ -0,0 +1,52 @@
1
+ import { useCallback, useLayoutEffect, useRef } from 'react';
2
+ import type { MutableRefObject, RefObject } from 'react';
3
+ import type { GridApi, GridColDef, GridValidRowModel } from '../../types';
4
+ import { clampAutosizeWidth, measureColumnContentWidth } from '../../utils/columnAutosize';
5
+
6
+ export interface UseGridColumnAutosizeParams<R extends GridValidRowModel> {
7
+ apiRef: MutableRefObject<GridApi>;
8
+ /** The grid root (`.ogx`). */
9
+ containerRef: RefObject<HTMLDivElement | null>;
10
+ /** Every column the grid knows (hidden ones are measured as not rendered and left alone). */
11
+ columns: readonly GridColDef<R>[];
12
+ /** The width setter a manual resize uses. */
13
+ onColumnResize: (field: string, width: number) => void;
14
+ }
15
+
16
+ /**
17
+ * Auto-sizing of columns to their content: the header and the cells in the current render window,
18
+ * measured in the DOM, clamped to `minWidth` / `maxWidth`. Columns with `resizable: false` are left
19
+ * alone. A flex column gets a fixed width, like after a manual resize. Installs `autosizeColumn` /
20
+ * `autosizeColumns` on the API and returns `autosizeColumn` for the resize handle.
21
+ */
22
+ export function useGridColumnAutosize<R extends GridValidRowModel>(params: UseGridColumnAutosizeParams<R>): (field: string) => void {
23
+ const { apiRef } = params;
24
+ const latestRef = useRef(params);
25
+ useLayoutEffect(() => {
26
+ latestRef.current = params;
27
+ });
28
+
29
+ const autosizeColumns = useCallback((fields?: readonly string[]) => {
30
+ const { containerRef, columns, onColumnResize } = latestRef.current;
31
+ const root = containerRef.current;
32
+ if (!root) return;
33
+ // Measure every column first: the reads all see the same layout, then the widths are set.
34
+ const widths: Array<[string, number]> = [];
35
+ for (const field of fields ?? columns.map(c => c.field)) {
36
+ const colDef = columns.find(c => c.field === field);
37
+ if (!colDef || colDef.resizable === false) continue;
38
+ const measured = measureColumnContentWidth(root, field);
39
+ if (measured !== null) widths.push([field, clampAutosizeWidth(measured, colDef.minWidth, colDef.maxWidth)]);
40
+ }
41
+ for (const [field, width] of widths) onColumnResize(field, width);
42
+ }, []);
43
+
44
+ const autosizeColumn = useCallback((field: string) => autosizeColumns([field]), [autosizeColumns]);
45
+
46
+ useLayoutEffect(() => {
47
+ apiRef.current.autosizeColumn = autosizeColumn;
48
+ apiRef.current.autosizeColumns = autosizeColumns;
49
+ }, [apiRef, autosizeColumn, autosizeColumns]);
50
+
51
+ return autosizeColumn;
52
+ }
@@ -103,8 +103,8 @@ export function useGridRowPipeline<R extends GridRowModel>(
103
103
  const sortedUnpinnedRows = useMemo<R[]>(() => {
104
104
  if (activeHierarchyHandlers) return unpinnedRows;
105
105
  if (sortingMode === 'server') return unpinnedRows;
106
- return sortRows(unpinnedRows, sortModel, columnLookup) as R[];
107
- }, [unpinnedRows, sortModel, activeHierarchyHandlers, sortingMode, columnLookup]);
106
+ return sortRows(unpinnedRows, sortModel, columnLookup, getRowId) as R[];
107
+ }, [unpinnedRows, sortModel, activeHierarchyHandlers, sortingMode, columnLookup, getRowId]);
108
108
 
109
109
  const isClientPaged = pagination && paginationMode !== 'server';
110
110
  const pageSize = normalizePageSize(effectivePaginationModel.pageSize);
@@ -362,10 +362,20 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
362
362
 
363
363
  const readSortValue = (id: GridRowId, sortItem: GridSortItem): GridSortValue => {
364
364
  const node = treeNodes.get(id);
365
- if (node && groupRowVariants.has(id)) {
365
+ const groupVariants = groupRowVariants.get(id);
366
+ if (node && groupVariants) {
366
367
  const groupingField = node.groupingField ?? '';
367
368
  if (sortItem.field === groupingField || sortItem.field === labelField) {
368
- return { value: node.groupingValue, type: columnLookup?.byField.get(groupingField)?.type };
369
+ const groupingColDef = columnLookup?.byField.get(groupingField);
370
+ const comparator = groupingColDef?.sortComparator;
371
+ return {
372
+ value: node.groupingValue,
373
+ type: groupingColDef?.type,
374
+ comparator,
375
+ params: comparator
376
+ ? { id, field: groupingField, row: groupVariants.inline, value: node.groupingValue }
377
+ : undefined,
378
+ };
369
379
  }
370
380
  if (node.aggregatedValues && Object.prototype.hasOwnProperty.call(node.aggregatedValues, sortItem.field)) {
371
381
  return { value: node.aggregatedValues[sortItem.field], type: columnLookup?.byField.get(sortItem.field)?.type };
@@ -374,7 +384,14 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
374
384
  }
375
385
  const row = rowLookup.get(id);
376
386
  const colDef = columnLookup?.byField.get(sortItem.field);
377
- return { value: row ? getCellValue(row, sortItem.field, colDef) : undefined, type: colDef?.type };
387
+ const value = row ? getCellValue(row, sortItem.field, colDef) : undefined;
388
+ const comparator = colDef?.sortComparator;
389
+ return {
390
+ value,
391
+ type: colDef?.type,
392
+ comparator,
393
+ params: comparator && row ? { id, field: sortItem.field, row, value } : undefined,
394
+ };
378
395
  };
379
396
 
380
397
  const result: R[] = [];
@@ -333,7 +333,14 @@ export function useTreeData<R extends GridRowModel>(props: UseTreeDataProps<R>)
333
333
  }
334
334
  const row = rowLookup.get(id);
335
335
  const colDef = columnLookup?.byField.get(sortItem.field);
336
- return { value: row ? getCellValue(row, sortItem.field, colDef) : undefined, type: colDef?.type };
336
+ const value = row ? getCellValue(row, sortItem.field, colDef) : undefined;
337
+ const comparator = colDef?.sortComparator;
338
+ return {
339
+ value,
340
+ type: colDef?.type,
341
+ comparator,
342
+ params: comparator && row ? { id, field: sortItem.field, row, value } : undefined,
343
+ };
337
344
  };
338
345
 
339
346
  const result: GridRowModel[] = [];
package/lib/index.ts CHANGED
@@ -89,6 +89,7 @@ export type {
89
89
  GridRenderEditCellParams,
90
90
  GridRenderHeaderParams,
91
91
  GridValueGetterParams,
92
+ GridSortCellParams,
92
93
  GridValueSetterParams,
93
94
  GridValueFormatterParams,
94
95
  GridRowParams,
@@ -172,6 +172,16 @@ export interface GridColDef<R extends GridValidRowModel = GridRowModel> {
172
172
  * @since v3.0
173
173
  */
174
174
  valueSetter?(params: GridValueSetterParams<R>): R;
175
+ /**
176
+ * Custom ascending comparator for client-side sorting by this column, used instead of the built-in
177
+ * type-aware comparison. `v1` / `v2` are the cell values (`valueGetter` applied) and **include
178
+ * `null` / `undefined`**, so the comparator decides where empty values go. The grid reverses the
179
+ * result for 'desc' and chains it with the other sort keys in a multi-sort. Row grouping uses the
180
+ * grouping column's comparator to order groups by their grouping value. A comparator that throws
181
+ * or returns NaN is read as 0 (with a one-time dev warning). Ignored with `sortingMode="server"`.
182
+ * @since v3.1
183
+ */
184
+ sortComparator?(v1: unknown, v2: unknown, params1: GridSortCellParams<R>, params2: GridSortCellParams<R>): number;
175
185
  /** Function to format a value into a human-readable string. */
176
186
  valueFormatter?(params: GridValueFormatterParams<R>): string;
177
187
  /** Custom component or element to render in the cell. */
@@ -262,6 +272,18 @@ export interface GridColumnGroup {
262
272
  export type GridColumnGroupingModel = GridColumnGroup[];
263
273
 
264
274
 
275
+ /**
276
+ * Parameters passed to `GridColDef.sortComparator` for each side of a comparison. For a row-grouping
277
+ * group row, `row` is the synthetic group row and `value` its grouping value.
278
+ * @since v3.1
279
+ */
280
+ export interface GridSortCellParams<R extends GridValidRowModel = GridRowModel> {
281
+ id: GridRowId;
282
+ field: string;
283
+ row: R;
284
+ value: unknown;
285
+ }
286
+
265
287
  /** Parameters passed to the `valueGetter` function. */
266
288
  export interface GridValueGetterParams<R extends GridValidRowModel = GridRowModel> {
267
289
  /** The row object. */
@@ -1127,6 +1149,16 @@ export interface GridApi<R extends GridValidRowModel = GridRowModel> {
1127
1149
  /** Scrolls the grid viewport to a specific row or column index. */
1128
1150
  scrollToIndexes: (params: { rowIndex?: number; colIndex?: number }) => void;
1129
1151
 
1152
+ /**
1153
+ * Sizes a column to fit its header and the cells rendered right now (the virtualization window),
1154
+ * clamped to `minWidth` (default 50px) / `maxWidth`, like double-clicking its resize handle.
1155
+ * Does nothing for `resizable: false` columns or a column that is not rendered.
1156
+ * A flex column gets a fixed width. @since v3.1
1157
+ */
1158
+ autosizeColumn: (field: string) => void;
1159
+ /** `autosizeColumn` for several columns (default: every column). @since v3.1 */
1160
+ autosizeColumns: (fields?: readonly string[]) => void;
1161
+
1130
1162
  /**
1131
1163
  * Programmatically copies all currently selected rows to the clipboard as TSV: every selected
1132
1164
  * row that passes the filter (other pages, collapsed groups and pinned rows included), with the
@@ -0,0 +1,56 @@
1
+ import { DEFAULT_MIN_COLUMN_WIDTH } from '../components/ColumnResizeHandle/clampResizeWidth';
2
+
3
+ /** The grid root an element belongs to, so a nested grid (in a detail panel) is never measured. */
4
+ const ROOT_SELECTOR = '.ogx';
5
+
6
+ /**
7
+ * The width `content` needs to show without clipping, plus the horizontal padding and borders of
8
+ * `cell`. `content` is laid out at `max-content` for the read and restored right after, so the
9
+ * reading works for a column that is too wide (shrinks) as well as one that is too narrow (grows).
10
+ */
11
+ function measureCell(cell: HTMLElement, content: HTMLElement): number {
12
+ const saved = content.style.cssText;
13
+ content.style.width = 'max-content';
14
+ content.style.minWidth = '0';
15
+ content.style.maxWidth = 'none';
16
+ content.style.flex = 'none';
17
+ const contentWidth = content.getBoundingClientRect().width;
18
+ content.style.cssText = saved;
19
+
20
+ const style = getComputedStyle(cell);
21
+ const extra = [style.paddingLeft, style.paddingRight, style.borderLeftWidth, style.borderRightWidth]
22
+ .reduce((sum, value) => sum + (parseFloat(value) || 0), 0);
23
+ return contentWidth + extra;
24
+ }
25
+
26
+ /**
27
+ * The width a column needs to fit its header and the cells rendered right now (the virtualization
28
+ * window, pinned rows included), measured in the DOM. Cells spanning several columns and cells
29
+ * hidden by a row span are skipped. Returns null when nothing of the column is rendered.
30
+ */
31
+ export function measureColumnContentWidth(root: HTMLElement, field: string): number | null {
32
+ const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(field) : field.replace(/"/g, '\\"');
33
+ const ownedByRoot = (el: Element) => el.closest(ROOT_SELECTOR) === root;
34
+ let width: number | null = null;
35
+ const take = (w: number) => { width = width === null ? w : Math.max(width, w); };
36
+
37
+ root.querySelectorAll<HTMLElement>(`[role="columnheader"][data-field="${escaped}"]`).forEach(header => {
38
+ if (!ownedByRoot(header)) return;
39
+ const content = header.querySelector<HTMLElement>('.ogx__header-cell-content');
40
+ if (content) take(measureCell(header, content));
41
+ });
42
+
43
+ root.querySelectorAll<HTMLElement>(`[role="gridcell"][data-field="${escaped}"]`).forEach(cell => {
44
+ if (!ownedByRoot(cell) || cell.hasAttribute('aria-colspan')) return;
45
+ const content = cell.querySelector<HTMLElement>(':scope > .ogx__cell-content');
46
+ if (content) take(measureCell(cell, content));
47
+ });
48
+
49
+ return width === null ? null : Math.ceil(width);
50
+ }
51
+
52
+ /** Clamps an autosized width to the column's `minWidth` (default 50px) and `maxWidth`. */
53
+ export function clampAutosizeWidth(width: number, minWidth?: number, maxWidth?: number): number {
54
+ const min = minWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
55
+ return Math.max(min, Math.min(maxWidth ?? Infinity, width));
56
+ }
@@ -18,7 +18,7 @@ import {
18
18
  isEmptyAggregateValue,
19
19
  } from '../aggregation';
20
20
  import { createRowFilter } from '../filtering';
21
- import { compareValues } from '../sorting';
21
+ import { applySortDirection, callSortComparator, compareValues } from '../sorting';
22
22
  import { buildColumnLookup } from '../columnLookup';
23
23
  import { getCellValue, getFormattedValue } from '../values';
24
24
 
@@ -256,7 +256,16 @@ export function computePivot(
256
256
  if (sortModel && sortModel.length > 0) {
257
257
  kept = [...kept].sort((a, b) => {
258
258
  for (const item of sortModel) {
259
- const c = compareValues(sortValue(a.row, item.field), sortValue(b.row, item.field), item.sort, typeOf(item.field));
259
+ const va = sortValue(a.row, item.field);
260
+ const vb = sortValue(b.row, item.field);
261
+ const comparator = isRowField(item.field) ? colDefMap.get(item.field)?.sortComparator : undefined;
262
+ const c = comparator
263
+ ? applySortDirection(callSortComparator(
264
+ comparator,
265
+ { id: a.row.id, field: item.field, row: a.row, value: va },
266
+ { id: b.row.id, field: item.field, row: b.row, value: vb },
267
+ ), item.sort)
268
+ : compareValues(va, vb, item.sort, typeOf(item.field));
260
269
  if (c !== 0) return c;
261
270
  }
262
271
  return 0;
@@ -1,7 +1,7 @@
1
1
 
2
- import type { GridColDef, GridRowModel, GridSortDirection, GridSortItem } from '../../types';
2
+ import type { GridColDef, GridRowId, GridRowModel, GridSortCellParams, GridSortDirection, GridSortItem } from '../../types';
3
3
  import type { GridColumnLookup } from '../columnLookup';
4
- import { getCellValue, toDate, toNumber } from '../values';
4
+ import { getCellValue, toDate, toNumber, warnCallbackFailure } from '../values';
5
5
 
6
6
  /**
7
7
  * Language-aware string order: accented letters sort next to their base letter instead of after
@@ -68,7 +68,8 @@ function compareSortKeys(a: SortKey, b: SortKey): number {
68
68
  return a.num < b.num ? -1 : a.num > b.num ? 1 : 0;
69
69
  }
70
70
 
71
- function applyDirection(comparison: number, direction: 'asc' | 'desc'): number {
71
+ /** Reverse an ascending comparison for 'desc'. */
72
+ export function applySortDirection(comparison: number, direction: 'asc' | 'desc'): number {
72
73
  if (comparison === 0) return 0;
73
74
  return direction === 'asc' ? comparison : -comparison;
74
75
  }
@@ -83,27 +84,62 @@ export function compareValues(
83
84
  direction: 'asc' | 'desc',
84
85
  type?: GridColDef['type']
85
86
  ): number {
86
- return applyDirection(compareSortKeys(toSortKey(a, type), toSortKey(b, type)), direction);
87
+ return applySortDirection(compareSortKeys(toSortKey(a, type), toSortKey(b, type)), direction);
87
88
  }
88
89
 
90
+ /** A column's `sortComparator`, as the sorting utils call it. */
91
+ export type GridSortComparatorFn = NonNullable<GridColDef['sortComparator']>;
92
+
93
+ /**
94
+ * Call a consumer `sortComparator`, containing failures: a throw or a non-finite result reads as 0
95
+ * (equal), with a one-time dev warning per column, so one bad comparison cannot break the sort.
96
+ */
97
+ export function callSortComparator(
98
+ comparator: GridSortComparatorFn,
99
+ a: GridSortCellParams,
100
+ b: GridSortCellParams
101
+ ): number {
102
+ let result: number;
103
+ try {
104
+ result = comparator(a.value, b.value, a, b);
105
+ } catch (error) {
106
+ warnCallbackFailure(`sortComparator:${a.field}`, `sortComparator for column "${a.field}" threw; the values are treated as equal`, error);
107
+ return 0;
108
+ }
109
+ if (typeof result !== 'number' || Number.isNaN(result)) {
110
+ warnCallbackFailure(`sortComparator:${a.field}:nan`, `sortComparator for column "${a.field}" returned ${String(result)}; the values are treated as equal`, undefined);
111
+ return 0;
112
+ }
113
+ return result;
114
+ }
115
+
116
+ const defaultRowId = (row: GridRowModel): GridRowId => row.id;
117
+
89
118
  /**
90
119
  * Compare two rows by a sort model, reading cells through `valueGetter` and using each column's
91
- * `type`. Returns 0 when every key is equal.
120
+ * `type` (or its `sortComparator`). Returns 0 when every key is equal.
92
121
  */
93
122
  export function compareRowsBySortModel(
94
123
  a: GridRowModel,
95
124
  b: GridRowModel,
96
125
  sortModel: readonly GridSortItem[],
97
- columns?: GridColumnLookup
126
+ columns?: GridColumnLookup,
127
+ getRowId: (row: GridRowModel) => GridRowId = defaultRowId
98
128
  ): number {
99
129
  for (const sortItem of sortModel) {
100
130
  const colDef = columns?.byField.get(sortItem.field);
101
- const comparison = compareValues(
102
- getCellValue(a, sortItem.field, colDef),
103
- getCellValue(b, sortItem.field, colDef),
104
- sortItem.sort,
105
- colDef?.type
106
- );
131
+ const va = getCellValue(a, sortItem.field, colDef);
132
+ const vb = getCellValue(b, sortItem.field, colDef);
133
+ const comparison = colDef?.sortComparator
134
+ ? applySortDirection(
135
+ callSortComparator(
136
+ colDef.sortComparator,
137
+ { id: getRowId(a), field: sortItem.field, row: a, value: va },
138
+ { id: getRowId(b), field: sortItem.field, row: b, value: vb }
139
+ ),
140
+ sortItem.sort
141
+ )
142
+ : compareValues(va, vb, sortItem.sort, colDef?.type);
107
143
  if (comparison !== 0) return comparison;
108
144
  }
109
145
  return 0;
@@ -112,28 +148,42 @@ export function compareRowsBySortModel(
112
148
  export function sortRows<R extends GridRowModel>(
113
149
  rows: R[],
114
150
  sortModel: GridSortItem[],
115
- columns?: GridColumnLookup
151
+ columns?: GridColumnLookup,
152
+ getRowId: (row: R) => GridRowId = defaultRowId
116
153
  ): R[] {
117
154
  if (sortModel.length === 0) {
118
155
  return rows;
119
156
  }
120
157
  return sortItemsBySortModel(rows, sortModel, (row, sortItem) => {
121
158
  const colDef = columns?.byField.get(sortItem.field);
122
- return { value: getCellValue(row, sortItem.field, colDef), type: colDef?.type };
159
+ const value = getCellValue(row, sortItem.field, colDef);
160
+ return {
161
+ value,
162
+ type: colDef?.type,
163
+ comparator: colDef?.sortComparator,
164
+ params: colDef?.sortComparator ? { id: getRowId(row), field: sortItem.field, row, value } : undefined,
165
+ };
123
166
  });
124
167
  }
125
168
 
126
- /** The value an item sorts by for one sort key, and the column type used to compare it. */
169
+ /** The value an item sorts by for one sort key, and how to compare it. */
127
170
  export interface GridSortValue {
128
171
  value: unknown;
129
172
  type?: GridColDef['type'];
173
+ /**
174
+ * The column's `sortComparator`. Two items are compared with it when both carry the same one
175
+ * (with their `params`); otherwise the built-in comparison of `value` / `type` applies.
176
+ */
177
+ comparator?: GridSortComparatorFn;
178
+ /** The params passed to `comparator` for this item; `value` here is what the comparator sees. */
179
+ params?: GridSortCellParams;
130
180
  }
131
181
 
132
182
  /**
133
- * Stable sort of any items by a sort model. `readValue` gives the value (and column type) an item
134
- * sorts by for each sort key; it is called once per item and key, not once per comparison. Used by
135
- * the flat pipeline and by tree data / row grouping, whose synthetic group rows sort by their
136
- * grouping value or label instead of a cell.
183
+ * Stable sort of any items by a sort model. `readValue` gives the value (and column type or
184
+ * comparator) an item sorts by for each sort key; it is called once per item and key, not once per
185
+ * comparison. Used by the flat pipeline and by tree data / row grouping, whose synthetic group rows
186
+ * sort by their grouping value or label instead of a cell.
137
187
  */
138
188
  export function sortItemsBySortModel<T>(
139
189
  items: readonly T[],
@@ -146,15 +196,18 @@ export function sortItemsBySortModel<T>(
146
196
 
147
197
  // Read and normalize every sort value once, instead of calling valueGetter and parsing dates
148
198
  // O(n log n) times inside the comparator.
149
- const keys = sortModel.map(sortItem => items.map(item => {
150
- const { value, type } = readValue(item, sortItem);
151
- return toSortKey(value, type);
152
- }));
199
+ const values = sortModel.map(sortItem => items.map(item => readValue(item, sortItem)));
200
+ const keys = values.map(column => column.map(({ value, type }) => toSortKey(value, type)));
153
201
 
154
202
  const order = items.map((_, index) => index);
155
203
  order.sort((ia, ib) => {
156
204
  for (let k = 0; k < sortModel.length; k++) {
157
- const comparison = applyDirection(compareSortKeys(keys[k][ia], keys[k][ib]), sortModel[k].sort);
205
+ const a = values[k][ia];
206
+ const b = values[k][ib];
207
+ const ascending = a.comparator && a.comparator === b.comparator && a.params && b.params
208
+ ? callSortComparator(a.comparator, a.params, b.params)
209
+ : compareSortKeys(keys[k][ia], keys[k][ib]);
210
+ const comparison = applySortDirection(ascending, sortModel[k].sort);
158
211
  if (comparison !== 0) return comparison;
159
212
  }
160
213
  return ia - ib;