@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 +53 -1
- package/package.json +4 -4
- package/src/components/index.ts +5 -1
- package/src/components/navigation/accordion/useAccordionState.ts +69 -0
- package/src/components/navigation/tabs/useTabsState.ts +57 -0
- package/src/components/overlay/drawer/drawer.story.tsx +45 -37
- package/src/components/overlay/drawer/index.tsx +50 -60
- package/src/components/overlay/drawer/useDrawerSize.ts +71 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/persist/index.ts +11 -0
- package/src/lib/persist/store.ts +80 -0
- package/src/lib/persist/useUIPersistedState.ts +123 -0
- package/src/components/overlay/drawer/store.ts +0 -64
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)
|
|
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.
|
|
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.
|
|
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.
|
|
163
|
+
"@djangocfg/i18n": "^2.1.312",
|
|
164
164
|
"@djangocfg/playground": "workspace:*",
|
|
165
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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",
|
package/src/components/index.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
159
|
+
const presetPx = isVertical
|
|
166
160
|
? parseInitialPx(height, verticalSizePx[size])
|
|
167
161
|
: parseInitialPx(width, horizontalSizePx[size]);
|
|
168
162
|
|
|
169
|
-
|
|
170
|
-
const
|
|
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
|
-
//
|
|
173
|
-
//
|
|
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 (!
|
|
177
|
-
|
|
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
|
|
188
|
+
startSize,
|
|
189
|
+
lastSize: startSize,
|
|
210
190
|
};
|
|
211
|
-
}, [resizeEnabled, isVertical,
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
210
|
+
const drag = dragStateRef.current;
|
|
211
|
+
if (!drag) return;
|
|
230
212
|
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
|
231
213
|
dragStateRef.current = null;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
241
|
-
? (
|
|
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 {
|
|
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
|
@@ -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
|
-
);
|