@alaarab/ogrid-react 2.0.0-beta
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/README.md +55 -0
- package/dist/esm/components/BaseInlineCellEditor.js +112 -0
- package/dist/esm/components/CellErrorBoundary.js +43 -0
- package/dist/esm/components/EmptyState.js +19 -0
- package/dist/esm/components/GridContextMenu.js +35 -0
- package/dist/esm/components/MarchingAntsOverlay.js +110 -0
- package/dist/esm/components/OGridLayout.js +91 -0
- package/dist/esm/components/SideBar.js +122 -0
- package/dist/esm/components/StatusBar.js +6 -0
- package/dist/esm/hooks/index.js +25 -0
- package/dist/esm/hooks/useActiveCell.js +62 -0
- package/dist/esm/hooks/useCellEditing.js +15 -0
- package/dist/esm/hooks/useCellSelection.js +327 -0
- package/dist/esm/hooks/useClipboard.js +161 -0
- package/dist/esm/hooks/useColumnChooserState.js +62 -0
- package/dist/esm/hooks/useColumnHeaderFilterState.js +180 -0
- package/dist/esm/hooks/useColumnResize.js +92 -0
- package/dist/esm/hooks/useContextMenu.js +21 -0
- package/dist/esm/hooks/useDataGridState.js +313 -0
- package/dist/esm/hooks/useDateFilterState.js +34 -0
- package/dist/esm/hooks/useDebounce.js +35 -0
- package/dist/esm/hooks/useFillHandle.js +195 -0
- package/dist/esm/hooks/useFilterOptions.js +40 -0
- package/dist/esm/hooks/useInlineCellEditorState.js +44 -0
- package/dist/esm/hooks/useKeyboardNavigation.js +419 -0
- package/dist/esm/hooks/useLatestRef.js +11 -0
- package/dist/esm/hooks/useMultiSelectFilterState.js +59 -0
- package/dist/esm/hooks/useOGrid.js +465 -0
- package/dist/esm/hooks/usePeopleFilterState.js +68 -0
- package/dist/esm/hooks/useRichSelectState.js +58 -0
- package/dist/esm/hooks/useRowSelection.js +80 -0
- package/dist/esm/hooks/useSideBarState.js +39 -0
- package/dist/esm/hooks/useTableLayout.js +77 -0
- package/dist/esm/hooks/useTextFilterState.js +25 -0
- package/dist/esm/hooks/useUndoRedo.js +83 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/storybook/index.js +1 -0
- package/dist/esm/storybook/mockData.js +73 -0
- package/dist/esm/types/columnTypes.js +1 -0
- package/dist/esm/types/dataGridTypes.js +1 -0
- package/dist/esm/types/index.js +1 -0
- package/dist/esm/utils/dataGridViewModel.js +220 -0
- package/dist/esm/utils/gridRowComparator.js +2 -0
- package/dist/esm/utils/index.js +5 -0
- package/dist/types/components/BaseInlineCellEditor.d.ts +33 -0
- package/dist/types/components/CellErrorBoundary.d.ts +25 -0
- package/dist/types/components/EmptyState.d.ts +26 -0
- package/dist/types/components/GridContextMenu.d.ts +18 -0
- package/dist/types/components/MarchingAntsOverlay.d.ts +15 -0
- package/dist/types/components/OGridLayout.d.ts +37 -0
- package/dist/types/components/SideBar.d.ts +30 -0
- package/dist/types/components/StatusBar.d.ts +24 -0
- package/dist/types/hooks/index.d.ts +48 -0
- package/dist/types/hooks/useActiveCell.d.ts +13 -0
- package/dist/types/hooks/useCellEditing.d.ts +16 -0
- package/dist/types/hooks/useCellSelection.d.ts +22 -0
- package/dist/types/hooks/useClipboard.d.ts +30 -0
- package/dist/types/hooks/useColumnChooserState.d.ts +27 -0
- package/dist/types/hooks/useColumnHeaderFilterState.d.ts +73 -0
- package/dist/types/hooks/useColumnResize.d.ts +23 -0
- package/dist/types/hooks/useContextMenu.d.ts +19 -0
- package/dist/types/hooks/useDataGridState.d.ts +137 -0
- package/dist/types/hooks/useDateFilterState.d.ts +19 -0
- package/dist/types/hooks/useDebounce.d.ts +9 -0
- package/dist/types/hooks/useFillHandle.d.ts +33 -0
- package/dist/types/hooks/useFilterOptions.d.ts +16 -0
- package/dist/types/hooks/useInlineCellEditorState.d.ts +24 -0
- package/dist/types/hooks/useKeyboardNavigation.d.ts +47 -0
- package/dist/types/hooks/useLatestRef.d.ts +6 -0
- package/dist/types/hooks/useMultiSelectFilterState.d.ts +24 -0
- package/dist/types/hooks/useOGrid.d.ts +52 -0
- package/dist/types/hooks/usePeopleFilterState.d.ts +25 -0
- package/dist/types/hooks/useRichSelectState.d.ts +22 -0
- package/dist/types/hooks/useRowSelection.d.ts +22 -0
- package/dist/types/hooks/useSideBarState.d.ts +20 -0
- package/dist/types/hooks/useTableLayout.d.ts +27 -0
- package/dist/types/hooks/useTextFilterState.d.ts +16 -0
- package/dist/types/hooks/useUndoRedo.d.ts +23 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/storybook/index.d.ts +2 -0
- package/dist/types/storybook/mockData.d.ts +37 -0
- package/dist/types/types/columnTypes.d.ts +25 -0
- package/dist/types/types/dataGridTypes.d.ts +152 -0
- package/dist/types/types/index.d.ts +3 -0
- package/dist/types/utils/dataGridViewModel.d.ts +161 -0
- package/dist/types/utils/gridRowComparator.d.ts +2 -0
- package/dist/types/utils/index.d.ts +6 -0
- package/package.json +46 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless column header filter state and handlers for Fluent, Material, and Radix.
|
|
3
|
+
* UI packages use this hook and render only presentation (popover, inputs, buttons).
|
|
4
|
+
* Composes 4 sub-hooks for each filter type's state management.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, useCallback, useRef, useEffect, useMemo, } from 'react';
|
|
7
|
+
import { useTextFilterState } from './useTextFilterState';
|
|
8
|
+
import { useMultiSelectFilterState } from './useMultiSelectFilterState';
|
|
9
|
+
import { usePeopleFilterState } from './usePeopleFilterState';
|
|
10
|
+
import { useDateFilterState } from './useDateFilterState';
|
|
11
|
+
const EMPTY_OPTIONS = [];
|
|
12
|
+
export function useColumnHeaderFilterState(params) {
|
|
13
|
+
const { filterType, onSort, selectedValues, onFilterChange, options, textValue = '', onTextChange, selectedUser, onUserChange, peopleSearch, dateValue, onDateChange, } = params;
|
|
14
|
+
const safeSelectedValues = selectedValues ?? EMPTY_OPTIONS;
|
|
15
|
+
// Shared state
|
|
16
|
+
const headerRef = useRef(null);
|
|
17
|
+
const popoverRef = useRef(null);
|
|
18
|
+
const [isFilterOpen, setFilterOpen] = useState(false);
|
|
19
|
+
const [popoverPosition, setPopoverPosition] = useState(null);
|
|
20
|
+
// Compose sub-hooks for each filter type
|
|
21
|
+
const textFilterState = useTextFilterState({
|
|
22
|
+
textValue,
|
|
23
|
+
onTextChange,
|
|
24
|
+
isFilterOpen,
|
|
25
|
+
});
|
|
26
|
+
const multiSelectFilterState = useMultiSelectFilterState({
|
|
27
|
+
selectedValues,
|
|
28
|
+
onFilterChange,
|
|
29
|
+
options,
|
|
30
|
+
isFilterOpen,
|
|
31
|
+
});
|
|
32
|
+
const peopleFilterState = usePeopleFilterState({
|
|
33
|
+
selectedUser,
|
|
34
|
+
onUserChange,
|
|
35
|
+
peopleSearch,
|
|
36
|
+
isFilterOpen,
|
|
37
|
+
filterType,
|
|
38
|
+
});
|
|
39
|
+
const dateFilterState = useDateFilterState({
|
|
40
|
+
dateValue,
|
|
41
|
+
onDateChange,
|
|
42
|
+
isFilterOpen,
|
|
43
|
+
});
|
|
44
|
+
// Close popover resets position
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isFilterOpen) {
|
|
47
|
+
setPopoverPosition(null);
|
|
48
|
+
}
|
|
49
|
+
}, [isFilterOpen]);
|
|
50
|
+
// Click outside and Escape to close
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isFilterOpen)
|
|
53
|
+
return;
|
|
54
|
+
const handleClickOutside = (e) => {
|
|
55
|
+
const target = e.target;
|
|
56
|
+
if (popoverRef.current &&
|
|
57
|
+
!popoverRef.current.contains(target) &&
|
|
58
|
+
headerRef.current &&
|
|
59
|
+
!headerRef.current.contains(target)) {
|
|
60
|
+
setFilterOpen(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const handleKeyDown = (e) => {
|
|
64
|
+
if (e.key === 'Escape' || e.key === 'Esc') {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
e.stopPropagation();
|
|
67
|
+
setFilterOpen(false);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const timeoutId = setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0);
|
|
71
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
72
|
+
return () => {
|
|
73
|
+
clearTimeout(timeoutId);
|
|
74
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
75
|
+
document.removeEventListener('keydown', handleKeyDown, true);
|
|
76
|
+
};
|
|
77
|
+
}, [isFilterOpen]);
|
|
78
|
+
// Shared handlers
|
|
79
|
+
const handleFilterIconClick = useCallback((e) => {
|
|
80
|
+
e.stopPropagation();
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
setFilterOpen((prev) => {
|
|
83
|
+
if (!prev && headerRef.current) {
|
|
84
|
+
const rect = headerRef.current.getBoundingClientRect();
|
|
85
|
+
setPopoverPosition({ top: rect.bottom + 4, left: rect.left });
|
|
86
|
+
}
|
|
87
|
+
return !prev;
|
|
88
|
+
});
|
|
89
|
+
}, []);
|
|
90
|
+
const handleSortClick = useCallback((e) => {
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
onSort?.();
|
|
93
|
+
}, [onSort]);
|
|
94
|
+
// Wrap sub-hook handlers to close popover
|
|
95
|
+
const handleApplyMultiSelect = useCallback(() => {
|
|
96
|
+
multiSelectFilterState.handleApplyMultiSelect();
|
|
97
|
+
setFilterOpen(false);
|
|
98
|
+
}, [multiSelectFilterState]);
|
|
99
|
+
const handleTextApply = useCallback(() => {
|
|
100
|
+
textFilterState.handleTextApply();
|
|
101
|
+
setFilterOpen(false);
|
|
102
|
+
}, [textFilterState]);
|
|
103
|
+
const handleUserSelect = useCallback((user) => {
|
|
104
|
+
peopleFilterState.handleUserSelect(user);
|
|
105
|
+
setFilterOpen(false);
|
|
106
|
+
}, [peopleFilterState]);
|
|
107
|
+
const handleClearUser = useCallback(() => {
|
|
108
|
+
peopleFilterState.handleClearUser();
|
|
109
|
+
setFilterOpen(false);
|
|
110
|
+
}, [peopleFilterState]);
|
|
111
|
+
const handleDateApply = useCallback(() => {
|
|
112
|
+
dateFilterState.handleDateApply();
|
|
113
|
+
setFilterOpen(false);
|
|
114
|
+
}, [dateFilterState]);
|
|
115
|
+
// Event propagation stoppers
|
|
116
|
+
const handlePopoverClick = useCallback((e) => e.stopPropagation(), []);
|
|
117
|
+
const handleInputFocus = useCallback((e) => e.stopPropagation(), []);
|
|
118
|
+
const handleInputMouseDown = useCallback((e) => e.stopPropagation(), []);
|
|
119
|
+
const handleInputClick = useCallback((e) => e.stopPropagation(), []);
|
|
120
|
+
const handleInputKeyDown = useCallback((e) => {
|
|
121
|
+
if (e.key !== 'Escape' && e.key !== 'Esc')
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
}, []);
|
|
124
|
+
// Compute hasActiveFilter from all sub-hooks
|
|
125
|
+
const hasActiveFilter = useMemo(() => {
|
|
126
|
+
if (filterType === 'multiSelect')
|
|
127
|
+
return safeSelectedValues.length > 0;
|
|
128
|
+
if (filterType === 'text')
|
|
129
|
+
return !!textValue.trim();
|
|
130
|
+
if (filterType === 'people')
|
|
131
|
+
return !!selectedUser;
|
|
132
|
+
if (filterType === 'date')
|
|
133
|
+
return !!(dateValue?.from || dateValue?.to);
|
|
134
|
+
return false;
|
|
135
|
+
}, [filterType, safeSelectedValues, textValue, selectedUser, dateValue]);
|
|
136
|
+
return {
|
|
137
|
+
headerRef,
|
|
138
|
+
popoverRef,
|
|
139
|
+
peopleInputRef: peopleFilterState.peopleInputRef,
|
|
140
|
+
isFilterOpen,
|
|
141
|
+
setFilterOpen,
|
|
142
|
+
tempSelected: multiSelectFilterState.tempSelected,
|
|
143
|
+
setTempSelected: multiSelectFilterState.setTempSelected,
|
|
144
|
+
tempTextValue: textFilterState.tempTextValue,
|
|
145
|
+
setTempTextValue: textFilterState.setTempTextValue,
|
|
146
|
+
searchText: multiSelectFilterState.searchText,
|
|
147
|
+
setSearchText: multiSelectFilterState.setSearchText,
|
|
148
|
+
debouncedSearchText: multiSelectFilterState.debouncedSearchText,
|
|
149
|
+
filteredOptions: multiSelectFilterState.filteredOptions,
|
|
150
|
+
peopleSuggestions: peopleFilterState.peopleSuggestions,
|
|
151
|
+
isPeopleLoading: peopleFilterState.isPeopleLoading,
|
|
152
|
+
peopleSearchText: peopleFilterState.peopleSearchText,
|
|
153
|
+
setPeopleSearchText: peopleFilterState.setPeopleSearchText,
|
|
154
|
+
tempDateFrom: dateFilterState.tempDateFrom,
|
|
155
|
+
setTempDateFrom: dateFilterState.setTempDateFrom,
|
|
156
|
+
tempDateTo: dateFilterState.tempDateTo,
|
|
157
|
+
setTempDateTo: dateFilterState.setTempDateTo,
|
|
158
|
+
hasActiveFilter,
|
|
159
|
+
popoverPosition,
|
|
160
|
+
handlers: {
|
|
161
|
+
handleFilterIconClick,
|
|
162
|
+
handleApplyMultiSelect,
|
|
163
|
+
handleTextApply,
|
|
164
|
+
handleTextClear: textFilterState.handleTextClear,
|
|
165
|
+
handleUserSelect,
|
|
166
|
+
handleClearUser,
|
|
167
|
+
handleDateApply,
|
|
168
|
+
handleDateClear: dateFilterState.handleDateClear,
|
|
169
|
+
handleCheckboxChange: multiSelectFilterState.handleCheckboxChange,
|
|
170
|
+
handleSelectAll: multiSelectFilterState.handleSelectAll,
|
|
171
|
+
handleClearSelection: multiSelectFilterState.handleClearSelection,
|
|
172
|
+
handlePopoverClick,
|
|
173
|
+
handleInputFocus,
|
|
174
|
+
handleInputMouseDown,
|
|
175
|
+
handleInputClick,
|
|
176
|
+
handleInputKeyDown,
|
|
177
|
+
handleSortClick,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { useLatestRef } from './useLatestRef';
|
|
3
|
+
/**
|
|
4
|
+
* Manages column resize drag interactions with RAF-throttled state updates.
|
|
5
|
+
* @param params - Sizing overrides, setter, min/default widths, and resize callback.
|
|
6
|
+
* @returns Resize start handler and column width getter.
|
|
7
|
+
*/
|
|
8
|
+
export function useColumnResize({ columnSizingOverrides, setColumnSizingOverrides, minWidth = 80, defaultWidth = 120, onColumnResized, }) {
|
|
9
|
+
const rafRef = useRef(0);
|
|
10
|
+
const onColumnResizedRef = useRef(onColumnResized);
|
|
11
|
+
onColumnResizedRef.current = onColumnResized;
|
|
12
|
+
const columnSizingOverridesRef = useLatestRef(columnSizingOverrides);
|
|
13
|
+
// Track active drag listeners so we can clean up on unmount
|
|
14
|
+
const cleanupRef = useRef(null);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
return () => {
|
|
17
|
+
if (cleanupRef.current) {
|
|
18
|
+
cleanupRef.current();
|
|
19
|
+
cleanupRef.current = null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}, []);
|
|
23
|
+
const handleResizeStart = useCallback((e, col) => {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
e.stopPropagation();
|
|
26
|
+
const startX = e.clientX;
|
|
27
|
+
const columnId = col.columnId;
|
|
28
|
+
// Measure the actual rendered width from the DOM. With table-layout: auto,
|
|
29
|
+
// the browser may have auto-sized the column wider than the config values.
|
|
30
|
+
// The resize handle is a direct child of <th>, so parentElement is the header cell.
|
|
31
|
+
const thEl = e.currentTarget.parentElement;
|
|
32
|
+
const startWidth = thEl
|
|
33
|
+
? thEl.getBoundingClientRect().width
|
|
34
|
+
: columnSizingOverridesRef.current[columnId]?.widthPx
|
|
35
|
+
?? col.idealWidth
|
|
36
|
+
?? col.defaultWidth
|
|
37
|
+
?? defaultWidth;
|
|
38
|
+
let latestWidth = startWidth;
|
|
39
|
+
// Lock cursor and prevent text selection during drag
|
|
40
|
+
const prevCursor = document.body.style.cursor;
|
|
41
|
+
const prevUserSelect = document.body.style.userSelect;
|
|
42
|
+
document.body.style.cursor = 'col-resize';
|
|
43
|
+
document.body.style.userSelect = 'none';
|
|
44
|
+
const flushWidth = () => {
|
|
45
|
+
setColumnSizingOverrides((prev) => ({
|
|
46
|
+
...prev,
|
|
47
|
+
[columnId]: { widthPx: latestWidth },
|
|
48
|
+
}));
|
|
49
|
+
};
|
|
50
|
+
const onMove = (moveEvent) => {
|
|
51
|
+
const deltaX = moveEvent.clientX - startX;
|
|
52
|
+
latestWidth = Math.max(minWidth, startWidth + deltaX);
|
|
53
|
+
if (!rafRef.current) {
|
|
54
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
55
|
+
rafRef.current = 0;
|
|
56
|
+
flushWidth();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const cleanup = () => {
|
|
61
|
+
document.removeEventListener('mousemove', onMove);
|
|
62
|
+
document.removeEventListener('mouseup', onUp);
|
|
63
|
+
cleanupRef.current = null;
|
|
64
|
+
// Restore cursor and user-select
|
|
65
|
+
document.body.style.cursor = prevCursor;
|
|
66
|
+
document.body.style.userSelect = prevUserSelect;
|
|
67
|
+
// Cancel pending RAF and flush final width synchronously
|
|
68
|
+
if (rafRef.current) {
|
|
69
|
+
cancelAnimationFrame(rafRef.current);
|
|
70
|
+
rafRef.current = 0;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const onUp = () => {
|
|
74
|
+
cleanup();
|
|
75
|
+
flushWidth();
|
|
76
|
+
if (onColumnResizedRef.current) {
|
|
77
|
+
onColumnResizedRef.current(columnId, latestWidth);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
document.addEventListener('mousemove', onMove);
|
|
81
|
+
document.addEventListener('mouseup', onUp);
|
|
82
|
+
cleanupRef.current = cleanup;
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [defaultWidth, minWidth, setColumnSizingOverrides]); // columnSizingOverrides read via ref
|
|
85
|
+
const getColumnWidth = useCallback((col) => {
|
|
86
|
+
return columnSizingOverrides[col.columnId]?.widthPx
|
|
87
|
+
?? col.idealWidth
|
|
88
|
+
?? col.defaultWidth
|
|
89
|
+
?? defaultWidth;
|
|
90
|
+
}, [columnSizingOverrides, defaultWidth]);
|
|
91
|
+
return { handleResizeStart, getColumnWidth };
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Manages context menu position state for right-click menus.
|
|
4
|
+
* @returns Menu position, setter, right-click handler, and close handler.
|
|
5
|
+
*/
|
|
6
|
+
export function useContextMenu() {
|
|
7
|
+
const [contextMenuPosition, setContextMenuPosition] = useState(null);
|
|
8
|
+
const handleCellContextMenu = useCallback((e) => {
|
|
9
|
+
e.preventDefault?.();
|
|
10
|
+
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
|
11
|
+
}, []);
|
|
12
|
+
const closeContextMenu = useCallback(() => {
|
|
13
|
+
setContextMenuPosition(null);
|
|
14
|
+
}, []);
|
|
15
|
+
return {
|
|
16
|
+
contextMenuPosition,
|
|
17
|
+
setContextMenuPosition,
|
|
18
|
+
handleCellContextMenu,
|
|
19
|
+
closeContextMenu,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { useMemo, useCallback, useState } from 'react';
|
|
2
|
+
import { flattenColumns, getDataGridStatusBarConfig, parseValue, computeAggregations } from '../utils';
|
|
3
|
+
import { useRowSelection } from './useRowSelection';
|
|
4
|
+
import { useCellEditing } from './useCellEditing';
|
|
5
|
+
import { useActiveCell } from './useActiveCell';
|
|
6
|
+
import { useCellSelection } from './useCellSelection';
|
|
7
|
+
import { useContextMenu } from './useContextMenu';
|
|
8
|
+
import { useClipboard } from './useClipboard';
|
|
9
|
+
import { useKeyboardNavigation } from './useKeyboardNavigation';
|
|
10
|
+
import { useFillHandle } from './useFillHandle';
|
|
11
|
+
import { useUndoRedo } from './useUndoRedo';
|
|
12
|
+
import { useLatestRef } from './useLatestRef';
|
|
13
|
+
import { useTableLayout } from './useTableLayout';
|
|
14
|
+
// Stable no-op handlers used when cellSelection is disabled (module-scope = no re-renders)
|
|
15
|
+
const NOOP = () => { };
|
|
16
|
+
const NOOP_ASYNC = async () => { };
|
|
17
|
+
const NOOP_MOUSE = (_e, _r, _c) => { };
|
|
18
|
+
const NOOP_KEY = (_e) => { };
|
|
19
|
+
const NOOP_CTX = (_e) => { };
|
|
20
|
+
/**
|
|
21
|
+
* Single orchestration hook for DataGridTable. Takes grid props and wrapper ref,
|
|
22
|
+
* returns all derived state and handlers so Fluent/Material/Radix can be thin view layers.
|
|
23
|
+
*/
|
|
24
|
+
export function useDataGridState(params) {
|
|
25
|
+
const { props, wrapperRef } = params;
|
|
26
|
+
const { items, columns, getRowId, visibleColumns, columnOrder, rowSelection = 'none', selectedRows: controlledSelectedRows, onSelectionChange, statusBar, emptyState, editable, cellSelection: cellSelectionProp, onCellValueChanged: onCellValueChangedProp, initialColumnWidths, onColumnResized, pinnedColumns, onCellError, } = props;
|
|
27
|
+
const cellSelection = cellSelectionProp !== false;
|
|
28
|
+
// Wrap onCellValueChanged with undo/redo tracking — all edits are recorded automatically
|
|
29
|
+
const undoRedo = useUndoRedo({ onCellValueChanged: onCellValueChangedProp });
|
|
30
|
+
const onCellValueChanged = undoRedo.onCellValueChanged;
|
|
31
|
+
// Cast is safe: input columns are React.IColumnDef instances; flattenColumns only extracts leaves.
|
|
32
|
+
const flatColumnsRaw = useMemo(() => flattenColumns(columns), [columns]);
|
|
33
|
+
// Apply runtime pin overrides (from applyColumnState or programmatic changes)
|
|
34
|
+
const flatColumns = useMemo(() => {
|
|
35
|
+
if (!pinnedColumns || Object.keys(pinnedColumns).length === 0)
|
|
36
|
+
return flatColumnsRaw;
|
|
37
|
+
return flatColumnsRaw.map((col) => {
|
|
38
|
+
const override = pinnedColumns[col.columnId];
|
|
39
|
+
if (override && col.pinned !== override) {
|
|
40
|
+
return { ...col, pinned: override };
|
|
41
|
+
}
|
|
42
|
+
// If col was pinned by definition but not in overrides, keep original
|
|
43
|
+
return col;
|
|
44
|
+
});
|
|
45
|
+
}, [flatColumnsRaw, pinnedColumns]);
|
|
46
|
+
const visibleCols = useMemo(() => {
|
|
47
|
+
const filtered = visibleColumns
|
|
48
|
+
? flatColumns.filter((c) => visibleColumns.has(c.columnId))
|
|
49
|
+
: flatColumns;
|
|
50
|
+
if (!columnOrder?.length)
|
|
51
|
+
return filtered;
|
|
52
|
+
return [...filtered].sort((a, b) => {
|
|
53
|
+
const ia = columnOrder.indexOf(a.columnId);
|
|
54
|
+
const ib = columnOrder.indexOf(b.columnId);
|
|
55
|
+
if (ia === -1 && ib === -1)
|
|
56
|
+
return 0;
|
|
57
|
+
if (ia === -1)
|
|
58
|
+
return 1;
|
|
59
|
+
if (ib === -1)
|
|
60
|
+
return -1;
|
|
61
|
+
return ia - ib;
|
|
62
|
+
});
|
|
63
|
+
}, [flatColumns, visibleColumns, columnOrder]);
|
|
64
|
+
const visibleColumnCount = visibleCols.length;
|
|
65
|
+
const hasCheckboxCol = rowSelection === 'multiple';
|
|
66
|
+
const totalColCount = visibleColumnCount + (hasCheckboxCol ? 1 : 0);
|
|
67
|
+
const colOffset = hasCheckboxCol ? 1 : 0;
|
|
68
|
+
const rowIndexByRowId = useMemo(() => {
|
|
69
|
+
const m = new Map();
|
|
70
|
+
items.forEach((item, idx) => m.set(getRowId(item), idx));
|
|
71
|
+
return m;
|
|
72
|
+
}, [items, getRowId]);
|
|
73
|
+
const rowSelectionResult = useRowSelection({
|
|
74
|
+
items,
|
|
75
|
+
getRowId,
|
|
76
|
+
rowSelection,
|
|
77
|
+
controlledSelectedRows,
|
|
78
|
+
onSelectionChange,
|
|
79
|
+
});
|
|
80
|
+
const { selectedRowIds, updateSelection, handleRowCheckboxChange, handleSelectAll, allSelected, someSelected, } = rowSelectionResult;
|
|
81
|
+
const { editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, } = useCellEditing();
|
|
82
|
+
const { activeCell, setActiveCell } = useActiveCell(wrapperRef, editingCell);
|
|
83
|
+
const { selectionRange, setSelectionRange, handleCellMouseDown: handleCellMouseDownBase, handleSelectAllCells, isDragging, } = useCellSelection({
|
|
84
|
+
colOffset,
|
|
85
|
+
rowCount: items.length,
|
|
86
|
+
visibleColCount: visibleCols.length,
|
|
87
|
+
setActiveCell,
|
|
88
|
+
wrapperRef,
|
|
89
|
+
});
|
|
90
|
+
const { contextMenuPosition, setContextMenuPosition, handleCellContextMenu, closeContextMenu } = useContextMenu();
|
|
91
|
+
const { handleCopy, handleCut, handlePaste, cutRange, copyRange, clearClipboardRanges } = useClipboard({
|
|
92
|
+
items,
|
|
93
|
+
visibleCols,
|
|
94
|
+
colOffset,
|
|
95
|
+
selectionRange,
|
|
96
|
+
activeCell,
|
|
97
|
+
editable,
|
|
98
|
+
onCellValueChanged,
|
|
99
|
+
beginBatch: undoRedo.beginBatch,
|
|
100
|
+
endBatch: undoRedo.endBatch,
|
|
101
|
+
});
|
|
102
|
+
const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
|
|
103
|
+
if (e.button !== 0)
|
|
104
|
+
return;
|
|
105
|
+
wrapperRef.current?.focus({ preventScroll: true });
|
|
106
|
+
clearClipboardRanges();
|
|
107
|
+
handleCellMouseDownBase(e, rowIndex, globalColIndex);
|
|
108
|
+
},
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
[handleCellMouseDownBase, clearClipboardRanges] // wrapperRef excluded — refs are stable
|
|
111
|
+
);
|
|
112
|
+
const { handleGridKeyDown } = useKeyboardNavigation({
|
|
113
|
+
data: { items, visibleCols, colOffset, hasCheckboxCol, visibleColumnCount, getRowId },
|
|
114
|
+
state: { activeCell, selectionRange, editingCell, selectedRowIds },
|
|
115
|
+
handlers: { setActiveCell, setSelectionRange, setEditingCell, handleRowCheckboxChange, handleCopy, handleCut, handlePaste, setContextMenu: setContextMenuPosition, onUndo: undoRedo.undo, onRedo: undoRedo.redo, clearClipboardRanges },
|
|
116
|
+
features: { editable, onCellValueChanged, rowSelection, wrapperRef },
|
|
117
|
+
});
|
|
118
|
+
const { handleFillHandleMouseDown } = useFillHandle({
|
|
119
|
+
items,
|
|
120
|
+
visibleCols,
|
|
121
|
+
editable,
|
|
122
|
+
onCellValueChanged,
|
|
123
|
+
selectionRange,
|
|
124
|
+
setSelectionRange,
|
|
125
|
+
setActiveCell,
|
|
126
|
+
colOffset,
|
|
127
|
+
wrapperRef,
|
|
128
|
+
beginBatch: undoRedo.beginBatch,
|
|
129
|
+
endBatch: undoRedo.endBatch,
|
|
130
|
+
});
|
|
131
|
+
const { containerWidth, minTableWidth, desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, } = useTableLayout({
|
|
132
|
+
wrapperRef,
|
|
133
|
+
visibleCols,
|
|
134
|
+
flatColumns,
|
|
135
|
+
hasCheckboxCol,
|
|
136
|
+
initialColumnWidths,
|
|
137
|
+
onColumnResized,
|
|
138
|
+
});
|
|
139
|
+
const aggregation = useMemo(() => computeAggregations(items, visibleCols, cellSelection ? selectionRange : null), [items, visibleCols, selectionRange, cellSelection]);
|
|
140
|
+
const statusBarConfig = useMemo(() => {
|
|
141
|
+
const base = getDataGridStatusBarConfig(statusBar, items.length, selectedRowIds.size);
|
|
142
|
+
if (!base)
|
|
143
|
+
return null;
|
|
144
|
+
return { ...base, aggregation: aggregation ?? undefined };
|
|
145
|
+
}, [statusBar, items.length, selectedRowIds.size, aggregation]);
|
|
146
|
+
const showEmptyInGrid = items.length === 0 && !!emptyState && !props.isLoading;
|
|
147
|
+
const hasCellSelection = selectionRange != null || activeCell != null;
|
|
148
|
+
// --- View-model inputs (shared across all 3 DataGridTables) ---
|
|
149
|
+
const { sortBy, sortDirection, onColumnSort, filters, onFilterChange, filterOptions, loadingFilterOptions, peopleSearch, } = props;
|
|
150
|
+
// Stabilize callbacks via refs — headerFilterInput only re-creates when data changes,
|
|
151
|
+
// not when callback identities change (which happens on unrelated state updates).
|
|
152
|
+
const onColumnSortRef = useLatestRef(onColumnSort);
|
|
153
|
+
const onFilterChangeRef = useLatestRef(onFilterChange);
|
|
154
|
+
const peopleSearchRef = useLatestRef(peopleSearch);
|
|
155
|
+
// Stable callback wrappers that delegate to refs
|
|
156
|
+
const stableOnColumnSort = useCallback((columnKey) => onColumnSortRef.current?.(columnKey),
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
[]);
|
|
159
|
+
const stableOnFilterChange = useCallback((...args) => onFilterChangeRef.current?.(...args),
|
|
160
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
+
[]);
|
|
162
|
+
const stablePeopleSearch = useCallback((...args) => peopleSearchRef.current?.(...args) ?? Promise.resolve([]),
|
|
163
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
|
+
[]);
|
|
165
|
+
const headerFilterInput = useMemo(() => ({
|
|
166
|
+
sortBy,
|
|
167
|
+
sortDirection,
|
|
168
|
+
onColumnSort: stableOnColumnSort,
|
|
169
|
+
filters,
|
|
170
|
+
onFilterChange: stableOnFilterChange,
|
|
171
|
+
filterOptions,
|
|
172
|
+
loadingFilterOptions,
|
|
173
|
+
peopleSearch: peopleSearch ? stablePeopleSearch : undefined,
|
|
174
|
+
}), [
|
|
175
|
+
sortBy,
|
|
176
|
+
sortDirection,
|
|
177
|
+
stableOnColumnSort,
|
|
178
|
+
filters,
|
|
179
|
+
stableOnFilterChange,
|
|
180
|
+
filterOptions,
|
|
181
|
+
loadingFilterOptions,
|
|
182
|
+
peopleSearch, stablePeopleSearch,
|
|
183
|
+
]);
|
|
184
|
+
const cellDescriptorInput = useMemo(() => ({
|
|
185
|
+
editingCell,
|
|
186
|
+
activeCell: cellSelection ? activeCell : null,
|
|
187
|
+
selectionRange: cellSelection ? selectionRange : null,
|
|
188
|
+
cutRange: cellSelection ? cutRange : null,
|
|
189
|
+
copyRange: cellSelection ? copyRange : null,
|
|
190
|
+
colOffset,
|
|
191
|
+
itemsLength: items.length,
|
|
192
|
+
getRowId,
|
|
193
|
+
editable,
|
|
194
|
+
onCellValueChanged,
|
|
195
|
+
isDragging: cellSelection ? isDragging : false,
|
|
196
|
+
}), [
|
|
197
|
+
editingCell,
|
|
198
|
+
activeCell,
|
|
199
|
+
selectionRange,
|
|
200
|
+
cutRange,
|
|
201
|
+
copyRange,
|
|
202
|
+
colOffset,
|
|
203
|
+
items.length,
|
|
204
|
+
getRowId,
|
|
205
|
+
editable,
|
|
206
|
+
onCellValueChanged,
|
|
207
|
+
cellSelection,
|
|
208
|
+
isDragging,
|
|
209
|
+
]);
|
|
210
|
+
// --- Cell edit helpers ---
|
|
211
|
+
const [popoverAnchorEl, setPopoverAnchorEl] = useState(null);
|
|
212
|
+
const visibleColsRef = useLatestRef(visibleCols);
|
|
213
|
+
const itemsLengthRef = useLatestRef(items.length);
|
|
214
|
+
const commitCellEdit = useCallback((item, columnId, oldValue, newValue, rowIndex, globalColIndex) => {
|
|
215
|
+
// Validate via valueParser before committing
|
|
216
|
+
const col = visibleColsRef.current.find((c) => c.columnId === columnId);
|
|
217
|
+
if (col) {
|
|
218
|
+
const result = parseValue(newValue, oldValue, item, col);
|
|
219
|
+
if (!result.valid) {
|
|
220
|
+
// Reject — cancel the edit
|
|
221
|
+
setEditingCell(null);
|
|
222
|
+
setPopoverAnchorEl(null);
|
|
223
|
+
setPendingEditorValue(undefined);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
newValue = result.value;
|
|
227
|
+
}
|
|
228
|
+
onCellValueChanged?.({
|
|
229
|
+
item,
|
|
230
|
+
columnId,
|
|
231
|
+
oldValue,
|
|
232
|
+
newValue,
|
|
233
|
+
rowIndex,
|
|
234
|
+
});
|
|
235
|
+
setEditingCell(null);
|
|
236
|
+
setPopoverAnchorEl(null);
|
|
237
|
+
setPendingEditorValue(undefined);
|
|
238
|
+
// Advance to next row for inline editors
|
|
239
|
+
if (rowIndex < itemsLengthRef.current - 1) {
|
|
240
|
+
setActiveCell({ rowIndex: rowIndex + 1, columnIndex: globalColIndex });
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
|
+
[onCellValueChanged, setEditingCell, setPendingEditorValue, setActiveCell]);
|
|
245
|
+
const cancelPopoverEdit = useCallback(() => {
|
|
246
|
+
setEditingCell(null);
|
|
247
|
+
setPopoverAnchorEl(null);
|
|
248
|
+
setPendingEditorValue(undefined);
|
|
249
|
+
}, [setEditingCell, setPendingEditorValue]);
|
|
250
|
+
// --- Memoize each sub-object so downstream consumers only re-render when their slice changes ---
|
|
251
|
+
const layoutState = useMemo(() => ({
|
|
252
|
+
flatColumns, visibleCols, visibleColumnCount, totalColCount, colOffset,
|
|
253
|
+
hasCheckboxCol, rowIndexByRowId, containerWidth, minTableWidth,
|
|
254
|
+
desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, onColumnResized,
|
|
255
|
+
}), [
|
|
256
|
+
flatColumns, visibleCols, visibleColumnCount, totalColCount, colOffset,
|
|
257
|
+
hasCheckboxCol, rowIndexByRowId, containerWidth, minTableWidth,
|
|
258
|
+
desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides, onColumnResized,
|
|
259
|
+
]);
|
|
260
|
+
const rowSelectionState = useMemo(() => ({
|
|
261
|
+
selectedRowIds, updateSelection, handleRowCheckboxChange,
|
|
262
|
+
handleSelectAll, allSelected, someSelected,
|
|
263
|
+
}), [selectedRowIds, updateSelection, handleRowCheckboxChange, handleSelectAll, allSelected, someSelected]);
|
|
264
|
+
const editingState = useMemo(() => ({
|
|
265
|
+
editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue,
|
|
266
|
+
commitCellEdit, cancelPopoverEdit, popoverAnchorEl, setPopoverAnchorEl,
|
|
267
|
+
}), [editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, commitCellEdit, cancelPopoverEdit, popoverAnchorEl, setPopoverAnchorEl]);
|
|
268
|
+
const interactionState = useMemo(() => ({
|
|
269
|
+
activeCell: cellSelection ? activeCell : null,
|
|
270
|
+
setActiveCell: cellSelection ? setActiveCell : NOOP,
|
|
271
|
+
selectionRange: cellSelection ? selectionRange : null,
|
|
272
|
+
setSelectionRange: cellSelection ? setSelectionRange : NOOP,
|
|
273
|
+
handleCellMouseDown: cellSelection ? handleCellMouseDown : NOOP_MOUSE,
|
|
274
|
+
handleSelectAllCells: cellSelection ? handleSelectAllCells : NOOP,
|
|
275
|
+
hasCellSelection: cellSelection ? hasCellSelection : false,
|
|
276
|
+
handleGridKeyDown: cellSelection ? handleGridKeyDown : NOOP_KEY,
|
|
277
|
+
handleFillHandleMouseDown: cellSelection ? handleFillHandleMouseDown : NOOP,
|
|
278
|
+
handleCopy: cellSelection ? handleCopy : NOOP,
|
|
279
|
+
handleCut: cellSelection ? handleCut : NOOP,
|
|
280
|
+
handlePaste: cellSelection ? handlePaste : NOOP_ASYNC,
|
|
281
|
+
cutRange: cellSelection ? cutRange : null,
|
|
282
|
+
copyRange: cellSelection ? copyRange : null,
|
|
283
|
+
clearClipboardRanges: cellSelection ? clearClipboardRanges : NOOP,
|
|
284
|
+
canUndo: undoRedo.canUndo,
|
|
285
|
+
canRedo: undoRedo.canRedo,
|
|
286
|
+
onUndo: undoRedo.undo,
|
|
287
|
+
onRedo: undoRedo.redo,
|
|
288
|
+
isDragging: cellSelection ? isDragging : false,
|
|
289
|
+
}), [
|
|
290
|
+
cellSelection, activeCell, setActiveCell, selectionRange, setSelectionRange,
|
|
291
|
+
handleCellMouseDown, handleSelectAllCells, hasCellSelection, handleGridKeyDown,
|
|
292
|
+
handleFillHandleMouseDown, handleCopy, handleCut, handlePaste, cutRange, copyRange,
|
|
293
|
+
clearClipboardRanges, undoRedo.canUndo, undoRedo.canRedo, undoRedo.undo, undoRedo.redo,
|
|
294
|
+
isDragging,
|
|
295
|
+
]);
|
|
296
|
+
const contextMenuState = useMemo(() => ({
|
|
297
|
+
menuPosition: cellSelection ? contextMenuPosition : null,
|
|
298
|
+
setMenuPosition: cellSelection ? setContextMenuPosition : NOOP,
|
|
299
|
+
handleCellContextMenu: cellSelection ? handleCellContextMenu : NOOP_CTX,
|
|
300
|
+
closeContextMenu: cellSelection ? closeContextMenu : NOOP,
|
|
301
|
+
}), [cellSelection, contextMenuPosition, setContextMenuPosition, handleCellContextMenu, closeContextMenu]);
|
|
302
|
+
const viewModelsState = useMemo(() => ({
|
|
303
|
+
headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid, onCellError,
|
|
304
|
+
}), [headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid, onCellError]);
|
|
305
|
+
return {
|
|
306
|
+
layout: layoutState,
|
|
307
|
+
rowSelection: rowSelectionState,
|
|
308
|
+
editing: editingState,
|
|
309
|
+
interaction: interactionState,
|
|
310
|
+
contextMenu: contextMenuState,
|
|
311
|
+
viewModels: viewModelsState,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Date filter state sub-hook for column header filters.
|
|
3
|
+
* Manages temporary date from/to values and apply/clear handlers.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
6
|
+
export function useDateFilterState(params) {
|
|
7
|
+
const { dateValue, onDateChange, isFilterOpen } = params;
|
|
8
|
+
const [tempDateFrom, setTempDateFrom] = useState(dateValue?.from ?? '');
|
|
9
|
+
const [tempDateTo, setTempDateTo] = useState(dateValue?.to ?? '');
|
|
10
|
+
// Sync temp state when popover opens
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (isFilterOpen) {
|
|
13
|
+
setTempDateFrom(dateValue?.from ?? '');
|
|
14
|
+
setTempDateTo(dateValue?.to ?? '');
|
|
15
|
+
}
|
|
16
|
+
}, [isFilterOpen, dateValue]);
|
|
17
|
+
const handleDateApply = useCallback(() => {
|
|
18
|
+
const from = tempDateFrom || undefined;
|
|
19
|
+
const to = tempDateTo || undefined;
|
|
20
|
+
onDateChange?.(from || to ? { from, to } : undefined);
|
|
21
|
+
}, [onDateChange, tempDateFrom, tempDateTo]);
|
|
22
|
+
const handleDateClear = useCallback(() => {
|
|
23
|
+
setTempDateFrom('');
|
|
24
|
+
setTempDateTo('');
|
|
25
|
+
}, []);
|
|
26
|
+
return {
|
|
27
|
+
tempDateFrom,
|
|
28
|
+
setTempDateFrom,
|
|
29
|
+
tempDateTo,
|
|
30
|
+
setTempDateTo,
|
|
31
|
+
handleDateApply,
|
|
32
|
+
handleDateClear,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Returns a debounced value that updates after the specified delay when the source value changes.
|
|
4
|
+
*/
|
|
5
|
+
export function useDebounce(value, delayMs) {
|
|
6
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const id = setTimeout(() => {
|
|
9
|
+
setDebouncedValue(value);
|
|
10
|
+
}, delayMs);
|
|
11
|
+
return () => clearTimeout(id);
|
|
12
|
+
}, [value, delayMs]);
|
|
13
|
+
return debouncedValue;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Returns a stable callback that invokes the given function after the specified delay.
|
|
17
|
+
* Each new call resets the timer.
|
|
18
|
+
*/
|
|
19
|
+
export function useDebouncedCallback(fn, delayMs) {
|
|
20
|
+
const timeoutRef = useRef();
|
|
21
|
+
const fnRef = useRef(fn);
|
|
22
|
+
fnRef.current = fn;
|
|
23
|
+
const debounced = useCallback(((...args) => {
|
|
24
|
+
if (timeoutRef.current)
|
|
25
|
+
clearTimeout(timeoutRef.current);
|
|
26
|
+
timeoutRef.current = setTimeout(() => {
|
|
27
|
+
fnRef.current(...args);
|
|
28
|
+
}, delayMs);
|
|
29
|
+
}), [delayMs]);
|
|
30
|
+
useEffect(() => () => {
|
|
31
|
+
if (timeoutRef.current)
|
|
32
|
+
clearTimeout(timeoutRef.current);
|
|
33
|
+
}, []);
|
|
34
|
+
return debounced;
|
|
35
|
+
}
|