@djangocfg/ui-core 2.1.409 → 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 +10 -10
- package/src/components/navigation/context-menu/index.tsx +2 -2
- package/src/components/navigation/dropdown-menu/index.tsx +2 -2
- package/src/components/navigation/menubar/index.tsx +2 -2
- package/src/components/overlay/hover-card/index.tsx +1 -1
- package/src/components/select/select.tsx +14 -2
- package/src/hooks/state/index.ts +10 -1
- package/src/hooks/state/useLocalStorage.ts +305 -90
- package/src/hooks/state/useSessionStorage.ts +44 -2
- package/src/hooks/state/useStoredValue.ts +19 -4
- package/src/styles/theme/tokens.css +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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.
|
|
103
|
+
"react": "^19.2.4",
|
|
104
104
|
"react-device-detect": "^2.2.3",
|
|
105
|
-
"react-dom": "^19.
|
|
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.
|
|
170
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
171
|
-
"@types/node": "^
|
|
172
|
-
"@types/react": "^19.
|
|
173
|
-
"@types/react-dom": "^19.
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
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}
|
package/src/hooks/state/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
// Not expired, extract value
|
|
107
|
-
setStoredValue(parsed._value);
|
|
187
|
+
} catch {
|
|
188
|
+
// ignore
|
|
108
189
|
}
|
|
109
|
-
|
|
110
|
-
// Old format (backwards compatible)
|
|
111
|
-
setStoredValue(parsed as T);
|
|
190
|
+
return null;
|
|
112
191
|
}
|
|
113
|
-
|
|
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
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
398
|
+
// ignore
|
|
217
399
|
}
|
|
218
|
-
|
|
219
|
-
setStoredValue(valueToStore);
|
|
400
|
+
broadcastKey(key, null, onLocalRef.current);
|
|
220
401
|
return;
|
|
221
402
|
}
|
|
222
403
|
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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(
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
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;
|