"use client" import * as React from 'react'; import { Drawer as DrawerPrimitive } from 'vaul'; import { cn } from '../../../lib/utils'; import { useIsMobile } from '../../../hooks/media/useMobile'; // Direction context lets inherit the Root's direction without // callers having to pass it twice (a frequent rebase / refactor footgun). const DrawerDirectionContext = React.createContext<'bottom' | 'right' | 'left' | 'top' | undefined>(undefined); /** @internal — used by DrawerContent to inherit `direction` from the Root. */ export const useDrawerDirection = () => React.useContext(DrawerDirectionContext); const Drawer = ({ shouldScaleBackground, direction = 'bottom', ...props }: React.ComponentProps & { direction?: 'bottom' | 'right' | 'left' | 'top' }) => { // vaul's body-scale animation is a mobile-bottom-sheet effect by design. // Applying it to side drawers (right/left) keeps the transformed for // ~300ms during open, which on heavy pages stalls the main thread *before* // any data fetch the consumer kicks off in response to opening — so the // network panel shows the request appearing only after the scale finishes. // Default to enabled only for bottom sheets; callers can still override. const resolvedScale = shouldScaleBackground ?? direction === 'bottom'; return ( ); }; Drawer.displayName = "Drawer" const DrawerTrigger = DrawerPrimitive.Trigger const DrawerPortal = DrawerPrimitive.Portal const DrawerClose = DrawerPrimitive.Close const DrawerOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, style, ...props }, ref) => ( )) DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName /** Drawer size preset. Maps to width for left/right, height for top/bottom. */ export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full' const horizontalSizePresets: Record = { sm: 'min(100vw, 360px)', md: 'min(100vw, 480px)', lg: 'min(100vw, 640px)', xl: 'min(100vw, 800px)', full: '100vw', }; const verticalSizePresets: Record = { sm: 'min(100vh, 240px)', md: 'min(100vh, 360px)', lg: 'min(100vh, 480px)', xl: 'min(100vh, 640px)', full: '100vh', }; // Numeric fallbacks used as initial size when resize starts and the // user has not provided an explicit width/height. We can't read the // CSS `min(100vw, …px)` value reliably before the first paint, so we // take the px portion as a baseline. const horizontalSizePx: Record = { sm: 360, md: 480, lg: 640, xl: 800, full: 1200, }; const verticalSizePx: Record = { sm: 240, md: 360, lg: 480, xl: 640, full: 900, }; const directionStyles = { bottom: "inset-x-0 bottom-0 mt-24 rounded-t-lg border-t", top: "inset-x-0 top-0 mb-24 rounded-b-lg border-b", right: "inset-y-0 right-0 h-full border-l", left: "inset-y-0 left-0 h-full border-r", } as const; const toCssLength = (value: string | number | undefined): string | undefined => { if (value == null) return undefined; return typeof value === 'number' ? `${value}px` : value; }; const parseInitialPx = (value: string | number | undefined, fallback: number): number => { if (typeof value === 'number') return value; if (typeof value === 'string') { const match = value.match(/(-?\d+(?:\.\d+)?)/); if (match) return Number(match[1]); } return fallback; }; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); export interface DrawerContentProps extends React.ComponentPropsWithoutRef { direction?: 'bottom' | 'right' | 'left' | 'top'; /** Preset size (default `'md'`). Width for horizontal, height for vertical. */ size?: DrawerSize; /** CSS length for width — applies to left/right drawers. Overrides `size`. */ width?: string | number; /** CSS length for height — applies to top/bottom drawers. Overrides `size`. */ height?: string | number; /** Allow user to resize the drawer by dragging its inner edge. */ resizable?: boolean; /** Disable resize on mobile viewports (< 768px). Default `true`. */ resizableOnDesktopOnly?: boolean; /** Min size in px when `resizable`. Width for horizontal, height for vertical. */ minSize?: number; /** Max size in px when `resizable`. Width for horizontal, height for vertical. */ maxSize?: number; /** * Controlled current size in px. Pair with `onSizeChange` for external * persistence (see `useDrawerSize` hook). When `undefined`, the drawer * is uncontrolled and size lives in local state. */ resizedSize?: number; /** Called once on pointer-up with the final resized size in px. */ onSizeChange?: (size: number) => void; } const DrawerContent = React.forwardRef< React.ElementRef, DrawerContentProps >(({ className, children, direction: directionProp, size = 'md', width, height, style, resizable = false, resizableOnDesktopOnly = true, minSize, maxSize, resizedSize, onSizeChange, ...props }, ref) => { // Inherit direction from the Drawer Root when not explicitly overridden. // This avoids the common bug where `` is paired // with a `` that silently falls back to "bottom". const inherited = useDrawerDirection(); const direction = directionProp ?? inherited ?? 'bottom'; const isVertical = direction === 'bottom' || direction === 'top'; const isMobile = useIsMobile(); const resizeEnabled = resizable && (!resizableOnDesktopOnly || !isMobile); const defaultMin = isVertical ? 200 : 280; const defaultMax = isVertical ? 800 : 960; const minPx = minSize ?? defaultMin; const maxPx = maxSize ?? defaultMax; const presetPx = isVertical ? parseInitialPx(height, verticalSizePx[size]) : parseInitialPx(width, horizontalSizePx[size]); // Uncontrolled: track size locally. Controlled: caller owns it via `resizedSize`. const isControlled = resizedSize !== undefined; const [internalPx, setInternalPx] = React.useState(null); const currentPx = isControlled ? clamp(resizedSize!, minPx, maxPx) : internalPx; // Reset uncontrolled state if direction or enabled flag flips, so we // don't carry a horizontal width into a vertical drawer. React.useEffect(() => { if (!isControlled) setInternalPx(null); }, [direction, resizeEnabled, isControlled]); const dragStateRef = React.useRef<{ startCoord: number; startSize: number; lastSize: number; } | null>(null); const handlePointerDown = React.useCallback((e: React.PointerEvent) => { if (!resizeEnabled) return; e.preventDefault(); e.stopPropagation(); (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); const startSize = currentPx ?? presetPx; dragStateRef.current = { startCoord: isVertical ? e.clientY : e.clientX, startSize, lastSize: startSize, }; }, [resizeEnabled, isVertical, currentPx, presetPx]); const handlePointerMove = React.useCallback((e: React.PointerEvent) => { const drag = dragStateRef.current; if (!drag) return; e.preventDefault(); const current = isVertical ? e.clientY : e.clientX; const delta = current - drag.startCoord; const sign = direction === 'right' || direction === 'bottom' ? -1 : 1; const next = clamp(drag.startSize + sign * delta, minPx, maxPx); drag.lastSize = next; if (!isControlled) setInternalPx(next); // Note: onSizeChange fires on pointer-up only — avoids hammering // localStorage / external stores during drag. For live feedback // during drag, render from `currentPx` (uncontrolled) or from your // own state synced separately. }, [direction, isVertical, minPx, maxPx, isControlled]); const handlePointerUp = React.useCallback((e: React.PointerEvent) => { const drag = dragStateRef.current; if (!drag) return; (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); dragStateRef.current = null; onSizeChange?.(drag.lastSize); }, [onSizeChange]); // Viewport clamp — keeps the drawer inside the window on narrow screens. // Without this, a fixed `width: 720px` on a 600px-wide window paints the // drawer 120px past the right edge (off-screen for `direction="right"`). // Applied to BOTH the resized px value and the initial preset so user- // dragged widths can't overflow either. CSS `min()` is cheap and the // browser re-evaluates on every viewport change, so window resize works // for free. const rawWidth = !isVertical ? (currentPx != null ? `${currentPx}px` : (toCssLength(width) ?? horizontalSizePresets[size])) : undefined; const rawHeight = isVertical ? (currentPx != null ? `${currentPx}px` : (toCssLength(height) ?? verticalSizePresets[size])) : undefined; const resolvedWidth = rawWidth ? `min(100vw, ${rawWidth})` : undefined; const resolvedHeight = rawHeight ? `min(100vh, ${rawHeight})` : undefined; const handlePosition: Record = { right: 'left-0 top-0 h-full w-1.5 -translate-x-1/2 cursor-ew-resize', left: 'right-0 top-0 h-full w-1.5 translate-x-1/2 cursor-ew-resize', bottom: 'top-0 left-0 w-full h-1.5 -translate-y-1/2 cursor-ns-resize', top: 'bottom-0 left-0 w-full h-1.5 translate-y-1/2 cursor-ns-resize', }; return ( {resizeEnabled && (
)} {isVertical &&
} {children} ); }) DrawerContent.displayName = "DrawerContent" const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => (
) DrawerHeader.displayName = "DrawerHeader" const DrawerFooter = ({ className, ...props }: React.HTMLAttributes) => (
) DrawerFooter.displayName = "DrawerFooter" const DrawerTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DrawerTitle.displayName = DrawerPrimitive.Title.displayName const DrawerDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DrawerDescription.displayName = DrawerPrimitive.Description.displayName export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, } export { useDrawerSize } from './useDrawerSize'; export type { UseDrawerSizeOptions, UseDrawerSizeResult } from './useDrawerSize';