@opencorestack/opengridx 3.2.1 → 3.3.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 (59) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +12 -1
  3. package/dist/index.d.ts +173 -1
  4. package/dist/opengridx.css +1 -1
  5. package/dist/opengridx.es.js +5478 -4841
  6. package/dist/opengridx.es.js.map +1 -1
  7. package/dist/opengridx.umd.js +15 -14
  8. package/dist/opengridx.umd.js.map +1 -1
  9. package/docs/API_REFERENCE.md +34 -2
  10. package/docs/README.md +1 -0
  11. package/docs/architecture/datagrid-orchestration.md +13 -5
  12. package/docs/components/header.md +25 -0
  13. package/docs/customization/theming.md +3 -1
  14. package/docs/features/cell-selection.md +131 -0
  15. package/docs/features/clipboard.md +2 -0
  16. package/docs/features/keyboard-navigation.md +14 -2
  17. package/docs/roadmap.md +64 -7
  18. package/lib/components/Cell/Cell.css +30 -0
  19. package/lib/components/Cell/Cell.tsx +25 -13
  20. package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
  21. package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
  22. package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
  23. package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
  24. package/lib/components/DataGrid/DataGrid.tsx +61 -1
  25. package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
  26. package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
  27. package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
  28. package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
  29. package/lib/components/FilterPanel/FilterPanel.tsx +2 -1
  30. package/lib/components/Header/Header.css +12 -0
  31. package/lib/components/Header/Header.tsx +17 -4
  32. package/lib/components/Row/Row.tsx +38 -1
  33. package/lib/components/Toolbar/GridToolbar.tsx +10 -4
  34. package/lib/components/ui/Checkbox.tsx +10 -5
  35. package/lib/hooks/core/gridApiPlaceholder.ts +6 -0
  36. package/lib/hooks/core/useGridCellSelection.ts +603 -0
  37. package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
  38. package/lib/hooks/core/useGridClipboardApi.ts +8 -2
  39. package/lib/hooks/core/useGridControlledState.ts +38 -0
  40. package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
  41. package/lib/hooks/core/useGridRowInteractions.ts +8 -4
  42. package/lib/hooks/core/useGridThemeDimensions.ts +1 -1
  43. package/lib/hooks/features/useAggregation.ts +8 -6
  44. package/lib/hooks/features/useGridClipboard.ts +47 -2
  45. package/lib/hooks/features/useGridEditing.ts +16 -7
  46. package/lib/index.ts +9 -0
  47. package/lib/styles/base/variables.css +6 -1
  48. package/lib/styles/opengridx.css +1 -0
  49. package/lib/theme/DataGridThemeProvider.tsx +4 -0
  50. package/lib/theme/presets.ts +8 -0
  51. package/lib/theme/types.ts +4 -0
  52. package/lib/types/index.ts +130 -0
  53. package/lib/utils/attempt.ts +14 -0
  54. package/lib/utils/cellSelection.ts +238 -0
  55. package/lib/utils/columnAutosize.ts +1 -1
  56. package/lib/utils/export/exportShared.ts +11 -2
  57. package/lib/utils/headerWrap.ts +23 -0
  58. package/llms.txt +17 -2
  59. package/package.json +11 -3
@@ -0,0 +1,31 @@
1
+ /* Status bar of showCellSelectionStats (v3.3). The area keeps its height while no range is selected. */
2
+ .ogx__cell-selection-stats-area {
3
+ flex: 0 0 auto;
4
+ min-height: 32px;
5
+ border-top: var(--ogx-border-width-thin, 1px) solid var(--ogx-grid-border-color);
6
+ background: var(--ogx-grid-header-background);
7
+ color: var(--ogx-grid-header-text);
8
+ font-size: var(--ogx-font-size-sm);
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: flex-end;
12
+ box-sizing: border-box;
13
+ }
14
+ .ogx__cell-selection-stats {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: var(--ogx-spacing-lg, 16px);
18
+ padding: 4px 16px;
19
+ }
20
+ .ogx__cell-selection-stat {
21
+ display: inline-flex;
22
+ gap: 6px;
23
+ white-space: nowrap;
24
+ }
25
+ .ogx__cell-selection-stat-label {
26
+ opacity: 0.75;
27
+ }
28
+ .ogx__cell-selection-stat-value {
29
+ font-weight: 600;
30
+ font-variant-numeric: tabular-nums;
31
+ }
@@ -0,0 +1,32 @@
1
+ import { formatAggregationValue } from '../../utils/aggregation';
2
+ import type { GridCellSelectionStatsSlotProps } from '../../types';
3
+
4
+ export type CellSelectionStatsProps = GridCellSelectionStatsSlotProps & Record<string, unknown>;
5
+
6
+ /**
7
+ * The status bar of `showCellSelectionStats`: count of non-empty cells, and the sum and average of
8
+ * the numeric cells of the selected range. Replace it with `slots.cellSelectionStats`, or wrap it.
9
+ * @since v3.3
10
+ */
11
+ export function CellSelectionStats({ count, sum, average }: CellSelectionStatsProps) {
12
+ return (
13
+ <div className="ogx__cell-selection-stats">
14
+ <span className="ogx__cell-selection-stat">
15
+ <span className="ogx__cell-selection-stat-label">Count</span>
16
+ <span className="ogx__cell-selection-stat-value" data-stat="count">{count.toLocaleString()}</span>
17
+ </span>
18
+ {sum !== null && (
19
+ <span className="ogx__cell-selection-stat">
20
+ <span className="ogx__cell-selection-stat-label">Sum</span>
21
+ <span className="ogx__cell-selection-stat-value" data-stat="sum">{formatAggregationValue(sum, 'sum')}</span>
22
+ </span>
23
+ )}
24
+ {average !== null && (
25
+ <span className="ogx__cell-selection-stat">
26
+ <span className="ogx__cell-selection-stat-label">Average</span>
27
+ <span className="ogx__cell-selection-stat-value" data-stat="average">{formatAggregationValue(average, 'avg')}</span>
28
+ </span>
29
+ )}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,24 @@
1
+ import type React from 'react';
2
+ import { CellSelectionStats } from './CellSelectionStats';
3
+ import type { GridCellSelectionStats } from '../../hooks/core/useGridCellSelection';
4
+ import type { GridApi, GridCellSelectionStatsSlotProps } from '../../types';
5
+
6
+ interface GridCellSelectionStatsAreaProps {
7
+ stats: GridCellSelectionStats | null;
8
+ apiRef: React.MutableRefObject<GridApi>;
9
+ slot?: React.ComponentType<GridCellSelectionStatsSlotProps & Record<string, unknown>>;
10
+ slotProps?: Partial<GridCellSelectionStatsSlotProps> & Record<string, unknown>;
11
+ }
12
+
13
+ /**
14
+ * The strip under the viewport that holds the status bar. It keeps its height while one cell or
15
+ * none is selected, so the viewport does not resize each time a range starts or ends.
16
+ */
17
+ export function GridCellSelectionStatsArea({ stats, apiRef, slot, slotProps }: GridCellSelectionStatsAreaProps) {
18
+ const Component = slot ?? CellSelectionStats;
19
+ return (
20
+ <div className="ogx__cell-selection-stats-area" aria-hidden={stats ? undefined : true}>
21
+ {stats && <Component apiRef={apiRef} {...stats} {...slotProps} />}
22
+ </div>
23
+ );
24
+ }
@@ -52,6 +52,9 @@ import { useGridAggregationApi } from '../../hooks/core/useGridAggregationApi';
52
52
  import { useGridClipboardApi } from '../../hooks/core/useGridClipboardApi';
53
53
  import { useGridScrollToIndexesApi } from '../../hooks/core/useGridScrollToIndexesApi';
54
54
  import { useGridColumnAutosize } from '../../hooks/core/useGridColumnAutosize';
55
+ import { useGridCellSelection } from '../../hooks/core/useGridCellSelection';
56
+ import { useGridCellSelectionApi } from '../../hooks/core/useGridCellSelectionApi';
57
+ import { GridCellSelectionStatsArea } from '../CellSelectionStats/GridCellSelectionStatsArea';
55
58
  import { useGridSortHandlers, useGridColumnMenuHandlers } from '../../hooks/core/useGridHeaderHandlers';
56
59
  import { useGridKeyboardMode, useGridPointerFocusHandlers } from '../../hooks/core/useGridFocusHandlers';
57
60
  import { getPaginationRowCount, pickPaginationLocaleText } from '../../utils/pagination';
@@ -76,12 +79,17 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
76
79
  getRowId,
77
80
  rowHeight: rowHeightProp,
78
81
  headerHeight: headerHeightProp,
82
+ wrapHeaderText = false,
79
83
  autoHeight = false,
80
84
  density,
81
85
  checkboxSelection = false,
82
86
  disableRowSelectionOnClick = false,
83
87
  disableMultipleRowSelection = false,
84
88
  disableClipboardCopy = false,
89
+ cellSelection: cellSelectionProp = false,
90
+ cellSelectionModel: propCellSelectionModel,
91
+ onCellSelectionModelChange,
92
+ showCellSelectionStats = false,
85
93
  rowSelectionModel: propRowSelectionModel,
86
94
  onRowSelectionModelChange: propOnRowSelectionModelChange,
87
95
  onRowClick,
@@ -184,6 +192,8 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
184
192
  rowSelectionModel: propRowSelectionModel,
185
193
  onRowSelectionModelChange: propOnRowSelectionModelChange,
186
194
  density,
195
+ cellSelectionModel: propCellSelectionModel,
196
+ onCellSelectionModelChange,
187
197
  });
188
198
 
189
199
  const {
@@ -493,12 +503,34 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
493
503
  [layout.leftPinnedCols, layout.unpinnedColsWithWidth, layout.rightPinnedCols]
494
504
  );
495
505
 
506
+ // Cell range selection (cellSelection): the range, pointer drag and Shift-keys, copy text, totals.
507
+ const cellSelection = useGridCellSelection<R>({
508
+ enabled: cellSelectionProp && !listView,
509
+ model: controlledState.cellSelectionModel,
510
+ onModelChange: controlledState.handleCellSelectionModelChange,
511
+ getPendingModel: controlledState.getPendingCellSelectionModel,
512
+ allRenderableRows,
513
+ getRowId: getRowIdOf,
514
+ navigationColumns,
515
+ columnIndexMap,
516
+ spanning,
517
+ rowMetaMap,
518
+ layout,
519
+ rowHeight: effectiveRowHeight,
520
+ pinnedTopRowCount: pinnedTopRows.length,
521
+ viewportRef,
522
+ containerRef,
523
+ localeText,
524
+ showStats: showCellSelectionStats,
525
+ });
526
+
496
527
  useGridClipboardApi({
497
528
  apiRef,
498
529
  columns: renderedDataColumns as unknown as GridColDef[],
499
530
  getRowId: getRowIdOf,
500
531
  containerRef,
501
532
  disableKeyboardShortcut: disableClipboardCopy,
533
+ getCellRangeCopyText: cellSelection.enabled ? cellSelection.getCopyText : undefined,
502
534
  });
503
535
  useGridScrollToIndexesApi<R>({ apiRef, viewportRef, layout, rowHeight: effectiveRowHeight });
504
536
  const handleColumnAutosize = useGridColumnAutosize<R>({ apiRef, containerRef, columns: orderedColumns, onColumnResize: handleColumnResize });
@@ -541,11 +573,23 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
541
573
  pinCheckboxColumn,
542
574
  pinExpandColumn,
543
575
  rowHeight: effectiveRowHeight,
576
+ cellSelection: cellSelection.keyboard,
544
577
  });
545
578
 
546
579
  const { handleCellClick, handleHeaderClick } = useGridPointerFocusHandlers<R>({
547
580
  ensureGridFocus, setKeyboardMode, setFocusedCell, getRowId: getRowIdOf, onCellClick,
548
581
  });
582
+ // Keeps the range anchor and the focused cell together; installs the cell selection API.
583
+ useGridCellSelectionApi({
584
+ apiRef,
585
+ selection: cellSelection,
586
+ model: controlledState.cellSelectionModel,
587
+ getPendingModel: controlledState.getPendingCellSelectionModel,
588
+ focusedCell,
589
+ setFocusedCell,
590
+ containerRef,
591
+ });
592
+
549
593
  const { handleHideColumn, handlePinColumn } = useGridColumnMenuHandlers({
550
594
  columnVisibilityModel,
551
595
  onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
@@ -658,6 +702,8 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
658
702
  rowSpanningCaches: spanning.rowSpanningCaches,
659
703
  rowHeight: effectiveRowHeight,
660
704
  rowMetaMap,
705
+ cellRange: cellSelection.displayRange,
706
+ cellRangeHasRowSpan: cellSelection.hasRowSpan,
661
707
  };
662
708
 
663
709
  return (
@@ -746,11 +792,13 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
746
792
  aria-label={ariaLabel || 'Data grid'}
747
793
  aria-rowcount={ariaRows.rowCount}
748
794
  aria-colcount={navigationColumns.length}
749
- aria-multiselectable={rowSelectionEnabled && !disableMultipleRowSelection}
795
+ aria-multiselectable={(rowSelectionEnabled && !disableMultipleRowSelection) || cellSelection.enabled}
750
796
  aria-busy={effectiveLoading}
751
797
  tabIndex={viewportTabIndex}
752
798
  onKeyDownCapture={() => { setKeyboardMode(true); }}
753
799
  onMouseDownCapture={handleMouseDownCapture}
800
+ onMouseDown={cellSelection.handleMouseDown}
801
+ onClickCapture={cellSelection.handleClickCapture}
754
802
  onKeyDown={handleKeyDown}
755
803
  onFocus={handleFocus}
756
804
  onBlur={handleBlur}
@@ -802,6 +850,8 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
802
850
  onHideColumn={handleHideColumn}
803
851
  onManageColumns={columnsPanel.openColumnsPanel}
804
852
  onPinColumn={handlePinColumn}
853
+ wrapHeaderText={wrapHeaderText}
854
+ headerHeight={headerHeight}
805
855
  />
806
856
 
807
857
  <GridPinnedRows<R>
@@ -881,6 +931,15 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
881
931
  </div>
882
932
  )}
883
933
 
934
+ {cellSelection.enabled && showCellSelectionStats && (
935
+ <GridCellSelectionStatsArea
936
+ stats={cellSelection.stats}
937
+ apiRef={gridData.apiRef}
938
+ slot={slots?.cellSelectionStats}
939
+ slotProps={slotProps?.cellSelectionStats}
940
+ />
941
+ )}
942
+
884
943
  {FooterSlot && (
885
944
  <FooterSlot
886
945
  apiRef={gridData.apiRef}
@@ -915,6 +974,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
915
974
  filteredRowCount={filteredRows.length}
916
975
  dataRowCount={dataRows.length}
917
976
  filterModel={filterModel}
977
+ cellSelectionAnnouncement={cellSelection.announcement}
918
978
  />
919
979
 
920
980
  {showLoadingOverRows && (
@@ -11,10 +11,15 @@ interface GridLiveRegionProps {
11
11
  /** Data rows that pass the filter: the "n rows found" count. */
12
12
  dataRowCount: number;
13
13
  filterModel: GridFilterModel | undefined;
14
+ /** The size of the selected cell range (`cellSelection`), already debounced. */
15
+ cellSelectionAnnouncement?: string;
14
16
  }
15
17
 
16
- /** Polite status announcements: loading, a load error, no rows, or the row count of an active filter. */
17
- export function GridLiveRegion({ loading, error, noRowsLabel, filteredRowCount, dataRowCount, filterModel }: GridLiveRegionProps) {
18
+ /**
19
+ * Polite status announcements: loading, a load error, no rows, or the row count of an active filter;
20
+ * and the size of the selected cell range.
21
+ */
22
+ export function GridLiveRegion({ loading, error, noRowsLabel, filteredRowCount, dataRowCount, filterModel, cellSelectionAnnouncement }: GridLiveRegionProps) {
18
23
  return (
19
24
  <div className="ogx-aria-live-status" role="status" aria-live="polite">
20
25
  {loading ? 'Loading data...' : ''}
@@ -26,6 +31,7 @@ export function GridLiveRegion({ loading, error, noRowsLabel, filteredRowCount,
26
31
  ? `${dataRowCount} ${dataRowCount === 1 ? 'row' : 'rows'} found`
27
32
  : ''
28
33
  )}
34
+ {cellSelectionAnnouncement ? <span className="ogx-aria-live-status__cells"> {cellSelectionAnnouncement}</span> : null}
29
35
  </div>
30
36
  );
31
37
  }
@@ -15,6 +15,8 @@ import type {
15
15
  GridRowMeta,
16
16
  } from '../../types';
17
17
  import type { CellColSpanInfo, RowSpanningCaches } from '../../hooks/features/useGridSpanning';
18
+ import type { GridDisplayCellRange } from '../../hooks/core/useGridCellSelection';
19
+ import { getRowCellRange } from '../../utils/cellSelection';
18
20
  import type { GridEditingState } from '../../hooks/features/useGridEditing';
19
21
 
20
22
  interface PinnedRowEditingHandlers {
@@ -58,6 +60,10 @@ export interface GridPinnedRowsProps<R extends GridRowModel> {
58
60
  /** `aria-rowindex` of the first of these rows. */
59
61
  ariaRowIndexBase?: number;
60
62
  columnIndexMap?: Map<string, number>;
63
+ /** The selected cell range to draw (`cellSelection`), in renderable-row / data-column indices. */
64
+ cellRange?: GridDisplayCellRange | null;
65
+ /** The grid has row spans: rows tell their cells how far the range bottom is. */
66
+ cellRangeHasRowSpan?: boolean;
61
67
  /** Pinned rows are edited like any other row. */
62
68
  editingHandlers?: PinnedRowEditingHandlers;
63
69
  isCellEditable?: (params: GridCellParams<R>) => boolean;
@@ -103,6 +109,8 @@ export function GridPinnedRows<R extends GridRowModel>({
103
109
  editingHandlers,
104
110
  isCellEditable,
105
111
  rowReordering = false,
112
+ cellRange,
113
+ cellRangeHasRowSpan = false,
106
114
  }: GridPinnedRowsProps<R>) {
107
115
  if (rows.length === 0) return null;
108
116
 
@@ -148,6 +156,7 @@ export function GridPinnedRows<R extends GridRowModel>({
148
156
  onEditCellValueChange={editingHandlers?.setEditCellValue}
149
157
  isCellEditable={isCellEditable}
150
158
  rowReordering={rowReordering}
159
+ cellRange={getRowCellRange(cellRange, rowIndex, cellRangeHasRowSpan)}
151
160
  />
152
161
  );
153
162
  })}
@@ -25,8 +25,13 @@ function isPlainFunctionComponent(component: React.ComponentType<ToolbarProps>):
25
25
  * Runs a plain function toolbar inside its own fiber. The caller keys this element on the
26
26
  * function's source, so the fiber (and every hook in it) survives when the consumer redefines
27
27
  * the same toolbar inline on each render, but remounts when a different toolbar is passed.
28
+ *
29
+ * `render` calls hooks, so it must run on every render. React Compiler would memoize the call
30
+ * like any other and skip it when `render` and `toolbarProps` are unchanged, which breaks the
31
+ * toolbar's hooks; `'use no memo'` keeps this wrapper out of the compiler.
28
32
  */
29
33
  function InlineToolbar({ render, toolbarProps }: InlineToolbarProps) {
34
+ 'use no memo';
30
35
  return <>{render(toolbarProps)}</>;
31
36
  }
32
37
 
@@ -18,6 +18,8 @@ import type { GridVisibleRow } from '../../hooks/core/useGridVisibleRows';
18
18
  import type { UseRowReorderReturn } from '../../hooks/useRowReorder';
19
19
  import type { GridEditingState } from '../../hooks/features/useGridEditing';
20
20
  import type { CellColSpanInfo, RowSpanningCaches } from '../../hooks/features/useGridSpanning';
21
+ import type { GridDisplayCellRange } from '../../hooks/core/useGridCellSelection';
22
+ import { getRowCellRange } from '../../utils/cellSelection';
21
23
 
22
24
  interface RowEditingHandlers {
23
25
  editingCell: GridEditingState['editingCell'];
@@ -71,6 +73,10 @@ export interface GridVirtualRowsProps<R extends GridRowModel> {
71
73
  /** Added to a row's rowIndex to get its `aria-rowindex` (header rows and earlier pages included). */
72
74
  ariaRowIndexOffset?: number;
73
75
  columnIndexMap?: Map<string, number>;
76
+ /** The selected cell range to draw (`cellSelection`), in renderable-row / data-column indices. */
77
+ cellRange?: GridDisplayCellRange | null;
78
+ /** The grid has row spans: rows tell their cells how far the range bottom is. */
79
+ cellRangeHasRowSpan?: boolean;
74
80
  }
75
81
 
76
82
  export function GridVirtualRows<R extends GridRowModel>({
@@ -115,6 +121,8 @@ export function GridVirtualRows<R extends GridRowModel>({
115
121
  loadingOverlay,
116
122
  ariaRowIndexOffset,
117
123
  columnIndexMap,
124
+ cellRange,
125
+ cellRangeHasRowSpan = false,
118
126
  }: GridVirtualRowsProps<R>) {
119
127
  const skeletonColumns: GridColDef<R>[] = baseColumns.length > 0
120
128
  ? baseColumns
@@ -206,6 +214,7 @@ export function GridVirtualRows<R extends GridRowModel>({
206
214
  rowSpanningCaches={rowSpanningCaches}
207
215
  rowHeight={rowHeight}
208
216
  rowMeta={rowMetaMap.get(id)}
217
+ cellRange={getRowCellRange(cellRange, actualIndex, cellRangeHasRowSpan)}
209
218
  />
210
219
  ))
211
220
  )}
@@ -107,7 +107,8 @@ const FilterRow: React.FC<{
107
107
  onChange: (item: GridFilterItem | null) => void;
108
108
  }> = ({ col, item, resetToken, onChange }) => {
109
109
  const filterId = useId();
110
- const typeOperators = getOperatorsForType(col.type);
110
+ // Memoized so React Compiler treats the list as frozen and can keep the callbacks below memoized.
111
+ const typeOperators = useMemo(() => getOperatorsForType(col.type), [col.type]);
111
112
  const defaultOperator = typeOperators[0];
112
113
  const currentOperator: GridFilterOperator = item?.operator ?? defaultOperator;
113
114
  // An operator set programmatically that the column type does not list is still shown, so the
@@ -68,6 +68,18 @@
68
68
  font-weight: 600;
69
69
  letter-spacing: -0.01em;
70
70
  }
71
+ /* Wrapped header title (wrapHeaderText): as many lines as fit in --ogx-header-height, the line count
72
+ is set inline as -webkit-line-clamp (getHeaderLineClamp). The line-height must match
73
+ HEADER_WRAP_LINE_HEIGHT in utils/headerWrap.ts. max-height keeps the header at its height even when
74
+ a theme changes the font or padding. */
75
+ .ogx__header-cell--wrap .ogx__header-cell-title {
76
+ display: -webkit-box;
77
+ -webkit-box-orient: vertical;
78
+ white-space: normal;
79
+ overflow-wrap: break-word;
80
+ line-height: 16px;
81
+ max-height: calc(var(--ogx-header-height, 56px) - 2 * var(--ogx-grid-cell-padding-y, 8px));
82
+ }
71
83
  .ogx__header-cell-aggregation {
72
84
  font-size: 0.6rem;
73
85
  font-weight: 500;
@@ -7,6 +7,7 @@ import { isColumnPinned, calculatePinnedPositions, getPinnedEdgeFields } from '.
7
7
  import { getRenderedColumnWidth } from '../../utils/columnWidth';
8
8
  import { buildColumnGroupRow, getColumnGroupDepth, getColumnGroupPaths } from '../../utils/columnGroups';
9
9
  import { ColumnMenu } from './ColumnMenu';
10
+ import { getHeaderLineClamp, shouldWrapHeaderText } from '../../utils/headerWrap';
10
11
 
11
12
 
12
13
  function MenuIcon() {
@@ -69,6 +70,10 @@ export interface HeaderProps<R extends GridRowModel = GridRowModel> {
69
70
  onManageColumns?: () => void;
70
71
  /** Position of each visible data column in render order, for `colIndex` and `aria-colindex`. */
71
72
  columnIndexMap?: Map<string, number>;
73
+ /** Wraps header titles onto several lines; `GridColDef.wrapHeaderText` overrides it per column. */
74
+ wrapHeaderText?: boolean;
75
+ /** Header row height in pixels; sets how many lines a wrapped title may take. Defaults to 56. */
76
+ headerHeight?: number;
72
77
  }
73
78
 
74
79
  export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps<R>) {
@@ -105,6 +110,8 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
105
110
  onPinColumn,
106
111
  onManageColumns,
107
112
  columnIndexMap,
113
+ wrapHeaderText = false,
114
+ headerHeight = 56,
108
115
  } = props;
109
116
 
110
117
  const selectAllId = useId();
@@ -411,6 +418,8 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
411
418
  const isPinned = pinnedPosition !== null;
412
419
  const isDragging = draggedColumn === colDef.field;
413
420
  const isDragOver = dragOverColumn === colDef.field;
421
+ const wraps = shouldWrapHeaderText(colDef.wrapHeaderText, wrapHeaderText);
422
+ const aggregationLabel = aggregationModel?.[colDef.field];
414
423
 
415
424
  const classNames = [
416
425
  'ogx__header-cell',
@@ -423,6 +432,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
423
432
  pinnedPosition === 'right' && colDef.field === pinnedEdges.firstRight && 'ogx__header-cell--pinned-right-first',
424
433
  isDragging && 'ogx__header-cell--dragging',
425
434
  isDragOver && 'ogx__header-cell--drag-over',
435
+ wraps && 'ogx__header-cell--wrap',
426
436
  isHeaderFocused(colDef.field) && 'ogx__header-cell--focused',
427
437
  colDef.headerClassName
428
438
  ].filter(Boolean).join(' ');
@@ -476,7 +486,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
476
486
  key={colDef.field}
477
487
  className={classNames}
478
488
  style={style}
479
- title={colDef.description}
489
+ title={colDef.description ?? (wraps && typeof headerContent === 'string' ? headerContent : undefined)}
480
490
  onClick={handleHeaderClick}
481
491
  role="columnheader"
482
492
  draggable={!!handleDragStart}
@@ -498,12 +508,15 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
498
508
  </div>
499
509
  )}
500
510
  <div className="ogx__header-cell-title-wrapper">
501
- <span className="ogx__header-cell-title">
511
+ <span
512
+ className="ogx__header-cell-title"
513
+ style={wraps ? { WebkitLineClamp: getHeaderLineClamp(headerHeight, !!aggregationLabel) } : undefined}
514
+ >
502
515
  {headerContent}
503
516
  </span>
504
- {aggregationModel && aggregationModel[colDef.field] && (
517
+ {aggregationLabel && (
505
518
  <span className="ogx__header-cell-aggregation">
506
- {aggregationModel[colDef.field]}
519
+ {aggregationLabel}
507
520
  </span>
508
521
  )}
509
522
  </div>
@@ -13,6 +13,8 @@ import { tryGetCellValue } from '../../utils/values';
13
13
  import { isSyntheticRowId } from '../../utils/syntheticRows';
14
14
  import { getRenderedColumnWidth } from '../../utils/columnWidth';
15
15
  import { DEFAULT_DETAIL_PANEL_HEIGHT } from '../../utils/detailPanel';
16
+ import { GROUPING_COLUMN_FIELD, getCellRangeFlags, isSameRowCellRange } from '../../utils/cellSelection';
17
+ import type { GridRowCellRange } from '../../utils/cellSelection';
16
18
 
17
19
  /** Sticky system cells (drag handle, detail toggle, checkbox) sit above scrolled, spanned and focused cells. */
18
20
  const SYSTEM_CELL_Z_INDEX = 12;
@@ -96,9 +98,14 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
96
98
  columnIndexMap?: Map<string, number>;
97
99
  /** 1-based `aria-rowindex` of this row in the whole grid (header rows included). Defaults to `rowIndex + 2`. */
98
100
  ariaRowIndex?: number;
101
+ /**
102
+ * The part of the selected cell range (`cellSelection`) in this row, or `null` / absent when the
103
+ * row is outside it. @since v3.3
104
+ */
105
+ cellRange?: GridRowCellRange | null;
99
106
  }
100
107
 
101
- export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
108
+ function RowImpl<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
102
109
  const {
103
110
  row,
104
111
  rowId: rowIdProp,
@@ -144,6 +151,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
144
151
  rowMeta,
145
152
  columnIndexMap,
146
153
  ariaRowIndex,
154
+ cellRange,
147
155
  } = props;
148
156
 
149
157
  const id = rowIdProp ?? row.id;
@@ -437,6 +445,14 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
437
445
  || (pinnedPosition === 'right' && colDef.field === pinnedEdges.firstRight);
438
446
  const rowSpan = rowSpanningCaches?.spannedCells[id]?.[colDef.field];
439
447
  const isHiddenByRowSpan = rowSpanningCaches?.hiddenCells[id]?.[colDef.field] || false;
448
+ const rangeFlags = cellRange && !isHiddenByRowSpan && colDef.field !== GROUPING_COLUMN_FIELD
449
+ ? getCellRangeFlags(
450
+ cellRange,
451
+ colIndex,
452
+ colSpanInfo && !colSpanInfo.spannedByColSpan ? colSpanInfo.cellProps.colSpan : 1,
453
+ rowSpan ?? 1,
454
+ )
455
+ : undefined;
440
456
 
441
457
  return (
442
458
  <Cell
@@ -468,6 +484,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
468
484
  isHiddenByRowSpan={isHiddenByRowSpan}
469
485
  rowMeta={rowMeta}
470
486
  valueError={valueError}
487
+ rangeFlags={rangeFlags}
471
488
  />
472
489
  );
473
490
  })}
@@ -490,3 +507,23 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
490
507
  </>
491
508
  );
492
509
  }
510
+
511
+ /**
512
+ * Shallow prop comparison, except `cellRange`, which is compared by value: a row inside a cell range
513
+ * gets a new range object on every render, but only re-renders when its part of the range changes.
514
+ */
515
+ function areRowPropsEqual<R extends GridRowModel>(prev: RowProps<R>, next: RowProps<R>): boolean {
516
+ const prevKeys = Object.keys(prev) as (keyof RowProps<R>)[];
517
+ if (prevKeys.length !== Object.keys(next).length) return false;
518
+ for (const key of prevKeys) {
519
+ if (key === 'cellRange') {
520
+ if (!isSameRowCellRange(prev.cellRange, next.cellRange)) return false;
521
+ } else if (!Object.is(prev[key], next[key])) {
522
+ return false;
523
+ }
524
+ }
525
+ return true;
526
+ }
527
+
528
+ /** One grid row. Memoised: rows whose props did not change (for example outside a dragged cell range) do not re-render. */
529
+ export const Row = React.memo(RowImpl, areRowPropsEqual) as typeof RowImpl;
@@ -474,9 +474,11 @@ function FilterPanelWrapper({
474
474
  }
475
475
 
476
476
  const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
477
+ // Module-level default, so `columns` is the same frozen value on every render (React Compiler).
478
+ const NO_COLUMNS: GridColDef[] = [];
477
479
 
478
480
  export function GridToolbar({
479
- columns = [],
481
+ columns = NO_COLUMNS,
480
482
  baseColumns,
481
483
  aggregationModel = {},
482
484
  onAggregationModelChange,
@@ -504,7 +506,7 @@ export function GridToolbar({
504
506
  // At most one panel is open. Every way the Columns panel closes (its button, a custom
505
507
  // button, another panel opening, click-outside, Escape) reports onColumnsPanelClose, so the
506
508
  // grid can ask for it again with forceColumnsOpen.
507
- const [openPanel, setOpenPanel] = useState<ToolbarPanel | null>(null);
509
+ const [openPanel, setOpenPanel] = useState<ToolbarPanel | null>(forceColumnsOpen ? 'columns' : null);
508
510
  const aggOpen = openPanel === 'summaries';
509
511
  const pivotOpen = openPanel === 'pivot';
510
512
  const colsOpen = openPanel === 'columns';
@@ -517,9 +519,13 @@ export function GridToolbar({
517
519
  const togglePanel = (panel: ToolbarPanel) => switchPanel(openPanel === panel ? null : panel);
518
520
  const closePanel = (panel: ToolbarPanel) => { if (openPanel === panel) switchPanel(null); };
519
521
 
520
- useEffect(() => {
522
+ // forceColumnsOpen turning true opens the Columns panel. Adjusted during render (not in an
523
+ // effect) so the panel opens in the same commit.
524
+ const [seenForceColumnsOpen, setSeenForceColumnsOpen] = useState(forceColumnsOpen);
525
+ if (seenForceColumnsOpen !== forceColumnsOpen) {
526
+ setSeenForceColumnsOpen(forceColumnsOpen);
521
527
  if (forceColumnsOpen) setOpenPanel('columns');
522
- }, [forceColumnsOpen]);
528
+ }
523
529
 
524
530
  // Tell the grid this toolbar shows the Columns panel for the column menu; it can only do so
525
531
  // when the grid's forceColumnsOpen reaches it and the Columns panel is enabled.
@@ -7,6 +7,15 @@ export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputE
7
7
  inputRef?: React.Ref<HTMLInputElement>;
8
8
  }
9
9
 
10
+ /** Points a callback or object ref at `element`. Outside the component, so it does not mutate a prop in render scope. */
11
+ function assignRef(ref: React.Ref<HTMLInputElement> | undefined, element: HTMLInputElement | null): void {
12
+ if (typeof ref === 'function') {
13
+ ref(element);
14
+ } else if (ref) {
15
+ (ref as React.MutableRefObject<HTMLInputElement | null>).current = element;
16
+ }
17
+ }
18
+
10
19
  export const Checkbox: React.FC<CheckboxProps> = ({
11
20
  indeterminate = false,
12
21
  label,
@@ -20,11 +29,7 @@ export const Checkbox: React.FC<CheckboxProps> = ({
20
29
  const setRef = React.useCallback((element: HTMLInputElement | null) => {
21
30
  internalRef.current = element;
22
31
 
23
- if (typeof inputRef === 'function') {
24
- inputRef(element);
25
- } else if (inputRef) {
26
- (inputRef as React.MutableRefObject<HTMLInputElement | null>).current = element;
27
- }
32
+ assignRef(inputRef, element);
28
33
  }, [inputRef]);
29
34
 
30
35
  React.useEffect(() => {
@@ -32,5 +32,11 @@ export function createGridApiPlaceholder<R extends GridValidRowModel = GridRowMo
32
32
  autosizeColumn: noop,
33
33
  autosizeColumns: noop,
34
34
  copySelectedRows: () => Promise.resolve(),
35
+ getCellSelectionModel: () => [],
36
+ setCellSelectionModel: noop,
37
+ selectCellRange: noop,
38
+ clearCellSelection: noop,
39
+ getSelectedCells: () => [],
40
+ copySelectedCells: () => Promise.resolve(),
35
41
  };
36
42
  }