@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,97 @@
1
+ import { useLayoutEffect, useRef } from 'react';
2
+ import type { MutableRefObject, RefObject } from 'react';
3
+ import { isSameCell } from '../../utils/focus';
4
+ import type { FocusedCell } from '../../utils/focus';
5
+ import { isRangeColumnField, isSameCoordinates } from '../../utils/cellSelection';
6
+ import { writeToClipboard } from '../features/useGridClipboard';
7
+ import type { UseGridCellSelectionReturn } from './useGridCellSelection';
8
+ import type { GridApi, GridCellCoordinates, GridCellSelectionModel } from '../../types';
9
+
10
+ export interface UseGridCellSelectionApiParams {
11
+ apiRef: MutableRefObject<GridApi>;
12
+ selection: UseGridCellSelectionReturn;
13
+ /** The rendered model (controlled or internal). */
14
+ model: GridCellSelectionModel;
15
+ getPendingModel: () => GridCellSelectionModel | null;
16
+ focusedCell: FocusedCell | null;
17
+ setFocusedCell: React.Dispatch<React.SetStateAction<FocusedCell | null>>;
18
+ /** The grid root: its `ogx--kb` class tells a keyboard focus move from a pointer one. */
19
+ containerRef: RefObject<HTMLDivElement | null>;
20
+ }
21
+
22
+ /**
23
+ * Runs after useGridKeyboardNavigation. Keeps the range's anchor and the focused cell together:
24
+ * a new anchor (pointer, keyboard, API or a controlled model) moves focus there, and focus moved
25
+ * to another data cell (plain arrows, Tab back in) collapses the range to it. Clears a range whose
26
+ * corner is no longer displayed (`reason: 'dataChange'`). Installs the cell selection API methods.
27
+ */
28
+ export function useGridCellSelectionApi(params: UseGridCellSelectionApiParams): void {
29
+ const { apiRef } = params;
30
+ const latestRef = useRef(params);
31
+ useLayoutEffect(() => {
32
+ latestRef.current = params;
33
+ });
34
+
35
+ const previousAnchorRef = useRef<GridCellCoordinates | null>(null);
36
+ const previousFocusRef = useRef<FocusedCell | null>(null);
37
+ const reportedDataChangeRef = useRef<GridCellSelectionModel | null>(null);
38
+
39
+ const { selection, model, focusedCell, setFocusedCell, containerRef } = params;
40
+ const anchor = selection.range?.anchor ?? null;
41
+ const anchorRow = selection.resolved?.anchorRow;
42
+
43
+ useLayoutEffect(() => {
44
+ selection.reportFocus(focusedCell);
45
+ const anchorChanged = !isSameCoordinates(previousAnchorRef.current, anchor);
46
+ const focusChanged = !isSameCell(previousFocusRef.current, focusedCell);
47
+ previousAnchorRef.current = anchor;
48
+ previousFocusRef.current = focusedCell;
49
+ if (!selection.enabled) return;
50
+
51
+ if (selection.range && !selection.resolved) {
52
+ // A corner's row or column is no longer displayed. Reported once per model.
53
+ if (reportedDataChangeRef.current !== model) {
54
+ reportedDataChangeRef.current = model;
55
+ selection.setModel([], 'dataChange');
56
+ }
57
+ return;
58
+ }
59
+ if (anchorChanged && anchor) {
60
+ if (!focusedCell || focusedCell.id !== anchor.id || focusedCell.field !== anchor.field) {
61
+ setFocusedCell({ id: anchor.id, field: anchor.field, rowIndex: anchorRow });
62
+ }
63
+ return;
64
+ }
65
+ if (focusChanged && focusedCell && focusedCell.id !== null && isRangeColumnField(focusedCell.field)
66
+ && !isSameCoordinates(anchor, { id: focusedCell.id, field: focusedCell.field })) {
67
+ const cell = { id: focusedCell.id, field: focusedCell.field };
68
+ const byKeyboard = containerRef.current?.classList.contains('ogx--kb') ?? false;
69
+ selection.setModel([{ anchor: cell, head: cell }], byKeyboard ? 'keyboard' : 'pointer');
70
+ }
71
+ }, [selection, model, anchor, anchorRow, focusedCell, setFocusedCell, containerRef]);
72
+
73
+ useLayoutEffect(() => {
74
+ const api = apiRef.current;
75
+ const current = () => latestRef.current;
76
+ api.getCellSelectionModel = () => {
77
+ const { selection: s, getPendingModel, model: rendered } = current();
78
+ return s.enabled ? (getPendingModel() ?? rendered) : [];
79
+ };
80
+ api.setCellSelectionModel = (next) => {
81
+ current().selection.setModel(next, 'api');
82
+ };
83
+ api.selectCellRange = (rangeAnchor, head) => {
84
+ current().selection.setModel([{ anchor: rangeAnchor, head }], 'api');
85
+ };
86
+ api.clearCellSelection = () => {
87
+ current().selection.setModel([], 'clear');
88
+ };
89
+ api.getSelectedCells = () => current().selection.getSelectedCells();
90
+ api.copySelectedCells = async () => {
91
+ const { selection: s } = current();
92
+ if (!s.enabled) return;
93
+ const text = s.getCopyText();
94
+ if (text !== null) await writeToClipboard(text);
95
+ };
96
+ }, [apiRef]);
97
+ }
@@ -11,15 +11,20 @@ export interface UseGridClipboardApiParams {
11
11
  /** The grid root: Ctrl/Cmd+C only copies while focus is inside it. */
12
12
  containerRef: RefObject<HTMLDivElement | null>;
13
13
  disableKeyboardShortcut: boolean;
14
+ /**
15
+ * Cell range selection: the text Ctrl/Cmd+C copies instead of the selected rows (`null`: nothing).
16
+ * Absent while `cellSelection` is off, so the shortcut copies rows.
17
+ */
18
+ getCellRangeCopyText?: () => string | null;
14
19
  }
15
20
 
16
21
  /**
17
22
  * Copies the selected rows (every page, collapsed groups, pinned rows) with the on-screen columns
18
23
  * in screen order, on Ctrl/Cmd+C while focus is inside this grid, and installs `copySelectedRows`
19
- * on the API for programmatic use.
24
+ * on the API for programmatic use. With `cellSelection` on, the shortcut copies the cell range instead.
20
25
  */
21
26
  export function useGridClipboardApi(params: UseGridClipboardApiParams): void {
22
- const { apiRef, columns, getRowId, containerRef, disableKeyboardShortcut } = params;
27
+ const { apiRef, columns, getRowId, containerRef, disableKeyboardShortcut, getCellRangeCopyText } = params;
23
28
 
24
29
  const getGridRootElement = useCallback(() => containerRef.current, [containerRef]);
25
30
  const { copySelectedRows } = useGridClipboard({
@@ -29,6 +34,7 @@ export function useGridClipboardApi(params: UseGridClipboardApiParams): void {
29
34
  getRowId,
30
35
  getRootElement: getGridRootElement,
31
36
  disableKeyboardShortcut,
37
+ getShortcutText: getCellRangeCopyText,
32
38
  });
33
39
 
34
40
  // Stable identity.
@@ -7,6 +7,9 @@ import type {
7
7
  GridPaginationModel,
8
8
  GridAggregationModel,
9
9
  GridPivotModel,
10
+ GridCellSelectionModel,
11
+ GridCellSelectionChangeDetails,
12
+ GridCellSelectionReason,
10
13
  } from '../../types';
11
14
  import type { GridInitialState, GridDensityState } from '../../state/types';
12
15
 
@@ -42,6 +45,9 @@ export interface UseGridControlledStateParams {
42
45
  onRowSelectionModelChange?: (model: GridRowId[]) => void;
43
46
 
44
47
  density?: GridDensity;
48
+
49
+ cellSelectionModel?: GridCellSelectionModel;
50
+ onCellSelectionModelChange?: (model: GridCellSelectionModel, details: GridCellSelectionChangeDetails) => void;
45
51
  }
46
52
 
47
53
  export interface UseGridControlledStateReturn {
@@ -94,8 +100,17 @@ export interface UseGridControlledStateReturn {
94
100
 
95
101
  // density (prop, else initialState.density, else 'standard')
96
102
  density: GridDensity;
103
+
104
+ // cell selection
105
+ cellSelectionModel: GridCellSelectionModel;
106
+ /** Sets the internal model when uncontrolled and fires onCellSelectionModelChange. */
107
+ handleCellSelectionModelChange: (model: GridCellSelectionModel, reason: GridCellSelectionReason) => void;
108
+ /** Like `getPendingRowSelectionModel`, for the cell selection. Stable identity. */
109
+ getPendingCellSelectionModel: () => GridCellSelectionModel | null;
97
110
  }
98
111
 
112
+ const EMPTY_CELL_SELECTION_MODEL: GridCellSelectionModel = [];
113
+
99
114
  const EMPTY_PIVOT_MODEL: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
100
115
  const DEFAULT_PAGE_SIZE = 100;
101
116
 
@@ -128,6 +143,8 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
128
143
  rowSelectionModel: propRowSelectionModel,
129
144
  onRowSelectionModelChange,
130
145
  density: propDensity,
146
+ cellSelectionModel: propCellSelectionModel,
147
+ onCellSelectionModelChange,
131
148
  } = params;
132
149
 
133
150
  // ── Sort ─────────────────────────────────────────────────────────────────
@@ -248,6 +265,23 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
248
265
  const [initialDensity] = useState<GridDensity>(() => initialState?.density?.density ?? 'standard');
249
266
  const density = propDensity ?? initialDensity;
250
267
 
268
+ // ── Cell selection ───────────────────────────────────────────────────────
269
+ const isCellSelectionControlled = propCellSelectionModel !== undefined;
270
+ const [internalCellSelectionModel, setInternalCellSelectionModel] = useState<GridCellSelectionModel>(EMPTY_CELL_SELECTION_MODEL);
271
+ const cellSelectionModel = isCellSelectionControlled ? propCellSelectionModel : internalCellSelectionModel;
272
+
273
+ const pendingCellSelectionRef = useRef<GridCellSelectionModel | null>(null);
274
+ useLayoutEffect(() => {
275
+ pendingCellSelectionRef.current = null;
276
+ });
277
+ const getPendingCellSelectionModel = useCallback(() => pendingCellSelectionRef.current, []);
278
+
279
+ const handleCellSelectionModelChange = useCallback((model: GridCellSelectionModel, reason: GridCellSelectionReason) => {
280
+ pendingCellSelectionRef.current = model;
281
+ if (!isCellSelectionControlled) setInternalCellSelectionModel(model);
282
+ onCellSelectionModelChange?.(model, { reason });
283
+ }, [isCellSelectionControlled, onCellSelectionModelChange]);
284
+
251
285
  return {
252
286
  sortModel: sortModel as GridSortItem[],
253
287
  isSortControlled,
@@ -281,5 +315,9 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
281
315
  getPendingRowSelectionModel,
282
316
 
283
317
  density,
318
+
319
+ cellSelectionModel,
320
+ handleCellSelectionModelChange,
321
+ getPendingCellSelectionModel,
284
322
  };
285
323
  }
@@ -14,6 +14,7 @@ import {
14
14
  isSystemField,
15
15
  } from '../../utils/focus';
16
16
  import type { FocusedCell } from '../../utils/focus';
17
+ import { isRangeColumnField } from '../../utils/cellSelection';
17
18
  import type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection, GridRowParams, GridRowMeta } from '../../types';
18
19
  import type { GridEditingState } from '../features/useGridEditing';
19
20
 
@@ -93,6 +94,21 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
93
94
  * Navigation skips the cells of the focused span and lands on span origins.
94
95
  */
95
96
  getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
97
+ /**
98
+ * Cell range selection (`cellSelection`), absent while it is off. Shift+navigation keys on a
99
+ * data cell move the range's head (focus stays on the anchor), Escape collapses the range and
100
+ * Ctrl/Cmd+A selects every data cell instead of every row.
101
+ */
102
+ cellSelection?: GridKeyboardCellSelection;
103
+ }
104
+
105
+ export interface GridKeyboardCellSelection {
106
+ /** Returns whether the key was used. */
107
+ extendSelection: (key: string, withModifier: boolean, pageSize: number, from: FocusedCell) => boolean;
108
+ /** Returns whether a multi-cell range was collapsed. */
109
+ collapseSelection: () => boolean;
110
+ /** Returns whether anything was selected. */
111
+ selectAllCells: () => boolean;
96
112
  }
97
113
 
98
114
  export interface UseGridKeyboardNavigationReturn {
@@ -170,6 +186,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
170
186
  pinExpandColumn = true,
171
187
  rowHeight,
172
188
  getSpanOrigin,
189
+ cellSelection,
173
190
  } = params;
174
191
 
175
192
  const [storedFocus, setFocusedCell] = useState<FocusedCell | null>(null);
@@ -419,11 +436,19 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
419
436
 
420
437
  // Tab is not captured outside edit mode: the grid is a single tab stop and Tab leaves it
421
438
  // (or reaches focusable content inside cells and detail panels).
439
+ if (key === 'Escape' && cellSelection?.collapseSelection()) {
440
+ event.preventDefault();
441
+ return;
442
+ }
422
443
  if (key === 'Tab' || key === 'Escape') return;
423
444
  // Enter and Space activate a control rendered inside a cell.
424
445
  if (targetKind === 'control' && (key === 'Enter' || isSpace)) return;
425
446
 
426
447
  if ((event.ctrlKey || event.metaKey) && (key === 'a' || key === 'A')) {
448
+ if (cellSelection) {
449
+ if (cellSelection.selectAllCells()) event.preventDefault();
450
+ return;
451
+ }
427
452
  if (!rowSelectionEnabled || !multipleRowSelectionEnabled || !handleSelectAll) return;
428
453
  event.preventDefault();
429
454
  handleSelectAll(true);
@@ -496,6 +521,12 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
496
521
  const withModifier = event.ctrlKey || event.metaKey;
497
522
  const page = pagination ? pageSize : 10;
498
523
 
524
+ // Shift+navigation on a data cell extends the cell range; focus stays on its anchor.
525
+ if (cellSelection && event.shiftKey && id !== null && isRangeColumnField(field)
526
+ && cellSelection.extendSelection(key, withModifier, page, focusedCell)) {
527
+ return;
528
+ }
529
+
499
530
  const step = (r: number, c: number): { r: number; c: number } => {
500
531
  switch (key) {
501
532
  case 'ArrowRight': return c + 1 > lastCol ? { r: r + 1, c: 0 } : { r, c: c + 1 };
@@ -558,6 +589,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
558
589
  scrollCellIntoView,
559
590
  getSpanOrigin,
560
591
  getRowId,
592
+ cellSelection,
561
593
  ]);
562
594
 
563
595
  return {
@@ -51,6 +51,10 @@ export function useGridRowInteractions<R extends GridRowModel>(
51
51
  selectableRowIds,
52
52
  });
53
53
 
54
+ // Depend on the selection callbacks, not the object around them (a new one each render), so the
55
+ // handlers keep their identity and memoised rows do not re-render.
56
+ const { clickRow, toggleRow } = rowSelection;
57
+
54
58
  const handleRowClick = useCallback((rowParams: GridRowParams<R>) => {
55
59
  const { id } = rowParams;
56
60
 
@@ -66,16 +70,16 @@ export function useGridRowInteractions<R extends GridRowModel>(
66
70
 
67
71
  // The pivot Grand Total is clickable (onRowClick) but, like any synthetic row, not selectable.
68
72
  if (!disableRowSelectionOnClick && !isSyntheticRowId(id, rowMetaMap)) {
69
- rowSelection.clickRow(id);
73
+ clickRow(id);
70
74
  }
71
- }, [isHierarchyEnabled, activeHierarchyHandlers, onRowClick, rowMetaMap, disableRowSelectionOnClick, rowSelection]);
75
+ }, [isHierarchyEnabled, activeHierarchyHandlers, onRowClick, rowMetaMap, disableRowSelectionOnClick, clickRow]);
72
76
 
73
77
  // Honors disableMultipleRowSelection like a row click does. Synthetic group / subtotal rows
74
78
  // and the pivot Grand Total are not data: their ids never enter the selection model.
75
79
  const handleSelectionChange = useCallback((rowId: GridRowId, isSelected: boolean) => {
76
80
  if (isSyntheticRowId(rowId, rowMetaMap)) return;
77
- rowSelection.toggleRow(rowId, isSelected);
78
- }, [rowMetaMap, rowSelection]);
81
+ toggleRow(rowId, isSelected);
82
+ }, [rowMetaMap, toggleRow]);
79
83
 
80
84
  return { rowSelection, handleRowClick, handleSelectionChange };
81
85
  }
@@ -10,7 +10,7 @@ const COMFORTABLE_ROW_HEIGHT = 72;
10
10
  /** A theme height in pixels (`'36px'` or `'36'`); anything else (rem, calc, …) is ignored. */
11
11
  export function parseThemePixels(value: string | undefined): number | undefined {
12
12
  if (value === undefined) return undefined;
13
- const match = /^\s*(\d+(?:\.\d+)?)\s*(px)?\s*$/.exec(value);
13
+ const match = /^(\d+(?:\.\d+)?)(?:\s*px)?$/.exec(value.trim());
14
14
  if (!match) return undefined;
15
15
  const px = Number(match[1]);
16
16
  return px > 0 ? px : undefined;
@@ -108,14 +108,16 @@ export function useAggregation<R extends GridRowModel>(
108
108
 
109
109
  let cancelled = false;
110
110
  const request = latestRequestRef.current;
111
+ // Built outside the try: React Compiler cannot compile `??` inside a try statement yet.
112
+ const query = {
113
+ sortModel: request.sortModel ?? [],
114
+ filterModel: request.filterModel ?? { items: [] },
115
+ groupKeys: [],
116
+ aggregationModel: request.aggregationModel,
117
+ };
111
118
  let pending: Promise<GridAggregationResult>;
112
119
  try {
113
- pending = Promise.resolve(getAggregations({
114
- sortModel: request.sortModel ?? [],
115
- filterModel: request.filterModel ?? { items: [] },
116
- groupKeys: [],
117
- aggregationModel: request.aggregationModel,
118
- }));
120
+ pending = Promise.resolve(getAggregations(query));
119
121
  } catch (err) {
120
122
  pending = Promise.reject(err);
121
123
  }
@@ -27,6 +27,11 @@ export interface UseGridClipboardProps {
27
27
  getRootElement?: () => HTMLElement | null;
28
28
  /** Turns the Ctrl/Cmd+C shortcut off. `copySelectedRows` still works. */
29
29
  disableKeyboardShortcut?: boolean;
30
+ /**
31
+ * When given, Ctrl/Cmd+C writes the text it returns instead of copying the selected rows, and
32
+ * writes nothing when it returns `null` (cell range selection).
33
+ */
34
+ getShortcutText?: () => string | null;
30
35
  }
31
36
 
32
37
  /** Grid-made columns that never hold row data. */
@@ -58,6 +63,39 @@ export function buildClipboardTsv(rows: GridRowModel[], columns: GridColDef[]):
58
63
  return [header, ...lines].join('\n');
59
64
  }
60
65
 
66
+ /** A cell value formatted the way the cell shows it; a valueFormatter that throws gives the raw text. */
67
+ function formatCopyValue(row: GridRowModel, col: GridColDef): string {
68
+ const value = getCellValue(row, col.field, col);
69
+ try {
70
+ return formatExportValue(row, col, value);
71
+ } catch {
72
+ return value == null ? '' : String(value);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * A cell range as Excel and Google Sheets copy it: tabs between columns, line breaks between rows and
78
+ * no header line. `rows` are the data rows of the range (synthetic rows already left out); a position
79
+ * for which `isCovered` returns true (covered by a span whose origin sits elsewhere) is left empty.
80
+ */
81
+ export function buildRangeTsv(
82
+ rows: readonly GridRowModel[],
83
+ columns: readonly GridColDef[],
84
+ isCovered?: (row: GridRowModel, col: GridColDef) => boolean,
85
+ ): string {
86
+ const lines: string[] = new Array(rows.length);
87
+ for (let r = 0; r < rows.length; r++) {
88
+ const row = rows[r];
89
+ const cells: string[] = new Array(columns.length);
90
+ for (let c = 0; c < columns.length; c++) {
91
+ const col = columns[c];
92
+ cells[c] = isCovered?.(row, col) ? '' : escapeTsvField(formatCopyValue(row, col));
93
+ }
94
+ lines[r] = cells.join('\t');
95
+ }
96
+ return lines.join('\n');
97
+ }
98
+
61
99
  /**
62
100
  * The textarea + execCommand fallback, for browsers without the async Clipboard API (plain http)
63
101
  * or when it rejects. It has to focus the textarea, so focus and the page selection are put back
@@ -98,7 +136,7 @@ function copyTextSynchronous(text: string): boolean {
98
136
  * Writes text to the clipboard: the async Clipboard API when there is one, the execCommand
99
137
  * fallback when it is missing or rejects. Rejects when neither worked.
100
138
  */
101
- async function writeToClipboard(text: string): Promise<void> {
139
+ export async function writeToClipboard(text: string): Promise<void> {
102
140
  const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
103
141
  if (clipboard && typeof clipboard.writeText === 'function') {
104
142
  try {
@@ -180,7 +218,14 @@ export function useGridClipboard(props: UseGridClipboardProps) {
180
218
  const selection = window.getSelection();
181
219
  if (selection && !selection.isCollapsed && selection.toString() !== '') return;
182
220
 
183
- copySelectedRows().catch((err: unknown) => {
221
+ const { getShortcutText } = latestRef.current;
222
+ const copy = getShortcutText
223
+ ? () => {
224
+ const text = getShortcutText();
225
+ return text === null ? Promise.resolve() : writeToClipboard(text);
226
+ }
227
+ : copySelectedRows;
228
+ copy().catch((err: unknown) => {
184
229
  console.error('[OpenGridX] Failed to copy to clipboard:', err);
185
230
  });
186
231
  };
@@ -1,5 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from 'react';
2
2
  import type { GridColDef, GridRowId, GridRowModel } from '../../types';
3
+ import { attempt } from '../../utils/attempt';
3
4
 
4
5
  export interface GridEditingState {
5
6
  editingCell: {
@@ -95,6 +96,10 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
95
96
  setEditingCellState(next);
96
97
  }, []);
97
98
 
99
+ // The in-flight branch below re-runs the commit through this ref: a callback that refers to
100
+ // itself is read before its declaration, which React Compiler rejects.
101
+ const commitRef = useRef<((token: number, cell: EditingCell) => Promise<void>) | null>(null);
102
+
98
103
  const commit = useCallback((token: number, cell: EditingCell): Promise<void> => {
99
104
  const inFlight = pendingRef.current.get(token);
100
105
  if (inFlight) {
@@ -103,7 +108,7 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
103
108
  return inFlight.promise.then(() => {
104
109
  const now = editingCellRef.current;
105
110
  if (sessionRef.current !== token || !now || isSameValue(now.value, inFlight.value)) return;
106
- return commit(token, now);
111
+ return commitRef.current?.(token, now);
107
112
  });
108
113
  }
109
114
 
@@ -159,18 +164,19 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
159
164
  if (pendingRef.current.get(token) === entry) pendingRef.current.delete(token);
160
165
  };
161
166
 
162
- let result: unknown;
163
- try {
164
- const colDef = columns?.find(c => c.field === cell.field);
167
+ const colDef = columns?.find(c => c.field === cell.field);
168
+ const outcome = attempt<unknown>(() => {
165
169
  const newRow = buildEditedRow(existingRow, cell, colDef, warnedFieldsRef.current);
166
- result = processRowUpdate ? processRowUpdate(newRow, existingRow) : newRow;
167
- } catch (error) {
170
+ return processRowUpdate ? processRowUpdate(newRow, existingRow) : newRow;
171
+ });
172
+ if (!outcome.ok) {
168
173
  // Consumer code (valueSetter / processRowUpdate) threw: report it, keep the editor open.
169
174
  finish();
170
- report(error);
175
+ report(outcome.error);
171
176
  settle();
172
177
  return promise;
173
178
  }
179
+ const result = outcome.value;
174
180
 
175
181
  const complete = (processed: unknown) => {
176
182
  finish();
@@ -194,6 +200,9 @@ export function useGridEditing<R extends GridRowModel>(params: UseGridEditingPar
194
200
  }
195
201
  return promise;
196
202
  }, [setEditingCell]);
203
+ useLayoutEffect(() => {
204
+ commitRef.current = commit;
205
+ }, [commit]);
197
206
 
198
207
  const startCellEdit = useCallback((editParams: { id: GridRowId; field: string; value: unknown }) => {
199
208
  const current = editingCellRef.current;
package/lib/index.ts CHANGED
@@ -21,6 +21,8 @@ export type { GridToolbarProps, ToolbarButtonRenderProps, ToolbarQuickFilterRend
21
21
  export { GridTooltip } from './components/Tooltip/Tooltip';
22
22
  export type { GridTooltipProps } from './components/Tooltip/Tooltip';
23
23
  export { ColumnVisibilityPanel } from './components/ColumnVisibilityPanel/ColumnVisibilityPanel';
24
+ export { CellSelectionStats } from './components/CellSelectionStats/CellSelectionStats';
25
+ export type { CellSelectionStatsProps } from './components/CellSelectionStats/CellSelectionStats';
24
26
  export type { ColumnVisibilityPanelProps } from './components/ColumnVisibilityPanel/ColumnVisibilityPanel';
25
27
 
26
28
  export { useGridApiRef } from './hooks/core/useGridApiRef';
@@ -99,6 +101,13 @@ export type {
99
101
  GridPinnedPosition,
100
102
  GridColumnVisibilityModel,
101
103
  GridRowSelectionModel,
104
+ GridCellCoordinates,
105
+ GridCellRange,
106
+ GridCellSelectionModel,
107
+ GridCellSelectionReason,
108
+ GridCellSelectionChangeDetails,
109
+ GridSelectedCell,
110
+ GridCellSelectionStatsSlotProps,
102
111
  GridColumnOrder,
103
112
  GridColumnOrderChangeParams,
104
113
  GridRowOrderChangeParams,
@@ -93,7 +93,10 @@
93
93
  --ogx-grid-row-selected-background: var(--ogx-color-primary-light);
94
94
  --ogx-grid-row-selected-hover-background: #dbeafe;
95
95
  --ogx-grid-cell-focus-border: var(--ogx-color-primary);
96
- --ogx-grid-pinned-left-shadow: 4px 0 24px -4px rgba(0, 0, 0, 0.05);
96
+ /* Cell range selection (cellSelection, v3.3): tint over the range and its outline. */
97
+ --ogx-range-background: rgba(59, 130, 246, 0.12);
98
+ --ogx-range-border: var(--ogx-color-primary);
99
+ --ogx-grid-pinned-left-shadow: 4px 0 24px -4px rgba(0, 0, 0, 0.05);
97
100
  --ogx-grid-pinned-right-shadow: -4px 0 24px -4px rgba(0, 0, 0, 0.05);
98
101
  --ogx-skeleton-base: #f0f0f0;
99
102
  --ogx-skeleton-highlight: #f8f8f8;
@@ -142,6 +145,8 @@
142
145
  --ogx-grid-row-selected-background: #1e3a5f;
143
146
  --ogx-grid-row-selected-hover-background: #1d4ed8;
144
147
  --ogx-grid-cell-focus-border: #60a5fa;
148
+ --ogx-range-background: rgba(96, 165, 250, 0.18);
149
+ --ogx-range-border: #60a5fa;
145
150
  --ogx-checkbox-bg: #1e293b;
146
151
  --ogx-checkbox-border: #64748b;
147
152
  --ogx-grid-pinned-left-shadow: 4px 0 24px -4px rgba(0, 0, 0, 0.3);
@@ -25,3 +25,4 @@
25
25
  @import '../components/ListView/ListView.css';
26
26
  @import '../components/ColumnGroupHeader/ColumnGroupHeader.css';
27
27
  @import '../components/Tooltip/Tooltip.css';
28
+ @import '../components/CellSelectionStats/CellSelectionStats.css';
@@ -9,6 +9,8 @@ const primaryDerived: Record<string, string> = {
9
9
  '--ogx-grid-row-selected-background': 'var(--ogx-color-primary-light)',
10
10
  '--ogx-grid-row-selected-hover-background': 'color-mix(in srgb, var(--ogx-color-primary) 14%, var(--ogx-color-primary-light))',
11
11
  '--ogx-grid-cell-focus-border': 'var(--ogx-color-primary)',
12
+ '--ogx-range-background': 'color-mix(in srgb, var(--ogx-color-primary) 12%, transparent)',
13
+ '--ogx-range-border': 'var(--ogx-color-primary)',
12
14
  '--ogx-toolbar-btn-primary-bg': 'var(--ogx-color-primary)',
13
15
  '--ogx-toolbar-btn-primary-hover': 'var(--ogx-color-primary-dark)',
14
16
  '--ogx-toolbar-input-focus-border': 'var(--ogx-color-primary)',
@@ -281,6 +283,8 @@ function themeToCSS(theme: GridTheme): Record<string, string> {
281
283
  set('--ogx-grid-row-selected-background', g.rowSelectedBackground);
282
284
  set('--ogx-grid-row-selected-hover-background', g.rowSelectedHoverBackground);
283
285
  set('--ogx-grid-cell-focus-border', g.cellFocusBorder);
286
+ set('--ogx-range-background', g.rangeBackground);
287
+ set('--ogx-range-border', g.rangeBorder);
284
288
  set('--ogx-grid-pinned-left-shadow', g.pinnedLeftShadow);
285
289
  set('--ogx-grid-pinned-right-shadow', g.pinnedRightShadow);
286
290
  set('--ogx-checkbox-bg', g.checkboxBg);
@@ -21,6 +21,8 @@ export const darkTheme: GridTheme = {
21
21
  rowSelectedBackground: '#1e3a5f',
22
22
  rowSelectedHoverBackground: '#1d4ed8',
23
23
  cellFocusBorder: '#60a5fa',
24
+ rangeBackground: 'rgba(96, 165, 250, 0.18)',
25
+ rangeBorder: '#60a5fa',
24
26
  checkboxBg: '#1e293b',
25
27
  checkboxBorder: '#64748b',
26
28
  },
@@ -92,6 +94,8 @@ export const roseTheme: GridTheme = {
92
94
  rowSelectedBackground: '#ffe4e6',
93
95
  rowSelectedHoverBackground: '#fecdd3',
94
96
  cellFocusBorder: '#e11d48',
97
+ rangeBackground: 'rgba(225, 29, 72, 0.10)',
98
+ rangeBorder: '#e11d48',
95
99
  },
96
100
  };
97
101
 
@@ -110,6 +114,8 @@ export const emeraldTheme: GridTheme = {
110
114
  rowSelectedBackground: '#d1fae5',
111
115
  rowSelectedHoverBackground: '#a7f3d0',
112
116
  cellFocusBorder: '#059669',
117
+ rangeBackground: 'rgba(5, 150, 105, 0.12)',
118
+ rangeBorder: '#059669',
113
119
  },
114
120
  };
115
121
 
@@ -128,6 +134,8 @@ export const amberTheme: GridTheme = {
128
134
  rowSelectedBackground: '#fef3c7',
129
135
  rowSelectedHoverBackground: '#fde68a',
130
136
  cellFocusBorder: '#d97706',
137
+ rangeBackground: 'rgba(217, 119, 6, 0.12)',
138
+ rangeBorder: '#d97706',
131
139
  },
132
140
  };
133
141
 
@@ -159,6 +159,10 @@ export interface GridThemeGrid {
159
159
  rowSelectedBackground?: string;
160
160
  rowSelectedHoverBackground?: string;
161
161
  cellFocusBorder?: string;
162
+ /** Tint over a selected cell range (`--ogx-range-background`). Default: the primary colour at 12%. @since v3.3 */
163
+ rangeBackground?: string;
164
+ /** Outline of a selected cell range (`--ogx-range-border`). Default: the primary colour. @since v3.3 */
165
+ rangeBorder?: string;
162
166
  pinnedLeftShadow?: string;
163
167
  pinnedRightShadow?: string;
164
168
  checkboxBg?: string;