"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';