@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,6 +1,18 @@
1
1
 
2
- import type { GridColDef, GridRowModel, GridAggregationModel } 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,13 +81,43 @@ 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;
91
+ /**
92
+ * Grouped row structure, typically from `apiRef.current.getGroupedExportRows()`.
93
+ * When provided, sheets with `rows: 'all'` are written in grouped order: group-header,
94
+ * leaf, group-subtotal and grand-total rows, using Excel row outlining so groups
95
+ * collapse natively. A `grand-total` entry replaces the sheet's `includeSummary` row.
96
+ * Ignored for `rows: 'selected'` sheets.
97
+ * @since v2.1
98
+ */
99
+ groupedRows?: GridGroupedExportRow[];
100
+ /** Fill color for group-header rows in grouped export (default: '#e8eaf6') */
101
+ groupHeaderFillColor?: string;
102
+ /** Fill color for group-subtotal rows in grouped export (default: '#f0f4ff') */
103
+ groupSubtotalFillColor?: string;
71
104
  }
72
105
 
73
- // ─── 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
+ }
74
117
 
75
- function inferCellType(colDef: GridColDef<GridRowModel>, value: unknown): 'number' | 'boolean' | 'date' | 'string' {
118
+ type CellKind = 'number' | 'boolean' | 'date' | 'string';
119
+
120
+ function cellKind(colDef: GridColDef<GridRowModel>, value: unknown): CellKind {
76
121
  if (colDef.type === 'number') return 'number';
77
122
  if (colDef.type === 'boolean') return 'boolean';
78
123
  if (colDef.type === 'date') return 'date';
@@ -82,14 +127,131 @@ function inferCellType(colDef: GridColDef<GridRowModel>, value: unknown): 'numbe
82
127
  return 'string';
83
128
  }
84
129
 
85
- // ─── 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 ──────────────────────────────────────────────────────────
86
231
 
87
- function defaultNumFmt(colDef: GridColDef<GridRowModel>): string | undefined {
88
- 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;
89
245
  if (colDef.type === 'date') return 'yyyy-mm-dd';
90
246
  return undefined;
91
247
  }
92
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
+
93
255
  // ─── Column display width (chars) ────────────────────────────────────────────
94
256
 
95
257
  function colWidth(colDef: GridColDef<GridRowModel>, override?: number): number {
@@ -102,48 +264,49 @@ function colWidth(colDef: GridColDef<GridRowModel>, override?: number): number {
102
264
 
103
265
  type SupportedImageExt = 'png' | 'jpeg' | 'gif';
104
266
 
105
- function imageExtension(url: string, contentType?: string | null): SupportedImageExt {
106
- const ct = contentType ?? '';
107
- if (ct.includes('jpeg') || ct.includes('jpg')) return 'jpeg';
108
- if (ct.includes('gif')) return 'gif';
109
- const path = url.split('?')[0].toLowerCase();
110
- if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'jpeg';
111
- if (path.endsWith('.gif')) return 'gif';
112
- 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;
113
282
  }
114
283
 
115
284
  /**
116
285
  * Fetch a list of image URLs in parallel.
117
- * CORS failures are logged and skipped — raw URL text is written to the cell
118
- * 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.
119
288
  */
120
289
  async function fetchImages(
121
290
  urls: string[]
122
291
  ): Promise<Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>> {
123
292
  const settled = await Promise.allSettled(
124
293
  urls.map(async (url) => {
125
- // ── data: URI — decode base64 directly, no network needed ──────────
294
+ let bytes: Uint8Array;
126
295
  if (url.startsWith('data:')) {
127
- const commaIdx = url.indexOf(',');
128
- const header = url.slice(0, commaIdx);
129
- const b64 = url.slice(commaIdx + 1);
130
- const binary = atob(b64);
131
- const uint8 = new Uint8Array(binary.length);
132
- for (let i = 0; i < binary.length; i++) uint8[i] = binary.charCodeAt(i);
133
- const mimeMatch = header.match(/data:([^;,]+)/);
134
- const mime = mimeMatch?.[1] ?? 'image/png';
135
- let ext: SupportedImageExt = 'png';
136
- if (mime.includes('jpeg') || mime.includes('jpg')) ext = 'jpeg';
137
- else if (mime.includes('gif')) ext = 'gif';
138
- 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());
139
306
  }
140
-
141
- // ── Remote URL — fetch with CORS ──────────────────────────────────
142
- const res = await fetch(url, { mode: 'cors' });
143
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
144
- const buffer = await res.arrayBuffer();
145
- const ext = imageExtension(url, res.headers.get('content-type'));
146
- 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 };
147
310
  })
148
311
  );
149
312
  const map = new Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>();
@@ -153,7 +316,7 @@ async function fetchImages(
153
316
  } else {
154
317
  console.warn(
155
318
  `[exportToExcelAdvanced] Cannot embed image "${urls[i]}" ` +
156
- `(CORS/network error — raw URL written as fallback):`,
319
+ `(CORS/network error or unsupported format — raw URL written as fallback):`,
157
320
  result.reason
158
321
  );
159
322
  }
@@ -171,6 +334,8 @@ async function fetchImages(
171
334
  * ExcelJS is loaded lazily so it does not affect bundle size for apps
172
335
  * that only use CSV/JSON export.
173
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>;
174
339
  export async function exportToExcelAdvanced<R extends GridRowModel>(
175
340
  rows: R[],
176
341
  columns: GridColDef<R>[],
@@ -187,6 +352,10 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
187
352
  aggregationResult = null,
188
353
  aggregationModel = null,
189
354
  selectedRows,
355
+ getRowId,
356
+ groupedRows,
357
+ groupHeaderFillColor = '#e8eaf6',
358
+ groupSubtotalFillColor = '#f0f4ff',
190
359
  } = options;
191
360
 
192
361
  // Lazy-load ExcelJS to keep initial bundle lean
@@ -195,26 +364,40 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
195
364
  workbook.creator = 'OpenGridX';
196
365
  workbook.created = new Date();
197
366
 
198
- // Filter system columns
199
- const exportColumns: GridColDef<GridRowModel>[] = (columns as unknown as GridColDef<GridRowModel>[]).filter(col =>
200
- col.exportable !== false &&
201
- col.field !== '__check__' &&
202
- col.field !== '__actions__' &&
203
- !col.isSpacer
204
- );
367
+ const allColumns = columns as unknown as GridColDef<GridRowModel>[];
368
+ const exportColumns: GridColDef<GridRowModel>[] = getExportColumns(allColumns);
205
369
 
206
370
  const allRows: GridRowModel[] = rows as GridRowModel[];
207
- const sRows: GridRowModel[] = (selectedRows && selectedRows.length > 0
208
- ? rows.filter(r => selectedRows.includes(r.id))
209
- : 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) : [];
210
373
 
211
374
  // Normalise sheet definitions
212
375
  const resolvedSheets: (ExcelSheetDefinition | { type: 'summary'; name?: string })[] =
213
376
  sheetDefs ?? [{ name: 'Data', rows: 'all', includeHeaders: true, includeSummary: false }];
377
+ const usedSheetNames = new Set<string>();
214
378
 
215
379
  // Workbook-level image cache — one fetch per URL, reused across multiple sheets
216
380
  const imageCache = new Map<string, number>();
217
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
+
218
401
  // ─── Build each sheet ────────────────────────────────────────────────────
219
402
 
220
403
  for (const sheetDef of resolvedSheets) {
@@ -222,7 +405,7 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
222
405
  // ── Summary-only sheet ───────────────────────────────────────────────
223
406
  if ('type' in sheetDef && sheetDef.type === 'summary') {
224
407
  if (!aggregationResult || !aggregationModel) continue;
225
- const ws = workbook.addWorksheet(sheetDef.name ?? 'Summary');
408
+ const ws = workbook.addWorksheet(sanitizeSheetName(sheetDef.name ?? 'Summary', usedSheetNames));
226
409
 
227
410
  // Title row
228
411
  ws.addRow(['Summary / Aggregation Totals']);
@@ -238,18 +421,15 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
238
421
  });
239
422
 
240
423
  exportColumns.forEach(col => {
241
- const fn = aggregationModel![col.field];
242
- const val = aggregationResult![col.field];
243
- if (fn == null || val == null) return;
244
- let formatted = val;
245
- if (col.valueFormatter) {
246
- formatted = col.valueFormatter({ value: val, row: {} as R, field: col.field });
247
- } else {
248
- formatted = formatAggregationValue(val, fn);
249
- }
250
- const row = ws.addRow([col.headerName ?? col.field, fn.toUpperCase(), formatted]);
251
- row.getCell(3).alignment = { horizontal: 'right' };
252
- 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 };
253
433
  });
254
434
 
255
435
  ws.getColumn(1).width = 20;
@@ -270,7 +450,12 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
270
450
  alternateRowColor = '#f8fafc',
271
451
  } = def;
272
452
 
273
- const rowsToExport = rowScope === 'selected' ? sRows : allRows;
453
+ const useGrouped = rowScope === 'all' && Boolean(groupedRows && groupedRows.length > 0);
454
+ const rowsToExport = rowScope === 'selected'
455
+ ? sRows
456
+ : useGrouped
457
+ ? groupedRows!.flatMap(e => (e.type === 'leaf' && e.row ? [e.row] : []))
458
+ : allRows;
274
459
 
275
460
  // ─ Pre-fetch images for this sheet ──────────────────────────────────────────
276
461
  const imageColumns = exportColumns.filter(col => columnStyles[col.field]?.embedImage);
@@ -279,10 +464,10 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
279
464
  const seen = new Set<string>();
280
465
  rowsToExport.forEach(row => {
281
466
  imageColumns.forEach(col => {
282
- const val = row[col.field];
283
- if (typeof val === 'string' && val && !imageCache.has(val) && !seen.has(val)) {
284
- urlsNeeded.push(val);
285
- 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);
286
471
  }
287
472
  });
288
473
  });
@@ -295,30 +480,34 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
295
480
  }
296
481
  }
297
482
 
298
- const ws = workbook.addWorksheet(name);
483
+ const ws = workbook.addWorksheet(sanitizeSheetName(name, usedSheetNames));
299
484
 
300
485
  // ── Set column definitions ───────────────────────────────────────────
301
486
  ws.columns = exportColumns.map(col => ({
302
487
  key: col.field,
303
488
  width: colWidth(col, columnStyles[col.field]?.width),
304
489
  style: {
305
- numFmt: columnStyles[col.field]?.numFmt ?? defaultNumFmt(col),
490
+ numFmt: columnNumFmt(col, columnStyles[col.field]),
306
491
  }
307
492
  }));
308
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
+
309
499
  // ── Header row ───────────────────────────────────────────────────────
310
500
  if (includeHeaders) {
311
501
  const headerValues = exportColumns.map(col => col.headerName ?? col.field);
312
502
  const headerRow = ws.addRow(headerValues);
313
503
  headerRow.height = 20;
314
504
 
315
- headerRow.eachCell((cell, colIndex) => {
316
- const colDef = exportColumns[colIndex - 1];
505
+ forEachColumnCell(headerRow, (cell, colDef) => {
317
506
  cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(headerFillColor) } };
318
507
  cell.font = { bold: true, size: headerFontSize, color: { argb: argb(headerTextColor) } };
319
508
  cell.alignment = {
320
- horizontal: columnStyles[colDef?.field]?.alignment ??
321
- (colDef?.type === 'number' ? 'right' : 'left'),
509
+ horizontal: columnStyles[colDef.field]?.alignment ??
510
+ (colDef.type === 'number' ? 'right' : 'left'),
322
511
  vertical: 'middle'
323
512
  };
324
513
  cell.border = bottomBorder();
@@ -343,36 +532,26 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
343
532
  // Header occupies index 0 when present; first data row is at index 1.
344
533
  let excelRowIdx = includeHeaders ? 1 : 0;
345
534
 
346
- rowsToExport.forEach((row, rowIdx) => {
535
+ const writeDataRow = (row: GridRowModel, rowIdx: number, outlineLevel: number) => {
347
536
  const cellValues = exportColumns.map(col => {
348
537
  // Image columns: write empty string; image placed below
349
538
  if (columnStyles[col.field]?.embedImage) return '';
350
-
351
- let value: unknown = row[col.field];
352
- if (col.valueGetter) {
353
- value = col.valueGetter({ row, field: col.field, value });
354
- }
355
- const cellType = inferCellType(col, value);
356
- if (cellType === 'string' && col.valueFormatter && value != null) {
357
- value = col.valueFormatter({ value, row, field: col.field });
358
- }
359
- return value;
539
+ return dataCellValue(row, col);
360
540
  });
361
541
 
362
542
  const dataRow = ws.addRow(cellValues);
363
543
  dataRow.font = { size: bodyFontSize };
544
+ if (outlineLevel > 0) dataRow.outlineLevel = outlineLevel;
364
545
 
365
- // Alternate row background
366
- if (alternateRowColor && rowIdx % 2 === 1) {
367
- dataRow.eachCell(cell => {
368
- cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(alternateRowColor) } };
369
- });
370
- }
546
+ const striped = Boolean(alternateRowColor) && rowIdx % 2 === 1;
371
547
 
372
- // Per-cell alignment and explicit web-like borders
373
- dataRow.eachCell((cell, colIndex) => {
374
- const colDef = exportColumns[colIndex - 1];
375
- 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
+ }
376
555
  cell.alignment = {
377
556
  horizontal: columnStyles[colDef.field]?.alignment ?? (colDef.type === 'number' ? 'right' : 'left'),
378
557
  vertical: 'middle',
@@ -399,12 +578,12 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
399
578
 
400
579
  imageColumns.forEach(col => {
401
580
  const colIdx = exportColumns.indexOf(col);
402
- const url = row[col.field];
403
- const imgId = typeof url === 'string' ? imageCache.get(url) : undefined;
581
+ const url = imageUrlOf(row, col);
582
+ const imgId = url ? imageCache.get(url) : undefined;
404
583
 
405
584
  if (imgId === undefined) {
406
- // Fetch failed — write URL text so data isn't lost
407
- 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 ?? '';
408
587
  return;
409
588
  }
410
589
 
@@ -440,37 +619,81 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
440
619
  }
441
620
 
442
621
  excelRowIdx++;
443
- });
622
+ };
623
+
624
+ const groupLabel = (entry: GridGroupedExportRow): string => groupHeaderLabel(entry, allColumns);
625
+
626
+ const writeSummaryRow = (label: string, values: Record<string, unknown>, fill: string, outlineLevel: number, bold: boolean) => {
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));
629
+ summaryRow.height = 18;
630
+ if (outlineLevel > 0) summaryRow.outlineLevel = outlineLevel;
631
+ summaryRow.font = { bold, italic: !bold, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
632
+ forEachColumnCell(summaryRow, (cell, colDef, colIndex) => {
633
+ const numFmt = cells[colIndex - 1].numFmt;
634
+ if (numFmt) cell.numFmt = numFmt;
635
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(fill) } };
636
+ cell.alignment = { horizontal: colIndex === 1 ? 'left' : (colDef.type === 'number' ? 'right' : 'left'), vertical: 'middle' };
637
+ cell.border = topBorder();
638
+ });
639
+ excelRowIdx++;
640
+ };
641
+
642
+ let wroteGrandTotal = false;
643
+
644
+ if (useGrouped) {
645
+ let stripe = 0;
646
+ groupedRows!.forEach(entry => {
647
+ if (entry.type === 'group-header') {
648
+ const groupHeaderRow = ws.addRow([groupLabel(entry)]);
649
+ groupHeaderRow.height = 18;
650
+ if (entry.depth > 0) groupHeaderRow.outlineLevel = entry.depth;
651
+ groupHeaderRow.font = { bold: true, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
652
+ for (let c = 1; c <= exportColumns.length; c++) {
653
+ groupHeaderRow.getCell(c).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(groupHeaderFillColor) } };
654
+ }
655
+ groupHeaderRow.getCell(1).alignment = { horizontal: 'left', vertical: 'middle', indent: entry.depth };
656
+ excelRowIdx++;
657
+ stripe = 0;
658
+ } else if (entry.type === 'leaf' && entry.row) {
659
+ writeDataRow(entry.row, stripe++, entry.depth);
660
+ } else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
661
+ writeSummaryRow('Subtotal', entry.aggregatedValues, groupSubtotalFillColor, entry.depth, false);
662
+ } else if (entry.type === 'grand-total' && entry.aggregatedValues) {
663
+ writeSummaryRow('Grand Total', entry.aggregatedValues, headerFillColor, 0, true);
664
+ wroteGrandTotal = true;
665
+ }
666
+ });
667
+ ws.properties.outlineProperties = { summaryBelow: true, summaryRight: false };
668
+ } else {
669
+ rowsToExport.forEach((row, rowIdx) => writeDataRow(row, rowIdx, 0));
670
+ }
444
671
 
445
672
  // ── Aggregation totals row ────────────────────────────────────────────
446
- if (includeSummary && 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) {
447
676
  // Label row
448
677
  const labelValues = exportColumns.map(col => {
449
- const fn = aggregationModel![col.field];
678
+ const fn = aggregationModel[col.field];
450
679
  return fn ? fn.toUpperCase() : '';
451
680
  });
452
681
  const labelRow = ws.addRow(labelValues);
453
682
  labelRow.height = 16;
454
683
  labelRow.font = { size: bodyFontSize - 1, italic: true, color: { argb: 'FF94a3b8' } };
455
- labelRow.eachCell(cell => {
684
+ forEachColumnCell(labelRow, cell => {
456
685
  cell.alignment = { horizontal: 'right', vertical: 'middle' };
457
686
  cell.border = { top: { style: 'thin', color: { argb: 'FFcbd5e1' } } };
458
687
  });
459
688
 
460
689
  // Value row
461
- const totalValues = exportColumns.map(col => {
462
- const fn = aggregationModel![col.field];
463
- const val = aggregationResult![col.field];
464
- if (fn == null || val == null) return '';
465
- if (col.valueFormatter) {
466
- return col.valueFormatter({ value: val, row: {} as R, field: col.field });
467
- }
468
- return formatAggregationValue(val, fn);
469
- });
470
- 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));
471
692
  totalRow.height = 18;
472
693
  totalRow.font = { bold: true, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
473
- totalRow.eachCell(cell => {
694
+ forEachColumnCell(totalRow, (cell, _colDef, colIndex) => {
695
+ const numFmt = totals[colIndex - 1].numFmt;
696
+ if (numFmt) cell.numFmt = numFmt;
474
697
  cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(headerFillColor) } };
475
698
  cell.alignment = { horizontal: 'right', vertical: 'middle' };
476
699
  cell.border = topBorder();