@opencorestack/opengridx 2.0.4 → 2.1.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.
- package/CHANGELOG.md +45 -3
- package/dist/index.d.ts +43 -2
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +3927 -3839
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +13 -4
- package/docs/components/column-visibility.md +40 -16
- package/docs/customization/slots-api.md +18 -6
- package/docs/features/export-guide.md +22 -3
- package/docs/features/sorting-pagination.md +2 -0
- package/docs/features/tree-data-grouping.md +8 -1
- package/docs/features/virtualization.md +36 -8
- package/docs/roadmap.md +13 -7
- package/lib/components/Cell/Cell.tsx +1 -0
- package/lib/components/DataGrid/DataGrid.css +7 -0
- package/lib/components/DataGrid/DataGrid.defects.test.tsx +103 -0
- package/lib/components/DataGrid/DataGrid.tsx +42 -10
- package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +183 -0
- package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
- package/lib/components/DataGrid/GridListView.tsx +3 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +3 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +8 -1
- package/lib/components/ListView/ListViewRow.tsx +3 -0
- package/lib/components/Row/Row.tsx +10 -0
- package/lib/hooks/core/useGridColumns.tsx +8 -3
- package/lib/hooks/core/useGridDevWarnings.ts +42 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +12 -14
- package/lib/hooks/core/useGridVisibleRows.ts +3 -2
- package/lib/hooks/features/useAggregation.ts +3 -38
- package/lib/hooks/useRowGrouping.test.ts +162 -1
- package/lib/hooks/useRowGrouping.ts +42 -59
- package/lib/hooks/useTreeData.test.ts +83 -0
- package/lib/hooks/useTreeData.ts +44 -43
- package/lib/index.ts +2 -0
- package/lib/types/index.ts +15 -2
- package/lib/utils/aggregation/aggregation.test.ts +16 -0
- package/lib/utils/aggregation/index.ts +62 -0
- package/lib/utils/export/exportToExcelAdvanced.test.ts +109 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +89 -5
- package/lib/utils/scroll/index.ts +28 -0
- package/llms.txt +22 -13
- package/package.json +7 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { GridAggregationModel, GridAggregationResult, GridColDef, GridRowModel } from '../../types';
|
|
2
|
+
|
|
3
|
+
export type BuiltInAggFn = 'sum' | 'avg' | 'count' | 'min' | 'max' | 'unique';
|
|
4
|
+
|
|
5
|
+
const toNumbers = (values: unknown[]): number[] => {
|
|
6
|
+
const nums: number[] = [];
|
|
7
|
+
for (const v of values) {
|
|
8
|
+
if (v == null) continue;
|
|
9
|
+
const n = Number(v);
|
|
10
|
+
if (!isNaN(n)) nums.push(n);
|
|
11
|
+
}
|
|
12
|
+
return nums;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Loops instead of Math.min(...nums): spreading >~120k arguments throws RangeError.
|
|
16
|
+
const minOf = (nums: number[]): number | null => {
|
|
17
|
+
if (nums.length === 0) return null;
|
|
18
|
+
let m = nums[0];
|
|
19
|
+
for (let i = 1; i < nums.length; i++) if (nums[i] < m) m = nums[i];
|
|
20
|
+
return m;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const maxOf = (nums: number[]): number | null => {
|
|
24
|
+
if (nums.length === 0) return null;
|
|
25
|
+
let m = nums[0];
|
|
26
|
+
for (let i = 1; i < nums.length; i++) if (nums[i] > m) m = nums[i];
|
|
27
|
+
return m;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const AGGREGATION_FUNCTIONS: Record<BuiltInAggFn, (values: unknown[]) => unknown> = {
|
|
31
|
+
sum: (values) => toNumbers(values).reduce((acc, n) => acc + n, 0),
|
|
32
|
+
avg: (values) => {
|
|
33
|
+
const nums = toNumbers(values);
|
|
34
|
+
return nums.length ? nums.reduce((acc, n) => acc + n, 0) / nums.length : null;
|
|
35
|
+
},
|
|
36
|
+
count: (values) => values.filter((v) => v != null).length,
|
|
37
|
+
min: (values) => minOf(toNumbers(values)),
|
|
38
|
+
max: (values) => maxOf(toNumbers(values)),
|
|
39
|
+
unique: (values) => new Set(values.filter((v) => v != null)).size,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function computeAggregations<R extends GridRowModel>(
|
|
43
|
+
rows: R[],
|
|
44
|
+
aggregationModel: GridAggregationModel,
|
|
45
|
+
columnsLookup: Map<string, GridColDef<R>>,
|
|
46
|
+
warnPrefix: string,
|
|
47
|
+
): GridAggregationResult {
|
|
48
|
+
const result: GridAggregationResult = {};
|
|
49
|
+
for (const [field, fnName] of Object.entries(aggregationModel)) {
|
|
50
|
+
const colDef = columnsLookup.get(field);
|
|
51
|
+
if (colDef?.availableAggregationFunctions && !colDef.availableAggregationFunctions.includes(fnName)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const fn = AGGREGATION_FUNCTIONS[fnName as BuiltInAggFn];
|
|
55
|
+
if (!fn) {
|
|
56
|
+
console.warn(`[${warnPrefix}] Unknown aggregation function: "${fnName}"`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
result[field] = fn(rows.map((row) => row[field]));
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import ExcelJS from 'exceljs';
|
|
3
|
+
import { exportToExcelAdvanced } from './exportToExcelAdvanced';
|
|
4
|
+
import type { GridColDef, GridGroupedExportRow, GridRowModel } from '../../types';
|
|
5
|
+
|
|
6
|
+
type Row = GridRowModel & { id: number; dept: string; salary: number };
|
|
7
|
+
|
|
8
|
+
const columns: GridColDef<Row>[] = [
|
|
9
|
+
{ field: 'dept', headerName: 'Department' },
|
|
10
|
+
{ field: 'salary', headerName: 'Salary', type: 'number' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const flatRows: Row[] = [
|
|
14
|
+
{ id: 1, dept: 'Engineering', salary: 90000 },
|
|
15
|
+
{ id: 2, dept: 'Engineering', salary: 85000 },
|
|
16
|
+
{ id: 3, dept: 'Marketing', salary: 70000 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const groupedRows: GridGroupedExportRow[] = [
|
|
20
|
+
{ type: 'group-header', depth: 0, groupField: 'dept', groupValue: 'Engineering' },
|
|
21
|
+
{ type: 'leaf', depth: 1, row: flatRows[0] },
|
|
22
|
+
{ type: 'leaf', depth: 1, row: flatRows[1] },
|
|
23
|
+
{ type: 'group-subtotal', depth: 0, groupField: 'dept', groupValue: 'Engineering', aggregatedValues: { salary: 175000 } },
|
|
24
|
+
{ type: 'group-header', depth: 0, groupField: 'dept', groupValue: 'Marketing' },
|
|
25
|
+
{ type: 'leaf', depth: 1, row: flatRows[2] },
|
|
26
|
+
{ type: 'group-subtotal', depth: 0, groupField: 'dept', groupValue: 'Marketing', aggregatedValues: { salary: 70000 } },
|
|
27
|
+
{ type: 'grand-total', depth: 0, aggregatedValues: { salary: 245000 } },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
let capturedBlob: Blob | null = null;
|
|
31
|
+
const origCreateObjectURL = URL.createObjectURL;
|
|
32
|
+
const origRevokeObjectURL = URL.revokeObjectURL;
|
|
33
|
+
const origClick = HTMLAnchorElement.prototype.click;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
capturedBlob = null;
|
|
37
|
+
URL.createObjectURL = (blob: Blob) => { capturedBlob = blob; return 'blob:mock'; };
|
|
38
|
+
URL.revokeObjectURL = () => {};
|
|
39
|
+
HTMLAnchorElement.prototype.click = () => {};
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
URL.createObjectURL = origCreateObjectURL;
|
|
44
|
+
URL.revokeObjectURL = origRevokeObjectURL;
|
|
45
|
+
HTMLAnchorElement.prototype.click = origClick;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function readSheet(): Promise<ExcelJS.Worksheet> {
|
|
49
|
+
const wb = new ExcelJS.Workbook();
|
|
50
|
+
await wb.xlsx.load(await capturedBlob!.arrayBuffer());
|
|
51
|
+
return wb.worksheets[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rowValues = (ws: ExcelJS.Worksheet) =>
|
|
55
|
+
ws.getSheetValues().slice(1).map(r => (Array.isArray(r) ? r.slice(1) : []));
|
|
56
|
+
|
|
57
|
+
describe('exportToExcelAdvanced — flat', () => {
|
|
58
|
+
it('writes a header row and one row per record when groupedRows is absent', async () => {
|
|
59
|
+
await exportToExcelAdvanced(flatRows, columns);
|
|
60
|
+
const ws = await readSheet();
|
|
61
|
+
expect(rowValues(ws)).toEqual([
|
|
62
|
+
['Department', 'Salary'],
|
|
63
|
+
['Engineering', 90000],
|
|
64
|
+
['Engineering', 85000],
|
|
65
|
+
['Marketing', 70000],
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('exportToExcelAdvanced — grouped', () => {
|
|
71
|
+
it('writes group headers, leaves, subtotals and a single grand total in order', async () => {
|
|
72
|
+
await exportToExcelAdvanced(flatRows, columns, {
|
|
73
|
+
groupedRows,
|
|
74
|
+
aggregationModel: { salary: 'sum' },
|
|
75
|
+
aggregationResult: { salary: 245000 },
|
|
76
|
+
sheets: [{ name: 'Data', includeSummary: true }],
|
|
77
|
+
});
|
|
78
|
+
const ws = await readSheet();
|
|
79
|
+
expect(rowValues(ws)).toEqual([
|
|
80
|
+
['Department', 'Salary'],
|
|
81
|
+
['Department: Engineering'],
|
|
82
|
+
['Engineering', 90000],
|
|
83
|
+
['Engineering', 85000],
|
|
84
|
+
['Subtotal', 175000],
|
|
85
|
+
['Department: Marketing'],
|
|
86
|
+
['Marketing', 70000],
|
|
87
|
+
['Subtotal', 70000],
|
|
88
|
+
['Grand Total', 245000],
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('outlines leaf rows one level below their group header', async () => {
|
|
93
|
+
await exportToExcelAdvanced(flatRows, columns, { groupedRows });
|
|
94
|
+
const ws = await readSheet();
|
|
95
|
+
expect(ws.getRow(2).outlineLevel ?? 0).toBe(0);
|
|
96
|
+
expect(ws.getRow(3).outlineLevel).toBe(1);
|
|
97
|
+
expect(ws.getRow(4).outlineLevel).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uses groupingValueFormatter for group-header labels', async () => {
|
|
101
|
+
const cols: GridColDef<Row>[] = [
|
|
102
|
+
{ ...columns[0], groupingValueFormatter: ({ value }) => `Dept ${String(value)}` },
|
|
103
|
+
columns[1],
|
|
104
|
+
];
|
|
105
|
+
await exportToExcelAdvanced(flatRows, cols, { groupedRows });
|
|
106
|
+
const ws = await readSheet();
|
|
107
|
+
expect(ws.getRow(2).getCell(1).value).toBe('Dept Engineering');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import type { GridColDef, GridRowModel, GridAggregationModel } from '../../types';
|
|
2
|
+
import type { GridColDef, GridRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
|
|
3
3
|
import { formatAggregationValue } from '../../hooks/features/useAggregation';
|
|
4
4
|
|
|
5
5
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -68,6 +68,19 @@ export interface ExcelAdvancedExportOptions {
|
|
|
68
68
|
aggregationModel?: GridAggregationModel | null;
|
|
69
69
|
/** Currently selected row IDs */
|
|
70
70
|
selectedRows?: (string | number)[];
|
|
71
|
+
/**
|
|
72
|
+
* Grouped row structure, typically from `apiRef.current.getGroupedExportRows()`.
|
|
73
|
+
* When provided, sheets with `rows: 'all'` are written in grouped order: group-header,
|
|
74
|
+
* leaf, group-subtotal and grand-total rows, using Excel row outlining so groups
|
|
75
|
+
* collapse natively. A `grand-total` entry replaces the sheet's `includeSummary` row.
|
|
76
|
+
* Ignored for `rows: 'selected'` sheets.
|
|
77
|
+
* @since v2.1
|
|
78
|
+
*/
|
|
79
|
+
groupedRows?: GridGroupedExportRow[];
|
|
80
|
+
/** Fill color for group-header rows in grouped export (default: '#e8eaf6') */
|
|
81
|
+
groupHeaderFillColor?: string;
|
|
82
|
+
/** Fill color for group-subtotal rows in grouped export (default: '#f0f4ff') */
|
|
83
|
+
groupSubtotalFillColor?: string;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
// ─── Cell type inference ──────────────────────────────────────────────────────
|
|
@@ -187,6 +200,9 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
187
200
|
aggregationResult = null,
|
|
188
201
|
aggregationModel = null,
|
|
189
202
|
selectedRows,
|
|
203
|
+
groupedRows,
|
|
204
|
+
groupHeaderFillColor = '#e8eaf6',
|
|
205
|
+
groupSubtotalFillColor = '#f0f4ff',
|
|
190
206
|
} = options;
|
|
191
207
|
|
|
192
208
|
// Lazy-load ExcelJS to keep initial bundle lean
|
|
@@ -270,7 +286,12 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
270
286
|
alternateRowColor = '#f8fafc',
|
|
271
287
|
} = def;
|
|
272
288
|
|
|
273
|
-
const
|
|
289
|
+
const useGrouped = rowScope === 'all' && Boolean(groupedRows && groupedRows.length > 0);
|
|
290
|
+
const rowsToExport = rowScope === 'selected'
|
|
291
|
+
? sRows
|
|
292
|
+
: useGrouped
|
|
293
|
+
? groupedRows!.flatMap(e => (e.type === 'leaf' && e.row ? [e.row] : []))
|
|
294
|
+
: allRows;
|
|
274
295
|
|
|
275
296
|
// ─ Pre-fetch images for this sheet ──────────────────────────────────────────
|
|
276
297
|
const imageColumns = exportColumns.filter(col => columnStyles[col.field]?.embedImage);
|
|
@@ -343,7 +364,7 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
343
364
|
// Header occupies index 0 when present; first data row is at index 1.
|
|
344
365
|
let excelRowIdx = includeHeaders ? 1 : 0;
|
|
345
366
|
|
|
346
|
-
|
|
367
|
+
const writeDataRow = (row: GridRowModel, rowIdx: number, outlineLevel: number) => {
|
|
347
368
|
const cellValues = exportColumns.map(col => {
|
|
348
369
|
// Image columns: write empty string; image placed below
|
|
349
370
|
if (columnStyles[col.field]?.embedImage) return '';
|
|
@@ -361,6 +382,7 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
361
382
|
|
|
362
383
|
const dataRow = ws.addRow(cellValues);
|
|
363
384
|
dataRow.font = { size: bodyFontSize };
|
|
385
|
+
if (outlineLevel > 0) dataRow.outlineLevel = outlineLevel;
|
|
364
386
|
|
|
365
387
|
// Alternate row background
|
|
366
388
|
if (alternateRowColor && rowIdx % 2 === 1) {
|
|
@@ -440,10 +462,72 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
440
462
|
}
|
|
441
463
|
|
|
442
464
|
excelRowIdx++;
|
|
443
|
-
}
|
|
465
|
+
};
|
|
466
|
+
|
|
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
|
+
};
|
|
483
|
+
|
|
484
|
+
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);
|
|
487
|
+
summaryRow.height = 18;
|
|
488
|
+
if (outlineLevel > 0) summaryRow.outlineLevel = outlineLevel;
|
|
489
|
+
summaryRow.font = { bold, italic: !bold, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
|
|
490
|
+
summaryRow.eachCell((cell, colIndex) => {
|
|
491
|
+
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' };
|
|
494
|
+
cell.border = topBorder();
|
|
495
|
+
});
|
|
496
|
+
excelRowIdx++;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
let wroteGrandTotal = false;
|
|
500
|
+
|
|
501
|
+
if (useGrouped) {
|
|
502
|
+
let stripe = 0;
|
|
503
|
+
groupedRows!.forEach(entry => {
|
|
504
|
+
if (entry.type === 'group-header') {
|
|
505
|
+
const groupHeaderRow = ws.addRow([groupLabel(entry)]);
|
|
506
|
+
groupHeaderRow.height = 18;
|
|
507
|
+
if (entry.depth > 0) groupHeaderRow.outlineLevel = entry.depth;
|
|
508
|
+
groupHeaderRow.font = { bold: true, size: bodyFontSize, color: { argb: argb(headerTextColor) } };
|
|
509
|
+
for (let c = 1; c <= exportColumns.length; c++) {
|
|
510
|
+
groupHeaderRow.getCell(c).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: argb(groupHeaderFillColor) } };
|
|
511
|
+
}
|
|
512
|
+
groupHeaderRow.getCell(1).alignment = { horizontal: 'left', vertical: 'middle', indent: entry.depth };
|
|
513
|
+
excelRowIdx++;
|
|
514
|
+
stripe = 0;
|
|
515
|
+
} else if (entry.type === 'leaf' && entry.row) {
|
|
516
|
+
writeDataRow(entry.row, stripe++, entry.depth);
|
|
517
|
+
} else if (entry.type === 'group-subtotal' && entry.aggregatedValues) {
|
|
518
|
+
writeSummaryRow('Subtotal', entry.aggregatedValues, groupSubtotalFillColor, entry.depth, false);
|
|
519
|
+
} else if (entry.type === 'grand-total' && entry.aggregatedValues) {
|
|
520
|
+
writeSummaryRow('Grand Total', entry.aggregatedValues, headerFillColor, 0, true);
|
|
521
|
+
wroteGrandTotal = true;
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
ws.properties.outlineProperties = { summaryBelow: true, summaryRight: false };
|
|
525
|
+
} else {
|
|
526
|
+
rowsToExport.forEach((row, rowIdx) => writeDataRow(row, rowIdx, 0));
|
|
527
|
+
}
|
|
444
528
|
|
|
445
529
|
// ── Aggregation totals row ────────────────────────────────────────────
|
|
446
|
-
if (includeSummary && aggregationResult && aggregationModel) {
|
|
530
|
+
if (includeSummary && !wroteGrandTotal && aggregationResult && aggregationModel) {
|
|
447
531
|
// Label row
|
|
448
532
|
const labelValues = exportColumns.map(col => {
|
|
449
533
|
const fn = aggregationModel![col.field];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vertically scrolls the grid viewport so the center (unpinned) row at `centerIndex`
|
|
3
|
+
* is fully visible below the sticky header / top-pinned rows and above the
|
|
4
|
+
* bottom-pinned rows. `cumulativeHeights` is the unpinned-row layout.
|
|
5
|
+
*/
|
|
6
|
+
export function scrollRowIntoView(
|
|
7
|
+
viewport: HTMLElement,
|
|
8
|
+
centerIndex: number,
|
|
9
|
+
cumulativeHeights: number[],
|
|
10
|
+
pinnedBottomHeight: number,
|
|
11
|
+
): void {
|
|
12
|
+
if (centerIndex < 0 || centerIndex >= cumulativeHeights.length) return;
|
|
13
|
+
const virtualContainer = viewport.querySelector<HTMLElement>('.ogx__virtual-container');
|
|
14
|
+
if (!virtualContainer) return;
|
|
15
|
+
|
|
16
|
+
const { scrollTop, clientHeight } = viewport;
|
|
17
|
+
// Everything above the center rows is sticky, so it covers exactly this many
|
|
18
|
+
// pixels at the top of the viewport at any scroll position.
|
|
19
|
+
const stickyTop = virtualContainer.getBoundingClientRect().top - viewport.getBoundingClientRect().top + scrollTop;
|
|
20
|
+
const rowTop = stickyTop + (centerIndex === 0 ? 0 : cumulativeHeights[centerIndex - 1]);
|
|
21
|
+
const rowBottom = stickyTop + cumulativeHeights[centerIndex];
|
|
22
|
+
|
|
23
|
+
if (rowTop < scrollTop + stickyTop) {
|
|
24
|
+
viewport.scrollTop = Math.max(0, rowTop - stickyTop);
|
|
25
|
+
} else if (rowBottom > scrollTop + clientHeight - pinnedBottomHeight) {
|
|
26
|
+
viewport.scrollTop = rowBottom - clientHeight + pinnedBottomHeight;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenGridX — AI Agent Context
|
|
2
2
|
# Package: @opencorestack/opengridx
|
|
3
|
-
# Version: 2.0
|
|
3
|
+
# Version: 2.1.0
|
|
4
4
|
# Docs: https://opencorestack.github.io/OpenGridX/
|
|
5
5
|
# This file is machine-readable. AI developers: start here.
|
|
6
6
|
|
|
@@ -67,8 +67,11 @@ export default function App() {
|
|
|
67
67
|
### Display
|
|
68
68
|
| Prop | Type | Default | Description |
|
|
69
69
|
|-------------------|-----------|---------|----------------------------------------|
|
|
70
|
-
| height | number
|
|
70
|
+
| height | number/string | — | Grid height. The grid must have a BOUNDED height or it renders every row (see GOTCHAS) |
|
|
71
71
|
| autoHeight | boolean | false | Fit height to rows |
|
|
72
|
+
| overscanRowCount | number | 3 | Minimum rows rendered outside the viewport (adaptive) |
|
|
73
|
+
| ariaLabel | string | — | Accessible name for the grid |
|
|
74
|
+
| localeText | GridLocaleText | — | Override pagination / no-rows strings |
|
|
72
75
|
| rowHeight | number | 52 | Row height in pixels |
|
|
73
76
|
| headerHeight | number | 56 | Header height in pixels |
|
|
74
77
|
| loading | boolean | false | Show skeleton loading overlay |
|
|
@@ -79,6 +82,8 @@ export default function App() {
|
|
|
79
82
|
| Prop | Type | Description |
|
|
80
83
|
|----------------------------|-------------------------------|-------------------------------------|
|
|
81
84
|
| checkboxSelection | boolean | Enable checkbox column |
|
|
85
|
+
| disableMultipleRowSelection | boolean | Cap selection to one row |
|
|
86
|
+
| pinCheckboxColumn | boolean (default true) | Pin the checkbox column left |
|
|
82
87
|
| rowSelectionModel | GridRowId[] | Controlled selection |
|
|
83
88
|
| onRowSelectionModelChange | (model: GridRowId[]) => void | Selection change callback |
|
|
84
89
|
| disableRowSelectionOnClick | boolean | Prevent click toggling selection |
|
|
@@ -87,9 +92,9 @@ export default function App() {
|
|
|
87
92
|
| Prop | Type | Description |
|
|
88
93
|
|-------------------|-----------------------------|----------------------------------|
|
|
89
94
|
| sortModel | GridSortItem[] | Controlled sort state |
|
|
95
|
+
| multiSort | boolean | Plain click appends to sortModel (Shift+click always does) |
|
|
90
96
|
| onSortModelChange | (model: GridSortItem[]) => void | Sort change callback |
|
|
91
97
|
| sortingMode | 'client' / 'server' | Where sorting is applied |
|
|
92
|
-
| disableColumnSorting | boolean | Disable sorting for all columns |
|
|
93
98
|
|
|
94
99
|
### Filtering
|
|
95
100
|
| Prop | Type | Description |
|
|
@@ -97,7 +102,6 @@ export default function App() {
|
|
|
97
102
|
| filterModel | GridFilterModel | Controlled filter state |
|
|
98
103
|
| onFilterModelChange | (model: GridFilterModel) => void | Filter change callback |
|
|
99
104
|
| filterMode | 'client' / 'server' | Where filtering is applied |
|
|
100
|
-
| disableColumnFilter | boolean | Disable filter for all columns |
|
|
101
105
|
|
|
102
106
|
### Pagination
|
|
103
107
|
| Prop | Type | Description |
|
|
@@ -119,7 +123,6 @@ export default function App() {
|
|
|
119
123
|
| columnOrder | string[] | Controlled column order |
|
|
120
124
|
| onColumnOrderChange | (params) => void | Column reorder callback |
|
|
121
125
|
| disableColumnReorder | boolean | Disable drag-to-reorder |
|
|
122
|
-
| disableColumnResize | boolean | Disable resize handles |
|
|
123
126
|
| columnGroupingModel | GridColumnGroupingModel | Multi-level column header groups |
|
|
124
127
|
|
|
125
128
|
### Row Features
|
|
@@ -129,8 +132,6 @@ export default function App() {
|
|
|
129
132
|
| rowReordering | boolean | Enable drag-and-drop row reordering |
|
|
130
133
|
| onRowOrderChange | (params) => void | Row reorder callback |
|
|
131
134
|
| getRowId | (row: R) => GridRowId | Custom row ID accessor (default: row.id) |
|
|
132
|
-
| getRowClassName | (params) => string | Dynamic CSS class per row |
|
|
133
|
-
| getRowHeight | (params) => number | Dynamic row height |
|
|
134
135
|
|
|
135
136
|
### Cell Editing
|
|
136
137
|
| Prop | Type | Description |
|
|
@@ -143,15 +144,18 @@ export default function App() {
|
|
|
143
144
|
| Prop | Type | Description |
|
|
144
145
|
|-------------------------|----------------------------|-----------------------------------------|
|
|
145
146
|
| getDetailPanelContent | (params) => ReactNode | Render expanded detail row |
|
|
147
|
+
| pinExpandColumn | boolean (default true) | Pin the detail-panel expand column |
|
|
146
148
|
| getDetailPanelHeight | (params) => number / 'auto'| Height of detail panel |
|
|
147
|
-
|
|
|
149
|
+
| detailPanelExpandedRowIds | Set<GridRowId> | Controlled expanded rows |
|
|
148
150
|
| onDetailPanelExpandedRowIdsChange | (ids) => void | Expand/collapse callback |
|
|
149
151
|
|
|
150
152
|
### Grouping & Aggregation
|
|
151
153
|
| Prop | Type | Description |
|
|
152
154
|
|------------------------|---------------------------|------------------------------------------|
|
|
153
|
-
| rowGroupingModel | string[] | Fields to group by
|
|
155
|
+
| rowGroupingModel | string[] | Fields to group by (one nesting level per field). Disables pagination |
|
|
154
156
|
| aggregationModel | GridAggregationModel | {field: 'sum'/'avg'/'min'/'max'/'count'} |
|
|
157
|
+
| onAggregationModelChange | (model) => void | Aggregation model changed |
|
|
158
|
+
| groupingColDef | Partial<GridColDef> | Dedicated pinned __group__ column |
|
|
155
159
|
| getAggregationPosition | (group) => 'footer'/'inline' | Where to render aggregation row |
|
|
156
160
|
| defaultGroupingExpansionDepth | number | Auto-expand depth (default -1 = all) |
|
|
157
161
|
|
|
@@ -179,8 +183,8 @@ export default function App() {
|
|
|
179
183
|
|--------------|-----------------------|------------------------------------------|
|
|
180
184
|
| slots | GridSlots | Replace toolbar, pagination, overlays |
|
|
181
185
|
| slotProps | GridSlotProps | Props for slot components |
|
|
182
|
-
|
|
|
183
|
-
|
|
|
186
|
+
| className | string | Extra CSS class on the root element |
|
|
187
|
+
| style | React.CSSProperties | Inline styles on the root element (use DataGridThemeProvider for theming) |
|
|
184
188
|
|
|
185
189
|
### Callbacks
|
|
186
190
|
| Prop | Type | Description |
|
|
@@ -473,8 +477,9 @@ interface GridColumnPinning { left?: string[]; right?: string[]; }
|
|
|
473
477
|
interface GridRowPinning { top?: GridRowId[]; bottom?: GridRowId[]; }
|
|
474
478
|
|
|
475
479
|
interface GridRenderCellParams<R = GridRowModel> {
|
|
476
|
-
value:
|
|
480
|
+
value: unknown; formattedValue?: string; row: R; field: string;
|
|
477
481
|
colDef: GridColDef<R>; rowIndex: number; colIndex: number;
|
|
482
|
+
rowMeta?: GridRowMeta; // hierarchy info for tree-data / grouping rows
|
|
478
483
|
}
|
|
479
484
|
|
|
480
485
|
interface GridDataSource {
|
|
@@ -501,4 +506,8 @@ interface GridGetRowsResponse { rows: GridRowModel[]; rowCount: number; }
|
|
|
501
506
|
7. **`singleSelect` editing**: for dropdown editors in cells, set `type: 'singleSelect'` and provide `valueOptions`.
|
|
502
507
|
8. **`initialState.columns.columnWidths`**: accepts `Record<string, number>` to restore saved column widths.
|
|
503
508
|
9. **`pinnedColumns`** and **`columnWidths`** are independent state — pinning does not reset widths.
|
|
504
|
-
10. **Aggregation
|
|
509
|
+
10. **Aggregation**: setting `aggregationModel` alone renders a sticky footer with the totals; with `rowGroupingModel` each group row also shows its own aggregates. `null`/`undefined` values are ignored by every function.
|
|
510
|
+
11. **GOTCHAS — bounded height**: virtualization only works if the grid's container has a bounded height. A flex child without `min-height: 0` grows to fit every row, so every row is rendered. Pagination hides this, and row grouping turns pagination off. Use `<div style={{ flex: 1, minHeight: 0 }}>` around `height="100%"`, or pass a fixed pixel `height`. Dev builds log a warning when this happens.
|
|
511
|
+
12. **Pagination is ignored under row grouping** — a grouped grid is one scrollable, virtualized list.
|
|
512
|
+
13. **`valueFormatter` + `renderCell`**: `renderCell` receives `params.formattedValue` (v2.1+), so a custom renderer can reuse the formatter instead of duplicating it.
|
|
513
|
+
14. **`slots.footer`** replaces the pagination area and receives `aggregationResult`, `rowCount`, `paginationModel`, `onPaginationModelChange` and `apiRef` (v2.1+).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencorestack/opengridx",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.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",
|
|
@@ -41,8 +41,10 @@
|
|
|
41
41
|
"build:lib": "tsc --project tsconfig.build.json && vite build --config vite.config.lib.js && cp lib/styles/opengridx.css.d.ts dist/opengridx.css.d.ts",
|
|
42
42
|
"lint": "eslint .",
|
|
43
43
|
"preview": "vite preview",
|
|
44
|
-
"test": "vitest run",
|
|
45
|
-
"test:
|
|
44
|
+
"test": "vitest run --project unit",
|
|
45
|
+
"test:browser": "vitest run --project browser",
|
|
46
|
+
"test:all": "vitest run",
|
|
47
|
+
"test:watch": "vitest --project unit",
|
|
46
48
|
"coverage": "vitest run --coverage",
|
|
47
49
|
"prepublishOnly": "npm run build:lib"
|
|
48
50
|
},
|
|
@@ -103,6 +105,7 @@
|
|
|
103
105
|
"@types/react": "^19.2.5",
|
|
104
106
|
"@types/react-dom": "^19.2.3",
|
|
105
107
|
"@vitejs/plugin-react": "^5.1.1",
|
|
108
|
+
"@vitest/browser-playwright": "^4.0.18",
|
|
106
109
|
"@vitest/coverage-v8": "^4.0.18",
|
|
107
110
|
"eslint": "^9.39.1",
|
|
108
111
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
@@ -111,6 +114,7 @@
|
|
|
111
114
|
"jsdom": "^29.1.1",
|
|
112
115
|
"jspdf": "^4.2.1",
|
|
113
116
|
"jspdf-autotable": "^5.0.8",
|
|
117
|
+
"playwright": "^1.58.2",
|
|
114
118
|
"react": "^19.2.0",
|
|
115
119
|
"react-dom": "^19.2.0",
|
|
116
120
|
"react-router-dom": "^7.13.1",
|