@alaarab/ogrid-core 1.2.2 → 1.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/dist/esm/components/GridContextMenu.js +12 -3
- package/dist/esm/components/MarchingAntsOverlay.js +109 -0
- package/dist/esm/hooks/useCellSelection.js +4 -0
- package/dist/esm/hooks/useClipboard.js +41 -8
- package/dist/esm/hooks/useColumnHeaderFilterState.js +11 -12
- package/dist/esm/hooks/useContextMenu.js +1 -0
- package/dist/esm/hooks/useDataGridState.js +74 -30
- package/dist/esm/hooks/useFillHandle.js +16 -3
- package/dist/esm/hooks/useInlineCellEditorState.js +2 -0
- package/dist/esm/hooks/useKeyboardNavigation.js +22 -2
- package/dist/esm/hooks/useOGrid.js +4 -1
- package/dist/esm/hooks/useUndoRedo.js +44 -14
- package/dist/esm/index.js +2 -1
- package/dist/esm/utils/dataGridViewModel.js +7 -1
- package/dist/esm/utils/gridContextMenuHelpers.js +20 -5
- package/dist/esm/utils/index.js +2 -1
- package/dist/esm/utils/valueParsers.js +107 -0
- package/dist/types/components/GridContextMenu.d.ts +4 -0
- package/dist/types/components/MarchingAntsOverlay.d.ts +15 -0
- package/dist/types/hooks/useCellSelection.d.ts +2 -0
- package/dist/types/hooks/useClipboard.d.ts +7 -0
- package/dist/types/hooks/useContextMenu.d.ts +1 -0
- package/dist/types/hooks/useDataGridState.d.ts +12 -0
- package/dist/types/hooks/useFillHandle.d.ts +3 -0
- package/dist/types/hooks/useKeyboardNavigation.d.ts +2 -1
- package/dist/types/hooks/useUndoRedo.d.ts +5 -1
- package/dist/types/index.d.ts +5 -3
- package/dist/types/types/columnTypes.d.ts +17 -0
- package/dist/types/types/dataGridTypes.d.ts +8 -0
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/utils/dataGridViewModel.d.ts +9 -0
- package/dist/types/utils/gridContextMenuHelpers.d.ts +8 -0
- package/dist/types/utils/index.d.ts +3 -1
- package/dist/types/utils/valueParsers.d.ts +37 -0
- package/package.json +1 -1
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
import { GRID_CONTEXT_MENU_ITEMS, getContextMenuHandlers } from '../utils/gridContextMenuHelpers';
|
|
3
|
+
import { GRID_CONTEXT_MENU_ITEMS, getContextMenuHandlers, formatShortcut } from '../utils/gridContextMenuHelpers';
|
|
4
4
|
export function GridContextMenu(props) {
|
|
5
|
-
const { x, y, hasSelection, onClose, classNames } = props;
|
|
5
|
+
const { x, y, hasSelection, canUndo, canRedo, onClose, classNames } = props;
|
|
6
6
|
const ref = React.useRef(null);
|
|
7
7
|
const handlers = React.useMemo(() => getContextMenuHandlers(props), [props]);
|
|
8
|
+
const isDisabled = React.useCallback((item) => {
|
|
9
|
+
if (item.disabledWhenNoSelection && !hasSelection)
|
|
10
|
+
return true;
|
|
11
|
+
if (item.id === 'undo' && !canUndo)
|
|
12
|
+
return true;
|
|
13
|
+
if (item.id === 'redo' && !canRedo)
|
|
14
|
+
return true;
|
|
15
|
+
return false;
|
|
16
|
+
}, [hasSelection, canUndo, canRedo]);
|
|
8
17
|
React.useEffect(() => {
|
|
9
18
|
const handleClickOutside = (e) => {
|
|
10
19
|
if (ref.current && !ref.current.contains(e.target))
|
|
@@ -21,5 +30,5 @@ export function GridContextMenu(props) {
|
|
|
21
30
|
document.removeEventListener('keydown', handleKeyDown, true);
|
|
22
31
|
};
|
|
23
32
|
}, [onClose]);
|
|
24
|
-
return (_jsx("div", { ref: ref, className: classNames?.contextMenu, role: "menu", style: { left: x, top: y }, "aria-label": "Grid context menu", children: GRID_CONTEXT_MENU_ITEMS.map((item) => (_jsxs(React.Fragment, { children: [item.
|
|
33
|
+
return (_jsx("div", { ref: ref, className: classNames?.contextMenu, role: "menu", style: { left: x, top: y }, "aria-label": "Grid context menu", children: GRID_CONTEXT_MENU_ITEMS.map((item) => (_jsxs(React.Fragment, { children: [item.dividerBefore && _jsx("div", { className: classNames?.contextMenuDivider }), _jsxs("button", { type: "button", className: classNames?.contextMenuItem, onClick: handlers[item.id], disabled: isDisabled(item), children: [_jsx("span", { className: classNames?.contextMenuItemLabel, children: item.label }), item.shortcut && (_jsx("span", { className: classNames?.contextMenuItemShortcut, children: formatShortcut(item.shortcut) }))] })] }, item.id))) }));
|
|
25
34
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* MarchingAntsOverlay — Renders range overlays on top of the grid:
|
|
4
|
+
*
|
|
5
|
+
* 1. **Selection range**: solid green border around the current selection
|
|
6
|
+
* 2. **Copy/Cut range**: animated dashed border (marching ants) like Excel
|
|
7
|
+
*
|
|
8
|
+
* Uses SVG rects positioned via cell data-attribute measurements.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
11
|
+
// Inject the @keyframes rule once into <head>
|
|
12
|
+
let styleInjected = false;
|
|
13
|
+
function ensureKeyframes() {
|
|
14
|
+
if (styleInjected || typeof document === 'undefined')
|
|
15
|
+
return;
|
|
16
|
+
const style = document.createElement('style');
|
|
17
|
+
style.id = 'ogrid-marching-ants-keyframes';
|
|
18
|
+
style.textContent =
|
|
19
|
+
'@keyframes ogrid-marching-ants{to{stroke-dashoffset:-8}}';
|
|
20
|
+
document.head.appendChild(style);
|
|
21
|
+
styleInjected = true;
|
|
22
|
+
}
|
|
23
|
+
/** Measure the bounding rect of a range within a container. */
|
|
24
|
+
function measureRange(container, range, colOffset) {
|
|
25
|
+
const startGlobalCol = range.startCol + colOffset;
|
|
26
|
+
const endGlobalCol = range.endCol + colOffset;
|
|
27
|
+
const topLeft = container.querySelector(`[data-row-index="${range.startRow}"][data-col-index="${startGlobalCol}"]`);
|
|
28
|
+
const bottomRight = container.querySelector(`[data-row-index="${range.endRow}"][data-col-index="${endGlobalCol}"]`);
|
|
29
|
+
if (!topLeft || !bottomRight)
|
|
30
|
+
return null;
|
|
31
|
+
const cRect = container.getBoundingClientRect();
|
|
32
|
+
const tlRect = topLeft.getBoundingClientRect();
|
|
33
|
+
const brRect = bottomRight.getBoundingClientRect();
|
|
34
|
+
return {
|
|
35
|
+
top: tlRect.top - cRect.top,
|
|
36
|
+
left: tlRect.left - cRect.left,
|
|
37
|
+
width: brRect.right - tlRect.left,
|
|
38
|
+
height: brRect.bottom - tlRect.top,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function MarchingAntsOverlay({ containerRef, selectionRange, copyRange, cutRange, colOffset, }) {
|
|
42
|
+
const [selRect, setSelRect] = useState(null);
|
|
43
|
+
const [clipRect, setClipRect] = useState(null);
|
|
44
|
+
const rafRef = useRef(0);
|
|
45
|
+
const clipRange = copyRange ?? cutRange;
|
|
46
|
+
const measureAll = useCallback(() => {
|
|
47
|
+
const container = containerRef.current;
|
|
48
|
+
if (!container) {
|
|
49
|
+
setSelRect(null);
|
|
50
|
+
setClipRect(null);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
setSelRect(selectionRange ? measureRange(container, selectionRange, colOffset) : null);
|
|
54
|
+
setClipRect(clipRange ? measureRange(container, clipRange, colOffset) : null);
|
|
55
|
+
}, [selectionRange, clipRange, containerRef, colOffset]);
|
|
56
|
+
// Inject keyframes on mount
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
ensureKeyframes();
|
|
59
|
+
}, []);
|
|
60
|
+
// Measure when any range changes; re-measure on resize
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!selectionRange && !clipRange) {
|
|
63
|
+
setSelRect(null);
|
|
64
|
+
setClipRect(null);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Delay one frame so cells are rendered
|
|
68
|
+
rafRef.current = requestAnimationFrame(measureAll);
|
|
69
|
+
const container = containerRef.current;
|
|
70
|
+
let ro;
|
|
71
|
+
if (container) {
|
|
72
|
+
ro = new ResizeObserver(measureAll);
|
|
73
|
+
ro.observe(container);
|
|
74
|
+
}
|
|
75
|
+
return () => {
|
|
76
|
+
cancelAnimationFrame(rafRef.current);
|
|
77
|
+
ro?.disconnect();
|
|
78
|
+
};
|
|
79
|
+
}, [selectionRange, clipRange, measureAll, containerRef]);
|
|
80
|
+
if (!selRect && !clipRect)
|
|
81
|
+
return null;
|
|
82
|
+
// When clipboard range matches the selection range, hide the solid selection border
|
|
83
|
+
// so the marching ants animation is clearly visible (not obscured by solid stroke underneath).
|
|
84
|
+
const clipRangeMatchesSel = selectionRange != null &&
|
|
85
|
+
clipRange != null &&
|
|
86
|
+
selectionRange.startRow === clipRange.startRow &&
|
|
87
|
+
selectionRange.startCol === clipRange.startCol &&
|
|
88
|
+
selectionRange.endRow === clipRange.endRow &&
|
|
89
|
+
selectionRange.endCol === clipRange.endCol;
|
|
90
|
+
return (_jsxs(_Fragment, { children: [selRect && !clipRangeMatchesSel && (_jsx("svg", { style: {
|
|
91
|
+
position: 'absolute',
|
|
92
|
+
top: selRect.top,
|
|
93
|
+
left: selRect.left,
|
|
94
|
+
width: selRect.width,
|
|
95
|
+
height: selRect.height,
|
|
96
|
+
pointerEvents: 'none',
|
|
97
|
+
zIndex: 4,
|
|
98
|
+
overflow: 'visible',
|
|
99
|
+
}, "aria-hidden": "true", children: _jsx("rect", { x: "1", y: "1", width: Math.max(0, selRect.width - 2), height: Math.max(0, selRect.height - 2), fill: "none", stroke: "var(--ogrid-selection, #217346)", strokeWidth: "2" }) })), clipRect && (_jsx("svg", { style: {
|
|
100
|
+
position: 'absolute',
|
|
101
|
+
top: clipRect.top,
|
|
102
|
+
left: clipRect.left,
|
|
103
|
+
width: clipRect.width,
|
|
104
|
+
height: clipRect.height,
|
|
105
|
+
pointerEvents: 'none',
|
|
106
|
+
zIndex: 5,
|
|
107
|
+
overflow: 'visible',
|
|
108
|
+
}, "aria-hidden": "true", children: _jsx("rect", { x: "1", y: "1", width: Math.max(0, clipRect.width - 2), height: Math.max(0, clipRect.height - 2), fill: "none", stroke: "var(--ogrid-selection, #217346)", strokeWidth: "2", strokeDasharray: "4 4", style: { animation: 'ogrid-marching-ants 0.5s linear infinite' } }) }))] }));
|
|
109
|
+
}
|
|
@@ -4,6 +4,7 @@ export function useCellSelection(params) {
|
|
|
4
4
|
const { colOffset, rowCount, visibleColCount, setActiveCell } = params;
|
|
5
5
|
const [selectionRange, setSelectionRange] = useState(null);
|
|
6
6
|
const isDraggingRef = useRef(false);
|
|
7
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
7
8
|
const dragStartRef = useRef(null);
|
|
8
9
|
const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
|
|
9
10
|
if (globalColIndex < colOffset)
|
|
@@ -30,6 +31,7 @@ export function useCellSelection(params) {
|
|
|
30
31
|
});
|
|
31
32
|
setActiveCell({ rowIndex, columnIndex: globalColIndex });
|
|
32
33
|
isDraggingRef.current = true;
|
|
34
|
+
setIsDragging(true);
|
|
33
35
|
}
|
|
34
36
|
}, [colOffset, selectionRange, setActiveCell]);
|
|
35
37
|
const handleSelectAllCells = useCallback(() => {
|
|
@@ -68,6 +70,7 @@ export function useCellSelection(params) {
|
|
|
68
70
|
};
|
|
69
71
|
const onUp = () => {
|
|
70
72
|
isDraggingRef.current = false;
|
|
73
|
+
setIsDragging(false);
|
|
71
74
|
dragStartRef.current = null;
|
|
72
75
|
};
|
|
73
76
|
window.addEventListener('mousemove', onMove, true);
|
|
@@ -82,5 +85,6 @@ export function useCellSelection(params) {
|
|
|
82
85
|
setSelectionRange,
|
|
83
86
|
handleCellMouseDown,
|
|
84
87
|
handleSelectAllCells,
|
|
88
|
+
isDragging,
|
|
85
89
|
};
|
|
86
90
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from 'react';
|
|
2
2
|
import { getCellValue } from '../utils';
|
|
3
|
+
import { parseValue } from '../utils/valueParsers';
|
|
3
4
|
import { normalizeSelectionRange } from '../types';
|
|
4
5
|
export function useClipboard(params) {
|
|
5
|
-
const { items, visibleCols, colOffset, selectionRange, activeCell, onCellValueChanged, } = params;
|
|
6
|
+
const { items, visibleCols, colOffset, selectionRange, activeCell, editable, onCellValueChanged, beginBatch, endBatch, } = params;
|
|
6
7
|
const cutRangeRef = useRef(null);
|
|
7
8
|
const [cutRange, setCutRange] = useState(null);
|
|
9
|
+
const [copyRange, setCopyRange] = useState(null);
|
|
8
10
|
/** In-page clipboard fallback when system clipboard is unavailable. */
|
|
9
11
|
const internalClipboardRef = useRef(null);
|
|
10
12
|
const handleCopy = useCallback(() => {
|
|
@@ -28,16 +30,20 @@ export function useClipboard(params) {
|
|
|
28
30
|
break;
|
|
29
31
|
const item = items[r];
|
|
30
32
|
const col = visibleCols[c];
|
|
31
|
-
const
|
|
33
|
+
const raw = getCellValue(item, col);
|
|
34
|
+
const val = col.valueFormatter ? col.valueFormatter(raw, item) : raw;
|
|
32
35
|
cells.push(val != null && val !== '' ? String(val).replace(/\t/g, ' ').replace(/\n/g, ' ') : '');
|
|
33
36
|
}
|
|
34
37
|
rows.push(cells.join('\t'));
|
|
35
38
|
}
|
|
36
39
|
const tsv = rows.join('\r\n');
|
|
37
40
|
internalClipboardRef.current = tsv;
|
|
41
|
+
setCopyRange(norm);
|
|
38
42
|
void navigator.clipboard.writeText(tsv).catch(() => { });
|
|
39
43
|
}, [selectionRange, activeCell, colOffset, items, visibleCols]);
|
|
40
44
|
const handleCut = useCallback(() => {
|
|
45
|
+
if (editable === false)
|
|
46
|
+
return;
|
|
41
47
|
const range = selectionRange ??
|
|
42
48
|
(activeCell != null
|
|
43
49
|
? {
|
|
@@ -52,9 +58,14 @@ export function useClipboard(params) {
|
|
|
52
58
|
const norm = normalizeSelectionRange(range);
|
|
53
59
|
cutRangeRef.current = norm;
|
|
54
60
|
setCutRange(norm);
|
|
61
|
+
setCopyRange(null);
|
|
55
62
|
handleCopy();
|
|
56
|
-
|
|
63
|
+
// handleCopy sets copyRange — override it back since this is a cut
|
|
64
|
+
setCopyRange(null);
|
|
65
|
+
}, [selectionRange, activeCell, colOffset, handleCopy, editable, onCellValueChanged]);
|
|
57
66
|
const handlePaste = useCallback(async () => {
|
|
67
|
+
if (editable === false)
|
|
68
|
+
return;
|
|
58
69
|
if (onCellValueChanged == null)
|
|
59
70
|
return;
|
|
60
71
|
let text;
|
|
@@ -81,6 +92,7 @@ export function useClipboard(params) {
|
|
|
81
92
|
const anchorRow = norm ? norm.startRow : 0;
|
|
82
93
|
const anchorCol = norm ? norm.startCol : 0;
|
|
83
94
|
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
|
95
|
+
beginBatch?.();
|
|
84
96
|
for (let r = 0; r < lines.length; r++) {
|
|
85
97
|
const cells = lines[r].split('\t');
|
|
86
98
|
for (let c = 0; c < cells.length; c++) {
|
|
@@ -90,14 +102,21 @@ export function useClipboard(params) {
|
|
|
90
102
|
continue;
|
|
91
103
|
const item = items[targetRow];
|
|
92
104
|
const col = visibleCols[targetCol];
|
|
93
|
-
const
|
|
105
|
+
const colEditable = col.editable === true ||
|
|
106
|
+
(typeof col.editable === 'function' && col.editable(item));
|
|
107
|
+
if (!colEditable)
|
|
108
|
+
continue;
|
|
109
|
+
const rawValue = cells[c] ?? '';
|
|
94
110
|
const oldValue = getCellValue(item, col);
|
|
111
|
+
const result = parseValue(rawValue, oldValue, item, col);
|
|
112
|
+
if (!result.valid)
|
|
113
|
+
continue;
|
|
95
114
|
onCellValueChanged({
|
|
96
115
|
item,
|
|
97
116
|
columnId: col.columnId,
|
|
98
117
|
field: col.columnId,
|
|
99
118
|
oldValue,
|
|
100
|
-
newValue,
|
|
119
|
+
newValue: result.value,
|
|
101
120
|
rowIndex: targetRow,
|
|
102
121
|
});
|
|
103
122
|
}
|
|
@@ -110,13 +129,20 @@ export function useClipboard(params) {
|
|
|
110
129
|
continue;
|
|
111
130
|
const item = items[r];
|
|
112
131
|
const col = visibleCols[c];
|
|
132
|
+
const colEditable = col.editable === true ||
|
|
133
|
+
(typeof col.editable === 'function' && col.editable(item));
|
|
134
|
+
if (!colEditable)
|
|
135
|
+
continue;
|
|
113
136
|
const oldValue = getCellValue(item, col);
|
|
137
|
+
const result = parseValue('', oldValue, item, col);
|
|
138
|
+
if (!result.valid)
|
|
139
|
+
continue;
|
|
114
140
|
onCellValueChanged({
|
|
115
141
|
item,
|
|
116
142
|
columnId: col.columnId,
|
|
117
143
|
field: col.columnId,
|
|
118
144
|
oldValue,
|
|
119
|
-
newValue:
|
|
145
|
+
newValue: result.value,
|
|
120
146
|
rowIndex: r,
|
|
121
147
|
});
|
|
122
148
|
}
|
|
@@ -124,6 +150,13 @@ export function useClipboard(params) {
|
|
|
124
150
|
cutRangeRef.current = null;
|
|
125
151
|
setCutRange(null);
|
|
126
152
|
}
|
|
127
|
-
|
|
128
|
-
|
|
153
|
+
endBatch?.();
|
|
154
|
+
setCopyRange(null);
|
|
155
|
+
}, [selectionRange, activeCell, colOffset, items, visibleCols, editable, onCellValueChanged, beginBatch, endBatch]);
|
|
156
|
+
const clearClipboardRanges = useCallback(() => {
|
|
157
|
+
setCopyRange(null);
|
|
158
|
+
setCutRange(null);
|
|
159
|
+
cutRangeRef.current = null;
|
|
160
|
+
}, []);
|
|
161
|
+
return { handleCopy, handleCut, handlePaste, cutRangeRef, cutRange, copyRange, clearClipboardRanges };
|
|
129
162
|
}
|
|
@@ -23,7 +23,7 @@ export function useColumnHeaderFilterState(params) {
|
|
|
23
23
|
const [isPeopleLoading, setIsPeopleLoading] = useState(false);
|
|
24
24
|
const [peopleSearchText, setPeopleSearchText] = useState('');
|
|
25
25
|
const [popoverPosition, setPopoverPosition] = useState(null);
|
|
26
|
-
// Sync temp state when popover opens
|
|
26
|
+
// Sync temp state when popover opens
|
|
27
27
|
useEffect(() => {
|
|
28
28
|
if (isFilterOpen) {
|
|
29
29
|
setTempSelected(new Set(safeSelectedValues));
|
|
@@ -31,16 +31,9 @@ export function useColumnHeaderFilterState(params) {
|
|
|
31
31
|
setSearchText('');
|
|
32
32
|
setPeopleSearchText('');
|
|
33
33
|
setPeopleSuggestions([]);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
setPopoverPosition({ top: rect.bottom + 4, left: rect.left });
|
|
38
|
-
}
|
|
39
|
-
if (filterType === 'people') {
|
|
40
|
-
setTimeout(() => peopleInputRef.current?.focus(), 50);
|
|
41
|
-
}
|
|
42
|
-
}, 0);
|
|
43
|
-
return () => clearTimeout(t);
|
|
34
|
+
if (filterType === 'people') {
|
|
35
|
+
setTimeout(() => peopleInputRef.current?.focus(), 50);
|
|
36
|
+
}
|
|
44
37
|
}
|
|
45
38
|
else {
|
|
46
39
|
setPopoverPosition(null);
|
|
@@ -112,7 +105,13 @@ export function useColumnHeaderFilterState(params) {
|
|
|
112
105
|
const handleFilterIconClick = useCallback((e) => {
|
|
113
106
|
e.stopPropagation();
|
|
114
107
|
e.preventDefault();
|
|
115
|
-
setFilterOpen((prev) =>
|
|
108
|
+
setFilterOpen((prev) => {
|
|
109
|
+
if (!prev && headerRef.current) {
|
|
110
|
+
const rect = headerRef.current.getBoundingClientRect();
|
|
111
|
+
setPopoverPosition({ top: rect.bottom + 4, left: rect.left });
|
|
112
|
+
}
|
|
113
|
+
return !prev;
|
|
114
|
+
});
|
|
116
115
|
}, []);
|
|
117
116
|
const handleSortClick = useCallback((e) => {
|
|
118
117
|
e.stopPropagation();
|
|
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
|
|
2
2
|
export function useContextMenu() {
|
|
3
3
|
const [contextMenu, setContextMenu] = useState(null);
|
|
4
4
|
const handleCellContextMenu = useCallback((e) => {
|
|
5
|
+
e.preventDefault?.();
|
|
5
6
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
6
7
|
}, []);
|
|
7
8
|
const closeContextMenu = useCallback(() => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo, useCallback, useEffect, useState } from 'react';
|
|
2
2
|
import { flattenColumns, getDataGridStatusBarConfig } from '../utils';
|
|
3
|
+
import { parseValue } from '../utils/valueParsers';
|
|
3
4
|
import { useRowSelection } from './useRowSelection';
|
|
4
5
|
import { useCellEditing } from './useCellEditing';
|
|
5
6
|
import { useActiveCell } from './useActiveCell';
|
|
@@ -8,13 +9,24 @@ import { useContextMenu } from './useContextMenu';
|
|
|
8
9
|
import { useClipboard } from './useClipboard';
|
|
9
10
|
import { useKeyboardNavigation } from './useKeyboardNavigation';
|
|
10
11
|
import { useFillHandle } from './useFillHandle';
|
|
12
|
+
import { useUndoRedo } from './useUndoRedo';
|
|
13
|
+
// Stable no-op handlers used when cellSelection is disabled (module-scope = no re-renders)
|
|
14
|
+
const NOOP = () => { };
|
|
15
|
+
const NOOP_ASYNC = async () => { };
|
|
16
|
+
const NOOP_MOUSE = (_e, _r, _c) => { };
|
|
17
|
+
const NOOP_KEY = (_e) => { };
|
|
18
|
+
const NOOP_CTX = (_e) => { };
|
|
11
19
|
/**
|
|
12
20
|
* Single orchestration hook for DataGridTable. Takes grid props and wrapper ref,
|
|
13
21
|
* returns all derived state and handlers so Fluent/Material/Radix can be thin view layers.
|
|
14
22
|
*/
|
|
15
23
|
export function useDataGridState(params) {
|
|
16
24
|
const { props, wrapperRef } = params;
|
|
17
|
-
const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, statusBar, emptyState, editable,
|
|
25
|
+
const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, } = props;
|
|
26
|
+
const cellSelection = cellSelectionProp !== false;
|
|
27
|
+
// Wrap onCellValueChanged with undo/redo tracking — all edits are recorded automatically
|
|
28
|
+
const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp });
|
|
29
|
+
const onCellValueChanged = undoRedo.onCellValueChanged;
|
|
18
30
|
const flatColumns = useMemo(() => flattenColumns(columns), [columns]);
|
|
19
31
|
const visibleCols = useMemo(() => {
|
|
20
32
|
const filtered = visibleColumns
|
|
@@ -53,25 +65,29 @@ export function useDataGridState(params) {
|
|
|
53
65
|
const { selectedRowIds, updateSelection, handleRowCheckboxChange, handleSelectAll, allSelected, someSelected, } = rowSelectionResult;
|
|
54
66
|
const { editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, } = useCellEditing();
|
|
55
67
|
const { activeCell, setActiveCell } = useActiveCell(wrapperRef, editingCell);
|
|
56
|
-
const { selectionRange, setSelectionRange, handleCellMouseDown: handleCellMouseDownBase, handleSelectAllCells, } = useCellSelection({
|
|
68
|
+
const { selectionRange, setSelectionRange, handleCellMouseDown: handleCellMouseDownBase, handleSelectAllCells, isDragging, } = useCellSelection({
|
|
57
69
|
colOffset,
|
|
58
70
|
rowCount: items.length,
|
|
59
71
|
visibleColCount: visibleCols.length,
|
|
60
72
|
setActiveCell,
|
|
61
73
|
});
|
|
62
|
-
const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
|
|
63
|
-
wrapperRef.current?.focus();
|
|
64
|
-
handleCellMouseDownBase(e, rowIndex, globalColIndex);
|
|
65
|
-
}, [handleCellMouseDownBase, wrapperRef]);
|
|
66
74
|
const { contextMenu, setContextMenu, handleCellContextMenu, closeContextMenu } = useContextMenu();
|
|
67
|
-
const { handleCopy, handleCut, handlePaste, cutRange } = useClipboard({
|
|
75
|
+
const { handleCopy, handleCut, handlePaste, cutRange, copyRange, clearClipboardRanges } = useClipboard({
|
|
68
76
|
items,
|
|
69
77
|
visibleCols,
|
|
70
78
|
colOffset,
|
|
71
79
|
selectionRange,
|
|
72
80
|
activeCell,
|
|
81
|
+
editable,
|
|
73
82
|
onCellValueChanged,
|
|
83
|
+
beginBatch: undoRedo.beginBatch,
|
|
84
|
+
endBatch: undoRedo.endBatch,
|
|
74
85
|
});
|
|
86
|
+
const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
|
|
87
|
+
wrapperRef.current?.focus();
|
|
88
|
+
clearClipboardRanges();
|
|
89
|
+
handleCellMouseDownBase(e, rowIndex, globalColIndex);
|
|
90
|
+
}, [handleCellMouseDownBase, wrapperRef, clearClipboardRanges]);
|
|
75
91
|
const { handleGridKeyDown } = useKeyboardNavigation({
|
|
76
92
|
items,
|
|
77
93
|
visibleCols,
|
|
@@ -95,18 +111,22 @@ export function useDataGridState(params) {
|
|
|
95
111
|
handlePaste,
|
|
96
112
|
setContextMenu,
|
|
97
113
|
wrapperRef,
|
|
98
|
-
onUndo,
|
|
99
|
-
onRedo,
|
|
114
|
+
onUndo: undoRedo.undo,
|
|
115
|
+
onRedo: undoRedo.redo,
|
|
116
|
+
clearClipboardRanges,
|
|
100
117
|
});
|
|
101
118
|
const { handleFillHandleMouseDown } = useFillHandle({
|
|
102
119
|
items,
|
|
103
120
|
visibleCols,
|
|
121
|
+
editable,
|
|
104
122
|
onCellValueChanged,
|
|
105
123
|
selectionRange,
|
|
106
124
|
setSelectionRange,
|
|
107
125
|
setActiveCell,
|
|
108
126
|
colOffset,
|
|
109
127
|
wrapperRef,
|
|
128
|
+
beginBatch: undoRedo.beginBatch,
|
|
129
|
+
endBatch: undoRedo.endBatch,
|
|
110
130
|
});
|
|
111
131
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
112
132
|
useEffect(() => {
|
|
@@ -179,28 +199,46 @@ export function useDataGridState(params) {
|
|
|
179
199
|
]);
|
|
180
200
|
const cellDescriptorInput = useMemo(() => ({
|
|
181
201
|
editingCell,
|
|
182
|
-
activeCell,
|
|
183
|
-
selectionRange,
|
|
184
|
-
cutRange,
|
|
202
|
+
activeCell: cellSelection ? activeCell : null,
|
|
203
|
+
selectionRange: cellSelection ? selectionRange : null,
|
|
204
|
+
cutRange: cellSelection ? cutRange : null,
|
|
205
|
+
copyRange: cellSelection ? copyRange : null,
|
|
185
206
|
colOffset,
|
|
186
207
|
itemsLength: items.length,
|
|
187
208
|
getRowId,
|
|
188
209
|
editable,
|
|
189
210
|
onCellValueChanged,
|
|
211
|
+
isDragging: cellSelection ? isDragging : false,
|
|
190
212
|
}), [
|
|
191
213
|
editingCell,
|
|
192
214
|
activeCell,
|
|
193
215
|
selectionRange,
|
|
194
216
|
cutRange,
|
|
217
|
+
copyRange,
|
|
195
218
|
colOffset,
|
|
196
219
|
items.length,
|
|
197
220
|
getRowId,
|
|
198
221
|
editable,
|
|
199
222
|
onCellValueChanged,
|
|
223
|
+
cellSelection,
|
|
224
|
+
isDragging,
|
|
200
225
|
]);
|
|
201
226
|
// --- Cell edit helpers ---
|
|
202
227
|
const [popoverAnchorEl, setPopoverAnchorEl] = useState(null);
|
|
203
228
|
const commitCellEdit = useCallback((item, columnId, oldValue, newValue, rowIndex, globalColIndex) => {
|
|
229
|
+
// Validate via valueParser before committing
|
|
230
|
+
const col = visibleCols.find((c) => c.columnId === columnId);
|
|
231
|
+
if (col) {
|
|
232
|
+
const result = parseValue(newValue, oldValue, item, col);
|
|
233
|
+
if (!result.valid) {
|
|
234
|
+
// Reject — cancel the edit
|
|
235
|
+
setEditingCell(null);
|
|
236
|
+
setPopoverAnchorEl(null);
|
|
237
|
+
setPendingEditorValue(undefined);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
newValue = result.value;
|
|
241
|
+
}
|
|
204
242
|
onCellValueChanged?.({
|
|
205
243
|
item,
|
|
206
244
|
columnId,
|
|
@@ -216,7 +254,7 @@ export function useDataGridState(params) {
|
|
|
216
254
|
if (rowIndex < items.length - 1) {
|
|
217
255
|
setActiveCell({ rowIndex: rowIndex + 1, columnIndex: globalColIndex });
|
|
218
256
|
}
|
|
219
|
-
}, [onCellValueChanged, setEditingCell, setPendingEditorValue, setActiveCell, items.length]);
|
|
257
|
+
}, [onCellValueChanged, setEditingCell, setPendingEditorValue, setActiveCell, items.length, visibleCols]);
|
|
220
258
|
const cancelPopoverEdit = useCallback(() => {
|
|
221
259
|
setEditingCell(null);
|
|
222
260
|
setPopoverAnchorEl(null);
|
|
@@ -240,22 +278,27 @@ export function useDataGridState(params) {
|
|
|
240
278
|
setEditingCell,
|
|
241
279
|
pendingEditorValue,
|
|
242
280
|
setPendingEditorValue,
|
|
243
|
-
activeCell,
|
|
244
|
-
setActiveCell,
|
|
245
|
-
selectionRange,
|
|
246
|
-
setSelectionRange,
|
|
247
|
-
handleCellMouseDown,
|
|
248
|
-
handleSelectAllCells,
|
|
249
|
-
contextMenu,
|
|
250
|
-
setContextMenu,
|
|
251
|
-
handleCellContextMenu,
|
|
252
|
-
closeContextMenu,
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
281
|
+
activeCell: cellSelection ? activeCell : null,
|
|
282
|
+
setActiveCell: cellSelection ? setActiveCell : NOOP,
|
|
283
|
+
selectionRange: cellSelection ? selectionRange : null,
|
|
284
|
+
setSelectionRange: cellSelection ? setSelectionRange : NOOP,
|
|
285
|
+
handleCellMouseDown: cellSelection ? handleCellMouseDown : NOOP_MOUSE,
|
|
286
|
+
handleSelectAllCells: cellSelection ? handleSelectAllCells : NOOP,
|
|
287
|
+
contextMenu: cellSelection ? contextMenu : null,
|
|
288
|
+
setContextMenu: cellSelection ? setContextMenu : NOOP,
|
|
289
|
+
handleCellContextMenu: cellSelection ? handleCellContextMenu : NOOP_CTX,
|
|
290
|
+
closeContextMenu: cellSelection ? closeContextMenu : NOOP,
|
|
291
|
+
canUndo: undoRedo.canUndo,
|
|
292
|
+
canRedo: undoRedo.canRedo,
|
|
293
|
+
onUndo: undoRedo.undo,
|
|
294
|
+
onRedo: undoRedo.redo,
|
|
295
|
+
handleCopy: cellSelection ? handleCopy : NOOP,
|
|
296
|
+
handleCut: cellSelection ? handleCut : NOOP,
|
|
297
|
+
handlePaste: cellSelection ? handlePaste : NOOP_ASYNC,
|
|
298
|
+
cutRange: cellSelection ? cutRange : null,
|
|
299
|
+
copyRange: cellSelection ? copyRange : null,
|
|
300
|
+
handleGridKeyDown: cellSelection ? handleGridKeyDown : NOOP_KEY,
|
|
301
|
+
handleFillHandleMouseDown: cellSelection ? handleFillHandleMouseDown : NOOP,
|
|
259
302
|
containerWidth,
|
|
260
303
|
minTableWidth,
|
|
261
304
|
columnSizingOverrides,
|
|
@@ -266,8 +309,9 @@ export function useDataGridState(params) {
|
|
|
266
309
|
cancelPopoverEdit,
|
|
267
310
|
popoverAnchorEl,
|
|
268
311
|
setPopoverAnchorEl,
|
|
312
|
+
clearClipboardRanges: cellSelection ? clearClipboardRanges : NOOP,
|
|
269
313
|
statusBarConfig,
|
|
270
314
|
showEmptyInGrid,
|
|
271
|
-
hasCellSelection,
|
|
315
|
+
hasCellSelection: cellSelection ? hasCellSelection : false,
|
|
272
316
|
};
|
|
273
317
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import { normalizeSelectionRange } from '../types';
|
|
3
3
|
import { getCellValue } from '../utils';
|
|
4
|
+
import { parseValue } from '../utils/valueParsers';
|
|
4
5
|
export function useFillHandle(params) {
|
|
5
|
-
const { items, visibleCols, onCellValueChanged, selectionRange, setSelectionRange, setActiveCell, colOffset, wrapperRef, } = params;
|
|
6
|
+
const { items, visibleCols, editable, onCellValueChanged, selectionRange, setSelectionRange, setActiveCell, colOffset, wrapperRef, beginBatch, endBatch, } = params;
|
|
6
7
|
const [fillDrag, setFillDrag] = useState(null);
|
|
7
8
|
const fillDragEndRef = useRef({ endRow: 0, endCol: 0 });
|
|
8
9
|
useEffect(() => {
|
|
9
|
-
if (!fillDrag || !onCellValueChanged || !wrapperRef.current)
|
|
10
|
+
if (!fillDrag || editable === false || !onCellValueChanged || !wrapperRef.current)
|
|
10
11
|
return;
|
|
11
12
|
fillDragEndRef.current = { endRow: fillDrag.startRow, endCol: fillDrag.startCol };
|
|
12
13
|
const onMove = (e) => {
|
|
@@ -41,6 +42,7 @@ export function useFillHandle(params) {
|
|
|
41
42
|
const startColDef = visibleCols[norm.startCol];
|
|
42
43
|
if (startItem && startColDef) {
|
|
43
44
|
const startValue = getCellValue(startItem, startColDef);
|
|
45
|
+
beginBatch?.();
|
|
44
46
|
for (let row = norm.startRow; row <= norm.endRow; row++) {
|
|
45
47
|
for (let col = norm.startCol; col <= norm.endCol; col++) {
|
|
46
48
|
if (row === fillDrag.startRow && col === fillDrag.startCol)
|
|
@@ -49,17 +51,25 @@ export function useFillHandle(params) {
|
|
|
49
51
|
continue;
|
|
50
52
|
const item = items[row];
|
|
51
53
|
const colDef = visibleCols[col];
|
|
54
|
+
const colEditable = colDef.editable === true ||
|
|
55
|
+
(typeof colDef.editable === 'function' && colDef.editable(item));
|
|
56
|
+
if (!colEditable)
|
|
57
|
+
continue;
|
|
52
58
|
const oldValue = getCellValue(item, colDef);
|
|
59
|
+
const result = parseValue(startValue, oldValue, item, colDef);
|
|
60
|
+
if (!result.valid)
|
|
61
|
+
continue;
|
|
53
62
|
onCellValueChanged({
|
|
54
63
|
item,
|
|
55
64
|
columnId: colDef.columnId,
|
|
56
65
|
field: colDef.columnId,
|
|
57
66
|
oldValue,
|
|
58
|
-
newValue:
|
|
67
|
+
newValue: result.value,
|
|
59
68
|
rowIndex: row,
|
|
60
69
|
});
|
|
61
70
|
}
|
|
62
71
|
}
|
|
72
|
+
endBatch?.();
|
|
63
73
|
}
|
|
64
74
|
setFillDrag(null);
|
|
65
75
|
};
|
|
@@ -71,6 +81,7 @@ export function useFillHandle(params) {
|
|
|
71
81
|
};
|
|
72
82
|
}, [
|
|
73
83
|
fillDrag,
|
|
84
|
+
editable,
|
|
74
85
|
colOffset,
|
|
75
86
|
items,
|
|
76
87
|
visibleCols,
|
|
@@ -78,6 +89,8 @@ export function useFillHandle(params) {
|
|
|
78
89
|
setActiveCell,
|
|
79
90
|
onCellValueChanged,
|
|
80
91
|
wrapperRef,
|
|
92
|
+
beginBatch,
|
|
93
|
+
endBatch,
|
|
81
94
|
]);
|
|
82
95
|
const handleFillHandleMouseDown = useCallback((e) => {
|
|
83
96
|
e.preventDefault();
|
|
@@ -19,10 +19,12 @@ export function useInlineCellEditorState(params) {
|
|
|
19
19
|
const handleKeyDown = useCallback((e) => {
|
|
20
20
|
if (e.key === 'Escape') {
|
|
21
21
|
e.preventDefault();
|
|
22
|
+
e.stopPropagation(); // Don't let the grid handler clear selection on Escape
|
|
22
23
|
cancel();
|
|
23
24
|
}
|
|
24
25
|
if (e.key === 'Enter' && editorType === 'text') {
|
|
25
26
|
e.preventDefault();
|
|
27
|
+
e.stopPropagation(); // Don't let the grid handler re-open an editor
|
|
26
28
|
commit(localValue);
|
|
27
29
|
}
|
|
28
30
|
}, [cancel, commit, localValue, editorType]);
|