@opencorestack/opengridx 3.0.1 → 3.2.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.
@@ -57,8 +57,49 @@ interface AutoTableOptions {
57
57
  showFoot?: string;
58
58
  showHead?: string;
59
59
  margin?: { top: number; left: number; right: number; bottom: number };
60
+ willDrawPage?: (data: AutoTableHookData) => void;
60
61
  }
61
62
 
63
+ /** The part of jspdf-autotable's hook data used here; `settings` and `cursor` are the table's live objects. */
64
+ interface AutoTableHookData {
65
+ settings: { showHead: string };
66
+ cursor: { y: number } | null;
67
+ }
68
+
69
+ type AutoTableFn = (doc: JsPDFDoc, opts: AutoTableOptions) => void;
70
+
71
+ /** How the work is sliced. Output does not depend on these values; tests shrink them. */
72
+ export interface PdfExportTuning {
73
+ /** Longest stretch of synchronous work before yielding to the event loop, in ms. */
74
+ sliceMs: number;
75
+ /** Table rows drawn by the first autoTable call; later calls adapt to `sliceMs`. Rounded to even. */
76
+ renderChunkRows: number;
77
+ /** Resize later chunks to fit `sliceMs` (default behaviour); false keeps every chunk at `renderChunkRows`. */
78
+ adaptive: boolean;
79
+ }
80
+
81
+ const DEFAULT_TUNING: PdfExportTuning = { sliceMs: 30, renderChunkRows: 40, adaptive: true };
82
+
83
+ /** Default `maxRows`: above this a PDF is tens of MB and takes seconds to build and open. */
84
+ export const PDF_MAX_ROWS_DEFAULT = 20_000;
85
+
86
+ /**
87
+ * Let the browser paint and run other tasks. A macrotask on purpose: `scheduler.yield()` resumes
88
+ * ahead of queued tasks, which starves timers and React's scheduler (a progress bar would not
89
+ * update until the export finished).
90
+ */
91
+ function yieldToEventLoop(): Promise<void> {
92
+ return new Promise(resolve => setTimeout(resolve, 0));
93
+ }
94
+
95
+ function abortError(): DOMException {
96
+ return new DOMException('PDF export was aborted', 'AbortError');
97
+ }
98
+
99
+ const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
100
+
101
+ const evenAtLeast2 = (n: number): number => Math.max(2, Math.floor(n / 2) * 2);
102
+
62
103
  type Rgb = [number, number, number];
63
104
 
64
105
  const DEFAULT_HEADER_BACKGROUND: Rgb = [79, 70, 229];
@@ -144,21 +185,37 @@ const LINE_HEIGHT_MM_PER_PT = 1.15 * 0.3528;
144
185
 
145
186
  export function exportToPdf<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: PdfExportOptions<R>): Promise<void>;
146
187
  export function exportToPdf<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: PdfExportOptions<R>): Promise<void>;
147
- export async function exportToPdf<R extends GridRowModel>(
188
+ export function exportToPdf<R extends GridRowModel>(
148
189
  rows: R[],
149
190
  columns: GridColDef<R>[],
150
191
  options: PdfExportOptions<R> = {}
151
192
  ): Promise<void> {
193
+ return runPdfExport(rows, columns, options, DEFAULT_TUNING);
194
+ }
195
+
196
+ /** `exportToPdf` with explicit slicing. Internal: not part of the public API. */
197
+ export async function runPdfExport<R extends GridRowModel>(
198
+ rows: R[],
199
+ columns: GridColDef<R>[],
200
+ options: PdfExportOptions<R>,
201
+ tuning: PdfExportTuning,
202
+ ): Promise<void> {
203
+ const { signal, onProgress } = options;
204
+ const checkAbort = (): void => {
205
+ if (signal?.aborted) throw abortError();
206
+ };
207
+ checkAbort();
208
+
152
209
  // Lazy-load peer deps — provides a clear error if not installed
153
210
  let JsPDF: new (opts: Record<string, unknown>) => JsPDFDoc;
154
- let autoTable: (doc: JsPDFDoc, opts: AutoTableOptions) => void;
211
+ let autoTable: AutoTableFn;
155
212
  try {
156
213
  const [jspdfMod, autoTableMod] = await Promise.all([
157
214
  import('jspdf'),
158
215
  import('jspdf-autotable'),
159
216
  ]);
160
217
  JsPDF = jspdfMod.default as unknown as new (opts: Record<string, unknown>) => JsPDFDoc;
161
- autoTable = autoTableMod.default as unknown as (doc: JsPDFDoc, opts: AutoTableOptions) => void;
218
+ autoTable = autoTableMod.default as unknown as AutoTableFn;
162
219
  } catch {
163
220
  throw new Error(
164
221
  "exportToPdf requires 'jspdf' and 'jspdf-autotable'. Run: npm install jspdf jspdf-autotable"
@@ -181,7 +238,21 @@ export async function exportToPdf<R extends GridRowModel>(
181
238
  fontSize = 9,
182
239
  groupedRows,
183
240
  font,
241
+ maxRows = PDF_MAX_ROWS_DEFAULT,
184
242
  } = options;
243
+ checkAbort();
244
+
245
+ // Time-sliced work: `maybeYield` yields once the current slice has run for `sliceMs`.
246
+ let sliceStart = now();
247
+ const maybeYield = async (): Promise<void> => {
248
+ if (now() - sliceStart < tuning.sliceMs) return;
249
+ await yieldToEventLoop();
250
+ checkAbort();
251
+ sliceStart = now();
252
+ };
253
+ const report = (phase: 'prepare' | 'render' | 'save', done: number, total: number): void => {
254
+ onProgress?.({ phase, done, total });
255
+ };
185
256
 
186
257
  const doc = new JsPDF({ orientation, unit: 'mm', format: 'a4' });
187
258
  const pageWidth = doc.internal.pageSize.getWidth();
@@ -274,30 +345,48 @@ export async function exportToPdf<R extends GridRowModel>(
274
345
  const aggregateTexts = (values: Record<string, unknown>): string[] =>
275
346
  exportColumns.map(col => formatExportAggregate(col, aggMod[col.field], values[col.field], values));
276
347
 
277
- let body: string[][];
348
+ const body: string[][] = [];
278
349
  let foot: string[][] | undefined;
350
+ const pushRow = (cells: string[]): void => {
351
+ body.push(cells.map(pdfText));
352
+ };
279
353
 
280
354
  if (useGrouped) {
281
355
  // Grouped export: flatten the ordered list into body rows with indentation
282
- body = [];
283
- groupedRows.forEach((entry: GridGroupedExportRow) => {
356
+ const total = groupedRows.length;
357
+ report('prepare', 0, total);
358
+ for (let index = 0; index < total; index++) {
359
+ const entry: GridGroupedExportRow = groupedRows[index];
284
360
  const indent = ' '.repeat(entry.depth * 2);
285
361
  if (entry.type === 'group-header') {
286
362
  const label = `${indent}${groupHeaderLabel(entry, columns)}`;
287
- body.push(exportColumns.map((_, i) => i === 0 ? label : ''));
363
+ pushRow(exportColumns.map((_, i) => i === 0 ? label : ''));
288
364
  } else if (entry.type === 'leaf' && entry.row) {
289
365
  const row = entry.row as R;
290
- body.push(exportColumns.map((col, i) => (i === 0 ? indent : '') + resolveValue(row, col)));
366
+ pushRow(exportColumns.map((col, i) => (i === 0 ? indent : '') + resolveValue(row, col)));
291
367
  } else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
292
368
  const texts = aggregateTexts(entry.aggregatedValues);
293
- body.push(texts.map((text, i) => (i === 0 ? `${indent}${summaryLabelText('Subtotal', text)}` : text)));
369
+ pushRow(texts.map((text, i) => (i === 0 ? `${indent}${summaryLabelText('Subtotal', text)}` : text)));
294
370
  } else if (entry.type === 'grand-total' && entry.aggregatedValues) {
295
371
  const texts = aggregateTexts(entry.aggregatedValues);
296
- foot = [texts.map((text, i) => (i === 0 ? summaryLabelText('Grand Total', text) : text))];
372
+ foot = [texts.map((text, i) => (i === 0 ? summaryLabelText('Grand Total', text) : text)).map(pdfText)];
297
373
  }
298
- });
374
+ if ((index & 63) === 63) {
375
+ report('prepare', index + 1, total);
376
+ await maybeYield();
377
+ }
378
+ }
379
+ report('prepare', total, total);
299
380
  } else {
300
- body = rowsToExport.map(row => exportColumns.map(col => resolveValue(row, col)));
381
+ const total = rowsToExport.length;
382
+ report('prepare', 0, total);
383
+ for (let index = 0; index < total; index++) {
384
+ pushRow(exportColumns.map(col => resolveValue(rowsToExport[index], col)));
385
+ if ((index & 63) === 63) {
386
+ report('prepare', index + 1, total);
387
+ await maybeYield();
388
+ }
389
+ }
301
390
 
302
391
  const totals = aggregationModel
303
392
  ? aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, aggregationResult, aggregationModel)
@@ -305,12 +394,19 @@ export async function exportToPdf<R extends GridRowModel>(
305
394
  if (totals && aggregationModel) {
306
395
  const hasAgg = exportColumns.some(c => aggregationModel[c.field]);
307
396
  const texts = aggregateTexts(totals);
308
- foot = [texts.map((text, i) => (i === 0 && hasAgg ? summaryLabelText('TOTAL', text) : text))];
397
+ foot = [texts.map((text, i) => (i === 0 && hasAgg ? summaryLabelText('TOTAL', text) : text)).map(pdfText)];
309
398
  }
399
+ report('prepare', total, total);
310
400
  }
401
+ checkAbort();
311
402
 
312
- body = body.map(cells => cells.map(pdfText));
313
- if (foot) foot = foot.map(cells => cells.map(pdfText));
403
+ if (body.length > maxRows && process.env.NODE_ENV !== 'production') {
404
+ console.warn(
405
+ `[exportToPdf] Exporting ${body.length} table rows (maxRows: ${maxRows}). PDFs this large are slow to build ` +
406
+ 'and open and can reach tens of MB. Prefer exportToCsv or exportToExcelAdvanced for full-dataset exports, ' +
407
+ 'or pass a larger `maxRows` to silence this warning.'
408
+ );
409
+ }
314
410
 
315
411
  if (replacedCharacters) {
316
412
  console.warn(
@@ -325,11 +421,8 @@ export async function exportToPdf<R extends GridRowModel>(
325
421
  const [hr, hg, hb] = hexToRgb(headerBackgroundColor, DEFAULT_HEADER_BACKGROUND);
326
422
  const [tr, tg, tb] = hexToRgb(headerTextColor, DEFAULT_HEADER_TEXT);
327
423
 
328
- autoTable(doc, {
424
+ const baseTableOptions: Omit<AutoTableOptions, 'body' | 'startY'> = {
329
425
  head,
330
- body,
331
- ...(foot ? { foot } : {}),
332
- startY,
333
426
  styles: { fontSize, cellPadding: 3, font: fontName, overflow: 'linebreak' },
334
427
  headStyles: {
335
428
  fillColor: [hr, hg, hb],
@@ -343,10 +436,59 @@ export async function exportToPdf<R extends GridRowModel>(
343
436
  ? { footStyles: { fillColor: [241, 245, 249], fontStyle: 'bold', textColor: [0, 0, 0] } }
344
437
  : {}),
345
438
  columnStyles,
346
- showFoot: foot ? 'lastPage' : 'never',
347
439
  showHead: 'everyPage',
348
440
  margin: { top: MARGIN, left: MARGIN, right: MARGIN, bottom: MARGIN + 6 },
349
- });
441
+ };
442
+
443
+ // The body is drawn as a series of autoTable calls, each continuing where the previous one
444
+ // ended, so the drawing phase yields too. Column widths are fixed (columnStyles), chunks hold
445
+ // an even number of rows (alternate shading keeps its parity), only the last chunk carries the
446
+ // footer, and a continuation chunk skips the header on the page it starts on but repeats it on
447
+ // every page it adds: the same pages a single call would produce.
448
+ const totalRows = body.length;
449
+ report('render', 0, totalRows);
450
+ let drawn = 0;
451
+ let chunkRows = evenAtLeast2(tuning.renderChunkRows);
452
+ do {
453
+ const chunk = body.slice(drawn, drawn + chunkRows);
454
+ const isFirst = drawn === 0;
455
+ const isLast = drawn + chunk.length >= totalRows;
456
+ const continueY = isFirst ? startY : doc.lastAutoTable.finalY;
457
+ let firstPageOfChunk = true;
458
+ const started = now();
459
+ autoTable(doc, {
460
+ ...baseTableOptions,
461
+ body: chunk,
462
+ ...(isLast && foot ? { foot } : {}),
463
+ // A continuation starts at the top margin so autoTable's "does the table fit below
464
+ // startY" check never adds a page; the hook then moves the cursor to where the previous
465
+ // chunk ended, and each row breaks the page exactly as it would in a single table.
466
+ startY: isFirst ? startY : MARGIN,
467
+ showFoot: isLast && foot ? 'lastPage' : 'never',
468
+ ...(isFirst ? {} : {
469
+ willDrawPage: (data: AutoTableHookData) => {
470
+ if (firstPageOfChunk) {
471
+ firstPageOfChunk = false;
472
+ // Same page as the previous chunk: its header is already drawn.
473
+ data.settings.showHead = 'never';
474
+ if (data.cursor) data.cursor.y = continueY;
475
+ } else {
476
+ data.settings.showHead = 'everyPage';
477
+ }
478
+ },
479
+ }),
480
+ });
481
+ drawn += chunk.length;
482
+ report('render', drawn, totalRows);
483
+ const elapsed = now() - started;
484
+ // Aim each call at one slice; grow at most 4x at a time.
485
+ if (tuning.adaptive) chunkRows = evenAtLeast2(elapsed > 0 ? Math.min(chunkRows * 4, chunkRows * (tuning.sliceMs / elapsed)) : chunkRows * 4);
486
+ if (!isLast) {
487
+ await yieldToEventLoop();
488
+ checkAbort();
489
+ sliceStart = now();
490
+ }
491
+ } while (drawn < totalRows);
350
492
 
351
493
  // Two-pass page numbering — stamp all pages after the table is fully rendered
352
494
  // so each stamp reads the correct final total (not the running count during draw)
@@ -361,7 +503,11 @@ export async function exportToPdf<R extends GridRowModel>(
361
503
  pageHeight - 8,
362
504
  { align: 'right' }
363
505
  );
506
+ await maybeYield();
364
507
  }
365
508
 
509
+ checkAbort();
510
+ report('save', 0, 1);
366
511
  doc.save(`${fileName}.pdf`);
512
+ report('save', 1, 1);
367
513
  }
@@ -690,4 +690,4 @@ function downloadFile(content: string, fileName: string, mimeType: string): void
690
690
  }
691
691
 
692
692
  export { exportToPdf } from './exportToPdf';
693
- export type { PdfExportOptions } from '../../types';
693
+ export type { PdfExportOptions, PdfExportProgress } from '../../types';
@@ -18,7 +18,7 @@ import {
18
18
  isEmptyAggregateValue,
19
19
  } from '../aggregation';
20
20
  import { createRowFilter } from '../filtering';
21
- import { compareValues } from '../sorting';
21
+ import { applySortDirection, callSortComparator, compareValues } from '../sorting';
22
22
  import { buildColumnLookup } from '../columnLookup';
23
23
  import { getCellValue, getFormattedValue } from '../values';
24
24
 
@@ -256,7 +256,16 @@ export function computePivot(
256
256
  if (sortModel && sortModel.length > 0) {
257
257
  kept = [...kept].sort((a, b) => {
258
258
  for (const item of sortModel) {
259
- const c = compareValues(sortValue(a.row, item.field), sortValue(b.row, item.field), item.sort, typeOf(item.field));
259
+ const va = sortValue(a.row, item.field);
260
+ const vb = sortValue(b.row, item.field);
261
+ const comparator = isRowField(item.field) ? colDefMap.get(item.field)?.sortComparator : undefined;
262
+ const c = comparator
263
+ ? applySortDirection(callSortComparator(
264
+ comparator,
265
+ { id: a.row.id, field: item.field, row: a.row, value: va },
266
+ { id: b.row.id, field: item.field, row: b.row, value: vb },
267
+ ), item.sort)
268
+ : compareValues(va, vb, item.sort, typeOf(item.field));
260
269
  if (c !== 0) return c;
261
270
  }
262
271
  return 0;
@@ -1,7 +1,7 @@
1
1
 
2
- import type { GridColDef, GridRowModel, GridSortDirection, GridSortItem } from '../../types';
2
+ import type { GridColDef, GridRowId, GridRowModel, GridSortCellParams, GridSortDirection, GridSortItem } from '../../types';
3
3
  import type { GridColumnLookup } from '../columnLookup';
4
- import { getCellValue, toDate, toNumber } from '../values';
4
+ import { getCellValue, toDate, toNumber, warnCallbackFailure } from '../values';
5
5
 
6
6
  /**
7
7
  * Language-aware string order: accented letters sort next to their base letter instead of after
@@ -68,7 +68,8 @@ function compareSortKeys(a: SortKey, b: SortKey): number {
68
68
  return a.num < b.num ? -1 : a.num > b.num ? 1 : 0;
69
69
  }
70
70
 
71
- function applyDirection(comparison: number, direction: 'asc' | 'desc'): number {
71
+ /** Reverse an ascending comparison for 'desc'. */
72
+ export function applySortDirection(comparison: number, direction: 'asc' | 'desc'): number {
72
73
  if (comparison === 0) return 0;
73
74
  return direction === 'asc' ? comparison : -comparison;
74
75
  }
@@ -83,27 +84,62 @@ export function compareValues(
83
84
  direction: 'asc' | 'desc',
84
85
  type?: GridColDef['type']
85
86
  ): number {
86
- return applyDirection(compareSortKeys(toSortKey(a, type), toSortKey(b, type)), direction);
87
+ return applySortDirection(compareSortKeys(toSortKey(a, type), toSortKey(b, type)), direction);
87
88
  }
88
89
 
90
+ /** A column's `sortComparator`, as the sorting utils call it. */
91
+ export type GridSortComparatorFn = NonNullable<GridColDef['sortComparator']>;
92
+
93
+ /**
94
+ * Call a consumer `sortComparator`, containing failures: a throw or a non-finite result reads as 0
95
+ * (equal), with a one-time dev warning per column, so one bad comparison cannot break the sort.
96
+ */
97
+ export function callSortComparator(
98
+ comparator: GridSortComparatorFn,
99
+ a: GridSortCellParams,
100
+ b: GridSortCellParams
101
+ ): number {
102
+ let result: number;
103
+ try {
104
+ result = comparator(a.value, b.value, a, b);
105
+ } catch (error) {
106
+ warnCallbackFailure(`sortComparator:${a.field}`, `sortComparator for column "${a.field}" threw; the values are treated as equal`, error);
107
+ return 0;
108
+ }
109
+ if (typeof result !== 'number' || Number.isNaN(result)) {
110
+ warnCallbackFailure(`sortComparator:${a.field}:nan`, `sortComparator for column "${a.field}" returned ${String(result)}; the values are treated as equal`, undefined);
111
+ return 0;
112
+ }
113
+ return result;
114
+ }
115
+
116
+ const defaultRowId = (row: GridRowModel): GridRowId => row.id;
117
+
89
118
  /**
90
119
  * Compare two rows by a sort model, reading cells through `valueGetter` and using each column's
91
- * `type`. Returns 0 when every key is equal.
120
+ * `type` (or its `sortComparator`). Returns 0 when every key is equal.
92
121
  */
93
122
  export function compareRowsBySortModel(
94
123
  a: GridRowModel,
95
124
  b: GridRowModel,
96
125
  sortModel: readonly GridSortItem[],
97
- columns?: GridColumnLookup
126
+ columns?: GridColumnLookup,
127
+ getRowId: (row: GridRowModel) => GridRowId = defaultRowId
98
128
  ): number {
99
129
  for (const sortItem of sortModel) {
100
130
  const colDef = columns?.byField.get(sortItem.field);
101
- const comparison = compareValues(
102
- getCellValue(a, sortItem.field, colDef),
103
- getCellValue(b, sortItem.field, colDef),
104
- sortItem.sort,
105
- colDef?.type
106
- );
131
+ const va = getCellValue(a, sortItem.field, colDef);
132
+ const vb = getCellValue(b, sortItem.field, colDef);
133
+ const comparison = colDef?.sortComparator
134
+ ? applySortDirection(
135
+ callSortComparator(
136
+ colDef.sortComparator,
137
+ { id: getRowId(a), field: sortItem.field, row: a, value: va },
138
+ { id: getRowId(b), field: sortItem.field, row: b, value: vb }
139
+ ),
140
+ sortItem.sort
141
+ )
142
+ : compareValues(va, vb, sortItem.sort, colDef?.type);
107
143
  if (comparison !== 0) return comparison;
108
144
  }
109
145
  return 0;
@@ -112,28 +148,42 @@ export function compareRowsBySortModel(
112
148
  export function sortRows<R extends GridRowModel>(
113
149
  rows: R[],
114
150
  sortModel: GridSortItem[],
115
- columns?: GridColumnLookup
151
+ columns?: GridColumnLookup,
152
+ getRowId: (row: R) => GridRowId = defaultRowId
116
153
  ): R[] {
117
154
  if (sortModel.length === 0) {
118
155
  return rows;
119
156
  }
120
157
  return sortItemsBySortModel(rows, sortModel, (row, sortItem) => {
121
158
  const colDef = columns?.byField.get(sortItem.field);
122
- return { value: getCellValue(row, sortItem.field, colDef), type: colDef?.type };
159
+ const value = getCellValue(row, sortItem.field, colDef);
160
+ return {
161
+ value,
162
+ type: colDef?.type,
163
+ comparator: colDef?.sortComparator,
164
+ params: colDef?.sortComparator ? { id: getRowId(row), field: sortItem.field, row, value } : undefined,
165
+ };
123
166
  });
124
167
  }
125
168
 
126
- /** The value an item sorts by for one sort key, and the column type used to compare it. */
169
+ /** The value an item sorts by for one sort key, and how to compare it. */
127
170
  export interface GridSortValue {
128
171
  value: unknown;
129
172
  type?: GridColDef['type'];
173
+ /**
174
+ * The column's `sortComparator`. Two items are compared with it when both carry the same one
175
+ * (with their `params`); otherwise the built-in comparison of `value` / `type` applies.
176
+ */
177
+ comparator?: GridSortComparatorFn;
178
+ /** The params passed to `comparator` for this item; `value` here is what the comparator sees. */
179
+ params?: GridSortCellParams;
130
180
  }
131
181
 
132
182
  /**
133
- * Stable sort of any items by a sort model. `readValue` gives the value (and column type) an item
134
- * sorts by for each sort key; it is called once per item and key, not once per comparison. Used by
135
- * the flat pipeline and by tree data / row grouping, whose synthetic group rows sort by their
136
- * grouping value or label instead of a cell.
183
+ * Stable sort of any items by a sort model. `readValue` gives the value (and column type or
184
+ * comparator) an item sorts by for each sort key; it is called once per item and key, not once per
185
+ * comparison. Used by the flat pipeline and by tree data / row grouping, whose synthetic group rows
186
+ * sort by their grouping value or label instead of a cell.
137
187
  */
138
188
  export function sortItemsBySortModel<T>(
139
189
  items: readonly T[],
@@ -146,15 +196,18 @@ export function sortItemsBySortModel<T>(
146
196
 
147
197
  // Read and normalize every sort value once, instead of calling valueGetter and parsing dates
148
198
  // O(n log n) times inside the comparator.
149
- const keys = sortModel.map(sortItem => items.map(item => {
150
- const { value, type } = readValue(item, sortItem);
151
- return toSortKey(value, type);
152
- }));
199
+ const values = sortModel.map(sortItem => items.map(item => readValue(item, sortItem)));
200
+ const keys = values.map(column => column.map(({ value, type }) => toSortKey(value, type)));
153
201
 
154
202
  const order = items.map((_, index) => index);
155
203
  order.sort((ia, ib) => {
156
204
  for (let k = 0; k < sortModel.length; k++) {
157
- const comparison = applyDirection(compareSortKeys(keys[k][ia], keys[k][ib]), sortModel[k].sort);
205
+ const a = values[k][ia];
206
+ const b = values[k][ib];
207
+ const ascending = a.comparator && a.comparator === b.comparator && a.params && b.params
208
+ ? callSortComparator(a.comparator, a.params, b.params)
209
+ : compareSortKeys(keys[k][ia], keys[k][ib]);
210
+ const comparison = applySortDirection(ascending, sortModel[k].sort);
158
211
  if (comparison !== 0) return comparison;
159
212
  }
160
213
  return ia - ib;
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenGridX — AI Agent Context
2
2
  # Package: @opencorestack/opengridx
3
- # Version: 3.0.1
3
+ # Version: 3.2.0
4
4
  # Docs: https://opencorestack.github.io/OpenGridX/
5
5
  # This file is machine-readable. AI developers: start here.
6
6
 
@@ -248,8 +248,9 @@ There are no getRowClassName / getCellClassName / theme props: use GridColDef.ce
248
248
  | align | 'left'/'center'/'right' | Cell content alignment |
249
249
  | headerAlign | 'left'/'center'/'right' | Header content alignment |
250
250
  | sortable | boolean (default true) | Enable sorting |
251
+ | sortComparator | (v1, v2, params1, params2) => number | Custom ascending comparator (3.1.0+). v1/v2 are valueGetter values incl. null/undefined; params { id, field, row, value }. Negated for desc; multi-sort tie-break chain; groups by grouping value; tree siblings; pivot row labels. Throw/NaN = 0. Ignored in server sortingMode |
251
252
  | filterable | boolean (default true) | Enable filtering and quick search |
252
- | resizable | boolean (default true) | Enable resize handle (mouse, touch, Alt+Arrow on header) |
253
+ | resizable | boolean (default true) | Enable resize handle (mouse, touch, Alt+Arrow on header); double-click or Enter on the handle auto-sizes to content (3.1.0+) |
253
254
  | hideable | boolean (default true) | Allow hiding via toolbar / column menu |
254
255
  | pinnable | boolean (default true) | Allow pinning via column menu (does not affect drag-reorder) |
255
256
  | exportable | boolean (default true) | Include in CSV/Excel/JSON/Print/PDF exports and clipboard copy |
@@ -298,6 +299,10 @@ apiRef.current.setPageSize(size)
298
299
  // Scroll
299
300
  apiRef.current.scrollToIndexes({ rowIndex, colIndex })
300
301
 
302
+ // Auto-size to header + rendered cells, clamped to minWidth/maxWidth (3.1.0+)
303
+ apiRef.current.autosizeColumn(field)
304
+ apiRef.current.autosizeColumns(fields?)
305
+
301
306
  // Clipboard
302
307
  apiRef.current.copySelectedRows() // → Promise<void>; rejects if the write fails. Copies every selected row that passes
303
308
  // the filter (all pages, collapsed groups, pinned rows), visible columns in screen order
@@ -492,10 +497,13 @@ await exportToExcelAdvanced(rows, columns, {
492
497
  });
493
498
  exportToJson(rows, columns, { fileName: 'export.json' });
494
499
  await exportToPdf(rows, columns, { fileName: 'export', title: 'My Report' });
500
+ // 3.2.0+: large PDFs — progress, cancel, size warning (never throws)
501
+ await exportToPdf(rows, columns, { onProgress: ({ phase, done, total }) => {}, signal: controller.signal, maxRows: 20000 });
495
502
  await printGrid(rows, columns, 'My Report Title');
496
503
  ```
497
504
 
498
505
  - Every export accepts `selectedRows`, `getRowId` (pass the grid's getRowId so ids match), `aggregationResult` + `aggregationModel` (totals row) and `groupedRows` from `apiRef.current.getGroupedExportRows()`.
506
+ - exportToPdf (3.2.0+) works in ~30 ms slices and yields between them (prepare and render phases), so the page stays responsive; only jsPDF's final save is one synchronous step. `onProgress({ phase: 'prepare'|'render'|'save', done, total })`; `signal` abort rejects with DOMException 'AbortError' and saves nothing; above `maxRows` (default 20000) a dev console.warn recommends exportToCsv / exportToExcelAdvanced. Output (pages, positions) is the same as before.
499
507
  - CSV and exportToExcel neutralise formulas: text starting with = + - @ tab or CR gets a leading `'` (`escapeFormulas: false` opts out).
500
508
  - exportToExcel writes HTML: a `.xlsx` fileName is saved as `.xls` with a console warning.
501
509
  - Columns with `exportable: false` are left out everywhere; `valueGetter` and `valueFormatter` are applied.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencorestack/opengridx",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.",
5
5
  "type": "module",
6
6
  "main": "./dist/opengridx.umd.js",