@djangocfg/ui-core 2.1.310 → 2.1.312

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
@@ -87,7 +87,7 @@ import { Button, Dialog, Table } from '@djangocfg/ui-core';
87
87
  </SidePanel>
88
88
  ```
89
89
 
90
- **`Drawer`** — modal vaul panel sliding from any edge (`top` / `right` / `bottom` / `left`). Sizes: `sm` `md` `lg` `xl` `full`, default `md`; or pass an explicit `width` / `height`. Pass `resizable` to drag the inner edge — auto-disabled on mobile (< 768px) unless `resizableOnDesktopOnly={false}`. Tune with `minSize` / `maxSize` (px) and observe via `onResizeChange`. Pass `persistKey` to save the resized size in `localStorage` and restore it on next open. The store is exported as `useDrawerSizeStore` (with `clearSize` / `clearAll`).
90
+ **`Drawer`** — modal vaul panel sliding from any edge (`top` / `right` / `bottom` / `left`). Sizes: `sm` `md` `lg` `xl` `full`, default `md`; or pass an explicit `width` / `height`. Pass `resizable` to drag the inner edge — auto-disabled on mobile (< 768px) unless `resizableOnDesktopOnly={false}`. Tune with `minSize` / `maxSize` (px). Resize is controlled via `resizedSize` + `onSizeChange` (uncontrolled if omitted). For built-in `localStorage` persistence use the `useDrawerSize(key, { axis, min, max })` hook it returns `{ size, setSize, reset }` you wire into the same props. Backed by the centralized `useUIPersistedState` / `useUIPersistStore` (see below).
91
91
 
92
92
  ```tsx
93
93
  <Drawer direction="right">
@@ -424,6 +424,58 @@ import '@djangocfg/ui-core/styles/globals';
424
424
  | `@djangocfg/ui-core/styles` | CSS |
425
425
  | `@djangocfg/ui-core/styles/palette` | Theme palette hooks & utilities |
426
426
 
427
+ ## Persisted UI State
428
+
429
+ Centralized localStorage-backed store for component preferences. One zustand+persist store under `djangocfg.ui.state`, scoped by category. Single migration surface, single «reset all» button.
430
+
431
+ ### Per-component hooks
432
+
433
+ ```tsx
434
+ // Drawer size
435
+ const drawer = useDrawerSize('settings', { min: 320, max: 900 });
436
+ <DrawerContent resizable resizedSize={drawer.size} onSizeChange={drawer.setSize} />
437
+
438
+ // Tabs
439
+ const { tab, setTab } = useTabsState('settings-page', 'general', {
440
+ allowed: ['general', 'appearance', 'advanced'],
441
+ });
442
+ <Tabs value={tab} onValueChange={setTab}>...</Tabs>
443
+
444
+ // Accordion
445
+ const { value, setValue } = useAccordionMultipleState('sidebar', ['general']);
446
+ <Accordion type="multiple" value={value} onValueChange={setValue}>...</Accordion>
447
+ ```
448
+
449
+ Need persist for something not on this list (table prefs, sidebar state, etc.)? Use the generic `useUIPersistedState` directly — it's the same API every per-component hook is built on top of.
450
+
451
+ ### Generic API
452
+
453
+ ```tsx
454
+ import {
455
+ useUIPersistedState,
456
+ useUIPersistedStateThrottled,
457
+ useUIPersistStore,
458
+ } from '@djangocfg/ui-core';
459
+
460
+ // Build your own per-component hook
461
+ const { value, setValue, reset, hydrated } = useUIPersistedState(
462
+ 'my-scope', 'instance-key', defaultValue,
463
+ { sanitize: (raw) => /* clamp / validate / migrate */ raw },
464
+ );
465
+
466
+ // Throttled writes for high-frequency updates (live splitter, etc.)
467
+ useUIPersistedStateThrottled('splitter', 'main', 50, 200 /* ms */);
468
+ ```
469
+
470
+ ### Inspection / DevTools
471
+
472
+ ```tsx
473
+ useUIPersistStore.getState().getAll(); // snapshot of all scopes
474
+ useUIPersistStore.getState().listScopes(); // ['drawer-size:width', 'tabs', ...]
475
+ useUIPersistStore.getState().clearScope('tabs');
476
+ useUIPersistStore.getState().clearAll(); // reset all UI preferences
477
+ ```
478
+
427
479
  ## Runtime Error Emitter
428
480
 
429
481
  Emit runtime errors as events (caught by ErrorTrackingProvider in layouts):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.310",
3
+ "version": "2.1.312",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -91,7 +91,7 @@
91
91
  "playground": "playground dev"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/i18n": "^2.1.310",
94
+ "@djangocfg/i18n": "^2.1.312",
95
95
  "consola": "^3.4.2",
96
96
  "lucide-react": "^0.545.0",
97
97
  "moment": "^2.30.1",
@@ -160,9 +160,9 @@
160
160
  "vaul": "1.1.2"
161
161
  },
162
162
  "devDependencies": {
163
- "@djangocfg/i18n": "^2.1.310",
163
+ "@djangocfg/i18n": "^2.1.312",
164
164
  "@djangocfg/playground": "workspace:*",
165
- "@djangocfg/typescript-config": "^2.1.310",
165
+ "@djangocfg/typescript-config": "^2.1.312",
166
166
  "@types/node": "^24.7.2",
167
167
  "@types/react": "^19.1.0",
168
168
  "@types/react-dom": "^19.1.0",
@@ -41,7 +41,7 @@ export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, Dialog
41
41
  export { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, AlertDialogPortal, AlertDialogOverlay } from './overlay/alert-dialog';
42
42
  export { Popover, PopoverContent, PopoverTrigger, PopoverAnchor, PopoverArrow } from './overlay/popover';
43
43
  export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription, SheetPortal, SheetOverlay } from './overlay/sheet';
44
- export { Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerPortal, DrawerOverlay, useDrawerSizeStore } from './overlay/drawer';
44
+ export { Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, DrawerPortal, DrawerOverlay, useDrawerSize } from './overlay/drawer';
45
45
  export { ResponsiveSheet, ResponsiveSheetContent, ResponsiveSheetHeader, ResponsiveSheetTitle, ResponsiveSheetDescription, ResponsiveSheetFooter } from './overlay/responsive-sheet';
46
46
  export { SidePanel, SidePanelContent, SidePanelHeader, SidePanelTitle, SidePanelDescription, SidePanelBody, SidePanelFooter, SidePanelClose } from './overlay/side-panel';
47
47
  export type { SidePanelProps, SidePanelContentProps, SidePanelCloseProps } from './overlay/side-panel';
@@ -57,7 +57,11 @@ export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, Menu
57
57
  export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup } from './navigation/dropdown-menu';
58
58
  export { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './navigation/context-menu';
59
59
  export { Tabs, TabsContent, TabsList, TabsTrigger } from './navigation/tabs';
60
+ export { useTabsState } from './navigation/tabs/useTabsState';
61
+ export type { UseTabsStateOptions, UseTabsStateResult } from './navigation/tabs/useTabsState';
60
62
  export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './navigation/accordion';
63
+ export { useAccordionSingleState, useAccordionMultipleState } from './navigation/accordion/useAccordionState';
64
+ export type { UseAccordionSingleResult, UseAccordionMultipleResult } from './navigation/accordion/useAccordionState';
61
65
  export { Collapsible, CollapsibleContent, CollapsibleTrigger } from './navigation/collapsible';
62
66
  export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut } from './navigation/command';
63
67
  export { Link, LinkProvider, LinkComponentContext, useLinkComponent } from './navigation/link';
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { useUIPersistedState } from '../../../lib/persist';
6
+
7
+ const SCOPE_SINGLE = 'accordion:single';
8
+ const SCOPE_MULTIPLE = 'accordion:multiple';
9
+
10
+ export interface UseAccordionSingleResult {
11
+ /** Currently open item id, or empty string if all closed. */
12
+ value: string;
13
+ setValue: (next: string) => void;
14
+ reset: () => void;
15
+ }
16
+
17
+ export interface UseAccordionMultipleResult {
18
+ /** List of open item ids. */
19
+ value: string[];
20
+ setValue: (next: string[]) => void;
21
+ reset: () => void;
22
+ }
23
+
24
+ /**
25
+ * Persist a single-mode Accordion's open item.
26
+ *
27
+ * @example
28
+ * const { value, setValue } = useAccordionSingleState('sidebar-groups');
29
+ * <Accordion type="single" value={value} onValueChange={setValue}>...</Accordion>
30
+ */
31
+ export function useAccordionSingleState(
32
+ key: string,
33
+ defaultValue = '',
34
+ ): UseAccordionSingleResult {
35
+ const sanitize = React.useCallback(
36
+ (raw: string) => (typeof raw === 'string' ? raw : undefined),
37
+ [],
38
+ );
39
+ const persisted = useUIPersistedState<string>(SCOPE_SINGLE, key, defaultValue, { sanitize });
40
+ return {
41
+ value: persisted.value,
42
+ setValue: persisted.setValue,
43
+ reset: persisted.reset,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Persist a multiple-mode Accordion's open items.
49
+ *
50
+ * @example
51
+ * const { value, setValue } = useAccordionMultipleState('sidebar-groups', ['general']);
52
+ * <Accordion type="multiple" value={value} onValueChange={setValue}>...</Accordion>
53
+ */
54
+ export function useAccordionMultipleState(
55
+ key: string,
56
+ defaultValue: string[] = [],
57
+ ): UseAccordionMultipleResult {
58
+ const sanitize = React.useCallback(
59
+ (raw: string[]) =>
60
+ Array.isArray(raw) && raw.every((v) => typeof v === 'string') ? raw : undefined,
61
+ [],
62
+ );
63
+ const persisted = useUIPersistedState<string[]>(SCOPE_MULTIPLE, key, defaultValue, { sanitize });
64
+ return {
65
+ value: persisted.value,
66
+ setValue: persisted.setValue,
67
+ reset: persisted.reset,
68
+ };
69
+ }
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { useUIPersistedState } from '../../../lib/persist';
6
+
7
+ const SCOPE = 'tabs';
8
+
9
+ export interface UseTabsStateOptions<T extends string = string> {
10
+ /**
11
+ * Whitelist of allowed tab values. If the persisted value is not in
12
+ * this list, falls back to default. Pass when you don't want a stale
13
+ * value (e.g. tab renamed/removed) to leak through.
14
+ */
15
+ allowed?: readonly T[];
16
+ }
17
+
18
+ export interface UseTabsStateResult<T extends string = string> {
19
+ /** Current tab value (default until hydrated, or if stored value is no longer allowed). */
20
+ tab: T;
21
+ /** Persist new tab value. Wire to `<Tabs onValueChange={...}>`. */
22
+ setTab: (next: T) => void;
23
+ /** Remove the persisted entry. */
24
+ reset: () => void;
25
+ }
26
+
27
+ /**
28
+ * Persist Tabs `value` between sessions.
29
+ *
30
+ * @example
31
+ * const { tab, setTab } = useTabsState('settings-page', 'general', { allowed: ['general', 'appearance', 'advanced'] });
32
+ * <Tabs value={tab} onValueChange={setTab}>...</Tabs>
33
+ */
34
+ export function useTabsState<T extends string = string>(
35
+ key: string,
36
+ defaultTab: T,
37
+ options: UseTabsStateOptions<T> = {},
38
+ ): UseTabsStateResult<T> {
39
+ const { allowed } = options;
40
+ const sanitize = React.useCallback(
41
+ (raw: T) => {
42
+ if (typeof raw !== 'string') return undefined;
43
+ if (allowed && !allowed.includes(raw)) return undefined;
44
+ return raw;
45
+ },
46
+ [allowed],
47
+ );
48
+
49
+ const { value, setValue, reset } = useUIPersistedState<T>(
50
+ SCOPE,
51
+ key,
52
+ defaultTab,
53
+ { sanitize },
54
+ );
55
+
56
+ return { tab: value, setTab: setValue, reset };
57
+ }
@@ -9,6 +9,7 @@ import {
9
9
  DrawerTitle,
10
10
  DrawerDescription,
11
11
  DrawerClose,
12
+ useDrawerSize,
12
13
  type DrawerSize,
13
14
  } from '.';
14
15
  import { Button } from '../../forms/button';
@@ -257,7 +258,7 @@ export const Resizable = () => {
257
258
  resizable
258
259
  minSize={320}
259
260
  maxSize={900}
260
- onResizeChange={setSize}
261
+ onSizeChange={setSize}
261
262
  >
262
263
  <DrawerHeader>
263
264
  <DrawerTitle>Resizable</DrawerTitle>
@@ -277,42 +278,49 @@ export const Resizable = () => {
277
278
  );
278
279
  };
279
280
 
280
- export const ResizablePersisted = () => (
281
- <div className="space-y-3">
282
- <p className="text-sm text-muted-foreground">
283
- Same as <code>Resizable</code>, but the size is persisted in
284
- <code> localStorage</code> under <code>persistKey="story-demo"</code>
285
- reload the page and reopen, the last width is restored.
286
- </p>
287
- <Drawer direction="right">
288
- <DrawerTrigger asChild>
289
- <Button variant="outline">Open persisted drawer</Button>
290
- </DrawerTrigger>
291
- <DrawerContent
292
- direction="right"
293
- size="md"
294
- resizable
295
- persistKey="story-demo"
296
- minSize={320}
297
- maxSize={900}
298
- >
299
- <DrawerHeader>
300
- <DrawerTitle>Resizable + persisted</DrawerTitle>
301
- <DrawerDescription>
302
- Width survives reloads. Open DevTools → Application → Local Storage
303
- and look for <code>djangocfg.ui.drawer-sizes</code>.
304
- </DrawerDescription>
305
- </DrawerHeader>
306
- <div className="p-4 text-sm">Drag the left edge to resize.</div>
307
- <DrawerFooter>
308
- <DrawerClose asChild>
309
- <Button variant="outline">Close</Button>
310
- </DrawerClose>
311
- </DrawerFooter>
312
- </DrawerContent>
313
- </Drawer>
314
- </div>
315
- );
281
+ export const ResizablePersisted = () => {
282
+ const drawer = useDrawerSize('story-demo', { axis: 'width', min: 320, max: 900 });
283
+ return (
284
+ <div className="space-y-3">
285
+ <p className="text-sm text-muted-foreground">
286
+ Persistence is wired via the <code>useDrawerSize(key)</code> hook
287
+ the drawer itself is just a controlled component. Reload the page and
288
+ reopen, the last width is restored from <code>localStorage</code>.
289
+ </p>
290
+ <div className="flex gap-2">
291
+ <Drawer direction="right">
292
+ <DrawerTrigger asChild>
293
+ <Button variant="outline">Open persisted drawer</Button>
294
+ </DrawerTrigger>
295
+ <DrawerContent
296
+ direction="right"
297
+ size="md"
298
+ resizable
299
+ minSize={320}
300
+ maxSize={900}
301
+ resizedSize={drawer.size}
302
+ onSizeChange={drawer.setSize}
303
+ >
304
+ <DrawerHeader>
305
+ <DrawerTitle>Resizable + persisted</DrawerTitle>
306
+ <DrawerDescription>
307
+ Stored in <code>djangocfg.ui.state</code>. Current:{' '}
308
+ {drawer.size != null ? `${Math.round(drawer.size)}px` : 'preset (480px)'}
309
+ </DrawerDescription>
310
+ </DrawerHeader>
311
+ <div className="p-4 text-sm">Drag the left edge to resize.</div>
312
+ <DrawerFooter>
313
+ <DrawerClose asChild>
314
+ <Button variant="outline">Close</Button>
315
+ </DrawerClose>
316
+ </DrawerFooter>
317
+ </DrawerContent>
318
+ </Drawer>
319
+ <Button variant="ghost" onClick={drawer.reset}>Reset stored size</Button>
320
+ </div>
321
+ </div>
322
+ );
323
+ };
316
324
 
317
325
  export const NarrowViewport = () => (
318
326
  <div className="space-y-3">
@@ -5,7 +5,6 @@ import { Drawer as DrawerPrimitive } from 'vaul';
5
5
 
6
6
  import { cn } from '../../../lib/utils';
7
7
  import { useIsMobile } from '../../../hooks/media/useMobile';
8
- import { useDrawerSizeStore } from './store';
9
8
 
10
9
  const Drawer = ({
11
10
  shouldScaleBackground = true,
@@ -78,11 +77,6 @@ const verticalSizePx: Record<DrawerSize, number> = {
78
77
  full: 900,
79
78
  };
80
79
 
81
- // Anchor + border + radius only — no width/height bake-in. Length is
82
- // applied via inline `style` from props so vaul's first
83
- // `getBoundingClientRect()` measurement matches the final layout
84
- // (avoids the inset.left=mismatch bug when className-based defaults
85
- // leak into the first paint).
86
80
  const directionStyles = {
87
81
  bottom: "inset-x-0 bottom-0 mt-24 rounded-t-lg border-t",
88
82
  top: "inset-x-0 top-0 mb-24 rounded-b-lg border-b",
@@ -124,14 +118,14 @@ export interface DrawerContentProps
124
118
  minSize?: number;
125
119
  /** Max size in px when `resizable`. Width for horizontal, height for vertical. */
126
120
  maxSize?: number;
127
- /** Called with the new size in px on every resize step. */
128
- onResizeChange?: (size: number) => void;
129
121
  /**
130
- * Persist resized size in localStorage under this key. Without it,
131
- * resize is session-only. Width and height are stored separately,
132
- * so the same key can be reused across directions.
122
+ * Controlled current size in px. Pair with `onSizeChange` for external
123
+ * persistence (see `useDrawerSize` hook). When `undefined`, the drawer
124
+ * is uncontrolled and size lives in local state.
133
125
  */
134
- persistKey?: string;
126
+ resizedSize?: number;
127
+ /** Called once on pointer-up with the final resized size in px. */
128
+ onSizeChange?: (size: number) => void;
135
129
  }
136
130
 
137
131
  const DrawerContent = React.forwardRef<
@@ -149,8 +143,8 @@ const DrawerContent = React.forwardRef<
149
143
  resizableOnDesktopOnly = true,
150
144
  minSize,
151
145
  maxSize,
152
- onResizeChange,
153
- persistKey,
146
+ resizedSize,
147
+ onSizeChange,
154
148
  ...props
155
149
  }, ref) => {
156
150
  const isVertical = direction === 'bottom' || direction === 'top';
@@ -162,41 +156,25 @@ const DrawerContent = React.forwardRef<
162
156
  const minPx = minSize ?? defaultMin;
163
157
  const maxPx = maxSize ?? defaultMax;
164
158
 
165
- const initialPx = isVertical
159
+ const presetPx = isVertical
166
160
  ? parseInitialPx(height, verticalSizePx[size])
167
161
  : parseInitialPx(width, horizontalSizePx[size]);
168
162
 
169
- const [resizedPx, setResizedPx] = React.useState<number | null>(null);
170
- const axis: 'width' | 'height' = isVertical ? 'height' : 'width';
163
+ // Uncontrolled: track size locally. Controlled: caller owns it via `resizedSize`.
164
+ const isControlled = resizedSize !== undefined;
165
+ const [internalPx, setInternalPx] = React.useState<number | null>(null);
166
+ const currentPx = isControlled ? clamp(resizedSize!, minPx, maxPx) : internalPx;
171
167
 
172
- // Hydrate persist store on mount (skipHydration in middleware) and
173
- // pull stored size for this key+axis. Clamp to current min/max so a
174
- // value persisted under looser bounds doesn't escape.
168
+ // Reset uncontrolled state if direction or enabled flag flips, so we
169
+ // don't carry a horizontal width into a vertical drawer.
175
170
  React.useEffect(() => {
176
- if (!resizeEnabled || !persistKey) return;
177
- let cancelled = false;
178
- const applyStored = () => {
179
- if (cancelled) return;
180
- const stored = useDrawerSizeStore.getState().getSize(persistKey, axis);
181
- if (stored != null) {
182
- setResizedPx(clamp(stored, minPx, maxPx));
183
- }
184
- };
185
- Promise.resolve(useDrawerSizeStore.persist.rehydrate()).then(applyStored);
186
- return () => {
187
- cancelled = true;
188
- };
189
- }, [resizeEnabled, persistKey, axis, minPx, maxPx]);
190
-
191
- // Reset resized state if direction or enabled flag flips, so we don't
192
- // carry a horizontal width into a vertical drawer.
193
- React.useEffect(() => {
194
- setResizedPx(null);
195
- }, [direction, resizeEnabled]);
171
+ if (!isControlled) setInternalPx(null);
172
+ }, [direction, resizeEnabled, isControlled]);
196
173
 
197
174
  const dragStateRef = React.useRef<{
198
175
  startCoord: number;
199
176
  startSize: number;
177
+ lastSize: number;
200
178
  } | null>(null);
201
179
 
202
180
  const handlePointerDown = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
@@ -204,11 +182,13 @@ const DrawerContent = React.forwardRef<
204
182
  e.preventDefault();
205
183
  e.stopPropagation();
206
184
  (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
185
+ const startSize = currentPx ?? presetPx;
207
186
  dragStateRef.current = {
208
187
  startCoord: isVertical ? e.clientY : e.clientX,
209
- startSize: resizedPx ?? initialPx,
188
+ startSize,
189
+ lastSize: startSize,
210
190
  };
211
- }, [resizeEnabled, isVertical, resizedPx, initialPx]);
191
+ }, [resizeEnabled, isVertical, currentPx, presetPx]);
212
192
 
213
193
  const handlePointerMove = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
214
194
  const drag = dragStateRef.current;
@@ -216,30 +196,39 @@ const DrawerContent = React.forwardRef<
216
196
  e.preventDefault();
217
197
  const current = isVertical ? e.clientY : e.clientX;
218
198
  const delta = current - drag.startCoord;
219
- // Direction sign: handle is on the side facing into the viewport, so
220
- // dragging away from the anchor grows the drawer.
221
- const sign =
222
- direction === 'right' || direction === 'bottom' ? -1 : 1;
199
+ const sign = direction === 'right' || direction === 'bottom' ? -1 : 1;
223
200
  const next = clamp(drag.startSize + sign * delta, minPx, maxPx);
224
- setResizedPx(next);
225
- onResizeChange?.(next);
226
- }, [direction, isVertical, minPx, maxPx, onResizeChange]);
201
+ drag.lastSize = next;
202
+ if (!isControlled) setInternalPx(next);
203
+ // Note: onSizeChange fires on pointer-up only — avoids hammering
204
+ // localStorage / external stores during drag. For live feedback
205
+ // during drag, render from `currentPx` (uncontrolled) or from your
206
+ // own state synced separately.
207
+ }, [direction, isVertical, minPx, maxPx, isControlled]);
227
208
 
228
209
  const handlePointerUp = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
229
- if (!dragStateRef.current) return;
210
+ const drag = dragStateRef.current;
211
+ if (!drag) return;
230
212
  (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
231
213
  dragStateRef.current = null;
232
- if (persistKey && resizedPx != null) {
233
- useDrawerSizeStore.getState().setSize(persistKey, axis, resizedPx);
234
- }
235
- }, [persistKey, axis, resizedPx]);
236
-
237
- const resolvedWidth = !isVertical
238
- ? (resizedPx != null ? `${resizedPx}px` : (toCssLength(width) ?? horizontalSizePresets[size]))
214
+ onSizeChange?.(drag.lastSize);
215
+ }, [onSizeChange]);
216
+
217
+ // Viewport clamp — keeps the drawer inside the window on narrow screens.
218
+ // Without this, a fixed `width: 720px` on a 600px-wide window paints the
219
+ // drawer 120px past the right edge (off-screen for `direction="right"`).
220
+ // Applied to BOTH the resized px value and the initial preset so user-
221
+ // dragged widths can't overflow either. CSS `min()` is cheap and the
222
+ // browser re-evaluates on every viewport change, so window resize works
223
+ // for free.
224
+ const rawWidth = !isVertical
225
+ ? (currentPx != null ? `${currentPx}px` : (toCssLength(width) ?? horizontalSizePresets[size]))
239
226
  : undefined;
240
- const resolvedHeight = isVertical
241
- ? (resizedPx != null ? `${resizedPx}px` : (toCssLength(height) ?? verticalSizePresets[size]))
227
+ const rawHeight = isVertical
228
+ ? (currentPx != null ? `${currentPx}px` : (toCssLength(height) ?? verticalSizePresets[size]))
242
229
  : undefined;
230
+ const resolvedWidth = rawWidth ? `min(100vw, ${rawWidth})` : undefined;
231
+ const resolvedHeight = rawHeight ? `min(100vh, ${rawHeight})` : undefined;
243
232
 
244
233
  const handlePosition: Record<typeof direction, string> = {
245
234
  right: 'left-0 top-0 h-full w-1.5 -translate-x-1/2 cursor-ew-resize',
@@ -352,4 +341,5 @@ export {
352
341
  DrawerDescription,
353
342
  }
354
343
 
355
- export { useDrawerSizeStore } from './store';
344
+ export { useDrawerSize } from './useDrawerSize';
345
+ export type { UseDrawerSizeOptions, UseDrawerSizeResult } from './useDrawerSize';
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { useUIPersistedState } from '../../../lib/persist';
6
+
7
+ const SCOPE_WIDTH = 'drawer-size:width';
8
+ const SCOPE_HEIGHT = 'drawer-size:height';
9
+
10
+ export interface UseDrawerSizeOptions {
11
+ /** Which axis to read/write. `'width'` for left/right, `'height'` for top/bottom. */
12
+ axis?: 'width' | 'height';
13
+ /** Clamp loaded value into these bounds (recommended). */
14
+ min?: number;
15
+ max?: number;
16
+ }
17
+
18
+ export interface UseDrawerSizeResult {
19
+ /** Persisted size in px, or `undefined` if nothing stored / not yet hydrated. */
20
+ size: number | undefined;
21
+ /** Persist a new size. */
22
+ setSize: (size: number) => void;
23
+ /** Remove the persisted entry for this key. */
24
+ reset: () => void;
25
+ }
26
+
27
+ /**
28
+ * Hook that wires a Drawer to centralized persisted UI state.
29
+ *
30
+ * Width and height live in separate scopes, so the same `key` can be
31
+ * reused across directions without one axis overwriting the other.
32
+ *
33
+ * @example
34
+ * const drawer = useDrawerSize('settings', { min: 320, max: 900 });
35
+ * <DrawerContent
36
+ * resizable
37
+ * resizedSize={drawer.size}
38
+ * onSizeChange={drawer.setSize}
39
+ * />
40
+ */
41
+ export function useDrawerSize(
42
+ key: string,
43
+ options: UseDrawerSizeOptions = {},
44
+ ): UseDrawerSizeResult {
45
+ const { axis = 'width', min, max } = options;
46
+ const scope = axis === 'height' ? SCOPE_HEIGHT : SCOPE_WIDTH;
47
+
48
+ const sanitize = React.useCallback(
49
+ (raw: number) => {
50
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return undefined;
51
+ let v = raw;
52
+ if (min != null) v = Math.max(v, min);
53
+ if (max != null) v = Math.min(v, max);
54
+ return v;
55
+ },
56
+ [min, max],
57
+ );
58
+
59
+ const persisted = useUIPersistedState<number | undefined>(
60
+ scope,
61
+ key,
62
+ undefined,
63
+ { sanitize: (raw) => (raw == null ? undefined : sanitize(raw)) },
64
+ );
65
+
66
+ return {
67
+ size: persisted.value,
68
+ setSize: persisted.setValue,
69
+ reset: persisted.reset,
70
+ };
71
+ }
package/src/lib/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./utils";
2
2
  export * from "./logger";
3
3
  export * from "./dialog-service";
4
4
  export * from "./env";
5
+ export * from "./persist";
@@ -0,0 +1,11 @@
1
+ 'use client';
2
+
3
+ export { useUIPersistStore } from './store';
4
+ export {
5
+ useUIPersistedState,
6
+ useUIPersistedStateThrottled,
7
+ } from './useUIPersistedState';
8
+ export type {
9
+ UseUIPersistedStateOptions,
10
+ UseUIPersistedStateResult,
11
+ } from './useUIPersistedState';
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { create } from 'zustand';
4
+ import { persist, createJSONStorage } from 'zustand/middleware';
5
+
6
+ /**
7
+ * Centralized UI-state persistence for ui-core components.
8
+ *
9
+ * Layout: `state[scope][key] = value`. Scopes namespace different
10
+ * categories (e.g. `'drawer-size'`, `'tabs'`, `'accordion'`) so keys
11
+ * never collide across components.
12
+ *
13
+ * One localStorage entry serves all components — single migration
14
+ * surface, single «reset all UI preferences» button.
15
+ */
16
+ type ScopeMap = Record<string, Record<string, unknown>>;
17
+
18
+ interface UIPersistStore {
19
+ state: ScopeMap;
20
+ set: (scope: string, key: string, value: unknown) => void;
21
+ get: <T>(scope: string, key: string) => T | undefined;
22
+ remove: (scope: string, key: string) => void;
23
+ clearScope: (scope: string) => void;
24
+ clearAll: () => void;
25
+ /** DevTools/inspection: snapshot of all scopes. */
26
+ getAll: () => ScopeMap;
27
+ /** DevTools/inspection: list scope names. */
28
+ listScopes: () => string[];
29
+ }
30
+
31
+ const noopStorage = {
32
+ getItem: () => null,
33
+ setItem: () => undefined,
34
+ removeItem: () => undefined,
35
+ };
36
+
37
+ export const useUIPersistStore = create<UIPersistStore>()(
38
+ persist(
39
+ (set, get) => ({
40
+ state: {},
41
+ set: (scope, key, value) => {
42
+ set((s) => ({
43
+ state: {
44
+ ...s.state,
45
+ [scope]: { ...s.state[scope], [key]: value },
46
+ },
47
+ }));
48
+ },
49
+ get: <T,>(scope: string, key: string) =>
50
+ get().state[scope]?.[key] as T | undefined,
51
+ remove: (scope, key) => {
52
+ set((s) => {
53
+ const scoped = s.state[scope];
54
+ if (!scoped || !(key in scoped)) return s;
55
+ const { [key]: _removed, ...rest } = scoped;
56
+ return { state: { ...s.state, [scope]: rest } };
57
+ });
58
+ },
59
+ clearScope: (scope) => {
60
+ set((s) => {
61
+ if (!(scope in s.state)) return s;
62
+ const { [scope]: _removed, ...rest } = s.state;
63
+ return { state: rest };
64
+ });
65
+ },
66
+ clearAll: () => set({ state: {} }),
67
+ getAll: () => get().state,
68
+ listScopes: () => Object.keys(get().state),
69
+ }),
70
+ {
71
+ name: 'djangocfg.ui.state',
72
+ version: 1,
73
+ storage: createJSONStorage(() =>
74
+ typeof window !== 'undefined' ? window.localStorage : noopStorage,
75
+ ),
76
+ // Components hydrate explicitly on mount via `useUIPersistedState`.
77
+ skipHydration: true,
78
+ },
79
+ ),
80
+ );
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { useUIPersistStore } from './store';
6
+
7
+ export interface UseUIPersistedStateOptions<T> {
8
+ /**
9
+ * Optional sanitizer applied to the stored value on load. Use it to
10
+ * clamp numbers into bounds, drop unknown enum values, or migrate
11
+ * shape. Return `undefined` to fall back to default.
12
+ */
13
+ sanitize?: (raw: T) => T | undefined;
14
+ }
15
+
16
+ export interface UseUIPersistedStateResult<T> {
17
+ /** Current value: persisted (post-sanitize) if available, otherwise the default. */
18
+ value: T;
19
+ /** Persist a new value. */
20
+ setValue: (next: T) => void;
21
+ /** Remove the persisted entry. `value` reverts to default on next render. */
22
+ reset: () => void;
23
+ /** True once the persist middleware has rehydrated from storage. */
24
+ hydrated: boolean;
25
+ }
26
+
27
+ /**
28
+ * Read/write a piece of UI state in the centralized persisted store.
29
+ *
30
+ * @param scope Namespace (e.g. `'drawer-size'`, `'tabs'`). Pick a
31
+ * stable string per component family.
32
+ * @param key Per-instance identifier within the scope.
33
+ * @param defaultValue Used until hydration completes or when no value is stored.
34
+ *
35
+ * @example
36
+ * const { value, setValue, reset } = useUIPersistedState('tabs', 'settings', 'general');
37
+ */
38
+ export function useUIPersistedState<T>(
39
+ scope: string,
40
+ key: string,
41
+ defaultValue: T,
42
+ options: UseUIPersistedStateOptions<T> = {},
43
+ ): UseUIPersistedStateResult<T> {
44
+ const { sanitize } = options;
45
+ const [hydrated, setHydrated] = React.useState(false);
46
+
47
+ React.useEffect(() => {
48
+ let cancelled = false;
49
+ Promise.resolve(useUIPersistStore.persist.rehydrate()).then(() => {
50
+ if (!cancelled) setHydrated(true);
51
+ });
52
+ return () => {
53
+ cancelled = true;
54
+ };
55
+ }, []);
56
+
57
+ const stored = useUIPersistStore(
58
+ (s) => s.state[scope]?.[key] as T | undefined,
59
+ );
60
+
61
+ const value = React.useMemo(() => {
62
+ if (!hydrated || stored === undefined) return defaultValue;
63
+ if (sanitize) {
64
+ const sanitized = sanitize(stored);
65
+ return sanitized === undefined ? defaultValue : sanitized;
66
+ }
67
+ return stored;
68
+ }, [hydrated, stored, defaultValue, sanitize]);
69
+
70
+ const setValue = React.useCallback(
71
+ (next: T) => {
72
+ useUIPersistStore.getState().set(scope, key, next);
73
+ },
74
+ [scope, key],
75
+ );
76
+
77
+ const reset = React.useCallback(() => {
78
+ useUIPersistStore.getState().remove(scope, key);
79
+ }, [scope, key]);
80
+
81
+ return { value, setValue, reset, hydrated };
82
+ }
83
+
84
+ /**
85
+ * Same as `useUIPersistedState` but trailing-edge throttles writes by
86
+ * `delayMs`. Use for high-frequency updates (live splitter drag, scroll
87
+ * position) where you don't want to hammer storage.
88
+ */
89
+ export function useUIPersistedStateThrottled<T>(
90
+ scope: string,
91
+ key: string,
92
+ defaultValue: T,
93
+ delayMs: number,
94
+ options: UseUIPersistedStateOptions<T> = {},
95
+ ): UseUIPersistedStateResult<T> {
96
+ const base = useUIPersistedState(scope, key, defaultValue, options);
97
+ const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
98
+ const pendingRef = React.useRef<T | null>(null);
99
+
100
+ const setValue = React.useCallback(
101
+ (next: T) => {
102
+ pendingRef.current = next;
103
+ if (timerRef.current != null) return;
104
+ timerRef.current = setTimeout(() => {
105
+ timerRef.current = null;
106
+ if (pendingRef.current !== null) {
107
+ base.setValue(pendingRef.current);
108
+ pendingRef.current = null;
109
+ }
110
+ }, delayMs);
111
+ },
112
+ [base, delayMs],
113
+ );
114
+
115
+ React.useEffect(
116
+ () => () => {
117
+ if (timerRef.current != null) clearTimeout(timerRef.current);
118
+ },
119
+ [],
120
+ );
121
+
122
+ return { ...base, setValue };
123
+ }
@@ -1,64 +0,0 @@
1
- 'use client';
2
-
3
- import { create } from 'zustand';
4
- import { persist, createJSONStorage } from 'zustand/middleware';
5
-
6
- /**
7
- * Persisted drawer sizes keyed by `persistKey`.
8
- *
9
- * Stores width (left/right drawers) and height (top/bottom drawers)
10
- * separately under the same key, so a single key can drive both axes
11
- * if the drawer is reused with different directions.
12
- */
13
- interface DrawerSizeEntry {
14
- width?: number;
15
- height?: number;
16
- }
17
-
18
- interface DrawerSizeStore {
19
- sizes: Record<string, DrawerSizeEntry>;
20
- setSize: (key: string, axis: 'width' | 'height', size: number) => void;
21
- getSize: (key: string, axis: 'width' | 'height') => number | undefined;
22
- clearSize: (key: string) => void;
23
- clearAll: () => void;
24
- }
25
-
26
- const noopStorage = {
27
- getItem: () => null,
28
- setItem: () => undefined,
29
- removeItem: () => undefined,
30
- };
31
-
32
- export const useDrawerSizeStore = create<DrawerSizeStore>()(
33
- persist(
34
- (set, get) => ({
35
- sizes: {},
36
- setSize: (key, axis, size) => {
37
- set((state) => ({
38
- sizes: {
39
- ...state.sizes,
40
- [key]: { ...state.sizes[key], [axis]: size },
41
- },
42
- }));
43
- },
44
- getSize: (key, axis) => get().sizes[key]?.[axis],
45
- clearSize: (key) => {
46
- set((state) => {
47
- const { [key]: _removed, ...rest } = state.sizes;
48
- return { sizes: rest };
49
- });
50
- },
51
- clearAll: () => set({ sizes: {} }),
52
- }),
53
- {
54
- name: 'djangocfg.ui.drawer-sizes',
55
- version: 1,
56
- storage: createJSONStorage(() =>
57
- typeof window !== 'undefined' ? window.localStorage : noopStorage,
58
- ),
59
- // Skip auto-hydrate so SSR markup doesn't differ from first client paint.
60
- // Components hydrate explicitly on mount via `useEffect`.
61
- skipHydration: true,
62
- },
63
- ),
64
- );