1'use client';
2
3import { cva, type VariantProps } from 'class-variance-authority';
4import {
5 AnimatePresence,
6 type HTMLMotionProps,
7 motion,
8 useDragControls,
9 useMotionValue,
10 useReducedMotion,
11} from 'motion/react';
12import * as React from 'react';
13import { createPortal } from 'react-dom';
14import { cn } from '../lib/cn';
15import {
16 assignRef,
17 focusFirst,
18 lockPageScroll,
19 trapFocus,
20 useIsClient,
21 useLayer,
22} from '../lib/overlay';
23import { backdrop, reveal, slideVariants } from '../lib/motion';
24import { useControllableState } from '../lib/use-controllable-state';
25import { Button, type ButtonProps } from './button';
26
27type IconProps = React.SVGProps<SVGSVGElement> & {
28 size?: number | string;
29};
30
31function XIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
32 return (
33 <svg
34 xmlns="http://www.w3.org/2000/svg"
35 width={size}
36 height={size}
37 viewBox="0 0 24 24"
38 fill="none"
39 stroke="currentColor"
40 strokeWidth={strokeWidth}
41 strokeLinecap="round"
42 strokeLinejoin="round"
43 aria-hidden="true"
44 {...props}
45 >
46 <path d="M18 6 6 18" />
47 <path d="m6 6 12 12" />
48 </svg>
49 );
50}
51
52const DRAWER_OVERLAY_STYLE = { willChange: 'opacity' } as const;
53const DRAWER_CONTENT_STYLE = { willChange: 'transform' } as const;
54const CLOSE_BUTTON_TAP = { scale: 0.9 } as const;
55const SWIPE_CLOSE_THRESHOLD = 80;
56
57type DrawerContextProps = {
58 open: boolean;
59 setOpen: React.Dispatch<React.SetStateAction<boolean>>;
60 id: string;
61 hasTitle: boolean;
62 setHasTitle: (value: boolean) => void;
63 hasDescription: boolean;
64 setHasDescription: (value: boolean) => void;
65};
66
67const DrawerContext = React.createContext<DrawerContextProps | null>(null);
68
69function useDrawerContext() {
70 const ctx = React.use(DrawerContext);
71 if (!ctx) throw new Error('Drawer components must be inside <Drawer>.');
72 return ctx;
73}
74
75interface DrawerProps {
76 children: React.ReactNode;
77 defaultOpen?: boolean;
78 open?: boolean;
79 onOpenChange?: (open: boolean) => void;
80}
81
82const DrawerRoot = ({
83 children,
84 defaultOpen = false,
85 open: controlledOpen,
86 onOpenChange,
87}: DrawerProps) => {
88 const [open, setOpen] = useControllableState({
89 value: controlledOpen,
90 defaultValue: defaultOpen,
91 onChange: onOpenChange,
92 });
93 const [hasTitle, setHasTitle] = React.useState(false);
94 const [hasDescription, setHasDescription] = React.useState(false);
95 const id = React.useId();
96
97 React.useEffect(() => {
98 if (!open) return;
99 return lockPageScroll();
100 }, [open]);
101
102 const value = React.useMemo<DrawerContextProps>(
103 () => ({ open, setOpen, id, hasTitle, setHasTitle, hasDescription, setHasDescription }),
104 [open, setOpen, id, hasTitle, hasDescription],
105 );
106
107 return <DrawerContext value={value}>{children}</DrawerContext>;
108};
109
110const DrawerTrigger = ({ children, className, variant, size, onClick, ...props }: ButtonProps) => {
111 const { open, setOpen } = useDrawerContext();
112
113 return (
114 <Button
115 className={className}
116 aria-haspopup="dialog"
117 aria-expanded={open}
118 onClick={(e) => {
119 onClick?.(e);
120 setOpen(true);
121 }}
122 variant={variant}
123 size={size}
124 {...props}
125 >
126 {children}
127 </Button>
128 );
129};
130
131const DrawerOverlay = ({ className }: { className?: string }) => {
132 const { setOpen } = useDrawerContext();
133 const shouldReduceMotion = useReducedMotion();
134
135 return (
136 <motion.div
137 variants={shouldReduceMotion ? reveal : backdrop}
138 initial="hidden"
139 animate="visible"
140 exit="exit"
141 style={DRAWER_OVERLAY_STYLE}
142 className={cn('bg-overlay fixed inset-0 z-500 backdrop-blur-xs', className)}
143 onClick={() => setOpen(false)}
144 />
145 );
146};
147
148const drawerVariants = cva(
149 'fixed z-500 flex flex-col overflow-hidden bg-background border shadow-lg rounded-2xl',
150 {
151 variants: {
152 side: {
153 bottom:
154 'bottom-[max(0.75rem,env(safe-area-inset-bottom))] left-[max(0.75rem,env(safe-area-inset-left))] right-[max(0.75rem,env(safe-area-inset-right))] max-h-[calc(100dvh-max(0.75rem,env(safe-area-inset-top))-max(0.75rem,env(safe-area-inset-bottom)))]',
155 top: 'top-[max(0.75rem,env(safe-area-inset-top))] left-[max(0.75rem,env(safe-area-inset-left))] right-[max(0.75rem,env(safe-area-inset-right))] max-h-[calc(100dvh-max(0.75rem,env(safe-area-inset-top))-max(0.75rem,env(safe-area-inset-bottom)))]',
156 left: 'left-[max(0.75rem,env(safe-area-inset-left))] top-[max(0.75rem,env(safe-area-inset-top))] bottom-[max(0.75rem,env(safe-area-inset-bottom))] w-[85vw] sm:w-96',
157 right:
158 'right-[max(0.75rem,env(safe-area-inset-right))] top-[max(0.75rem,env(safe-area-inset-top))] bottom-[max(0.75rem,env(safe-area-inset-bottom))] w-[85vw] sm:w-96',
159 },
160 },
161 defaultVariants: {
162 side: 'bottom',
163 },
164 },
165);
166
167interface DrawerContentProps
168 extends Omit<HTMLMotionProps<'div'>, 'children'>, VariantProps<typeof drawerVariants> {
169 children: React.ReactNode;
170 showDragHandle?: boolean;
171 showClose?: boolean;
172 closeLabel?: string;
173}
174
175const DrawerContent = ({
176 children,
177 className,
178 side = 'bottom',
179 showDragHandle = true,
180 showClose = true,
181 closeLabel = 'Close',
182 ref,
183 ...props
184}: DrawerContentProps & { ref?: React.Ref<HTMLDivElement> }) => {
185 const { open, setOpen, id, hasTitle, hasDescription } = useDrawerContext();
186 const mounted = useIsClient();
187 const contentRef = React.useRef<HTMLDivElement>(null);
188 const layer = useLayer(open, true);
189 const triggerRef = React.useRef<HTMLElement | null>(null);
190
191 const dragY = useMotionValue(0);
192 const dragX = useMotionValue(0);
193 const dragControls = useDragControls();
194 const shouldReduceMotion = useReducedMotion();
195
196 React.useEffect(() => {
197 if (!open) return;
198
199 triggerRef.current = document.activeElement as HTMLElement;
200
201 const handleKeyDown = (e: KeyboardEvent) => {
202 if (e.defaultPrevented) return;
203 const content = contentRef.current;
204 if (!content) return;
205
206 if (e.key === 'Escape') {
207 if (!layer.isTopmost()) return;
208 e.preventDefault();
209 setOpen(false);
210 return;
211 }
212 if (layer.isTopmostModal() && !layer.isInsideUpperLayer(e.target)) trapFocus(e, content);
213 };
214
215 document.addEventListener('keydown', handleKeyDown);
216
217 const frame = requestAnimationFrame(() => {
218 if (contentRef.current) focusFirst(contentRef.current);
219 });
220
221 return () => {
222 cancelAnimationFrame(frame);
223 document.removeEventListener('keydown', handleKeyDown);
224 triggerRef.current?.focus();
225 };
226 }, [open, setOpen, layer]);
227
228 const isVertical = side === 'bottom' || side === 'top';
229 const isHorizontal = side === 'left' || side === 'right';
230
231 const dragAxis = isVertical ? 'y' : 'x';
232
233 const dragConstraints = React.useMemo(() => {
234 if (side === 'bottom') return { top: 0, bottom: 0 };
235 if (side === 'top') return { top: 0, bottom: 0 };
236 if (side === 'left') return { left: 0, right: 0 };
237 if (side === 'right') return { left: 0, right: 0 };
238 return {};
239 }, [side]);
240
241 const dragElastic = 0.15;
242
243 const handleDragEnd = (_: unknown, info: { offset: { x: number; y: number } }) => {
244 const { x, y } = info.offset;
245
246 const shouldClose =
247 (side === 'bottom' && y > SWIPE_CLOSE_THRESHOLD) ||
248 (side === 'top' && -y > SWIPE_CLOSE_THRESHOLD) ||
249 (side === 'right' && x > SWIPE_CLOSE_THRESHOLD) ||
250 (side === 'left' && -x > SWIPE_CLOSE_THRESHOLD);
251
252 if (shouldClose) {
253 setOpen(false);
254 } else {
255 if (isVertical) dragY.set(0);
256 if (isHorizontal) dragX.set(0);
257 }
258 };
259
260 if (!mounted) return null;
261
262 const drawer = (
263 <AnimatePresence>
264 {open && (
265 <>
266 <DrawerOverlay />
267 <motion.div
268 ref={(node: HTMLDivElement | null) => {
269 contentRef.current = node;
270 assignRef(ref, node);
271 }}
272 role="dialog"
273 aria-modal="true"
274 aria-labelledby={hasTitle ? `${id}-title` : undefined}
275 aria-describedby={hasDescription ? `${id}-description` : undefined}
276 tabIndex={-1}
277 drag={dragAxis}
278 dragControls={dragControls}
279 dragListener={false}
280 dragMomentum={false}
281 dragElastic={dragElastic}
282 dragConstraints={dragConstraints}
283 onDragEnd={handleDragEnd}
284 style={{
285 ...(isVertical ? { y: dragY } : { x: dragX }),
286 ...DRAWER_CONTENT_STYLE,
287 }}
288 variants={
289 shouldReduceMotion ? reveal : slideVariants[side as keyof typeof slideVariants]
290 }
291 initial="hidden"
292 animate="visible"
293 exit="exit"
294 className={cn(drawerVariants({ side }), className)}
295 {...props}
296 >
297 <div
298 className="shrink-0 touch-none select-none"
299 onPointerDown={(event) => dragControls.start(event)}
300 >
301 {showDragHandle && (
302 <div aria-hidden="true" className="flex items-center justify-center py-2.5">
303 <div className="bg-muted-foreground/30 h-1 w-10 cursor-grab rounded-full active:cursor-grabbing" />
304 </div>
305 )}
306 {showClose && (
307 <div
308 className={cn(
309 'flex justify-end px-4 pt-2 pb-0',
310 isVertical && 'mx-auto w-full max-w-lg',
311 )}
312 >
313 <motion.button
314 type="button"
315 whileTap={shouldReduceMotion ? undefined : CLOSE_BUTTON_TAP}
316 onClick={() => setOpen(false)}
317 className="hover:bg-muted focus-visible:ring-ring rounded-full p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
318 aria-label={closeLabel}
319 >
320 <XIcon className="h-4 w-4" size={16} />
321 </motion.button>
322 </div>
323 )}
324 </div>
325 <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
326 <div className={cn('px-5 pt-2 pb-5', isVertical && 'mx-auto w-full max-w-lg')}>
327 {children}
328 </div>
329 </div>
330 </motion.div>
331 </>
332 )}
333 </AnimatePresence>
334 );
335
336 return createPortal(drawer, document.body);
337};
338
339const DrawerClose = ({
340 children,
341 onClick,
342 ...props
343}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
344 const { setOpen } = useDrawerContext();
345
346 return (
347 <button
348 type="button"
349 onClick={(e) => {
350 onClick?.(e);
351 setOpen(false);
352 }}
353 {...props}
354 >
355 {children}
356 </button>
357 );
358};
359
360const DrawerHeader = ({
361 children,
362 className,
363}: {
364 children: React.ReactNode;
365 className?: string;
366}) => {
367 return <div className={cn('mb-4', className)}>{children}</div>;
368};
369
370const DrawerTitle = ({
371 children,
372 className,
373}: {
374 children: React.ReactNode;
375 className?: string;
376}) => {
377 const { id, setHasTitle } = useDrawerContext();
378
379 React.useEffect(() => {
380 setHasTitle(true);
381 return () => setHasTitle(false);
382 }, [setHasTitle]);
383
384 return (
385 <h2 id={`${id}-title`} className={cn('text-xl font-semibold tracking-tight', className)}>
386 {children}
387 </h2>
388 );
389};
390
391const DrawerDescription = ({
392 children,
393 className,
394}: {
395 children: React.ReactNode;
396 className?: string;
397}) => {
398 const { id, setHasDescription } = useDrawerContext();
399
400 React.useEffect(() => {
401 setHasDescription(true);
402 return () => setHasDescription(false);
403 }, [setHasDescription]);
404
405 return (
406 <p id={`${id}-description`} className={cn('text-muted-foreground mt-1 text-sm', className)}>
407 {children}
408 </p>
409 );
410};
411
412const Drawer = Object.assign(DrawerRoot, {
413 Trigger: DrawerTrigger,
414 Content: DrawerContent,
415 Header: DrawerHeader,
416 Title: DrawerTitle,
417 Description: DrawerDescription,
418 Close: DrawerClose,
419});
420
421export { Drawer };