@alaarab/ogrid-core 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -31
- 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 +4 -3
- package/dist/esm/components/MarchingAntsOverlay.js +6 -5
- package/dist/esm/components/OGridLayout.js +7 -6
- package/dist/esm/components/SideBar.js +66 -44
- package/dist/esm/constants.js +11 -0
- package/dist/esm/hooks/index.js +6 -0
- package/dist/esm/hooks/useActiveCell.js +40 -11
- package/dist/esm/hooks/useCellEditing.js +4 -0
- package/dist/esm/hooks/useCellSelection.js +58 -20
- package/dist/esm/hooks/useClipboard.js +36 -36
- package/dist/esm/hooks/useColumnHeaderFilterState.js +71 -119
- package/dist/esm/hooks/useColumnResize.js +27 -4
- package/dist/esm/hooks/useContextMenu.js +9 -5
- package/dist/esm/hooks/useDataGridState.js +110 -162
- package/dist/esm/hooks/useDateFilterState.js +34 -0
- package/dist/esm/hooks/useFillHandle.js +7 -2
- package/dist/esm/hooks/useFilterOptions.js +5 -5
- package/dist/esm/hooks/useKeyboardNavigation.js +18 -34
- package/dist/esm/hooks/useLatestRef.js +11 -0
- package/dist/esm/hooks/useMultiSelectFilterState.js +59 -0
- package/dist/esm/hooks/useOGrid.js +71 -18
- package/dist/esm/hooks/usePeopleFilterState.js +68 -0
- package/dist/esm/hooks/useRichSelectState.js +5 -0
- package/dist/esm/hooks/useRowSelection.js +14 -4
- package/dist/esm/hooks/useSideBarState.js +5 -0
- package/dist/esm/hooks/useTableLayout.js +77 -0
- package/dist/esm/hooks/useTextFilterState.js +25 -0
- package/dist/esm/hooks/useUndoRedo.js +6 -5
- package/dist/esm/index.js +7 -2
- package/dist/esm/utils/clientSideData.js +25 -12
- package/dist/esm/utils/columnUtils.js +6 -0
- package/dist/esm/utils/gridRowComparator.js +68 -0
- package/dist/esm/utils/index.js +1 -0
- package/dist/esm/utils/ogridHelpers.js +2 -1
- package/dist/esm/utils/paginationHelpers.js +7 -1
- 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/constants.d.ts +11 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/useCellEditing.d.ts +4 -0
- package/dist/types/hooks/useCellSelection.d.ts +5 -0
- package/dist/types/hooks/useClipboard.d.ts +5 -0
- package/dist/types/hooks/useColumnHeaderFilterState.d.ts +1 -0
- package/dist/types/hooks/useColumnResize.d.ts +5 -0
- package/dist/types/hooks/useContextMenu.d.ts +6 -2
- package/dist/types/hooks/useDataGridState.d.ts +1 -0
- package/dist/types/hooks/useDateFilterState.d.ts +19 -0
- package/dist/types/hooks/useFillHandle.d.ts +5 -0
- package/dist/types/hooks/useKeyboardNavigation.d.ts +38 -25
- 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 +30 -9
- package/dist/types/hooks/usePeopleFilterState.d.ts +25 -0
- package/dist/types/hooks/useRichSelectState.d.ts +5 -0
- package/dist/types/hooks/useRowSelection.d.ts +5 -0
- package/dist/types/hooks/useSideBarState.d.ts +5 -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 +3 -1
- package/dist/types/index.d.ts +11 -4
- package/dist/types/types/columnTypes.d.ts +2 -3
- package/dist/types/types/dataGridTypes.d.ts +32 -4
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/utils/gridRowComparator.d.ts +49 -0
- package/dist/types/utils/index.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import { normalizeSelectionRange } from '../types';
|
|
3
|
+
/** Compares two selection ranges by value. */
|
|
4
|
+
function rangesEqual(a, b) {
|
|
5
|
+
if (a === b)
|
|
6
|
+
return true;
|
|
7
|
+
if (!a || !b)
|
|
8
|
+
return false;
|
|
9
|
+
return a.startRow === b.startRow && a.endRow === b.endRow &&
|
|
10
|
+
a.startCol === b.startCol && a.endCol === b.endCol;
|
|
11
|
+
}
|
|
3
12
|
/** DOM attribute name used for drag-range highlighting (bypasses React). */
|
|
4
13
|
const DRAG_ATTR = 'data-drag-range';
|
|
5
14
|
/** Auto-scroll config */
|
|
@@ -12,11 +21,18 @@ function autoScrollSpeed(distance) {
|
|
|
12
21
|
const t = Math.min(distance / AUTO_SCROLL_EDGE, 1);
|
|
13
22
|
return AUTO_SCROLL_MIN_SPEED + t * (AUTO_SCROLL_MAX_SPEED - AUTO_SCROLL_MIN_SPEED);
|
|
14
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Manages cell selection range with drag-to-select and select-all support.
|
|
26
|
+
* @param params - Row/col counts, active cell setter, and wrapper ref for auto-scroll.
|
|
27
|
+
* @returns Selection range, setters, mouse/keyboard handlers, and drag state.
|
|
28
|
+
*/
|
|
15
29
|
export function useCellSelection(params) {
|
|
16
30
|
const { colOffset, rowCount, visibleColCount, setActiveCell, wrapperRef } = params;
|
|
17
|
-
const [selectionRange,
|
|
31
|
+
const [selectionRange, _setSelectionRange] = useState(null);
|
|
18
32
|
const isDraggingRef = useRef(false);
|
|
19
33
|
const [isDragging, setIsDragging] = useState(false);
|
|
34
|
+
/** True once a mousemove has been seen during the current drag gesture. */
|
|
35
|
+
const dragMovedRef = useRef(false);
|
|
20
36
|
const dragStartRef = useRef(null);
|
|
21
37
|
const rafRef = useRef(0);
|
|
22
38
|
/** Live drag range kept in a ref — only committed to React state on mouseup. */
|
|
@@ -27,6 +43,12 @@ export function useCellSelection(params) {
|
|
|
27
43
|
// without adding selectionRange to its useCallback deps (keeps it stable).
|
|
28
44
|
const selectionRangeRef = useRef(selectionRange);
|
|
29
45
|
selectionRangeRef.current = selectionRange;
|
|
46
|
+
// Deduplicating setter — skips re-render when the range hasn't actually changed.
|
|
47
|
+
const setSelectionRange = useCallback((next) => {
|
|
48
|
+
if (rangesEqual(selectionRangeRef.current, next))
|
|
49
|
+
return;
|
|
50
|
+
_setSelectionRange(next);
|
|
51
|
+
}, []);
|
|
30
52
|
const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
|
|
31
53
|
// Only handle primary (left) button — let middle-click scroll and right-click context menu work natively
|
|
32
54
|
if (e.button !== 0)
|
|
@@ -48,6 +70,7 @@ export function useCellSelection(params) {
|
|
|
48
70
|
}
|
|
49
71
|
else {
|
|
50
72
|
dragStartRef.current = { row: rowIndex, col: dataColIndex };
|
|
73
|
+
dragMovedRef.current = false;
|
|
51
74
|
const initial = {
|
|
52
75
|
startRow: rowIndex,
|
|
53
76
|
startCol: dataColIndex,
|
|
@@ -57,8 +80,10 @@ export function useCellSelection(params) {
|
|
|
57
80
|
setSelectionRange(initial);
|
|
58
81
|
liveDragRangeRef.current = initial;
|
|
59
82
|
setActiveCell({ rowIndex, columnIndex: globalColIndex });
|
|
83
|
+
// Mark drag as "started" but don't set isDragging state yet —
|
|
84
|
+
// setIsDragging(true) is deferred to the first mousemove to avoid
|
|
85
|
+
// a true→false toggle on simple clicks (which causes 2 extra renders).
|
|
60
86
|
isDraggingRef.current = true;
|
|
61
|
-
setIsDragging(true);
|
|
62
87
|
}
|
|
63
88
|
}, [colOffset, setActiveCell]);
|
|
64
89
|
const handleSelectAllCells = useCallback(() => {
|
|
@@ -204,6 +229,12 @@ export function useCellSelection(params) {
|
|
|
204
229
|
const onMove = (e) => {
|
|
205
230
|
if (!isDraggingRef.current || !dragStartRef.current)
|
|
206
231
|
return;
|
|
232
|
+
// Promote to a real drag on first mousemove (deferred from mouseDown
|
|
233
|
+
// to avoid a true→false toggle on simple clicks).
|
|
234
|
+
if (!dragMovedRef.current) {
|
|
235
|
+
dragMovedRef.current = true;
|
|
236
|
+
setIsDragging(true);
|
|
237
|
+
}
|
|
207
238
|
// Always store latest position so mouseUp can flush if RAF hasn't executed
|
|
208
239
|
lastMousePosRef.current = { cx: e.clientX, cy: e.clientY };
|
|
209
240
|
// Update auto-scroll based on mouse proximity to edges
|
|
@@ -242,29 +273,35 @@ export function useCellSelection(params) {
|
|
|
242
273
|
rafRef.current = 0;
|
|
243
274
|
}
|
|
244
275
|
isDraggingRef.current = false;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
276
|
+
const wasDrag = dragMovedRef.current;
|
|
277
|
+
if (wasDrag) {
|
|
278
|
+
// Flush: if the last RAF hasn't executed yet, resolve the range now from the
|
|
279
|
+
// last known mouse position so the final committed range is always accurate.
|
|
280
|
+
const pos = lastMousePosRef.current;
|
|
281
|
+
if (pos) {
|
|
282
|
+
const flushed = resolveRange(pos.cx, pos.cy);
|
|
283
|
+
if (flushed)
|
|
284
|
+
liveDragRangeRef.current = flushed;
|
|
285
|
+
}
|
|
286
|
+
// Commit final range to React state (triggers a single re-render)
|
|
287
|
+
const finalRange = liveDragRangeRef.current;
|
|
288
|
+
if (finalRange) {
|
|
289
|
+
setSelectionRange(finalRange);
|
|
290
|
+
setActiveCell({
|
|
291
|
+
rowIndex: finalRange.endRow,
|
|
292
|
+
columnIndex: finalRange.endCol + colOff,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
261
295
|
}
|
|
296
|
+
// For simple clicks (no drag movement), mouseDown already set
|
|
297
|
+
// selectionRange + activeCell — skip redundant state updates.
|
|
262
298
|
// Clean up DOM attributes — React will apply CSS-module classes on the same paint
|
|
263
299
|
clearDragAttrs();
|
|
264
300
|
liveDragRangeRef.current = null;
|
|
265
301
|
lastMousePosRef.current = null;
|
|
266
302
|
dragStartRef.current = null;
|
|
267
|
-
|
|
303
|
+
if (wasDrag)
|
|
304
|
+
setIsDragging(false);
|
|
268
305
|
};
|
|
269
306
|
window.addEventListener('mousemove', onMove, true);
|
|
270
307
|
window.addEventListener('mouseup', onUp, true);
|
|
@@ -275,7 +312,8 @@ export function useCellSelection(params) {
|
|
|
275
312
|
cancelAnimationFrame(rafRef.current);
|
|
276
313
|
stopAutoScroll();
|
|
277
314
|
};
|
|
278
|
-
|
|
315
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
316
|
+
}, [colOffset, setActiveCell]); // wrapperRef excluded — refs are stable across renders
|
|
279
317
|
return {
|
|
280
318
|
selectionRange,
|
|
281
319
|
setSelectionRange,
|
|
@@ -2,26 +2,41 @@ import { useCallback, useRef, useState } from 'react';
|
|
|
2
2
|
import { getCellValue } from '../utils';
|
|
3
3
|
import { parseValue } from '../utils/valueParsers';
|
|
4
4
|
import { normalizeSelectionRange } from '../types';
|
|
5
|
+
import { useLatestRef } from './useLatestRef';
|
|
6
|
+
/**
|
|
7
|
+
* Manages copy, cut, and paste operations for cell ranges with TSV clipboard format.
|
|
8
|
+
* @param params - Items, columns, selection, editability, and value change callback.
|
|
9
|
+
* @returns Copy/cut/paste handlers, cut/copy ranges, and range clear function.
|
|
10
|
+
*/
|
|
5
11
|
export function useClipboard(params) {
|
|
6
|
-
const {
|
|
12
|
+
const { colOffset, beginBatch, endBatch, } = params;
|
|
13
|
+
// Volatile values accessed via refs — keeps callbacks stable
|
|
14
|
+
const itemsRef = useLatestRef(params.items);
|
|
15
|
+
const visibleColsRef = useLatestRef(params.visibleCols);
|
|
16
|
+
const selectionRangeRef = useLatestRef(params.selectionRange);
|
|
17
|
+
const activeCellRef = useLatestRef(params.activeCell);
|
|
18
|
+
const editableRef = useLatestRef(params.editable);
|
|
19
|
+
const onCellValueChangedRef = useLatestRef(params.onCellValueChanged);
|
|
7
20
|
const cutRangeRef = useRef(null);
|
|
8
21
|
const [cutRange, setCutRange] = useState(null);
|
|
9
22
|
const [copyRange, setCopyRange] = useState(null);
|
|
10
23
|
/** In-page clipboard fallback when system clipboard is unavailable. */
|
|
11
24
|
const internalClipboardRef = useRef(null);
|
|
25
|
+
/** Resolve current effective range from selection or active cell. */
|
|
26
|
+
const getEffectiveRange = useCallback(() => {
|
|
27
|
+
const sel = selectionRangeRef.current;
|
|
28
|
+
const ac = activeCellRef.current;
|
|
29
|
+
return sel ?? (ac != null
|
|
30
|
+
? { startRow: ac.rowIndex, startCol: ac.columnIndex - colOffset, endRow: ac.rowIndex, endCol: ac.columnIndex - colOffset }
|
|
31
|
+
: null);
|
|
32
|
+
}, [colOffset, selectionRangeRef, activeCellRef]);
|
|
12
33
|
const handleCopy = useCallback(() => {
|
|
13
|
-
const range =
|
|
14
|
-
(activeCell != null
|
|
15
|
-
? {
|
|
16
|
-
startRow: activeCell.rowIndex,
|
|
17
|
-
startCol: activeCell.columnIndex - colOffset,
|
|
18
|
-
endRow: activeCell.rowIndex,
|
|
19
|
-
endCol: activeCell.columnIndex - colOffset,
|
|
20
|
-
}
|
|
21
|
-
: null);
|
|
34
|
+
const range = getEffectiveRange();
|
|
22
35
|
if (range == null)
|
|
23
36
|
return;
|
|
24
37
|
const norm = normalizeSelectionRange(range);
|
|
38
|
+
const items = itemsRef.current;
|
|
39
|
+
const visibleCols = visibleColsRef.current;
|
|
25
40
|
const rows = [];
|
|
26
41
|
for (let r = norm.startRow; r <= norm.endRow; r++) {
|
|
27
42
|
const cells = [];
|
|
@@ -40,20 +55,12 @@ export function useClipboard(params) {
|
|
|
40
55
|
internalClipboardRef.current = tsv;
|
|
41
56
|
setCopyRange(norm);
|
|
42
57
|
void navigator.clipboard.writeText(tsv).catch(() => { });
|
|
43
|
-
}, [
|
|
58
|
+
}, [getEffectiveRange, itemsRef, visibleColsRef]);
|
|
44
59
|
const handleCut = useCallback(() => {
|
|
45
|
-
if (
|
|
60
|
+
if (editableRef.current === false)
|
|
46
61
|
return;
|
|
47
|
-
const range =
|
|
48
|
-
|
|
49
|
-
? {
|
|
50
|
-
startRow: activeCell.rowIndex,
|
|
51
|
-
startCol: activeCell.columnIndex - colOffset,
|
|
52
|
-
endRow: activeCell.rowIndex,
|
|
53
|
-
endCol: activeCell.columnIndex - colOffset,
|
|
54
|
-
}
|
|
55
|
-
: null);
|
|
56
|
-
if (range == null || onCellValueChanged == null)
|
|
62
|
+
const range = getEffectiveRange();
|
|
63
|
+
if (range == null || onCellValueChangedRef.current == null)
|
|
57
64
|
return;
|
|
58
65
|
const norm = normalizeSelectionRange(range);
|
|
59
66
|
cutRangeRef.current = norm;
|
|
@@ -62,10 +69,11 @@ export function useClipboard(params) {
|
|
|
62
69
|
handleCopy();
|
|
63
70
|
// handleCopy sets copyRange — override it back since this is a cut
|
|
64
71
|
setCopyRange(null);
|
|
65
|
-
}, [
|
|
72
|
+
}, [getEffectiveRange, handleCopy, editableRef, onCellValueChangedRef]);
|
|
66
73
|
const handlePaste = useCallback(async () => {
|
|
67
|
-
if (
|
|
74
|
+
if (editableRef.current === false)
|
|
68
75
|
return;
|
|
76
|
+
const onCellValueChanged = onCellValueChangedRef.current;
|
|
69
77
|
if (onCellValueChanged == null)
|
|
70
78
|
return;
|
|
71
79
|
let text;
|
|
@@ -80,17 +88,11 @@ export function useClipboard(params) {
|
|
|
80
88
|
}
|
|
81
89
|
if (!text.trim())
|
|
82
90
|
return;
|
|
83
|
-
const norm =
|
|
84
|
-
(activeCell != null
|
|
85
|
-
? {
|
|
86
|
-
startRow: activeCell.rowIndex,
|
|
87
|
-
startCol: activeCell.columnIndex - colOffset,
|
|
88
|
-
endRow: activeCell.rowIndex,
|
|
89
|
-
endCol: activeCell.columnIndex - colOffset,
|
|
90
|
-
}
|
|
91
|
-
: null);
|
|
91
|
+
const norm = getEffectiveRange();
|
|
92
92
|
const anchorRow = norm ? norm.startRow : 0;
|
|
93
93
|
const anchorCol = norm ? norm.startCol : 0;
|
|
94
|
+
const items = itemsRef.current;
|
|
95
|
+
const visibleCols = visibleColsRef.current;
|
|
94
96
|
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
|
95
97
|
beginBatch?.();
|
|
96
98
|
for (let r = 0; r < lines.length; r++) {
|
|
@@ -114,7 +116,6 @@ export function useClipboard(params) {
|
|
|
114
116
|
onCellValueChanged({
|
|
115
117
|
item,
|
|
116
118
|
columnId: col.columnId,
|
|
117
|
-
field: col.columnId,
|
|
118
119
|
oldValue,
|
|
119
120
|
newValue: result.value,
|
|
120
121
|
rowIndex: targetRow,
|
|
@@ -140,7 +141,6 @@ export function useClipboard(params) {
|
|
|
140
141
|
onCellValueChanged({
|
|
141
142
|
item,
|
|
142
143
|
columnId: col.columnId,
|
|
143
|
-
field: col.columnId,
|
|
144
144
|
oldValue,
|
|
145
145
|
newValue: result.value,
|
|
146
146
|
rowIndex: r,
|
|
@@ -152,7 +152,7 @@ export function useClipboard(params) {
|
|
|
152
152
|
}
|
|
153
153
|
endBatch?.();
|
|
154
154
|
setCopyRange(null);
|
|
155
|
-
}, [
|
|
155
|
+
}, [getEffectiveRange, itemsRef, visibleColsRef, editableRef, onCellValueChangedRef, beginBatch, endBatch]);
|
|
156
156
|
const clearClipboardRanges = useCallback(() => {
|
|
157
157
|
setCopyRange(null);
|
|
158
158
|
setCutRange(null);
|
|
@@ -1,48 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Headless column header filter state and handlers for Fluent, Material, and Radix.
|
|
3
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.
|
|
4
5
|
*/
|
|
5
6
|
import { useState, useCallback, useRef, useEffect, useMemo, } from 'react';
|
|
6
|
-
import {
|
|
7
|
-
|
|
7
|
+
import { useTextFilterState } from './useTextFilterState';
|
|
8
|
+
import { useMultiSelectFilterState } from './useMultiSelectFilterState';
|
|
9
|
+
import { usePeopleFilterState } from './usePeopleFilterState';
|
|
10
|
+
import { useDateFilterState } from './useDateFilterState';
|
|
8
11
|
const EMPTY_OPTIONS = [];
|
|
9
12
|
export function useColumnHeaderFilterState(params) {
|
|
10
13
|
const { filterType, onSort, selectedValues, onFilterChange, options, textValue = '', onTextChange, selectedUser, onUserChange, peopleSearch, dateValue, onDateChange, } = params;
|
|
11
14
|
const safeSelectedValues = selectedValues ?? EMPTY_OPTIONS;
|
|
12
|
-
|
|
15
|
+
// Shared state
|
|
13
16
|
const headerRef = useRef(null);
|
|
14
17
|
const popoverRef = useRef(null);
|
|
15
|
-
const peopleInputRef = useRef(null);
|
|
16
|
-
const peopleSearchTimeoutRef = useRef(undefined);
|
|
17
18
|
const [isFilterOpen, setFilterOpen] = useState(false);
|
|
18
|
-
const [tempSelected, setTempSelected] = useState(() => new Set(safeSelectedValues));
|
|
19
|
-
const [tempTextValue, setTempTextValue] = useState(textValue);
|
|
20
|
-
const [searchText, setSearchText] = useState('');
|
|
21
|
-
const debouncedSearchText = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
|
|
22
|
-
const [peopleSuggestions, setPeopleSuggestions] = useState([]);
|
|
23
|
-
const [isPeopleLoading, setIsPeopleLoading] = useState(false);
|
|
24
|
-
const [peopleSearchText, setPeopleSearchText] = useState('');
|
|
25
|
-
const [tempDateFrom, setTempDateFrom] = useState(dateValue?.from ?? '');
|
|
26
|
-
const [tempDateTo, setTempDateTo] = useState(dateValue?.to ?? '');
|
|
27
19
|
const [popoverPosition, setPopoverPosition] = useState(null);
|
|
28
|
-
//
|
|
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
|
|
29
45
|
useEffect(() => {
|
|
30
|
-
if (isFilterOpen) {
|
|
31
|
-
setTempSelected(new Set(safeSelectedValues));
|
|
32
|
-
setTempTextValue(textValue);
|
|
33
|
-
setTempDateFrom(dateValue?.from ?? '');
|
|
34
|
-
setTempDateTo(dateValue?.to ?? '');
|
|
35
|
-
setSearchText('');
|
|
36
|
-
setPeopleSearchText('');
|
|
37
|
-
setPeopleSuggestions([]);
|
|
38
|
-
if (filterType === 'people') {
|
|
39
|
-
setTimeout(() => peopleInputRef.current?.focus(), 50);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
else {
|
|
46
|
+
if (!isFilterOpen) {
|
|
43
47
|
setPopoverPosition(null);
|
|
44
48
|
}
|
|
45
|
-
}, [isFilterOpen
|
|
49
|
+
}, [isFilterOpen]);
|
|
46
50
|
// Click outside and Escape to close
|
|
47
51
|
useEffect(() => {
|
|
48
52
|
if (!isFilterOpen)
|
|
@@ -71,41 +75,7 @@ export function useColumnHeaderFilterState(params) {
|
|
|
71
75
|
document.removeEventListener('keydown', handleKeyDown, true);
|
|
72
76
|
};
|
|
73
77
|
}, [isFilterOpen]);
|
|
74
|
-
//
|
|
75
|
-
const filteredOptions = useMemo(() => {
|
|
76
|
-
if (!debouncedSearchText.trim())
|
|
77
|
-
return safeOptions;
|
|
78
|
-
const searchLower = debouncedSearchText.toLowerCase().trim();
|
|
79
|
-
return safeOptions.filter((opt) => opt.toLowerCase().includes(searchLower));
|
|
80
|
-
}, [safeOptions, debouncedSearchText]);
|
|
81
|
-
// People search
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (!peopleSearch || !isFilterOpen || filterType !== 'people')
|
|
84
|
-
return;
|
|
85
|
-
if (peopleSearchTimeoutRef.current)
|
|
86
|
-
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
87
|
-
if (!peopleSearchText.trim()) {
|
|
88
|
-
setPeopleSuggestions([]);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
setIsPeopleLoading(true);
|
|
92
|
-
peopleSearchTimeoutRef.current = window.setTimeout(async () => {
|
|
93
|
-
try {
|
|
94
|
-
const results = await peopleSearch(peopleSearchText);
|
|
95
|
-
setPeopleSuggestions(results.slice(0, 10));
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
setPeopleSuggestions([]);
|
|
99
|
-
}
|
|
100
|
-
finally {
|
|
101
|
-
setIsPeopleLoading(false);
|
|
102
|
-
}
|
|
103
|
-
}, 300);
|
|
104
|
-
return () => {
|
|
105
|
-
if (peopleSearchTimeoutRef.current)
|
|
106
|
-
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
107
|
-
};
|
|
108
|
-
}, [peopleSearchText, peopleSearch, isFilterOpen, filterType]);
|
|
78
|
+
// Shared handlers
|
|
109
79
|
const handleFilterIconClick = useCallback((e) => {
|
|
110
80
|
e.stopPropagation();
|
|
111
81
|
e.preventDefault();
|
|
@@ -121,47 +91,28 @@ export function useColumnHeaderFilterState(params) {
|
|
|
121
91
|
e.stopPropagation();
|
|
122
92
|
onSort?.();
|
|
123
93
|
}, [onSort]);
|
|
124
|
-
|
|
125
|
-
setTempSelected((prev) => {
|
|
126
|
-
const next = new Set(prev);
|
|
127
|
-
if (checked)
|
|
128
|
-
next.add(option);
|
|
129
|
-
else
|
|
130
|
-
next.delete(option);
|
|
131
|
-
return next;
|
|
132
|
-
});
|
|
133
|
-
}, []);
|
|
134
|
-
const handleSelectAll = useCallback(() => {
|
|
135
|
-
setTempSelected(new Set(filteredOptions));
|
|
136
|
-
}, [filteredOptions]);
|
|
137
|
-
const handleClearSelection = useCallback(() => setTempSelected(new Set()), []);
|
|
94
|
+
// Wrap sub-hook handlers to close popover
|
|
138
95
|
const handleApplyMultiSelect = useCallback(() => {
|
|
139
|
-
|
|
96
|
+
multiSelectFilterState.handleApplyMultiSelect();
|
|
140
97
|
setFilterOpen(false);
|
|
141
|
-
}, [
|
|
98
|
+
}, [multiSelectFilterState]);
|
|
142
99
|
const handleTextApply = useCallback(() => {
|
|
143
|
-
|
|
100
|
+
textFilterState.handleTextApply();
|
|
144
101
|
setFilterOpen(false);
|
|
145
|
-
}, [
|
|
146
|
-
const handleTextClear = useCallback(() => setTempTextValue(''), []);
|
|
102
|
+
}, [textFilterState]);
|
|
147
103
|
const handleUserSelect = useCallback((user) => {
|
|
148
|
-
|
|
104
|
+
peopleFilterState.handleUserSelect(user);
|
|
149
105
|
setFilterOpen(false);
|
|
150
|
-
}, [
|
|
151
|
-
const handleDateApply = useCallback(() => {
|
|
152
|
-
const from = tempDateFrom || undefined;
|
|
153
|
-
const to = tempDateTo || undefined;
|
|
154
|
-
onDateChange?.(from || to ? { from, to } : undefined);
|
|
155
|
-
setFilterOpen(false);
|
|
156
|
-
}, [onDateChange, tempDateFrom, tempDateTo]);
|
|
157
|
-
const handleDateClear = useCallback(() => {
|
|
158
|
-
setTempDateFrom('');
|
|
159
|
-
setTempDateTo('');
|
|
160
|
-
}, []);
|
|
106
|
+
}, [peopleFilterState]);
|
|
161
107
|
const handleClearUser = useCallback(() => {
|
|
162
|
-
|
|
108
|
+
peopleFilterState.handleClearUser();
|
|
109
|
+
setFilterOpen(false);
|
|
110
|
+
}, [peopleFilterState]);
|
|
111
|
+
const handleDateApply = useCallback(() => {
|
|
112
|
+
dateFilterState.handleDateApply();
|
|
163
113
|
setFilterOpen(false);
|
|
164
|
-
}, [
|
|
114
|
+
}, [dateFilterState]);
|
|
115
|
+
// Event propagation stoppers
|
|
165
116
|
const handlePopoverClick = useCallback((e) => e.stopPropagation(), []);
|
|
166
117
|
const handleInputFocus = useCallback((e) => e.stopPropagation(), []);
|
|
167
118
|
const handleInputMouseDown = useCallback((e) => e.stopPropagation(), []);
|
|
@@ -170,6 +121,7 @@ export function useColumnHeaderFilterState(params) {
|
|
|
170
121
|
if (e.key !== 'Escape' && e.key !== 'Esc')
|
|
171
122
|
e.stopPropagation();
|
|
172
123
|
}, []);
|
|
124
|
+
// Compute hasActiveFilter from all sub-hooks
|
|
173
125
|
const hasActiveFilter = useMemo(() => {
|
|
174
126
|
if (filterType === 'multiSelect')
|
|
175
127
|
return safeSelectedValues.length > 0;
|
|
@@ -184,39 +136,39 @@ export function useColumnHeaderFilterState(params) {
|
|
|
184
136
|
return {
|
|
185
137
|
headerRef,
|
|
186
138
|
popoverRef,
|
|
187
|
-
peopleInputRef,
|
|
139
|
+
peopleInputRef: peopleFilterState.peopleInputRef,
|
|
188
140
|
isFilterOpen,
|
|
189
141
|
setFilterOpen,
|
|
190
|
-
tempSelected,
|
|
191
|
-
setTempSelected,
|
|
192
|
-
tempTextValue,
|
|
193
|
-
setTempTextValue,
|
|
194
|
-
searchText,
|
|
195
|
-
setSearchText,
|
|
196
|
-
debouncedSearchText,
|
|
197
|
-
filteredOptions,
|
|
198
|
-
peopleSuggestions,
|
|
199
|
-
isPeopleLoading,
|
|
200
|
-
peopleSearchText,
|
|
201
|
-
setPeopleSearchText,
|
|
202
|
-
tempDateFrom,
|
|
203
|
-
setTempDateFrom,
|
|
204
|
-
tempDateTo,
|
|
205
|
-
setTempDateTo,
|
|
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,
|
|
206
158
|
hasActiveFilter,
|
|
207
159
|
popoverPosition,
|
|
208
160
|
handlers: {
|
|
209
161
|
handleFilterIconClick,
|
|
210
162
|
handleApplyMultiSelect,
|
|
211
163
|
handleTextApply,
|
|
212
|
-
handleTextClear,
|
|
164
|
+
handleTextClear: textFilterState.handleTextClear,
|
|
213
165
|
handleUserSelect,
|
|
214
166
|
handleClearUser,
|
|
215
167
|
handleDateApply,
|
|
216
|
-
handleDateClear,
|
|
217
|
-
handleCheckboxChange,
|
|
218
|
-
handleSelectAll,
|
|
219
|
-
handleClearSelection,
|
|
168
|
+
handleDateClear: dateFilterState.handleDateClear,
|
|
169
|
+
handleCheckboxChange: multiSelectFilterState.handleCheckboxChange,
|
|
170
|
+
handleSelectAll: multiSelectFilterState.handleSelectAll,
|
|
171
|
+
handleClearSelection: multiSelectFilterState.handleClearSelection,
|
|
220
172
|
handlePopoverClick,
|
|
221
173
|
handleInputFocus,
|
|
222
174
|
handleInputMouseDown,
|
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
import { useCallback, useRef } from 'react';
|
|
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
|
+
*/
|
|
2
8
|
export function useColumnResize({ columnSizingOverrides, setColumnSizingOverrides, minWidth = 80, defaultWidth = 120, onColumnResized, }) {
|
|
3
9
|
const rafRef = useRef(0);
|
|
4
10
|
const onColumnResizedRef = useRef(onColumnResized);
|
|
5
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
|
+
}, []);
|
|
6
23
|
const handleResizeStart = useCallback((e, col) => {
|
|
7
24
|
e.preventDefault();
|
|
8
25
|
e.stopPropagation();
|
|
@@ -14,7 +31,7 @@ export function useColumnResize({ columnSizingOverrides, setColumnSizingOverride
|
|
|
14
31
|
const thEl = e.currentTarget.parentElement;
|
|
15
32
|
const startWidth = thEl
|
|
16
33
|
? thEl.getBoundingClientRect().width
|
|
17
|
-
:
|
|
34
|
+
: columnSizingOverridesRef.current[columnId]?.widthPx
|
|
18
35
|
?? col.idealWidth
|
|
19
36
|
?? col.defaultWidth
|
|
20
37
|
?? defaultWidth;
|
|
@@ -40,9 +57,10 @@ export function useColumnResize({ columnSizingOverrides, setColumnSizingOverride
|
|
|
40
57
|
});
|
|
41
58
|
}
|
|
42
59
|
};
|
|
43
|
-
const
|
|
60
|
+
const cleanup = () => {
|
|
44
61
|
document.removeEventListener('mousemove', onMove);
|
|
45
62
|
document.removeEventListener('mouseup', onUp);
|
|
63
|
+
cleanupRef.current = null;
|
|
46
64
|
// Restore cursor and user-select
|
|
47
65
|
document.body.style.cursor = prevCursor;
|
|
48
66
|
document.body.style.userSelect = prevUserSelect;
|
|
@@ -51,6 +69,9 @@ export function useColumnResize({ columnSizingOverrides, setColumnSizingOverride
|
|
|
51
69
|
cancelAnimationFrame(rafRef.current);
|
|
52
70
|
rafRef.current = 0;
|
|
53
71
|
}
|
|
72
|
+
};
|
|
73
|
+
const onUp = () => {
|
|
74
|
+
cleanup();
|
|
54
75
|
flushWidth();
|
|
55
76
|
if (onColumnResizedRef.current) {
|
|
56
77
|
onColumnResizedRef.current(columnId, latestWidth);
|
|
@@ -58,7 +79,9 @@ export function useColumnResize({ columnSizingOverrides, setColumnSizingOverride
|
|
|
58
79
|
};
|
|
59
80
|
document.addEventListener('mousemove', onMove);
|
|
60
81
|
document.addEventListener('mouseup', onUp);
|
|
61
|
-
|
|
82
|
+
cleanupRef.current = cleanup;
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [defaultWidth, minWidth, setColumnSizingOverrides]); // columnSizingOverrides read via ref
|
|
62
85
|
const getColumnWidth = useCallback((col) => {
|
|
63
86
|
return columnSizingOverrides[col.columnId]?.widthPx
|
|
64
87
|
?? col.idealWidth
|
|
@@ -1,16 +1,20 @@
|
|
|
1
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
|
+
*/
|
|
2
6
|
export function useContextMenu() {
|
|
3
|
-
const [
|
|
7
|
+
const [contextMenuPosition, setContextMenuPosition] = useState(null);
|
|
4
8
|
const handleCellContextMenu = useCallback((e) => {
|
|
5
9
|
e.preventDefault?.();
|
|
6
|
-
|
|
10
|
+
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
|
7
11
|
}, []);
|
|
8
12
|
const closeContextMenu = useCallback(() => {
|
|
9
|
-
|
|
13
|
+
setContextMenuPosition(null);
|
|
10
14
|
}, []);
|
|
11
15
|
return {
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
contextMenuPosition,
|
|
17
|
+
setContextMenuPosition,
|
|
14
18
|
handleCellContextMenu,
|
|
15
19
|
closeContextMenu,
|
|
16
20
|
};
|