@opencorestack/opengridx 0.1.7 → 0.1.9
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/CHANGELOG.md +31 -1
- package/dist/index.d.ts +6 -1
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +3780 -3610
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +12 -12
- package/dist/opengridx.umd.js.map +1 -1
- package/lib/components/Cell/Cell.tsx +53 -55
- package/lib/components/DataGrid/DataGrid.smoke.test.tsx +58 -0
- package/lib/components/DataGrid/DataGrid.tsx +96 -656
- package/lib/components/FilterPanel/FilterPanel.tsx +46 -15
- package/lib/components/Row/Row.tsx +39 -38
- package/lib/components/Toolbar/GlobalSearch.css +0 -3
- package/lib/components/Toolbar/GlobalSearch.tsx +78 -31
- package/lib/components/Toolbar/GridToolbar.css +17 -0
- package/lib/components/Toolbar/GridToolbar.tsx +54 -28
- package/lib/components/Toolbar/PivotPanel.tsx +7 -2
- package/lib/components/ui/DragHandleIcon.tsx +1 -1
- package/lib/hooks/core/useDataGrid.ts +2 -2
- package/lib/hooks/core/useGridKeyboardNavigation.ts +371 -0
- package/lib/hooks/core/useLayout.ts +354 -0
- package/lib/hooks/features/useAggregation.test.ts +135 -0
- package/lib/hooks/features/useGridClipboard.ts +1 -1
- package/lib/hooks/features/useGridDataSource.ts +1 -1
- package/lib/hooks/features/usePivot.ts +25 -0
- package/lib/state/useGridStateStorage.ts +1 -1
- package/lib/utils/export/index.ts +1 -1
- package/lib/utils/filtering/filtering.test.ts +178 -0
- package/lib/utils/sorting/sorting.test.ts +88 -0
- package/package.json +10 -4
- package/lib/utils/virtualization/index.ts +0 -106
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
GridFilterModel,
|
|
4
4
|
GridFilterItem,
|
|
@@ -29,6 +29,37 @@ const FilterRow: React.FC<{
|
|
|
29
29
|
const isActive = !!item;
|
|
30
30
|
const currentOperator = item?.operator ?? operators[0];
|
|
31
31
|
|
|
32
|
+
// Local input state to prevent full filter-panel re-render on every keystroke
|
|
33
|
+
const [localValue, setLocalValue] = useState(item?.value ?? '');
|
|
34
|
+
const onChangeRef = useRef(onChange);
|
|
35
|
+
onChangeRef.current = onChange;
|
|
36
|
+
|
|
37
|
+
// Sync when the external item changes (e.g. operator change, clear)
|
|
38
|
+
const prevItemValue = useRef(item?.value);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (item?.value !== prevItemValue.current) {
|
|
41
|
+
prevItemValue.current = item?.value;
|
|
42
|
+
setLocalValue(item?.value ?? '');
|
|
43
|
+
}
|
|
44
|
+
}, [item?.value]);
|
|
45
|
+
|
|
46
|
+
// Debounce propagating the value upward (300ms)
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const handler = setTimeout(() => {
|
|
49
|
+
const currentValue = item?.value ?? '';
|
|
50
|
+
if (localValue !== currentValue) {
|
|
51
|
+
onChangeRef.current({
|
|
52
|
+
id: item?.id ?? `${col.field}-${Date.now()}`,
|
|
53
|
+
field: col.field,
|
|
54
|
+
operator: currentOperator as GridFilterOperator,
|
|
55
|
+
value: localValue,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}, 300);
|
|
59
|
+
return () => clearTimeout(handler);
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
|
+
}, [localValue]);
|
|
62
|
+
|
|
32
63
|
const handleOperatorChange = (op: string) => {
|
|
33
64
|
const noValue = NO_VALUE_OPERATORS.has(op);
|
|
34
65
|
const newItem: GridFilterItem = {
|
|
@@ -40,16 +71,8 @@ const FilterRow: React.FC<{
|
|
|
40
71
|
onChange(newItem);
|
|
41
72
|
};
|
|
42
73
|
|
|
43
|
-
const handleValueChange = (value: string) => {
|
|
44
|
-
onChange({
|
|
45
|
-
id: item?.id ?? `${col.field}-${Date.now()}`,
|
|
46
|
-
field: col.field,
|
|
47
|
-
operator: currentOperator as GridFilterOperator,
|
|
48
|
-
value,
|
|
49
|
-
});
|
|
50
|
-
};
|
|
51
|
-
|
|
52
74
|
const handleClear = () => {
|
|
75
|
+
setLocalValue('');
|
|
53
76
|
onChange(null);
|
|
54
77
|
};
|
|
55
78
|
|
|
@@ -87,7 +110,15 @@ const FilterRow: React.FC<{
|
|
|
87
110
|
name={`filter-val-${col.field}`}
|
|
88
111
|
className={`ogx-filter__value-input ogx-filter__value-select${isActive ? ' ogx-filter__value-input--active' : ''}`}
|
|
89
112
|
value={String(item?.value ?? 'true')}
|
|
90
|
-
onChange={(e) =>
|
|
113
|
+
onChange={(e) => {
|
|
114
|
+
setLocalValue(e.target.value);
|
|
115
|
+
onChange({
|
|
116
|
+
id: item?.id ?? `${col.field}-${Date.now()}`,
|
|
117
|
+
field: col.field,
|
|
118
|
+
operator: currentOperator as GridFilterOperator,
|
|
119
|
+
value: e.target.value,
|
|
120
|
+
});
|
|
121
|
+
}}
|
|
91
122
|
aria-label={`Filter value for ${col.headerName || col.field}`}
|
|
92
123
|
>
|
|
93
124
|
<option value="true">true</option>
|
|
@@ -100,10 +131,10 @@ const FilterRow: React.FC<{
|
|
|
100
131
|
id={`ogx-filter-val-${col.field}`}
|
|
101
132
|
name={`filter-val-${col.field}`}
|
|
102
133
|
className={`ogx-filter__value-input${isActive ? ' ogx-filter__value-input--active' : ''}`}
|
|
103
|
-
type=
|
|
104
|
-
value={
|
|
105
|
-
onChange={(e) =>
|
|
106
|
-
placeholder=
|
|
134
|
+
type="text"
|
|
135
|
+
value={localValue}
|
|
136
|
+
onChange={(e) => setLocalValue(e.target.value)}
|
|
137
|
+
placeholder={col.type === 'number' ? 'Number…' : 'Value…'}
|
|
107
138
|
aria-label={`Filter value for ${col.headerName || col.field}`}
|
|
108
139
|
/>
|
|
109
140
|
)}
|
|
@@ -97,6 +97,45 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
97
97
|
const expandCellRef = React.useRef<HTMLDivElement>(null);
|
|
98
98
|
const checkboxCellRef = React.useRef<HTMLDivElement>(null);
|
|
99
99
|
|
|
100
|
+
// All hooks must come before any early returns (Rules of Hooks)
|
|
101
|
+
React.useLayoutEffect(() => {
|
|
102
|
+
if (focusedCellField === '__expand_col__' && expandCellRef.current) {
|
|
103
|
+
expandCellRef.current.focus({ preventScroll: true });
|
|
104
|
+
}
|
|
105
|
+
if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
|
|
106
|
+
checkboxCellRef.current.focus({ preventScroll: true });
|
|
107
|
+
}
|
|
108
|
+
}, [focusedCellField]);
|
|
109
|
+
|
|
110
|
+
const pinnedPositions = React.useMemo(() => {
|
|
111
|
+
return calculatePinnedPositions(
|
|
112
|
+
columns,
|
|
113
|
+
columnWidths,
|
|
114
|
+
pinnedColumns,
|
|
115
|
+
checkboxSelection,
|
|
116
|
+
pinCheckboxColumn,
|
|
117
|
+
hasDetailPanel,
|
|
118
|
+
pinExpandColumn,
|
|
119
|
+
rowReordering
|
|
120
|
+
);
|
|
121
|
+
}, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
|
|
122
|
+
|
|
123
|
+
const isGroupRow = (row as any)._hasChildren === true;
|
|
124
|
+
|
|
125
|
+
const handleCellEditStart = React.useCallback((field: string, value: any) => {
|
|
126
|
+
if (!isGroupRow) {
|
|
127
|
+
onEditStart?.({ id: row.id, field, value });
|
|
128
|
+
}
|
|
129
|
+
}, [isGroupRow, row.id, onEditStart]);
|
|
130
|
+
|
|
131
|
+
const handleCellValueChange = React.useCallback((field: string, newValue: any) => {
|
|
132
|
+
onEditCellValueChange?.({ id: row.id, field, value: newValue });
|
|
133
|
+
}, [onEditCellValueChange, row.id]);
|
|
134
|
+
|
|
135
|
+
const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
|
|
136
|
+
onEditStop?.({ cancel });
|
|
137
|
+
}, [onEditStop]);
|
|
138
|
+
|
|
100
139
|
// ── Skeleton row (shown during infinite-scroll fetch) ─────────────────────
|
|
101
140
|
if ((row as any)._isSkeleton) {
|
|
102
141
|
// Render shimmer placeholders — one per visible column, matching row height
|
|
@@ -117,15 +156,6 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
117
156
|
);
|
|
118
157
|
}
|
|
119
158
|
|
|
120
|
-
React.useLayoutEffect(() => {
|
|
121
|
-
if (focusedCellField === '__expand_col__' && expandCellRef.current) {
|
|
122
|
-
expandCellRef.current.focus({ preventScroll: true });
|
|
123
|
-
}
|
|
124
|
-
if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
|
|
125
|
-
checkboxCellRef.current.focus({ preventScroll: true });
|
|
126
|
-
}
|
|
127
|
-
}, [focusedCellField]);
|
|
128
|
-
|
|
129
159
|
const handleRowClick = (event: React.MouseEvent) => {
|
|
130
160
|
|
|
131
161
|
if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell')) {
|
|
@@ -144,39 +174,10 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
144
174
|
onDetailPanelToggle?.(row.id);
|
|
145
175
|
};
|
|
146
176
|
|
|
147
|
-
const pinnedPositions = React.useMemo(() => {
|
|
148
|
-
return calculatePinnedPositions(
|
|
149
|
-
columns,
|
|
150
|
-
columnWidths,
|
|
151
|
-
pinnedColumns,
|
|
152
|
-
checkboxSelection,
|
|
153
|
-
pinCheckboxColumn,
|
|
154
|
-
hasDetailPanel,
|
|
155
|
-
pinExpandColumn,
|
|
156
|
-
rowReordering
|
|
157
|
-
);
|
|
158
|
-
}, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
|
|
159
|
-
|
|
160
177
|
const rowPinnedPosition = isRowPinned(row.id, pinnedRows);
|
|
161
178
|
const isRowPinnedTop = rowPinnedPosition === 'top';
|
|
162
179
|
const isRowPinnedBottom = rowPinnedPosition === 'bottom';
|
|
163
180
|
|
|
164
|
-
const isGroupRow = (row as any)._hasChildren === true;
|
|
165
|
-
|
|
166
|
-
const handleCellEditStart = React.useCallback((field: string, value: any) => {
|
|
167
|
-
if (!isGroupRow) {
|
|
168
|
-
onEditStart?.({ id: row.id, field, value });
|
|
169
|
-
}
|
|
170
|
-
}, [isGroupRow, row.id, onEditStart]);
|
|
171
|
-
|
|
172
|
-
const handleCellValueChange = React.useCallback((field: string, newValue: any) => {
|
|
173
|
-
onEditCellValueChange?.({ id: row.id, field, value: newValue });
|
|
174
|
-
}, [onEditCellValueChange, row.id]);
|
|
175
|
-
|
|
176
|
-
const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
|
|
177
|
-
onEditStop?.({ cancel });
|
|
178
|
-
}, [onEditStop]);
|
|
179
|
-
|
|
180
181
|
const classNames = [
|
|
181
182
|
'ogx__row',
|
|
182
183
|
isSelected && 'ogx__row--selected',
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
|
|
41
41
|
/* ─── Icon wrapper ────────────────────────────────────────────────────────── */
|
|
42
42
|
.ogx-global-search__icon-wrapper {
|
|
43
|
-
/* Fixed 34×34 square — never shrinks so the icon is always centred */
|
|
44
43
|
flex: 0 0 34px;
|
|
45
44
|
width: 34px;
|
|
46
45
|
height: 34px;
|
|
@@ -51,7 +50,6 @@
|
|
|
51
50
|
pointer-events: none;
|
|
52
51
|
flex-shrink: 0;
|
|
53
52
|
transition: color 0.15s;
|
|
54
|
-
/* No border, no background — the unit looks seamless */
|
|
55
53
|
}
|
|
56
54
|
.ogx-global-search--expanded .ogx-global-search__icon-wrapper {
|
|
57
55
|
color: var(--ogx-color-primary, #3b82f6);
|
|
@@ -74,7 +72,6 @@
|
|
|
74
72
|
font-size: var(--ogx-font-size-sm, 13px);
|
|
75
73
|
color: var(--ogx-grid-row-text, #1e293b);
|
|
76
74
|
outline: none;
|
|
77
|
-
/* Hide with opacity, not display:none, so focus events still fire */
|
|
78
75
|
opacity: 0;
|
|
79
76
|
pointer-events: none;
|
|
80
77
|
transition: opacity 0.18s ease;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
interface GlobalSearchProps {
|
|
@@ -27,48 +27,102 @@ function CloseIcon() {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function GlobalSearch({ value = '', onChange, placeholder = 'Search...', debounceMs = 250 }: GlobalSearchProps) {
|
|
30
|
-
const [isExpanded, setIsExpanded] = useState(
|
|
30
|
+
const [isExpanded, setIsExpanded] = useState(() => value.length > 0);
|
|
31
|
+
// Fully uncontrolled: localValue is the single source of truth.
|
|
31
32
|
const [localValue, setLocalValue] = useState(value);
|
|
32
33
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
33
34
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
// Keep onChange always current without adding it to effect deps
|
|
36
|
+
const onChangeRef = useRef(onChange);
|
|
37
|
+
useLayoutEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
|
38
|
+
// Track the last value we emitted upward
|
|
39
|
+
const lastEmittedRef = useRef(value);
|
|
40
|
+
// Track the last incoming prop value to detect external resets
|
|
41
|
+
const lastIncomingValueRef = useRef(value);
|
|
42
|
+
// Track whether input was focused BEFORE a re-render so we can restore it
|
|
43
|
+
const wasFocusedRef = useRef(false);
|
|
34
44
|
|
|
35
|
-
//
|
|
45
|
+
// ── Focus preservation across re-renders ─────────────────────────────────
|
|
46
|
+
// When the parent updates filterModel (triggered by our debounce), React
|
|
47
|
+
// re-renders the entire tree. Even though the input element stays mounted,
|
|
48
|
+
// the browser can lose focus during heavy reconciliation. We detect this
|
|
49
|
+
// via useLayoutEffect (runs synchronously after DOM mutations, before paint)
|
|
50
|
+
// and immediately restore focus without any visual flicker.
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
if (wasFocusedRef.current && document.activeElement !== inputRef.current) {
|
|
53
|
+
inputRef.current?.focus();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── External reset detection ──────────────────────────────────────────────
|
|
58
|
+
// Only responds when the PARENT programmatically changes the value
|
|
59
|
+
// (e.g. "Clear all filters" button) — not from our own typing.
|
|
36
60
|
useEffect(() => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
if (value === lastIncomingValueRef.current) return;
|
|
62
|
+
lastIncomingValueRef.current = value;
|
|
63
|
+
|
|
64
|
+
if (value !== lastEmittedRef.current) {
|
|
65
|
+
setLocalValue(value);
|
|
66
|
+
lastEmittedRef.current = value;
|
|
67
|
+
if (!value) {
|
|
68
|
+
wasFocusedRef.current = false;
|
|
69
|
+
setIsExpanded(false);
|
|
70
|
+
} else {
|
|
71
|
+
setIsExpanded(true);
|
|
72
|
+
}
|
|
40
73
|
}
|
|
41
|
-
}, [value
|
|
74
|
+
}, [value]);
|
|
42
75
|
|
|
43
|
-
//
|
|
76
|
+
// ── Debounced upward notification ─────────────────────────────────────────
|
|
44
77
|
useEffect(() => {
|
|
45
78
|
const handler = setTimeout(() => {
|
|
46
|
-
if (localValue !==
|
|
47
|
-
|
|
79
|
+
if (localValue !== lastEmittedRef.current) {
|
|
80
|
+
lastEmittedRef.current = localValue;
|
|
81
|
+
onChangeRef.current(localValue);
|
|
48
82
|
}
|
|
49
83
|
}, debounceMs);
|
|
50
84
|
return () => clearTimeout(handler);
|
|
51
|
-
}, [localValue,
|
|
85
|
+
}, [localValue, debounceMs]);
|
|
52
86
|
|
|
53
|
-
const
|
|
87
|
+
const handleExpand = useCallback(() => {
|
|
54
88
|
setIsExpanded(true);
|
|
55
|
-
|
|
89
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
90
|
+
}, []);
|
|
56
91
|
|
|
57
|
-
const handleClear = (e: React.MouseEvent) => {
|
|
92
|
+
const handleClear = useCallback((e: React.MouseEvent) => {
|
|
58
93
|
e.stopPropagation();
|
|
59
94
|
setLocalValue('');
|
|
95
|
+
lastEmittedRef.current = '';
|
|
96
|
+
onChangeRef.current('');
|
|
60
97
|
inputRef.current?.focus();
|
|
61
|
-
|
|
62
|
-
};
|
|
98
|
+
}, []);
|
|
63
99
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
};
|
|
100
|
+
const handleFocus = useCallback(() => {
|
|
101
|
+
wasFocusedRef.current = true;
|
|
102
|
+
setIsExpanded(true);
|
|
103
|
+
}, []);
|
|
70
104
|
|
|
71
|
-
//
|
|
105
|
+
// Blur: defer 150ms to allow focus to move within the container first
|
|
106
|
+
// (e.g. clicking the clear button) before deciding to collapse.
|
|
107
|
+
const handleBlur = useCallback(() => {
|
|
108
|
+
wasFocusedRef.current = false;
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
if (
|
|
111
|
+
containerRef.current &&
|
|
112
|
+
document.activeElement &&
|
|
113
|
+
containerRef.current.contains(document.activeElement)
|
|
114
|
+
) {
|
|
115
|
+
// Focus is still inside — mark as focused again and abort
|
|
116
|
+
wasFocusedRef.current = true;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!localValue) {
|
|
120
|
+
setIsExpanded(false);
|
|
121
|
+
}
|
|
122
|
+
}, 150);
|
|
123
|
+
}, [localValue]);
|
|
124
|
+
|
|
125
|
+
// ── Click outside ─────────────────────────────────────────────────────────
|
|
72
126
|
useEffect(() => {
|
|
73
127
|
const handleClickOutside = (event: MouseEvent) => {
|
|
74
128
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
@@ -77,7 +131,6 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
77
131
|
}
|
|
78
132
|
}
|
|
79
133
|
};
|
|
80
|
-
|
|
81
134
|
document.addEventListener('mousedown', handleClickOutside);
|
|
82
135
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
83
136
|
}, [localValue]);
|
|
@@ -86,13 +139,7 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
86
139
|
<div
|
|
87
140
|
ref={containerRef}
|
|
88
141
|
className={`ogx-global-search${isExpanded ? ' ogx-global-search--expanded' : ''}`}
|
|
89
|
-
onClick={
|
|
90
|
-
if (!isExpanded) {
|
|
91
|
-
setIsExpanded(true);
|
|
92
|
-
|
|
93
|
-
setTimeout(() => inputRef.current?.focus(), 50);
|
|
94
|
-
}
|
|
95
|
-
}}
|
|
142
|
+
onClick={!isExpanded ? handleExpand : undefined}
|
|
96
143
|
>
|
|
97
144
|
<div className="ogx-global-search__icon-wrapper">
|
|
98
145
|
<SearchIcon />
|
|
@@ -117,6 +117,23 @@
|
|
|
117
117
|
background: var(--ogx-toolbar-btn-danger-bg, rgba(239, 68, 68, 0.08));
|
|
118
118
|
border-color: var(--ogx-color-error, #ef4444);
|
|
119
119
|
}
|
|
120
|
+
.ogx-toolbar__close-btn {
|
|
121
|
+
padding: 2px 10px;
|
|
122
|
+
border: var(--ogx-border-width-thin) solid var(--ogx-border-color);
|
|
123
|
+
border-radius: var(--ogx-border-radius-sm);
|
|
124
|
+
background: transparent;
|
|
125
|
+
color: var(--ogx-grid-header-text);
|
|
126
|
+
font-family: var(--ogx-font-family);
|
|
127
|
+
font-size: var(--ogx-font-size-xs);
|
|
128
|
+
font-weight: 500;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
transition: background 0.15s, border-color 0.15s;
|
|
131
|
+
}
|
|
132
|
+
.ogx-toolbar__close-btn:hover {
|
|
133
|
+
background: var(--ogx-grid-header-hover-background);
|
|
134
|
+
border-color: var(--ogx-color-primary);
|
|
135
|
+
color: var(--ogx-color-primary);
|
|
136
|
+
}
|
|
120
137
|
.ogx-toolbar__panel-body {
|
|
121
138
|
padding: var(--ogx-spacing-xs) 0;
|
|
122
139
|
max-height: 360px;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
3
|
import type { GridColDef, GridAggregationModel, GridPivotModel, GridFilterModel } from '../../types';
|
|
4
4
|
import { PivotPanel, PivotIcon } from './PivotPanel';
|
|
@@ -9,6 +9,11 @@ import { GridTooltip } from '../Tooltip/Tooltip';
|
|
|
9
9
|
|
|
10
10
|
export interface GridToolbarProps {
|
|
11
11
|
columns?: GridColDef[];
|
|
12
|
+
/** Original user-defined columns (before pivot transformation).
|
|
13
|
+
* When provided, the PivotPanel shows these instead of `columns`
|
|
14
|
+
* to prevent pivot-generated synthetic columns from appearing in
|
|
15
|
+
* 'Available Fields' and causing infinite nested aggregations. */
|
|
16
|
+
baseColumns?: GridColDef[];
|
|
12
17
|
aggregationModel?: GridAggregationModel;
|
|
13
18
|
onAggregationModelChange?: (model: GridAggregationModel) => void;
|
|
14
19
|
pivotModel?: GridPivotModel;
|
|
@@ -82,6 +87,11 @@ function AggregationPanel({
|
|
|
82
87
|
}) {
|
|
83
88
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
84
89
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
90
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
91
|
+
|
|
92
|
+
useLayoutEffect(() => {
|
|
93
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
94
|
+
}, [anchorRef]);
|
|
85
95
|
|
|
86
96
|
const computePos = useCallback((): PanelPosition => {
|
|
87
97
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -180,7 +190,7 @@ function AggregationPanel({
|
|
|
180
190
|
</div>
|
|
181
191
|
);
|
|
182
192
|
|
|
183
|
-
return ReactDOM.createPortal(panel,
|
|
193
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
function ColumnsPanelWrapper({
|
|
@@ -204,6 +214,11 @@ function ColumnsPanelWrapper({
|
|
|
204
214
|
}) {
|
|
205
215
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
206
216
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
217
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
218
|
+
|
|
219
|
+
useLayoutEffect(() => {
|
|
220
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
221
|
+
}, [anchorRef]);
|
|
207
222
|
|
|
208
223
|
const computePos = useCallback((): PanelPosition => {
|
|
209
224
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -257,7 +272,7 @@ function ColumnsPanelWrapper({
|
|
|
257
272
|
</div>
|
|
258
273
|
);
|
|
259
274
|
|
|
260
|
-
return ReactDOM.createPortal(panel,
|
|
275
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
261
276
|
}
|
|
262
277
|
|
|
263
278
|
function FilterPanelWrapper({
|
|
@@ -275,6 +290,16 @@ function FilterPanelWrapper({
|
|
|
275
290
|
}) {
|
|
276
291
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
277
292
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
293
|
+
// Stable ref for onClose — prevents the mousedown effect from
|
|
294
|
+
// tearing down and re-registering on every filterModel update,
|
|
295
|
+
// which could fire the close callback during the re-register window.
|
|
296
|
+
const onCloseRef = useRef(onClose);
|
|
297
|
+
useLayoutEffect(() => { onCloseRef.current = onClose; }, [onClose]);
|
|
298
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
299
|
+
|
|
300
|
+
useLayoutEffect(() => {
|
|
301
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
302
|
+
}, [anchorRef]);
|
|
278
303
|
|
|
279
304
|
const computePos = useCallback((): PanelPosition => {
|
|
280
305
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -296,25 +321,17 @@ function FilterPanelWrapper({
|
|
|
296
321
|
};
|
|
297
322
|
}, [computePos]);
|
|
298
323
|
|
|
324
|
+
// Only Escape key closes the filter panel automatically.
|
|
325
|
+
// Click-outside is intentionally removed: users need to type in filter
|
|
326
|
+
// inputs without the panel closing. There is an explicit Close button.
|
|
299
327
|
useEffect(() => {
|
|
300
|
-
function handleClick(e: MouseEvent) {
|
|
301
|
-
if (
|
|
302
|
-
panelRef.current && !panelRef.current.contains(e.target as Node) &&
|
|
303
|
-
anchorRef.current && !anchorRef.current.contains(e.target as Node)
|
|
304
|
-
) {
|
|
305
|
-
onClose();
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
328
|
function handleKey(e: KeyboardEvent) {
|
|
309
|
-
if (e.key === 'Escape')
|
|
329
|
+
if (e.key === 'Escape') onCloseRef.current();
|
|
310
330
|
}
|
|
311
|
-
document.addEventListener('mousedown', handleClick);
|
|
312
331
|
document.addEventListener('keydown', handleKey);
|
|
313
|
-
return () =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
};
|
|
317
|
-
}, [anchorRef, onClose]);
|
|
332
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
333
|
+
// Empty deps — listener is registered once and uses the stable ref
|
|
334
|
+
}, []);
|
|
318
335
|
|
|
319
336
|
const activeFilterCount = filterModel.items?.length ?? 0;
|
|
320
337
|
|
|
@@ -330,7 +347,6 @@ function FilterPanelWrapper({
|
|
|
330
347
|
aria-label="Advanced filters"
|
|
331
348
|
style={{ top: pos.top, right: pos.right, width: 540, maxWidth: 'calc(100vw - 24px)' }}
|
|
332
349
|
>
|
|
333
|
-
{ }
|
|
334
350
|
<div className="ogx-toolbar__panel-header">
|
|
335
351
|
<span className="ogx-toolbar__panel-title">
|
|
336
352
|
<FilterIcon />
|
|
@@ -339,18 +355,27 @@ function FilterPanelWrapper({
|
|
|
339
355
|
<span className="ogx-toolbar__panel-count">{activeFilterCount}</span>
|
|
340
356
|
)}
|
|
341
357
|
</span>
|
|
342
|
-
{
|
|
358
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
359
|
+
{activeFilterCount > 0 && (
|
|
360
|
+
<button
|
|
361
|
+
className="ogx-toolbar__clear-btn"
|
|
362
|
+
onClick={handleClearAll}
|
|
363
|
+
title="Clear all filters"
|
|
364
|
+
>
|
|
365
|
+
Clear all
|
|
366
|
+
</button>
|
|
367
|
+
)}
|
|
343
368
|
<button
|
|
344
|
-
className="ogx-
|
|
345
|
-
onClick={
|
|
346
|
-
title="
|
|
369
|
+
className="ogx-toolbar__close-btn"
|
|
370
|
+
onClick={() => onCloseRef.current()}
|
|
371
|
+
title="Close filters"
|
|
372
|
+
aria-label="Close filters panel"
|
|
347
373
|
>
|
|
348
|
-
|
|
374
|
+
Close
|
|
349
375
|
</button>
|
|
350
|
-
|
|
376
|
+
</div>
|
|
351
377
|
</div>
|
|
352
378
|
|
|
353
|
-
{ }
|
|
354
379
|
<div className="ogx-toolbar__panel-body ogx-toolbar__panel-body--filter">
|
|
355
380
|
<FilterPanel
|
|
356
381
|
columns={columns}
|
|
@@ -361,13 +386,14 @@ function FilterPanelWrapper({
|
|
|
361
386
|
</div>
|
|
362
387
|
);
|
|
363
388
|
|
|
364
|
-
return ReactDOM.createPortal(panel,
|
|
389
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
365
390
|
}
|
|
366
391
|
|
|
367
392
|
const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
|
|
368
393
|
|
|
369
394
|
export function GridToolbar({
|
|
370
395
|
columns = [],
|
|
396
|
+
baseColumns,
|
|
371
397
|
aggregationModel = {},
|
|
372
398
|
onAggregationModelChange,
|
|
373
399
|
pivotModel,
|
|
@@ -594,7 +620,7 @@ export function GridToolbar({
|
|
|
594
620
|
{pivotOpen && (
|
|
595
621
|
<PivotPanel
|
|
596
622
|
anchorRef={pivotButtonRef}
|
|
597
|
-
columns={columns}
|
|
623
|
+
columns={baseColumns ?? columns}
|
|
598
624
|
model={currentPivotModel}
|
|
599
625
|
onChange={(m) => { onPivotModelChange?.(m); }}
|
|
600
626
|
onClose={() => setPivotOpen(false)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import ReactDOM from 'react-dom';
|
|
3
3
|
import type { GridColDef } from '../../types';
|
|
4
4
|
import type { GridPivotModel, GridPivotAggFn, GridPivotValueField } from '../../types';
|
|
@@ -31,6 +31,11 @@ export interface PivotPanelProps {
|
|
|
31
31
|
export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: PivotPanelProps) {
|
|
32
32
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
33
33
|
const [pos, setPos] = useState({ top: 0, right: 0 });
|
|
34
|
+
const [portalContainer, setPortalContainer] = useState<Element>(document.body);
|
|
35
|
+
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body);
|
|
38
|
+
}, [anchorRef]);
|
|
34
39
|
|
|
35
40
|
const computePos = useCallback(() => {
|
|
36
41
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -235,5 +240,5 @@ export function PivotPanel({ anchorRef, columns, model, onChange, onClose }: Piv
|
|
|
235
240
|
</div>
|
|
236
241
|
);
|
|
237
242
|
|
|
238
|
-
return ReactDOM.createPortal(panel,
|
|
243
|
+
return ReactDOM.createPortal(panel, portalContainer);
|
|
239
244
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export type DragHandleIconProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
4
4
|
|
|
5
5
|
export const DragHandleIcon: React.FC<DragHandleIconProps> = ({ className = '', ...props }) => {
|
|
6
6
|
return (
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { useReducer, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
2
|
+
import { useReducer, useCallback, useMemo, useRef, useEffect, useLayoutEffect } from 'react';
|
|
3
3
|
import type {
|
|
4
4
|
GridState,
|
|
5
5
|
GridRowModel,
|
|
@@ -259,7 +259,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
259
259
|
});
|
|
260
260
|
|
|
261
261
|
const stateRef = useRef(state);
|
|
262
|
-
stateRef.current = state;
|
|
262
|
+
useLayoutEffect(() => { stateRef.current = state; });
|
|
263
263
|
|
|
264
264
|
// Sync internal state when the external `rows` prop changes
|
|
265
265
|
const prevRowsRef = useRef(rows);
|