'use client'; import * as React from 'react'; import * as DrawerPrimitive from 'radix-ui/dialog'; import { cn } from '@/lib/utils'; import { useControllableState } from '@/hooks/use-controllable-state'; export type DrawerDirection = 'top' | 'right' | 'bottom' | 'left'; export interface DrawerProps extends React.ComponentProps { /** Edge the panel comes in from. */ direction?: DrawerDirection; /** Allow dragging and the overlay click to close it. */ dismissible?: boolean; } interface DrawerContextValue { direction: DrawerDirection; dismissible: boolean; close: () => void; } const DrawerContext = React.createContext(null); const useDrawer = () => { const context = React.useContext(DrawerContext); if (!context) throw new Error('Drawer parts must be used inside .'); return context; }; /** Travel, in px, past which releasing the drag closes the drawer. */ const DRAG_THRESHOLD = 80; /** A flick this fast closes it regardless of distance (px per ms). */ const FLICK_VELOCITY = 0.4; const axisOf = (direction: DrawerDirection) => direction === 'top' || direction === 'bottom' ? ('y' as const) : ('x' as const); /** Which way is "away from the screen" for this edge. */ const outwardOf = (direction: DrawerDirection) => direction === 'bottom' || direction === 'right' ? 1 : -1; /** * Whether something under the pointer can still scroll the way the drag is * going. If it can, the gesture belongs to that scroller, not to the drawer — * otherwise a list would drag the whole panel instead of scrolling. */ const scrollAbsorbsDrag = ( from: Element | null, boundary: Element, axis: 'x' | 'y', delta: number ) => { let node: Element | null = from; while (node && node !== boundary.parentElement) { const canScroll = axis === 'y' ? node.scrollHeight > node.clientHeight : node.scrollWidth > node.clientWidth; if (canScroll) { const position = axis === 'y' ? node.scrollTop : node.scrollLeft; const max = axis === 'y' ? node.scrollHeight - node.clientHeight : node.scrollWidth - node.clientWidth; if (delta > 0 ? position > 0 : position < max) return true; } node = node.parentElement; } return false; }; /** * Edge sheet with drag-to-dismiss. * * The touch-native counterpart to Dialog, and modal for the same reasons — it * is built on the same Radix primitive, so focus trapping, scroll locking and * Escape all behave identically. A common pattern is Drawer on small screens * and Dialog above `md`; `useIsMobile()` from `/hooks` gives you the * breakpoint without a layout flash. * * ```tsx * * * * Filters * * * ``` */ function Drawer({ direction = 'bottom', dismissible = true, open, defaultOpen, onOpenChange, children, ...props }: DrawerProps) { const [isOpen, setOpen] = useControllableState({ value: open, defaultValue: defaultOpen ?? false, onChange: onOpenChange, }); const context = React.useMemo( () => ({ direction, dismissible, close: () => setOpen(false) }), [direction, dismissible, setOpen] ); return ( {children} ); } function DrawerTrigger(props: React.ComponentProps) { return ; } function DrawerPortal(props: React.ComponentProps) { return ; } function DrawerClose(props: React.ComponentProps) { return ; } function DrawerOverlay({ className, ...props }: React.ComponentProps) { return ( ); } const directionClasses: Record = { top: 'inset-x-0 top-0 mb-24 max-h-[80vh] rounded-b-lg border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top', bottom: 'inset-x-0 bottom-0 mt-24 max-h-[80vh] rounded-t-lg border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom', right: 'inset-y-0 right-0 w-3/4 border-l sm:max-w-sm data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right', left: 'inset-y-0 left-0 w-3/4 border-r sm:max-w-sm data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left', }; export interface DrawerContentProps extends React.ComponentProps { /** Show the grab bar. Defaults on for the top and bottom edges. */ showHandle?: boolean; } function DrawerContent({ className, children, showHandle, ...props }: DrawerContentProps) { const { direction, dismissible, close } = useDrawer(); const contentRef = React.useRef(null); /** Where the pointer went down, and when — `null` while no drag is running. */ const drag = React.useRef<{ at: number; time: number; active: boolean } | null>(null); const [offset, setOffset] = React.useState(0); const [dragging, setDragging] = React.useState(false); const axis = axisOf(direction); const outward = outwardOf(direction); const handleVisible = showHandle ?? (direction === 'bottom' || direction === 'top'); const positionOf = (event: React.PointerEvent) => (axis === 'y' ? event.clientY : event.clientX); const endDrag = (travelled: number, elapsed: number) => { const velocity = elapsed > 0 ? travelled / elapsed : 0; if (travelled >= DRAG_THRESHOLD || velocity >= FLICK_VELOCITY) close(); /* Either way the inline transform goes: on close the exit animation takes over, and on cancel the panel springs back to its resting place. */ drag.current = null; setDragging(false); setOffset(0); }; return ( { if (!dismissible) event.preventDefault(); }} onEscapeKeyDown={(event) => { if (!dismissible) event.preventDefault(); }} className={cn( 'group/drawer-content fixed z-(--ui-z-modal) flex h-auto flex-col bg-background', 'data-[state=open]:animate-in data-[state=closed]:animate-out', 'duration-(--ui-duration-normal) ease-(--ui-ease-standard)', /* No transition mid-drag: the panel has to track the finger exactly. */ !dragging && 'transition-transform', directionClasses[direction], className )} style={offset ? { transform: `translate${axis.toUpperCase()}(${offset * outward}px)` } : undefined} onPointerDown={(event) => { if (!dismissible || event.button !== 0) return; drag.current = { at: positionOf(event), time: Date.now(), active: false }; }} onPointerMove={(event) => { const start = drag.current; if (!start || !contentRef.current) return; /* Signed travel, then folded to "how far outward" — dragging the panel further onto the screen should do nothing. */ const delta = (positionOf(event) - start.at) * outward; if (!start.active) { if (Math.abs(delta) < 4) return; if (scrollAbsorbsDrag(event.target as Element, contentRef.current, axis, -delta * outward)) { drag.current = null; return; } start.active = true; setDragging(true); } setOffset(Math.max(0, delta)); }} onPointerUp={() => { if (!drag.current) return; endDrag(offset, Date.now() - drag.current.time); }} onPointerCancel={() => { drag.current = null; setDragging(false); setOffset(0); }} {...props} > {handleVisible ? (
) : null} {children} ); } function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
); } function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) { return (
); } function DrawerTitle({ className, ...props }: React.ComponentProps) { return ( ); } function DrawerDescription({ className, ...props }: React.ComponentProps) { return ( ); } export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };