Repository Public
1'use client';2import * as React from 'react';3import * as D from '@radix-ui/react-dialog';4import { AnimatePresence, motion, useDragControls } from 'motion/react';5import { cn } from '@/lib/cn';67/**8 * Phone sheet that follows the finger: drag the handle/header down to dismiss (distance or9 * velocity), springs back otherwise. Focus-trapped and labelled through Radix Dialog.10 */11export function BottomSheet({12 open, onOpenChange, title, children, className, headerExtra, testId,13}: {14 open: boolean;15 onOpenChange: (open: boolean) => void;16 title: string;17 children: React.ReactNode;18 className?: string;19 headerExtra?: React.ReactNode;20 testId?: string;21}) {22 const controls = useDragControls();23 return (24 <D.Root open={open} onOpenChange={onOpenChange}>25 <AnimatePresence>26 {open && (27 <D.Portal forceMount>28 <D.Overlay asChild forceMount>29 <motion.div className="fixed inset-0 z-50 bg-scrim" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.18 }} />30 </D.Overlay>31 <D.Content asChild forceMount onOpenAutoFocus={(e) => e.preventDefault()}>32 <motion.div33 data-testid={testId}34 className={cn('fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-[88dvh] w-full max-w-xl flex-col rounded-t-[26px] bg-surface shadow-pop focus:outline-none', className)}35 initial={{ y: '100%' }}36 animate={{ y: 0 }}37 exit={{ y: '100%' }}38 transition={{ type: 'spring', stiffness: 420, damping: 38, mass: 0.9 }}39 drag="y"40 dragControls={controls}41 dragListener={false}42 dragConstraints={{ top: 0, bottom: 0 }}43 dragElastic={{ top: 0, bottom: 0.7 }}44 onDragEnd={(_, info) => {45 if (info.offset.y > 110 || info.velocity.y > 650) onOpenChange(false);46 }}47 >48 <div className="shrink-0 cursor-grab touch-none active:cursor-grabbing" onPointerDown={(e) => controls.start(e)}>49 <div className="mx-auto mt-2.5 h-1.5 w-10 rounded-full bg-line-3" aria-hidden />50 <div className="flex items-center gap-2 px-5 pb-2 pt-3">51 <D.Title className="min-w-0 flex-1 truncate text-[17px] font-semibold tracking-tight">{title}</D.Title>52 <D.Description className="sr-only">{title}</D.Description>53 {headerExtra}54 </div>55 </div>56 <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-[max(16px,env(safe-area-inset-bottom))]">{children}</div>57 </motion.div>58 </D.Content>59 </D.Portal>60 )}61 </AnimatePresence>62 </D.Root>63 );64}65