@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +6 -3
  3. package/dist/ai.cjs +4 -2
  4. package/dist/ai.cjs.map +1 -1
  5. package/dist/ai.d.ts +2 -2
  6. package/dist/ai.es.js +4 -2
  7. package/dist/ai.es.js.map +1 -1
  8. package/dist/index.d.ts +32 -0
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +6924 -6691
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +17 -17
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +14 -1
  15. package/docs/README.md +2 -0
  16. package/docs/architecture/datagrid-orchestration.md +2 -1
  17. package/docs/features/ai-toolkit.md +2 -2
  18. package/docs/features/export-guide.md +2 -0
  19. package/docs/features/header-filters.md +34 -0
  20. package/docs/features/keyboard-navigation.md +2 -0
  21. package/docs/features/nextjs.md +241 -0
  22. package/docs/features/pdf-export.md +2 -0
  23. package/docs/features/pinning.md +2 -0
  24. package/docs/features/rtl.md +58 -0
  25. package/docs/features/virtualization.md +5 -5
  26. package/docs/performance.md +180 -1
  27. package/docs/roadmap.md +5 -3
  28. package/lib/ai/promptHandler.ts +12 -8
  29. package/lib/ai/validate.ts +5 -2
  30. package/lib/components/AiAssistant/GridAiAssistantPanel.css +1 -1
  31. package/lib/components/Cell/Cell.css +29 -9
  32. package/lib/components/Cell/Cell.tsx +2 -2
  33. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +2 -2
  34. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +3 -3
  35. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +18 -2
  36. package/lib/components/DataGrid/DataGrid.css +12 -6
  37. package/lib/components/DataGrid/DataGrid.tsx +9 -1
  38. package/lib/components/DataGrid/GridAggregationFooter.tsx +3 -3
  39. package/lib/components/DataGrid/GridListView.tsx +3 -1
  40. package/lib/components/Header/ColumnMenu.css +1 -1
  41. package/lib/components/Header/ColumnMenu.tsx +14 -6
  42. package/lib/components/Header/Header.css +18 -10
  43. package/lib/components/Header/Header.tsx +16 -9
  44. package/lib/components/Header/headerColumnStyle.ts +3 -2
  45. package/lib/components/HeaderFilter/HeaderFilter.css +8 -2
  46. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +21 -18
  47. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +5 -4
  48. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +9 -3
  49. package/lib/components/ListView/ListViewRow.tsx +1 -1
  50. package/lib/components/Pagination/Pagination.css +4 -0
  51. package/lib/components/Pagination/Pagination.tsx +4 -4
  52. package/lib/components/Row/Row.tsx +3 -3
  53. package/lib/components/SkeletonRow/SkeletonRow.tsx +2 -2
  54. package/lib/components/Toolbar/GridToolbar.tsx +8 -28
  55. package/lib/components/Toolbar/PivotPanel.tsx +4 -7
  56. package/lib/components/ui/ExpandIcon.css +13 -0
  57. package/lib/components/ui/ExpandIcon.tsx +2 -4
  58. package/lib/hooks/core/useGridCellSelection.ts +20 -5
  59. package/lib/hooks/core/useGridColumns.tsx +2 -2
  60. package/lib/hooks/core/useGridDevWarnings.ts +21 -6
  61. package/lib/hooks/core/useGridKeyboardNavigation.ts +7 -4
  62. package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
  63. package/lib/hooks/core/useGridScrollSync.ts +28 -5
  64. package/lib/hooks/core/useGridVirtualization.ts +19 -0
  65. package/lib/hooks/core/useLayout.ts +13 -6
  66. package/lib/hooks/useRowGrouping.ts +9 -9
  67. package/lib/types/index.ts +30 -0
  68. package/lib/utils/aggregation/index.ts +56 -1
  69. package/lib/utils/direction.ts +52 -0
  70. package/lib/utils/export/exportToExcelAdvanced.ts +9 -2
  71. package/lib/utils/export/exportToPdf.ts +2 -1
  72. package/lib/utils/filtering/index.ts +58 -6
  73. package/lib/utils/headerFilterLocale.ts +37 -0
  74. package/lib/utils/maxElementHeight.ts +53 -0
  75. package/lib/utils/scroll/index.ts +5 -3
  76. package/lib/utils/sorting/index.ts +141 -26
  77. package/lib/utils/values.ts +10 -6
  78. package/llms.txt +4 -2
  79. 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
- interface PanelPosition { top: number; right: number; }
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
- style={{
63
- transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
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
- const bodyLeft = box.left + layout.pinnedSystemColumnsWidth + layout.leftWidth;
452
- const bodyRight = box.left + width - layout.rightWidth;
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, box.left + 1, box.left + width - 2);
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 < box.left || drag.x > box.left + width;
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
- const canScrollX = (dx < 0 && viewport.scrollLeft > 0) || (dx > 0 && viewport.scrollLeft + width < viewport.scrollWidth - 1);
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', paddingLeft: depth * 24, width: '100%', height: '100%' }}>
286
- <div style={{ marginRight: 4, width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: hasChildren ? 'pointer' : 'default', flexShrink: 0 }}>
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 || scrollHeight <= MAX_SCROLLABLE_HEIGHT) return;
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. Browsers cap element heights ` +
74
- '(about 17.9M px in Firefox, 33.5M px in Chrome and Safari), so the last rows may not be reachable by scrolling. ' +
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.scrollLeft > 0) el.scrollLeft = 0;
260
+ if (!isPinned && getScrollStart(el) > 0) setScrollStart(el, 0);
260
261
  return;
261
262
  }
262
263
  if (!columnMetrics) return;
263
264
 
264
- const { clientWidth, scrollLeft } = el;
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.scrollLeft = newScrollLeft;
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
- const { key } = event;
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(target.scrollLeft - prevRef.current.scrollLeft);
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: target.scrollLeft, time: now };
94
- pendingRef.current = { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, velocity };
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.scrollLeft = scrollLeft;
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.scrollLeft };
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
- const rowHeights = unpinnedRows.map((row, index) => heightOf(row, pinnedTopRowsLength + index));
259
-
260
- const cumulativeHeights = rowHeights.reduce((acc, height, index) => {
261
- acc.push((acc[index - 1] || 0) + height);
262
- return acc;
263
- }, [] as number[]);
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
- treeNodes.forEach((node, id) => {
312
- if (!node.children || node.children.length === 0) return;
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
- map.set(id, {
443
- hasChildren: false,
444
- treeDepth: node.depth,
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 = {
@@ -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] = fn(rows.map((row) => getCellValue(row, field, colDef as GridColDef | undefined)));
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 '—';