@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.
Files changed (35) hide show
  1. package/dist/esm/components/GridContextMenu.js +12 -3
  2. package/dist/esm/components/MarchingAntsOverlay.js +109 -0
  3. package/dist/esm/hooks/useCellSelection.js +4 -0
  4. package/dist/esm/hooks/useClipboard.js +41 -8
  5. package/dist/esm/hooks/useColumnHeaderFilterState.js +11 -12
  6. package/dist/esm/hooks/useContextMenu.js +1 -0
  7. package/dist/esm/hooks/useDataGridState.js +74 -30
  8. package/dist/esm/hooks/useFillHandle.js +16 -3
  9. package/dist/esm/hooks/useInlineCellEditorState.js +2 -0
  10. package/dist/esm/hooks/useKeyboardNavigation.js +22 -2
  11. package/dist/esm/hooks/useOGrid.js +4 -1
  12. package/dist/esm/hooks/useUndoRedo.js +44 -14
  13. package/dist/esm/index.js +2 -1
  14. package/dist/esm/utils/dataGridViewModel.js +7 -1
  15. package/dist/esm/utils/gridContextMenuHelpers.js +20 -5
  16. package/dist/esm/utils/index.js +2 -1
  17. package/dist/esm/utils/valueParsers.js +107 -0
  18. package/dist/types/components/GridContextMenu.d.ts +4 -0
  19. package/dist/types/components/MarchingAntsOverlay.d.ts +15 -0
  20. package/dist/types/hooks/useCellSelection.d.ts +2 -0
  21. package/dist/types/hooks/useClipboard.d.ts +7 -0
  22. package/dist/types/hooks/useContextMenu.d.ts +1 -0
  23. package/dist/types/hooks/useDataGridState.d.ts +12 -0
  24. package/dist/types/hooks/useFillHandle.d.ts +3 -0
  25. package/dist/types/hooks/useKeyboardNavigation.d.ts +2 -1
  26. package/dist/types/hooks/useUndoRedo.d.ts +5 -1
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/types/columnTypes.d.ts +17 -0
  29. package/dist/types/types/dataGridTypes.d.ts +8 -0
  30. package/dist/types/types/index.d.ts +1 -1
  31. package/dist/types/utils/dataGridViewModel.d.ts +9 -0
  32. package/dist/types/utils/gridContextMenuHelpers.d.ts +8 -0
  33. package/dist/types/utils/index.d.ts +3 -1
  34. package/dist/types/utils/valueParsers.d.ts +37 -0
  35. 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.id === 'selectAll' && _jsx("div", { className: classNames?.contextMenuDivider }), _jsx("button", { type: "button", className: classNames?.contextMenuItem, onClick: handlers[item.id], disabled: item.disabledWhenNoSelection ? !hasSelection : false, children: item.label })] }, item.id))) }));
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 val = getCellValue(item, col);
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
- }, [selectionRange, activeCell, colOffset, handleCopy, onCellValueChanged]);
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 newValue = cells[c] ?? '';
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
- }, [selectionRange, activeCell, colOffset, items, visibleCols, onCellValueChanged]);
128
- return { handleCopy, handleCut, handlePaste, cutRangeRef, cutRange };
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; compute position
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
- const t = setTimeout(() => {
35
- if (headerRef.current) {
36
- const rect = headerRef.current.getBoundingClientRect();
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) => !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, onCellValueChanged, onUndo, onRedo, } = props;
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
- handleCopy,
254
- handleCut,
255
- handlePaste,
256
- cutRange,
257
- handleGridKeyDown,
258
- handleFillHandleMouseDown,
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: startValue,
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]);