@djangocfg/ui-core 2.1.603 → 2.1.604

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/README.md CHANGED
@@ -117,6 +117,39 @@ fields (`DateField` / `TimeField` / `DateTimeField`) have their own reference in
117
117
  | `time/` | `useNow` · `useInterval` · `useTimeout` |
118
118
  | `router/` | `useLink` · `useRouter` (router-adapter consumer) |
119
119
 
120
+ ### Storage keys and namespaces (`hooks/state`)
121
+
122
+ `useLocalStorage`, `useSessionStorage` and `useStoredValue` (every component
123
+ `storageKey` prop) write under one scheme, `<owner>.<area>.<name>`, e.g.
124
+ `cfg.ui.sidebar_width`, `kino.player.volume`. Full rules for agents:
125
+ `@djangocfg/api` → `@docs/STORAGE_KEYS.md`.
126
+
127
+ - A key that already follows the scheme is used as is.
128
+ - A bare name (`'search-filter'`) is folded to `snake_case` and put under the
129
+ active owner: `cfg.ui.search_filter`. The owner is the hook's `namespace`
130
+ option, else the nearest `<StorageNamespaceProvider owner="kino">`, else the
131
+ framework default `cfg`.
132
+ - Quota recovery drops only non-personal keys of our scheme, never tokens,
133
+ drafts, the profile or third-party keys (`NEXT_LOCALE`).
134
+
135
+ ```tsx
136
+ // app root, once
137
+ <StorageNamespaceProvider owner="kino">…</StorageNamespaceProvider>
138
+
139
+ // lib/storage.ts, fixed keys declared once
140
+ export const kino = createStorageNamespace('kino');
141
+ export const VOLUME = kino.key('player', 'volume'); // kino.player.volume
142
+
143
+ const [volume, setVolume] = useLocalStorage(VOLUME, 1);
144
+ kino.keys(); // every kino.* key in localStorage
145
+ kino.clear(); // "reset preferences" (personal cfg keys survive)
146
+ ```
147
+
148
+ Helpers: `resolveStorageKey`, `isSchemeKey`, `listStorageKeys(owner, area)`,
149
+ `clearStorageNamespace(owner, { area, includePersonal })`,
150
+ `createStorageNamespace(owner)` (`key`, `storage` = guarded read/write/onChange, `keys`, `clear`).
151
+ Do not write `localStorage` literals or ad-hoc keys in components.
152
+
120
153
  ## Lib utilities (`/lib`)
121
154
 
122
155
  - `cn(...)` — `clsx` + `tailwind-merge` shortcut
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.603",
3
+ "version": "2.1.604",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -133,8 +133,8 @@
133
133
  "check:docs": "node scripts/check-docs.mjs"
134
134
  },
135
135
  "peerDependencies": {
136
- "@djangocfg/api": "^2.1.603",
137
- "@djangocfg/i18n": "^2.1.603",
136
+ "@djangocfg/api": "^2.1.604",
137
+ "@djangocfg/i18n": "^2.1.604",
138
138
  "consola": "^3.4.2",
139
139
  "lucide-react": "^0.545.0",
140
140
  "moment": "^2.30.1",
@@ -210,10 +210,10 @@
210
210
  "vaul": "1.1.2"
211
211
  },
212
212
  "devDependencies": {
213
- "@djangocfg/api": "^2.1.603",
214
- "@djangocfg/eslint-config": "^2.1.603",
215
- "@djangocfg/i18n": "^2.1.603",
216
- "@djangocfg/typescript-config": "^2.1.603",
213
+ "@djangocfg/api": "^2.1.604",
214
+ "@djangocfg/eslint-config": "^2.1.604",
215
+ "@djangocfg/i18n": "^2.1.604",
216
+ "@djangocfg/typescript-config": "^2.1.604",
217
217
  "@storybook/react-vite": "^10.5.0",
218
218
  "@types/node": "^24.13.3",
219
219
  "@types/react": "19.2.15",
@@ -19,3 +19,19 @@ export { useStateMachine } from './useStateMachine';
19
19
  export type { StateMachineConfig } from './useStateMachine';
20
20
  export { useCallbackRef } from './useCallbackRef';
21
21
  export { usePrevious } from './usePrevious';
22
+ export {
23
+ DEFAULT_STORAGE_NAMESPACE,
24
+ StorageNamespaceProvider,
25
+ useStorageNamespace,
26
+ resolveStorageKey,
27
+ isSchemeKey,
28
+ listStorageKeys,
29
+ clearStorageNamespace,
30
+ createStorageNamespace,
31
+ } from './storage-namespace';
32
+ export type {
33
+ StorageNamespace,
34
+ StorageNamespaceProviderProps,
35
+ StorageArea,
36
+ ClearStorageOptions,
37
+ } from './storage-namespace';
@@ -0,0 +1,215 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Storage namespaces for the state hooks.
5
+ *
6
+ * Every key we write to the browser follows one scheme (full rules:
7
+ * `@djangocfg/api` → `@docs/STORAGE_KEYS.md`):
8
+ *
9
+ * `<owner>.<area>.<name>[.<id>]` e.g. `cfg.ui.sidebar_width`, `kino.player.volume`
10
+ *
11
+ * The hooks (`useLocalStorage`, `useSessionStorage`, `useStoredValue`) enforce
12
+ * it by resolving the key you pass:
13
+ *
14
+ * 1. A key that already follows the scheme (`kino.player.volume`) is used as is.
15
+ * 2. A bare name (`sidebar-width`, `Last Volume`) is folded to `snake_case` and
16
+ * placed under the active namespace: `<owner>.ui.sidebar_width`.
17
+ *
18
+ * The active owner is, in order: the hook's `namespace` option, the nearest
19
+ * `<StorageNamespaceProvider owner="…">`, then `cfg` (the framework default).
20
+ * An app sets its owner once at the root and forgets about it.
21
+ */
22
+
23
+ import { createContext, createElement, useContext, type ReactNode } from 'react';
24
+ import {
25
+ CFG_FAMILIES,
26
+ CFG_KEYS,
27
+ createStorage,
28
+ normalizeSegment,
29
+ type GuardedStorage,
30
+ } from '@djangocfg/api/storage';
31
+
32
+ /** The framework's owner. Used when nothing else is declared. */
33
+ export const DEFAULT_STORAGE_NAMESPACE = 'cfg';
34
+
35
+ /** Area given to a bare key. Hook-level state is UI state unless the caller names the area. */
36
+ export const DEFAULT_STORAGE_AREA = 'ui';
37
+
38
+ const OWNER_RE = /^[a-z][a-z0-9_]*$/;
39
+ const SCHEME_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
40
+
41
+ const NamespaceContext = createContext<string>(DEFAULT_STORAGE_NAMESPACE);
42
+
43
+ export interface StorageNamespaceProviderProps {
44
+ /** Short lower-case owner for every key written below: `kino`, `mls`, `crm`. */
45
+ owner: string;
46
+ children?: ReactNode;
47
+ }
48
+
49
+ /**
50
+ * Declares the owner of every bare storage key in the subtree.
51
+ * Place it once, near the root of an app.
52
+ */
53
+ export function StorageNamespaceProvider({ owner, children }: StorageNamespaceProviderProps) {
54
+ if (!OWNER_RE.test(owner)) {
55
+ throw new TypeError(`StorageNamespaceProvider: owner "${owner}" must match ${OWNER_RE}`);
56
+ }
57
+ return createElement(NamespaceContext.Provider, { value: owner }, children);
58
+ }
59
+
60
+ /** The owner the hooks use here (the provider's, or `cfg`). */
61
+ export function useStorageNamespace(): string {
62
+ return useContext(NamespaceContext);
63
+ }
64
+
65
+ /** True when `key` already follows `<owner>.<area>.<name>`. */
66
+ export function isSchemeKey(key: string): boolean {
67
+ return SCHEME_RE.test(key);
68
+ }
69
+
70
+ /**
71
+ * Pure resolver behind the hooks: scheme keys pass through, bare names are
72
+ * folded and prefixed with `<owner>.ui.`. Throws when nothing usable is left.
73
+ */
74
+ export function resolveStorageKey(key: string, owner: string = DEFAULT_STORAGE_NAMESPACE): string {
75
+ if (isSchemeKey(key)) return key;
76
+ const name = normalizeSegment(key);
77
+ if (!name) throw new TypeError(`storage key "${key}" is empty after normalisation`);
78
+ return `${owner}.${DEFAULT_STORAGE_AREA}.${name}`;
79
+ }
80
+
81
+ /** Hook form: owner = option → provider → `cfg`. */
82
+ export function useResolvedStorageKey(key: string, namespace?: string): string {
83
+ const fromContext = useStorageNamespace();
84
+ return resolveStorageKey(key, namespace ?? fromContext);
85
+ }
86
+
87
+ // ---- eviction (shared by the quota-recovery paths of the hooks) -------------
88
+
89
+ const PERSONAL_KEYS = new Set(
90
+ Object.values(CFG_KEYS)
91
+ .filter((spec) => spec.personal)
92
+ .map((spec) => spec.key),
93
+ );
94
+ const PERSONAL_PREFIXES = Object.values(CFG_FAMILIES)
95
+ .filter((spec) => spec.personal)
96
+ .map((spec) => spec.prefix);
97
+
98
+ /**
99
+ * May the quota-recovery code drop this key? Only keys that follow our scheme
100
+ * and are not personal (tokens, profile, drafts). Third-party keys such as
101
+ * `NEXT_LOCALE` and everything signed-in-user related are never evicted.
102
+ */
103
+ export function isEvictableKey(key: string): boolean {
104
+ if (!isSchemeKey(key)) return false;
105
+ if (key.startsWith('cfg.auth.') || PERSONAL_KEYS.has(key)) return false;
106
+ return !PERSONAL_PREFIXES.some((prefix) => key.startsWith(prefix));
107
+ }
108
+
109
+ /** Names in `storage` that the quota recovery is allowed to remove. */
110
+ export function evictableKeys(storage: Storage): string[] {
111
+ const out: string[] = [];
112
+ for (let i = 0; i < storage.length; i += 1) {
113
+ const name = storage.key(i);
114
+ if (name && isEvictableKey(name)) out.push(name);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // ---- inspection / reset ------------------------------------------------------
120
+
121
+ export type StorageArea = 'local' | 'session';
122
+
123
+ function nativeArea(area: StorageArea): Storage | null {
124
+ if (typeof window === 'undefined') return null;
125
+ try {
126
+ return area === 'session' ? window.sessionStorage : window.localStorage;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /** Every key an owner holds in one store (`cfg` by default). */
133
+ export function listStorageKeys(owner: string = DEFAULT_STORAGE_NAMESPACE, area: StorageArea = 'local'): string[] {
134
+ const native = nativeArea(area);
135
+ if (!native) return [];
136
+ const prefix = `${owner}.`;
137
+ const out: string[] = [];
138
+ for (let i = 0; i < native.length; i += 1) {
139
+ const name = native.key(i);
140
+ if (name?.startsWith(prefix)) out.push(name);
141
+ }
142
+ return out.sort();
143
+ }
144
+
145
+ export interface ClearStorageOptions {
146
+ /** Store to clear. Default: both. */
147
+ area?: StorageArea;
148
+ /** Also remove personal keys (tokens, profile, drafts). Default false: preferences only. */
149
+ includePersonal?: boolean;
150
+ }
151
+
152
+ /**
153
+ * Removes the keys of one owner. By default personal keys survive, so a
154
+ * "reset preferences" button never signs the user out.
155
+ * Hooks mounted on a removed key keep their in-memory value until they write.
156
+ */
157
+ export function clearStorageNamespace(
158
+ owner: string = DEFAULT_STORAGE_NAMESPACE,
159
+ { area, includePersonal = false }: ClearStorageOptions = {},
160
+ ): string[] {
161
+ const areas: StorageArea[] = area ? [area] : ['local', 'session'];
162
+ const removed: string[] = [];
163
+ for (const a of areas) {
164
+ const native = nativeArea(a);
165
+ if (!native) continue;
166
+ for (const name of listStorageKeys(owner, a)) {
167
+ if (!includePersonal && owner === DEFAULT_STORAGE_NAMESPACE && !isEvictableKey(name)) continue;
168
+ try {
169
+ native.removeItem(name);
170
+ removed.push(name);
171
+ } catch {
172
+ /* ignore */
173
+ }
174
+ }
175
+ }
176
+ return removed;
177
+ }
178
+
179
+ // ---- an app's namespace -------------------------------------------------------
180
+
181
+ export interface StorageNamespace {
182
+ readonly owner: string;
183
+ /** `kino.player.volume` from `('player', 'volume')`; parts are folded to snake_case. */
184
+ key(...parts: string[]): string;
185
+ /** Guarded non-React access (`read`, `write`, `readJSON`, `onChange`, `clearAll`…). */
186
+ readonly storage: GuardedStorage;
187
+ keys(area?: StorageArea): string[];
188
+ clear(options?: ClearStorageOptions): string[];
189
+ }
190
+
191
+ /**
192
+ * Declares an app's namespace in one place:
193
+ *
194
+ * ```ts
195
+ * // lib/storage.ts
196
+ * export const kinoStorage = createStorageNamespace('kino');
197
+ * export const PLAYER_VOLUME = kinoStorage.key('player', 'volume'); // kino.player.volume
198
+ *
199
+ * // component
200
+ * const [volume, setVolume] = useLocalStorage(PLAYER_VOLUME, 1);
201
+ * ```
202
+ *
203
+ * and wraps the app root in `<StorageNamespaceProvider owner="kino">` so bare
204
+ * `storageKey` props on ui-core components land under `kino.ui.*`.
205
+ */
206
+ export function createStorageNamespace(owner: string): StorageNamespace {
207
+ const storage = createStorage(owner);
208
+ return {
209
+ owner,
210
+ key: (...parts) => storage.key(...parts),
211
+ storage,
212
+ keys: (area = 'local') => listStorageKeys(owner, area),
213
+ clear: (options) => clearStorageNamespace(owner, options),
214
+ };
215
+ }
@@ -3,6 +3,7 @@
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
 
5
5
  import { isQuotaExceededError } from './storage-quota';
6
+ import { evictableKeys, useResolvedStorageKey } from './storage-namespace';
6
7
 
7
8
  /**
8
9
  * Storage wrapper format with metadata
@@ -27,6 +28,12 @@ export interface UseLocalStorageOptions {
27
28
  * @example 24 * 60 * 60 * 1000 // 24 hours
28
29
  */
29
30
  ttl?: number;
31
+ /**
32
+ * Owner for a bare key (`'sidebar-width'` → `<owner>.ui.sidebar_width`).
33
+ * Default: the nearest `StorageNamespaceProvider`, else `cfg`.
34
+ * A key that already follows `<owner>.<area>.<name>` is used as is.
35
+ */
36
+ namespace?: string;
30
37
  }
31
38
 
32
39
  /**
@@ -152,10 +159,11 @@ function broadcastKey(
152
159
  * patch({ b: 3 }); // merges → { a: 1, b: 3 }, only writes if it changed
153
160
  */
154
161
  export function useLocalStorage<T>(
155
- key: string,
162
+ keyInput: string,
156
163
  initialValue: T,
157
164
  options?: UseLocalStorageOptions
158
165
  ): UseLocalStorageReturn<T> {
166
+ const key = useResolvedStorageKey(keyInput, options?.namespace);
159
167
  const ttl = options?.ttl;
160
168
  // Always start with initialValue to match SSR
161
169
  const [storedValue, setStoredValue] = useState<T>(initialValue);
@@ -276,43 +284,25 @@ export function useLocalStorage<T>(
276
284
  };
277
285
 
278
286
  // Clear old data when localStorage is full
279
- const clearOldData = () => {
287
+ // Drop evictable keys (our scheme, non-personal) when the quota is full.
288
+ // Tokens, drafts and third-party keys are never touched.
289
+ const evict = (fraction: number) => {
280
290
  try {
281
- const keys = Object.keys(localStorage).filter(key => key && typeof key === 'string');
282
- // Remove oldest items if we have more than 50 items
283
- if (keys.length > 50) {
284
- const itemsToRemove = Math.ceil(keys.length * 0.2);
285
- for (let i = 0; i < itemsToRemove; i++) {
286
- try {
287
- const key = keys[i];
288
- if (key) {
289
- localStorage.removeItem(key);
290
- }
291
- } catch {
292
- // Ignore errors when removing items
293
- }
294
- }
295
- }
296
- } catch (error) {
297
- console.error('Error clearing old localStorage data:', error);
298
- }
299
- };
300
-
301
- // Force clear all data if quota is exceeded
302
- const forceClearAll = () => {
303
- try {
304
- const keys = Object.keys(localStorage);
305
- for (const key of keys) {
291
+ const names = evictableKeys(window.localStorage).filter((name) => name !== key);
292
+ const count = fraction >= 1 ? names.length : Math.ceil(names.length * fraction);
293
+ for (const name of names.slice(0, count)) {
306
294
  try {
307
- localStorage.removeItem(key);
295
+ window.localStorage.removeItem(name);
308
296
  } catch {
309
297
  // Ignore errors when removing items
310
298
  }
311
299
  }
312
300
  } catch (error) {
313
- console.error('Error force clearing localStorage:', error);
301
+ console.error('Error clearing old localStorage data:', error);
314
302
  }
315
303
  };
304
+ const clearOldData = () => evict(0.2);
305
+ const forceClearAll = () => evict(1);
316
306
 
317
307
  // Prepare data for storage (with or without TTL wrapper)
318
308
  const prepareForStorage = (value: T): string => {
@@ -3,6 +3,7 @@
3
3
  import { useState } from 'react';
4
4
 
5
5
  import { isQuotaExceededError } from './storage-quota';
6
+ import { evictableKeys, useResolvedStorageKey } from './storage-namespace';
6
7
 
7
8
  /**
8
9
  * Storage wrapper format with metadata
@@ -27,6 +28,12 @@ export interface UseSessionStorageOptions {
27
28
  * @example 24 * 60 * 60 * 1000 // 24 hours
28
29
  */
29
30
  ttl?: number;
31
+ /**
32
+ * Owner for a bare key (`'wizard-step'` → `<owner>.ui.wizard_step`).
33
+ * Default: the nearest `StorageNamespaceProvider`, else `cfg`.
34
+ * A key that already follows `<owner>.<area>.<name>` is used as is.
35
+ */
36
+ namespace?: string;
30
37
  }
31
38
 
32
39
  /**
@@ -89,10 +96,11 @@ function isExpired<T>(wrapped: StorageWrapper<T>): boolean {
89
96
  * });
90
97
  */
91
98
  export function useSessionStorage<T>(
92
- key: string,
99
+ keyInput: string,
93
100
  initialValue: T,
94
101
  options?: UseSessionStorageOptions
95
102
  ): UseSessionStorageReturn<T> {
103
+ const key = useResolvedStorageKey(keyInput, options?.namespace);
96
104
  const ttl = options?.ttl;
97
105
 
98
106
  // Get initial value from sessionStorage or use provided initialValue
@@ -152,44 +160,25 @@ export function useSessionStorage<T>(
152
160
  }
153
161
  };
154
162
 
155
- // Clear old data when sessionStorage is full
156
- const clearOldData = () => {
163
+ // Drop evictable keys (our scheme, non-personal) when the quota is full.
164
+ // Tokens, drafts and third-party keys are never touched.
165
+ const evict = (fraction: number) => {
157
166
  try {
158
- const keys = Object.keys(sessionStorage).filter(k => k && typeof k === 'string');
159
- // Remove oldest items if we have more than 50 items
160
- if (keys.length > 50) {
161
- const itemsToRemove = Math.ceil(keys.length * 0.2);
162
- for (let i = 0; i < itemsToRemove; i++) {
163
- try {
164
- const k = keys[i];
165
- if (k) {
166
- sessionStorage.removeItem(k);
167
- }
168
- } catch {
169
- // Ignore errors when removing items
170
- }
171
- }
172
- }
173
- } catch (error) {
174
- console.error('Error clearing old sessionStorage data:', error);
175
- }
176
- };
177
-
178
- // Force clear all data if quota is exceeded
179
- const forceClearAll = () => {
180
- try {
181
- const keys = Object.keys(sessionStorage);
182
- for (const k of keys) {
167
+ const names = evictableKeys(window.sessionStorage).filter((name) => name !== key);
168
+ const count = fraction >= 1 ? names.length : Math.ceil(names.length * fraction);
169
+ for (const name of names.slice(0, count)) {
183
170
  try {
184
- sessionStorage.removeItem(k);
171
+ window.sessionStorage.removeItem(name);
185
172
  } catch {
186
173
  // Ignore errors when removing items
187
174
  }
188
175
  }
189
176
  } catch (error) {
190
- console.error('Error force clearing sessionStorage:', error);
177
+ console.error('Error clearing old sessionStorage data:', error);
191
178
  }
192
179
  };
180
+ const clearOldData = () => evict(0.2);
181
+ const forceClearAll = () => evict(1);
193
182
 
194
183
  // Prepare data for storage (with or without TTL wrapper)
195
184
  const prepareForStorage = (value: T): string => {
@@ -19,6 +19,11 @@ export interface UseStoredValueOptions {
19
19
  * @example ttl: 24 * 60 * 60 * 1000 // 24 hours
20
20
  */
21
21
  ttl?: number;
22
+ /**
23
+ * Owner for a bare `storageKey`. Default: nearest `StorageNamespaceProvider`, else `cfg`.
24
+ * @see resolveStorageKey
25
+ */
26
+ namespace?: string;
22
27
  }
23
28
 
24
29
  /**
@@ -41,6 +46,9 @@ export type UseStoredValueReturn<T> = readonly [
41
46
  * Used by components (Input, Combobox, MultiSelect, Tabs, etc.) to add
42
47
  * optional persistence via a single `storageKey` prop.
43
48
  *
49
+ * `storageKey` follows the storage-key scheme (see `./storage-namespace`): a full
50
+ * `owner.area.name` key is used as is, a bare name lands under `<owner>.ui.<name>`.
51
+ *
44
52
  * When storageKey is undefined the hook is a no-op:
45
53
  * - returns initialValue as current value
46
54
  * - setValue / removeValue / patch are no-ops
@@ -55,12 +63,12 @@ export function useStoredValue<T>(
55
63
  options?: UseStoredValueOptions,
56
64
  ): UseStoredValueReturn<T> {
57
65
  const storageType = options?.storage ?? 'local';
58
- const ttlOption = options?.ttl ? { ttl: options.ttl } : undefined;
66
+ const ttlOption = { ttl: options?.ttl, namespace: options?.namespace };
59
67
 
60
68
  // Both hooks are always called (rules of hooks — no conditional calls).
61
69
  // Only one of them is actually used depending on storageType.
62
70
  // When storageKey is undefined we pass a dummy key; the result is discarded.
63
- const dummyKey = '__useStoredValue_noop__';
71
+ const dummyKey = 'cfg.ui.noop';
64
72
 
65
73
  const localResult = useLocalStorage<T>(
66
74
  storageType === 'local' && storageKey ? storageKey : dummyKey,