@opencorestack/opengridx 2.0.4 → 3.0.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 (221) hide show
  1. package/CHANGELOG.md +408 -3
  2. package/README.md +93 -72
  3. package/dist/index.d.ts +611 -121
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +7992 -5650
  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 -161
  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 +45 -22
  29. package/docs/components/datagrid.md +14 -12
  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 +100 -36
  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 +147 -41
  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 +56 -19
  54. package/docs/features/pinning.md +23 -5
  55. package/docs/features/selection.md +50 -10
  56. package/docs/features/sorting-pagination.md +66 -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 +44 -9
  60. package/docs/features/virtualization.md +64 -15
  61. package/docs/migration/v1-to-v2.md +115 -0
  62. package/docs/migration/v2-to-v3.md +1222 -0
  63. package/docs/performance.md +124 -0
  64. package/docs/roadmap.md +16 -10
  65. package/lib/components/Cell/Cell.css +13 -1
  66. package/lib/components/Cell/Cell.tsx +130 -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 +11 -0
  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 +84 -12
  76. package/lib/components/DataGrid/DataGrid.tsx +606 -872
  77. package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
  78. package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
  79. package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
  80. package/lib/components/DataGrid/GridListView.tsx +166 -23
  81. package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
  82. package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
  83. package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
  84. package/lib/components/DataGrid/GridPinnedRows.tsx +65 -11
  85. package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
  86. package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
  87. package/lib/components/DataGrid/GridVirtualRows.tsx +48 -25
  88. package/lib/components/DetailPanel/DetailPanel.css +0 -6
  89. package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
  90. package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
  91. package/lib/components/FilterPanel/FilterPanel.css +13 -2
  92. package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
  93. package/lib/components/Header/ColumnMenu.tsx +91 -39
  94. package/lib/components/Header/Header.css +3 -3
  95. package/lib/components/Header/Header.tsx +179 -160
  96. package/lib/components/ListView/ListView.css +25 -0
  97. package/lib/components/ListView/ListViewRow.tsx +79 -10
  98. package/lib/components/Pagination/Pagination.tsx +15 -7
  99. package/lib/components/Row/Row.tsx +185 -75
  100. package/lib/components/Toolbar/GlobalSearch.css +6 -3
  101. package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
  102. package/lib/components/Toolbar/GridToolbar.css +12 -11
  103. package/lib/components/Toolbar/GridToolbar.tsx +133 -89
  104. package/lib/components/Toolbar/PivotPanel.tsx +89 -49
  105. package/lib/components/Tooltip/Tooltip.css +20 -0
  106. package/lib/components/Tooltip/Tooltip.tsx +68 -35
  107. package/lib/components/ui/Button.tsx +3 -0
  108. package/lib/components/ui/Checkbox.tsx +2 -1
  109. package/lib/components/ui/ExpandIcon.css +0 -9
  110. package/lib/components/ui/ExpandIcon.tsx +4 -1
  111. package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
  112. package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
  113. package/lib/hooks/core/useDataGrid.ts +115 -173
  114. package/lib/hooks/core/useGridAggregationApi.ts +72 -0
  115. package/lib/hooks/core/useGridApiMethods.ts +115 -0
  116. package/lib/hooks/core/useGridApiRef.ts +15 -3
  117. package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
  118. package/lib/hooks/core/useGridAriaRows.ts +38 -0
  119. package/lib/hooks/core/useGridClipboardApi.ts +38 -0
  120. package/lib/hooks/core/useGridColumnLookup.ts +30 -0
  121. package/lib/hooks/core/useGridColumns.tsx +284 -76
  122. package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
  123. package/lib/hooks/core/useGridControlledState.ts +99 -6
  124. package/lib/hooks/core/useGridDetailPanel.ts +51 -0
  125. package/lib/hooks/core/useGridDevWarnings.ts +123 -0
  126. package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
  127. package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
  128. package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
  129. package/lib/hooks/core/useGridHierarchy.ts +81 -0
  130. package/lib/hooks/core/useGridKeyboardNavigation.ts +452 -250
  131. package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
  132. package/lib/hooks/core/useGridPageCorrection.ts +55 -0
  133. package/lib/hooks/core/useGridRowIdOf.ts +32 -0
  134. package/lib/hooks/core/useGridRowInteractions.ts +81 -0
  135. package/lib/hooks/core/useGridRowPipeline.ts +69 -39
  136. package/lib/hooks/core/useGridRowSelection.ts +91 -0
  137. package/lib/hooks/core/useGridScrollSync.ts +80 -32
  138. package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
  139. package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
  140. package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
  141. package/lib/hooks/core/useGridToolbarProps.ts +97 -0
  142. package/lib/hooks/core/useGridViewport.ts +38 -0
  143. package/lib/hooks/core/useGridViewportSize.ts +26 -0
  144. package/lib/hooks/core/useGridVisibleRows.ts +20 -7
  145. package/lib/hooks/core/useLayout.ts +215 -195
  146. package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
  147. package/lib/hooks/core/useStableColumns.ts +34 -0
  148. package/lib/hooks/features/useAggregation.ts +86 -90
  149. package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
  150. package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
  151. package/lib/hooks/features/useGridClipboard.ts +133 -73
  152. package/lib/hooks/features/useGridDataSource.ts +354 -128
  153. package/lib/hooks/features/useGridEditing.ts +211 -51
  154. package/lib/hooks/features/useGridPivot.ts +92 -0
  155. package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
  156. package/lib/hooks/features/useGridSpanning.ts +410 -306
  157. package/lib/hooks/features/usePivot.ts +0 -0
  158. package/lib/hooks/features/useServerTreeChildren.ts +50 -0
  159. package/lib/hooks/useColumnReorder.ts +38 -44
  160. package/lib/hooks/useRowGrouping.ts +335 -214
  161. package/lib/hooks/useRowReorder.ts +62 -31
  162. package/lib/hooks/useTreeData.ts +330 -262
  163. package/lib/index.ts +30 -2
  164. package/lib/state/index.ts +1 -0
  165. package/lib/state/types.ts +7 -1
  166. package/lib/state/useGridStateStorage.ts +88 -29
  167. package/lib/theme/DataGridThemeProvider.tsx +143 -38
  168. package/lib/theme/gridThemeContext.ts +5 -0
  169. package/lib/theme/index.ts +5 -0
  170. package/lib/theme/presets.ts +5 -4
  171. package/lib/theme/types.ts +40 -2
  172. package/lib/types/index.ts +311 -108
  173. package/lib/utils/aggregation/index.ts +157 -0
  174. package/lib/utils/aria/index.ts +40 -0
  175. package/lib/utils/columnGroups.ts +110 -0
  176. package/lib/utils/columnLookup.ts +33 -0
  177. package/lib/utils/columnWidth.ts +22 -0
  178. package/lib/utils/dataSource/index.ts +46 -0
  179. package/lib/utils/detailPanel.ts +66 -0
  180. package/lib/utils/dragData.ts +44 -0
  181. package/lib/utils/editing/index.ts +30 -0
  182. package/lib/utils/export/exportShared.ts +197 -0
  183. package/lib/utils/export/exportToExcelAdvanced.ts +344 -121
  184. package/lib/utils/export/exportToPdf.ts +143 -92
  185. package/lib/utils/export/groupLabel.ts +14 -0
  186. package/lib/utils/export/index.ts +278 -272
  187. package/lib/utils/export/pdfText.ts +50 -0
  188. package/lib/utils/filtering/index.ts +277 -148
  189. package/lib/utils/focus/index.ts +115 -0
  190. package/lib/utils/gridApiRows.ts +35 -0
  191. package/lib/utils/gridModes.ts +77 -0
  192. package/lib/utils/grouping/groupedExportRows.ts +91 -0
  193. package/lib/utils/pagination/index.ts +56 -0
  194. package/lib/utils/pinning/index.ts +77 -33
  195. package/lib/utils/pivot/index.ts +368 -0
  196. package/lib/utils/scroll/index.ts +78 -0
  197. package/lib/utils/scroll/scrollEnd.ts +18 -0
  198. package/lib/utils/sorting/index.ts +154 -30
  199. package/lib/utils/syntheticRows.ts +12 -0
  200. package/lib/utils/values.ts +206 -0
  201. package/lib/utils/viewport.ts +14 -0
  202. package/llms.txt +272 -166
  203. package/package.json +18 -6
  204. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
  205. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
  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/Header/Header.test.tsx +0 -135
  210. package/lib/components/Pagination/Pagination.test.tsx +0 -64
  211. package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
  212. package/lib/hooks/core/useGridControlledState.test.ts +0 -86
  213. package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
  214. package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
  215. package/lib/hooks/features/useAggregation.test.ts +0 -175
  216. package/lib/hooks/features/usePivot.test.ts +0 -202
  217. package/lib/hooks/useRowGrouping.test.ts +0 -137
  218. package/lib/utils/export/exportToPdf.test.ts +0 -120
  219. package/lib/utils/export/groupedExport.test.ts +0 -144
  220. package/lib/utils/filtering/filtering.test.ts +0 -178
  221. package/lib/utils/sorting/sorting.test.ts +0 -88
@@ -1,7 +1,7 @@
1
-
2
1
  import { useMemo, useEffect, useRef, useCallback, useState } from 'react';
3
2
  import type {
4
3
  GridRowModel,
4
+ GridValidRowModel,
5
5
  GridColDef,
6
6
  GridAggregationModel,
7
7
  GridAggregationResult,
@@ -9,45 +9,14 @@ import type {
9
9
  GridSortItem,
10
10
  GridDataSource,
11
11
  } from '../../types';
12
+ import { computeAggregations } from '../../utils/aggregation';
12
13
 
13
- export type BuiltInAggFn = 'sum' | 'avg' | 'count' | 'min' | 'max' | 'unique';
14
-
15
- const AGGREGATION_FUNCTIONS: Record<BuiltInAggFn, (values: unknown[]) => unknown> = {
16
- sum: (values) => {
17
- const nums = values.filter((v) => v != null && !isNaN(Number(v)));
18
- return nums.reduce((acc: number, v) => acc + Number(v), 0);
19
- },
20
- avg: (values) => {
21
- const nums = values.filter((v) => v != null && !isNaN(Number(v)));
22
- if (nums.length === 0) return null;
23
- return nums.reduce((acc: number, v) => acc + Number(v), 0) / nums.length;
24
- },
25
- count: (values) => values.filter((v) => v != null).length,
26
- min: (values) => {
27
- const nums = values.filter((v) => v != null && !isNaN(Number(v))).map(Number);
28
- return nums.length ? Math.min(...nums) : null;
29
- },
30
- max: (values) => {
31
- const nums = values.filter((v) => v != null && !isNaN(Number(v))).map(Number);
32
- return nums.length ? Math.max(...nums) : null;
33
- },
34
- unique: (values) => new Set(values.filter((v) => v != null)).size,
35
- };
36
-
37
- export function formatAggregationValue(value: unknown, fnName: string): string {
38
- if (value == null) return '—';
39
- if (typeof value === 'number') {
40
- if (fnName === 'avg') {
41
- return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
42
- }
43
- return value.toLocaleString();
44
- }
45
- return String(value);
46
- }
14
+ export type { BuiltInAggFn } from '../../utils/aggregation';
15
+ export { formatAggregationValue } from '../../utils/aggregation';
47
16
 
48
- export interface UseAggregationParams<R extends GridRowModel> {
17
+ export interface UseAggregationParams<R extends GridValidRowModel = GridRowModel> {
49
18
  rows: R[];
50
- columns?: GridColDef[];
19
+ columns?: GridColDef<R>[];
51
20
  aggregationModel: GridAggregationModel;
52
21
  isServerSide: boolean;
53
22
  dataSource?: GridDataSource<R>;
@@ -62,6 +31,37 @@ export interface UseAggregationReturn {
62
31
  error: unknown;
63
32
  }
64
33
 
34
+ const EMPTY_RESULT: GridAggregationResult = {};
35
+
36
+ interface ServerAggregationState {
37
+ /** The request (model, filter, sort) this outcome belongs to. */
38
+ key: string;
39
+ result: GridAggregationResult | null;
40
+ error: unknown;
41
+ }
42
+
43
+ /**
44
+ * State for totals that arrive with a getRows response. The stored results are tagged with the
45
+ * aggregation model they were computed for and read back as `null` once the model changes, so the
46
+ * footer never shows a total under the wrong function.
47
+ */
48
+ export function useServerAggregationResults(
49
+ aggregationModel: GridAggregationModel,
50
+ ): [GridAggregationResult | null, (results: GridAggregationResult) => void] {
51
+ const modelKey = JSON.stringify(aggregationModel);
52
+ const [state, setState] = useState<{ modelKey: string; results: GridAggregationResult } | null>(null);
53
+ const setResults = useCallback(
54
+ (results: GridAggregationResult) => setState({ modelKey, results }),
55
+ [modelKey],
56
+ );
57
+ return [state && state.modelKey === modelKey ? state.results : null, setResults];
58
+ }
59
+
60
+ export function useAggregation<R extends GridValidRowModel = GridRowModel>(params: UseAggregationParams<R>): UseAggregationReturn;
61
+ export function useAggregation<R extends GridValidRowModel = GridRowModel>(
62
+ params: Omit<UseAggregationParams<R>, 'columns'> & { columns?: GridColDef[] },
63
+ ): UseAggregationReturn;
64
+ // Internally rows are read as GridRowModel records; the signature above is the public one.
65
65
  export function useAggregation<R extends GridRowModel>(
66
66
  params: UseAggregationParams<R>
67
67
  ): UseAggregationReturn {
@@ -76,6 +76,8 @@ export function useAggregation<R extends GridRowModel>(
76
76
  serverAggregationResults,
77
77
  } = params;
78
78
 
79
+ const hasModel = Object.keys(aggregationModel).length > 0;
80
+
79
81
  const columnsLookup = useMemo(() => {
80
82
  const map = new Map<string, GridColDef>();
81
83
  (columns ?? []).forEach(col => map.set(col.field, col));
@@ -83,65 +85,59 @@ export function useAggregation<R extends GridRowModel>(
83
85
  }, [columns]);
84
86
 
85
87
  const clientResult = useMemo<GridAggregationResult>(() => {
86
- if (isServerSide || Object.keys(aggregationModel).length === 0) return {};
87
-
88
- const result: GridAggregationResult = {};
89
- for (const [field, fnName] of Object.entries(aggregationModel)) {
90
- const colDef = columnsLookup.get(field);
91
- if (colDef?.availableAggregationFunctions && !colDef.availableAggregationFunctions.includes(fnName)) {
92
- continue;
93
- }
94
- const fn = AGGREGATION_FUNCTIONS[fnName as BuiltInAggFn];
95
- if (!fn) {
96
- console.warn(`[useAggregation] Unknown aggregation function: "${fnName}"`);
97
- continue;
98
- }
99
- const values = rows.map((row) => (row as GridRowModel)[field]);
100
- result[field] = fn(values);
101
- }
102
- return result;
103
- }, [rows, columnsLookup, aggregationModel, isServerSide]);
88
+ if (isServerSide || !hasModel) return EMPTY_RESULT;
89
+
90
+ return computeAggregations(rows, aggregationModel, columnsLookup, 'useAggregation');
91
+ }, [rows, columnsLookup, aggregationModel, isServerSide, hasModel]);
104
92
 
105
- const [serverResult, setServerResult] = useState<GridAggregationResult>({});
106
- const [isLoading, setIsLoading] = useState(false);
107
- const [error, setError] = useState<unknown>(null);
108
- const requestIdRef = useRef(0);
93
+ // Requests are identified by content, so an inline `aggregationModel={{ salary: 'sum' }}` (a new
94
+ // object on every parent render) neither refetches nor drops the result it already has.
95
+ const requestKey = JSON.stringify([aggregationModel, filterModel ?? null, sortModel ?? []]);
96
+ const shouldFetch = isServerSide && hasModel && Boolean(dataSource?.getAggregations) && !serverAggregationResults;
109
97
 
110
- const fetchServerAggregations = useCallback(async () => {
111
- if (!dataSource?.getAggregations || Object.keys(aggregationModel).length === 0) return;
98
+ const latestRequestRef = useRef({ aggregationModel, filterModel, sortModel });
99
+ useEffect(() => {
100
+ latestRequestRef.current = { aggregationModel, filterModel, sortModel };
101
+ });
112
102
 
113
- const requestId = ++requestIdRef.current;
114
- setIsLoading(true);
115
- setError(null);
103
+ const [serverState, setServerState] = useState<ServerAggregationState | null>(null);
104
+
105
+ useEffect(() => {
106
+ const getAggregations = dataSource?.getAggregations;
107
+ if (!shouldFetch || !getAggregations) return;
116
108
 
109
+ let cancelled = false;
110
+ const request = latestRequestRef.current;
111
+ let pending: Promise<GridAggregationResult>;
117
112
  try {
118
- const result = await dataSource.getAggregations({
119
- sortModel: sortModel ?? [],
120
- filterModel: filterModel ?? { items: [] },
113
+ pending = Promise.resolve(getAggregations({
114
+ sortModel: request.sortModel ?? [],
115
+ filterModel: request.filterModel ?? { items: [] },
121
116
  groupKeys: [],
122
- aggregationModel,
123
- });
124
-
125
- if (requestId === requestIdRef.current) {
126
- setServerResult(result);
127
- }
117
+ aggregationModel: request.aggregationModel,
118
+ }));
128
119
  } catch (err) {
129
- if (requestId === requestIdRef.current) {
130
- setError(err);
131
- console.error('[useAggregation] Server aggregation error:', err);
132
- }
133
- } finally {
134
- if (requestId === requestIdRef.current) {
135
- setIsLoading(false);
136
- }
137
- }
138
- }, [dataSource, aggregationModel, filterModel, sortModel]);
139
-
140
- useEffect(() => {
141
- if (isServerSide && dataSource?.getAggregations && !serverAggregationResults) {
142
- fetchServerAggregations();
120
+ pending = Promise.reject(err);
143
121
  }
144
- }, [fetchServerAggregations, isServerSide, dataSource, serverAggregationResults]);
122
+ pending
123
+ .then(
124
+ (result) => {
125
+ if (!cancelled) setServerState({ key: requestKey, result, error: null });
126
+ },
127
+ (err: unknown) => {
128
+ if (cancelled) return;
129
+ console.error('[useAggregation] Server aggregation error:', err);
130
+ setServerState({ key: requestKey, result: null, error: err });
131
+ },
132
+ );
133
+ return () => { cancelled = true; };
134
+ }, [shouldFetch, dataSource, requestKey]);
135
+
136
+ // An outcome only counts for the request it answered: while a new request is pending, or after it
137
+ // failed, there is no server result (the footer shows '—'), never the previous request's totals.
138
+ const current = serverState && serverState.key === requestKey ? serverState : null;
139
+ const isLoading = shouldFetch && current === null;
140
+ const error = shouldFetch ? current?.error ?? null : null;
145
141
 
146
142
  const aggregationResult = useMemo<GridAggregationResult>(() => {
147
143
  if (!isServerSide) return clientResult;
@@ -149,8 +145,8 @@ export function useAggregation<R extends GridRowModel>(
149
145
  if (serverAggregationResults && Object.keys(serverAggregationResults).length > 0) {
150
146
  return serverAggregationResults;
151
147
  }
152
- return serverResult;
153
- }, [isServerSide, clientResult, serverAggregationResults, serverResult]);
148
+ return current?.result ?? EMPTY_RESULT;
149
+ }, [isServerSide, clientResult, serverAggregationResults, current]);
154
150
 
155
151
  return { aggregationResult, isLoading, error };
156
152
  }
@@ -0,0 +1,34 @@
1
+ import { useMemo } from 'react';
2
+ import type { GridColumnGroupingModel } from '../../types';
3
+ import type { UseColumnReorderReturn } from '../useColumnReorder';
4
+ import { canReorderWithinColumnGroups } from '../../utils/columnGroups';
5
+
6
+ /**
7
+ * Header drag-reorder under `columnGroupingModel`: a column can be dropped only onto a column of the
8
+ * same innermost group (or, for an ungrouped column, onto another ungrouped column), so a drag can
9
+ * never split a group. A disallowed target is not a drop target (no dragover `preventDefault`, no
10
+ * highlight) and a drop on it only ends the drag.
11
+ */
12
+ export function useColumnGroupReorderGuard(
13
+ columnGroupingModel: GridColumnGroupingModel | undefined,
14
+ handlers: UseColumnReorderReturn,
15
+ ): UseColumnReorderReturn {
16
+ const { draggedColumn, onDragOver, onDrop, onDragEnd } = handlers;
17
+
18
+ const guarded = useMemo(() => {
19
+ if (!columnGroupingModel || columnGroupingModel.length === 0) return null;
20
+ const allowed = (target: string) =>
21
+ draggedColumn === null || canReorderWithinColumnGroups(columnGroupingModel, draggedColumn, target);
22
+ return {
23
+ onDragOver: onDragOver && ((field: string) => (allowed(field) ? onDragOver(field) : () => {})),
24
+ onDrop: onDrop && ((field: string) => (allowed(field)
25
+ ? onDrop(field)
26
+ : (event: React.DragEvent) => {
27
+ event.preventDefault();
28
+ onDragEnd?.();
29
+ })),
30
+ };
31
+ }, [columnGroupingModel, draggedColumn, onDragOver, onDrop, onDragEnd]);
32
+
33
+ return guarded ? { ...handlers, ...guarded } : handlers;
34
+ }
@@ -0,0 +1,29 @@
1
+ import { useCallback, useState } from 'react';
2
+ import type { GridRowId } from '../../types';
3
+
4
+ export interface UseDetailPanelHeightsResult {
5
+ /** Rendered heights of `'auto'` detail panels, by row id. Pass to `useLayout`. */
6
+ detailPanelHeights: Map<GridRowId, number>;
7
+ /** Stable callback the panels report their rendered height through. */
8
+ reportDetailPanelHeight: (rowId: GridRowId, height: number) => void;
9
+ }
10
+
11
+ /**
12
+ * Keeps the measured heights of `'auto'` detail panels, so the layout reserves what the panel
13
+ * really renders at instead of a guess. A height is kept after its panel collapses or scrolls out
14
+ * of the render window: it is the best estimate for the next time the panel is shown.
15
+ */
16
+ export function useDetailPanelHeights(): UseDetailPanelHeightsResult {
17
+ const [detailPanelHeights, setDetailPanelHeights] = useState<Map<GridRowId, number>>(() => new Map());
18
+
19
+ const reportDetailPanelHeight = useCallback((rowId: GridRowId, height: number) => {
20
+ setDetailPanelHeights(prev => {
21
+ if (prev.get(rowId) === height) return prev;
22
+ const next = new Map(prev);
23
+ next.set(rowId, height);
24
+ return next;
25
+ });
26
+ }, []);
27
+
28
+ return { detailPanelHeights, reportDetailPanelHeight };
29
+ }
@@ -1,117 +1,165 @@
1
- import { useEffect, useCallback } from 'react';
1
+ import { useEffect, useCallback, useLayoutEffect, useRef } from 'react';
2
2
  import type { GridColDef, GridRowModel, GridRowId } from '../../types';
3
-
4
- interface UseGridClipboardProps {
5
- /** The currently selected row IDs — must come from the live selection source (not state.selection which is stale). */
6
- selectedRowIds: Set<GridRowId>;
7
- columns: GridColDef[];
8
- getVisibleRows: () => GridRowModel[];
3
+ import { getCellValue } from '../../utils/values';
4
+ import { getExportColumns, formatExportValue } from '../../utils/export/exportShared';
5
+ import { CHECKBOX_FIELD, EXPAND_FIELD, REORDER_FIELD } from '../../utils/focus';
6
+
7
+ export interface UseGridClipboardProps {
8
+ /**
9
+ * The live selection. Read at copy time, so a copy made right after a selection change (from
10
+ * `onRowSelectionModelChange`, or after `apiRef.selectRows`) sees the new selection.
11
+ */
12
+ getSelectedRowIds: () => Iterable<GridRowId>;
13
+ /** The columns on screen, in screen order (hidden columns left out). */
14
+ getColumns: () => GridColDef[];
15
+ /**
16
+ * Every data row the selection can refer to, in display order: pinned rows, rows on other
17
+ * pages and rows inside collapsed groups included; rows removed by the filter and synthetic
18
+ * group rows left out.
19
+ */
20
+ getRows: () => GridRowModel[];
21
+ /** The grid's key for a row. Never the consumer's getRowId on a grid-made row. */
9
22
  getRowId: (row: GridRowModel) => GridRowId;
23
+ /**
24
+ * Returns the grid's root element. When given, Ctrl/Cmd+C only copies while focus is inside it,
25
+ * so a page with several grids (or other content) is not affected by a grid that is not in use.
26
+ */
27
+ getRootElement?: () => HTMLElement | null;
28
+ /** Turns the Ctrl/Cmd+C shortcut off. `copySelectedRows` still works. */
29
+ disableKeyboardShortcut?: boolean;
30
+ }
31
+
32
+ /** Grid-made columns that never hold row data. */
33
+ const SYSTEM_FIELDS = new Set<string>([CHECKBOX_FIELD, EXPAND_FIELD, REORDER_FIELD]);
34
+
35
+ const TSV_SPECIAL = /[\t\n\r"]/;
36
+
37
+ /**
38
+ * One TSV field as Excel, Google Sheets and LibreOffice read it: a field holding a tab, line break
39
+ * or double quote is wrapped in double quotes, with inner quotes doubled.
40
+ */
41
+ export function escapeTsvField(text: string): string {
42
+ return TSV_SPECIAL.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
43
+ }
44
+
45
+ /** The copied columns: the on-screen columns minus system columns and `exportable: false` ones. */
46
+ export function getClipboardColumns(columns: GridColDef[]): GridColDef[] {
47
+ return getExportColumns(columns).filter(col => !SYSTEM_FIELDS.has(col.field));
48
+ }
49
+
50
+ /** Header line plus one line per row, with values read and formatted the way the cells show them. */
51
+ export function buildClipboardTsv(rows: GridRowModel[], columns: GridColDef[]): string {
52
+ const header = columns.map(col => escapeTsvField(col.headerName || col.field)).join('\t');
53
+ const lines = rows.map(row =>
54
+ columns
55
+ .map(col => escapeTsvField(formatExportValue(row, col, getCellValue(row, col.field, col))))
56
+ .join('\t')
57
+ );
58
+ return [header, ...lines].join('\n');
10
59
  }
11
60
 
61
+ /**
62
+ * The textarea + execCommand fallback, for browsers without the async Clipboard API (plain http)
63
+ * or when it rejects. It has to focus the textarea, so focus and the page selection are put back
64
+ * afterwards: the focused grid cell (or button) keeps focus.
65
+ */
12
66
  function copyTextSynchronous(text: string): boolean {
67
+ if (typeof document.execCommand !== 'function') return false;
68
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
69
+ const selection = document.getSelection();
70
+ const previousRanges: Range[] = [];
71
+ if (selection) {
72
+ for (let i = 0; i < selection.rangeCount; i++) previousRanges.push(selection.getRangeAt(i));
73
+ }
74
+
13
75
  const textarea = document.createElement('textarea');
14
76
  textarea.value = text;
77
+ textarea.setAttribute('readonly', '');
78
+ textarea.setAttribute('aria-hidden', 'true');
15
79
  textarea.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
16
80
  document.body.appendChild(textarea);
17
- textarea.focus();
18
- textarea.select();
19
81
  let success = false;
20
82
  try {
83
+ textarea.focus({ preventScroll: true });
84
+ textarea.select();
21
85
  success = document.execCommand('copy');
22
86
  } finally {
23
87
  document.body.removeChild(textarea);
88
+ if (previousFocus && previousFocus.isConnected) previousFocus.focus({ preventScroll: true });
89
+ if (selection) {
90
+ selection.removeAllRanges();
91
+ previousRanges.forEach(range => selection.addRange(range));
92
+ }
24
93
  }
25
94
  return success;
26
95
  }
27
96
 
28
97
  /**
29
- * Writes text to clipboard.
30
- * Uses both execCommand (synchronous, needed for non-HTTPS or non-async-gesture environments)
31
- * and navigator.clipboard for modern browser support.
98
+ * Writes text to the clipboard: the async Clipboard API when there is one, the execCommand
99
+ * fallback when it is missing or rejects. Rejects when neither worked.
32
100
  */
33
101
  async function writeToClipboard(text: string): Promise<void> {
34
- let fallbackSuccess = false;
35
- try {
36
- fallbackSuccess = copyTextSynchronous(text);
37
- } catch {
38
- // Ignore fallback errors
39
- }
40
-
41
- if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
102
+ const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
103
+ if (clipboard && typeof clipboard.writeText === 'function') {
42
104
  try {
43
- await navigator.clipboard.writeText(text);
105
+ await clipboard.writeText(text);
44
106
  return;
45
- } catch (e) {
46
- if (fallbackSuccess) return;
47
- throw e;
107
+ } catch (error) {
108
+ if (copyTextSynchronous(text)) return;
109
+ throw error;
48
110
  }
49
- } else if (!fallbackSuccess) {
111
+ }
112
+ if (!copyTextSynchronous(text)) {
50
113
  throw new Error('Clipboard API not available and fallback failed.');
51
114
  }
52
115
  }
53
116
 
117
+ /** Ctrl/Cmd+C, also with Caps Lock on ("C") and on non-Latin layouts (the key printed "c" is KeyC). */
118
+ function isCopyShortcut(event: KeyboardEvent): boolean {
119
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return false;
120
+ const key = event.key ?? '';
121
+ if (key.toLowerCase() === 'c') return true;
122
+ // A layout whose KeyC types a non-Latin letter (Cyrillic, Greek, ...): browsers copy on KeyC.
123
+ // A Latin layout that puts another letter there (Dvorak) copies on its own "c" key instead.
124
+ return event.code === 'KeyC' && !/^[a-z]$/i.test(key);
125
+ }
126
+
54
127
  /**
55
128
  * Hook to handle clipboard operations.
56
129
  * Copies selected rows as Tab-Separated Values (TSV) on Ctrl+C / Cmd+C.
57
130
  * Also exposes `copySelectedRows` for programmatic use via apiRef.
58
131
  */
59
132
  export function useGridClipboard(props: UseGridClipboardProps) {
60
- const { selectedRowIds, columns, getVisibleRows, getRowId } = props;
61
-
62
- const copySelectedRows = useCallback(async () => {
63
-
64
- if (selectedRowIds.size === 0) return;
65
-
66
- const visibleRows = getVisibleRows();
67
- const rowsToCopy = visibleRows.filter((row) => selectedRowIds.has(getRowId(row)));
68
-
69
-
70
-
133
+ const latestRef = useRef(props);
134
+ useLayoutEffect(() => {
135
+ latestRef.current = props;
136
+ });
137
+
138
+ /** Copies the selected rows. Resolves without writing when no selected row is found; rejects when the write fails. */
139
+ const copySelectedRows = useCallback(async (): Promise<void> => {
140
+ const { getSelectedRowIds, getColumns, getRows, getRowId } = latestRef.current;
141
+ const selected = new Set<GridRowId>(getSelectedRowIds());
142
+ if (selected.size === 0) return;
143
+
144
+ const rowsToCopy = getRows().filter(row => selected.has(getRowId(row)));
71
145
  if (rowsToCopy.length === 0) return;
72
146
 
73
- // Exclude internal grid columns (checkbox, expand, reorder)
74
- const visibleColumns = columns.filter((col) => !col.field.startsWith('__'));
75
-
76
- // Header row
77
- const headerRow = visibleColumns
78
- .map((col) => col.headerName || col.field)
79
- .join('\t');
80
-
81
- // Data rows
82
- const dataRows = rowsToCopy
83
- .map((row) =>
84
- visibleColumns
85
- .map((col) => {
86
- const value = row[col.field];
87
- if (col.valueFormatter) {
88
- return col.valueFormatter({ value, row, field: col.field });
89
- }
90
- return value ?? '';
91
- })
92
- .join('\t')
93
- )
94
- .join('\n');
95
-
96
- const fullContent = `${headerRow}\n${dataRows}`;
147
+ const text = buildClipboardTsv(rowsToCopy, getClipboardColumns(getColumns()));
148
+ await writeToClipboard(text);
149
+ }, []);
97
150
 
98
- try {
99
- await writeToClipboard(fullContent);
100
-
101
- } catch (err) {
102
- console.error('[OpenGridX] Failed to copy to clipboard:', err);
103
- }
104
- }, [selectedRowIds, columns, getVisibleRows, getRowId]);
151
+ const disableKeyboardShortcut = Boolean(props.disableKeyboardShortcut);
105
152
 
106
- // Keyboard shortcut: Ctrl+C / Cmd+C
153
+ // Keyboard shortcut: Ctrl+C / Cmd+C. Registered once; it reads the latest props through the ref.
107
154
  useEffect(() => {
155
+ if (disableKeyboardShortcut) return;
108
156
  const handleKeyDown = (event: KeyboardEvent) => {
109
- if (!(event.ctrlKey || event.metaKey) || event.key !== 'c') return;
157
+ if (event.defaultPrevented || !isCopyShortcut(event)) return;
110
158
 
111
159
  // Don't intercept when user is typing in an input / editor
112
- const activeEl = document.activeElement as HTMLElement;
160
+ const activeEl = document.activeElement as HTMLElement | null;
113
161
  const tag = activeEl?.tagName.toLowerCase();
114
- const type = (activeEl as HTMLInputElement)?.type;
162
+ const type = (activeEl as HTMLInputElement | null)?.type;
115
163
 
116
164
  // Skip capturing Ctrl+C if we are genuinely inside a text input
117
165
  if (
@@ -122,12 +170,24 @@ export function useGridClipboard(props: UseGridClipboardProps) {
122
170
 
123
171
  if (activeEl?.isContentEditable) return;
124
172
 
125
- copySelectedRows();
173
+ // Only the grid that has focus copies.
174
+ const { getRootElement } = latestRef.current;
175
+ if (getRootElement) {
176
+ const root = getRootElement();
177
+ if (!root || !activeEl || !root.contains(activeEl)) return;
178
+ }
179
+ // A text selection on the page is the user's copy target: leave it to the browser.
180
+ const selection = window.getSelection();
181
+ if (selection && !selection.isCollapsed && selection.toString() !== '') return;
182
+
183
+ copySelectedRows().catch((err: unknown) => {
184
+ console.error('[OpenGridX] Failed to copy to clipboard:', err);
185
+ });
126
186
  };
127
187
 
128
188
  window.addEventListener('keydown', handleKeyDown);
129
189
  return () => window.removeEventListener('keydown', handleKeyDown);
130
- }, [copySelectedRows]);
190
+ }, [copySelectedRows, disableKeyboardShortcut]);
131
191
 
132
192
  return { copySelectedRows };
133
193
  }