@opencorestack/opengridx 0.1.7 → 0.1.8
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 +15 -1
- package/dist/index.d.ts +6 -1
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +2504 -2449
- 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/DataGrid/DataGrid.tsx +31 -15
- package/lib/components/FilterPanel/FilterPanel.tsx +46 -15
- 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 +35 -24
- package/lib/hooks/features/usePivot.ts +25 -0
- package/package.json +4 -5
|
@@ -123,16 +123,30 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
123
123
|
const isAggregationControlled = propAggregationModel !== undefined;
|
|
124
124
|
|
|
125
125
|
// ─── Stabilize toolbar component identity ────────────────────────────────
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
126
|
+
// Problem: demos/users often define their toolbar as an inline function
|
|
127
|
+
// INSIDE their component body, e.g.:
|
|
128
|
+
// const MyToolbar = (props) => <GridToolbar {...props} /> // inside render!
|
|
129
|
+
// This creates a NEW function reference on every parent re-render. React
|
|
130
|
+
// reconciles by component *identity*, so a new reference = unmount old
|
|
131
|
+
// toolbar + mount fresh one = all toolbar state (search expansion, filter
|
|
132
|
+
// open, typed text) is destroyed on every keystroke.
|
|
133
|
+
//
|
|
134
|
+
// Fix: a stable wrapper component created ONCE via useRef. Its identity
|
|
135
|
+
// never changes, so React keeps it mounted. It reads the latest toolbar
|
|
136
|
+
// from a separate ref that is updated on every render, so the rendered
|
|
137
|
+
// output is always current — zero stale closures.
|
|
138
|
+
const latestToolbarRef = useRef(slots?.toolbar);
|
|
139
|
+
latestToolbarRef.current = slots?.toolbar;
|
|
140
|
+
// The wrapper itself has a stable identity (created once via useRef).
|
|
141
|
+
// We call latestToolbarRef.current(props) as a PLAIN FUNCTION — not via
|
|
142
|
+
// React.createElement — so React never sees a changing component type.
|
|
143
|
+
// The elements the toolbar function returns are reconciled normally, so
|
|
144
|
+
// GridToolbar's internal state (filterOpen, search expansion) is preserved
|
|
145
|
+
// even when the toolbar is defined as an inline function inside the parent.
|
|
146
|
+
const StableToolbar = useRef((props: Record<string, unknown>) => {
|
|
147
|
+
const Toolbar = latestToolbarRef.current;
|
|
148
|
+
return Toolbar ? (Toolbar as (p: typeof props) => React.ReactElement | null)(props) : null;
|
|
149
|
+
}).current;
|
|
136
150
|
const [internalAggregationModel, setInternalAggregationModel] = useState<GridAggregationModel>(
|
|
137
151
|
() => propAggregationModel ?? {}
|
|
138
152
|
);
|
|
@@ -1653,10 +1667,11 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
1653
1667
|
aria-busy={effectiveLoading}
|
|
1654
1668
|
>
|
|
1655
1669
|
{ }
|
|
1656
|
-
{
|
|
1670
|
+
{slots?.toolbar && (() => {
|
|
1657
1671
|
const toolbarProps = {
|
|
1658
1672
|
apiRef: gridData.apiRef,
|
|
1659
|
-
columns: effectiveColumns,
|
|
1673
|
+
columns: effectiveColumns as unknown as GridColDef[],
|
|
1674
|
+
baseColumns: columns as unknown as GridColDef[],
|
|
1660
1675
|
aggregationModel,
|
|
1661
1676
|
onAggregationModelChange: handleAggregationModelChange,
|
|
1662
1677
|
|
|
@@ -1667,7 +1682,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
1667
1682
|
|
|
1668
1683
|
filterModel,
|
|
1669
1684
|
onFilterModelChange,
|
|
1670
|
-
|
|
1671
1685
|
columnVisibilityModel,
|
|
1672
1686
|
onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
|
|
1673
1687
|
|
|
@@ -2141,8 +2155,10 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
2141
2155
|
</div>
|
|
2142
2156
|
)}
|
|
2143
2157
|
|
|
2144
|
-
{/* Aggregation Footer Row
|
|
2145
|
-
|
|
2158
|
+
{/* Aggregation Footer Row — suppressed in pivot mode because pivot rows
|
|
2159
|
+
already contain pre-aggregated values with synthetic field keys
|
|
2160
|
+
(e.g. 'Q1\u001frevenue\u001fsum') that don't match aggregationModel keys. */}
|
|
2161
|
+
{hasAggregation && !pivotMode && (
|
|
2146
2162
|
<div
|
|
2147
2163
|
className="ogx__aggregation-footer"
|
|
2148
2164
|
role="row"
|
|
@@ -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
|
)}
|
|
@@ -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
|
+
onChangeRef.current = 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;
|
|
@@ -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;
|
|
@@ -275,6 +280,11 @@ function FilterPanelWrapper({
|
|
|
275
280
|
}) {
|
|
276
281
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
277
282
|
const [pos, setPos] = useState<PanelPosition>({ top: 0, right: 0 });
|
|
283
|
+
// Stable ref for onClose — prevents the mousedown effect from
|
|
284
|
+
// tearing down and re-registering on every filterModel update,
|
|
285
|
+
// which could fire the close callback during the re-register window.
|
|
286
|
+
const onCloseRef = useRef(onClose);
|
|
287
|
+
onCloseRef.current = onClose;
|
|
278
288
|
|
|
279
289
|
const computePos = useCallback((): PanelPosition => {
|
|
280
290
|
if (!anchorRef.current) return { top: 0, right: 0 };
|
|
@@ -296,25 +306,17 @@ function FilterPanelWrapper({
|
|
|
296
306
|
};
|
|
297
307
|
}, [computePos]);
|
|
298
308
|
|
|
309
|
+
// Only Escape key closes the filter panel automatically.
|
|
310
|
+
// Click-outside is intentionally removed: users need to type in filter
|
|
311
|
+
// inputs without the panel closing. There is an explicit Close button.
|
|
299
312
|
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
313
|
function handleKey(e: KeyboardEvent) {
|
|
309
|
-
if (e.key === 'Escape')
|
|
314
|
+
if (e.key === 'Escape') onCloseRef.current();
|
|
310
315
|
}
|
|
311
|
-
document.addEventListener('mousedown', handleClick);
|
|
312
316
|
document.addEventListener('keydown', handleKey);
|
|
313
|
-
return () =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
};
|
|
317
|
-
}, [anchorRef, onClose]);
|
|
317
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
318
|
+
// Empty deps — listener is registered once and uses the stable ref
|
|
319
|
+
}, []);
|
|
318
320
|
|
|
319
321
|
const activeFilterCount = filterModel.items?.length ?? 0;
|
|
320
322
|
|
|
@@ -330,7 +332,6 @@ function FilterPanelWrapper({
|
|
|
330
332
|
aria-label="Advanced filters"
|
|
331
333
|
style={{ top: pos.top, right: pos.right, width: 540, maxWidth: 'calc(100vw - 24px)' }}
|
|
332
334
|
>
|
|
333
|
-
{ }
|
|
334
335
|
<div className="ogx-toolbar__panel-header">
|
|
335
336
|
<span className="ogx-toolbar__panel-title">
|
|
336
337
|
<FilterIcon />
|
|
@@ -339,18 +340,27 @@ function FilterPanelWrapper({
|
|
|
339
340
|
<span className="ogx-toolbar__panel-count">{activeFilterCount}</span>
|
|
340
341
|
)}
|
|
341
342
|
</span>
|
|
342
|
-
{
|
|
343
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
344
|
+
{activeFilterCount > 0 && (
|
|
345
|
+
<button
|
|
346
|
+
className="ogx-toolbar__clear-btn"
|
|
347
|
+
onClick={handleClearAll}
|
|
348
|
+
title="Clear all filters"
|
|
349
|
+
>
|
|
350
|
+
Clear all
|
|
351
|
+
</button>
|
|
352
|
+
)}
|
|
343
353
|
<button
|
|
344
|
-
className="ogx-
|
|
345
|
-
onClick={
|
|
346
|
-
title="
|
|
354
|
+
className="ogx-toolbar__close-btn"
|
|
355
|
+
onClick={() => onCloseRef.current()}
|
|
356
|
+
title="Close filters"
|
|
357
|
+
aria-label="Close filters panel"
|
|
347
358
|
>
|
|
348
|
-
|
|
359
|
+
Close
|
|
349
360
|
</button>
|
|
350
|
-
|
|
361
|
+
</div>
|
|
351
362
|
</div>
|
|
352
363
|
|
|
353
|
-
{ }
|
|
354
364
|
<div className="ogx-toolbar__panel-body ogx-toolbar__panel-body--filter">
|
|
355
365
|
<FilterPanel
|
|
356
366
|
columns={columns}
|
|
@@ -368,6 +378,7 @@ const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFiel
|
|
|
368
378
|
|
|
369
379
|
export function GridToolbar({
|
|
370
380
|
columns = [],
|
|
381
|
+
baseColumns,
|
|
371
382
|
aggregationModel = {},
|
|
372
383
|
onAggregationModelChange,
|
|
373
384
|
pivotModel,
|
|
@@ -594,7 +605,7 @@ export function GridToolbar({
|
|
|
594
605
|
{pivotOpen && (
|
|
595
606
|
<PivotPanel
|
|
596
607
|
anchorRef={pivotButtonRef}
|
|
597
|
-
columns={columns}
|
|
608
|
+
columns={baseColumns ?? columns}
|
|
598
609
|
model={currentPivotModel}
|
|
599
610
|
onChange={(m) => { onPivotModelChange?.(m); }}
|
|
600
611
|
onClose={() => setPivotOpen(false)}
|
|
@@ -93,6 +93,31 @@ export function usePivot(
|
|
|
93
93
|
pivotRows.push(pivotRow);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ── Grand Total row ───────────────────────────────────────────────────
|
|
97
|
+
// Sum every numeric pivot cell across all pivot rows so users get
|
|
98
|
+
// column totals. The row uses the same synthetic field keys as regular
|
|
99
|
+
// pivot rows, so it renders with the correct valueFormatters.
|
|
100
|
+
// Label columns (rowFields) show 'Grand Total' in the first field, empty elsewhere.
|
|
101
|
+
const totalRow: GridRowModel = { id: '__pivot_grand_total__' };
|
|
102
|
+
for (let i = 0; i < rowFields.length; i++) {
|
|
103
|
+
totalRow[rowFields[i]] = i === 0 ? 'Grand Total' : '';
|
|
104
|
+
}
|
|
105
|
+
for (const ck of colKeys) {
|
|
106
|
+
for (const vf of valueFields) {
|
|
107
|
+
const cellKey = columnFields.length > 0
|
|
108
|
+
? `${ck}\u001f${vf.field}\u001f${vf.aggFn}`
|
|
109
|
+
: `${vf.field}\u001f${vf.aggFn}`;
|
|
110
|
+
// Sum the already-aggregated cell values across all pivot rows
|
|
111
|
+
const colTotal = pivotRows.reduce((acc, r) => {
|
|
112
|
+
const v = r[cellKey];
|
|
113
|
+
return acc + (v != null && !isNaN(Number(v)) ? Number(v) : 0);
|
|
114
|
+
}, 0);
|
|
115
|
+
totalRow[cellKey] = colTotal;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
pivotRows.push(totalRow);
|
|
119
|
+
|
|
120
|
+
|
|
96
121
|
const pivotColumns: GridColDef[] = [];
|
|
97
122
|
|
|
98
123
|
for (const f of rowFields) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencorestack/opengridx",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/opengridx.umd.js",
|
|
@@ -99,9 +99,8 @@
|
|
|
99
99
|
"typescript": "^5.9.3",
|
|
100
100
|
"vite": "^7.2.4",
|
|
101
101
|
"vite-plugin-dts": "^4.5.4",
|
|
102
|
-
"vitest": "^4.0.18"
|
|
103
|
-
},
|
|
104
|
-
"dependencies": {
|
|
102
|
+
"vitest": "^4.0.18",
|
|
105
103
|
"react-router-dom": "^7.13.1"
|
|
106
|
-
}
|
|
104
|
+
},
|
|
105
|
+
"dependencies": {}
|
|
107
106
|
}
|