@djangocfg/ui-core 2.1.309 → 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`.
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.309",
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.309",
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.309",
163
+ "@djangocfg/i18n": "^2.1.312",
164
164
  "@djangocfg/playground": "workspace:*",
165
- "@djangocfg/typescript-config": "^2.1.309",
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 } 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';
@@ -238,6 +239,89 @@ export const CustomWidth = () => (
238
239
  </Drawer>
239
240
  );
240
241
 
242
+ export const Resizable = () => {
243
+ const [size, setSize] = React.useState<number | null>(null);
244
+ return (
245
+ <div className="space-y-3">
246
+ <p className="text-sm text-muted-foreground">
247
+ Drag the inner edge to resize. Disabled on mobile (&lt; 768px) by
248
+ default — pass <code>resizableOnDesktopOnly={'{false}'}</code> to allow
249
+ on touch.
250
+ </p>
251
+ <Drawer direction="right">
252
+ <DrawerTrigger asChild>
253
+ <Button variant="outline">Open resizable drawer</Button>
254
+ </DrawerTrigger>
255
+ <DrawerContent
256
+ direction="right"
257
+ size="md"
258
+ resizable
259
+ minSize={320}
260
+ maxSize={900}
261
+ onSizeChange={setSize}
262
+ >
263
+ <DrawerHeader>
264
+ <DrawerTitle>Resizable</DrawerTitle>
265
+ <DrawerDescription>
266
+ Current width: {size != null ? `${Math.round(size)}px` : 'preset (480px)'}
267
+ </DrawerDescription>
268
+ </DrawerHeader>
269
+ <div className="p-4 text-sm">Drag the left edge to resize.</div>
270
+ <DrawerFooter>
271
+ <DrawerClose asChild>
272
+ <Button variant="outline">Close</Button>
273
+ </DrawerClose>
274
+ </DrawerFooter>
275
+ </DrawerContent>
276
+ </Drawer>
277
+ </div>
278
+ );
279
+ };
280
+
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
+ };
324
+
241
325
  export const NarrowViewport = () => (
242
326
  <div className="space-y-3">
243
327
  <p className="text-sm text-muted-foreground">
@@ -4,6 +4,7 @@ import * as React from 'react';
4
4
  import { Drawer as DrawerPrimitive } from 'vaul';
5
5
 
6
6
  import { cn } from '../../../lib/utils';
7
+ import { useIsMobile } from '../../../hooks/media/useMobile';
7
8
 
8
9
  const Drawer = ({
9
10
  shouldScaleBackground = true,
@@ -56,11 +57,26 @@ const verticalSizePresets: Record<DrawerSize, string> = {
56
57
  full: '100vh',
57
58
  };
58
59
 
59
- // Anchor + border + radius only no width/height bake-in. Length is
60
- // applied via inline `style` from props so vaul's first
61
- // `getBoundingClientRect()` measurement matches the final layout
62
- // (avoids the inset.left=mismatch bug when className-based defaults
63
- // leak into the first paint).
60
+ // Numeric fallbacks used as initial size when resize starts and the
61
+ // user has not provided an explicit width/height. We can't read the
62
+ // CSS `min(100vw, …px)` value reliably before the first paint, so we
63
+ // take the px portion as a baseline.
64
+ const horizontalSizePx: Record<DrawerSize, number> = {
65
+ sm: 360,
66
+ md: 480,
67
+ lg: 640,
68
+ xl: 800,
69
+ full: 1200,
70
+ };
71
+
72
+ const verticalSizePx: Record<DrawerSize, number> = {
73
+ sm: 240,
74
+ md: 360,
75
+ lg: 480,
76
+ xl: 640,
77
+ full: 900,
78
+ };
79
+
64
80
  const directionStyles = {
65
81
  bottom: "inset-x-0 bottom-0 mt-24 rounded-t-lg border-t",
66
82
  top: "inset-x-0 top-0 mb-24 rounded-b-lg border-b",
@@ -73,6 +89,18 @@ const toCssLength = (value: string | number | undefined): string | undefined =>
73
89
  return typeof value === 'number' ? `${value}px` : value;
74
90
  };
75
91
 
92
+ const parseInitialPx = (value: string | number | undefined, fallback: number): number => {
93
+ if (typeof value === 'number') return value;
94
+ if (typeof value === 'string') {
95
+ const match = value.match(/(-?\d+(?:\.\d+)?)/);
96
+ if (match) return Number(match[1]);
97
+ }
98
+ return fallback;
99
+ };
100
+
101
+ const clamp = (value: number, min: number, max: number) =>
102
+ Math.min(Math.max(value, min), max);
103
+
76
104
  export interface DrawerContentProps
77
105
  extends React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> {
78
106
  direction?: 'bottom' | 'right' | 'left' | 'top';
@@ -82,20 +110,132 @@ export interface DrawerContentProps
82
110
  width?: string | number;
83
111
  /** CSS length for height — applies to top/bottom drawers. Overrides `size`. */
84
112
  height?: string | number;
113
+ /** Allow user to resize the drawer by dragging its inner edge. */
114
+ resizable?: boolean;
115
+ /** Disable resize on mobile viewports (< 768px). Default `true`. */
116
+ resizableOnDesktopOnly?: boolean;
117
+ /** Min size in px when `resizable`. Width for horizontal, height for vertical. */
118
+ minSize?: number;
119
+ /** Max size in px when `resizable`. Width for horizontal, height for vertical. */
120
+ maxSize?: number;
121
+ /**
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.
125
+ */
126
+ resizedSize?: number;
127
+ /** Called once on pointer-up with the final resized size in px. */
128
+ onSizeChange?: (size: number) => void;
85
129
  }
86
130
 
87
131
  const DrawerContent = React.forwardRef<
88
132
  React.ElementRef<typeof DrawerPrimitive.Content>,
89
133
  DrawerContentProps
90
- >(({ className, children, direction = 'bottom', size = 'md', width, height, style, ...props }, ref) => {
134
+ >(({
135
+ className,
136
+ children,
137
+ direction = 'bottom',
138
+ size = 'md',
139
+ width,
140
+ height,
141
+ style,
142
+ resizable = false,
143
+ resizableOnDesktopOnly = true,
144
+ minSize,
145
+ maxSize,
146
+ resizedSize,
147
+ onSizeChange,
148
+ ...props
149
+ }, ref) => {
91
150
  const isVertical = direction === 'bottom' || direction === 'top';
151
+ const isMobile = useIsMobile();
152
+ const resizeEnabled = resizable && (!resizableOnDesktopOnly || !isMobile);
153
+
154
+ const defaultMin = isVertical ? 200 : 280;
155
+ const defaultMax = isVertical ? 800 : 960;
156
+ const minPx = minSize ?? defaultMin;
157
+ const maxPx = maxSize ?? defaultMax;
92
158
 
93
- const resolvedWidth = !isVertical
94
- ? (toCssLength(width) ?? horizontalSizePresets[size])
159
+ const presetPx = isVertical
160
+ ? parseInitialPx(height, verticalSizePx[size])
161
+ : parseInitialPx(width, horizontalSizePx[size]);
162
+
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;
167
+
168
+ // Reset uncontrolled state if direction or enabled flag flips, so we
169
+ // don't carry a horizontal width into a vertical drawer.
170
+ React.useEffect(() => {
171
+ if (!isControlled) setInternalPx(null);
172
+ }, [direction, resizeEnabled, isControlled]);
173
+
174
+ const dragStateRef = React.useRef<{
175
+ startCoord: number;
176
+ startSize: number;
177
+ lastSize: number;
178
+ } | null>(null);
179
+
180
+ const handlePointerDown = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
181
+ if (!resizeEnabled) return;
182
+ e.preventDefault();
183
+ e.stopPropagation();
184
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
185
+ const startSize = currentPx ?? presetPx;
186
+ dragStateRef.current = {
187
+ startCoord: isVertical ? e.clientY : e.clientX,
188
+ startSize,
189
+ lastSize: startSize,
190
+ };
191
+ }, [resizeEnabled, isVertical, currentPx, presetPx]);
192
+
193
+ const handlePointerMove = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
194
+ const drag = dragStateRef.current;
195
+ if (!drag) return;
196
+ e.preventDefault();
197
+ const current = isVertical ? e.clientY : e.clientX;
198
+ const delta = current - drag.startCoord;
199
+ const sign = direction === 'right' || direction === 'bottom' ? -1 : 1;
200
+ const next = clamp(drag.startSize + sign * delta, minPx, maxPx);
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]);
208
+
209
+ const handlePointerUp = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
210
+ const drag = dragStateRef.current;
211
+ if (!drag) return;
212
+ (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
213
+ dragStateRef.current = null;
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]))
95
226
  : undefined;
96
- const resolvedHeight = isVertical
97
- ? (toCssLength(height) ?? verticalSizePresets[size])
227
+ const rawHeight = isVertical
228
+ ? (currentPx != null ? `${currentPx}px` : (toCssLength(height) ?? verticalSizePresets[size]))
98
229
  : undefined;
230
+ const resolvedWidth = rawWidth ? `min(100vw, ${rawWidth})` : undefined;
231
+ const resolvedHeight = rawHeight ? `min(100vh, ${rawHeight})` : undefined;
232
+
233
+ const handlePosition: Record<typeof direction, string> = {
234
+ right: 'left-0 top-0 h-full w-1.5 -translate-x-1/2 cursor-ew-resize',
235
+ left: 'right-0 top-0 h-full w-1.5 translate-x-1/2 cursor-ew-resize',
236
+ bottom: 'top-0 left-0 w-full h-1.5 -translate-y-1/2 cursor-ns-resize',
237
+ top: 'bottom-0 left-0 w-full h-1.5 translate-y-1/2 cursor-ns-resize',
238
+ };
99
239
 
100
240
  return (
101
241
  <DrawerPortal>
@@ -108,13 +248,29 @@ const DrawerContent = React.forwardRef<
108
248
  className
109
249
  )}
110
250
  style={{
111
- transition: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
251
+ transition: dragStateRef.current
252
+ ? 'none'
253
+ : 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
112
254
  ...(resolvedWidth ? { width: resolvedWidth } : {}),
113
255
  ...(resolvedHeight ? { height: resolvedHeight } : {}),
114
256
  ...style,
115
257
  }}
116
258
  {...props}
117
259
  >
260
+ {resizeEnabled && (
261
+ <div
262
+ role="separator"
263
+ aria-orientation={isVertical ? 'horizontal' : 'vertical'}
264
+ onPointerDown={handlePointerDown}
265
+ onPointerMove={handlePointerMove}
266
+ onPointerUp={handlePointerUp}
267
+ onPointerCancel={handlePointerUp}
268
+ className={cn(
269
+ "absolute z-10 select-none touch-none bg-transparent hover:bg-border/60 transition-colors",
270
+ handlePosition[direction]
271
+ )}
272
+ />
273
+ )}
118
274
  {isVertical && <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />}
119
275
  {children}
120
276
  </DrawerPrimitive.Content>
@@ -184,3 +340,6 @@ export {
184
340
  DrawerTitle,
185
341
  DrawerDescription,
186
342
  }
343
+
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
+ }