@opencorestack/opengridx 3.2.2 → 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.
- package/CHANGELOG.md +22 -0
- package/README.md +12 -1
- package/dist/index.d.ts +173 -1
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +5472 -4841
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +15 -14
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +34 -2
- package/docs/README.md +1 -0
- package/docs/architecture/datagrid-orchestration.md +13 -5
- package/docs/components/header.md +25 -0
- package/docs/customization/theming.md +3 -1
- package/docs/features/cell-selection.md +131 -0
- package/docs/features/clipboard.md +2 -0
- package/docs/features/keyboard-navigation.md +14 -2
- package/docs/roadmap.md +64 -7
- package/lib/components/Cell/Cell.css +30 -0
- package/lib/components/Cell/Cell.tsx +25 -13
- package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
- package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
- package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
- package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
- package/lib/components/DataGrid/DataGrid.tsx +61 -1
- package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
- package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +2 -1
- package/lib/components/Header/Header.css +12 -0
- package/lib/components/Header/Header.tsx +17 -4
- package/lib/components/Row/Row.tsx +38 -1
- package/lib/components/Toolbar/GridToolbar.tsx +10 -4
- package/lib/components/ui/Checkbox.tsx +10 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +6 -0
- package/lib/hooks/core/useGridCellSelection.ts +603 -0
- package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
- package/lib/hooks/core/useGridClipboardApi.ts +8 -2
- package/lib/hooks/core/useGridControlledState.ts +38 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +8 -4
- package/lib/hooks/features/useAggregation.ts +8 -6
- package/lib/hooks/features/useGridClipboard.ts +47 -2
- package/lib/hooks/features/useGridEditing.ts +16 -7
- package/lib/index.ts +9 -0
- package/lib/styles/base/variables.css +6 -1
- package/lib/styles/opengridx.css +1 -0
- package/lib/theme/DataGridThemeProvider.tsx +4 -0
- package/lib/theme/presets.ts +8 -0
- package/lib/theme/types.ts +4 -0
- package/lib/types/index.ts +130 -0
- package/lib/utils/attempt.ts +14 -0
- package/lib/utils/cellSelection.ts +238 -0
- package/lib/utils/headerWrap.ts +23 -0
- package/llms.txt +17 -2
- package/package.json +7 -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
|
-
|
|
73
|
+
clickRow(id);
|
|
70
74
|
}
|
|
71
|
-
}, [isHierarchyEnabled, activeHierarchyHandlers, onRowClick, rowMetaMap, disableRowSelectionOnClick,
|
|
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
|
-
|
|
78
|
-
}, [rowMetaMap,
|
|
81
|
+
toggleRow(rowId, isSelected);
|
|
82
|
+
}, [rowMetaMap, toggleRow]);
|
|
79
83
|
|
|
80
84
|
return { rowSelection, handleRowClick, handleSelectionChange };
|
|
81
85
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
}
|
|
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
|
-
|
|
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);
|
package/lib/styles/opengridx.css
CHANGED
|
@@ -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);
|
package/lib/theme/presets.ts
CHANGED
|
@@ -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
|
|
package/lib/theme/types.ts
CHANGED
|
@@ -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;
|