Skip to content

Commit 443d863

Browse files
committed
feat: Add debounced input and improved state management for filter panel and global search components.
1 parent f6afbd7 commit 443d863

14 files changed

Lines changed: 362 additions & 150 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@
55

66
---
77

8+
## [0.1.8] — March 18, 2026 🐛✨
9+
10+
### Fixed
11+
- **Toolbar Component Identity:** Fixed a major bug where defining the `GridToolbar` within a component's render body produced a new React component reference on every render, causing the toolbar to constantly unmount and remount (destroying all internal states like open panels or typed search text). Replaced `React.createElement` with direct function invocation in the `StableWrapper` to bypass React's component-identity check and persist internal DOM state.
12+
- **Global Search Focus Preservation:** Refactored `GlobalSearch` into an uncontrolled component to prevent continuous data re-renders from stealing focus. Added a `useLayoutEffect` to automatically restore browser focus to the input field if a React virtual DOM diff incidentally drops it mid-keystroke.
13+
- **Filter Panel Auto-Dismiss:** The Advanced Filter panel no longer collapses indiscriminately when clicking into numeric filter fields or during parent re-renders. Implemented a stable callback ref that prevents the underlying event listeners from rehooking during typing. Click-outside auto-close has been structurally disabled in favor of an explicit "Close" button.
14+
- **Pivot Mode Aggregation:** Addressed a critical bug where `aggregationModel` was trying to read base columns (e.g. `revenue`) on pivot rows that use synthetic column keys (e.g. `Q1\u001frevenue\u001fsum`), resulting in broken totals.
15+
- The aggregation footer now renders synthetic pivot totals correctly.
16+
- The `GridToolbar` now actively provisions `effectiveColumns` to the `AggregationPanel` to allow users to build summaries on pivot dimensions.
17+
- Added a built-in "Grand Total" row appended directly to the `usePivot` output to generate automatic baseline column totals.
18+
- **Exporting Selected Rows:** Corrected data omission in the Demo files where print exports were grabbing the entire dataset instead of respecting active row selection. Used `apiRef.current.getSelectedRows()` to extract standard export data without requiring explicit prop-threading.
19+
20+
---
21+
822
## [0.1.7] — March 13, 2026 🐛✨
923

1024
### Added
@@ -178,4 +192,4 @@ This is the initial public release of OpenGridX. All planned v0.1.0 features are
178192

179193
---
180194

181-
*Last Updated: March 6, 2026*
195+
*Last Updated: March 18, 2026*

demo/App.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@
149149
padding-right: 24px;
150150
}
151151

152+
.app-content-column--full-width {
153+
padding-right: 0;
154+
}
155+
152156
/* Base Headings within app content area for TOC generation */
153157
.app-content-column h2 {
154158
margin-top: 36px;

demo/App.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,18 @@ function DemoSkeleton() {
100100

101101
function PageWrapper({ children }: { children: React.ReactNode }) {
102102
const location = useLocation();
103+
104+
// Check if current route is in 'Resources' category
105+
const currentConfig = examplesConfig.find(item => item.path === location.pathname);
106+
const isResourcePage = currentConfig?.category === 'Resources';
103107

104-
// Automatically extract TOC from current page
108+
// Automatically extract TOC from current page, except for Resources
105109
return (
106110
<div className="app-page-wrapper" key={location.pathname}>
107-
<div className="app-content-column">
111+
<div className={`app-content-column ${isResourcePage ? 'app-content-column--full-width' : ''}`}>
108112
{children}
109113
</div>
110-
<Toc />
114+
{!isResourcePage && <Toc />}
111115
</div>
112116
);
113117
}
@@ -129,7 +133,7 @@ export default function App() {
129133
<img src={`${import.meta.env.BASE_URL}logo.png`} alt="OpenGridX Logo" className="app-logo" />
130134
<h2 className="app-title">
131135
OpenGridX
132-
<span className="app-version">v0.1.7</span>
136+
<span className="app-version">v0.1.8</span>
133137
</h2>
134138
</div>
135139

demo/examples/PivotModeDemo/PivotModeDemo.tsx

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -297,27 +297,28 @@ function ExportToolbar({ apiRef, fallbackRows, fallbackColumns }: ExportToolbarP
297297
);
298298
}
299299

300+
// Module-level: stable function reference so React never unmounts/remounts the toolbar.
301+
function CustomToolbar(props: any) {
302+
return (
303+
<GridToolbar
304+
{...props}
305+
rightContent={
306+
<div className="pivot-toolbar-right">
307+
<ExportToolbar
308+
apiRef={props.apiRef}
309+
fallbackRows={props._fallbackRows || []}
310+
fallbackColumns={props._fallbackColumns || []}
311+
/>
312+
</div>
313+
}
314+
/>
315+
);
316+
}
317+
300318
export default function PivotModeDemo() {
301319
const [pivotMode, setPivotMode] = useState(true);
302320
const [pivotModel, setPivotModel] = useState<GridPivotModel>(PRESETS[0].model);
303321

304-
const CustomToolbar = (props: any) => {
305-
return (
306-
<GridToolbar
307-
{...props}
308-
rightContent={
309-
<div className="pivot-toolbar-right">
310-
<ExportToolbar
311-
apiRef={props.apiRef}
312-
fallbackRows={RAW_ROWS}
313-
fallbackColumns={SOURCE_COLUMNS as any}
314-
/>
315-
</div>
316-
}
317-
/>
318-
);
319-
};
320-
321322
return (
322323
<div className="pivot-demo-container">
323324

@@ -395,6 +396,12 @@ export default function PivotModeDemo() {
395396
paginationModel={{ page: 0, pageSize: 50 }}
396397
pageSizeOptions={[25, 50, 100]}
397398
slots={{ toolbar: CustomToolbar }}
399+
slotProps={{
400+
toolbar: {
401+
_fallbackRows: RAW_ROWS,
402+
_fallbackColumns: SOURCE_COLUMNS,
403+
}
404+
}}
398405
height={500}
399406
/>
400407
</div>

demo/examples/ThemingDemo/ThemingDemo.tsx

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -92,37 +92,55 @@ function ExportToolbar({ apiRef, fallbackRows, fallbackColumns }: ExportToolbarP
9292
const [isPrinting, setIsPrinting] = useState(false);
9393
const [showMenu, setShowMenu] = useState(false);
9494

95-
const getRows = () => apiRef?.current?.getVisibleRows?.() || fallbackRows;
95+
// Use apiRef to get current state — no need to thread selectedRows as a prop.
96+
// getSelectedRows() returns the array of selected row IDs.
97+
// getVisibleRows() returns rows after filtering/sorting.
98+
const getAllRows = () => apiRef?.current?.getVisibleRows?.() || fallbackRows;
9699
const getColumns = () => apiRef?.current?.getVisibleColumns?.() || fallbackColumns;
97100
const getAgg = () => apiRef?.current?.getAggregationResult?.() || null;
98101
const getAggModel = () => apiRef?.current?.getAggregationModel?.() || null;
99102

100-
const performPrint = async () => {
101-
setIsPrinting(true);
102-
try {
103-
await printGrid(getRows(), getColumns(), {
104-
title: 'Data Export',
105-
aggregationResult: getAgg(),
106-
aggregationModel: getAggModel(),
107-
});
108-
} catch (error) {
109-
console.error('Print failed:', error);
110-
} finally {
111-
setIsPrinting(false);
103+
const getRowsToExport = () => {
104+
const selectedIds: (string | number)[] = apiRef?.current?.getSelectedRows?.() || [];
105+
if (selectedIds.length > 0) {
106+
const allRows = getAllRows();
107+
return allRows.filter((r: any) => selectedIds.includes(r.id));
112108
}
109+
return getAllRows();
113110
};
114111

115-
const handleExportClick = (format: 'csv' | 'excel' | 'json' | 'print') => {
116-
const currentRows = getRows();
112+
const getSelectedCount = () => (apiRef?.current?.getSelectedRows?.() || []).length;
113+
114+
const performExport = async (format: 'csv' | 'excel' | 'json' | 'print') => {
115+
const rowsToExport = getRowsToExport();
117116
const currentCols = getColumns();
118117
const aggResult = getAgg();
119118
const aggModel = getAggModel();
119+
const selectedCount = getSelectedCount();
120+
const suffix = selectedCount > 0 ? ` (${selectedCount} selected)` : '';
120121

121-
if (format === 'csv') exportToCsv(currentRows, currentCols, { fileName: 'export.csv', aggregationResult: aggResult, aggregationModel: aggModel });
122-
else if (format === 'excel') exportToExcel(currentRows, currentCols, { fileName: 'export.xlsx', sheetName: 'Data', aggregationResult: aggResult, aggregationModel: aggModel });
123-
else if (format === 'json') exportToJson(currentRows, currentCols, { fileName: 'export.json', pretty: true, aggregationResult: aggResult, aggregationModel: aggModel });
124-
else if (format === 'print') performPrint();
125122
setShowMenu(false);
123+
124+
if (format === 'csv') {
125+
exportToCsv(rowsToExport, currentCols, { fileName: 'export.csv', aggregationResult: aggResult, aggregationModel: aggModel });
126+
} else if (format === 'excel') {
127+
exportToExcel(rowsToExport, currentCols, { fileName: 'export.xlsx', sheetName: 'Data', aggregationResult: aggResult, aggregationModel: aggModel });
128+
} else if (format === 'json') {
129+
exportToJson(rowsToExport, currentCols, { fileName: 'export.json', pretty: true, aggregationResult: aggResult, aggregationModel: aggModel });
130+
} else if (format === 'print') {
131+
setIsPrinting(true);
132+
try {
133+
await printGrid(rowsToExport, currentCols, {
134+
title: `Data Export${suffix}`,
135+
aggregationResult: aggResult,
136+
aggregationModel: aggModel,
137+
});
138+
} catch (error) {
139+
console.error('Print failed:', error);
140+
} finally {
141+
setIsPrinting(false);
142+
}
143+
}
126144
};
127145

128146
return (
@@ -149,17 +167,17 @@ function ExportToolbar({ apiRef, fallbackRows, fallbackColumns }: ExportToolbarP
149167
onClick={() => setShowMenu(false)}
150168
/>
151169
<div className="ogx-toolbar__panel theming-export-menu">
152-
<div onClick={() => handleExportClick('csv')} className="ogx-menu-item">
170+
<div onClick={() => performExport('csv')} className="ogx-menu-item">
153171
{Icons.Csv} Export as CSV
154172
</div>
155-
<div onClick={() => handleExportClick('excel')} className="ogx-menu-item">
173+
<div onClick={() => performExport('excel')} className="ogx-menu-item">
156174
{Icons.Excel} Export as Excel
157175
</div>
158-
<div onClick={() => handleExportClick('json')} className="ogx-menu-item">
176+
<div onClick={() => performExport('json')} className="ogx-menu-item">
159177
{Icons.Json} Export as JSON
160178
</div>
161179
<div className="ogx-menu-divider" />
162-
<div onClick={() => handleExportClick('print')} className="ogx-menu-item">
180+
<div onClick={() => performExport('print')} className="ogx-menu-item">
163181
{Icons.Print} Print View
164182
</div>
165183
</div>
@@ -169,6 +187,25 @@ function ExportToolbar({ apiRef, fallbackRows, fallbackColumns }: ExportToolbarP
169187
);
170188
}
171189

190+
// ── Stable toolbar component (defined at MODULE LEVEL, not inside ThemingDemo) ──
191+
// If this were defined inside the component body, every render would create a
192+
// new function reference, causing React to unmount/remount the entire toolbar
193+
// and losing all state (search expansion, filter panel open, etc.).
194+
function ToolbarWithExport(props: any) {
195+
return (
196+
<GridToolbar
197+
{...props}
198+
rightContent={
199+
<ExportToolbar
200+
apiRef={props.apiRef}
201+
fallbackRows={props._fallbackRows || []}
202+
fallbackColumns={props._fallbackColumns || []}
203+
/>
204+
}
205+
/>
206+
);
207+
}
208+
172209
export default function ThemingDemo() {
173210
const [rows] = useState(() => generateEmployees(500));
174211
const [selectedTheme, setSelectedTheme] = useState('Default');
@@ -187,14 +224,6 @@ export default function ThemingDemo() {
187224
const theme = presetThemes[selectedTheme];
188225
const isDark = selectedTheme === 'Dark' || selectedTheme === 'Dark + Compact';
189226

190-
const ToolbarWithExport = (props: any) => (
191-
<GridToolbar
192-
{...props}
193-
globalSearch={true}
194-
rightContent={<ExportToolbar apiRef={props.apiRef} fallbackRows={rows} fallbackColumns={allColumns} />}
195-
/>
196-
);
197-
198227
return (
199228
<DocsLayout
200229
title="Theming"
@@ -264,6 +293,12 @@ export default function ThemingDemo() {
264293
onColumnVisibilityModelChange={setColumnVisibilityModel}
265294

266295
slots={{ toolbar: ToolbarWithExport }}
296+
slotProps={{
297+
toolbar: {
298+
_fallbackRows: rows,
299+
_fallbackColumns: allColumns,
300+
}
301+
}}
267302

268303
height={500}
269304
rowHeight={selectedTheme.includes('Compact') ? 36 : undefined}

lib/components/DataGrid/DataGrid.tsx

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,30 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
123123
const isAggregationControlled = propAggregationModel !== undefined;
124124

125125
// ─── Stabilize toolbar component identity ────────────────────────────────
126-
// If the consumer passes an inline arrow function as slots.toolbar (e.g.
127-
// `slots={{ toolbar: (p) => <GridToolbar {...p}/> }}`), a new function is
128-
// created on every parent render. React reconciles by component *identity*,
129-
// so a changed reference means it unmounts the old toolbar and mounts a new
130-
// one — destroying focus, replaying animations, etc.
131-
// useMemo with an empty dep-array gives React a stable identity. If the
132-
// caller genuinely changes slots.toolbar (e.g. swaps to a different UI),
133-
// the new value IS used; we just don't thrash for incidental re-renders.
134-
// eslint-disable-next-line react-hooks/exhaustive-deps
135-
const StableToolbar = useMemo(() => slots?.toolbar, [slots?.toolbar]);
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;
136150
const [internalAggregationModel, setInternalAggregationModel] = useState<GridAggregationModel>(
137151
() => propAggregationModel ?? {}
138152
);
@@ -1653,10 +1667,11 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
16531667
aria-busy={effectiveLoading}
16541668
>
16551669
{ }
1656-
{StableToolbar && (() => {
1670+
{slots?.toolbar && (() => {
16571671
const toolbarProps = {
16581672
apiRef: gridData.apiRef,
1659-
columns: effectiveColumns,
1673+
columns: effectiveColumns as unknown as GridColDef[],
1674+
baseColumns: columns as unknown as GridColDef[],
16601675
aggregationModel,
16611676
onAggregationModelChange: handleAggregationModelChange,
16621677

@@ -1667,7 +1682,6 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
16671682

16681683
filterModel,
16691684
onFilterModelChange,
1670-
16711685
columnVisibilityModel,
16721686
onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
16731687

@@ -2141,8 +2155,10 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
21412155
</div>
21422156
)}
21432157

2144-
{/* Aggregation Footer Row */}
2145-
{hasAggregation && (
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 && (
21462162
<div
21472163
className="ogx__aggregation-footer"
21482164
role="row"

0 commit comments

Comments
 (0)