@alaarab/ogrid-react 2.0.22 → 2.1.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 (66) hide show
  1. package/dist/esm/components/ColumnHeaderFilterContent.js +0 -2
  2. package/dist/esm/components/MarchingAntsOverlay.js +2 -3
  3. package/dist/esm/components/SideBar.js +8 -7
  4. package/dist/esm/components/createOGrid.js +1 -4
  5. package/dist/esm/hooks/index.js +10 -0
  6. package/dist/esm/hooks/useActiveCell.js +2 -4
  7. package/dist/esm/hooks/useCellSelection.js +85 -52
  8. package/dist/esm/hooks/useClipboard.js +15 -54
  9. package/dist/esm/hooks/useColumnChooserState.js +25 -13
  10. package/dist/esm/hooks/useColumnHeaderFilterState.js +22 -11
  11. package/dist/esm/hooks/useColumnHeaderMenuState.js +1 -1
  12. package/dist/esm/hooks/useColumnMeta.js +61 -0
  13. package/dist/esm/hooks/useColumnPinning.js +11 -12
  14. package/dist/esm/hooks/useColumnReorder.js +8 -1
  15. package/dist/esm/hooks/useColumnResize.js +6 -2
  16. package/dist/esm/hooks/useDataGridContextMenu.js +24 -0
  17. package/dist/esm/hooks/useDataGridEditing.js +56 -0
  18. package/dist/esm/hooks/useDataGridInteraction.js +109 -0
  19. package/dist/esm/hooks/useDataGridLayout.js +172 -0
  20. package/dist/esm/hooks/useDataGridState.js +83 -318
  21. package/dist/esm/hooks/useDataGridTableOrchestration.js +2 -4
  22. package/dist/esm/hooks/useFillHandle.js +60 -55
  23. package/dist/esm/hooks/useFilterOptions.js +2 -4
  24. package/dist/esm/hooks/useInlineCellEditorState.js +7 -13
  25. package/dist/esm/hooks/useKeyboardNavigation.js +19 -132
  26. package/dist/esm/hooks/useMultiSelectFilterState.js +1 -1
  27. package/dist/esm/hooks/useOGrid.js +159 -301
  28. package/dist/esm/hooks/useOGridDataFetching.js +74 -0
  29. package/dist/esm/hooks/useOGridFilters.js +59 -0
  30. package/dist/esm/hooks/useOGridPagination.js +24 -0
  31. package/dist/esm/hooks/useOGridSorting.js +24 -0
  32. package/dist/esm/hooks/usePaginationControls.js +2 -5
  33. package/dist/esm/hooks/usePeopleFilterState.js +6 -1
  34. package/dist/esm/hooks/useRichSelectState.js +7 -5
  35. package/dist/esm/hooks/useRowSelection.js +6 -26
  36. package/dist/esm/hooks/useSelectState.js +2 -5
  37. package/dist/esm/hooks/useShallowEqualMemo.js +14 -0
  38. package/dist/esm/hooks/useTableLayout.js +3 -11
  39. package/dist/esm/hooks/useUndoRedo.js +16 -10
  40. package/dist/esm/index.js +1 -1
  41. package/dist/esm/utils/index.js +1 -1
  42. package/dist/types/components/ColumnChooserProps.d.ts +2 -0
  43. package/dist/types/components/ColumnHeaderFilterContent.d.ts +0 -2
  44. package/dist/types/hooks/index.d.ts +19 -0
  45. package/dist/types/hooks/useClipboard.d.ts +0 -1
  46. package/dist/types/hooks/useColumnChooserState.d.ts +2 -0
  47. package/dist/types/hooks/useColumnHeaderFilterState.d.ts +0 -2
  48. package/dist/types/hooks/useColumnHeaderMenuState.d.ts +0 -2
  49. package/dist/types/hooks/useColumnMeta.d.ts +34 -0
  50. package/dist/types/hooks/useDataGridContextMenu.d.ts +20 -0
  51. package/dist/types/hooks/useDataGridEditing.d.ts +39 -0
  52. package/dist/types/hooks/useDataGridInteraction.d.ts +95 -0
  53. package/dist/types/hooks/useDataGridLayout.d.ts +45 -0
  54. package/dist/types/hooks/useDataGridState.d.ts +7 -1
  55. package/dist/types/hooks/useDataGridTableOrchestration.d.ts +1 -2
  56. package/dist/types/hooks/useOGrid.d.ts +4 -2
  57. package/dist/types/hooks/useOGridDataFetching.d.ts +29 -0
  58. package/dist/types/hooks/useOGridFilters.d.ts +24 -0
  59. package/dist/types/hooks/useOGridPagination.d.ts +18 -0
  60. package/dist/types/hooks/useOGridSorting.d.ts +23 -0
  61. package/dist/types/hooks/usePaginationControls.d.ts +1 -1
  62. package/dist/types/hooks/useRichSelectState.d.ts +2 -0
  63. package/dist/types/hooks/useShallowEqualMemo.d.ts +7 -0
  64. package/dist/types/index.d.ts +2 -2
  65. package/dist/types/utils/index.d.ts +2 -2
  66. package/package.json +12 -4
@@ -1,85 +1,51 @@
1
- import { useMemo, useCallback, useState, useLayoutEffect } from 'react';
2
- import { flattenColumns, getDataGridStatusBarConfig, parseValue, computeAggregations } from '../utils';
3
- import { CHECKBOX_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH } from '@alaarab/ogrid-core';
1
+ import { useMemo, useCallback } from 'react';
2
+ import { getDataGridStatusBarConfig, computeAggregations } from '../utils';
4
3
  import { useRowSelection } from './useRowSelection';
5
4
  import { useCellEditing } from './useCellEditing';
6
5
  import { useActiveCell } from './useActiveCell';
7
- import { useCellSelection } from './useCellSelection';
8
- import { useContextMenu } from './useContextMenu';
9
- import { useClipboard } from './useClipboard';
10
- import { useKeyboardNavigation } from './useKeyboardNavigation';
11
- import { useFillHandle } from './useFillHandle';
12
- import { useUndoRedo } from './useUndoRedo';
13
6
  import { useLatestRef } from './useLatestRef';
14
- import { useTableLayout } from './useTableLayout';
15
- import { useColumnPinning } from './useColumnPinning';
16
- import { useColumnHeaderMenuState } from './useColumnHeaderMenuState';
17
- // Stable no-op handlers used when cellSelection is disabled (module-scope = no re-renders)
18
- const NOOP = () => { };
19
- const NOOP_ASYNC = async () => { };
20
- const NOOP_MOUSE = (_e, _r, _c) => { };
21
- const NOOP_KEY = (_e) => { };
22
- const NOOP_CTX = (_e) => { };
7
+ import { useDataGridLayout } from './useDataGridLayout';
8
+ import { useDataGridEditing } from './useDataGridEditing';
9
+ import { useDataGridInteraction } from './useDataGridInteraction';
10
+ import { useDataGridContextMenu } from './useDataGridContextMenu';
23
11
  /**
24
12
  * Single orchestration hook for DataGridTable. Takes grid props and wrapper ref,
25
13
  * returns all derived state and handlers so Fluent/Material/Radix can be thin view layers.
14
+ *
15
+ * Internally delegates to focused sub-hooks:
16
+ * - useDataGridLayout -- column layout, sizing, pinning, header menu
17
+ * - useDataGridEditing -- cell editing commit/cancel, popover editor
18
+ * - useDataGridInteraction -- cell selection, keyboard nav, clipboard, fill handle, undo/redo
19
+ * - useDataGridContextMenu -- context menu state
26
20
  */
27
21
  export function useDataGridState(params) {
28
22
  const { props, wrapperRef } = params;
29
23
  const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, showRowNumbers, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, initialColumnWidths, onColumnResized, onAutosizeColumn, pinnedColumns, onColumnPinned, onCellError, } = props;
30
24
  const cellSelection = cellSelectionProp !== false;
31
- // Wrap onCellValueChanged with undo/redo tracking all edits are recorded automatically
32
- const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp });
33
- const onCellValueChanged = undoRedo.onCellValueChanged;
34
- // Cast is safe: input columns are React.IColumnDef instances; flattenColumns only extracts leaves.
35
- const flatColumnsRaw = useMemo(() => flattenColumns(columns), [columns]);
36
- // Apply runtime pin overrides (from applyColumnState or programmatic changes)
37
- const flatColumns = useMemo(() => {
38
- if (!pinnedColumns || Object.keys(pinnedColumns).length === 0)
39
- return flatColumnsRaw;
40
- return flatColumnsRaw.map((col) => {
41
- const override = pinnedColumns[col.columnId];
42
- if (override && col.pinned !== override) {
43
- return { ...col, pinned: override };
44
- }
45
- // If col was pinned by definition but not in overrides, keep original
46
- return col;
47
- });
48
- }, [flatColumnsRaw, pinnedColumns]);
49
- const visibleCols = useMemo(() => {
50
- const filtered = visibleColumns
51
- ? flatColumns.filter((c) => visibleColumns.has(c.columnId))
52
- : flatColumns;
53
- if (!columnOrder?.length)
54
- return filtered;
55
- // Build index map for O(1) lookup instead of repeated O(n) indexOf
56
- const orderMap = new Map();
57
- for (let i = 0; i < columnOrder.length; i++) {
58
- orderMap.set(columnOrder[i], i);
59
- }
60
- return [...filtered].sort((a, b) => {
61
- const ia = orderMap.get(a.columnId) ?? -1;
62
- const ib = orderMap.get(b.columnId) ?? -1;
63
- if (ia === -1 && ib === -1)
64
- return 0;
65
- if (ia === -1)
66
- return 1;
67
- if (ib === -1)
68
- return -1;
69
- return ia - ib;
70
- });
71
- }, [flatColumns, visibleColumns, columnOrder]);
72
- const visibleColumnCount = visibleCols.length;
73
- const hasCheckboxCol = rowSelection === 'multiple';
74
- const hasRowNumbersCol = !!showRowNumbers;
75
- const specialColsCount = (hasCheckboxCol ? 1 : 0) + (hasRowNumbersCol ? 1 : 0);
76
- const totalColCount = visibleColumnCount + specialColsCount;
77
- const colOffset = specialColsCount;
78
- const rowIndexByRowId = useMemo(() => {
79
- const m = new Map();
80
- items.forEach((item, idx) => m.set(getRowId(item), idx));
81
- return m;
82
- }, [items, getRowId]);
25
+ // --- Shared state hooks (called at orchestrator level to break circular deps) ---
26
+ const { editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, } = useCellEditing();
27
+ const { activeCell, setActiveCell } = useActiveCell(wrapperRef, editingCell);
28
+ // --- 1. Layout, pinning, header menu ---
29
+ const layoutResult = useDataGridLayout({
30
+ columns,
31
+ items,
32
+ getRowId,
33
+ visibleColumns,
34
+ columnOrder,
35
+ rowSelection,
36
+ showRowNumbers,
37
+ initialColumnWidths,
38
+ onColumnResized,
39
+ onAutosizeColumn,
40
+ pinnedColumns,
41
+ onColumnPinned,
42
+ sortBy: props.sortBy,
43
+ sortDirection: props.sortDirection,
44
+ onColumnSort: props.onColumnSort,
45
+ wrapperRef,
46
+ });
47
+ const { visibleCols, visibleColumnCount, colOffset, hasCheckboxCol, } = layoutResult;
48
+ // --- 2. Row selection ---
83
49
  const rowSelectionResult = useRowSelection({
84
50
  items,
85
51
  getRowId,
@@ -88,159 +54,52 @@ export function useDataGridState(params) {
88
54
  onSelectionChange,
89
55
  });
90
56
  const { selectedRowIds, updateSelection, handleRowCheckboxChange, handleSelectAll, allSelected, someSelected, } = rowSelectionResult;
91
- const { editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, } = useCellEditing();
92
- const { activeCell, setActiveCell } = useActiveCell(wrapperRef, editingCell);
93
- const { selectionRange, setSelectionRange, handleCellMouseDown: handleCellMouseDownBase, handleSelectAllCells, isDragging, } = useCellSelection({
94
- colOffset,
95
- rowCount: items.length,
96
- visibleColCount: visibleCols.length,
97
- setActiveCell,
98
- wrapperRef,
99
- });
100
- const { contextMenuPosition, setContextMenuPosition, handleCellContextMenu, closeContextMenu } = useContextMenu();
101
- const { handleCopy, handleCut, handlePaste, cutRange, copyRange, clearClipboardRanges } = useClipboard({
57
+ // --- 3. Context menu ---
58
+ const contextMenuResult = useDataGridContextMenu({ cellSelection });
59
+ const { setContextMenuPosition } = contextMenuResult;
60
+ // --- 4. Interaction (selection, keyboard, clipboard, fill handle, undo/redo) ---
61
+ const interactionResult = useDataGridInteraction({
102
62
  items,
103
63
  visibleCols,
104
64
  colOffset,
105
- selectionRange,
106
- activeCell,
107
- editable,
108
- onCellValueChanged,
109
- beginBatch: undoRedo.beginBatch,
110
- endBatch: undoRedo.endBatch,
111
- });
112
- const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
113
- if (e.button !== 0)
114
- return;
115
- wrapperRef.current?.focus({ preventScroll: true });
116
- clearClipboardRanges();
117
- handleCellMouseDownBase(e, rowIndex, globalColIndex);
118
- },
119
- // eslint-disable-next-line react-hooks/exhaustive-deps
120
- [handleCellMouseDownBase, clearClipboardRanges] // wrapperRef excluded — refs are stable
121
- );
122
- const { handleGridKeyDown } = useKeyboardNavigation({
123
- data: { items, visibleCols, colOffset, hasCheckboxCol, visibleColumnCount, getRowId },
124
- state: { activeCell, selectionRange, editingCell, selectedRowIds },
125
- handlers: { setActiveCell, setSelectionRange, setEditingCell, handleRowCheckboxChange, handleCopy, handleCut, handlePaste, setContextMenu: setContextMenuPosition, onUndo: undoRedo.undo, onRedo: undoRedo.redo, clearClipboardRanges },
126
- features: { editable, onCellValueChanged, rowSelection, wrapperRef },
127
- });
128
- const { handleFillHandleMouseDown } = useFillHandle({
129
- items,
130
- visibleCols,
65
+ hasCheckboxCol,
66
+ visibleColumnCount,
67
+ getRowId,
131
68
  editable,
132
- onCellValueChanged,
133
- selectionRange,
134
- setSelectionRange,
69
+ onCellValueChangedProp,
70
+ cellSelection,
71
+ rowSelection,
72
+ selectedRowIds,
73
+ editingCell,
74
+ setEditingCell,
75
+ activeCell,
135
76
  setActiveCell,
136
- colOffset,
77
+ handleRowCheckboxChange,
78
+ setContextMenuPosition,
137
79
  wrapperRef,
138
- beginBatch: undoRedo.beginBatch,
139
- endBatch: undoRedo.endBatch,
140
80
  });
141
- const { containerWidth, minTableWidth, desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, } = useTableLayout({
142
- wrapperRef,
81
+ const { selectionRange, cutRange, copyRange, isDragging, onCellValueChanged, } = interactionResult;
82
+ // --- 5. Editing (commit/cancel logic) ---
83
+ const editingResult = useDataGridEditing({
84
+ editingCell,
85
+ setEditingCell,
86
+ pendingEditorValue,
87
+ setPendingEditorValue,
143
88
  visibleCols,
144
- flatColumns,
145
- hasCheckboxCol,
146
- initialColumnWidths,
147
- onColumnResized,
148
- });
149
- const pinningResult = useColumnPinning({
150
- columns: flatColumns,
151
- pinnedColumns,
152
- onColumnPinned,
89
+ itemsLength: items.length,
90
+ onCellValueChanged,
91
+ setActiveCell,
153
92
  });
154
- // Measure actual column widths from the DOM for accurate pinning offsets.
155
- // With table-layout: auto, rendered widths can exceed declared minimums.
156
- const [measuredColumnWidths, setMeasuredColumnWidths] = useState({});
157
- useLayoutEffect(() => {
158
- const wrapper = wrapperRef.current;
159
- if (!wrapper)
160
- return;
161
- const headerCells = wrapper.querySelectorAll('th[data-column-id]');
162
- if (headerCells.length === 0)
163
- return;
164
- const measured = {};
165
- headerCells.forEach((cell) => {
166
- const colId = cell.getAttribute('data-column-id');
167
- if (colId)
168
- measured[colId] = cell.offsetWidth;
169
- });
170
- setMeasuredColumnWidths((prev) => {
171
- // Only update if widths actually changed to avoid render loops
172
- for (const key in measured) {
173
- if (prev[key] !== measured[key])
174
- return measured;
175
- }
176
- if (Object.keys(prev).length !== Object.keys(measured).length)
177
- return measured;
178
- return prev;
179
- });
180
- // Re-measure when columns, container size, or resize overrides change
181
- // eslint-disable-next-line react-hooks/exhaustive-deps
182
- }, [visibleCols, containerWidth, columnSizingOverrides]);
183
- // Build column width map for pinning offset computation
184
- const columnWidthMap = useMemo(() => {
185
- const map = {};
186
- for (const col of visibleCols) {
187
- const override = columnSizingOverrides[col.columnId];
188
- map[col.columnId] = override
189
- ? override.widthPx
190
- : (measuredColumnWidths[col.columnId] ?? col.idealWidth ?? col.defaultWidth ?? col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH);
191
- }
192
- return map;
193
- }, [visibleCols, columnSizingOverrides, measuredColumnWidths]);
194
- const leftOffsets = useMemo(() => pinningResult.computeLeftOffsets(visibleCols, columnWidthMap, DEFAULT_MIN_COLUMN_WIDTH, hasCheckboxCol, CHECKBOX_COLUMN_WIDTH), [pinningResult, visibleCols, columnWidthMap, hasCheckboxCol]);
195
- const rightOffsets = useMemo(() => pinningResult.computeRightOffsets(visibleCols, columnWidthMap, DEFAULT_MIN_COLUMN_WIDTH), [pinningResult, visibleCols, columnWidthMap]);
196
- const aggregation = useMemo(() => computeAggregations(items, visibleCols, cellSelection ? selectionRange : null), [items, visibleCols, selectionRange, cellSelection]);
197
- const statusBarConfig = useMemo(() => {
198
- const base = getDataGridStatusBarConfig(statusBar, items.length, selectedRowIds.size);
199
- if (!base)
200
- return null;
201
- return { ...base, aggregation: aggregation ?? undefined };
202
- }, [statusBar, items.length, selectedRowIds.size, aggregation]);
203
- const showEmptyInGrid = items.length === 0 && !!emptyState && !props.isLoading;
204
- const hasCellSelection = selectionRange != null || activeCell != null;
205
- // --- View-model inputs (shared across all 3 DataGridTables) ---
206
- const { sortBy, sortDirection, onColumnSort, filters, onFilterChange, filterOptions, loadingFilterOptions, peopleSearch, } = props;
207
- // Stabilize callbacks via refs — headerFilterInput only re-creates when data changes,
208
- // not when callback identities change (which happens on unrelated state updates).
209
- const onColumnSortRef = useLatestRef(onColumnSort);
93
+ // --- 6. View models ---
94
+ const { sortBy, sortDirection, filters, onFilterChange, filterOptions, loadingFilterOptions, peopleSearch, } = props;
210
95
  const onFilterChangeRef = useLatestRef(onFilterChange);
211
96
  const peopleSearchRef = useLatestRef(peopleSearch);
212
- // Stable callback wrappers that delegate to refs
213
- const stableOnColumnSort = useCallback((columnKey, direction) => onColumnSortRef.current?.(columnKey, direction),
214
- // eslint-disable-next-line react-hooks/exhaustive-deps
215
- []);
216
- // Autosize callback — updates internal column sizing state + notifies external listener
217
- const handleAutosizeColumn = useCallback((columnId, width) => {
218
- setColumnSizingOverrides((prev) => ({ ...prev, [columnId]: { widthPx: width } }));
219
- (onAutosizeColumn ?? onColumnResized)?.(columnId, width);
220
- }, [setColumnSizingOverrides, onAutosizeColumn, onColumnResized]);
221
- const headerMenuResult = useColumnHeaderMenuState({
222
- pinnedColumns: pinningResult.pinnedColumns,
223
- onPinColumn: pinningResult.pinColumn,
224
- onUnpinColumn: pinningResult.unpinColumn,
225
- sortBy,
226
- sortDirection,
227
- onColumnSort: stableOnColumnSort,
228
- onColumnResized,
229
- onAutosizeColumn: handleAutosizeColumn,
230
- columns: flatColumns,
231
- data: items,
232
- getRowId: getRowId,
233
- });
234
- const stableOnFilterChange = useCallback((...args) => onFilterChangeRef.current?.(...args),
235
- // eslint-disable-next-line react-hooks/exhaustive-deps
236
- []);
237
- const stablePeopleSearch = useCallback((...args) => peopleSearchRef.current?.(...args) ?? Promise.resolve([]),
238
- // eslint-disable-next-line react-hooks/exhaustive-deps
239
- []);
97
+ const stableOnFilterChange = useCallback((...args) => onFilterChangeRef.current?.(...args), [onFilterChangeRef]);
98
+ const stablePeopleSearch = useCallback((...args) => peopleSearchRef.current?.(...args) ?? Promise.resolve([]), [peopleSearchRef]);
240
99
  const headerFilterInput = useMemo(() => ({
241
100
  sortBy,
242
101
  sortDirection,
243
- onColumnSort: stableOnColumnSort,
102
+ onColumnSort: layoutResult.stableOnColumnSort,
244
103
  filters,
245
104
  onFilterChange: stableOnFilterChange,
246
105
  filterOptions,
@@ -249,7 +108,7 @@ export function useDataGridState(params) {
249
108
  }), [
250
109
  sortBy,
251
110
  sortDirection,
252
- stableOnColumnSort,
111
+ layoutResult.stableOnColumnSort,
253
112
  filters,
254
113
  stableOnFilterChange,
255
114
  filterOptions,
@@ -282,123 +141,29 @@ export function useDataGridState(params) {
282
141
  cellSelection,
283
142
  isDragging,
284
143
  ]);
285
- // --- Cell edit helpers ---
286
- const [popoverAnchorEl, setPopoverAnchorEl] = useState(null);
287
- const visibleColsRef = useLatestRef(visibleCols);
288
- const itemsLengthRef = useLatestRef(items.length);
289
- const commitCellEdit = useCallback((item, columnId, oldValue, newValue, rowIndex, globalColIndex) => {
290
- // Validate via valueParser before committing
291
- const col = visibleColsRef.current.find((c) => c.columnId === columnId);
292
- if (col) {
293
- const result = parseValue(newValue, oldValue, item, col);
294
- if (!result.valid) {
295
- // Reject — cancel the edit
296
- setEditingCell(null);
297
- setPopoverAnchorEl(null);
298
- setPendingEditorValue(undefined);
299
- return;
300
- }
301
- newValue = result.value;
302
- }
303
- onCellValueChanged?.({
304
- item,
305
- columnId,
306
- oldValue,
307
- newValue,
308
- rowIndex,
309
- });
310
- setEditingCell(null);
311
- setPopoverAnchorEl(null);
312
- setPendingEditorValue(undefined);
313
- // Advance to next row for inline editors
314
- if (rowIndex < itemsLengthRef.current - 1) {
315
- setActiveCell({ rowIndex: rowIndex + 1, columnIndex: globalColIndex });
316
- }
317
- },
318
- // eslint-disable-next-line react-hooks/exhaustive-deps
319
- [onCellValueChanged, setEditingCell, setPendingEditorValue, setActiveCell]);
320
- const cancelPopoverEdit = useCallback(() => {
321
- setEditingCell(null);
322
- setPopoverAnchorEl(null);
323
- setPendingEditorValue(undefined);
324
- }, [setEditingCell, setPendingEditorValue]);
325
- // --- Memoize each sub-object so downstream consumers only re-render when their slice changes ---
326
- const layoutState = useMemo(() => ({
327
- flatColumns, visibleCols, visibleColumnCount, totalColCount, colOffset,
328
- hasCheckboxCol, hasRowNumbersCol, rowIndexByRowId, containerWidth, minTableWidth,
329
- desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, onColumnResized,
330
- measuredColumnWidths,
331
- }), [
332
- flatColumns, visibleCols, visibleColumnCount, totalColCount, colOffset,
333
- hasCheckboxCol, hasRowNumbersCol, rowIndexByRowId, containerWidth, minTableWidth,
334
- desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, onColumnResized,
335
- measuredColumnWidths,
336
- ]);
144
+ const aggregation = useMemo(() => computeAggregations(items, visibleCols, cellSelection ? selectionRange : null), [items, visibleCols, selectionRange, cellSelection]);
145
+ const statusBarConfig = useMemo(() => {
146
+ const base = getDataGridStatusBarConfig(statusBar, items.length, selectedRowIds.size);
147
+ if (!base)
148
+ return null;
149
+ return { ...base, aggregation: aggregation ?? undefined };
150
+ }, [statusBar, items.length, selectedRowIds.size, aggregation]);
151
+ const showEmptyInGrid = items.length === 0 && !!emptyState && !props.isLoading;
152
+ // --- Memoize remaining sub-objects ---
337
153
  const rowSelectionState = useMemo(() => ({
338
154
  selectedRowIds, updateSelection, handleRowCheckboxChange,
339
155
  handleSelectAll, allSelected, someSelected,
340
156
  }), [selectedRowIds, updateSelection, handleRowCheckboxChange, handleSelectAll, allSelected, someSelected]);
341
- const editingState = useMemo(() => ({
342
- editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue,
343
- commitCellEdit, cancelPopoverEdit, popoverAnchorEl, setPopoverAnchorEl,
344
- }), [editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, commitCellEdit, cancelPopoverEdit, popoverAnchorEl, setPopoverAnchorEl]);
345
- const interactionState = useMemo(() => ({
346
- activeCell: cellSelection ? activeCell : null,
347
- setActiveCell: cellSelection ? setActiveCell : NOOP,
348
- selectionRange: cellSelection ? selectionRange : null,
349
- setSelectionRange: cellSelection ? setSelectionRange : NOOP,
350
- handleCellMouseDown: cellSelection ? handleCellMouseDown : NOOP_MOUSE,
351
- handleSelectAllCells: cellSelection ? handleSelectAllCells : NOOP,
352
- hasCellSelection: cellSelection ? hasCellSelection : false,
353
- handleGridKeyDown: cellSelection ? handleGridKeyDown : NOOP_KEY,
354
- handleFillHandleMouseDown: cellSelection ? handleFillHandleMouseDown : NOOP,
355
- handleCopy: cellSelection ? handleCopy : NOOP,
356
- handleCut: cellSelection ? handleCut : NOOP,
357
- handlePaste: cellSelection ? handlePaste : NOOP_ASYNC,
358
- cutRange: cellSelection ? cutRange : null,
359
- copyRange: cellSelection ? copyRange : null,
360
- clearClipboardRanges: cellSelection ? clearClipboardRanges : NOOP,
361
- canUndo: undoRedo.canUndo,
362
- canRedo: undoRedo.canRedo,
363
- onUndo: undoRedo.undo,
364
- onRedo: undoRedo.redo,
365
- isDragging: cellSelection ? isDragging : false,
366
- }), [
367
- cellSelection, activeCell, setActiveCell, selectionRange, setSelectionRange,
368
- handleCellMouseDown, handleSelectAllCells, hasCellSelection, handleGridKeyDown,
369
- handleFillHandleMouseDown, handleCopy, handleCut, handlePaste, cutRange, copyRange,
370
- clearClipboardRanges, undoRedo.canUndo, undoRedo.canRedo, undoRedo.undo, undoRedo.redo,
371
- isDragging,
372
- ]);
373
- const contextMenuState = useMemo(() => ({
374
- menuPosition: cellSelection ? contextMenuPosition : null,
375
- setMenuPosition: cellSelection ? setContextMenuPosition : NOOP,
376
- handleCellContextMenu: cellSelection ? handleCellContextMenu : NOOP_CTX,
377
- closeContextMenu: cellSelection ? closeContextMenu : NOOP,
378
- }), [cellSelection, contextMenuPosition, setContextMenuPosition, handleCellContextMenu, closeContextMenu]);
379
157
  const viewModelsState = useMemo(() => ({
380
158
  headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid, onCellError,
381
159
  }), [headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid, onCellError]);
382
- const pinningState = useMemo(() => ({
383
- pinnedColumns: pinningResult.pinnedColumns,
384
- pinColumn: pinningResult.pinColumn,
385
- unpinColumn: pinningResult.unpinColumn,
386
- isPinned: pinningResult.isPinned,
387
- leftOffsets,
388
- rightOffsets,
389
- headerMenu: headerMenuResult,
390
- }), [
391
- pinningResult.pinnedColumns, pinningResult.pinColumn, pinningResult.unpinColumn,
392
- pinningResult.isPinned, leftOffsets, rightOffsets,
393
- headerMenuResult,
394
- ]);
395
160
  return {
396
- layout: layoutState,
161
+ layout: layoutResult.layout,
397
162
  rowSelection: rowSelectionState,
398
- editing: editingState,
399
- interaction: interactionState,
400
- contextMenu: contextMenuState,
163
+ editing: editingResult.editing,
164
+ interaction: interactionResult.interaction,
165
+ contextMenu: contextMenuResult.contextMenu,
401
166
  viewModels: viewModelsState,
402
- pinning: pinningState,
167
+ pinning: layoutResult.pinning,
403
168
  };
404
169
  }
@@ -82,16 +82,14 @@ export function useDataGridTableOrchestration(params) {
82
82
  return;
83
83
  const ids = selectedRowIdsRef.current;
84
84
  updateSelection(ids.has(rowId) ? new Set() : new Set([rowId]));
85
- // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedRowIdsRef is a stable ref
86
- }, [rowSelection, updateSelection]);
85
+ }, [rowSelection, updateSelection, selectedRowIdsRef]);
87
86
  // ── Return ─────────────────────────────────────────────────────────────
88
87
  return {
89
88
  // Refs
90
89
  wrapperRef,
91
90
  tableContainerRef,
92
91
  lastMouseShiftRef,
93
- // Full state sub-objects
94
- state,
92
+ // State sub-objects
95
93
  layout,
96
94
  rowSel,
97
95
  editing,
@@ -1,6 +1,7 @@
1
1
  import { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { normalizeSelectionRange } from '../types';
3
- import { getCellValue, parseValue } from '../utils';
3
+ import { applyFillValues } from '../utils';
4
+ import { useLatestRef } from './useLatestRef';
4
5
  /** DOM attribute name for fill-drag range highlighting (same as cell selection drag). */
5
6
  const DRAG_ATTR = 'data-drag-range';
6
7
  /**
@@ -14,12 +15,31 @@ export function useFillHandle(params) {
14
15
  const fillDragEndRef = useRef({ endRow: 0, endCol: 0 });
15
16
  const rafRef = useRef(0);
16
17
  const liveFillRangeRef = useRef(null);
18
+ const colOffsetRef = useLatestRef(colOffset);
17
19
  useEffect(() => {
18
20
  if (!fillDrag || editable === false || !onCellValueChanged || !wrapperRef.current)
19
21
  return;
20
22
  fillDragEndRef.current = { endRow: fillDrag.startRow, endCol: fillDrag.startCol };
21
23
  liveFillRangeRef.current = null;
22
- const colOff = colOffset;
24
+ /** Set of currently drag-marked HTMLElements — avoids O(n) full DOM scan on clear. */
25
+ const markedCells = new Set();
26
+ /** Cell lookup index built on drag start — O(1) lookups per frame. */
27
+ let cellIndex = null;
28
+ const buildCellIndex = () => {
29
+ const wrapper = wrapperRef.current;
30
+ if (!wrapper)
31
+ return;
32
+ cellIndex = new Map();
33
+ const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
34
+ for (let i = 0; i < cells.length; i++) {
35
+ const el = cells[i];
36
+ const r = el.getAttribute('data-row-index') ?? '';
37
+ const c = el.getAttribute('data-col-index') ?? '';
38
+ cellIndex.set(`${r},${c}`, el);
39
+ }
40
+ };
41
+ // Build the index once at fill drag start
42
+ buildCellIndex();
23
43
  const applyDragAttrs = (range) => {
24
44
  const wrapper = wrapperRef.current;
25
45
  if (!wrapper)
@@ -28,29 +48,40 @@ export function useFillHandle(params) {
28
48
  const maxR = Math.max(range.startRow, range.endRow);
29
49
  const minC = Math.min(range.startCol, range.endCol);
30
50
  const maxC = Math.max(range.startCol, range.endCol);
31
- const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
32
- for (let i = 0; i < cells.length; i++) {
33
- const el = cells[i];
34
- const r = parseInt(el.getAttribute('data-row-index'), 10);
35
- const c = parseInt(el.getAttribute('data-col-index'), 10) - colOff;
36
- const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
37
- if (inRange) {
38
- if (!el.hasAttribute(DRAG_ATTR))
39
- el.setAttribute(DRAG_ATTR, '');
51
+ const colOff = colOffsetRef.current;
52
+ // Un-mark cells no longer in range
53
+ for (const el of markedCells) {
54
+ const r = parseInt(el.getAttribute('data-row-index') ?? '', 10);
55
+ const c = parseInt(el.getAttribute('data-col-index') ?? '', 10) - colOff;
56
+ if (!(r >= minR && r <= maxR && c >= minC && c <= maxC)) {
57
+ el.removeAttribute(DRAG_ATTR);
58
+ markedCells.delete(el);
40
59
  }
41
- else {
42
- if (el.hasAttribute(DRAG_ATTR))
43
- el.removeAttribute(DRAG_ATTR);
60
+ }
61
+ // Look up only cells in the new range — O(range size) via Map lookup
62
+ for (let r = minR; r <= maxR; r++) {
63
+ for (let c = minC; c <= maxC; c++) {
64
+ const key = `${r},${c + colOff}`;
65
+ let el = cellIndex?.get(key);
66
+ // Handle virtual scroll recycling — if element is stale, rebuild index once
67
+ if (el && !el.isConnected) {
68
+ buildCellIndex();
69
+ el = cellIndex?.get(key);
70
+ }
71
+ if (el) {
72
+ if (!el.hasAttribute(DRAG_ATTR))
73
+ el.setAttribute(DRAG_ATTR, '');
74
+ markedCells.add(el);
75
+ }
44
76
  }
45
77
  }
46
78
  };
47
79
  const clearDragAttrs = () => {
48
- const wrapper = wrapperRef.current;
49
- if (!wrapper)
50
- return;
51
- const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
52
- for (let i = 0; i < marked.length; i++)
53
- marked[i].removeAttribute(DRAG_ATTR);
80
+ for (const el of markedCells) {
81
+ el.removeAttribute(DRAG_ATTR);
82
+ }
83
+ markedCells.clear();
84
+ cellIndex = null;
54
85
  };
55
86
  let lastFillMousePos = null;
56
87
  const resolveRange = (cx, cy) => {
@@ -60,9 +91,9 @@ export function useFillHandle(params) {
60
91
  return null;
61
92
  const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
62
93
  const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
63
- if (Number.isNaN(r) || Number.isNaN(c) || c < colOff)
94
+ if (Number.isNaN(r) || Number.isNaN(c) || c < colOffsetRef.current)
64
95
  return null;
65
- const dataCol = c - colOff;
96
+ const dataCol = c - colOffsetRef.current;
66
97
  return normalizeSelectionRange({
67
98
  startRow: fillDrag.startRow,
68
99
  startCol: fillDrag.startCol,
@@ -118,38 +149,13 @@ export function useFillHandle(params) {
118
149
  });
119
150
  // Commit range to React state
120
151
  setSelectionRange(norm);
121
- setActiveCell({ rowIndex: end.endRow, columnIndex: end.endCol + colOff });
152
+ setActiveCell({ rowIndex: end.endRow, columnIndex: end.endCol + colOffsetRef.current });
122
153
  // Apply fill values
123
- const startItem = items[norm.startRow];
124
- const startColDef = visibleCols[norm.startCol];
125
- if (startItem && startColDef) {
126
- const startValue = getCellValue(startItem, startColDef);
154
+ const fillEvents = applyFillValues(norm, fillDrag.startRow, fillDrag.startCol, items, visibleCols);
155
+ if (fillEvents.length > 0) {
127
156
  beginBatch?.();
128
- for (let row = norm.startRow; row <= norm.endRow; row++) {
129
- for (let col = norm.startCol; col <= norm.endCol; col++) {
130
- if (row === fillDrag.startRow && col === fillDrag.startCol)
131
- continue;
132
- if (row >= items.length || col >= visibleCols.length)
133
- continue;
134
- const item = items[row];
135
- const colDef = visibleCols[col];
136
- const colEditable = colDef.editable === true ||
137
- (typeof colDef.editable === 'function' && colDef.editable(item));
138
- if (!colEditable)
139
- continue;
140
- const oldValue = getCellValue(item, colDef);
141
- const result = parseValue(startValue, oldValue, item, colDef);
142
- if (!result.valid)
143
- continue;
144
- onCellValueChanged({
145
- item,
146
- columnId: colDef.columnId,
147
- oldValue,
148
- newValue: result.value,
149
- rowIndex: row,
150
- });
151
- }
152
- }
157
+ for (const evt of fillEvents)
158
+ onCellValueChanged(evt);
153
159
  endBatch?.();
154
160
  }
155
161
  setFillDrag(null);
@@ -163,19 +169,18 @@ export function useFillHandle(params) {
163
169
  if (rafRef.current)
164
170
  cancelAnimationFrame(rafRef.current);
165
171
  };
166
- // eslint-disable-next-line react-hooks/exhaustive-deps
167
172
  }, [
168
173
  fillDrag,
169
174
  editable,
170
- colOffset,
171
175
  items,
172
176
  visibleCols,
173
177
  setSelectionRange,
174
178
  setActiveCell,
175
179
  onCellValueChanged,
176
- // wrapperRef excluded — refs are stable across renders
177
180
  beginBatch,
178
181
  endBatch,
182
+ colOffsetRef,
183
+ wrapperRef,
179
184
  ]);
180
185
  // Ref mirror — keeps handleFillHandleMouseDown stable across selection changes
181
186
  const selectionRangeRef = useRef(selectionRange);