@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
@@ -1,4 +1,4 @@
1
- import type { GridApi } from '../../types';
1
+ import type { GridApi, GridRowModel, GridValidRowModel } from '../../types';
2
2
 
3
3
  const noop = () => {};
4
4
 
@@ -7,7 +7,7 @@ const noop = () => {};
7
7
  * `apiRef.current` is never null (its type says it is not) before the grid has mounted;
8
8
  * the grid replaces it with the live API when it mounts.
9
9
  */
10
- export function createGridApiPlaceholder(): GridApi {
10
+ export function createGridApiPlaceholder<R extends GridValidRowModel = GridRowModel>(): GridApi<R> {
11
11
  return {
12
12
  getRow: () => null,
13
13
  getAllRows: () => [],
@@ -29,6 +29,8 @@ export function createGridApiPlaceholder(): GridApi {
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
  }
@@ -1,6 +1,6 @@
1
1
  import { useRef, useState } from 'react';
2
2
  import type { MutableRefObject } from 'react';
3
- import type { GridApi } from '../../types';
3
+ import type { GridApi, GridRowModel, GridValidRowModel } from '../../types';
4
4
  import { createGridApiPlaceholder } from './gridApiPlaceholder';
5
5
 
6
6
  /**
@@ -15,8 +15,11 @@ import { createGridApiPlaceholder } from './gridApiPlaceholder';
15
15
  * which matches `DataGridProps.apiRef` under both @types/react 18 and 19. Left to inference,
16
16
  * React 19's types emit `RefObject<GridApi>`, which React 18's types treat as read-only and
17
17
  * nullable, so `apiRef={useGridApiRef()}` failed to type-check on React 18.
18
+ *
19
+ * Pass your row type to have the row getters typed: `useGridApiRef<Invoice>().current.getRow(id)`
20
+ * returns `Invoice | null`.
18
21
  */
19
- export function useGridApiRef(): MutableRefObject<GridApi> {
20
- const [placeholder] = useState(createGridApiPlaceholder);
21
- return useRef<GridApi>(placeholder);
22
+ export function useGridApiRef<R extends GridValidRowModel = GridRowModel>(): MutableRefObject<GridApi<R>> {
23
+ const [placeholder] = useState(createGridApiPlaceholder<R>);
24
+ return useRef<GridApi<R>>(placeholder);
22
25
  }
@@ -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
+ }
@@ -14,11 +14,16 @@ export interface UseGridDevWarningsParams {
14
14
  scrollHeight?: number;
15
15
  /** `listView` is set but `listViewColumn` is not, so the grid view is shown. */
16
16
  listViewWithoutColumn?: boolean;
17
+ /** Number of items the list view renders (0 when the list view is not shown). It does not virtualize. */
18
+ listViewRowCount?: number;
17
19
  }
18
20
 
19
21
  // Below this many rows, rendering everything is harmless, so an unbounded container is not worth a warning.
20
22
  const UNBOUNDED_WARN_THRESHOLD = 200;
21
23
 
24
+ /** The list view renders every item; above this many the page slows down noticeably. */
25
+ export const LIST_VIEW_WARN_THRESHOLD = 2000;
26
+
22
27
  /**
23
28
  * Browsers cap element heights (Firefox at about 17.9M px, Chromium and Safari at about 33.5M px):
24
29
  * rows laid out below the cap cannot be scrolled to.
@@ -26,12 +31,22 @@ const UNBOUNDED_WARN_THRESHOLD = 200;
26
31
  export const MAX_SCROLLABLE_HEIGHT = 17_800_000;
27
32
 
28
33
  export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
29
- const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, listViewWithoutColumn = false } = params;
34
+ const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, listViewWithoutColumn = false, listViewRowCount = 0 } = params;
30
35
  const warnedPaginationRef = useRef(false);
31
36
  const warnedUnboundedRef = useRef(false);
32
37
  const warnedPinnedRowsRef = useRef(false);
33
38
  const warnedHeightRef = useRef(false);
34
39
  const warnedListViewRef = useRef(false);
40
+ const warnedListViewSizeRef = useRef(false);
41
+
42
+ useEffect(() => {
43
+ if (process.env.NODE_ENV === 'production' || warnedListViewSizeRef.current || listViewRowCount <= LIST_VIEW_WARN_THRESHOLD) return;
44
+ warnedListViewSizeRef.current = true;
45
+ console.warn(
46
+ `[OpenGridX] The list view is rendering all ${listViewRowCount} items: it does not virtualize. ` +
47
+ 'Above a few thousand items use `pagination`, server-side loading, or the grid view. See docs/features/list-view.md.'
48
+ );
49
+ }, [listViewRowCount]);
35
50
 
36
51
  useEffect(() => {
37
52
  if (process.env.NODE_ENV === 'production' || warnedListViewRef.current || !listViewWithoutColumn) return;
@@ -74,15 +89,18 @@ export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
74
89
 
75
90
  useEffect(() => {
76
91
  if (process.env.NODE_ENV === 'production' || warnedUnboundedRef.current || autoHeight) return;
77
- if (viewportHeight > 0 && totalRowCount > UNBOUNDED_WARN_THRESHOLD && renderedRowCount >= totalRowCount) {
92
+ // Really unbounded: every row is rendered and the viewport is at least as tall as the rows, so it never scrolls.
93
+ if (viewportHeight > 0 && totalRowCount > UNBOUNDED_WARN_THRESHOLD && renderedRowCount >= totalRowCount && viewportHeight >= scrollHeight) {
78
94
  warnedUnboundedRef.current = true;
79
95
  console.warn(
80
96
  `[OpenGridX] The grid viewport is ${Math.round(viewportHeight)}px tall and is rendering all ${totalRowCount} rows, ` +
81
- 'so row virtualization is effectively off. The grid container has no bounded height: it grows to fit its ' +
82
- 'content. Give it a fixed height, or if it is a flex child add `min-height: 0`. See docs/features/virtualization.md.'
97
+ 'so row virtualization is effectively off: the grid fills its container, and the container grows to fit every row. ' +
98
+ 'Give the container a definite height (for example `height: 600px` or `100vh`). If the container, or any ancestor ' +
99
+ 'up to the sized one, is a flex or grid item, add `min-height: 0` to it. Alternatively pass a `height` prop, ' +
100
+ 'or `autoHeight` if rendering every row is intended. See docs/features/virtualization.md.'
83
101
  );
84
102
  }
85
- }, [autoHeight, viewportHeight, renderedRowCount, totalRowCount]);
103
+ }, [autoHeight, viewportHeight, renderedRowCount, totalRowCount, scrollHeight]);
86
104
  }
87
105
 
88
106
  /**
@@ -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. */
@@ -912,8 +934,11 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
912
934
  /** Callback fired when scrolling reaches the bottom of the grid viewport. */
913
935
  onRowsScrollEnd?: (params: GridRowScrollEndParams) => void;
914
936
 
915
- /** Advanced: Reactive reference to the internal API for imperative control. */
916
- apiRef?: React.MutableRefObject<GridApi>;
937
+ /**
938
+ * Advanced: reactive reference to the internal API for imperative control. Pass
939
+ * `useGridApiRef<MyRow>()` to have the row getters typed as `MyRow`; a plain `useGridApiRef()` also works.
940
+ */
941
+ apiRef?: React.MutableRefObject<GridApi<R>> | React.MutableRefObject<GridApi>;
917
942
 
918
943
  /** Custom components to replace internal grid parts. */
919
944
  slots?: {
@@ -1052,18 +1077,22 @@ export type GridDetailPanelHeight = number | 'auto';
1052
1077
  * The Imperative API for interacting with the DataGrid.
1053
1078
  * Access this via the `apiRef` prop or a ref passed to the component.
1054
1079
  */
1055
- export interface GridApi {
1080
+ /**
1081
+ * The imperative grid API. `R` types the row getters (`getRow`, `getAllRows`, `getVisibleRows`,
1082
+ * `getAllFilteredRows`); it defaults to `GridRowModel`, so `GridApi` alone keeps working.
1083
+ */
1084
+ export interface GridApi<R extends GridValidRowModel = GridRowModel> {
1056
1085
  /**
1057
1086
  * Returns the row model with the given ID.
1058
1087
  * @param id The row unique ID.
1059
1088
  */
1060
- getRow: (id: GridRowId) => GridRowModel | null;
1089
+ getRow: (id: GridRowId) => R | null;
1061
1090
  /** Returns all rows currently loaded into the grid. */
1062
- getAllRows: () => GridRowModel[];
1091
+ getAllRows: () => R[];
1063
1092
  /** Returns all rows currently visible after filtering and sorting. */
1064
- getVisibleRows: () => GridRowModel[];
1093
+ getVisibleRows: () => R[];
1065
1094
  /** Returns all filtered and sorted rows, ignoring pagination. Use this for full-dataset exports. */
1066
- getAllFilteredRows: () => GridRowModel[];
1095
+ getAllFilteredRows: () => R[];
1067
1096
  /**
1068
1097
  * Returns an ordered flat list of `GridGroupedExportRow` entries that mirrors the
1069
1098
  * on-screen grouping tree: group-headers, leaf rows, per-group subtotals, and a
@@ -1120,6 +1149,16 @@ export interface GridApi {
1120
1149
  /** Scrolls the grid viewport to a specific row or column index. */
1121
1150
  scrollToIndexes: (params: { rowIndex?: number; colIndex?: number }) => void;
1122
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
+
1123
1162
  /**
1124
1163
  * Programmatically copies all currently selected rows to the clipboard as TSV: every selected
1125
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;