/** * @title Bottom Sheet * @description 화면 하단에서 올라오는 모달 컴포넌트예요. 현재 맥락을 유지하면서 추가 정보나 액션을 보여줄 때 사용해요. */'use client';import * as React from 'react';import { Drawer as DrawerPrimitive } from 'vaul';import { cva } from 'class-variance-authority';import { XIcon } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: bottom-sheet.yaml, bottom-sheet-handle.yaml, bottom-sheet-close-button.yamltype HeaderAlign = 'left' | 'center';interface BottomSheetContextValue { headerAlign: HeaderAlign;}const BottomSheetContext = React.createContext<BottomSheetContextValue>({ headerAlign: 'left' });type BottomSheetProps = React.ComponentProps<typeof DrawerPrimitive.Root> & { /** Title 과 Description 의 정렬이에요. */ headerAlign?: HeaderAlign;};function BottomSheet({ headerAlign = 'left', ...props }: BottomSheetProps) { const value = React.useMemo(() => ({ headerAlign }), [headerAlign]); return ( <BottomSheetContext.Provider value={value}> <DrawerPrimitive.Root data-slot="bottom-sheet" {...props} /> </BottomSheetContext.Provider> );}function BottomSheetTrigger(props: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { return <DrawerPrimitive.Trigger data-slot="bottom-sheet-trigger" {...props} />;}function BottomSheetClose(props: React.ComponentProps<typeof DrawerPrimitive.Close>) { return <DrawerPrimitive.Close data-slot="bottom-sheet-close" {...props} />;}function BottomSheetPortal(props: React.ComponentProps<typeof DrawerPrimitive.Portal>) { return <DrawerPrimitive.Portal data-slot="bottom-sheet-portal" {...props} />;}function BottomSheetBackdrop({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) { return ( <DrawerPrimitive.Overlay data-slot="bottom-sheet-backdrop" className={cn( 'fixed inset-0 z-50 bg-bg-overlay', // backdrop: enter d6 / enter, exit d4 / exit (vaul 기본 애니메이션 값을 덮어써요) 'data-[state=open]:[animation-duration:var(--rb-duration-d6)]! data-[state=open]:[animation-timing-function:var(--rb-timing-function-enter)]!', 'data-[state=closed]:[animation-duration:var(--rb-duration-d4)]! data-[state=closed]:[animation-timing-function:var(--rb-timing-function-exit)]!', className, )} {...props} /> );}function BottomSheetHandle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Handle>) { return ( <DrawerPrimitive.Handle data-slot="bottom-sheet-handle" className={cn( // vaul 이 주입하는 핸들 스타일을 SEED 스펙(36x4, gray-400, top 6px)으로 덮어써요. 'absolute! top-[6px]! left-1/2! m-0! h-[4px]! w-[36px]! -translate-x-1/2 rounded-full! bg-palette-gray-400! opacity-100!', 'transition-[background-color] duration-(--rb-duration-color-transition) ease-easing active:bg-palette-gray-500!', className, )} {...props} /> );}interface BottomSheetContentProps extends Omit<React.ComponentProps<typeof DrawerPrimitive.Content>, 'title'> { title?: React.ReactNode; description?: React.ReactNode; /** @default true */ showCloseButton?: boolean; /** @default false */ showHandle?: boolean;}function BottomSheetContent({ className, children, title, description, showCloseButton = true, showHandle = false, ...props}: BottomSheetContentProps) { const shouldRenderHeader = Boolean(title || description); return ( <BottomSheetPortal> <BottomSheetBackdrop /> <DrawerPrimitive.Content data-slot="bottom-sheet-content" className={cn( 'fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-[480px] flex-col break-all outline-none', 'max-h-[min(90dvh,100dvh_-_env(safe-area-inset-top))] rounded-t-r6 bg-bg-layer-floating pb-[env(safe-area-inset-bottom)]', // content: enter d6 / enter-expressive, exit d4 / exit 'data-[state=open]:[animation-duration:var(--rb-duration-d6)]! data-[state=open]:[animation-timing-function:var(--rb-timing-function-enter-expressive)]!', 'data-[state=closed]:[animation-duration:var(--rb-duration-d4)]! data-[state=closed]:[animation-timing-function:var(--rb-timing-function-exit)]!', className, )} {...props} > {showHandle && <BottomSheetHandle />} {shouldRenderHeader ? ( <BottomSheetHeader> {title ? ( <BottomSheetTitle showCloseButton={showCloseButton}>{title}</BottomSheetTitle> ) : ( <DrawerPrimitive.Title className="sr-only">{props['aria-label'] ?? ''}</DrawerPrimitive.Title> )} {description && <BottomSheetDescription>{description}</BottomSheetDescription>} </BottomSheetHeader> ) : ( <DrawerPrimitive.Title className="sr-only">{props['aria-label'] ?? ''}</DrawerPrimitive.Title> )} {children} {showCloseButton && <BottomSheetCloseButton />} </DrawerPrimitive.Content> </BottomSheetPortal> );}function BottomSheetHeader({ className, ...props }: React.ComponentProps<'div'>) { const { headerAlign } = React.useContext(BottomSheetContext); return ( <div data-slot="bottom-sheet-header" data-header-align={headerAlign} className={cn( 'flex flex-col gap-x2 pt-x6 pb-x4', headerAlign === 'center' ? 'justify-center text-center' : 'justify-start', className, )} {...props} /> );}const bottomSheetTitleVariants = cva('m-0 text-t8 font-bold text-fg-neutral break-keep', { variants: { headerAlign: { left: '', center: '' }, showCloseButton: { true: '', false: '' }, }, compoundVariants: [ { headerAlign: 'left', showCloseButton: true, className: 'pr-[56px] pl-spacing-x-global-gutter' }, { headerAlign: 'left', showCloseButton: false, className: 'px-spacing-x-global-gutter' }, { headerAlign: 'center', showCloseButton: true, className: 'px-[56px]' }, { headerAlign: 'center', showCloseButton: false, className: 'px-spacing-x-global-gutter' }, ], defaultVariants: { headerAlign: 'left', showCloseButton: true },});function BottomSheetTitle({ className, showCloseButton = true, ...props}: React.ComponentProps<typeof DrawerPrimitive.Title> & { showCloseButton?: boolean }) { const { headerAlign } = React.useContext(BottomSheetContext); return ( <DrawerPrimitive.Title data-slot="bottom-sheet-title" data-show-close-button={showCloseButton || undefined} className={cn(bottomSheetTitleVariants({ headerAlign, showCloseButton }), className)} {...props} /> );}function BottomSheetDescription({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Description>) { return ( <DrawerPrimitive.Description data-slot="bottom-sheet-description" className={cn( 'm-0 px-spacing-x-global-gutter text-t5 font-regular whitespace-pre-wrap text-fg-neutral-muted', className, )} {...props} /> );}interface BottomSheetBodyProps extends React.ComponentProps<'div'> { /** Body 의 최대 높이예요. 넘치면 Body 안에서 스크롤돼요. */ maxHeight?: React.CSSProperties['maxHeight'];}function BottomSheetBody({ className, maxHeight, style, ...props }: BottomSheetBodyProps) { return ( <div data-slot="bottom-sheet-body" className={cn('flex min-h-0 flex-col overflow-y-auto px-spacing-x-global-gutter', className)} style={maxHeight !== undefined ? { maxHeight, ...style } : style} {...props} /> );}function BottomSheetFooter({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="bottom-sheet-footer" className={cn('flex flex-col px-spacing-x-global-gutter pt-x3 pb-x4', className)} {...props} /> );}function BottomSheetCloseButton({ className, children, 'aria-label': ariaLabel = '닫기', ...props}: React.ComponentProps<typeof DrawerPrimitive.Close>) { return ( <DrawerPrimitive.Close data-slot="bottom-sheet-close-button" aria-label={ariaLabel} className={cn( 'absolute top-x6 right-x4 flex size-[28px] cursor-pointer items-center justify-center rounded-full border-none bg-bg-neutral-weak text-fg-neutral', // 44px 터치 영역 "after:absolute after:-inset-[8px] after:rounded-r1 after:content-['']", 'transition-[background-color,scale,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', 'active:scale-[0.95] active:bg-bg-neutral-weak-pressed', '[&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0', className, )} {...props} > {children ?? <XIcon strokeWidth={2.5} />} </DrawerPrimitive.Close> );}export { BottomSheet, BottomSheetTrigger, BottomSheetClose, BottomSheetPortal, BottomSheetBackdrop, BottomSheetHandle, BottomSheetContent, BottomSheetHeader, BottomSheetTitle, BottomSheetDescription, BottomSheetBody, BottomSheetFooter, BottomSheetCloseButton,};export type { BottomSheetProps, BottomSheetContentProps, BottomSheetBodyProps };