@opencorestack/opengridx 3.2.2 → 3.4.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 (88) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +16 -2
  3. package/dist/ai.cjs +1 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +166 -0
  6. package/dist/ai.es.js +1 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +321 -3
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +6483 -5291
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -13
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +132 -3
  15. package/docs/README.md +4 -1
  16. package/docs/architecture/datagrid-orchestration.md +23 -6
  17. package/docs/components/header.md +25 -0
  18. package/docs/customization/theming.md +12 -1
  19. package/docs/features/ai-toolkit.md +119 -0
  20. package/docs/features/cell-selection.md +131 -0
  21. package/docs/features/clipboard.md +90 -0
  22. package/docs/features/editing-reordering.md +50 -0
  23. package/docs/features/keyboard-navigation.md +25 -2
  24. package/docs/features/undo-redo.md +79 -0
  25. package/docs/roadmap.md +64 -7
  26. package/lib/ai/coerce.ts +90 -0
  27. package/lib/ai/columns.ts +90 -0
  28. package/lib/ai/index.ts +16 -0
  29. package/lib/ai/schema.ts +214 -0
  30. package/lib/ai/types.ts +88 -0
  31. package/lib/ai/validate.ts +286 -0
  32. package/lib/components/Cell/Cell.css +35 -0
  33. package/lib/components/Cell/Cell.tsx +25 -13
  34. package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
  35. package/lib/components/Cell/GridEditInputCell.css +12 -4
  36. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  37. package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
  38. package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
  39. package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
  40. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  41. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  42. package/lib/components/DataGrid/DataGrid.tsx +123 -3
  43. package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
  44. package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
  45. package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
  46. package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
  47. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  48. package/lib/components/FilterPanel/FilterPanel.tsx +5 -3
  49. package/lib/components/Header/Header.css +12 -0
  50. package/lib/components/Header/Header.tsx +17 -4
  51. package/lib/components/ListView/ListView.css +2 -4
  52. package/lib/components/ListView/ListViewRow.tsx +2 -2
  53. package/lib/components/Row/Row.tsx +38 -1
  54. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  55. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  56. package/lib/components/Toolbar/GridToolbar.tsx +10 -4
  57. package/lib/components/ui/Checkbox.tsx +26 -10
  58. package/lib/components/ui/Input.css +43 -0
  59. package/lib/components/ui/Input.tsx +24 -5
  60. package/lib/hooks/core/gridApiPlaceholder.ts +14 -1
  61. package/lib/hooks/core/useDataGrid.ts +27 -0
  62. package/lib/hooks/core/useGridCellSelection.ts +631 -0
  63. package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
  64. package/lib/hooks/core/useGridClipboardApi.ts +8 -2
  65. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  66. package/lib/hooks/core/useGridControlledState.ts +38 -0
  67. package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
  68. package/lib/hooks/core/useGridRowInteractions.ts +8 -4
  69. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  70. package/lib/hooks/features/useAggregation.ts +8 -6
  71. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  72. package/lib/hooks/features/useGridClipboard.ts +47 -2
  73. package/lib/hooks/features/useGridEditing.ts +39 -51
  74. package/lib/index.ts +16 -0
  75. package/lib/styles/base/variables.css +6 -1
  76. package/lib/styles/opengridx.css +3 -1
  77. package/lib/theme/DataGridThemeProvider.tsx +4 -0
  78. package/lib/theme/presets.ts +8 -0
  79. package/lib/theme/types.ts +4 -0
  80. package/lib/types/index.ts +254 -0
  81. package/lib/utils/attempt.ts +14 -0
  82. package/lib/utils/cellSelection.ts +238 -0
  83. package/lib/utils/editing/commit.ts +69 -0
  84. package/lib/utils/editing/targets.ts +109 -0
  85. package/lib/utils/headerWrap.ts +23 -0
  86. package/lib/utils/parsing.ts +252 -0
  87. package/llms.txt +71 -2
  88. package/package.json +14 -4
@@ -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.
@@ -0,0 +1,283 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
2
+ import type { MutableRefObject, RefObject } from 'react';
3
+ import type {
4
+ GridApi,
5
+ GridBatchEditResult,
6
+ GridBeforeClipboardPasteParams,
7
+ GridCellCoordinates,
8
+ GridCellParams,
9
+ GridClipboardPasteResult,
10
+ GridColDef,
11
+ GridEditSkipReason,
12
+ GridRowId,
13
+ GridRowMeta,
14
+ GridRowModel,
15
+ } from '../../types';
16
+ import { attempt } from '../../utils/attempt';
17
+ import { classifyKeyTarget } from '../../utils/focus';
18
+ import type { FocusedCell } from '../../utils/focus';
19
+ import { isRangeColumnField, rectCellCount } from '../../utils/cellSelection';
20
+ import type { CellRangeRect } from '../../utils/cellSelection';
21
+ import { parseCellText, parseTsv } from '../../utils/parsing';
22
+ import { getCellValue, warnCallbackFailure } from '../../utils/values';
23
+ import {
24
+ buildEditTargetContext,
25
+ isEditTargetWritable,
26
+ isTextColumn,
27
+ rectCorners,
28
+ } from '../../utils/editing/targets';
29
+ import type { GridEditTargetContext } from '../../utils/editing/targets';
30
+ import type { GridBatchCellEdit, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
31
+ import type { UseGridCellSelectionReturn } from './useGridCellSelection';
32
+
33
+ /** Above this many cells a paste warns in development. */
34
+ export const LARGE_PASTE_CELLS = 10_000;
35
+
36
+ const emptyResult = (): GridBatchEditResult => ({ updated: [], failed: [], skipped: [] });
37
+
38
+ export interface UseGridClipboardPasteParams<R extends GridRowModel> {
39
+ apiRef: MutableRefObject<GridApi>;
40
+ /** The grid root: paste and Delete are listened for here. */
41
+ containerRef: RefObject<HTMLDivElement | null>;
42
+ viewportRef: RefObject<HTMLDivElement | null>;
43
+ selection: UseGridCellSelectionReturn;
44
+ disableClipboardPaste: boolean;
45
+ disableRangeClear: boolean;
46
+ focusedCell: FocusedCell | null;
47
+ allRenderableRows: R[];
48
+ getRowId: (row: R) => GridRowId;
49
+ /** System columns and the rendered data columns, in render order. */
50
+ navigationColumns: ReadonlyArray<GridColDef<R>>;
51
+ isCellEditable?: (params: GridCellParams<R>) => boolean;
52
+ rowMetaMap: Map<GridRowId, GridRowMeta>;
53
+ getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
54
+ /** Whether a cell editor is open: paste and Delete then belong to it. */
55
+ isEditing: boolean;
56
+ batchEdit: UseGridBatchEditReturn<R>;
57
+ onClipboardPaste?: (result: GridClipboardPasteResult) => void;
58
+ onBeforeClipboardPaste?: (params: GridBeforeClipboardPasteParams) => boolean | string;
59
+ }
60
+
61
+ function warnLargePaste(cellCount: number): void {
62
+ if (process.env.NODE_ENV === 'production') return;
63
+ console.warn(`[OpenGridX] Pasting ${cellCount} cells. Pastes above ${LARGE_PASTE_CELLS} cells can be slow; consider importing the data instead.`);
64
+ }
65
+
66
+ /**
67
+ * Clipboard paste and range clear (v3.4). With `cellSelection` on: Ctrl/Cmd+V (the `paste` event,
68
+ * so no clipboard permission is needed) writes tab-separated text into the editable cells, and
69
+ * Delete / Backspace on a multi-cell range empties them. Both are one batch edit (`useGridBatchEdit`),
70
+ * so one undo. Installs `apiRef.pasteText`. Runs after useGridCellSelectionApi.
71
+ */
72
+ export function useGridClipboardPaste<R extends GridRowModel>(params: UseGridClipboardPasteParams<R>): void {
73
+ const { apiRef, containerRef, selection, disableClipboardPaste, disableRangeClear } = params;
74
+ const latestRef = useRef(params);
75
+ useLayoutEffect(() => {
76
+ latestRef.current = params;
77
+ });
78
+
79
+ const getContext = useCallback((): GridEditTargetContext<R> => {
80
+ const { allRenderableRows, getRowId, navigationColumns } = latestRef.current;
81
+ return buildEditTargetContext(allRenderableRows, getRowId, navigationColumns);
82
+ }, []);
83
+
84
+ /** The range's rectangle (display indices), or null. */
85
+ const currentRect = useCallback((): CellRangeRect | null => {
86
+ const { selection: s } = latestRef.current;
87
+ if (!s.enabled) return null;
88
+ return s.getCurrentRange().resolved;
89
+ }, []);
90
+
91
+ /** The cell a paste starts from: the range's top-left cell, else the focused data cell. */
92
+ const defaultAnchor = useCallback((ctx: GridEditTargetContext<R>): GridCellCoordinates | null => {
93
+ const rect = currentRect();
94
+ if (rect && ctx.rowIds[rect.top] !== undefined && ctx.columns[rect.left]) {
95
+ return { id: ctx.rowIds[rect.top], field: ctx.columns[rect.left].field };
96
+ }
97
+ const focus = latestRef.current.focusedCell;
98
+ if (focus && focus.id !== null && isRangeColumnField(focus.field)) return { id: focus.id, field: focus.field };
99
+ return null;
100
+ }, [currentRect]);
101
+
102
+ const paste = useCallback(async (clipboardText: string, anchorOverride: GridCellCoordinates | undefined, viaKeyboard: boolean): Promise<GridBatchEditResult> => {
103
+ const ctx = getContext();
104
+ const anchor = anchorOverride ?? defaultAnchor(ctx);
105
+ let text = clipboardText;
106
+ const { onBeforeClipboardPaste } = latestRef.current;
107
+ if (onBeforeClipboardPaste) {
108
+ const verdict = attempt(() => onBeforeClipboardPaste({ text, anchor }));
109
+ if (!verdict.ok) {
110
+ warnCallbackFailure('onBeforeClipboardPaste', 'onBeforeClipboardPaste threw; the paste was cancelled', verdict.error);
111
+ return emptyResult();
112
+ }
113
+ if (verdict.value === false) return emptyResult();
114
+ if (typeof verdict.value === 'string') text = verdict.value;
115
+ }
116
+ if (!anchor) return emptyResult();
117
+ const block = parseTsv(text);
118
+ const anchorRow = ctx.rowIndexById.get(anchor.id);
119
+ const anchorCol = ctx.colIndexByField.get(anchor.field);
120
+ if (block.length === 0 || anchorRow === undefined || anchorCol === undefined) return emptyResult();
121
+
122
+ const lastRow = ctx.rowIds.length - 1;
123
+ const lastCol = ctx.columns.length - 1;
124
+ // A range of several cells bounds the paste; one value fills all of it.
125
+ const range = anchorOverride ? null : currentRect();
126
+ const multiCellRange = range && rectCellCount(range) > 1 ? range : null;
127
+ const singleValue = block.length === 1 && block[0].length === 1;
128
+ const blockWidth = block.reduce((max, line) => Math.max(max, line.length), 0);
129
+ let rect: CellRangeRect;
130
+ if (singleValue && multiCellRange) {
131
+ rect = multiCellRange;
132
+ } else {
133
+ rect = {
134
+ top: anchorRow,
135
+ left: anchorCol,
136
+ bottom: Math.min(anchorRow + block.length - 1, lastRow, multiCellRange ? multiCellRange.bottom : lastRow),
137
+ right: Math.min(anchorCol + blockWidth - 1, lastCol, multiCellRange ? multiCellRange.right : lastCol),
138
+ };
139
+ }
140
+ const cellCount = rectCellCount(rect);
141
+ if (cellCount > LARGE_PASTE_CELLS) warnLargePaste(cellCount);
142
+
143
+ const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin } = latestRef.current;
144
+ const rules = { isCellEditable, rowMetaMap, getSpanOrigin };
145
+ const edits: GridBatchCellEdit[] = [];
146
+ const skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[] = [];
147
+ for (let r = rect.top; r <= rect.bottom; r++) {
148
+ const id = ctx.rowIds[r];
149
+ const row = batchEdit.getRow(id) ?? ctx.rows[r];
150
+ const line = singleValue ? block[0] : block[r - rect.top];
151
+ for (let c = rect.left; c <= rect.right; c++) {
152
+ const cellText = singleValue ? block[0][0] : line?.[c - rect.left];
153
+ if (cellText === undefined) continue;
154
+ const colDef = ctx.columns[c];
155
+ if (!isEditTargetWritable(ctx, rules, row, r, c)) {
156
+ skipped.push({ id, field: colDef.field, reason: 'notEditable' });
157
+ continue;
158
+ }
159
+ const parsed = parseCellText(cellText, colDef, row, { current: getCellValue(row, colDef.field, colDef) });
160
+ if (!parsed.ok) {
161
+ skipped.push({ id, field: colDef.field, reason: 'invalidValue' });
162
+ continue;
163
+ }
164
+ edits.push({ id, field: colDef.field, value: parsed.value });
165
+ }
166
+ }
167
+
168
+ // The pasted area becomes the selection, so one look shows what changed.
169
+ if (latestRef.current.selection.enabled) {
170
+ const { anchor: from, head: to } = rectCorners(ctx, rect);
171
+ latestRef.current.selection.setModel([{ anchor: from, head: to }], viaKeyboard ? 'keyboard' : 'api');
172
+ }
173
+
174
+ const outcome = await batchEdit.applyEdits(edits, 'paste');
175
+ const result: GridBatchEditResult = {
176
+ updated: outcome.result.updated,
177
+ failed: outcome.result.failed,
178
+ skipped: [...skipped, ...outcome.result.skipped],
179
+ };
180
+ latestRef.current.onClipboardPaste?.({ ...result, text });
181
+ return result;
182
+ }, [getContext, defaultAnchor, currentRect]);
183
+
184
+ /** Delete / Backspace: empties the editable cells of a multi-cell range. Returns whether it applied. */
185
+ const clearRange = useCallback((): boolean => {
186
+ const rect = currentRect();
187
+ if (!rect || rectCellCount(rect) <= 1) return false;
188
+ const ctx = getContext();
189
+ const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin } = latestRef.current;
190
+ const rules = { isCellEditable, rowMetaMap, getSpanOrigin };
191
+ const edits: GridBatchCellEdit[] = [];
192
+ for (let r = rect.top; r <= rect.bottom; r++) {
193
+ const id = ctx.rowIds[r];
194
+ if (id === undefined) continue;
195
+ const row = batchEdit.getRow(id) ?? ctx.rows[r];
196
+ for (let c = rect.left; c <= rect.right; c++) {
197
+ const colDef = ctx.columns[c];
198
+ if (!colDef || !isEditTargetWritable(ctx, rules, row, r, c)) continue;
199
+ edits.push({ id, field: colDef.field, value: isTextColumn(colDef) ? '' : null });
200
+ }
201
+ }
202
+ void batchEdit.applyEdits(edits, 'clear');
203
+ return true;
204
+ }, [currentRect, getContext]);
205
+
206
+ const pasteEnabled = selection.enabled && !disableClipboardPaste;
207
+ const clearEnabled = selection.enabled && !disableRangeClear;
208
+
209
+ useEffect(() => {
210
+ const root = containerRef.current;
211
+ if (!root || (!pasteEnabled && !clearEnabled)) return;
212
+ /** Only events aimed at this grid's cells (not an editor, a text field or a nested grid). */
213
+ const ownsTarget = (event: Event) => {
214
+ const viewport = latestRef.current.viewportRef.current;
215
+ if (!viewport || event.defaultPrevented || latestRef.current.isEditing) return false;
216
+ const kind = classifyKeyTarget(event.target, viewport);
217
+ return kind === 'grid' || kind === 'control';
218
+ };
219
+ // Some engines (WebKit on Linux) only fire `paste` on an editable element, never on a focused
220
+ // cell. On the paste shortcut, focus moves to an off-screen textarea for that one event, so the
221
+ // browser's paste lands there and bubbles to the root; focus then goes back to the cell.
222
+ let capture: { textarea: HTMLTextAreaElement; restore: HTMLElement | null; timer: number } | null = null;
223
+ const endCapture = () => {
224
+ if (!capture) return;
225
+ const { textarea, restore, timer } = capture;
226
+ capture = null;
227
+ window.clearTimeout(timer);
228
+ const hadFocus = document.activeElement === textarea;
229
+ textarea.remove();
230
+ if (hadFocus && restore && restore.isConnected) restore.focus({ preventScroll: true });
231
+ };
232
+ const startCapture = () => {
233
+ endCapture();
234
+ const textarea = document.createElement('textarea');
235
+ textarea.setAttribute('aria-hidden', 'true');
236
+ textarea.tabIndex = -1;
237
+ textarea.className = 'ogx__paste-capture';
238
+ textarea.style.cssText = 'position:fixed;top:0;left:-10000px;width:1px;height:1px;opacity:0;';
239
+ root.appendChild(textarea);
240
+ const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null;
241
+ // No paste event within the timeout (an empty or non-text clipboard): give focus back anyway.
242
+ capture = { textarea, restore, timer: window.setTimeout(endCapture, 250) };
243
+ textarea.focus({ preventScroll: true });
244
+ };
245
+ const isCaptureTarget = (event: Event) => capture !== null && event.target === capture.textarea;
246
+ const onPaste = (event: ClipboardEvent) => {
247
+ if (isCaptureTarget(event)) {
248
+ const text = event.clipboardData ? event.clipboardData.getData('text/plain') : '';
249
+ event.preventDefault();
250
+ endCapture();
251
+ if (text !== '') void paste(text, undefined, true);
252
+ return;
253
+ }
254
+ if (!pasteEnabled || !ownsTarget(event)) return;
255
+ if (!latestRef.current.navigationColumns.some(c => c.editable)) return;
256
+ const text = event.clipboardData ? event.clipboardData.getData('text/plain') : '';
257
+ if (text === '') return;
258
+ event.preventDefault();
259
+ void paste(text, undefined, true);
260
+ };
261
+ const onKeyDown = (event: KeyboardEvent) => {
262
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && (event.key === 'v' || event.key === 'V' || event.code === 'KeyV')) {
263
+ if (pasteEnabled && ownsTarget(event) && latestRef.current.navigationColumns.some(c => c.editable)) startCapture();
264
+ return;
265
+ }
266
+ if (!clearEnabled || (event.key !== 'Delete' && event.key !== 'Backspace')) return;
267
+ if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
268
+ if (!ownsTarget(event)) return;
269
+ if (clearRange()) event.preventDefault();
270
+ };
271
+ root.addEventListener('paste', onPaste);
272
+ root.addEventListener('keydown', onKeyDown);
273
+ return () => {
274
+ endCapture();
275
+ root.removeEventListener('paste', onPaste);
276
+ root.removeEventListener('keydown', onKeyDown);
277
+ };
278
+ }, [containerRef, pasteEnabled, clearEnabled, paste, clearRange]);
279
+
280
+ useLayoutEffect(() => {
281
+ apiRef.current.pasteText = (text, anchor) => paste(text, anchor, false);
282
+ }, [apiRef, paste]);
283
+ }
@@ -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
  }