@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 +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 +84 -0
- package/src/components/overlay/drawer/index.tsx +170 -11
- 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/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.
|
|
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 } 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 (< 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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// `
|
|
62
|
-
//
|
|
63
|
-
|
|
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
|
-
>(({
|
|
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
|
|
94
|
-
? (
|
|
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
|
|
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:
|
|
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
|
@@ -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
|
+
}
|