@opencorestack/opengridx 2.1.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/CHANGELOG.md +399 -0
  2. package/README.md +95 -72
  3. package/dist/index.d.ts +590 -131
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7891 -5631
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +17 -23
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +348 -166
  10. package/docs/README.md +6 -1
  11. package/docs/architecture/datagrid-orchestration.md +81 -0
  12. package/docs/architecture/grid-row-meta.md +102 -0
  13. package/docs/architecture/use-grid-columns.md +128 -0
  14. package/docs/architecture/use-grid-controlled-state.md +157 -0
  15. package/docs/architecture/use-grid-row-pipeline.md +120 -0
  16. package/docs/architecture/use-grid-scroll-sync.md +111 -0
  17. package/docs/architecture/use-grid-state-snapshot.md +90 -0
  18. package/docs/architecture/use-grid-virtualization.md +115 -0
  19. package/docs/architecture/use-grid-visible-rows.md +85 -0
  20. package/docs/assets/banner-alt.png +0 -0
  21. package/docs/assets/banner.png +0 -0
  22. package/docs/assets/hero.png +0 -0
  23. package/docs/assets/logo.png +0 -0
  24. package/docs/components/aggregation-footer.md +15 -10
  25. package/docs/components/cell.md +16 -6
  26. package/docs/components/column-group-header.md +55 -28
  27. package/docs/components/column-resize.md +17 -13
  28. package/docs/components/column-visibility.md +6 -7
  29. package/docs/components/datagrid.md +15 -13
  30. package/docs/components/empty-state.md +2 -1
  31. package/docs/components/error-overlay.md +12 -7
  32. package/docs/components/filter-panel.md +28 -8
  33. package/docs/components/header.md +28 -9
  34. package/docs/components/pagination.md +14 -8
  35. package/docs/components/row.md +21 -5
  36. package/docs/components/toolbar.md +27 -9
  37. package/docs/components/tooltip.md +16 -7
  38. package/docs/customization/slots-api.md +90 -38
  39. package/docs/customization/theming.md +66 -18
  40. package/docs/features/aggregation-pivot.md +75 -9
  41. package/docs/features/cell-spanning.md +80 -125
  42. package/docs/features/clipboard.md +41 -16
  43. package/docs/features/custom-pagination.md +38 -18
  44. package/docs/features/data-source.md +36 -13
  45. package/docs/features/editing-reordering.md +125 -16
  46. package/docs/features/export-guide.md +126 -39
  47. package/docs/features/filtering.md +44 -6
  48. package/docs/features/infinite-scroll.md +54 -46
  49. package/docs/features/keyboard-navigation.md +83 -0
  50. package/docs/features/list-view.md +58 -5
  51. package/docs/features/loading-states.md +18 -20
  52. package/docs/features/master-detail.md +18 -5
  53. package/docs/features/pdf-export.md +72 -19
  54. package/docs/features/pinning.md +24 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +64 -14
  57. package/docs/features/state-persistence.md +78 -10
  58. package/docs/features/toolbar-customization.md +26 -17
  59. package/docs/features/tree-data-grouping.md +36 -8
  60. package/docs/features/virtualization.md +37 -14
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1233 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +5 -5
  65. package/lib/components/Cell/Cell.css +17 -1
  66. package/lib/components/Cell/Cell.tsx +129 -57
  67. package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
  68. package/lib/components/Cell/GridEditInputCell.tsx +114 -41
  69. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
  70. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +15 -2
  71. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
  72. package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
  73. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
  74. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
  75. package/lib/components/DataGrid/DataGrid.css +85 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +582 -876
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  79. package/lib/components/DataGrid/GridListView.tsx +165 -24
  80. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  81. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  82. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  83. package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
  84. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  85. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  86. package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
  87. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  88. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  89. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  90. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  91. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  92. package/lib/components/Header/ColumnMenu.tsx +91 -39
  93. package/lib/components/Header/Header.css +6 -3
  94. package/lib/components/Header/Header.tsx +181 -160
  95. package/lib/components/ListView/ListView.css +25 -0
  96. package/lib/components/ListView/ListViewRow.tsx +76 -10
  97. package/lib/components/Pagination/Pagination.tsx +15 -7
  98. package/lib/components/Row/Row.tsx +177 -77
  99. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  100. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  101. package/lib/components/Toolbar/GridToolbar.css +12 -11
  102. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  103. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  104. package/lib/components/Tooltip/Tooltip.css +20 -0
  105. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  106. package/lib/components/ui/Button.tsx +3 -0
  107. package/lib/components/ui/Checkbox.tsx +2 -1
  108. package/lib/components/ui/ExpandIcon.css +0 -9
  109. package/lib/components/ui/ExpandIcon.tsx +4 -1
  110. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  111. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  112. package/lib/hooks/core/useDataGrid.ts +115 -173
  113. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  114. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  115. package/lib/hooks/core/useGridApiRef.ts +19 -4
  116. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  117. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  118. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  119. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  120. package/lib/hooks/core/useGridColumns.tsx +279 -76
  121. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  122. package/lib/hooks/core/useGridControlledState.ts +99 -6
  123. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  124. package/lib/hooks/core/useGridDevWarnings.ts +104 -5
  125. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  126. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  127. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  128. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  129. package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
  130. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  131. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  132. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  133. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  134. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  135. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  136. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  137. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  138. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  139. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  140. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  141. package/lib/hooks/core/useGridViewport.ts +38 -0
  142. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  143. package/lib/hooks/core/useGridVisibleRows.ts +17 -5
  144. package/lib/hooks/core/useLayout.ts +215 -195
  145. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  146. package/lib/hooks/core/useStableColumns.ts +34 -0
  147. package/lib/hooks/features/useAggregation.ts +82 -51
  148. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  149. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  150. package/lib/hooks/features/useGridClipboard.ts +133 -73
  151. package/lib/hooks/features/useGridDataSource.ts +354 -128
  152. package/lib/hooks/features/useGridEditing.ts +211 -51
  153. package/lib/hooks/features/useGridPivot.ts +92 -0
  154. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  155. package/lib/hooks/features/useGridSpanning.ts +410 -306
  156. package/lib/hooks/features/usePivot.ts +0 -0
  157. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  158. package/lib/hooks/useColumnReorder.ts +38 -44
  159. package/lib/hooks/useRowGrouping.ts +298 -160
  160. package/lib/hooks/useRowReorder.ts +62 -31
  161. package/lib/hooks/useTreeData.ts +287 -220
  162. package/lib/index.ts +28 -2
  163. package/lib/state/index.ts +1 -0
  164. package/lib/state/types.ts +7 -1
  165. package/lib/state/useGridStateStorage.ts +88 -29
  166. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  167. package/lib/theme/gridThemeContext.ts +5 -0
  168. package/lib/theme/index.ts +5 -0
  169. package/lib/theme/presets.ts +5 -4
  170. package/lib/theme/types.ts +40 -2
  171. package/lib/types/index.ts +313 -116
  172. package/lib/utils/aggregation/index.ts +119 -24
  173. package/lib/utils/aria/index.ts +40 -0
  174. package/lib/utils/columnGroups.ts +110 -0
  175. package/lib/utils/columnLookup.ts +33 -0
  176. package/lib/utils/columnWidth.ts +22 -0
  177. package/lib/utils/dataSource/index.ts +46 -0
  178. package/lib/utils/detailPanel.ts +66 -0
  179. package/lib/utils/dragData.ts +44 -0
  180. package/lib/utils/editing/index.ts +30 -0
  181. package/lib/utils/export/exportShared.ts +197 -0
  182. package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
  183. package/lib/utils/export/exportToPdf.ts +143 -92
  184. package/lib/utils/export/groupLabel.ts +14 -0
  185. package/lib/utils/export/index.ts +278 -272
  186. package/lib/utils/export/pdfText.ts +50 -0
  187. package/lib/utils/filtering/index.ts +277 -148
  188. package/lib/utils/focus/index.ts +115 -0
  189. package/lib/utils/gridApiRows.ts +35 -0
  190. package/lib/utils/gridModes.ts +77 -0
  191. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  192. package/lib/utils/pagination/index.ts +56 -0
  193. package/lib/utils/pinning/index.ts +77 -33
  194. package/lib/utils/pivot/index.ts +368 -0
  195. package/lib/utils/scroll/index.ts +55 -5
  196. package/lib/utils/scroll/scrollEnd.ts +18 -0
  197. package/lib/utils/sorting/index.ts +154 -30
  198. package/lib/utils/syntheticRows.ts +12 -0
  199. package/lib/utils/values.ts +206 -0
  200. package/lib/utils/viewport.ts +14 -0
  201. package/llms.txt +261 -164
  202. package/package.json +13 -5
  203. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  204. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  205. package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
  206. package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
  207. package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
  208. package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
  209. package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
  210. package/lib/components/Header/Header.test.tsx +0 -135
  211. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  212. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  213. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  214. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  215. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  216. package/lib/hooks/features/useAggregation.test.ts +0 -175
  217. package/lib/hooks/features/usePivot.test.ts +0 -202
  218. package/lib/hooks/useRowGrouping.test.ts +0 -298
  219. package/lib/hooks/useTreeData.test.ts +0 -83
  220. package/lib/utils/aggregation/aggregation.test.ts +0 -16
  221. package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
  222. package/lib/utils/export/exportToPdf.test.ts +0 -120
  223. package/lib/utils/export/groupedExport.test.ts +0 -144
  224. package/lib/utils/filtering/filtering.test.ts +0 -178
  225. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -0,0 +1,368 @@
1
+ import type {
2
+ GridColDef,
3
+ GridFilterGroup,
4
+ GridFilterItem,
5
+ GridFilterModel,
6
+ GridPivotAggFn,
7
+ GridPivotModel,
8
+ GridPivotValueField,
9
+ GridRenderCellParams,
10
+ GridRowId,
11
+ GridRowModel,
12
+ GridSortItem,
13
+ } from '../../types';
14
+ import {
15
+ AGGREGATION_FUNCTIONS,
16
+ formatAggregateForColumn,
17
+ isAggregationAllowed,
18
+ isEmptyAggregateValue,
19
+ } from '../aggregation';
20
+ import { createRowFilter } from '../filtering';
21
+ import { compareValues } from '../sorting';
22
+ import { buildColumnLookup } from '../columnLookup';
23
+ import { getCellValue, getFormattedValue } from '../values';
24
+
25
+ /** Row id of the Grand Total row that closes every pivot. */
26
+ export const PIVOT_GRAND_TOTAL_ID = '__pivot_grand_total__';
27
+ /**
28
+ * Prefix of every pivot data row id. Pivot rows are keyed by their row-field values
29
+ * (`__pivot_row__:["North","Q1"]`), so their ids never collide with source row ids (a selection
30
+ * made on the source rows never marks an unrelated pivot row) and stay the same for the same
31
+ * group when the source data changes.
32
+ */
33
+ export const PIVOT_ROW_ID_PREFIX = '__pivot_row__:';
34
+ const pivotRowId = (groupKey: string): string => `${PIVOT_ROW_ID_PREFIX}${JSON.stringify(groupKey.split(SEP_COL))}`;
35
+ const GRAND_TOTAL_LABEL = 'Grand Total';
36
+
37
+ // Joins the values of several column fields into one column key.
38
+ const SEP_COL = '\u0000';
39
+ // Separates the parts of a generated value-column field: `[colKey␟]field␟aggFn`.
40
+ export const PIVOT_FIELD_SEPARATOR = '\u001f';
41
+ // A row field named `id` cannot keep its label under `id`, which must stay the unique pivot row id.
42
+ const ID_LABEL_KEY = `${PIVOT_FIELD_SEPARATOR}id`;
43
+ const labelKey = (field: string) => (field === 'id' ? ID_LABEL_KEY : field);
44
+
45
+ export interface PivotResult {
46
+ /** Pivot data rows followed by the Grand Total row (omitted when there are no data rows). */
47
+ pivotRows: GridRowModel[];
48
+ /** Row-label columns (one per row field) followed by one value column per column key and value field. */
49
+ pivotColumns: GridColDef[];
50
+ /** The distinct column-field value combinations, in display order. */
51
+ colKeys: string[];
52
+ /** `false` when the model has no usable row field or value field. */
53
+ isValid: boolean;
54
+ }
55
+
56
+ export interface PivotOptions {
57
+ /**
58
+ * Filter applied to the generated pivot rows (conditions on generated value columns). Rows it
59
+ * removes are also left out of the Grand Total, so the total always matches the rows shown.
60
+ */
61
+ outputFilterModel?: GridFilterModel;
62
+ /** Sort applied to the pivot data rows. The Grand Total row always stays last. */
63
+ sortModel?: GridSortItem[];
64
+ }
65
+
66
+ export const EMPTY_PIVOT_RESULT: PivotResult = { pivotRows: [], pivotColumns: [], colKeys: [], isValid: false };
67
+
68
+ // Groups are keyed by each value's display string, as row grouping does, so 1 and '1' share a group;
69
+ // null, undefined and '' all key to '' and form one blank group.
70
+ const keyOf = (values: unknown[]): string => values.map((v) => String(v ?? '')).join(SEP_COL);
71
+
72
+ // The blank group is labelled null whichever blank value was seen first.
73
+ const normalizeLabel = (v: unknown): unknown => (v === undefined || v === '' ? null : v);
74
+
75
+ // Column-field values are ordered by their raw value: numbers and dates numerically, strings
76
+ // naturally ('9' before '10'), blanks last.
77
+ function compareDimension(a: unknown, b: unknown): number {
78
+ const aBlank = a == null;
79
+ const bBlank = b == null;
80
+ if (aBlank || bBlank) return aBlank === bBlank ? 0 : aBlank ? 1 : -1;
81
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
82
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
83
+ if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b);
84
+ return String(a).localeCompare(String(b), undefined, { numeric: true });
85
+ }
86
+
87
+ function compareTuples(a: unknown[], b: unknown[]): number {
88
+ for (let i = 0; i < a.length; i++) {
89
+ const c = compareDimension(a[i], b[i]);
90
+ if (c !== 0) return c;
91
+ }
92
+ return 0;
93
+ }
94
+
95
+ // A number, or a Date for min / max of dates.
96
+ function aggregate(values: unknown[] | undefined, fn: GridPivotAggFn): number | Date | null {
97
+ if (!values || values.length === 0) return null;
98
+ const result = AGGREGATION_FUNCTIONS[fn](values);
99
+ return typeof result === 'number' || result instanceof Date ? result : null;
100
+ }
101
+
102
+ const isFilterGroup = (item: GridFilterItem | GridFilterGroup): item is GridFilterGroup =>
103
+ 'logicOperator' in item && 'items' in item;
104
+
105
+ const hasFilterItems = (model?: GridFilterModel): model is GridFilterModel =>
106
+ Boolean(model && model.items && model.items.length > 0);
107
+
108
+ /**
109
+ * Splits a filter model for pivot mode. Conditions on generated value columns (fields the source
110
+ * columns do not have) can only be evaluated on pivot rows; everything else, including the quick
111
+ * filter and conditions on row-field columns, is applied to the source rows before pivoting.
112
+ */
113
+ export function splitPivotFilterModel(
114
+ filterModel: GridFilterModel,
115
+ sourceFields: ReadonlySet<string>,
116
+ ): { source: GridFilterModel; output: GridFilterModel } {
117
+ const isGenerated = (field: string) => !sourceFields.has(field) && field.includes(PIVOT_FIELD_SEPARATOR);
118
+ const targetsPivotRows = (item: GridFilterItem | GridFilterGroup): boolean =>
119
+ isFilterGroup(item)
120
+ ? item.items.length > 0 && item.items.every(targetsPivotRows)
121
+ : isGenerated(item.field);
122
+
123
+ const sourceItems: (GridFilterItem | GridFilterGroup)[] = [];
124
+ const outputItems: (GridFilterItem | GridFilterGroup)[] = [];
125
+ for (const item of filterModel.items ?? []) {
126
+ (targetsPivotRows(item) ? outputItems : sourceItems).push(item);
127
+ }
128
+ return {
129
+ source: { ...filterModel, items: sourceItems },
130
+ output: { items: outputItems, logicOperator: filterModel.logicOperator },
131
+ };
132
+ }
133
+
134
+ interface GroupEntry {
135
+ labels: unknown[];
136
+ /** First source row of the group: the `row` given to the row-field column's formatter and renderer. */
137
+ sample: GridRowModel;
138
+ /** Column key → value field → raw values. */
139
+ cells: Map<string, Map<string, unknown[]>>;
140
+ }
141
+
142
+ interface ColumnKeyInfo {
143
+ values: unknown[];
144
+ /** First source row with this column key: the `row` given to the header label's formatter. */
145
+ sample?: GridRowModel;
146
+ }
147
+
148
+ /**
149
+ * Builds pivot rows and columns from source rows. Values are read through each column's
150
+ * `valueGetter`, aggregated with the shared `lib/utils/aggregation` functions, and formatted with
151
+ * `formatAggregateForColumn` (the source column's `valueFormatter` for sum / avg / min / max, with the
152
+ * pivot row as its `row`, as for group rows and exports). Row and column fields on a
153
+ * `groupable: false` column, and value fields on an `aggregable: false` column or whose function
154
+ * the column's `availableAggregationFunctions` does not allow, are skipped.
155
+ */
156
+ export function computePivot(
157
+ rows: GridRowModel[],
158
+ columns: GridColDef[],
159
+ model: GridPivotModel,
160
+ options: PivotOptions = {},
161
+ ): PivotResult {
162
+ const colDefMap = new Map(columns.map((c) => [c.field, c]));
163
+ const isGroupable = (f: string) => colDefMap.get(f)?.groupable !== false;
164
+ const rowFields = model.rowFields.filter(isGroupable);
165
+ const columnFields = model.columnFields.filter(isGroupable);
166
+
167
+ const seenValueFields = new Set<string>();
168
+ const valueFields: GridPivotValueField[] = model.valueFields.filter((vf) => {
169
+ if (!isAggregationAllowed(colDefMap.get(vf.field), vf.aggFn)) return false;
170
+ const id = `${vf.field}${PIVOT_FIELD_SEPARATOR}${vf.aggFn}`;
171
+ if (seenValueFields.has(id)) return false;
172
+ seenValueFields.add(id);
173
+ return true;
174
+ });
175
+
176
+ if (rowFields.length === 0 || valueFields.length === 0) return EMPTY_PIVOT_RESULT;
177
+
178
+ const hasColFields = columnFields.length > 0;
179
+ // Raw values are collected once per distinct field, however many functions use it.
180
+ const valueSourceFields = Array.from(new Set(valueFields.map((vf) => vf.field)));
181
+ const get = (row: GridRowModel, field: string) => getCellValue(row, field, colDefMap.get(field));
182
+
183
+ const groups = new Map<string, GroupEntry>();
184
+ // Without column fields there is exactly one column key, present even when there is no data.
185
+ const colKeyInfo = new Map<string, ColumnKeyInfo>(hasColFields ? [] : [['', { values: [] }]]);
186
+
187
+ for (const row of rows) {
188
+ const labelValues = rowFields.map((f) => get(row, f));
189
+ const rk = keyOf(labelValues);
190
+ let entry = groups.get(rk);
191
+ if (!entry) {
192
+ entry = { labels: labelValues.map(normalizeLabel), sample: row, cells: new Map() };
193
+ groups.set(rk, entry);
194
+ }
195
+
196
+ const colValues = columnFields.map((f) => get(row, f));
197
+ const ck = hasColFields ? keyOf(colValues) : '';
198
+ const info = colKeyInfo.get(ck);
199
+ if (!info) colKeyInfo.set(ck, { values: colValues.map(normalizeLabel), sample: row });
200
+ else if (!info.sample) info.sample = row;
201
+
202
+ let cell = entry.cells.get(ck);
203
+ if (!cell) {
204
+ cell = new Map();
205
+ entry.cells.set(ck, cell);
206
+ }
207
+ for (const f of valueSourceFields) {
208
+ const v = get(row, f);
209
+ if (isEmptyAggregateValue(v)) continue;
210
+ let bucket = cell.get(f);
211
+ if (!bucket) {
212
+ bucket = [];
213
+ cell.set(f, bucket);
214
+ }
215
+ bucket.push(v);
216
+ }
217
+ }
218
+
219
+ const colKeys = Array.from(colKeyInfo.keys()).sort((a, b) =>
220
+ compareTuples(colKeyInfo.get(a)!.values, colKeyInfo.get(b)!.values));
221
+
222
+ const cellField = (ck: string, vf: GridPivotValueField) => hasColFields
223
+ ? `${ck}${PIVOT_FIELD_SEPARATOR}${vf.field}${PIVOT_FIELD_SEPARATOR}${vf.aggFn}`
224
+ : `${vf.field}${PIVOT_FIELD_SEPARATOR}${vf.aggFn}`;
225
+
226
+ // ── Data rows ────────────────────────────────────────────────────────────
227
+ const entryByRowId = new Map<GridRowId, GroupEntry>();
228
+ let kept: { row: GridRowModel; entry: GroupEntry }[] = [];
229
+ for (const [groupKey, entry] of groups) {
230
+ const row: GridRowModel = { id: pivotRowId(groupKey) };
231
+ rowFields.forEach((f, i) => { row[labelKey(f)] = entry.labels[i]; });
232
+ row.id = pivotRowId(groupKey);
233
+ for (const ck of colKeys) {
234
+ const cell = entry.cells.get(ck);
235
+ for (const vf of valueFields) row[cellField(ck, vf)] = aggregate(cell?.get(vf.field), vf.aggFn);
236
+ }
237
+ entryByRowId.set(row.id, entry);
238
+ kept.push({ row, entry });
239
+ }
240
+
241
+ // Generated value columns are numeric (or dates for min / max of dates); row-label columns keep
242
+ // their source column's type, so filtering and sorting compare them the way the grid does.
243
+ const isRowField = (field: string) => rowFields.includes(field);
244
+ const typeOf = (field: string): GridColDef['type'] => (isRowField(field) ? colDefMap.get(field)?.type : 'number');
245
+
246
+ const { outputFilterModel, sortModel } = options;
247
+ if (hasFilterItems(outputFilterModel)) {
248
+ const generatedColumns: GridColDef[] = [];
249
+ for (const ck of colKeys) for (const vf of valueFields) generatedColumns.push({ field: cellField(ck, vf), type: 'number' });
250
+ const predicate = createRowFilter(outputFilterModel, buildColumnLookup(generatedColumns));
251
+ if (predicate) kept = kept.filter(({ row }) => predicate(row));
252
+ }
253
+
254
+ const sortValue = (row: GridRowModel, field: string) =>
255
+ field === 'id' && isRowField('id') ? row[ID_LABEL_KEY] : row[field];
256
+ if (sortModel && sortModel.length > 0) {
257
+ kept = [...kept].sort((a, b) => {
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));
260
+ if (c !== 0) return c;
261
+ }
262
+ return 0;
263
+ });
264
+ }
265
+
266
+ // ── Grand Total: aggregated from the raw values of the rows shown ────────
267
+ const pivotRows = kept.map((k) => k.row);
268
+ if (kept.length > 0) {
269
+ const total: GridRowModel = { id: PIVOT_GRAND_TOTAL_ID };
270
+ rowFields.forEach((f, i) => { total[labelKey(f)] = i === 0 ? GRAND_TOTAL_LABEL : ''; });
271
+ total.id = PIVOT_GRAND_TOTAL_ID;
272
+ for (const ck of colKeys) {
273
+ for (const f of valueSourceFields) {
274
+ const values: unknown[] = [];
275
+ for (const { entry } of kept) {
276
+ const bucket = entry.cells.get(ck)?.get(f);
277
+ if (bucket) for (const v of bucket) values.push(v);
278
+ }
279
+ for (const vf of valueFields) {
280
+ if (vf.field === f) total[cellField(ck, vf)] = aggregate(values, vf.aggFn);
281
+ }
282
+ }
283
+ }
284
+ pivotRows.push(total);
285
+ }
286
+
287
+ // ── Columns ──────────────────────────────────────────────────────────────
288
+ const firstSample = kept[0]?.entry.sample;
289
+ const groupSample = (id: GridRowId | undefined): GridRowModel | undefined =>
290
+ id === PIVOT_GRAND_TOTAL_ID ? firstSample : id === undefined ? undefined : entryByRowId.get(id)?.sample;
291
+
292
+ const pivotColumns: GridColDef[] = rowFields.map((f) => buildRowFieldColumn(f, colDefMap.get(f), groupSample));
293
+
294
+ for (const ck of colKeys) {
295
+ const info = colKeyInfo.get(ck)!;
296
+ const colLabel = hasColFields
297
+ ? columnFields.map((f, i) => {
298
+ const def = colDefMap.get(f);
299
+ const shown = formatDimension(def, f, info.values[i], info.sample);
300
+ return def?.headerName ? `${def.headerName}: ${shown}` : shown;
301
+ }).join(' / ')
302
+ : '';
303
+
304
+ for (const vf of valueFields) {
305
+ const orig = colDefMap.get(vf.field);
306
+ const labelBase = vf.headerName ?? orig?.headerName ?? vf.field;
307
+ pivotColumns.push({
308
+ field: cellField(ck, vf),
309
+ headerName: colLabel ? `${colLabel} — ${labelBase} (${vf.aggFn})` : `${labelBase} (${vf.aggFn})`,
310
+ width: 140,
311
+ type: 'number',
312
+ align: 'right',
313
+ headerAlign: 'right',
314
+ sortable: true,
315
+ valueFormatter: ({ value, row }) => formatAggregateForColumn(value, vf.aggFn, orig, row),
316
+ });
317
+ }
318
+ }
319
+
320
+ return { pivotRows, pivotColumns, colKeys, isValid: true };
321
+ }
322
+
323
+ function formatDimension(def: GridColDef | undefined, field: string, value: unknown, sample?: GridRowModel): string {
324
+ const formatted = sample ? getFormattedValue(sample, field, value, def) : undefined;
325
+ if (formatted !== undefined) return formatted;
326
+ return value == null ? '' : String(value);
327
+ }
328
+
329
+ // A row-label column keeps how the source column displays its values (formatter, renderer, type,
330
+ // alignment) and is always shown: the row fields are what the pivot rows are labelled by.
331
+ function buildRowFieldColumn(
332
+ field: string,
333
+ orig: GridColDef | undefined,
334
+ groupSample: (id: GridRowId | undefined) => GridRowModel | undefined,
335
+ ): GridColDef {
336
+ const col: GridColDef = {
337
+ field,
338
+ headerName: orig?.headerName ?? field,
339
+ width: orig?.width ?? 140,
340
+ sortable: true,
341
+ hideable: false,
342
+ };
343
+ if (orig) {
344
+ if (orig.type !== undefined) col.type = orig.type;
345
+ if (orig.align !== undefined) col.align = orig.align;
346
+ if (orig.headerAlign !== undefined) col.headerAlign = orig.headerAlign;
347
+ if (orig.description !== undefined) col.description = orig.description;
348
+ if (orig.minWidth !== undefined) col.minWidth = orig.minWidth;
349
+ if (orig.maxWidth !== undefined) col.maxWidth = orig.maxWidth;
350
+ if (orig.valueOptions !== undefined) col.valueOptions = orig.valueOptions;
351
+ }
352
+ if (field === 'id') col.valueGetter = ({ row }) => row[ID_LABEL_KEY];
353
+
354
+ const isTotal = (row: GridRowModel | undefined) => row?.id === PIVOT_GRAND_TOTAL_ID;
355
+ const sourceFormatter = orig?.valueFormatter;
356
+ if (sourceFormatter) {
357
+ col.valueFormatter = ({ value, row }) => isTotal(row)
358
+ ? String(value ?? '')
359
+ : sourceFormatter({ value, row: groupSample(row?.id) ?? row, field });
360
+ }
361
+ const sourceRenderer = orig?.renderCell;
362
+ if (sourceRenderer) {
363
+ col.renderCell = (params: GridRenderCellParams) => isTotal(params.row)
364
+ ? (params.formattedValue ?? String(params.value ?? ''))
365
+ : sourceRenderer({ ...params, row: groupSample(params.row.id) ?? params.row });
366
+ }
367
+ return col;
368
+ }
@@ -1,13 +1,28 @@
1
+ /**
2
+ * Height of the sticky block at the bottom of the viewport (bottom-pinned rows, their expanded
3
+ * detail panels and the aggregation footer), measured from the DOM.
4
+ */
5
+ function stickyBottomHeight(viewport: HTMLElement): number {
6
+ const block = viewport.querySelector<HTMLElement>('.ogx__sticky-bottom');
7
+ return block ? block.getBoundingClientRect().height : 0;
8
+ }
9
+
1
10
  /**
2
11
  * Vertically scrolls the grid viewport so the center (unpinned) row at `centerIndex`
3
- * is fully visible below the sticky header / top-pinned rows and above the
4
- * bottom-pinned rows. `cumulativeHeights` is the unpinned-row layout.
12
+ * is fully visible below the sticky header / top-pinned rows and above the sticky
13
+ * bottom block (bottom-pinned rows and aggregation footer). `cumulativeHeights` is the
14
+ * unpinned-row layout; `pinnedBottomHeight` is a lower bound for the bottom block.
15
+ *
16
+ * `cumulativeHeights` includes each row's expanded detail panel. Pass `rowHeight` to bring
17
+ * only the row itself into view: otherwise a panel taller than the viewport pushes its own
18
+ * row out of view above the header.
5
19
  */
6
20
  export function scrollRowIntoView(
7
21
  viewport: HTMLElement,
8
22
  centerIndex: number,
9
23
  cumulativeHeights: number[],
10
24
  pinnedBottomHeight: number,
25
+ rowHeight?: number,
11
26
  ): void {
12
27
  if (centerIndex < 0 || centerIndex >= cumulativeHeights.length) return;
13
28
  const virtualContainer = viewport.querySelector<HTMLElement>('.ogx__virtual-container');
@@ -17,12 +32,47 @@ export function scrollRowIntoView(
17
32
  // Everything above the center rows is sticky, so it covers exactly this many
18
33
  // pixels at the top of the viewport at any scroll position.
19
34
  const stickyTop = virtualContainer.getBoundingClientRect().top - viewport.getBoundingClientRect().top + scrollTop;
35
+ const bottomCover = Math.max(pinnedBottomHeight, stickyBottomHeight(viewport));
20
36
  const rowTop = stickyTop + (centerIndex === 0 ? 0 : cumulativeHeights[centerIndex - 1]);
21
- const rowBottom = stickyTop + cumulativeHeights[centerIndex];
37
+ const rowBottom = rowHeight !== undefined
38
+ ? Math.min(rowTop + rowHeight, stickyTop + cumulativeHeights[centerIndex])
39
+ : stickyTop + cumulativeHeights[centerIndex];
22
40
 
23
41
  if (rowTop < scrollTop + stickyTop) {
24
42
  viewport.scrollTop = Math.max(0, rowTop - stickyTop);
25
- } else if (rowBottom > scrollTop + clientHeight - pinnedBottomHeight) {
26
- viewport.scrollTop = rowBottom - clientHeight + pinnedBottomHeight;
43
+ } else if (rowBottom > scrollTop + clientHeight - bottomCover) {
44
+ viewport.scrollTop = rowBottom - clientHeight + bottomCover;
45
+ }
46
+ }
47
+
48
+ export interface GridColumnScrollMetrics {
49
+ /** Cumulative widths of the unpinned columns (layout). */
50
+ unpinnedAccWidths: number[];
51
+ leftWidth: number;
52
+ rightWidth: number;
53
+ /** Width of all system columns (they precede the data columns). */
54
+ systemColumnsWidth: number;
55
+ /** Width of the system columns that are sticky. */
56
+ pinnedSystemColumnsWidth: number;
57
+ }
58
+
59
+ /**
60
+ * Horizontally scrolls the viewport so the unpinned column at `unpinnedIndex` is fully visible
61
+ * between the sticky left block (pinned system columns + left-pinned columns) and the
62
+ * right-pinned columns.
63
+ */
64
+ export function scrollColumnIntoView(viewport: HTMLElement, unpinnedIndex: number, metrics: GridColumnScrollMetrics): void {
65
+ const { unpinnedAccWidths, leftWidth, rightWidth, systemColumnsWidth, pinnedSystemColumnsWidth } = metrics;
66
+ if (unpinnedIndex < 0 || unpinnedIndex >= unpinnedAccWidths.length) return;
67
+ const contentStart = systemColumnsWidth + leftWidth;
68
+ const colLeft = contentStart + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
69
+ const colRight = contentStart + unpinnedAccWidths[unpinnedIndex];
70
+ const stickyLeft = pinnedSystemColumnsWidth + leftWidth;
71
+ const { scrollLeft, clientWidth } = viewport;
72
+
73
+ if (colLeft < scrollLeft + stickyLeft) {
74
+ viewport.scrollLeft = Math.max(0, colLeft - stickyLeft);
75
+ } else if (colRight > scrollLeft + clientWidth - rightWidth) {
76
+ viewport.scrollLeft = colRight - clientWidth + rightWidth;
27
77
  }
28
78
  }
@@ -0,0 +1,18 @@
1
+ import type { GridRowScrollEndParams } from '../../types';
2
+
3
+ /** How close (px) to the bottom of a scroll container counts as the end for `onRowsScrollEnd`. */
4
+ export const ROWS_SCROLL_END_THRESHOLD = 100;
5
+
6
+ /**
7
+ * The `onRowsScrollEnd` params for a scrolled row container, or null while it is further than
8
+ * ROWS_SCROLL_END_THRESHOLD from the bottom. Shared by the grid viewport and the list view.
9
+ */
10
+ export function getRowsScrollEndParams(el: HTMLElement): GridRowScrollEndParams | null {
11
+ const { scrollTop, scrollHeight, clientHeight } = el;
12
+ if (scrollHeight - scrollTop - clientHeight >= ROWS_SCROLL_END_THRESHOLD) return null;
13
+ return {
14
+ visibleTop: scrollTop,
15
+ visibleBottom: scrollTop + clientHeight,
16
+ viewportHeight: clientHeight,
17
+ };
18
+ }
@@ -1,55 +1,179 @@
1
1
 
2
- import type { GridRowModel, GridSortItem } from '../../types';
2
+ import type { GridColDef, GridRowModel, GridSortDirection, GridSortItem } from '../../types';
3
+ import type { GridColumnLookup } from '../columnLookup';
4
+ import { getCellValue, toDate, toNumber } from '../values';
3
5
 
4
- export function compareValues(a: unknown, b: unknown, direction: 'asc' | 'desc'): number {
6
+ /**
7
+ * Language-aware string order: accented letters sort next to their base letter instead of after
8
+ * 'z', case is ignored, and digit runs compare by value ('item9' before 'item10').
9
+ */
10
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'accent' });
5
11
 
6
- if (a == null && b == null) return 0;
7
- if (a == null) return direction === 'asc' ? 1 : -1;
8
- if (b == null) return direction === 'asc' ? -1 : 1;
12
+ // Values of different kinds are ordered by kind first, so the comparator stays transitive when a
13
+ // column mixes, say, numbers and strings.
14
+ const RANK_NUMBER = 0;
15
+ const RANK_DATE = 1;
16
+ const RANK_BOOLEAN = 2;
17
+ const RANK_STRING = 3;
18
+ const RANK_OTHER = 4;
9
19
 
10
- if (typeof a === 'number' && typeof b === 'number') {
11
- return direction === 'asc' ? a - b : b - a;
12
- }
20
+ /** A normalized sort value. null is the empty bucket (null, undefined, NaN, Invalid Date, unparsable). */
21
+ type SortKey = { rank: number; num: number; str: string } | null;
22
+
23
+ function numberKey(rank: number, num: number): SortKey {
24
+ return { rank, num, str: '' };
25
+ }
13
26
 
14
- if (a instanceof Date && b instanceof Date) {
15
- return direction === 'asc'
16
- ? a.getTime() - b.getTime()
17
- : b.getTime() - a.getTime();
27
+ /**
28
+ * Normalize a cell value for sorting. `type: 'number'` columns coerce numeric strings ('10' sorts
29
+ * after '9'), `type: 'date'` columns parse date strings; values that cannot be read go to the empty
30
+ * bucket together with null.
31
+ */
32
+ function toSortKey(value: unknown, type?: GridColDef['type']): SortKey {
33
+ if (value == null) return null;
34
+ if (type === 'number') {
35
+ const n = toNumber(value);
36
+ return n === null ? null : numberKey(RANK_NUMBER, n);
37
+ }
38
+ if (type === 'date') {
39
+ const date = toDate(value);
40
+ return date ? numberKey(RANK_DATE, date.getTime()) : null;
41
+ }
42
+ switch (typeof value) {
43
+ case 'number':
44
+ return Number.isNaN(value) ? null : numberKey(RANK_NUMBER, value);
45
+ case 'bigint':
46
+ return numberKey(RANK_NUMBER, Number(value));
47
+ case 'boolean':
48
+ return numberKey(RANK_BOOLEAN, value ? 1 : 0);
49
+ case 'string':
50
+ return { rank: RANK_STRING, num: 0, str: value };
51
+ default:
52
+ break;
18
53
  }
54
+ if (value instanceof Date) {
55
+ const t = value.getTime();
56
+ return Number.isNaN(t) ? null : numberKey(RANK_DATE, t);
57
+ }
58
+ return { rank: RANK_OTHER, num: 0, str: String(value) };
59
+ }
60
+
61
+ /** Ascending comparison of two keys. The empty bucket sorts after every value. Never returns NaN. */
62
+ function compareSortKeys(a: SortKey, b: SortKey): number {
63
+ if (a === null && b === null) return 0;
64
+ if (a === null) return 1;
65
+ if (b === null) return -1;
66
+ if (a.rank !== b.rank) return a.rank - b.rank;
67
+ if (a.rank === RANK_STRING || a.rank === RANK_OTHER) return collator.compare(a.str, b.str);
68
+ return a.num < b.num ? -1 : a.num > b.num ? 1 : 0;
69
+ }
19
70
 
20
- const aStr = String(a).toLowerCase();
21
- const bStr = String(b).toLowerCase();
71
+ function applyDirection(comparison: number, direction: 'asc' | 'desc'): number {
72
+ if (comparison === 0) return 0;
73
+ return direction === 'asc' ? comparison : -comparison;
74
+ }
75
+
76
+ /**
77
+ * Compare two cell values. Empty values (null, undefined, NaN, Invalid Date) sort last in 'asc'
78
+ * and first in 'desc'. Pass the column `type` to get numeric / date coercion of string values.
79
+ */
80
+ export function compareValues(
81
+ a: unknown,
82
+ b: unknown,
83
+ direction: 'asc' | 'desc',
84
+ type?: GridColDef['type']
85
+ ): number {
86
+ return applyDirection(compareSortKeys(toSortKey(a, type), toSortKey(b, type)), direction);
87
+ }
22
88
 
23
- if (direction === 'asc') {
24
- return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
25
- } else {
26
- return aStr > bStr ? -1 : aStr < bStr ? 1 : 0;
89
+ /**
90
+ * 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.
92
+ */
93
+ export function compareRowsBySortModel(
94
+ a: GridRowModel,
95
+ b: GridRowModel,
96
+ sortModel: readonly GridSortItem[],
97
+ columns?: GridColumnLookup
98
+ ): number {
99
+ for (const sortItem of sortModel) {
100
+ 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
+ );
107
+ if (comparison !== 0) return comparison;
27
108
  }
109
+ return 0;
28
110
  }
29
111
 
30
112
  export function sortRows<R extends GridRowModel>(
31
113
  rows: R[],
32
- sortModel: GridSortItem[]
114
+ sortModel: GridSortItem[],
115
+ columns?: GridColumnLookup
33
116
  ): R[] {
34
117
  if (sortModel.length === 0) {
35
118
  return rows;
36
119
  }
120
+ return sortItemsBySortModel(rows, sortModel, (row, sortItem) => {
121
+ const colDef = columns?.byField.get(sortItem.field);
122
+ return { value: getCellValue(row, sortItem.field, colDef), type: colDef?.type };
123
+ });
124
+ }
37
125
 
38
- const sortedRows = [...rows];
126
+ /** The value an item sorts by for one sort key, and the column type used to compare it. */
127
+ export interface GridSortValue {
128
+ value: unknown;
129
+ type?: GridColDef['type'];
130
+ }
39
131
 
40
- sortedRows.sort((a, b) => {
41
- for (const sortItem of sortModel) {
42
- const aValue = a[sortItem.field];
43
- const bValue = b[sortItem.field];
132
+ /**
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.
137
+ */
138
+ export function sortItemsBySortModel<T>(
139
+ items: readonly T[],
140
+ sortModel: readonly GridSortItem[],
141
+ readValue: (item: T, sortItem: GridSortItem) => GridSortValue
142
+ ): T[] {
143
+ if (sortModel.length === 0 || items.length < 2) {
144
+ return [...items];
145
+ }
44
146
 
45
- const comparison = compareValues(aValue, bValue, sortItem.sort);
147
+ // Read and normalize every sort value once, instead of calling valueGetter and parsing dates
148
+ // 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
+ }));
46
153
 
47
- if (comparison !== 0) {
48
- return comparison;
49
- }
154
+ const order = items.map((_, index) => index);
155
+ order.sort((ia, ib) => {
156
+ for (let k = 0; k < sortModel.length; k++) {
157
+ const comparison = applyDirection(compareSortKeys(keys[k][ia], keys[k][ib]), sortModel[k].sort);
158
+ if (comparison !== 0) return comparison;
50
159
  }
51
- return 0;
160
+ return ia - ib;
52
161
  });
53
162
 
54
- return sortedRows;
163
+ return order.map(index => items[index]);
164
+ }
165
+
166
+ /**
167
+ * The sort model after setting one column's direction while keeping the others: an existing key
168
+ * keeps its priority and only changes direction, a new key is appended, and `null` removes it.
169
+ */
170
+ export function upsertSortItem(
171
+ sortModel: readonly GridSortItem[],
172
+ field: string,
173
+ direction: GridSortDirection
174
+ ): GridSortItem[] {
175
+ if (!direction) return sortModel.filter(item => item.field !== field);
176
+ const index = sortModel.findIndex(item => item.field === field);
177
+ if (index === -1) return [...sortModel, { field, sort: direction }];
178
+ return sortModel.map((item, i) => (i === index ? { field, sort: direction } : item));
55
179
  }