@opencorestack/opengridx 3.5.1 → 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 +36 -0
- package/README.md +2 -0
- 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 +6573 -6390
- 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 +14 -1
- package/docs/README.md +2 -0
- 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/performance.md +86 -118
- package/docs/roadmap.md +4 -2
- 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 +1 -0
- 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/useGridKeyboardNavigation.ts +7 -4
- package/lib/hooks/core/useGridScrollSync.ts +28 -5
- 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/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 +1 -1
|
@@ -115,7 +115,7 @@ export function Pagination(props: PaginationProps) {
|
|
|
115
115
|
aria-label="Go to first page"
|
|
116
116
|
title="First page"
|
|
117
117
|
>
|
|
118
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
118
|
+
<svg className="ogx-pagination__icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
119
119
|
<path d="M18.41 16.59L13.82 12l4.59-4.59L17 6l-6 6 6 6zM6 6h2v12H6z" />
|
|
120
120
|
</svg>
|
|
121
121
|
</Button>
|
|
@@ -129,7 +129,7 @@ export function Pagination(props: PaginationProps) {
|
|
|
129
129
|
aria-label="Go to previous page"
|
|
130
130
|
title="Previous page"
|
|
131
131
|
>
|
|
132
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
132
|
+
<svg className="ogx-pagination__icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
133
133
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
|
134
134
|
</svg>
|
|
135
135
|
</Button>
|
|
@@ -146,7 +146,7 @@ export function Pagination(props: PaginationProps) {
|
|
|
146
146
|
aria-label="Go to next page"
|
|
147
147
|
title="Next page"
|
|
148
148
|
>
|
|
149
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
149
|
+
<svg className="ogx-pagination__icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
150
150
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
|
151
151
|
</svg>
|
|
152
152
|
</Button>
|
|
@@ -160,7 +160,7 @@ export function Pagination(props: PaginationProps) {
|
|
|
160
160
|
aria-label="Go to last page"
|
|
161
161
|
title="Last page"
|
|
162
162
|
>
|
|
163
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
163
|
+
<svg className="ogx-pagination__icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
164
164
|
<path d="M5.59 7.41L10.18 12l-4.59 4.59L7 18l6-6-6-6zM16 6h2v12h-2z" />
|
|
165
165
|
</svg>
|
|
166
166
|
</Button>
|
|
@@ -300,7 +300,7 @@ function RowImpl<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
300
300
|
tabIndex={-1}
|
|
301
301
|
style={{
|
|
302
302
|
position: 'sticky',
|
|
303
|
-
|
|
303
|
+
insetInlineStart: 0,
|
|
304
304
|
zIndex: SYSTEM_CELL_Z_INDEX
|
|
305
305
|
}}
|
|
306
306
|
draggable={!!onDragStart}
|
|
@@ -326,7 +326,7 @@ function RowImpl<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
326
326
|
data-field="__expand_col__"
|
|
327
327
|
style={{
|
|
328
328
|
position: pinExpandColumn ? 'sticky' : undefined,
|
|
329
|
-
|
|
329
|
+
insetInlineStart: pinExpandColumn ? (rowReordering ? 48 : 0) : undefined,
|
|
330
330
|
zIndex: pinExpandColumn ? SYSTEM_CELL_Z_INDEX : undefined
|
|
331
331
|
}}
|
|
332
332
|
tabIndex={-1}
|
|
@@ -364,7 +364,7 @@ function RowImpl<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
364
364
|
data-field="__checkbox_col__"
|
|
365
365
|
style={{
|
|
366
366
|
position: pinCheckboxColumn ? 'sticky' : undefined,
|
|
367
|
-
|
|
367
|
+
insetInlineStart: pinCheckboxColumn ? ((rowReordering ? 48 : 0) + (hasDetailPanel && pinExpandColumn ? 48 : 0)) : undefined,
|
|
368
368
|
zIndex: pinCheckboxColumn ? SYSTEM_CELL_Z_INDEX : undefined
|
|
369
369
|
}}
|
|
370
370
|
tabIndex={-1}
|
|
@@ -72,8 +72,8 @@ export const SkeletonRow: React.FC<SkeletonRowProps> = ({
|
|
|
72
72
|
width,
|
|
73
73
|
minWidth: col.minWidth,
|
|
74
74
|
maxWidth: col.maxWidth,
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
insetInlineStart: col.left,
|
|
76
|
+
insetInlineEnd: col.right,
|
|
77
77
|
position: col.isSpacer ? 'relative' : (col.pinned ? 'sticky' : 'relative'),
|
|
78
78
|
zIndex: col.zIndex
|
|
79
79
|
}}
|
|
@@ -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)); }}
|
|
@@ -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
|
|
|
@@ -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 };
|
|
@@ -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 '—';
|
|
@@ -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
|
+
}
|