@djangocfg/ui-core 2.1.408 → 2.1.411

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.408",
3
+ "version": "2.1.411",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -95,14 +95,14 @@
95
95
  "check": "tsc --noEmit"
96
96
  },
97
97
  "peerDependencies": {
98
- "@djangocfg/i18n": "^2.1.408",
98
+ "@djangocfg/i18n": "^2.1.411",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
101
101
  "moment": "^2.30.1",
102
102
  "next": ">=14.0.0",
103
- "react": "^19.1.0",
103
+ "react": "^19.2.4",
104
104
  "react-device-detect": "^2.2.3",
105
- "react-dom": "^19.1.0",
105
+ "react-dom": "^19.2.4",
106
106
  "react-hook-form": "^7.69.0",
107
107
  "tailwindcss": "^4.1.18",
108
108
  "zod": "^4.3.6",
@@ -166,13 +166,13 @@
166
166
  "vaul": "1.1.2"
167
167
  },
168
168
  "devDependencies": {
169
- "@djangocfg/i18n": "^2.1.408",
170
- "@djangocfg/typescript-config": "^2.1.408",
171
- "@types/node": "^24.7.2",
172
- "@types/react": "^19.1.0",
173
- "@types/react-dom": "^19.1.0",
169
+ "@djangocfg/i18n": "^2.1.411",
170
+ "@djangocfg/typescript-config": "^2.1.411",
171
+ "@types/node": "^25.2.3",
172
+ "@types/react": "^19.2.15",
173
+ "@types/react-dom": "^19.2.3",
174
174
  "lucide-react": "^0.545.0",
175
- "next": "^16.2.4",
175
+ "next": "^16.2.2",
176
176
  "typescript": "^5.9.3"
177
177
  },
178
178
  "publishConfig": {
@@ -47,7 +47,7 @@ const ContextMenuSubContent = React.forwardRef<
47
47
  <ContextMenuPrimitive.SubContent
48
48
  ref={ref}
49
49
  className={cn(
50
- "z-150 min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
50
+ "z-[700] min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
51
51
  className
52
52
  )}
53
53
  {...props}
@@ -63,7 +63,7 @@ const ContextMenuContent = React.forwardRef<
63
63
  <ContextMenuPrimitive.Content
64
64
  ref={ref}
65
65
  className={cn(
66
- "z-150 max-h-[--radix-context-menu-content-available-height] min-w-32 overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
66
+ "z-[700] max-h-[--radix-context-menu-content-available-height] min-w-32 overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
67
67
  className
68
68
  )}
69
69
  {...props}
@@ -48,7 +48,7 @@ const DropdownMenuSubContent = React.forwardRef<
48
48
  <DropdownMenuPrimitive.SubContent
49
49
  ref={ref}
50
50
  className={cn(
51
- "z-600 min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
51
+ "z-[700] min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
52
52
  className
53
53
  )}
54
54
  {...props}
@@ -66,7 +66,7 @@ const DropdownMenuContent = React.forwardRef<
66
66
  ref={ref}
67
67
  sideOffset={sideOffset}
68
68
  className={cn(
69
- "z-600 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-32 overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md",
69
+ "z-[700] max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-32 overflow-y-auto overflow-x-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md",
70
70
  "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
71
71
  className
72
72
  )}
@@ -95,7 +95,7 @@ const MenubarSubContent = React.forwardRef<
95
95
  <MenubarPrimitive.SubContent
96
96
  ref={ref}
97
97
  className={cn(
98
- "z-150 min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
98
+ "z-[700] min-w-32 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
99
99
  className
100
100
  )}
101
101
  {...props}
@@ -118,7 +118,7 @@ const MenubarContent = React.forwardRef<
118
118
  alignOffset={alignOffset}
119
119
  sideOffset={sideOffset}
120
120
  className={cn(
121
- "z-150 min-w-48 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
121
+ "z-[700] min-w-48 overflow-hidden rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
122
122
  className
123
123
  )}
124
124
  {...props}
@@ -20,7 +20,7 @@ const HoverCardContent = React.forwardRef<
20
20
  align={align}
21
21
  sideOffset={sideOffset}
22
22
  className={cn(
23
- "z-150 w-64 rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
23
+ "z-[700] w-64 rounded-[var(--radius-popover)] border bg-popover backdrop-blur-xl p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
24
24
  className
25
25
  )}
26
26
  {...props}
@@ -159,8 +159,15 @@ const SelectItem = React.forwardRef<
159
159
  key?: React.Key;
160
160
  icon?: React.ComponentType<{ className?: string }>;
161
161
  badge?: string | React.ReactNode;
162
+ /**
163
+ * Optional secondary line shown under the label in the dropdown.
164
+ * Rendered OUTSIDE `SelectPrimitive.ItemText`, so it does NOT
165
+ * appear in the trigger — only `children` (the label) does. Use
166
+ * this for two-line options whose trigger must stay single-line.
167
+ */
168
+ description?: React.ReactNode;
162
169
  }
163
- >(({ className, children, icon: Icon, badge, value, ...props }, ref) => (
170
+ >(({ className, children, icon: Icon, badge, description, value, ...props }, ref) => (
164
171
  <SelectPrimitive.Item
165
172
  ref={ref}
166
173
  value={value === '' ? EMPTY_SENTINEL : value}
@@ -177,7 +184,12 @@ const SelectItem = React.forwardRef<
177
184
  </span>
178
185
  <div className="flex items-center gap-2 flex-1 min-w-0 pr-6">
179
186
  {Icon && <Icon className="h-4 w-4 shrink-0" />}
180
- <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
187
+ <div className="flex min-w-0 flex-1 flex-col">
188
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
189
+ {description && (
190
+ <span className="text-xs text-muted-foreground">{description}</span>
191
+ )}
192
+ </div>
181
193
  {badge && (
182
194
  <Badge variant="outline" className="text-xs shrink-0">
183
195
  {badge}
@@ -3,6 +3,15 @@
3
3
  export { useDebounce } from './useDebounce';
4
4
  export { useDebouncedCallback } from './useDebouncedCallback';
5
5
  export { useLocalStorage } from './useLocalStorage';
6
+ export type { UseLocalStorageOptions, UseLocalStorageReturn } from './useLocalStorage';
6
7
  export { useSessionStorage } from './useSessionStorage';
8
+ export type {
9
+ UseSessionStorageOptions,
10
+ UseSessionStorageReturn,
11
+ } from './useSessionStorage';
7
12
  export { useStoredValue } from './useStoredValue';
8
- export type { UseStoredValueOptions, StorageType } from './useStoredValue';
13
+ export type {
14
+ UseStoredValueOptions,
15
+ UseStoredValueReturn,
16
+ StorageType,
17
+ } from './useStoredValue';
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
4
 
5
5
  /**
6
6
  * Storage wrapper format with metadata
@@ -27,6 +27,20 @@ export interface UseLocalStorageOptions {
27
27
  ttl?: number;
28
28
  }
29
29
 
30
+ /**
31
+ * Tuple returned by {@link useLocalStorage}.
32
+ *
33
+ * The 4th element (`patch`) is always present at runtime, but it only does
34
+ * something useful when `T` is a plain object — for primitive `T` it falls
35
+ * back to a plain `setValue` of the partial. See the `patch` JSDoc below.
36
+ */
37
+ export type UseLocalStorageReturn<T> = readonly [
38
+ value: T,
39
+ setValue: (value: T | ((prev: T) => T)) => void,
40
+ removeValue: () => void,
41
+ patch: (partial: Partial<T>) => void,
42
+ ];
43
+
30
44
  /**
31
45
  * Check if data is in new wrapped format with _meta
32
46
  */
@@ -50,17 +64,75 @@ function isExpired<T>(wrapped: StorageWrapper<T>): boolean {
50
64
  return age > wrapped._meta.ttl;
51
65
  }
52
66
 
67
+ /** True for a plain object (eligible for shallow-merge `patch`). */
68
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
69
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
70
+ }
71
+
72
+ /**
73
+ * Same-tab cross-instance pub/sub.
74
+ *
75
+ * `window`'s native `storage` event only fires in OTHER tabs — two
76
+ * `useLocalStorage` hooks on the same key in the SAME tab would never see
77
+ * each other's writes. This module-level registry closes that gap: every
78
+ * write notifies all live hook instances for that key so they re-render
79
+ * with the fresh value. Other-tab sync still rides the `storage` event.
80
+ */
81
+ const keyListeners = new Map<string, Set<(raw: string | null) => void>>();
82
+
83
+ function subscribeKey(key: string, fn: (raw: string | null) => void): () => void {
84
+ let set = keyListeners.get(key);
85
+ if (!set) {
86
+ set = new Set();
87
+ keyListeners.set(key, set);
88
+ }
89
+ set.add(fn);
90
+ return () => {
91
+ set!.delete(fn);
92
+ if (set!.size === 0) keyListeners.delete(key);
93
+ };
94
+ }
95
+
96
+ /** Notify every same-tab hook instance bound to `key` (skips `self`). */
97
+ function broadcastKey(
98
+ key: string,
99
+ raw: string | null,
100
+ self: (raw: string | null) => void,
101
+ ): void {
102
+ const set = keyListeners.get(key);
103
+ if (!set) return;
104
+ for (const fn of set) {
105
+ if (fn !== self) fn(raw);
106
+ }
107
+ }
108
+
53
109
  /**
54
- * Simple localStorage hook with better error handling and optional TTL support
110
+ * Simple localStorage hook with better error handling and optional TTL support.
55
111
  *
56
112
  * IMPORTANT: To prevent hydration mismatch, this hook:
57
113
  * - Always returns initialValue on first render (same as SSR)
58
114
  * - Reads from localStorage only after component mounts
59
115
  *
116
+ * Synchronization (added):
117
+ * - Same-tab: a module-level pub/sub keyed by storage key — two hooks on the
118
+ * same key in one tab stay in sync (the native `storage` event does NOT
119
+ * fire in the originating tab, so this is required).
120
+ * - Cross-tab: the `window` `storage` event keeps other tabs in sync.
121
+ *
122
+ * Write coalescing (added):
123
+ * - State updates synchronously (React stays consistent within the tick),
124
+ * but the actual `localStorage.setItem` is flushed once via
125
+ * `queueMicrotask`. N `patch`/`setValue` calls in one tick collapse into a
126
+ * single write + a single broadcast — no localStorage thrash, no storm of
127
+ * `storage` events.
128
+ *
60
129
  * @param key - Storage key
61
130
  * @param initialValue - Default value if key doesn't exist
62
131
  * @param options - Optional configuration (ttl for auto-expiration)
63
- * @returns [value, setValue, removeValue] - Current value, setter function, and remove function
132
+ * @returns `[value, setValue, removeValue, patch]`
133
+ * - `patch(partial)` shallow-merges a subset of keys into an object value.
134
+ * It is a NO-OP when the partial changes nothing (shallow-equal on the
135
+ * touched keys) — no write, no re-render, no `storage` event.
64
136
  *
65
137
  * @example
66
138
  * // Without TTL (backwards compatible)
@@ -71,56 +143,115 @@ function isExpired<T>(wrapped: StorageWrapper<T>): boolean {
71
143
  * const [value, setValue] = useLocalStorage('key', 'default', {
72
144
  * ttl: 24 * 60 * 60 * 1000
73
145
  * });
146
+ *
147
+ * @example
148
+ * // Partial / grouped updates on an object value
149
+ * const [prefs, , , patch] = useLocalStorage('prefs', { a: 1, b: 2 });
150
+ * patch({ b: 3 }); // merges → { a: 1, b: 3 }, only writes if it changed
74
151
  */
75
152
  export function useLocalStorage<T>(
76
153
  key: string,
77
154
  initialValue: T,
78
155
  options?: UseLocalStorageOptions
79
- ) {
156
+ ): UseLocalStorageReturn<T> {
80
157
  const ttl = options?.ttl;
81
158
  // Always start with initialValue to match SSR
82
159
  const [storedValue, setStoredValue] = useState<T>(initialValue);
83
160
  const [_isHydrated, setIsHydrated] = useState(false);
84
161
  const isInitialized = useRef(false);
85
162
 
86
- // Read from localStorage after mount (avoids hydration mismatch)
87
- useEffect(() => {
88
- if (isInitialized.current) return;
89
- isInitialized.current = true;
163
+ // Latest value, readable synchronously by setValue/patch updaters without
164
+ // adding them to dependency arrays (avoids stale closures across coalesced
165
+ // calls in the same tick).
166
+ const valueRef = useRef<T>(initialValue);
167
+ valueRef.current = storedValue;
90
168
 
91
- try {
92
- const item = window.localStorage.getItem(key);
93
- if (item !== null) {
94
- // Try to parse as JSON first, fallback to string
95
- try {
96
- const parsed = JSON.parse(item);
169
+ // Pending write: the value to flush to localStorage at the next microtask.
170
+ // `hasPending` guards against scheduling more than one flush per tick.
171
+ const pendingRef = useRef<{ value: T } | null>(null);
172
+ const flushScheduledRef = useRef(false);
97
173
 
98
- // Check if new format with _meta
99
- if (isWrappedFormat<T>(parsed)) {
100
- // Check TTL expiration
101
- if (isExpired(parsed)) {
102
- // Expired! Clean up and use initial value
174
+ /**
175
+ * Parse a raw localStorage string into a value of T, honoring TTL.
176
+ * Returns `{ value }` or `null` when the entry is absent / expired.
177
+ */
178
+ const parseRaw = useCallback(
179
+ (raw: string | null): { value: T } | null => {
180
+ if (raw === null) return null;
181
+ try {
182
+ const parsed = JSON.parse(raw);
183
+ if (isWrappedFormat<T>(parsed)) {
184
+ if (isExpired(parsed)) {
185
+ try {
103
186
  window.localStorage.removeItem(key);
104
- // Keep initialValue (already set)
105
- } else {
106
- // Not expired, extract value
107
- setStoredValue(parsed._value);
187
+ } catch {
188
+ // ignore
108
189
  }
109
- } else {
110
- // Old format (backwards compatible)
111
- setStoredValue(parsed as T);
190
+ return null;
112
191
  }
113
- } catch {
114
- // If JSON.parse fails, return as string
115
- setStoredValue(item as T);
192
+ return { value: parsed._value };
116
193
  }
194
+ return { value: parsed as T };
195
+ } catch {
196
+ // Not JSON — treat as a raw string value.
197
+ return { value: raw as T };
117
198
  }
199
+ },
200
+ [key],
201
+ );
202
+
203
+ // Read from localStorage after mount (avoids hydration mismatch)
204
+ useEffect(() => {
205
+ if (isInitialized.current) return;
206
+ isInitialized.current = true;
207
+
208
+ try {
209
+ const item = window.localStorage.getItem(key);
210
+ const parsed = parseRaw(item);
211
+ if (parsed) setStoredValue(parsed.value);
118
212
  } catch (error) {
119
213
  console.error(`Error reading localStorage key "${key}":`, error);
120
214
  }
121
215
 
122
216
  setIsHydrated(true);
123
- }, [key]);
217
+ }, [key, parseRaw]);
218
+
219
+ // Identity used to skip self when broadcasting. The sync effect below owns
220
+ // the real listener; this ref just lets `broadcastKey` exclude it.
221
+ const onLocalRef = useRef<(raw: string | null) => void>(() => {});
222
+
223
+ // Latest initialValue, read by the sync listener for the removal fallback
224
+ // without making the effect re-subscribe on every inline-literal render.
225
+ const initialValueRef = useRef<T>(initialValue);
226
+ initialValueRef.current = initialValue;
227
+
228
+ // Cross-instance (same tab) + cross-tab sync.
229
+ useEffect(() => {
230
+ if (typeof window === 'undefined') return;
231
+
232
+ // Same-tab: re-read whenever a sibling hook on this key writes.
233
+ // The native `storage` event does NOT fire in the originating tab, so
234
+ // without this two hooks on one key in one tab would drift apart.
235
+ const onLocal = (raw: string | null) => {
236
+ const parsed = parseRaw(raw);
237
+ setStoredValue(parsed ? parsed.value : initialValueRef.current);
238
+ };
239
+ onLocalRef.current = onLocal;
240
+ const unsubscribe = subscribeKey(key, onLocal);
241
+
242
+ // Other tabs: the native `storage` event (never fires in this tab).
243
+ const onStorage = (e: StorageEvent) => {
244
+ if (e.key !== key || e.storageArea !== window.localStorage) return;
245
+ const parsed = parseRaw(e.newValue);
246
+ setStoredValue(parsed ? parsed.value : initialValueRef.current);
247
+ };
248
+ window.addEventListener('storage', onStorage);
249
+
250
+ return () => {
251
+ unsubscribe();
252
+ window.removeEventListener('storage', onStorage);
253
+ };
254
+ }, [key, parseRaw]);
124
255
 
125
256
  // Check data size and limit
126
257
  const checkDataSize = (data: any): boolean => {
@@ -128,13 +259,13 @@ export function useLocalStorage<T>(
128
259
  const jsonString = JSON.stringify(data);
129
260
  const sizeInBytes = new Blob([jsonString]).size;
130
261
  const sizeInKB = sizeInBytes / 1024;
131
-
262
+
132
263
  // Limit to 1MB per item
133
264
  if (sizeInKB > 1024) {
134
265
  console.warn(`Data size (${sizeInKB.toFixed(2)}KB) exceeds 1MB limit for key "${key}"`);
135
266
  return false;
136
267
  }
137
-
268
+
138
269
  return true;
139
270
  } catch (error) {
140
271
  console.error(`Error checking data size for key "${key}":`, error);
@@ -201,100 +332,184 @@ export function useLocalStorage<T>(
201
332
  return JSON.stringify(value);
202
333
  };
203
334
 
204
- // Update localStorage when value changes
205
- const setValue = (value: T | ((val: T) => T)) => {
206
- try {
207
- const valueToStore = value instanceof Function ? value(storedValue) : value;
335
+ // Low-level write: persist `dataToStore`, with quota recovery. Returns the
336
+ // raw string actually written (so siblings can be notified with it).
337
+ const writeRaw = useCallback(
338
+ (dataToStore: string): void => {
339
+ try {
340
+ window.localStorage.setItem(key, dataToStore);
341
+ } catch (storageError: any) {
342
+ if (
343
+ storageError.name === 'QuotaExceededError' ||
344
+ storageError.code === 22 ||
345
+ storageError.message?.includes('quota')
346
+ ) {
347
+ console.warn('localStorage quota exceeded, clearing old data...');
348
+ clearOldData();
349
+ try {
350
+ window.localStorage.setItem(key, dataToStore);
351
+ } catch (retryError) {
352
+ console.error(
353
+ `Failed to set localStorage key "${key}" after clearing old data:`,
354
+ retryError,
355
+ );
356
+ try {
357
+ forceClearAll();
358
+ window.localStorage.setItem(key, dataToStore);
359
+ } catch (finalError) {
360
+ console.error(
361
+ `Failed to set localStorage key "${key}" after force clearing:`,
362
+ finalError,
363
+ );
364
+ }
365
+ }
366
+ } else {
367
+ throw storageError;
368
+ }
369
+ }
370
+ },
371
+ [key],
372
+ );
373
+
374
+ /**
375
+ * Schedule a single coalesced flush of the pending value to localStorage.
376
+ *
377
+ * Why: a feature may fire several `patch`/`setValue` calls in one tick.
378
+ * State is updated immediately (React stays correct), but the I/O —
379
+ * `setItem` + the cross-instance broadcast — is deferred to a microtask
380
+ * so N updates in one tick become exactly ONE write and ONE broadcast.
381
+ */
382
+ const scheduleFlush = useCallback(() => {
383
+ if (flushScheduledRef.current) return;
384
+ flushScheduledRef.current = true;
208
385
 
209
- // Check data size before attempting to save
210
- if (!checkDataSize(valueToStore)) {
386
+ queueMicrotask(() => {
387
+ flushScheduledRef.current = false;
388
+ const pending = pendingRef.current;
389
+ pendingRef.current = null;
390
+ if (!pending) return;
391
+ if (typeof window === 'undefined') return;
392
+
393
+ if (!checkDataSize(pending.value)) {
211
394
  console.warn(`Data size too large for key "${key}", removing key`);
212
- // Remove the key if data is too large
213
395
  try {
214
396
  window.localStorage.removeItem(key);
215
397
  } catch {
216
- // Ignore errors when removing
398
+ // ignore
217
399
  }
218
- // Still update the state
219
- setStoredValue(valueToStore);
400
+ broadcastKey(key, null, onLocalRef.current);
220
401
  return;
221
402
  }
222
403
 
223
- setStoredValue(valueToStore);
404
+ const dataToStore = prepareForStorage(pending.value);
405
+ try {
406
+ writeRaw(dataToStore);
407
+ // Keep sibling hooks on this key in sync within the same tab.
408
+ broadcastKey(key, dataToStore, onLocalRef.current);
409
+ } catch (error) {
410
+ console.error(`Error setting localStorage key "${key}":`, error);
411
+ }
412
+ });
413
+ // prepareForStorage / checkDataSize close over `ttl` + `key`; both stable
414
+ // enough for this callback's purpose.
415
+ // eslint-disable-next-line react-hooks/exhaustive-deps
416
+ }, [key, writeRaw]);
224
417
 
225
- if (typeof window !== 'undefined') {
226
- const dataToStore = prepareForStorage(valueToStore);
418
+ // Commit a new value: update React state now, queue the write.
419
+ const commit = useCallback(
420
+ (next: T) => {
421
+ setStoredValue(next);
422
+ valueRef.current = next;
423
+ pendingRef.current = { value: next };
424
+ scheduleFlush();
425
+ },
426
+ [scheduleFlush],
427
+ );
227
428
 
228
- // Try to set the value
229
- try {
230
- window.localStorage.setItem(key, dataToStore);
231
- } catch (storageError: any) {
232
- // If quota exceeded, clear old data and try again
233
- if (storageError.name === 'QuotaExceededError' ||
234
- storageError.code === 22 ||
235
- storageError.message?.includes('quota')) {
236
- console.warn('localStorage quota exceeded, clearing old data...');
237
- clearOldData();
429
+ // Update localStorage when value changes
430
+ const setValue = useCallback(
431
+ (value: T | ((val: T) => T)) => {
432
+ const next =
433
+ value instanceof Function ? value(valueRef.current) : value;
434
+ commit(next);
435
+ },
436
+ [commit],
437
+ );
238
438
 
239
- // Try again after clearing
240
- try {
241
- window.localStorage.setItem(key, dataToStore);
242
- } catch (retryError) {
243
- console.error(`Failed to set localStorage key "${key}" after clearing old data:`, retryError);
244
- // If still fails, force clear all and try one more time
245
- try {
246
- forceClearAll();
247
- window.localStorage.setItem(key, dataToStore);
248
- } catch (finalError) {
249
- console.error(`Failed to set localStorage key "${key}" after force clearing:`, finalError);
250
- // If still fails, just update the state without localStorage
251
- setStoredValue(valueToStore);
252
- }
253
- }
254
- } else {
255
- throw storageError;
256
- }
439
+ /**
440
+ * Shallow-merge a subset of keys into an object value.
441
+ *
442
+ * Safe by design: when every key in `partial` already equals the stored
443
+ * value (shallow `Object.is` check) this is a NO-OP no state update, no
444
+ * write, no `storage` event, no re-render. This makes it cheap to call
445
+ * `patch` defensively from effects / event handlers.
446
+ *
447
+ * When the current value is not a plain object, `patch` degrades to a
448
+ * plain `setValue(partial as T)` so the call is still well-defined.
449
+ */
450
+ const patch = useCallback(
451
+ (partial: Partial<T>) => {
452
+ const prev = valueRef.current;
453
+
454
+ if (!isPlainObject(prev)) {
455
+ commit(partial as T);
456
+ return;
457
+ }
458
+
459
+ // Skip the write entirely when nothing actually changes.
460
+ let changed = false;
461
+ for (const k of Object.keys(partial) as Array<keyof T>) {
462
+ if (!Object.is((prev as T)[k], partial[k])) {
463
+ changed = true;
464
+ break;
257
465
  }
258
466
  }
259
- } catch (error) {
260
- console.error(`Error setting localStorage key "${key}":`, error);
261
- // Still update the state even if localStorage fails
262
- const valueToStore = value instanceof Function ? value(storedValue) : value;
263
- setStoredValue(valueToStore);
264
- }
265
- };
467
+ if (!changed) return;
468
+
469
+ commit({ ...prev, ...partial } as T);
470
+ },
471
+ [commit],
472
+ );
266
473
 
267
474
  // Remove value from localStorage
268
- const removeValue = () => {
475
+ const removeValue = useCallback(() => {
269
476
  try {
270
477
  setStoredValue(initialValue);
478
+ valueRef.current = initialValue;
479
+ // Drop any queued write — it would resurrect the removed entry.
480
+ pendingRef.current = null;
271
481
  if (typeof window !== 'undefined') {
272
482
  try {
273
483
  window.localStorage.removeItem(key);
274
484
  } catch (removeError: any) {
275
- // If removal fails due to quota, try to clear some data first
276
- if (removeError.name === 'QuotaExceededError' ||
277
- removeError.code === 22 ||
278
- removeError.message?.includes('quota')) {
485
+ if (
486
+ removeError.name === 'QuotaExceededError' ||
487
+ removeError.code === 22 ||
488
+ removeError.message?.includes('quota')
489
+ ) {
279
490
  console.warn('localStorage quota exceeded during removal, clearing old data...');
280
491
  clearOldData();
281
-
282
492
  try {
283
493
  window.localStorage.removeItem(key);
284
494
  } catch (retryError) {
285
- console.error(`Failed to remove localStorage key "${key}" after clearing:`, retryError);
286
- // If still fails, force clear all
495
+ console.error(
496
+ `Failed to remove localStorage key "${key}" after clearing:`,
497
+ retryError,
498
+ );
287
499
  forceClearAll();
288
500
  }
289
501
  } else {
290
502
  throw removeError;
291
503
  }
292
504
  }
505
+ broadcastKey(key, null, onLocalRef.current);
293
506
  }
294
507
  } catch (error) {
295
508
  console.error(`Error removing localStorage key "${key}":`, error);
296
509
  }
297
- };
510
+ // `initialValue` excluded for the inline-literal reason above.
511
+ // eslint-disable-next-line react-hooks/exhaustive-deps
512
+ }, [key]);
298
513
 
299
- return [storedValue, setValue, removeValue] as const;
514
+ return [storedValue, setValue, removeValue, patch] as const;
300
515
  }
@@ -27,6 +27,24 @@ export interface UseSessionStorageOptions {
27
27
  ttl?: number;
28
28
  }
29
29
 
30
+ /**
31
+ * Tuple returned by {@link useSessionStorage}.
32
+ *
33
+ * The 4th element (`patch`) shallow-merges a subset of keys when `T` is a
34
+ * plain object — a no-op when nothing changes. Mirrors `useLocalStorage`.
35
+ */
36
+ export type UseSessionStorageReturn<T> = readonly [
37
+ value: T,
38
+ setValue: (value: T | ((prev: T) => T)) => void,
39
+ removeValue: () => void,
40
+ patch: (partial: Partial<T>) => void,
41
+ ];
42
+
43
+ /** True for a plain object (eligible for shallow-merge `patch`). */
44
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
45
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
46
+ }
47
+
30
48
  /**
31
49
  * Check if data is in new wrapped format with _meta
32
50
  */
@@ -72,7 +90,7 @@ export function useSessionStorage<T>(
72
90
  key: string,
73
91
  initialValue: T,
74
92
  options?: UseSessionStorageOptions
75
- ) {
93
+ ): UseSessionStorageReturn<T> {
76
94
  const ttl = options?.ttl;
77
95
 
78
96
  // Get initial value from sessionStorage or use provided initialValue
@@ -286,5 +304,29 @@ export function useSessionStorage<T>(
286
304
  }
287
305
  };
288
306
 
289
- return [storedValue, setValue, removeValue] as const;
307
+ /**
308
+ * Shallow-merge a subset of keys into an object value.
309
+ *
310
+ * Safe by design: a NO-OP when the partial changes nothing (shallow
311
+ * `Object.is` check on the touched keys) — no write, no re-render. When
312
+ * the current value is not a plain object it degrades to `setValue`.
313
+ */
314
+ const patch = (partial: Partial<T>) => {
315
+ const prev = storedValue;
316
+ if (!isPlainObject(prev)) {
317
+ setValue(partial as T);
318
+ return;
319
+ }
320
+ let changed = false;
321
+ for (const k of Object.keys(partial) as Array<keyof T>) {
322
+ if (!Object.is((prev as T)[k], partial[k])) {
323
+ changed = true;
324
+ break;
325
+ }
326
+ }
327
+ if (!changed) return;
328
+ setValue({ ...prev, ...partial } as T);
329
+ };
330
+
331
+ return [storedValue, setValue, removeValue, patch] as const;
290
332
  }
@@ -21,6 +21,20 @@ export interface UseStoredValueOptions {
21
21
  ttl?: number;
22
22
  }
23
23
 
24
+ /**
25
+ * Tuple returned by {@link useStoredValue}.
26
+ *
27
+ * The 4th element (`patch`) shallow-merges a subset of keys when `T` is a
28
+ * plain object — see {@link useLocalStorage}. It is a no-op when no
29
+ * `storageKey` is supplied.
30
+ */
31
+ export type UseStoredValueReturn<T> = readonly [
32
+ value: T,
33
+ setValue: (value: T | ((prev: T) => T)) => void,
34
+ removeValue: () => void,
35
+ patch: (partial: Partial<T>) => void,
36
+ ];
37
+
24
38
  /**
25
39
  * Unified storage hook that delegates to useLocalStorage or useSessionStorage.
26
40
  *
@@ -29,17 +43,17 @@ export interface UseStoredValueOptions {
29
43
  *
30
44
  * When storageKey is undefined the hook is a no-op:
31
45
  * - returns initialValue as current value
32
- * - setValue / removeValue are no-ops
46
+ * - setValue / removeValue / patch are no-ops
33
47
  * This means adding storageKey to a component has zero overhead when unused.
34
48
  *
35
49
  * @example
36
- * const [value, setValue, removeValue] = useStoredValue('my-key', '', { storage: 'session' });
50
+ * const [value, setValue, removeValue, patch] = useStoredValue('my-key', '', { storage: 'session' });
37
51
  */
38
52
  export function useStoredValue<T>(
39
53
  storageKey: string | undefined,
40
54
  initialValue: T,
41
55
  options?: UseStoredValueOptions,
42
- ): readonly [T, (value: T | ((prev: T) => T)) => void, () => void] {
56
+ ): UseStoredValueReturn<T> {
43
57
  const storageType = options?.storage ?? 'local';
44
58
  const ttlOption = options?.ttl ? { ttl: options.ttl } : undefined;
45
59
 
@@ -62,10 +76,11 @@ export function useStoredValue<T>(
62
76
 
63
77
  const noopSetValue = useCallback((_value: T | ((prev: T) => T)) => {}, []);
64
78
  const noopRemove = useCallback(() => {}, []);
79
+ const noopPatch = useCallback((_partial: Partial<T>) => {}, []);
65
80
 
66
81
  // No storageKey — pure no-op, no storage reads/writes
67
82
  if (!storageKey) {
68
- return [initialValue, noopSetValue, noopRemove] as const;
83
+ return [initialValue, noopSetValue, noopRemove, noopPatch] as const;
69
84
  }
70
85
 
71
86
  return storageType === 'local' ? localResult : sessionResult;
@@ -151,7 +151,21 @@
151
151
  /* Custom width utilities */
152
152
  --width-dropdown: 500px;
153
153
 
154
- /* Z-index scale */
154
+ /* Z-index scale
155
+ *
156
+ * Overlay tiers, low → high. Each tier must clear the one below it so a
157
+ * deeper overlay always renders above its trigger surface:
158
+ * ≤ 40 page content, sticky headers
159
+ * 100 floating page furniture (chat dock & companions) — below all overlays
160
+ * 200 sheet (edge drawer)
161
+ * 500 side-panel / drawer
162
+ * 600 modal dialog / alert dialog / command palette
163
+ * 700 anchored overlays (popover, tooltip, select, dropdown,
164
+ * context-menu, menubar, hover-card) — sit above any dialog
165
+ * that triggered them
166
+ * 9999 full-viewport takeovers (e.g. Mermaid fullscreen)
167
+ * Toasts (Sonner) own the topmost layer via the library's own stacking.
168
+ */
155
169
  --z-index-0: 0;
156
170
  --z-index-10: 10;
157
171
  --z-index-20: 20;