@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
@@ -1,6 +1,18 @@
1
1
 
2
- import type { GridColDef, GridRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
3
- import { formatAggregationValue } from '../../hooks/features/useAggregation';
2
+ import type { GridColDef, GridRowId, GridRowModel, GridValidRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
3
+ import { groupHeaderLabel } from './groupLabel';
4
+ import {
5
+ aggregationForExport,
6
+ formatExportAggregate,
7
+ formatExportValue,
8
+ getExportColumns,
9
+ getRawExportValue,
10
+ hasSelection,
11
+ isCountAggregation,
12
+ normalizeAggregateValue,
13
+ pickSelectedRows,
14
+ sanitizeSheetName,
15
+ } from './exportShared';
4
16
 
5
17
  // ─── Types ────────────────────────────────────────────────────────────────────
6
18
 
@@ -12,10 +24,10 @@ export interface ExcelColumnStyle {
12
24
  /** Horizontal alignment override */
13
25
  alignment?: 'left' | 'center' | 'right';
14
26
  /**
15
- * If true, the cell value (expected to be an image URL string) will be
16
- * fetched and embedded as an inline image in the cell.
17
- * Requires the image server to serve CORS headers. Falls back to the
18
- * raw URL string if the fetch fails (a warning is logged to the console).
27
+ * If true, the cell value (an image URL string, after `valueGetter`) is fetched and embedded
28
+ * as an inline image in the cell. PNG, JPEG and GIF are supported (detected from the bytes).
29
+ * Requires the image server to serve CORS headers. Falls back to the raw URL string if the
30
+ * fetch fails or the image is in another format (a warning is logged to the console).
19
31
  */
20
32
  embedImage?: boolean;
21
33
  /** Width of the embedded image in pixels (default: 40) */
@@ -25,9 +37,12 @@ export interface ExcelColumnStyle {
25
37
  }
26
38
 
27
39
  export interface ExcelSheetDefinition {
28
- /** Sheet tab name (max 31 chars) */
40
+ /**
41
+ * Sheet tab name. Characters Excel rejects (`\ / ? * : [ ]`) become `-`, the name is cut to
42
+ * 31 characters, and a duplicate name gets a " (2)" suffix.
43
+ */
29
44
  name: string;
30
- /** Which rows to include. Default: 'all' */
45
+ /** Which rows to include. `'selected'` writes only `selectedRows` (none when nothing is selected). Default: 'all' */
31
46
  rows?: 'all' | 'selected';
32
47
  /** Include header row. Default: true */
33
48
  includeHeaders?: boolean;
@@ -41,7 +56,7 @@ export interface ExcelSheetDefinition {
41
56
  alternateRowColor?: string | false;
42
57
  }
43
58
 
44
- export interface ExcelAdvancedExportOptions {
59
+ export interface ExcelAdvancedExportOptions<R extends GridValidRowModel = GridRowModel> {
45
60
  /** Output filename (default: 'export.xlsx') */
46
61
  fileName?: string;
47
62
  /**
@@ -51,7 +66,7 @@ export interface ExcelAdvancedExportOptions {
51
66
  * Built-in special sheet type: `{ type: 'summary' }` renders a
52
67
  * standalone aggregation sheet.
53
68
  */
54
- sheets?: (ExcelSheetDefinition | { type: 'summary'; name?: string })[];
69
+ sheets?: (ExcelSheetDefinition | { type: 'summary'; name?: string })[];
55
70
  /** Per-column style overrides, keyed by field name */
56
71
  columnStyles?: Record<string, ExcelColumnStyle>;
57
72
  /** Header row fill color (default: '#f1f5f9') */
@@ -66,8 +81,13 @@ export interface ExcelAdvancedExportOptions {
66
81
  aggregationResult?: Record<string, unknown> | null;
67
82
  /** Current aggregation model */
68
83
  aggregationModel?: GridAggregationModel | null;
69
- /** Currently selected row IDs */
84
+ /** Currently selected row IDs, written by `rows: 'selected'` sheets */
70
85
  selectedRows?: (string | number)[];
86
+ /**
87
+ * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
88
+ * copy them onto `row.id` (v3.0+). Defaults to `row.id`.
89
+ */
90
+ getRowId?: (row: R) => GridRowId;
71
91
  /**
72
92
  * Grouped row structure, typically from `apiRef.current.getGroupedExportRows()`.
73
93
  * When provided, sheets with `rows: 'all'` are written in grouped order: group-header,
@@ -83,9 +103,21 @@ export interface ExcelAdvancedExportOptions {
83
103
  groupSubtotalFillColor?: string;
84
104
  }
85
105
 
86
- // ─── Cell type inference ──────────────────────────────────────────────────────
106
+ // ─── Cell values ──────────────────────────────────────────────────────────────
107
+
108
+ /** The only values handed to ExcelJS. Objects are never passed through, because ExcelJS reads
109
+ * `{ formula }`, `{ hyperlink }`, `{ richText }` and `{ error }` objects as live cell content. */
110
+ type ExcelCellValue = string | number | boolean | Date | null;
111
+
112
+ /** A subtotal / total cell value, with the number format it needs when it differs from the column's. */
113
+ interface AggregateCell {
114
+ value: ExcelCellValue;
115
+ numFmt?: string;
116
+ }
117
+
118
+ type CellKind = 'number' | 'boolean' | 'date' | 'string';
87
119
 
88
- function inferCellType(colDef: GridColDef<GridRowModel>, value: unknown): 'number' | 'boolean' | 'date' | 'string' {
120
+ function cellKind(colDef: GridColDef<GridRowModel>, value: unknown): CellKind {
89
121
  if (colDef.type === 'number') return 'number';
90
122
  if (colDef.type === 'boolean') return 'boolean';
91
123
  if (colDef.type === 'date') return 'date';
@@ -95,14 +127,131 @@ function inferCellType(colDef: GridColDef<GridRowModel>, value: unknown): 'numbe
95
127
  return 'string';
96
128
  }
97
129
 
98
- // ─── Default numFmt per column type ──────────────────────────────────────────
130
+ /**
131
+ * Excel date serials carry no timezone and are read as wall-clock time, while ExcelJS converts a
132
+ * Date from its UTC instant. Re-anchor the local wall-clock fields at UTC so the cell shows the
133
+ * date and time the grid shows. Invalid dates become null.
134
+ */
135
+ function toExcelDate(date: Date): Date | null {
136
+ if (Number.isNaN(date.getTime())) return null;
137
+ return new Date(Date.UTC(
138
+ date.getFullYear(), date.getMonth(), date.getDate(),
139
+ date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds(),
140
+ ));
141
+ }
142
+
143
+ const ISO_DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
144
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i;
145
+ const NUMERIC_STRING = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i;
146
+
147
+ /** A date-column value as a Date: null when missing or invalid, undefined when not a date at all. */
148
+ function dateCellValue(value: unknown): Date | null | undefined {
149
+ if (value instanceof Date) return toExcelDate(value);
150
+ if (typeof value === 'number') return Number.isFinite(value) ? toExcelDate(new Date(value)) : null;
151
+ if (typeof value !== 'string') return undefined;
152
+ const text = value.trim();
153
+ const dateOnly = ISO_DATE_ONLY.exec(text);
154
+ if (dateOnly) {
155
+ // A calendar date: already wall-clock, so build it at UTC directly.
156
+ const [year, month, day] = [Number(dateOnly[1]), Number(dateOnly[2]), Number(dateOnly[3])];
157
+ const date = new Date(Date.UTC(year, month - 1, day));
158
+ return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : undefined;
159
+ }
160
+ if (ISO_DATE_TIME.test(text)) {
161
+ const date = new Date(text.replace(' ', 'T'));
162
+ return Number.isNaN(date.getTime()) ? undefined : toExcelDate(date);
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ /**
168
+ * A native value for a number / boolean / date cell: null for an empty cell (missing, NaN,
169
+ * Infinity, Invalid Date), undefined when the value is not of that kind (e.g. 'n/a' in a
170
+ * number column).
171
+ */
172
+ function typedCellValue(kind: Exclude<CellKind, 'string'>, value: unknown): number | boolean | Date | null | undefined {
173
+ if (value == null || value === '') return null;
174
+ if (kind === 'number') {
175
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
176
+ if (typeof value === 'bigint') {
177
+ const n = Number(value);
178
+ return Number.isSafeInteger(n) ? n : undefined;
179
+ }
180
+ if (typeof value === 'string' && NUMERIC_STRING.test(value.trim())) {
181
+ const n = Number(value.trim());
182
+ return Number.isFinite(n) ? n : undefined;
183
+ }
184
+ return undefined;
185
+ }
186
+ if (kind === 'boolean') {
187
+ if (typeof value === 'boolean') return value;
188
+ if (typeof value === 'string') {
189
+ const text = value.trim().toLowerCase();
190
+ if (text === 'true') return true;
191
+ if (text === 'false') return false;
192
+ }
193
+ return undefined;
194
+ }
195
+ return dateCellValue(value);
196
+ }
197
+
198
+ /** Any value as inert cell content: primitives and Dates as themselves, objects as JSON text. */
199
+ function inertCellValue(value: unknown): ExcelCellValue {
200
+ if (value == null) return null;
201
+ if (typeof value === 'string' || typeof value === 'boolean') return value;
202
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
203
+ if (value instanceof Date) return toExcelDate(value);
204
+ if (typeof value === 'object') {
205
+ try {
206
+ return JSON.stringify(value) ?? String(value);
207
+ } catch {
208
+ return String(value);
209
+ }
210
+ }
211
+ return String(value);
212
+ }
213
+
214
+ /**
215
+ * The value of a data cell. Number, boolean and date cells are native (their `valueFormatter` is
216
+ * not used, so Excel's number format applies); a value that is missing or not of the column's
217
+ * kind falls back to the text the grid shows. Other cells get the `valueFormatter` text.
218
+ */
219
+ function dataCellValue(row: GridRowModel, col: GridColDef<GridRowModel>): ExcelCellValue {
220
+ const raw = getRawExportValue(row, col);
221
+ const kind = cellKind(col, raw);
222
+ if (kind !== 'string') {
223
+ const typed = typedCellValue(kind, raw);
224
+ if (typed != null) return typed;
225
+ if (!col.valueFormatter) return typed === null ? null : inertCellValue(raw);
226
+ }
227
+ return col.valueFormatter ? formatExportValue(row, col, raw) : inertCellValue(raw);
228
+ }
229
+
230
+ // ─── Number formats ──────────────────────────────────────────────────────────
99
231
 
100
- function defaultNumFmt(colDef: GridColDef<GridRowModel>): string | undefined {
101
- if (colDef.type === 'number') return '#,##0.##';
232
+ const INTEGER_FORMAT = '#,##0';
233
+ const DECIMAL_FORMAT = '#,##0.##';
234
+
235
+ /**
236
+ * Default format for a number-column value. '#,##0.##' alone shows whole numbers with a trailing
237
+ * decimal point in Excel ("5."), so whole numbers get '#,##0'.
238
+ */
239
+ function defaultNumberFormat(value: number): string {
240
+ return Number.isInteger(value) ? INTEGER_FORMAT : DECIMAL_FORMAT;
241
+ }
242
+
243
+ function columnNumFmt(colDef: GridColDef<GridRowModel>, style: ExcelColumnStyle | undefined): string | undefined {
244
+ if (style?.numFmt) return style.numFmt;
102
245
  if (colDef.type === 'date') return 'yyyy-mm-dd';
103
246
  return undefined;
104
247
  }
105
248
 
249
+ /** The number format for a native cell value, or undefined to keep the column's format. */
250
+ function cellNumFmt(colDef: GridColDef<GridRowModel>, style: ExcelColumnStyle | undefined, value: ExcelCellValue): string | undefined {
251
+ if (style?.numFmt || typeof value !== 'number' || colDef.type !== 'number') return undefined;
252
+ return defaultNumberFormat(value);
253
+ }
254
+
106
255
  // ─── Column display width (chars) ────────────────────────────────────────────
107
256
 
108
257
  function colWidth(colDef: GridColDef<GridRowModel>, override?: number): number {
@@ -115,48 +264,49 @@ function colWidth(colDef: GridColDef<GridRowModel>, override?: number): number {
115
264
 
116
265
  type SupportedImageExt = 'png' | 'jpeg' | 'gif';
117
266
 
118
- function imageExtension(url: string, contentType?: string | null): SupportedImageExt {
119
- const ct = contentType ?? '';
120
- if (ct.includes('jpeg') || ct.includes('jpg')) return 'jpeg';
121
- if (ct.includes('gif')) return 'gif';
122
- const path = url.split('?')[0].toLowerCase();
123
- if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'jpeg';
124
- if (path.endsWith('.gif')) return 'gif';
125
- return 'png';
267
+ /**
268
+ * The image format from the file's magic bytes. Excel can only show PNG, JPEG and GIF here; SVG,
269
+ * WebP, AVIF and anything else return null so the URL text is written instead.
270
+ */
271
+ function detectImageExtension(bytes: Uint8Array): SupportedImageExt | null {
272
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png';
273
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
274
+ if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'gif';
275
+ return null;
276
+ }
277
+
278
+ /** The image URL of an embedImage cell: the cell value after `valueGetter`. */
279
+ function imageUrlOf(row: GridRowModel, col: GridColDef<GridRowModel>): string | null {
280
+ const value = getRawExportValue(row, col);
281
+ return typeof value === 'string' && value ? value : null;
126
282
  }
127
283
 
128
284
  /**
129
285
  * Fetch a list of image URLs in parallel.
130
- * CORS failures are logged and skipped — raw URL text is written to the cell
131
- * instead so data is never silently lost.
286
+ * CORS failures and unsupported formats are logged and skipped — raw URL text is written to the
287
+ * cell instead so data is never silently lost.
132
288
  */
133
289
  async function fetchImages(
134
290
  urls: string[]
135
291
  ): Promise<Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>> {
136
292
  const settled = await Promise.allSettled(
137
293
  urls.map(async (url) => {
138
- // ── data: URI — decode base64 directly, no network needed ──────────
294
+ let bytes: Uint8Array;
139
295
  if (url.startsWith('data:')) {
140
- const commaIdx = url.indexOf(',');
141
- const header = url.slice(0, commaIdx);
142
- const b64 = url.slice(commaIdx + 1);
143
- const binary = atob(b64);
144
- const uint8 = new Uint8Array(binary.length);
145
- for (let i = 0; i < binary.length; i++) uint8[i] = binary.charCodeAt(i);
146
- const mimeMatch = header.match(/data:([^;,]+)/);
147
- const mime = mimeMatch?.[1] ?? 'image/png';
148
- let ext: SupportedImageExt = 'png';
149
- if (mime.includes('jpeg') || mime.includes('jpg')) ext = 'jpeg';
150
- else if (mime.includes('gif')) ext = 'gif';
151
- return { url, buffer: uint8.buffer as ArrayBuffer, ext };
296
+ // ── data: URI — decode base64 directly, no network needed ──────────
297
+ const b64 = url.slice(url.indexOf(',') + 1);
298
+ const binary = atob(b64);
299
+ bytes = new Uint8Array(binary.length);
300
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
301
+ } else {
302
+ // ── Remote URL — fetch with CORS ──────────────────────────────────
303
+ const res = await fetch(url, { mode: 'cors' });
304
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
305
+ bytes = new Uint8Array(await res.arrayBuffer());
152
306
  }
153
-
154
- // ── Remote URL — fetch with CORS ──────────────────────────────────
155
- const res = await fetch(url, { mode: 'cors' });
156
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
157
- const buffer = await res.arrayBuffer();
158
- const ext = imageExtension(url, res.headers.get('content-type'));
159
- return { url, buffer, ext };
307
+ const ext = detectImageExtension(bytes);
308
+ if (!ext) throw new Error('unsupported image format (only PNG, JPEG and GIF can be embedded)');
309
+ return { url, buffer: bytes.slice().buffer, ext };
160
310
  })
161
311
  );
162
312
  const map = new Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>();
@@ -166,7 +316,7 @@ async function fetchImages(
166
316
  } else {
167
317
  console.warn(
168
318
  `[exportToExcelAdvanced] Cannot embed image "${urls[i]}" ` +
169
- `(CORS/network error — raw URL written as fallback):`,
319
+ `(CORS/network error or unsupported format — raw URL written as fallback):`,
170
320
  result.reason
171
321
  );
172
322
  }
@@ -184,6 +334,8 @@ async function fetchImages(
184
334
  * ExcelJS is loaded lazily so it does not affect bundle size for apps
185
335
  * that only use CSV/JSON export.
186
336
  */
337
+ export function exportToExcelAdvanced<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: ExcelAdvancedExportOptions<R>): Promise<void>;
338
+ export function exportToExcelAdvanced<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: ExcelAdvancedExportOptions<R>): Promise<void>;
187
339
  export async function exportToExcelAdvanced<R extends GridRowModel>(
188
340
  rows: R[],
189
341
  columns: GridColDef<R>[],
@@ -200,6 +352,7 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
200
352
  aggregationResult = null,
201
353
  aggregationModel = null,
202
354
  selectedRows,
355
+ getRowId,
203
356
  groupedRows,
204
357
  groupHeaderFillColor = '#e8eaf6',
205
358
  groupSubtotalFillColor = '#f0f4ff',
@@ -211,26 +364,40 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
211
364
  workbook.creator = 'OpenGridX';
212
365
  workbook.created = new Date();
213
366
 
214
- // Filter system columns
215
- const exportColumns: GridColDef<GridRowModel>[] = (columns as unknown as GridColDef<GridRowModel>[]).filter(col =>
216
- col.exportable !== false &&
217
- col.field !== '__check__' &&
218
- col.field !== '__actions__' &&
219
- !col.isSpacer
220
- );
367
+ const allColumns = columns as unknown as GridColDef<GridRowModel>[];
368
+ const exportColumns: GridColDef<GridRowModel>[] = getExportColumns(allColumns);
221
369
 
222
370
  const allRows: GridRowModel[] = rows as GridRowModel[];
223
- const sRows: GridRowModel[] = (selectedRows && selectedRows.length > 0
224
- ? rows.filter(r => selectedRows.includes(r.id))
225
- : rows) as GridRowModel[];
371
+ // A 'selected' sheet writes the selection only: an empty selection is an empty sheet.
372
+ const sRows: GridRowModel[] = hasSelection(selectedRows) ? pickSelectedRows(allRows, selectedRows, getRowId) : [];
226
373
 
227
374
  // Normalise sheet definitions
228
375
  const resolvedSheets: (ExcelSheetDefinition | { type: 'summary'; name?: string })[] =
229
376
  sheetDefs ?? [{ name: 'Data', rows: 'all', includeHeaders: true, includeSummary: false }];
377
+ const usedSheetNames = new Set<string>();
230
378
 
231
379
  // Workbook-level image cache — one fetch per URL, reused across multiple sheets
232
380
  const imageCache = new Map<string, number>();
233
381
 
382
+ /**
383
+ * A subtotal / total cell. Numbers stay numeric: `count` / `unique` in a plain integer format
384
+ * (not the column's date or currency format), `min` / `max` of a date column as a date, and
385
+ * other numbers in the column's format. Anything else is the formatted text.
386
+ */
387
+ const aggregateCell = (col: GridColDef<GridRowModel>, values: Record<string, unknown>): AggregateCell => {
388
+ const raw = values[col.field];
389
+ const fnName = aggregationModel?.[col.field];
390
+ if (raw == null) return { value: null };
391
+ const normalized = normalizeAggregateValue(col, fnName, raw);
392
+ if (typeof normalized === 'number') {
393
+ if (!Number.isFinite(normalized)) return { value: null };
394
+ if (isCountAggregation(fnName)) return { value: normalized, numFmt: INTEGER_FORMAT };
395
+ return { value: normalized, numFmt: cellNumFmt(col, columnStyles[col.field], normalized) };
396
+ }
397
+ if (normalized instanceof Date) return { value: toExcelDate(normalized) };
398
+ return { value: formatExportAggregate(col, fnName, raw, values) };
399
+ };
400
+
234
401
  // ─── Build each sheet ────────────────────────────────────────────────────
235
402
 
236
403
  for (const sheetDef of resolvedSheets) {
@@ -238,7 +405,7 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
238
405
  // ── Summary-only sheet ───────────────────────────────────────────────
239
406
  if ('type' in sheetDef && sheetDef.type === 'summary') {
240
407
  if (!aggregationResult || !aggregationModel) continue;
241
- const ws = workbook.addWorksheet(sheetDef.name ?? 'Summary');
408
+ const ws = workbook.addWorksheet(sanitizeSheetName(sheetDef.name ?? 'Summary', usedSheetNames));
242
409
 
243
410
  // Title row
244
411
  ws.addRow(['Summary / Aggregation Totals']);
@@ -254,18 +421,15 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
254
421
  });
255
422
 
256
423
  exportColumns.forEach(col => {
257
- const fn = aggregationModel![col.field];
258
- const val = aggregationResult![col.field];
259
- if (fn == null || val == null) return;
260
- let formatted = val;
261
- if (col.valueFormatter) {
262
- formatted = col.valueFormatter({ value: val, row: {} as R, field: col.field });
263
- } else {
264
- formatted = formatAggregationValue(val, fn);
265
- }
266
- const row = ws.addRow([col.headerName ?? col.field, fn.toUpperCase(), formatted]);
267
- row.getCell(3).alignment = { horizontal: 'right' };
268
- row.getCell(3).font = { bold: true, size: bodyFontSize };
424
+ const fn = aggregationModel[col.field];
425
+ if (fn == null || aggregationResult[col.field] == null) return;
426
+ const { value, numFmt } = aggregateCell(col, aggregationResult);
427
+ const row = ws.addRow([col.headerName ?? col.field, fn.toUpperCase(), value]);
428
+ const valueCell = row.getCell(3);
429
+ const format = numFmt ?? columnNumFmt(col, columnStyles[col.field]);
430
+ if (format) valueCell.numFmt = format;
431
+ valueCell.alignment = { horizontal: 'right' };
432
+ valueCell.font = { bold: true, size: bodyFontSize };
269
433
  });
270
434
 
271
435
  ws.getColumn(1).width = 20;
@@ -300,10 +464,10 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
300
464
  const seen = new Set<string>();
301
465
  rowsToExport.forEach(row => {
302
466
  imageColumns.forEach(col => {
303
- const val = row[col.field];
304
- if (typeof val === 'string' && val && !imageCache.has(val) && !seen.has(val)) {
305
- urlsNeeded.push(val);
306
- seen.add(val);
467
+ const url = imageUrlOf(row, col);
468
+ if (url && !imageCache.has(url) && !seen.has(url)) {
469
+ urlsNeeded.push(url);
470
+ seen.add(url);
307
471
  }
308
472
  });
309
473
  });
@@ -316,30 +480,34 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
316
480
  }
317
481
  }
318
482
 
319
- const ws = workbook.addWorksheet(name);
483
+ const ws = workbook.addWorksheet(sanitizeSheetName(name, usedSheetNames));
320
484
 
321
485
  // ── Set column definitions ───────────────────────────────────────────
322
486
  ws.columns = exportColumns.map(col => ({
323
487
  key: col.field,
324
488
  width: colWidth(col, columnStyles[col.field]?.width),
325
489
  style: {
326
- numFmt: columnStyles[col.field]?.numFmt ?? defaultNumFmt(col),
490
+ numFmt: columnNumFmt(col, columnStyles[col.field]),
327
491
  }
328
492
  }));
329
493
 
494
+ /** Every cell of a row, including empty ones (ExcelJS eachCell skips them). */
495
+ const forEachColumnCell = (row: import('exceljs').Row, fn: (cell: import('exceljs').Cell, colDef: GridColDef<GridRowModel>, colIndex: number) => void) => {
496
+ exportColumns.forEach((colDef, i) => fn(row.getCell(i + 1), colDef, i + 1));
497
+ };
498
+
330
499
  // ── Header row ───────────────────────────────────────────────────────
331
500
  if (includeHeaders) {
332
501
  const headerValues = exportColumns.map(col => col.headerName ?? col.field);
333
502
  const headerRow = ws.addRow(headerValues);
334
503
  headerRow.height = 20;
335
504
 
336
- headerRow.eachCell((cell, colIndex) => {
337
- const colDef = exportColumns[colIndex - 1];
505
+ forEachColumnCell(headerRow, (cell, colDef) => {
338
506
  cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(headerFillColor) } };
339
507
  cell.font = { bold: true, size: headerFontSize, color: { argb: argb(headerTextColor) } };
340
508
  cell.alignment = {
341
- horizontal: columnStyles[colDef?.field]?.alignment ??
342
- (colDef?.type === 'number' ? 'right' : 'left'),
509
+ horizontal: columnStyles[colDef.field]?.alignment ??
510
+ (colDef.type === 'number' ? 'right' : 'left'),
343
511
  vertical: 'middle'
344
512
  };
345
513
  cell.border = bottomBorder();
@@ -368,33 +536,22 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
368
536
  const cellValues = exportColumns.map(col => {
369
537
  // Image columns: write empty string; image placed below
370
538
  if (columnStyles[col.field]?.embedImage) return '';
371
-
372
- let value: unknown = row[col.field];
373
- if (col.valueGetter) {
374
- value = col.valueGetter({ row, field: col.field, value });
375
- }
376
- const cellType = inferCellType(col, value);
377
- if (cellType === 'string' && col.valueFormatter && value != null) {
378
- value = col.valueFormatter({ value, row, field: col.field });
379
- }
380
- return value;
539
+ return dataCellValue(row, col);
381
540
  });
382
541
 
383
542
  const dataRow = ws.addRow(cellValues);
384
543
  dataRow.font = { size: bodyFontSize };
385
544
  if (outlineLevel > 0) dataRow.outlineLevel = outlineLevel;
386
545
 
387
- // Alternate row background
388
- if (alternateRowColor && rowIdx % 2 === 1) {
389
- dataRow.eachCell(cell => {
390
- cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(alternateRowColor) } };
391
- });
392
- }
546
+ const striped = Boolean(alternateRowColor) && rowIdx % 2 === 1;
393
547
 
394
- // Per-cell alignment and explicit web-like borders
395
- dataRow.eachCell((cell, colIndex) => {
396
- const colDef = exportColumns[colIndex - 1];
397
- if (!colDef) return;
548
+ // Per-cell number format, stripe, alignment and web-like borders (empty cells included)
549
+ forEachColumnCell(dataRow, (cell, colDef, colIndex) => {
550
+ const numFmt = cellNumFmt(colDef, columnStyles[colDef.field], cellValues[colIndex - 1]);
551
+ if (numFmt) cell.numFmt = numFmt;
552
+ if (striped && alternateRowColor) {
553
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(alternateRowColor) } };
554
+ }
398
555
  cell.alignment = {
399
556
  horizontal: columnStyles[colDef.field]?.alignment ?? (colDef.type === 'number' ? 'right' : 'left'),
400
557
  vertical: 'middle',
@@ -421,12 +578,12 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
421
578
 
422
579
  imageColumns.forEach(col => {
423
580
  const colIdx = exportColumns.indexOf(col);
424
- const url = row[col.field];
425
- const imgId = typeof url === 'string' ? imageCache.get(url) : undefined;
581
+ const url = imageUrlOf(row, col);
582
+ const imgId = url ? imageCache.get(url) : undefined;
426
583
 
427
584
  if (imgId === undefined) {
428
- // Fetch failed — write URL text so data isn't lost
429
- dataRow.getCell(colIdx + 1).value = typeof url === 'string' ? url : '';
585
+ // Fetch failed or unsupported format — write URL text so data isn't lost
586
+ dataRow.getCell(colIdx + 1).value = url ?? '';
430
587
  return;
431
588
  }
432
589
 
@@ -464,33 +621,19 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
464
621
  excelRowIdx++;
465
622
  };
466
623
 
467
- const groupLabel = (entry: GridGroupedExportRow): string => {
468
- const colDef = exportColumns.find(c => c.field === entry.groupField);
469
- if (colDef?.groupingValueFormatter && entry.groupField) {
470
- return colDef.groupingValueFormatter({ field: entry.groupField, value: entry.groupValue });
471
- }
472
- return `${colDef?.headerName ?? entry.groupField ?? ''}: ${String(entry.groupValue ?? '')}`;
473
- };
474
-
475
- // Numbers are written raw so the column numFmt applies; other values use the formatter.
476
- const aggregateCell = (col: GridColDef<GridRowModel>, values: Record<string, unknown>): unknown => {
477
- const val = values[col.field];
478
- if (val == null) return '';
479
- if (typeof val === 'number') return val;
480
- if (col.valueFormatter) return col.valueFormatter({ value: val, row: {} as GridRowModel, field: col.field });
481
- return formatAggregationValue(val, aggregationModel?.[col.field] ?? '');
482
- };
624
+ const groupLabel = (entry: GridGroupedExportRow): string => groupHeaderLabel(entry, allColumns);
483
625
 
484
626
  const writeSummaryRow = (label: string, values: Record<string, unknown>, fill: string, outlineLevel: number, bold: boolean) => {
485
- const cells = exportColumns.map((col, i) => (i === 0 && values[col.field] == null ? label : aggregateCell(col, values)));
486
- const summaryRow = ws.addRow(cells);
627
+ const cells = exportColumns.map((col, i): AggregateCell => (i === 0 && values[col.field] == null ? { value: label } : aggregateCell(col, values)));
628
+ const summaryRow = ws.addRow(cells.map(c => c.value));
487
629
  summaryRow.height = 18;
488
630
  if (outlineLevel > 0) summaryRow.outlineLevel = outlineLevel;
489
631
  summaryRow.font = { bold, italic: !bold, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
490
- summaryRow.eachCell((cell, colIndex) => {
632
+ forEachColumnCell(summaryRow, (cell, colDef, colIndex) => {
633
+ const numFmt = cells[colIndex - 1].numFmt;
634
+ if (numFmt) cell.numFmt = numFmt;
491
635
  cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(fill) } };
492
- const colDef = exportColumns[colIndex - 1];
493
- cell.alignment = { horizontal: colIndex === 1 ? 'left' : (colDef?.type === 'number' ? 'right' : 'left'), vertical: 'middle' };
636
+ cell.alignment = { horizontal: colIndex === 1 ? 'left' : (colDef.type === 'number' ? 'right' : 'left'), vertical: 'middle' };
494
637
  cell.border = topBorder();
495
638
  });
496
639
  excelRowIdx++;
@@ -527,34 +670,30 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
527
670
  }
528
671
 
529
672
  // ── Aggregation totals row ────────────────────────────────────────────
530
- if (includeSummary && !wroteGrandTotal && aggregationResult && aggregationModel) {
673
+ // A 'selected' sheet's totals are recomputed over the rows it contains.
674
+ const sheetTotals = aggregationForExport(rowsToExport, rowScope === 'selected', allColumns, aggregationResult, aggregationModel);
675
+ if (includeSummary && !wroteGrandTotal && sheetTotals && aggregationModel) {
531
676
  // Label row
532
677
  const labelValues = exportColumns.map(col => {
533
- const fn = aggregationModel![col.field];
678
+ const fn = aggregationModel[col.field];
534
679
  return fn ? fn.toUpperCase() : '';
535
680
  });
536
681
  const labelRow = ws.addRow(labelValues);
537
682
  labelRow.height = 16;
538
683
  labelRow.font = { size: bodyFontSize - 1, italic: true, color: { argb: 'FF94a3b8' } };
539
- labelRow.eachCell(cell => {
684
+ forEachColumnCell(labelRow, cell => {
540
685
  cell.alignment = { horizontal: 'right', vertical: 'middle' };
541
686
  cell.border = { top: { style: 'thin', color: { argb: 'FFcbd5e1' } } };
542
687
  });
543
688
 
544
689
  // Value row
545
- const totalValues = exportColumns.map(col => {
546
- const fn = aggregationModel![col.field];
547
- const val = aggregationResult![col.field];
548
- if (fn == null || val == null) return '';
549
- if (col.valueFormatter) {
550
- return col.valueFormatter({ value: val, row: {} as R, field: col.field });
551
- }
552
- return formatAggregationValue(val, fn);
553
- });
554
- const totalRow = ws.addRow(totalValues);
690
+ const totals = exportColumns.map((col): AggregateCell => (aggregationModel[col.field] == null ? { value: null } : aggregateCell(col, sheetTotals)));
691
+ const totalRow = ws.addRow(totals.map(t => t.value));
555
692
  totalRow.height = 18;
556
693
  totalRow.font = { bold: true, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
557
- totalRow.eachCell(cell => {
694
+ forEachColumnCell(totalRow, (cell, _colDef, colIndex) => {
695
+ const numFmt = totals[colIndex - 1].numFmt;
696
+ if (numFmt) cell.numFmt = numFmt;
558
697
  cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(headerFillColor) } };
559
698
  cell.alignment = { horizontal: 'right', vertical: 'middle' };
560
699
  cell.border = topBorder();