@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +2 -0
  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 +6573 -6390
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -16
  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/features/ai-toolkit.md +2 -2
  17. package/docs/features/export-guide.md +2 -0
  18. package/docs/features/header-filters.md +34 -0
  19. package/docs/features/keyboard-navigation.md +2 -0
  20. package/docs/features/nextjs.md +241 -0
  21. package/docs/features/pdf-export.md +2 -0
  22. package/docs/features/pinning.md +2 -0
  23. package/docs/features/rtl.md +58 -0
  24. package/docs/performance.md +86 -118
  25. package/docs/roadmap.md +4 -2
  26. package/lib/ai/promptHandler.ts +12 -8
  27. package/lib/ai/validate.ts +5 -2
  28. package/lib/components/AiAssistant/GridAiAssistantPanel.css +1 -1
  29. package/lib/components/Cell/Cell.css +29 -9
  30. package/lib/components/Cell/Cell.tsx +2 -2
  31. package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +2 -2
  32. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +3 -3
  33. package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +18 -2
  34. package/lib/components/DataGrid/DataGrid.css +12 -6
  35. package/lib/components/DataGrid/DataGrid.tsx +1 -0
  36. package/lib/components/DataGrid/GridAggregationFooter.tsx +3 -3
  37. package/lib/components/DataGrid/GridListView.tsx +3 -1
  38. package/lib/components/Header/ColumnMenu.css +1 -1
  39. package/lib/components/Header/ColumnMenu.tsx +14 -6
  40. package/lib/components/Header/Header.css +18 -10
  41. package/lib/components/Header/Header.tsx +16 -9
  42. package/lib/components/Header/headerColumnStyle.ts +3 -2
  43. package/lib/components/HeaderFilter/HeaderFilter.css +8 -2
  44. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +21 -18
  45. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +5 -4
  46. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +9 -3
  47. package/lib/components/ListView/ListViewRow.tsx +1 -1
  48. package/lib/components/Pagination/Pagination.css +4 -0
  49. package/lib/components/Pagination/Pagination.tsx +4 -4
  50. package/lib/components/Row/Row.tsx +3 -3
  51. package/lib/components/SkeletonRow/SkeletonRow.tsx +2 -2
  52. package/lib/components/Toolbar/GridToolbar.tsx +8 -28
  53. package/lib/components/Toolbar/PivotPanel.tsx +4 -7
  54. package/lib/components/ui/ExpandIcon.css +13 -0
  55. package/lib/components/ui/ExpandIcon.tsx +2 -4
  56. package/lib/hooks/core/useGridCellSelection.ts +20 -5
  57. package/lib/hooks/core/useGridColumns.tsx +2 -2
  58. package/lib/hooks/core/useGridKeyboardNavigation.ts +7 -4
  59. package/lib/hooks/core/useGridScrollSync.ts +28 -5
  60. package/lib/hooks/core/useLayout.ts +13 -6
  61. package/lib/hooks/useRowGrouping.ts +9 -9
  62. package/lib/types/index.ts +30 -0
  63. package/lib/utils/aggregation/index.ts +56 -1
  64. package/lib/utils/direction.ts +52 -0
  65. package/lib/utils/export/exportToExcelAdvanced.ts +9 -2
  66. package/lib/utils/export/exportToPdf.ts +2 -1
  67. package/lib/utils/filtering/index.ts +58 -6
  68. package/lib/utils/headerFilterLocale.ts +37 -0
  69. package/lib/utils/scroll/index.ts +5 -3
  70. package/lib/utils/sorting/index.ts +141 -26
  71. package/lib/utils/values.ts +10 -6
  72. package/llms.txt +4 -2
  73. 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
- left: 0,
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
- left: pinExpandColumn ? (rowReordering ? 48 : 0) : undefined,
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
- left: pinCheckboxColumn ? ((rowReordering ? 48 : 0) + (hasDetailPanel && pinExpandColumn ? 48 : 0)) : undefined,
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
- left: col.left,
76
- right: col.right,
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
- 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)); }}
@@ -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
 
@@ -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 };
@@ -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 '—';
@@ -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
+ }