@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
|
@@ -4,6 +4,7 @@ import type { GridColDef, GridAggregationModel, GridPivotModel, GridFilterModel
|
|
|
4
4
|
import { AGGREGATION_FUNCTIONS } from '../../utils/aggregation';
|
|
5
5
|
import type { BuiltInAggFn } from '../../utils/aggregation';
|
|
6
6
|
import { getViewportWidth } from '../../utils/viewport';
|
|
7
|
+
import { getPanelPosition, type GridPanelPosition } from '../../utils/direction';
|
|
7
8
|
import { GridToolbarHostContext } from '../../hooks/core/gridToolbarHostContext';
|
|
8
9
|
import { PivotPanel, PivotIcon } from './PivotPanel';
|
|
9
10
|
import { GlobalSearch } from './GlobalSearch';
|
|
@@ -149,7 +150,7 @@ function FilterIcon() {
|
|
|
149
150
|
);
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
type PanelPosition = GridPanelPosition;
|
|
153
154
|
|
|
154
155
|
function AggregationPanel({
|
|
155
156
|
anchorRef,
|
|
@@ -176,14 +177,7 @@ function AggregationPanel({
|
|
|
176
177
|
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
177
178
|
}, [anchorRef]);
|
|
178
179
|
|
|
179
|
-
const computePos = useCallback((): PanelPosition =>
|
|
180
|
-
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
181
|
-
const rect = anchorRef.current.getBoundingClientRect();
|
|
182
|
-
return {
|
|
183
|
-
top: rect.bottom + 6,
|
|
184
|
-
right: getViewportWidth() - rect.right,
|
|
185
|
-
};
|
|
186
|
-
}, [anchorRef]);
|
|
180
|
+
const computePos = useCallback((): PanelPosition => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]);
|
|
187
181
|
|
|
188
182
|
useEffect(() => {
|
|
189
183
|
setPos(computePos());
|
|
@@ -221,7 +215,7 @@ function AggregationPanel({
|
|
|
221
215
|
className="ogx-toolbar__panel"
|
|
222
216
|
role="dialog"
|
|
223
217
|
aria-label="Summaries configuration"
|
|
224
|
-
style={{ top: pos.top, right: pos.right, width: 400, maxWidth: 'calc(100vw - 24px)' }}
|
|
218
|
+
style={{ top: pos.top, right: pos.right, left: pos.left, width: 400, maxWidth: 'calc(100vw - 24px)' }}
|
|
225
219
|
>
|
|
226
220
|
<div className="ogx-toolbar__panel-header">
|
|
227
221
|
<span className="ogx-toolbar__panel-title">
|
|
@@ -309,14 +303,7 @@ function ColumnsPanelWrapper({
|
|
|
309
303
|
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
310
304
|
}, [anchorRef]);
|
|
311
305
|
|
|
312
|
-
const computePos = useCallback((): PanelPosition =>
|
|
313
|
-
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
314
|
-
const rect = anchorRef.current.getBoundingClientRect();
|
|
315
|
-
return {
|
|
316
|
-
top: rect.bottom + 6,
|
|
317
|
-
right: getViewportWidth() - rect.right,
|
|
318
|
-
};
|
|
319
|
-
}, [anchorRef]);
|
|
306
|
+
const computePos = useCallback((): PanelPosition => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]);
|
|
320
307
|
|
|
321
308
|
useEffect(() => {
|
|
322
309
|
setPos(computePos());
|
|
@@ -354,7 +341,7 @@ function ColumnsPanelWrapper({
|
|
|
354
341
|
className="ogx-toolbar__panel"
|
|
355
342
|
role="dialog"
|
|
356
343
|
aria-label="Column visibility"
|
|
357
|
-
style={{ top: pos.top, right: pos.right, width: 280, padding: 0 }}
|
|
344
|
+
style={{ top: pos.top, right: pos.right, left: pos.left, width: 280, padding: 0 }}
|
|
358
345
|
>
|
|
359
346
|
<ColumnVisibilityPanel
|
|
360
347
|
columns={columns}
|
|
@@ -398,14 +385,7 @@ function FilterPanelWrapper({
|
|
|
398
385
|
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
399
386
|
}, [anchorRef]);
|
|
400
387
|
|
|
401
|
-
const computePos = useCallback((): PanelPosition =>
|
|
402
|
-
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
403
|
-
const rect = anchorRef.current.getBoundingClientRect();
|
|
404
|
-
return {
|
|
405
|
-
top: rect.bottom + 6,
|
|
406
|
-
right: getViewportWidth() - rect.right,
|
|
407
|
-
};
|
|
408
|
-
}, [anchorRef]);
|
|
388
|
+
const computePos = useCallback((): PanelPosition => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]);
|
|
409
389
|
|
|
410
390
|
useEffect(() => {
|
|
411
391
|
setPos(computePos());
|
|
@@ -442,7 +422,7 @@ function FilterPanelWrapper({
|
|
|
442
422
|
className="ogx-toolbar__panel ogx-toolbar__panel--filter"
|
|
443
423
|
role="dialog"
|
|
444
424
|
aria-label="Advanced filters"
|
|
445
|
-
style={{ top: pos.top, right: pos.right, width: 540, maxWidth: 'calc(100vw - 24px)' }}
|
|
425
|
+
style={{ top: pos.top, right: pos.right, left: pos.left, width: 540, maxWidth: 'calc(100vw - 24px)' }}
|
|
446
426
|
>
|
|
447
427
|
<div className="ogx-toolbar__panel-header">
|
|
448
428
|
<span className="ogx-toolbar__panel-title">
|
|
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
|
|
|
3
3
|
import type { GridColDef } from '../../types';
|
|
4
4
|
import type { GridPivotModel, GridPivotAggFn, GridPivotValueField } from '../../types';
|
|
5
5
|
import { getViewportWidth } from '../../utils/viewport';
|
|
6
|
+
import { getPanelPosition, type GridPanelPosition } from '../../utils/direction';
|
|
6
7
|
|
|
7
8
|
const AGG_FNS: GridPivotAggFn[] = ['sum', 'avg', 'count', 'min', 'max'];
|
|
8
9
|
|
|
@@ -31,18 +32,14 @@ export interface PivotPanelProps {
|
|
|
31
32
|
|
|
32
33
|
export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: PivotPanelProps) {
|
|
33
34
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
34
|
-
const [pos, setPos] = useState({ top: 0, right: 0 });
|
|
35
|
+
const [pos, setPos] = useState<GridPanelPosition>({ top: 0, right: 0 });
|
|
35
36
|
const [portalContainer, setPortalContainer] = useState<Element | null>(null);
|
|
36
37
|
|
|
37
38
|
useLayoutEffect(() => {
|
|
38
39
|
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
39
40
|
}, [anchorRef]);
|
|
40
41
|
|
|
41
|
-
const computePos = useCallback(() =>
|
|
42
|
-
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
43
|
-
const rect = anchorRef.current.getBoundingClientRect();
|
|
44
|
-
return { top: rect.bottom + 6, right: getViewportWidth() - rect.right };
|
|
45
|
-
}, [anchorRef]);
|
|
42
|
+
const computePos = useCallback(() => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]);
|
|
46
43
|
|
|
47
44
|
useEffect(() => {
|
|
48
45
|
setPos(computePos());
|
|
@@ -132,7 +129,7 @@ export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: Piv
|
|
|
132
129
|
className="ogx-pivot-panel"
|
|
133
130
|
role="dialog"
|
|
134
131
|
aria-label="Pivot configuration"
|
|
135
|
-
style={{ top: pos.top, right: pos.right }}
|
|
132
|
+
style={{ top: pos.top, right: pos.right, left: pos.left }}
|
|
136
133
|
>
|
|
137
134
|
{ }
|
|
138
135
|
<div className="ogx-pivot-panel__header">
|
|
@@ -27,3 +27,16 @@
|
|
|
27
27
|
display: block;
|
|
28
28
|
transition: transform 0.2s ease;
|
|
29
29
|
}
|
|
30
|
+
/* The chevron points towards the inline end (right, or left in a right-to-left grid) and down when expanded. */
|
|
31
|
+
.ogx-expand-icon__chevron {
|
|
32
|
+
transform: rotate(0deg);
|
|
33
|
+
}
|
|
34
|
+
.ogx-expand-icon--expanded .ogx-expand-icon__chevron {
|
|
35
|
+
transform: rotate(90deg);
|
|
36
|
+
}
|
|
37
|
+
.ogx-expand-icon__chevron:dir(rtl) {
|
|
38
|
+
transform: scaleX(-1) rotate(0deg);
|
|
39
|
+
}
|
|
40
|
+
.ogx-expand-icon--expanded .ogx-expand-icon__chevron:dir(rtl) {
|
|
41
|
+
transform: scaleX(-1) rotate(90deg);
|
|
42
|
+
}
|
|
@@ -59,10 +59,8 @@ export function ExpandIcon({ isExpanded, onClick, variant = 'chevron', tabIndex
|
|
|
59
59
|
viewBox="0 0 16 16"
|
|
60
60
|
fill="none"
|
|
61
61
|
xmlns="http://www.w3.org/2000/svg"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
transition: 'transform 0.2s ease'
|
|
65
|
-
}}
|
|
62
|
+
className="ogx-expand-icon__chevron"
|
|
63
|
+
aria-hidden="true"
|
|
66
64
|
>
|
|
67
65
|
<path
|
|
68
66
|
d="M6 4L10 8L6 12"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import type { RefObject } from 'react';
|
|
3
3
|
import { scrollColumnIntoView, scrollRowIntoView } from '../../utils/scroll';
|
|
4
|
+
import { isRtl } from '../../utils/direction';
|
|
4
5
|
import { isSyntheticRowId } from '../../utils/syntheticRows';
|
|
5
6
|
import { isTextEntryElement } from '../../utils/focus';
|
|
6
7
|
import type { FocusedCell } from '../../utils/focus';
|
|
@@ -132,6 +133,11 @@ interface DragState {
|
|
|
132
133
|
y: number;
|
|
133
134
|
frame: number;
|
|
134
135
|
moved: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Whether the viewport lays out right to left, read once per drag: `getComputedStyle` every frame
|
|
138
|
+
* forces a style recalculation, which slowed WebKit's auto-scroll enough to delay the release.
|
|
139
|
+
*/
|
|
140
|
+
rtl?: boolean;
|
|
135
141
|
/** Ends the drag; `cancelled` unless the button was released normally (tracked drags: Escape cancels). */
|
|
136
142
|
stop: (cancelled?: boolean, altKey?: boolean) => void;
|
|
137
143
|
/** A tracked drag (fill handle): told the cell under the pointer each frame instead of moving the range. */
|
|
@@ -448,11 +454,17 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
448
454
|
const bodyTop = stickyTop ? stickyTop.getBoundingClientRect().bottom : box.top;
|
|
449
455
|
const bodyBottom = Math.min(box.top + height, stickyBottom ? stickyBottom.getBoundingClientRect().top : box.top + height);
|
|
450
456
|
const headerBottom = headerWrap ? headerWrap.getBoundingClientRect().bottom : bodyTop;
|
|
451
|
-
|
|
452
|
-
|
|
457
|
+
// Left-pinned columns (and pinned system columns) sit at the inline start: on the right in a
|
|
458
|
+
// right-to-left grid, whose vertical scrollbar is on the left.
|
|
459
|
+
if (drag.rtl === undefined) drag.rtl = isRtl(viewport);
|
|
460
|
+
const rtl = drag.rtl;
|
|
461
|
+
const contentLeft = rtl ? box.right - width : box.left;
|
|
462
|
+
const startPinnedWidth = layout.pinnedSystemColumnsWidth + layout.leftWidth;
|
|
463
|
+
const bodyLeft = contentLeft + (rtl ? layout.rightWidth : startPinnedWidth);
|
|
464
|
+
const bodyRight = contentLeft + width - (rtl ? startPinnedWidth : layout.rightWidth);
|
|
453
465
|
|
|
454
466
|
// The cell under the pointer; outside the viewport (or over the header) the nearest body cell.
|
|
455
|
-
const x = clamp(drag.x,
|
|
467
|
+
const x = clamp(drag.x, contentLeft + 1, contentLeft + width - 2);
|
|
456
468
|
let y = drag.y;
|
|
457
469
|
if (y < box.top || y > box.top + height || y < headerBottom) y = clamp(y, bodyTop + 1, Math.max(bodyTop + 1, bodyBottom - 2));
|
|
458
470
|
// During fast auto-scroll the rows under the pointer may not be rendered yet (virtualization
|
|
@@ -476,7 +488,7 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
476
488
|
if (drag.onTrack) drag.onTrack(drag.head, drag.x, drag.y);
|
|
477
489
|
|
|
478
490
|
if (!allowScroll) return;
|
|
479
|
-
const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x <
|
|
491
|
+
const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x < contentLeft || drag.x > contentLeft + width;
|
|
480
492
|
if (!drag.moved && !outside) return;
|
|
481
493
|
let dy = 0;
|
|
482
494
|
let dx = 0;
|
|
@@ -485,7 +497,10 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
485
497
|
if (drag.x < bodyLeft + AUTO_SCROLL_EDGE) dx = -autoScrollStep(bodyLeft + AUTO_SCROLL_EDGE - drag.x);
|
|
486
498
|
else if (drag.x > bodyRight - AUTO_SCROLL_EDGE) dx = autoScrollStep(drag.x - (bodyRight - AUTO_SCROLL_EDGE));
|
|
487
499
|
const canScrollY = (dy < 0 && viewport.scrollTop > 0) || (dy > 0 && viewport.scrollTop + height < viewport.scrollHeight - 1);
|
|
488
|
-
|
|
500
|
+
// scrollLeft runs from 0 to the maximum, or from minus the maximum to 0 right to left.
|
|
501
|
+
const maxScroll = viewport.scrollWidth - width;
|
|
502
|
+
const minLeft = rtl ? -maxScroll : 0;
|
|
503
|
+
const canScrollX = (dx < 0 && viewport.scrollLeft > minLeft) || (dx > 0 && viewport.scrollLeft < minLeft + maxScroll - 1);
|
|
489
504
|
if (!canScrollY && !canScrollX) return;
|
|
490
505
|
if (canScrollY) viewport.scrollTop += dy;
|
|
491
506
|
if (canScrollX) viewport.scrollLeft += dx;
|
|
@@ -282,8 +282,8 @@ export function useGridColumns<R extends GridRowModel>(
|
|
|
282
282
|
const content = custom.useDefault ? defaultHierarchyContent(cellParams, isRowGrouping, isTreeData) : custom.content;
|
|
283
283
|
|
|
284
284
|
return (
|
|
285
|
-
<div style={{ display: 'flex', alignItems: 'center',
|
|
286
|
-
<div style={{
|
|
285
|
+
<div style={{ display: 'flex', alignItems: 'center', paddingInlineStart: depth * 24, width: '100%', height: '100%' }}>
|
|
286
|
+
<div style={{ marginInlineEnd: 4, width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: hasChildren ? 'pointer' : 'default', flexShrink: 0 }}>
|
|
287
287
|
{hasChildren ? (
|
|
288
288
|
<div
|
|
289
289
|
onClick={(e) => { e.stopPropagation(); e.preventDefault(); activeHierarchyHandlers?.toggleExpansion(getRowId(cellParams.row)); }}
|
|
@@ -10,8 +10,16 @@ export interface UseGridDevWarningsParams {
|
|
|
10
10
|
totalRowCount: number;
|
|
11
11
|
/** `pinnedRows` is set while tree data or row grouping is active, where it has no effect. */
|
|
12
12
|
pinnedRowsIgnored?: boolean;
|
|
13
|
-
/** Pixel height of the scrollable content. */
|
|
13
|
+
/** Pixel height of the scrollable content, uncapped. */
|
|
14
14
|
scrollHeight?: number;
|
|
15
|
+
/**
|
|
16
|
+
* The tallest element the browser lays out (`getMaxElementHeight`): `undefined` until measured (the warning
|
|
17
|
+
* waits), `Infinity` where there is no layout engine (the lowest known cap is used).
|
|
18
|
+
*/
|
|
19
|
+
maxContentHeight?: number;
|
|
20
|
+
/** Unpinned rows the user can scroll to, and how many unpinned rows there are. */
|
|
21
|
+
reachableRowCount?: number;
|
|
22
|
+
unpinnedRowCount?: number;
|
|
15
23
|
/** `listView` is set but `listViewColumn` is not, so the grid view is shown. */
|
|
16
24
|
listViewWithoutColumn?: boolean;
|
|
17
25
|
/** Number of items the list view renders (0 when the list view is not shown). It does not virtualize. */
|
|
@@ -31,7 +39,7 @@ export const LIST_VIEW_WARN_THRESHOLD = 2000;
|
|
|
31
39
|
export const MAX_SCROLLABLE_HEIGHT = 17_800_000;
|
|
32
40
|
|
|
33
41
|
export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
|
|
34
|
-
const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, listViewWithoutColumn = false, listViewRowCount = 0 } = params;
|
|
42
|
+
const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, maxContentHeight, reachableRowCount, unpinnedRowCount, listViewWithoutColumn = false, listViewRowCount = 0 } = params;
|
|
35
43
|
const warnedPaginationRef = useRef(false);
|
|
36
44
|
const warnedUnboundedRef = useRef(false);
|
|
37
45
|
const warnedPinnedRowsRef = useRef(false);
|
|
@@ -67,14 +75,21 @@ export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
|
|
|
67
75
|
}, [pinnedRowsIgnored]);
|
|
68
76
|
|
|
69
77
|
useEffect(() => {
|
|
70
|
-
if (process.env.NODE_ENV === 'production' || warnedHeightRef.current ||
|
|
78
|
+
if (process.env.NODE_ENV === 'production' || warnedHeightRef.current || maxContentHeight === undefined) return;
|
|
79
|
+
const measured = Number.isFinite(maxContentHeight);
|
|
80
|
+
// Once the browser's cap is measured, warn only when rows are actually cut off; before that (and
|
|
81
|
+
// where there is no layout engine) fall back to the lowest known cap.
|
|
82
|
+
if (measured ? scrollHeight <= maxContentHeight : scrollHeight <= MAX_SCROLLABLE_HEIGHT) return;
|
|
71
83
|
warnedHeightRef.current = true;
|
|
84
|
+
const reach = measured && reachableRowCount !== undefined && unpinnedRowCount !== undefined
|
|
85
|
+
? `Only the first ${reachableRowCount.toLocaleString('en-US')} of ${unpinnedRowCount.toLocaleString('en-US')} rows can be scrolled to in this browser. `
|
|
86
|
+
: 'The last rows may not be reachable by scrolling. ';
|
|
72
87
|
console.warn(
|
|
73
|
-
`[OpenGridX] The grid content is ${Math.round(scrollHeight)}px tall
|
|
74
|
-
'(about 17.9M px in Firefox, 33.5M px in Chrome and Safari)
|
|
88
|
+
`[OpenGridX] The grid content is ${Math.round(scrollHeight)}px tall, more than this browser lays out ` +
|
|
89
|
+
'(about 17.9M px in Firefox, 33.5M px in Chrome and Safari). ' + reach +
|
|
75
90
|
'Use pagination, server-side or infinite loading, or a smaller rowHeight. See docs/features/virtualization.md.'
|
|
76
91
|
);
|
|
77
|
-
}, [scrollHeight]);
|
|
92
|
+
}, [scrollHeight, maxContentHeight, reachableRowCount, unpinnedRowCount]);
|
|
78
93
|
|
|
79
94
|
useEffect(() => {
|
|
80
95
|
if (process.env.NODE_ENV === 'production' || warnedPaginationRef.current) return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo, useRef, useLayoutEffect } from 'react';
|
|
2
|
+
import { getScrollStart, isRtl, logicalArrowKey, setScrollStart } from '../../utils/direction';
|
|
2
3
|
import { scrollRowIntoView } from '../../utils/scroll';
|
|
3
4
|
import { resolveCellEditable } from '../../utils/editing';
|
|
4
5
|
import { getCellValue } from '../../utils/values';
|
|
@@ -256,12 +257,13 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
256
257
|
const isPinned = field === REORDER_FIELD
|
|
257
258
|
|| (field === EXPAND_FIELD && pinExpandColumn)
|
|
258
259
|
|| (field === CHECKBOX_FIELD && pinCheckboxColumn);
|
|
259
|
-
if (!isPinned && el
|
|
260
|
+
if (!isPinned && getScrollStart(el) > 0) setScrollStart(el, 0);
|
|
260
261
|
return;
|
|
261
262
|
}
|
|
262
263
|
if (!columnMetrics) return;
|
|
263
264
|
|
|
264
|
-
const { clientWidth
|
|
265
|
+
const { clientWidth } = el;
|
|
266
|
+
const scrollLeft = getScrollStart(el);
|
|
265
267
|
const { leftPinnedWidth, rightPinnedWidth, unpinnedAccWidths, unpinnedCols, totalSpecialsWidth, pinnedSpecialsWidth } = columnMetrics;
|
|
266
268
|
const unpinnedIndex = unpinnedCols.findIndex(c => c.field === field);
|
|
267
269
|
if (unpinnedIndex === -1) return;
|
|
@@ -277,7 +279,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
277
279
|
} else if (colRight > visibleEnd) {
|
|
278
280
|
newScrollLeft = colRight - clientWidth + rightPinnedWidth;
|
|
279
281
|
}
|
|
280
|
-
if (newScrollLeft !== scrollLeft) el
|
|
282
|
+
if (newScrollLeft !== scrollLeft) setScrollStart(el, newScrollLeft);
|
|
281
283
|
}, [viewportRef, virtualization, pinnedTopRowCount, rowHeight, pinCheckboxColumn, pinExpandColumn]);
|
|
282
284
|
|
|
283
285
|
// Keep DOM focus on the element of the focused cell. This covers keyboard moves, a cell that
|
|
@@ -365,7 +367,8 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
365
367
|
// While an IME is composing, Enter / Escape / arrows confirm or navigate candidates.
|
|
366
368
|
if (event.nativeEvent?.isComposing || event.keyCode === 229) return;
|
|
367
369
|
|
|
368
|
-
|
|
370
|
+
// In a right-to-left grid ArrowLeft moves to the column on the left: the next column.
|
|
371
|
+
const key = logicalArrowKey(event.key, isRtl(viewport));
|
|
369
372
|
const isSpace = key === ' ' || key === 'Spacebar';
|
|
370
373
|
const isEditing = Boolean(editingHandlers.editingCell);
|
|
371
374
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useLayoutEffect, useState } from 'react';
|
|
2
|
+
import type { RefObject } from 'react';
|
|
3
|
+
import { getMaxElementHeight } from '../../utils/maxElementHeight';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The tallest element this browser lays out (see `getMaxElementHeight`), measured once per document after
|
|
7
|
+
* mount: `undefined` until measured, `Infinity` where there is no layout engine (jsdom, SSR).
|
|
8
|
+
*/
|
|
9
|
+
export function useGridMaxContentHeight(containerRef: RefObject<HTMLElement | null>): number | undefined {
|
|
10
|
+
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
const measured = getMaxElementHeight(containerRef.current?.ownerDocument);
|
|
13
|
+
setMaxHeight(prev => (prev === measured ? prev : measured));
|
|
14
|
+
}, [containerRef]);
|
|
15
|
+
return maxHeight;
|
|
16
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { getScrollStart, setScrollStart } from '../../utils/direction';
|
|
2
3
|
import type { GridRowScrollEndParams } from '../../types';
|
|
3
4
|
import { getRowsScrollEndParams } from '../../utils/scroll/scrollEnd';
|
|
4
5
|
|
|
@@ -82,16 +83,18 @@ export function useGridScrollSync(params: UseGridScrollSyncParams): UseGridScrol
|
|
|
82
83
|
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
|
83
84
|
const target = event.currentTarget;
|
|
84
85
|
const now = performance.now();
|
|
86
|
+
// Counted from the inline start, so the column window is the same in a right-to-left grid.
|
|
87
|
+
const scrollLeft = getScrollStart(target);
|
|
85
88
|
|
|
86
89
|
// Velocity: total positional delta / elapsed time (px/ms)
|
|
87
90
|
const deltaPos = Math.abs(target.scrollTop - prevRef.current.scrollTop)
|
|
88
|
-
+ Math.abs(
|
|
91
|
+
+ Math.abs(scrollLeft - prevRef.current.scrollLeft);
|
|
89
92
|
const deltaTime = now - prevRef.current.time;
|
|
90
93
|
const velocity = deltaTime > 0 ? deltaPos / deltaTime : 0;
|
|
91
94
|
const movedVertically = target.scrollTop !== prevRef.current.scrollTop;
|
|
92
95
|
|
|
93
|
-
prevRef.current = { scrollTop: target.scrollTop, scrollLeft
|
|
94
|
-
pendingRef.current = { scrollTop: target.scrollTop, scrollLeft
|
|
96
|
+
prevRef.current = { scrollTop: target.scrollTop, scrollLeft, time: now };
|
|
97
|
+
pendingRef.current = { scrollTop: target.scrollTop, scrollLeft, velocity };
|
|
95
98
|
|
|
96
99
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
97
100
|
rafRef.current = requestAnimationFrame(() => {
|
|
@@ -120,16 +123,36 @@ export function useGridScrollSync(params: UseGridScrollSyncParams): UseGridScrol
|
|
|
120
123
|
checkRowsScrollEnd(el);
|
|
121
124
|
}, [rowCount, autoHeight, checkRowsScrollEnd]);
|
|
122
125
|
|
|
126
|
+
// Switching `dir` on the grid or an ancestor moves the content to the other side, and browsers
|
|
127
|
+
// reset (Firefox without a scroll event) or keep the scroll position as they please. Re-read it
|
|
128
|
+
// so the column window matches what the viewport shows.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const doc = elementRef.current?.ownerDocument ?? (typeof document === 'undefined' ? null : document);
|
|
131
|
+
if (!doc || typeof MutationObserver === 'undefined') return;
|
|
132
|
+
const observer = new MutationObserver(() => {
|
|
133
|
+
const target = elementRef.current;
|
|
134
|
+
if (!target) return;
|
|
135
|
+
const actual = { scrollTop: target.scrollTop, scrollLeft: getScrollStart(target) };
|
|
136
|
+
const pending = pendingRef.current;
|
|
137
|
+
if (actual.scrollTop === pending.scrollTop && actual.scrollLeft === pending.scrollLeft) return;
|
|
138
|
+
pendingRef.current = { ...pending, ...actual };
|
|
139
|
+
prevRef.current = { ...prevRef.current, ...actual };
|
|
140
|
+
setScrollState(prev => ({ ...prev, ...actual }));
|
|
141
|
+
});
|
|
142
|
+
observer.observe(doc.documentElement, { attributes: true, attributeFilter: ['dir'], subtree: true });
|
|
143
|
+
return () => observer.disconnect();
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
123
146
|
const attachViewport = useCallback((element: HTMLDivElement | null) => {
|
|
124
147
|
elementRef.current = element;
|
|
125
148
|
if (!element) return;
|
|
126
149
|
const { scrollTop, scrollLeft } = pendingRef.current;
|
|
127
150
|
if (scrollTop === 0 && scrollLeft === 0) return;
|
|
128
151
|
element.scrollTop = scrollTop;
|
|
129
|
-
element
|
|
152
|
+
setScrollStart(element, scrollLeft);
|
|
130
153
|
// The browser clamps to the new content. If it did, sync the state to what the element
|
|
131
154
|
// shows (no scroll event fires when the position ends up unchanged).
|
|
132
|
-
const actual = { scrollTop: element.scrollTop, scrollLeft: element
|
|
155
|
+
const actual = { scrollTop: element.scrollTop, scrollLeft: getScrollStart(element) };
|
|
133
156
|
if (actual.scrollTop !== scrollTop || actual.scrollLeft !== scrollLeft) {
|
|
134
157
|
pendingRef.current = { ...pendingRef.current, ...actual };
|
|
135
158
|
prevRef.current = { ...prevRef.current, ...actual };
|
|
@@ -16,6 +16,11 @@ export interface UseGridVirtualizationParams<R extends GridRowModel> {
|
|
|
16
16
|
pinExpandColumn: boolean;
|
|
17
17
|
/** Adaptive overscan row count from useGridScrollSync. Defaults to 5. */
|
|
18
18
|
overscanRows?: number;
|
|
19
|
+
/**
|
|
20
|
+
* The tallest element the browser lays out (`getMaxElementHeight`); the scrollable content is capped to
|
|
21
|
+
* it. Defaults to no cap.
|
|
22
|
+
*/
|
|
23
|
+
maxContentHeight?: number;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel> {
|
|
@@ -28,6 +33,11 @@ export interface GridVirtualizationResult<R extends GridRowModel = GridRowModel>
|
|
|
28
33
|
offsetTop: number;
|
|
29
34
|
offsetLeft: number;
|
|
30
35
|
totalHeight: number;
|
|
36
|
+
/**
|
|
37
|
+
* Height of the unpinned rows' container: their full height, capped so the whole content stays under
|
|
38
|
+
* `maxContentHeight`. Rows below the cap cannot be scrolled to (the dev warning names how many can).
|
|
39
|
+
*/
|
|
40
|
+
virtualContainerHeight: number;
|
|
31
41
|
pinnedTopHeight: number;
|
|
32
42
|
pinnedBottomHeight: number;
|
|
33
43
|
totalWidth: number;
|
|
@@ -60,6 +70,7 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
60
70
|
pinCheckboxColumn,
|
|
61
71
|
pinExpandColumn,
|
|
62
72
|
overscanRows = 5,
|
|
73
|
+
maxContentHeight = Infinity,
|
|
63
74
|
} = params;
|
|
64
75
|
|
|
65
76
|
return useMemo<GridVirtualizationResult<R>>(() => {
|
|
@@ -122,6 +133,12 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
122
133
|
const hasHorizontalScroll = viewportWidth > 0 && totalWidth > viewportWidth;
|
|
123
134
|
const scrollbarBuffer = hasHorizontalScroll ? 16 : 0;
|
|
124
135
|
const totalHeight = pinnedTopHeight + unpinnedRowsHeight + pinnedBottomHeight + scrollbarBuffer + 2;
|
|
136
|
+
// Firefox collapses an element taller than its cap (about 17.9M px) instead of clamping it, which
|
|
137
|
+
// made a large grid unscrollable there; keep the rows' container under the browser's cap.
|
|
138
|
+
const fullContainerHeight = totalHeight - pinnedTopHeight - pinnedBottomHeight;
|
|
139
|
+
const virtualContainerHeight = Number.isFinite(maxContentHeight)
|
|
140
|
+
? Math.max(0, Math.min(fullContainerHeight, maxContentHeight - pinnedTopHeight - pinnedBottomHeight))
|
|
141
|
+
: fullContainerHeight;
|
|
125
142
|
|
|
126
143
|
let firstRowIndex = 0;
|
|
127
144
|
let lastRowIndex = unpinnedRowsLength - 1;
|
|
@@ -157,6 +174,7 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
157
174
|
offsetTop,
|
|
158
175
|
offsetLeft: 0,
|
|
159
176
|
totalHeight,
|
|
177
|
+
virtualContainerHeight,
|
|
160
178
|
pinnedTopHeight,
|
|
161
179
|
pinnedBottomHeight,
|
|
162
180
|
totalWidth,
|
|
@@ -185,5 +203,6 @@ export function useGridVirtualization<R extends GridRowModel>(
|
|
|
185
203
|
pinCheckboxColumn,
|
|
186
204
|
pinExpandColumn,
|
|
187
205
|
overscanRows,
|
|
206
|
+
maxContentHeight,
|
|
188
207
|
]);
|
|
189
208
|
}
|
|
@@ -255,12 +255,19 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
|
|
|
255
255
|
return rowHeight + getDetailPanelLayoutHeight(panelHeight, detailPanelHeights?.get(id));
|
|
256
256
|
};
|
|
257
257
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
258
|
+
// One pass with preallocated arrays: this runs for every row after each sort or filter. With
|
|
259
|
+
// no detail panel open every row has the plain height, so row ids are not looked up.
|
|
260
|
+
const rowCount = unpinnedRows.length;
|
|
261
|
+
const rowHeights = new Array<number>(rowCount);
|
|
262
|
+
const cumulativeHeights = new Array<number>(rowCount);
|
|
263
|
+
const anyPanelOpen = expandedRowIds.size > 0;
|
|
264
|
+
let runningHeight = 0;
|
|
265
|
+
for (let index = 0; index < rowCount; index++) {
|
|
266
|
+
const height = anyPanelOpen ? heightOf(unpinnedRows[index], pinnedTopRowsLength + index) : rowHeight;
|
|
267
|
+
rowHeights[index] = height;
|
|
268
|
+
runningHeight = (runningHeight || 0) + height; // as before: a NaN total restarts from 0
|
|
269
|
+
cumulativeHeights[index] = runningHeight;
|
|
270
|
+
}
|
|
264
271
|
|
|
265
272
|
const skeletonCount = (paginationMode === 'infinite' && isLoading && unpinnedRows.length > 0)
|
|
266
273
|
? Math.min(pageSize, 20)
|
|
@@ -307,13 +307,15 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
307
307
|
), [defaultGroupingExpansionDepth]);
|
|
308
308
|
|
|
309
309
|
const expandedGroupIds = useMemo<Set<GridRowId>>(() => {
|
|
310
|
+
// Only group nodes have children, so walk the groups rather than every leaf, in tree order.
|
|
310
311
|
const ids = new Set<GridRowId>();
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
for (const id of groupIds) {
|
|
313
|
+
const node = treeNodes.get(id);
|
|
314
|
+
if (!node?.children || node.children.length === 0) continue;
|
|
313
315
|
if (overrides.get(id) ?? isDefaultExpanded(node)) ids.add(id);
|
|
314
|
-
}
|
|
316
|
+
}
|
|
315
317
|
return ids;
|
|
316
|
-
}, [treeNodes, overrides, isDefaultExpanded]);
|
|
318
|
+
}, [groupIds, treeNodes, overrides, isDefaultExpanded]);
|
|
317
319
|
|
|
318
320
|
const toggleExpansion = useCallback((id: GridRowId) => {
|
|
319
321
|
setExpansionState(prev => {
|
|
@@ -439,11 +441,9 @@ export function useRowGrouping<R extends GridRowModel>(params: UseRowGroupingPar
|
|
|
439
441
|
treeNodes.forEach((node, id) => {
|
|
440
442
|
const variants = groupRowVariants.get(id);
|
|
441
443
|
if (!variants) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
isGroupRow: false,
|
|
446
|
-
});
|
|
444
|
+
// One object per row (not shared per depth): `params.rowMeta` is public, and a consumer that
|
|
445
|
+
// writes to it must not change other rows.
|
|
446
|
+
map.set(id, { hasChildren: false, treeDepth: node.depth, isGroupRow: false });
|
|
447
447
|
return;
|
|
448
448
|
}
|
|
449
449
|
const groupMeta: GridRowMeta = {
|
package/lib/types/index.ts
CHANGED
|
@@ -940,6 +940,36 @@ export interface GridLocaleText {
|
|
|
940
940
|
cellSelectionAnnouncement?: (cellCount: number, rowCount: number, columnCount: number) => string;
|
|
941
941
|
/** Label of the toolbar button that opens the `aiAssistant` panel. Default: `"Ask AI"` @since v3.5 */
|
|
942
942
|
aiAssistantButton?: string;
|
|
943
|
+
/** Header filter cell whose column has a filter the row cannot edit. Default: `"Custom filter"` @since v3.6 */
|
|
944
|
+
headerFilterCustom?: string;
|
|
945
|
+
/** Tooltip of the "Custom filter" cell. Default: `"Set in the filter panel"` @since v3.6 */
|
|
946
|
+
headerFilterCustomTooltip?: string;
|
|
947
|
+
/** `aria-label` of a header filter cell. Receives the column header. Default: `` (h) => `Filter ${h}` `` @since v3.6 */
|
|
948
|
+
headerFilterCellLabel?: (header: string) => string;
|
|
949
|
+
/** `aria-label` of the value input or select. Default: `` (h) => `Filter value for ${h}` `` @since v3.6 */
|
|
950
|
+
headerFilterValueLabel?: (header: string) => string;
|
|
951
|
+
/**
|
|
952
|
+
* `aria-label` of the operator button. Receives the header and the operator's label.
|
|
953
|
+
* Default: `` (h, op) => `Filter operator for ${h}: ${op}` `` @since v3.6
|
|
954
|
+
*/
|
|
955
|
+
headerFilterOperatorButtonLabel?: (header: string, operatorLabel: string) => string;
|
|
956
|
+
/** `aria-label` of the operator menu. Default: `` (h) => `Filter operator for ${h}` `` @since v3.6 */
|
|
957
|
+
headerFilterOperatorMenuLabel?: (header: string) => string;
|
|
958
|
+
/** `aria-label` of the clear button. Default: `` (h) => `Clear filter for ${h}` `` @since v3.6 */
|
|
959
|
+
headerFilterClearLabel?: (header: string) => string;
|
|
960
|
+
/** Tooltip of the clear button. Default: `"Clear"` @since v3.6 */
|
|
961
|
+
headerFilterClearTooltip?: string;
|
|
962
|
+
/** Empty choice of the boolean and single-select filters. Default: `"Any"` @since v3.6 */
|
|
963
|
+
headerFilterAny?: string;
|
|
964
|
+
/** "true" choice of the boolean filter. Default: `"Yes"` @since v3.6 */
|
|
965
|
+
headerFilterTrue?: string;
|
|
966
|
+
/** "false" choice of the boolean filter. Default: `"No"` @since v3.6 */
|
|
967
|
+
headerFilterFalse?: string;
|
|
968
|
+
/**
|
|
969
|
+
* Operator names in the header filter menu, tooltips and no-value cells ("Is empty"). Missing
|
|
970
|
+
* operators keep their English name. @since v3.6
|
|
971
|
+
*/
|
|
972
|
+
headerFilterOperators?: Partial<Record<GridFilterOperator, string>>;
|
|
943
973
|
}
|
|
944
974
|
|
|
945
975
|
/**
|
|
@@ -85,11 +85,66 @@ export function computeAggregations<R extends GridRowModel>(
|
|
|
85
85
|
console.warn(`[${warnPrefix}] Unknown aggregation function: "${fnName}"`);
|
|
86
86
|
continue;
|
|
87
87
|
}
|
|
88
|
-
result[field] =
|
|
88
|
+
result[field] = Object.prototype.hasOwnProperty.call(AGGREGATION_FUNCTIONS, fnName)
|
|
89
|
+
? aggregateRows(rows, field, colDef as GridColDef | undefined, fnName as BuiltInAggFn)
|
|
90
|
+
: fn(rows.map((row) => getCellValue(row, field, colDef as GridColDef | undefined)));
|
|
89
91
|
}
|
|
90
92
|
return result;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* AGGREGATION_FUNCTIONS[fnName] over the cells of `field`, read row by row instead of through
|
|
97
|
+
* intermediate value arrays (group rows and the footer aggregate every row). Same additions in the
|
|
98
|
+
* same order, so the results are identical.
|
|
99
|
+
*/
|
|
100
|
+
function aggregateRows(rows: readonly GridRowModel[], field: string, colDef: GridColDef | undefined, fnName: BuiltInAggFn): unknown {
|
|
101
|
+
switch (fnName) {
|
|
102
|
+
case 'sum':
|
|
103
|
+
case 'avg': {
|
|
104
|
+
let sum = 0;
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
const n = toNumber(getCellValue(row, field, colDef));
|
|
108
|
+
if (n === null) continue;
|
|
109
|
+
sum += n;
|
|
110
|
+
count++;
|
|
111
|
+
}
|
|
112
|
+
if (fnName === 'sum') return sum;
|
|
113
|
+
return count ? sum / count : null;
|
|
114
|
+
}
|
|
115
|
+
case 'count': {
|
|
116
|
+
let count = 0;
|
|
117
|
+
for (const row of rows) if (!isEmptyAggregateValue(getCellValue(row, field, colDef))) count++;
|
|
118
|
+
return count;
|
|
119
|
+
}
|
|
120
|
+
case 'min':
|
|
121
|
+
case 'max': {
|
|
122
|
+
const isMin = fnName === 'min';
|
|
123
|
+
let best: number | null = null;
|
|
124
|
+
let bestValue: unknown = null;
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
const v = getCellValue(row, field, colDef);
|
|
127
|
+
const n = toNumber(v);
|
|
128
|
+
if (n === null) continue;
|
|
129
|
+
if (best === null || (isMin ? n < best : n > best)) {
|
|
130
|
+
best = n;
|
|
131
|
+
bestValue = v;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (best === null) return null;
|
|
135
|
+
return bestValue instanceof Date ? bestValue : best;
|
|
136
|
+
}
|
|
137
|
+
case 'unique': {
|
|
138
|
+
const seen = new Set<unknown>();
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
const v = getCellValue(row, field, colDef);
|
|
141
|
+
if (!isEmptyAggregateValue(v)) seen.add(v);
|
|
142
|
+
}
|
|
143
|
+
return seen.size;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
93
148
|
/** Default display string for an aggregate: `—` when missing, locale-grouped numbers, avg to 2 decimals. */
|
|
94
149
|
export function formatAggregationValue(value: unknown, fnName: string): string {
|
|
95
150
|
if (value == null) return '—';
|