@djangocfg/ui-core 2.1.309 → 2.1.310

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) and observe via `onResizeChange`. Pass `persistKey` to save the resized size in `localStorage` and restore it on next open. The store is exported as `useDrawerSizeStore` (with `clearSize` / `clearAll`).
91
91
 
92
92
  ```tsx
93
93
  <Drawer direction="right">
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.310",
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.310",
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.310",
164
164
  "@djangocfg/playground": "workspace:*",
165
- "@djangocfg/typescript-config": "^2.1.309",
165
+ "@djangocfg/typescript-config": "^2.1.310",
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, useDrawerSizeStore } 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';
@@ -238,6 +238,82 @@ export const CustomWidth = () => (
238
238
  </Drawer>
239
239
  );
240
240
 
241
+ export const Resizable = () => {
242
+ const [size, setSize] = React.useState<number | null>(null);
243
+ return (
244
+ <div className="space-y-3">
245
+ <p className="text-sm text-muted-foreground">
246
+ Drag the inner edge to resize. Disabled on mobile (&lt; 768px) by
247
+ default — pass <code>resizableOnDesktopOnly={'{false}'}</code> to allow
248
+ on touch.
249
+ </p>
250
+ <Drawer direction="right">
251
+ <DrawerTrigger asChild>
252
+ <Button variant="outline">Open resizable drawer</Button>
253
+ </DrawerTrigger>
254
+ <DrawerContent
255
+ direction="right"
256
+ size="md"
257
+ resizable
258
+ minSize={320}
259
+ maxSize={900}
260
+ onResizeChange={setSize}
261
+ >
262
+ <DrawerHeader>
263
+ <DrawerTitle>Resizable</DrawerTitle>
264
+ <DrawerDescription>
265
+ Current width: {size != null ? `${Math.round(size)}px` : 'preset (480px)'}
266
+ </DrawerDescription>
267
+ </DrawerHeader>
268
+ <div className="p-4 text-sm">Drag the left edge to resize.</div>
269
+ <DrawerFooter>
270
+ <DrawerClose asChild>
271
+ <Button variant="outline">Close</Button>
272
+ </DrawerClose>
273
+ </DrawerFooter>
274
+ </DrawerContent>
275
+ </Drawer>
276
+ </div>
277
+ );
278
+ };
279
+
280
+ export const ResizablePersisted = () => (
281
+ <div className="space-y-3">
282
+ <p className="text-sm text-muted-foreground">
283
+ Same as <code>Resizable</code>, but the size is persisted in
284
+ <code> localStorage</code> under <code>persistKey="story-demo"</code> —
285
+ reload the page and reopen, the last width is restored.
286
+ </p>
287
+ <Drawer direction="right">
288
+ <DrawerTrigger asChild>
289
+ <Button variant="outline">Open persisted drawer</Button>
290
+ </DrawerTrigger>
291
+ <DrawerContent
292
+ direction="right"
293
+ size="md"
294
+ resizable
295
+ persistKey="story-demo"
296
+ minSize={320}
297
+ maxSize={900}
298
+ >
299
+ <DrawerHeader>
300
+ <DrawerTitle>Resizable + persisted</DrawerTitle>
301
+ <DrawerDescription>
302
+ Width survives reloads. Open DevTools → Application → Local Storage
303
+ and look for <code>djangocfg.ui.drawer-sizes</code>.
304
+ </DrawerDescription>
305
+ </DrawerHeader>
306
+ <div className="p-4 text-sm">Drag the left edge to resize.</div>
307
+ <DrawerFooter>
308
+ <DrawerClose asChild>
309
+ <Button variant="outline">Close</Button>
310
+ </DrawerClose>
311
+ </DrawerFooter>
312
+ </DrawerContent>
313
+ </Drawer>
314
+ </div>
315
+ );
316
+
241
317
  export const NarrowViewport = () => (
242
318
  <div className="space-y-3">
243
319
  <p className="text-sm text-muted-foreground">
@@ -4,6 +4,8 @@ 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';
8
+ import { useDrawerSizeStore } from './store';
7
9
 
8
10
  const Drawer = ({
9
11
  shouldScaleBackground = true,
@@ -56,6 +58,26 @@ const verticalSizePresets: Record<DrawerSize, string> = {
56
58
  full: '100vh',
57
59
  };
58
60
 
61
+ // Numeric fallbacks used as initial size when resize starts and the
62
+ // user has not provided an explicit width/height. We can't read the
63
+ // CSS `min(100vw, …px)` value reliably before the first paint, so we
64
+ // take the px portion as a baseline.
65
+ const horizontalSizePx: Record<DrawerSize, number> = {
66
+ sm: 360,
67
+ md: 480,
68
+ lg: 640,
69
+ xl: 800,
70
+ full: 1200,
71
+ };
72
+
73
+ const verticalSizePx: Record<DrawerSize, number> = {
74
+ sm: 240,
75
+ md: 360,
76
+ lg: 480,
77
+ xl: 640,
78
+ full: 900,
79
+ };
80
+
59
81
  // Anchor + border + radius only — no width/height bake-in. Length is
60
82
  // applied via inline `style` from props so vaul's first
61
83
  // `getBoundingClientRect()` measurement matches the final layout
@@ -73,6 +95,18 @@ const toCssLength = (value: string | number | undefined): string | undefined =>
73
95
  return typeof value === 'number' ? `${value}px` : value;
74
96
  };
75
97
 
98
+ const parseInitialPx = (value: string | number | undefined, fallback: number): number => {
99
+ if (typeof value === 'number') return value;
100
+ if (typeof value === 'string') {
101
+ const match = value.match(/(-?\d+(?:\.\d+)?)/);
102
+ if (match) return Number(match[1]);
103
+ }
104
+ return fallback;
105
+ };
106
+
107
+ const clamp = (value: number, min: number, max: number) =>
108
+ Math.min(Math.max(value, min), max);
109
+
76
110
  export interface DrawerContentProps
77
111
  extends React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> {
78
112
  direction?: 'bottom' | 'right' | 'left' | 'top';
@@ -82,21 +116,138 @@ export interface DrawerContentProps
82
116
  width?: string | number;
83
117
  /** CSS length for height — applies to top/bottom drawers. Overrides `size`. */
84
118
  height?: string | number;
119
+ /** Allow user to resize the drawer by dragging its inner edge. */
120
+ resizable?: boolean;
121
+ /** Disable resize on mobile viewports (< 768px). Default `true`. */
122
+ resizableOnDesktopOnly?: boolean;
123
+ /** Min size in px when `resizable`. Width for horizontal, height for vertical. */
124
+ minSize?: number;
125
+ /** Max size in px when `resizable`. Width for horizontal, height for vertical. */
126
+ maxSize?: number;
127
+ /** Called with the new size in px on every resize step. */
128
+ onResizeChange?: (size: number) => void;
129
+ /**
130
+ * Persist resized size in localStorage under this key. Without it,
131
+ * resize is session-only. Width and height are stored separately,
132
+ * so the same key can be reused across directions.
133
+ */
134
+ persistKey?: string;
85
135
  }
86
136
 
87
137
  const DrawerContent = React.forwardRef<
88
138
  React.ElementRef<typeof DrawerPrimitive.Content>,
89
139
  DrawerContentProps
90
- >(({ className, children, direction = 'bottom', size = 'md', width, height, style, ...props }, ref) => {
140
+ >(({
141
+ className,
142
+ children,
143
+ direction = 'bottom',
144
+ size = 'md',
145
+ width,
146
+ height,
147
+ style,
148
+ resizable = false,
149
+ resizableOnDesktopOnly = true,
150
+ minSize,
151
+ maxSize,
152
+ onResizeChange,
153
+ persistKey,
154
+ ...props
155
+ }, ref) => {
91
156
  const isVertical = direction === 'bottom' || direction === 'top';
157
+ const isMobile = useIsMobile();
158
+ const resizeEnabled = resizable && (!resizableOnDesktopOnly || !isMobile);
159
+
160
+ const defaultMin = isVertical ? 200 : 280;
161
+ const defaultMax = isVertical ? 800 : 960;
162
+ const minPx = minSize ?? defaultMin;
163
+ const maxPx = maxSize ?? defaultMax;
164
+
165
+ const initialPx = isVertical
166
+ ? parseInitialPx(height, verticalSizePx[size])
167
+ : parseInitialPx(width, horizontalSizePx[size]);
168
+
169
+ const [resizedPx, setResizedPx] = React.useState<number | null>(null);
170
+ const axis: 'width' | 'height' = isVertical ? 'height' : 'width';
171
+
172
+ // Hydrate persist store on mount (skipHydration in middleware) and
173
+ // pull stored size for this key+axis. Clamp to current min/max so a
174
+ // value persisted under looser bounds doesn't escape.
175
+ React.useEffect(() => {
176
+ if (!resizeEnabled || !persistKey) return;
177
+ let cancelled = false;
178
+ const applyStored = () => {
179
+ if (cancelled) return;
180
+ const stored = useDrawerSizeStore.getState().getSize(persistKey, axis);
181
+ if (stored != null) {
182
+ setResizedPx(clamp(stored, minPx, maxPx));
183
+ }
184
+ };
185
+ Promise.resolve(useDrawerSizeStore.persist.rehydrate()).then(applyStored);
186
+ return () => {
187
+ cancelled = true;
188
+ };
189
+ }, [resizeEnabled, persistKey, axis, minPx, maxPx]);
190
+
191
+ // Reset resized state if direction or enabled flag flips, so we don't
192
+ // carry a horizontal width into a vertical drawer.
193
+ React.useEffect(() => {
194
+ setResizedPx(null);
195
+ }, [direction, resizeEnabled]);
196
+
197
+ const dragStateRef = React.useRef<{
198
+ startCoord: number;
199
+ startSize: number;
200
+ } | null>(null);
201
+
202
+ const handlePointerDown = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
203
+ if (!resizeEnabled) return;
204
+ e.preventDefault();
205
+ e.stopPropagation();
206
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
207
+ dragStateRef.current = {
208
+ startCoord: isVertical ? e.clientY : e.clientX,
209
+ startSize: resizedPx ?? initialPx,
210
+ };
211
+ }, [resizeEnabled, isVertical, resizedPx, initialPx]);
212
+
213
+ const handlePointerMove = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
214
+ const drag = dragStateRef.current;
215
+ if (!drag) return;
216
+ e.preventDefault();
217
+ const current = isVertical ? e.clientY : e.clientX;
218
+ const delta = current - drag.startCoord;
219
+ // Direction sign: handle is on the side facing into the viewport, so
220
+ // dragging away from the anchor grows the drawer.
221
+ const sign =
222
+ direction === 'right' || direction === 'bottom' ? -1 : 1;
223
+ const next = clamp(drag.startSize + sign * delta, minPx, maxPx);
224
+ setResizedPx(next);
225
+ onResizeChange?.(next);
226
+ }, [direction, isVertical, minPx, maxPx, onResizeChange]);
227
+
228
+ const handlePointerUp = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {
229
+ if (!dragStateRef.current) return;
230
+ (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
231
+ dragStateRef.current = null;
232
+ if (persistKey && resizedPx != null) {
233
+ useDrawerSizeStore.getState().setSize(persistKey, axis, resizedPx);
234
+ }
235
+ }, [persistKey, axis, resizedPx]);
92
236
 
93
237
  const resolvedWidth = !isVertical
94
- ? (toCssLength(width) ?? horizontalSizePresets[size])
238
+ ? (resizedPx != null ? `${resizedPx}px` : (toCssLength(width) ?? horizontalSizePresets[size]))
95
239
  : undefined;
96
240
  const resolvedHeight = isVertical
97
- ? (toCssLength(height) ?? verticalSizePresets[size])
241
+ ? (resizedPx != null ? `${resizedPx}px` : (toCssLength(height) ?? verticalSizePresets[size]))
98
242
  : undefined;
99
243
 
244
+ const handlePosition: Record<typeof direction, string> = {
245
+ right: 'left-0 top-0 h-full w-1.5 -translate-x-1/2 cursor-ew-resize',
246
+ left: 'right-0 top-0 h-full w-1.5 translate-x-1/2 cursor-ew-resize',
247
+ bottom: 'top-0 left-0 w-full h-1.5 -translate-y-1/2 cursor-ns-resize',
248
+ top: 'bottom-0 left-0 w-full h-1.5 translate-y-1/2 cursor-ns-resize',
249
+ };
250
+
100
251
  return (
101
252
  <DrawerPortal>
102
253
  <DrawerOverlay />
@@ -108,13 +259,29 @@ const DrawerContent = React.forwardRef<
108
259
  className
109
260
  )}
110
261
  style={{
111
- transition: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
262
+ transition: dragStateRef.current
263
+ ? 'none'
264
+ : 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
112
265
  ...(resolvedWidth ? { width: resolvedWidth } : {}),
113
266
  ...(resolvedHeight ? { height: resolvedHeight } : {}),
114
267
  ...style,
115
268
  }}
116
269
  {...props}
117
270
  >
271
+ {resizeEnabled && (
272
+ <div
273
+ role="separator"
274
+ aria-orientation={isVertical ? 'horizontal' : 'vertical'}
275
+ onPointerDown={handlePointerDown}
276
+ onPointerMove={handlePointerMove}
277
+ onPointerUp={handlePointerUp}
278
+ onPointerCancel={handlePointerUp}
279
+ className={cn(
280
+ "absolute z-10 select-none touch-none bg-transparent hover:bg-border/60 transition-colors",
281
+ handlePosition[direction]
282
+ )}
283
+ />
284
+ )}
118
285
  {isVertical && <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />}
119
286
  {children}
120
287
  </DrawerPrimitive.Content>
@@ -184,3 +351,5 @@ export {
184
351
  DrawerTitle,
185
352
  DrawerDescription,
186
353
  }
354
+
355
+ export { useDrawerSizeStore } from './store';
@@ -0,0 +1,64 @@
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
+ );