@opencorestack/opengridx 3.5.0 → 3.6.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 +48 -0
- package/README.md +6 -3
- package/dist/ai.cjs +4 -2
- package/dist/ai.cjs.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.es.js +4 -2
- package/dist/ai.es.js.map +1 -1
- package/dist/index.d.ts +32 -0
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +6924 -6691
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +17 -17
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +14 -1
- package/docs/README.md +2 -0
- package/docs/architecture/datagrid-orchestration.md +2 -1
- package/docs/features/ai-toolkit.md +2 -2
- package/docs/features/export-guide.md +2 -0
- package/docs/features/header-filters.md +34 -0
- package/docs/features/keyboard-navigation.md +2 -0
- package/docs/features/nextjs.md +241 -0
- package/docs/features/pdf-export.md +2 -0
- package/docs/features/pinning.md +2 -0
- package/docs/features/rtl.md +58 -0
- package/docs/features/virtualization.md +5 -5
- package/docs/performance.md +180 -1
- package/docs/roadmap.md +5 -3
- package/lib/ai/promptHandler.ts +12 -8
- package/lib/ai/validate.ts +5 -2
- package/lib/components/AiAssistant/GridAiAssistantPanel.css +1 -1
- package/lib/components/Cell/Cell.css +29 -9
- package/lib/components/Cell/Cell.tsx +2 -2
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +2 -2
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +3 -3
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +18 -2
- package/lib/components/DataGrid/DataGrid.css +12 -6
- package/lib/components/DataGrid/DataGrid.tsx +9 -1
- package/lib/components/DataGrid/GridAggregationFooter.tsx +3 -3
- package/lib/components/DataGrid/GridListView.tsx +3 -1
- package/lib/components/Header/ColumnMenu.css +1 -1
- package/lib/components/Header/ColumnMenu.tsx +14 -6
- package/lib/components/Header/Header.css +18 -10
- package/lib/components/Header/Header.tsx +16 -9
- package/lib/components/Header/headerColumnStyle.ts +3 -2
- package/lib/components/HeaderFilter/HeaderFilter.css +8 -2
- package/lib/components/HeaderFilter/HeaderFilterCell.tsx +21 -18
- package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +5 -4
- package/lib/components/HeaderFilter/HeaderFilterRow.tsx +9 -3
- package/lib/components/ListView/ListViewRow.tsx +1 -1
- package/lib/components/Pagination/Pagination.css +4 -0
- package/lib/components/Pagination/Pagination.tsx +4 -4
- package/lib/components/Row/Row.tsx +3 -3
- package/lib/components/SkeletonRow/SkeletonRow.tsx +2 -2
- package/lib/components/Toolbar/GridToolbar.tsx +8 -28
- package/lib/components/Toolbar/PivotPanel.tsx +4 -7
- package/lib/components/ui/ExpandIcon.css +13 -0
- package/lib/components/ui/ExpandIcon.tsx +2 -4
- package/lib/hooks/core/useGridCellSelection.ts +20 -5
- package/lib/hooks/core/useGridColumns.tsx +2 -2
- package/lib/hooks/core/useGridDevWarnings.ts +21 -6
- package/lib/hooks/core/useGridKeyboardNavigation.ts +7 -4
- package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
- package/lib/hooks/core/useGridScrollSync.ts +28 -5
- package/lib/hooks/core/useGridVirtualization.ts +19 -0
- package/lib/hooks/core/useLayout.ts +13 -6
- package/lib/hooks/useRowGrouping.ts +9 -9
- package/lib/types/index.ts +30 -0
- package/lib/utils/aggregation/index.ts +56 -1
- package/lib/utils/direction.ts +52 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +9 -2
- package/lib/utils/export/exportToPdf.ts +2 -1
- package/lib/utils/filtering/index.ts +58 -6
- package/lib/utils/headerFilterLocale.ts +37 -0
- package/lib/utils/maxElementHeight.ts +53 -0
- package/lib/utils/scroll/index.ts +5 -3
- package/lib/utils/sorting/index.ts +141 -26
- package/lib/utils/values.ts +10 -6
- package/llms.txt +4 -2
- package/package.json +3 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Whether `element` lays out right to left (`dir="rtl"` on it or an ancestor, or `direction: rtl`). */
|
|
2
|
+
export function isRtl(element: Element | null | undefined): boolean {
|
|
3
|
+
if (!element) return false;
|
|
4
|
+
const view = element.ownerDocument.defaultView;
|
|
5
|
+
return view ? view.getComputedStyle(element).direction === 'rtl' : false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The key as seen in a left-to-right layout: in a right-to-left one ArrowLeft and ArrowRight are
|
|
10
|
+
* swapped, so ArrowLeft always moves (or resizes, expands) towards the left of the screen.
|
|
11
|
+
*/
|
|
12
|
+
export function logicalArrowKey(key: string, rtl: boolean): string {
|
|
13
|
+
if (!rtl) return key;
|
|
14
|
+
if (key === 'ArrowLeft') return 'ArrowRight';
|
|
15
|
+
if (key === 'ArrowRight') return 'ArrowLeft';
|
|
16
|
+
return key;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How far `element` is scrolled from its inline start, never negative. In a right-to-left
|
|
21
|
+
* scroller modern engines report `scrollLeft` from 0 (at the start, on the right) down to
|
|
22
|
+
* `-(scrollWidth - clientWidth)`; the grid's column maths counts from the start edge.
|
|
23
|
+
*/
|
|
24
|
+
export function getScrollStart(element: HTMLElement): number {
|
|
25
|
+
return isRtl(element) ? -element.scrollLeft : element.scrollLeft;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Scrolls `element` to `offset` pixels from its inline start (see `getScrollStart`). */
|
|
29
|
+
export function setScrollStart(element: HTMLElement, offset: number): void {
|
|
30
|
+
element.scrollLeft = isRtl(element) ? -offset : offset;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fixed position of a toolbar panel under its button. */
|
|
34
|
+
export interface GridPanelPosition {
|
|
35
|
+
top: number;
|
|
36
|
+
/** Distance from the viewport's right edge to the panel's right edge (left-to-right). */
|
|
37
|
+
right?: number;
|
|
38
|
+
/** Distance from the viewport's left edge to the panel's left edge (right-to-left). */
|
|
39
|
+
left?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Places a panel under `anchor`, lined up with the button's end edge and opening towards the
|
|
44
|
+
* grid: leftwards in a left-to-right grid, rightwards in a right-to-left one.
|
|
45
|
+
*/
|
|
46
|
+
export function getPanelPosition(anchor: HTMLElement | null, viewportWidth: number): GridPanelPosition {
|
|
47
|
+
if (!anchor) return { top: 0, right: 0 };
|
|
48
|
+
const rect = anchor.getBoundingClientRect();
|
|
49
|
+
return isRtl(anchor)
|
|
50
|
+
? { top: rect.bottom + 6, left: rect.left }
|
|
51
|
+
: { top: rect.bottom + 6, right: viewportWidth - rect.right };
|
|
52
|
+
}
|
|
@@ -358,8 +358,15 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
|
|
|
358
358
|
groupSubtotalFillColor = '#f0f4ff',
|
|
359
359
|
} = options;
|
|
360
360
|
|
|
361
|
-
// Lazy-load ExcelJS to keep initial bundle lean
|
|
362
|
-
|
|
361
|
+
// Lazy-load ExcelJS to keep initial bundle lean. The import must stay inside `try`: webpack
|
|
362
|
+
// (Next.js 15, plain webpack 5) and Turbopack (Next.js 16) treat an unresolvable import() in a
|
|
363
|
+
// try block as optional, so apps without the peer still build. Outside `try` the build fails.
|
|
364
|
+
let ExcelJS: typeof import('exceljs');
|
|
365
|
+
try {
|
|
366
|
+
ExcelJS = (await import('exceljs')).default;
|
|
367
|
+
} catch {
|
|
368
|
+
throw new Error("exportToExcelAdvanced requires 'exceljs'. Run: npm install exceljs");
|
|
369
|
+
}
|
|
363
370
|
const workbook = new ExcelJS.Workbook();
|
|
364
371
|
workbook.creator = 'OpenGridX';
|
|
365
372
|
workbook.created = new Date();
|
|
@@ -206,7 +206,8 @@ export async function runPdfExport<R extends GridRowModel>(
|
|
|
206
206
|
};
|
|
207
207
|
checkAbort();
|
|
208
208
|
|
|
209
|
-
// Lazy-load peer deps — provides a clear error if not installed
|
|
209
|
+
// Lazy-load peer deps — provides a clear error if not installed. Keep the imports inside `try`:
|
|
210
|
+
// webpack and Turbopack only build an app without the peers when the import() sits in a try block.
|
|
210
211
|
let JsPDF: new (opts: Record<string, unknown>) => JsPDFDoc;
|
|
211
212
|
let autoTable: AutoTableFn;
|
|
212
213
|
try {
|
|
@@ -255,15 +255,56 @@ function getCachedQuickFilterTexts(row: GridRowModel, columns: GridColumnLookup
|
|
|
255
255
|
return texts;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
function
|
|
259
|
-
|
|
258
|
+
function quickFilterTerms(values: readonly string[] | undefined): string[] {
|
|
259
|
+
return (values ?? [])
|
|
260
260
|
.map(value => String(value ?? '').trim().toLowerCase())
|
|
261
261
|
.filter(term => term !== '');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function textsMatchTerms(texts: readonly string[], terms: readonly string[]): boolean {
|
|
265
|
+
// Plain loops: this runs once per row on every keystroke.
|
|
266
|
+
for (let t = 0; t < terms.length; t++) {
|
|
267
|
+
const term = terms[t];
|
|
268
|
+
let found = false;
|
|
269
|
+
for (let i = 0; i < texts.length; i++) {
|
|
270
|
+
if (texts[i].includes(term)) {
|
|
271
|
+
found = true;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!found) return false;
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function compileQuickFilter(values: readonly string[] | undefined, columns: GridColumnLookup | undefined): RowPredicate | null {
|
|
281
|
+
const terms = quickFilterTerms(values);
|
|
262
282
|
if (terms.length === 0) return null;
|
|
263
|
-
return (row) =>
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
283
|
+
return (row) => textsMatchTerms(getCachedQuickFilterTexts(row, columns), terms);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// filterRows also keeps the texts of the last rows array it searched, by position, per column
|
|
287
|
+
// lookup. Looking a text up by index is much cheaper than one WeakMap entry per row, and after an
|
|
288
|
+
// edit (a new array with mostly the same row objects at the same positions) only the rows whose
|
|
289
|
+
// object changed are read again.
|
|
290
|
+
interface QuickFilterArrayTexts {
|
|
291
|
+
rows: readonly GridRowModel[];
|
|
292
|
+
texts: string[][];
|
|
293
|
+
}
|
|
294
|
+
const quickFilterArrayCache = new WeakMap<GridColumnLookup, QuickFilterArrayTexts>();
|
|
295
|
+
|
|
296
|
+
function getQuickFilterTextsByIndex(rows: readonly GridRowModel[], columns: GridColumnLookup): string[][] {
|
|
297
|
+
const previous = quickFilterArrayCache.get(columns);
|
|
298
|
+
if (previous && previous.rows === rows) return previous.texts;
|
|
299
|
+
const texts = new Array<string[]>(rows.length);
|
|
300
|
+
for (let i = 0; i < rows.length; i++) {
|
|
301
|
+
const row = rows[i];
|
|
302
|
+
texts[i] = previous && i < previous.rows.length && previous.rows[i] === row
|
|
303
|
+
? previous.texts[i]
|
|
304
|
+
: getQuickFilterTexts(row, columns);
|
|
305
|
+
}
|
|
306
|
+
quickFilterArrayCache.set(columns, { rows, texts });
|
|
307
|
+
return texts;
|
|
267
308
|
}
|
|
268
309
|
|
|
269
310
|
/**
|
|
@@ -333,6 +374,17 @@ export function filterRows<R extends GridRowModel>(
|
|
|
333
374
|
filterModel: GridFilterModel,
|
|
334
375
|
columns?: GridColumnLookup
|
|
335
376
|
): R[] {
|
|
377
|
+
const terms = columns && filterModel ? quickFilterTerms(filterModel.quickFilterValues) : [];
|
|
378
|
+
if (columns && terms.length > 0) {
|
|
379
|
+
// The same result as createRowFilter's predicate: the quick filter and then the items.
|
|
380
|
+
const itemsPredicate = compileGroup(filterModel.items ?? [], filterModel.logicOperator ?? 'and', { columns, warned: new Set() });
|
|
381
|
+
const texts = getQuickFilterTextsByIndex(rows, columns);
|
|
382
|
+
const result: R[] = [];
|
|
383
|
+
for (let i = 0; i < rows.length; i++) {
|
|
384
|
+
if (textsMatchTerms(texts[i], terms) && (!itemsPredicate || itemsPredicate(rows[i]))) result.push(rows[i]);
|
|
385
|
+
}
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
336
388
|
const predicate = createRowFilter(filterModel, columns);
|
|
337
389
|
if (!predicate) return rows;
|
|
338
390
|
return rows.filter(row => predicate(row));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GridFilterOperator, GridLocaleText } from '../types';
|
|
2
|
+
import { HEADER_FILTER_OPERATOR_LABELS } from './headerFilters';
|
|
3
|
+
|
|
4
|
+
/** The header filter row's strings, with the English defaults filled in. */
|
|
5
|
+
export interface HeaderFilterLocale {
|
|
6
|
+
custom: string;
|
|
7
|
+
customTooltip: string;
|
|
8
|
+
cellLabel: (header: string) => string;
|
|
9
|
+
valueLabel: (header: string) => string;
|
|
10
|
+
operatorButtonLabel: (header: string, operatorLabel: string) => string;
|
|
11
|
+
operatorMenuLabel: (header: string) => string;
|
|
12
|
+
clearLabel: (header: string) => string;
|
|
13
|
+
clearTooltip: string;
|
|
14
|
+
any: string;
|
|
15
|
+
yes: string;
|
|
16
|
+
no: string;
|
|
17
|
+
operator: (operator: GridFilterOperator) => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolves the header filter strings of `localeText`, falling back to English. */
|
|
21
|
+
export function resolveHeaderFilterLocale(localeText: GridLocaleText | undefined): HeaderFilterLocale {
|
|
22
|
+
const operators = localeText?.headerFilterOperators;
|
|
23
|
+
return {
|
|
24
|
+
custom: localeText?.headerFilterCustom ?? 'Custom filter',
|
|
25
|
+
customTooltip: localeText?.headerFilterCustomTooltip ?? 'Set in the filter panel',
|
|
26
|
+
cellLabel: localeText?.headerFilterCellLabel ?? (h => `Filter ${h}`),
|
|
27
|
+
valueLabel: localeText?.headerFilterValueLabel ?? (h => `Filter value for ${h}`),
|
|
28
|
+
operatorButtonLabel: localeText?.headerFilterOperatorButtonLabel ?? ((h, op) => `Filter operator for ${h}: ${op}`),
|
|
29
|
+
operatorMenuLabel: localeText?.headerFilterOperatorMenuLabel ?? (h => `Filter operator for ${h}`),
|
|
30
|
+
clearLabel: localeText?.headerFilterClearLabel ?? (h => `Clear filter for ${h}`),
|
|
31
|
+
clearTooltip: localeText?.headerFilterClearTooltip ?? 'Clear',
|
|
32
|
+
any: localeText?.headerFilterAny ?? 'Any',
|
|
33
|
+
yes: localeText?.headerFilterTrue ?? 'Yes',
|
|
34
|
+
no: localeText?.headerFilterFalse ?? 'No',
|
|
35
|
+
operator: op => operators?.[op] ?? HEADER_FILTER_OPERATOR_LABELS[op] ?? op,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tallest element this browser lays out. Browsers cap element heights: Chromium and Safari at
|
|
3
|
+
* about 33.5M px, Firefox at about 17.9M px. Firefox does not clamp an element that is taller — it
|
|
4
|
+
* collapses it, so a grid whose content is taller than the cap cannot be scrolled at all.
|
|
5
|
+
*
|
|
6
|
+
* Measured once per document with an invisible probe (one layout), from the largest candidate down.
|
|
7
|
+
* Returns `Infinity` where there is no layout engine (jsdom, SSR), so nothing is capped there.
|
|
8
|
+
*/
|
|
9
|
+
const CANDIDATES = [33_554_400, 17_895_000, 10_000_000, 5_000_000] as const;
|
|
10
|
+
|
|
11
|
+
const cache = new WeakMap<Document, number>();
|
|
12
|
+
|
|
13
|
+
function measure(doc: Document): number {
|
|
14
|
+
const body = doc.body;
|
|
15
|
+
if (!body) return Infinity;
|
|
16
|
+
const probe = doc.createElement('div');
|
|
17
|
+
probe.setAttribute('aria-hidden', 'true');
|
|
18
|
+
probe.style.cssText = 'position:absolute;top:0;left:-10000px;width:1px;visibility:hidden;pointer-events:none;';
|
|
19
|
+
body.appendChild(probe);
|
|
20
|
+
try {
|
|
21
|
+
probe.style.height = '100px';
|
|
22
|
+
// No layout engine: every element reads 0 tall.
|
|
23
|
+
if (Math.abs(probe.getBoundingClientRect().height - 100) > 1) return Infinity;
|
|
24
|
+
for (const height of CANDIDATES) {
|
|
25
|
+
probe.style.height = `${height}px`;
|
|
26
|
+
if (Math.abs(probe.getBoundingClientRect().height - height) < 2) return height;
|
|
27
|
+
}
|
|
28
|
+
return CANDIDATES[CANDIDATES.length - 1];
|
|
29
|
+
} finally {
|
|
30
|
+
probe.remove();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getMaxElementHeight(doc: Document | null | undefined): number {
|
|
35
|
+
if (!doc) return Infinity;
|
|
36
|
+
const known = cache.get(doc);
|
|
37
|
+
if (known !== undefined) return known;
|
|
38
|
+
const height = measure(doc);
|
|
39
|
+
cache.set(doc, height);
|
|
40
|
+
return height;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How many of the unpinned rows end at or above `limit` px (the rows a user can scroll to). */
|
|
44
|
+
export function countReachableRows(cumulativeHeights: readonly number[], limit: number): number {
|
|
45
|
+
if (!Number.isFinite(limit)) return cumulativeHeights.length;
|
|
46
|
+
let lo = 0;
|
|
47
|
+
let hi = cumulativeHeights.length;
|
|
48
|
+
while (lo < hi) {
|
|
49
|
+
const mid = (lo + hi) >> 1;
|
|
50
|
+
if (cumulativeHeights[mid] <= limit) lo = mid + 1; else hi = mid;
|
|
51
|
+
}
|
|
52
|
+
return lo;
|
|
53
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getScrollStart, setScrollStart } from '../direction';
|
|
1
2
|
/**
|
|
2
3
|
* Height of the sticky block at the bottom of the viewport (bottom-pinned rows, their expanded
|
|
3
4
|
* detail panels and the aggregation footer), measured from the DOM.
|
|
@@ -68,11 +69,12 @@ export function scrollColumnIntoView(viewport: HTMLElement, unpinnedIndex: numbe
|
|
|
68
69
|
const colLeft = contentStart + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
|
|
69
70
|
const colRight = contentStart + unpinnedAccWidths[unpinnedIndex];
|
|
70
71
|
const stickyLeft = pinnedSystemColumnsWidth + leftWidth;
|
|
71
|
-
const {
|
|
72
|
+
const { clientWidth } = viewport;
|
|
73
|
+
const scrollLeft = getScrollStart(viewport);
|
|
72
74
|
|
|
73
75
|
if (colLeft < scrollLeft + stickyLeft) {
|
|
74
|
-
viewport
|
|
76
|
+
setScrollStart(viewport, Math.max(0, colLeft - stickyLeft));
|
|
75
77
|
} else if (colRight > scrollLeft + clientWidth - rightWidth) {
|
|
76
|
-
viewport
|
|
78
|
+
setScrollStart(viewport, colRight - clientWidth + rightWidth);
|
|
77
79
|
}
|
|
78
80
|
}
|
|
@@ -154,16 +154,20 @@ export function sortRows<R extends GridRowModel>(
|
|
|
154
154
|
if (sortModel.length === 0) {
|
|
155
155
|
return rows;
|
|
156
156
|
}
|
|
157
|
-
|
|
157
|
+
if (rows.length < 2) return [...rows];
|
|
158
|
+
const sortColumns = sortModel.map(sortItem => {
|
|
158
159
|
const colDef = columns?.byField.get(sortItem.field);
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
160
|
+
const comparator = colDef?.sortComparator;
|
|
161
|
+
if (comparator) {
|
|
162
|
+
return genericSortColumn(rows.map(row => {
|
|
163
|
+
const value = getCellValue(row, sortItem.field, colDef);
|
|
164
|
+
return { value, type: colDef.type, comparator, params: { id: getRowId(row), field: sortItem.field, row, value } };
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
const type = colDef?.type;
|
|
168
|
+
return encodeSortColumn(rows.length, i => getCellValue(rows[i], sortItem.field, colDef), () => type);
|
|
166
169
|
});
|
|
170
|
+
return sortByColumns(rows, sortModel, sortColumns);
|
|
167
171
|
}
|
|
168
172
|
|
|
169
173
|
/** The value an item sorts by for one sort key, and how to compare it. */
|
|
@@ -194,26 +198,137 @@ export function sortItemsBySortModel<T>(
|
|
|
194
198
|
return [...items];
|
|
195
199
|
}
|
|
196
200
|
|
|
197
|
-
// Read
|
|
198
|
-
//
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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);
|
|
211
|
-
if (comparison !== 0) return comparison;
|
|
212
|
-
}
|
|
213
|
-
return ia - ib;
|
|
201
|
+
// Read every sort value once, instead of calling valueGetter and parsing dates O(n log n) times
|
|
202
|
+
// inside the comparator.
|
|
203
|
+
const columns = sortModel.map(sortItem => {
|
|
204
|
+
const values = items.map(item => readValue(item, sortItem));
|
|
205
|
+
// A key where no item carries a comparator compares by the built-in order only, so it can be
|
|
206
|
+
// encoded up front.
|
|
207
|
+
return values.some(v => v.comparator && v.params)
|
|
208
|
+
? genericSortColumn(values)
|
|
209
|
+
: encodeSortColumn(values.length, i => values[i].value, i => values[i].type);
|
|
214
210
|
});
|
|
211
|
+
return sortByColumns(items, sortModel, columns);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One sort key, ready to compare: encoded into typed arrays, or kept as values when a sortComparator applies. */
|
|
215
|
+
type SortColumn =
|
|
216
|
+
| { kind: 'encoded'; group: Uint8Array; num: Float64Array }
|
|
217
|
+
| { kind: 'generic'; values: readonly GridSortValue[]; keys: readonly SortKey[] };
|
|
218
|
+
|
|
219
|
+
/** The group of the empty bucket: after every rank, as in compareSortKeys. */
|
|
220
|
+
const GROUP_EMPTY = RANK_OTHER + 1;
|
|
221
|
+
|
|
222
|
+
/** Give each item of `buckets` (distinct text → item indexes) its text's position in collator order. */
|
|
223
|
+
function rankTexts(buckets: Map<string, number[]>, num: Float64Array): void {
|
|
224
|
+
const distinct = [...buckets.keys()].sort(collator.compare);
|
|
225
|
+
// Texts the collator calls equal share a position, so they tie exactly as in compareSortKeys.
|
|
226
|
+
let position = 0;
|
|
227
|
+
for (let d = 0; d < distinct.length; d++) {
|
|
228
|
+
if (d > 0 && collator.compare(distinct[d - 1], distinct[d]) !== 0) position++;
|
|
229
|
+
for (const i of buckets.get(distinct[d]) ?? []) num[i] = position;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function addToBucket(buckets: Map<string, number[]>, text: string, index: number): void {
|
|
234
|
+
const bucket = buckets.get(text);
|
|
235
|
+
if (bucket) bucket.push(index);
|
|
236
|
+
else buckets.set(text, [index]);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Encode one key as (group, number) per item. The group is the value's rank, with the empty bucket
|
|
241
|
+
* last; the number is the value itself for numbers, dates and booleans, and for strings (and other
|
|
242
|
+
* values) the position of its text among the key's distinct texts in collator order. Comparing
|
|
243
|
+
* (group, number) gives exactly compareSortKeys' order, while the collator runs only to sort the
|
|
244
|
+
* distinct texts instead of in every row comparison.
|
|
245
|
+
*/
|
|
246
|
+
function encodeSortColumn(count: number, valueAt: (i: number) => unknown, typeAt: (i: number) => GridColDef['type']): SortColumn {
|
|
247
|
+
const group = new Uint8Array(count);
|
|
248
|
+
const num = new Float64Array(count);
|
|
249
|
+
const strings = new Map<string, number[]>();
|
|
250
|
+
const others = new Map<string, number[]>();
|
|
251
|
+
for (let i = 0; i < count; i++) {
|
|
252
|
+
const value = valueAt(i);
|
|
253
|
+
const type = typeAt(i);
|
|
254
|
+
if (typeof value === 'number' && type !== 'date') {
|
|
255
|
+
// toSortKey for a number, without the allocation.
|
|
256
|
+
if (Number.isNaN(value)) {
|
|
257
|
+
group[i] = GROUP_EMPTY;
|
|
258
|
+
} else {
|
|
259
|
+
group[i] = RANK_NUMBER;
|
|
260
|
+
num[i] = value;
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const key = toSortKey(value, type);
|
|
265
|
+
if (key === null) {
|
|
266
|
+
group[i] = GROUP_EMPTY;
|
|
267
|
+
} else {
|
|
268
|
+
group[i] = key.rank;
|
|
269
|
+
if (key.rank === RANK_STRING) addToBucket(strings, key.str, i);
|
|
270
|
+
else if (key.rank === RANK_OTHER) addToBucket(others, key.str, i);
|
|
271
|
+
else num[i] = key.num;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
rankTexts(strings, num);
|
|
275
|
+
rankTexts(others, num);
|
|
276
|
+
return { kind: 'encoded', group, num };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function genericSortColumn(values: readonly GridSortValue[]): SortColumn {
|
|
280
|
+
return { kind: 'generic', values, keys: values.map(({ value, type }) => toSortKey(value, type)) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Ascending comparison of items `ia` and `ib` on one key. */
|
|
284
|
+
function compareInColumn(column: SortColumn, ia: number, ib: number): number {
|
|
285
|
+
if (column.kind === 'encoded') {
|
|
286
|
+
const ga = column.group[ia];
|
|
287
|
+
const gb = column.group[ib];
|
|
288
|
+
if (ga !== gb) return ga - gb;
|
|
289
|
+
const na = column.num[ia];
|
|
290
|
+
const nb = column.num[ib];
|
|
291
|
+
return na < nb ? -1 : na > nb ? 1 : 0;
|
|
292
|
+
}
|
|
293
|
+
const a = column.values[ia];
|
|
294
|
+
const b = column.values[ib];
|
|
295
|
+
return a.comparator && a.comparator === b.comparator && a.params && b.params
|
|
296
|
+
? callSortComparator(a.comparator, a.params, b.params)
|
|
297
|
+
: compareSortKeys(column.keys[ia], column.keys[ib]);
|
|
298
|
+
}
|
|
215
299
|
|
|
216
|
-
|
|
300
|
+
/** Stable sort by prepared keys: the first key that differs decides, ties keep the input order. */
|
|
301
|
+
function sortByColumns<T>(items: readonly T[], sortModel: readonly GridSortItem[], columns: readonly SortColumn[]): T[] {
|
|
302
|
+
const n = items.length;
|
|
303
|
+
const order = new Array<number>(n);
|
|
304
|
+
for (let i = 0; i < n; i++) order[i] = i;
|
|
305
|
+
const first = columns[0];
|
|
306
|
+
if (columns.length === 1 && first.kind === 'encoded') {
|
|
307
|
+
// The common single-key case, without the per-comparison key loop.
|
|
308
|
+
const { group, num } = first;
|
|
309
|
+
const sign = sortModel[0].sort === 'asc' ? 1 : -1;
|
|
310
|
+
order.sort((ia, ib) => {
|
|
311
|
+
const ga = group[ia];
|
|
312
|
+
const gb = group[ib];
|
|
313
|
+
if (ga !== gb) return (ga - gb) * sign;
|
|
314
|
+
const na = num[ia];
|
|
315
|
+
const nb = num[ib];
|
|
316
|
+
if (na < nb) return -sign;
|
|
317
|
+
if (na > nb) return sign;
|
|
318
|
+
return ia - ib;
|
|
319
|
+
});
|
|
320
|
+
} else {
|
|
321
|
+
order.sort((ia, ib) => {
|
|
322
|
+
for (let k = 0; k < columns.length; k++) {
|
|
323
|
+
const comparison = applySortDirection(compareInColumn(columns[k], ia, ib), sortModel[k].sort);
|
|
324
|
+
if (comparison !== 0) return comparison;
|
|
325
|
+
}
|
|
326
|
+
return ia - ib;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
const result = new Array<T>(n);
|
|
330
|
+
for (let i = 0; i < n; i++) result[i] = items[order[i]];
|
|
331
|
+
return result;
|
|
217
332
|
}
|
|
218
333
|
|
|
219
334
|
/**
|
package/lib/utils/values.ts
CHANGED
|
@@ -53,6 +53,9 @@ export function tryGetCellValue<R extends GridRowModel>(
|
|
|
53
53
|
* per column), so one bad record cannot take the whole grid down.
|
|
54
54
|
*/
|
|
55
55
|
export function getCellValue<R extends GridRowModel>(row: R, field: string, colDef?: ValueGetterColumn<R>): unknown {
|
|
56
|
+
// Plain columns read the field directly: this runs once per row and column in every
|
|
57
|
+
// whole-dataset pass (sort, filter, grouping, aggregation), so it must not allocate.
|
|
58
|
+
if (!colDef?.valueGetter) return row[field];
|
|
56
59
|
const { value, error } = tryGetCellValue(row, field, colDef);
|
|
57
60
|
if (error !== undefined) {
|
|
58
61
|
warnCallbackFailure(`valueGetter:${field}`, `valueGetter for column "${field}" threw; the value is read as undefined`, error);
|
|
@@ -72,12 +75,13 @@ export function getFormattedValue<R extends GridRowModel>(
|
|
|
72
75
|
): string | undefined {
|
|
73
76
|
const formatter = colDef?.valueFormatter;
|
|
74
77
|
if (!formatter) return undefined;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
`valueFormatter for column "${field}" threw; the value is shown unformatted`,
|
|
80
|
-
|
|
78
|
+
// callSafely inlined: this runs for every cell of the quick filter's first search.
|
|
79
|
+
try {
|
|
80
|
+
return formatter({ value, row, field });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
warnCallbackFailure(`valueFormatter:${field}`, `valueFormatter for column "${field}" threw; the value is shown unformatted`, error);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
/**
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenGridX — AI Agent Context
|
|
2
2
|
# Package: @opencorestack/opengridx
|
|
3
|
-
# Version: 3.
|
|
3
|
+
# Version: 3.6.0
|
|
4
4
|
# Docs: https://opencorestack.github.io/OpenGridX/
|
|
5
5
|
# This file is machine-readable. AI developers: start here.
|
|
6
6
|
|
|
@@ -75,7 +75,7 @@ export default function App() {
|
|
|
75
75
|
| autoHeight | boolean | false | Fit height to rows (renders every row) |
|
|
76
76
|
| overscanRowCount | number | 3 | Minimum rows rendered outside the viewport (raised automatically while scrolling fast) |
|
|
77
77
|
| ariaLabel | string | — | Accessible name for the grid |
|
|
78
|
-
| localeText | GridLocaleText | — | Override
|
|
78
|
+
| localeText | GridLocaleText | — | Override strings: paginationRowsPerPage, paginationOf(from,to,count), paginationPage(page,count), noRowsLabel, cellSelectionAnnouncement, aiAssistantButton; header filter row (3.6): headerFilterCustom, headerFilterCustomTooltip, headerFilterCellLabel(h), headerFilterValueLabel(h), headerFilterOperatorButtonLabel(h, op), headerFilterOperatorMenuLabel(h), headerFilterClearLabel(h), headerFilterClearTooltip, headerFilterAny, headerFilterTrue, headerFilterFalse, headerFilterOperators (Partial<Record<GridFilterOperator,string>>) |
|
|
79
79
|
| rowHeight | number | 52 | Row height in pixels |
|
|
80
80
|
| headerHeight | number | 56 | Header height in pixels |
|
|
81
81
|
| wrapHeaderText | boolean | false | Wrap header titles within headerHeight (2 lines at 56px, 3 at 72, 4 at 88; ellipsis on the last line). Header never grows: raise headerHeight for more lines. Class ogx__header-cell--wrap. Per column: GridColDef.wrapHeaderText (3.3.0+) |
|
|
@@ -725,3 +725,5 @@ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, Pa
|
|
|
725
725
|
17. **Server modes without dataSource**: with `paginationMode` / `sortingMode` / `filterMode` = `'server'` the grid shows `rows` as given (no slicing, sorting or filtering). Fetch from the change callbacks and control the models.
|
|
726
726
|
18. **`valueFormatter` / `valueGetter` params are `unknown`-typed**: cast (`(value as number).toFixed(2)`) before calling methods, or TypeScript strict mode rejects it.
|
|
727
727
|
19. **Inline `columns` are reused while shallow-equal**: a new array with the same column objects does not re-run filtering/sorting, but mutating a column object in place is not detected — pass new objects when a definition changes.
|
|
728
|
+
20. **Next.js App Router**: render `<DataGrid>` from a `'use client'` file (column functions such as `valueFormatter` cannot cross from a Server Component) and import the stylesheet in `app/layout.tsx`. Rows fetched in a Server Component pass as props (serialisable data only). The ES bundle starts with `'use client'` (3.6+), so a Server Component may also render `<DataGrid>` with serialisable props. `next build` fails with `Can't resolve 'exceljs'` unless the optional peers are installed or aliased in `next.config.ts` (`turbopack.resolveAlias` to an empty module; webpack `resolve.alias[name] = false`). `@opencorestack/opengridx/ai` runs on the server too, but the validation brand is a Symbol that does not survive JSON: validate on the client (`createGridAiPromptHandler`). Guide: `docs/features/nextjs.md`.
|
|
729
|
+
21. **Right-to-left**: wrap the grid in `dir="rtl"` (or set `document.dir`); no prop. Works without pinned columns (3.6+): column order, text alignment (`align: 'left'`/'right' = start/end), horizontal scrolling + column virtualization, ArrowLeft = next column, resize handle on the left edge, fill handle bottom-left, toolbar panels open rightwards. **Pinned columns are not supported in RTL** (they do not stick); do not pin in RTL grids. Guide: `docs/features/rtl.md`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencorestack/opengridx",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.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",
|
|
@@ -53,12 +53,13 @@
|
|
|
53
53
|
"build": "tsc --project tsconfig.build.json && vite build",
|
|
54
54
|
"build:lib": "tsc --project tsconfig.build.json && vite build --config vite.config.lib.js && vite build --config vite.config.lib.js --mode ai && cp lib/styles/opengridx.css.d.ts dist/opengridx.css.d.ts && node scripts/check-bundle.mjs",
|
|
55
55
|
"lint": "eslint .",
|
|
56
|
-
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit && tsc -p e2e/tsconfig.json",
|
|
56
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit && tsc -p e2e/tsconfig.json && tsc -p bench/tsconfig.json",
|
|
57
57
|
"preview": "vite preview",
|
|
58
58
|
"test": "vitest run --project unit",
|
|
59
59
|
"test:browser": "vitest run --project browser",
|
|
60
60
|
"test:all": "vitest run",
|
|
61
61
|
"test:smoke": "node scripts/smoke.mjs",
|
|
62
|
+
"bench": "node bench/run.mjs",
|
|
62
63
|
"test:watch": "vitest --project unit",
|
|
63
64
|
"coverage": "vitest run --coverage",
|
|
64
65
|
"prepublishOnly": "npm run build:lib",
|