@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.
@@ -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
- // 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;
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
- {StableToolbar && (() => {
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
- {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 && (
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) => handleValueChange(e.target.value)}
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={col.type === 'number' ? 'number' : 'text'}
104
- value={item?.value ?? ''}
105
- onChange={(e) => handleValueChange(e.target.value)}
106
- placeholder="Value…"
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(false);
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
- // Sync local value with prop
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
- setLocalValue(value);
38
- if (value && !isExpanded) {
39
- setIsExpanded(true);
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, isExpanded]);
74
+ }, [value]);
42
75
 
43
- // Debounce change
76
+ // ── Debounced upward notification ─────────────────────────────────────────
44
77
  useEffect(() => {
45
78
  const handler = setTimeout(() => {
46
- if (localValue !== value) {
47
- onChange(localValue);
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, onChange, debounceMs, value]);
85
+ }, [localValue, debounceMs]);
52
86
 
53
- const handleFocus = () => {
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
- onChange(''); // Immediate clear
62
- };
98
+ }, []);
63
99
 
64
- // Close on blur if empty
65
- const handleBlur = () => {
66
- if (!localValue) {
67
- setIsExpanded(false);
68
- }
69
- };
100
+ const handleFocus = useCallback(() => {
101
+ wasFocusedRef.current = true;
102
+ setIsExpanded(true);
103
+ }, []);
70
104
 
71
- // Click outside to collapse if empty
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') onClose();
314
+ if (e.key === 'Escape') onCloseRef.current();
310
315
  }
311
- document.addEventListener('mousedown', handleClick);
312
316
  document.addEventListener('keydown', handleKey);
313
- return () => {
314
- document.removeEventListener('mousedown', handleClick);
315
- document.removeEventListener('keydown', handleKey);
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
- {activeFilterCount > 0 && (
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-toolbar__clear-btn"
345
- onClick={handleClearAll}
346
- title="Clear all filters"
354
+ className="ogx-toolbar__close-btn"
355
+ onClick={() => onCloseRef.current()}
356
+ title="Close filters"
357
+ aria-label="Close filters panel"
347
358
  >
348
- Clear all
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.7",
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
  }