/** * @title Dialog * @description 사용자의 흐름을 잠시 멈추고 중요한 정보를 전달하거나 작업을 완료하도록 하는 모달형 컴포넌트예요. */'use client';import * as React from 'react';import { Dialog as DialogPrimitive } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { XIcon } from 'lucide-react';import { cn } from '@/lib/utils';import { ActionButton, type ActionButtonProps } from '@/components/ui/action-button';// SEED Rootage spec: dialog.yaml, dialog-close-button.yamltype DialogSize = 'medium' | 'large';interface DialogContextValue { size: DialogSize; closeOnInteractOutside: boolean;}const DialogContext = React.createContext<DialogContextValue>({ size: 'medium', closeOnInteractOutside: false });interface DialogProps extends React.ComponentProps<typeof DialogPrimitive.Root> { /** * md 이상에서 적용되는 너비예요. medium = 480px, large = 800px. * @default 'medium' */ size?: DialogSize; /** * 바깥 영역(Backdrop)을 눌렀을 때 닫을지 정해요. * @default false */ closeOnInteractOutside?: boolean;}function Dialog({ size = 'medium', closeOnInteractOutside = false, ...props }: DialogProps) { const value = React.useMemo(() => ({ size, closeOnInteractOutside }), [size, closeOnInteractOutside]); return ( <DialogContext.Provider value={value}> <DialogPrimitive.Root data-slot="dialog" {...props} /> </DialogContext.Provider> );}function DialogTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger>) { return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;}function DialogPortal(props: React.ComponentProps<typeof DialogPrimitive.Portal>) { return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;}function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) { return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;}function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { return ( <DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn( 'fixed inset-0 z-50 bg-bg-overlay', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-(--rb-duration-d2) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', className, )} {...props} /> );}// md 미만: 화면 너비의 90%, md 이상: size 너비 (좌우 최소 x5 여백). 최대 높이는 화면의 80%.const dialogContentVariants = cva( [ 'group/dialog fixed top-1/2 left-1/2 z-50 flex -translate-x-1/2 -translate-y-1/2 flex-col break-all outline-none', 'w-[90vw] max-h-[80dvh] md:max-w-[calc(100vw-var(--rb-dimension-x5)*2)]', 'rounded-r5 bg-bg-layer-floating', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-130 data-[state=open]:duration-(--rb-duration-d4) data-[state=open]:ease-enter-expressive', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', ], { variants: { size: { medium: 'md:w-[480px]', large: 'md:w-[800px]', }, }, defaultVariants: { size: 'medium', }, },);interface DialogContentProps extends Omit<React.ComponentProps<typeof DialogPrimitive.Content>, 'title'>, VariantProps<typeof dialogContentVariants> { /** 지정하면 Header 에 제목을 그려요. */ title?: React.ReactNode; /** 지정하면 Header 에 설명을 그려요. */ description?: React.ReactNode; /** * Header 오른쪽 위에 닫기 버튼을 보여줘요. * @default true */ showCloseButton?: boolean;}function DialogContent({ className, size, title, description, showCloseButton = true, children, onPointerDownOutside, ...props}: DialogContentProps) { const context = React.useContext(DialogContext); const shouldRenderHeader = title != null || description != null; return ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Content data-slot="dialog-content" data-show-close-button={showCloseButton || undefined} className={cn(dialogContentVariants({ size: size ?? context.size }), className)} onPointerDownOutside={(event) => { onPointerDownOutside?.(event); if (!context.closeOnInteractOutside) event.preventDefault(); }} {...props} > {shouldRenderHeader && ( <DialogHeader> {title != null && <DialogTitle>{title}</DialogTitle>} {description != null && <DialogDescription>{description}</DialogDescription>} </DialogHeader> )} {children} {showCloseButton && <DialogCloseButton />} </DialogPrimitive.Content> </DialogPortal> );}function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="dialog-header" className={cn( 'flex shrink-0 flex-col gap-x1_5 px-x6 pt-x6 pb-x4', // 닫기 버튼 자리 확보: fromRight(x6) + icon(22px) + closeButtonGap(x1_5) 'group-data-[show-close-button]/dialog:pr-[calc(var(--rb-dimension-x6)+22px+var(--rb-dimension-x1_5))]', className, )} {...props} /> );}function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { return ( <DialogPrimitive.Title data-slot="dialog-title" className={cn('m-0 text-t8 font-bold text-fg-neutral', className)} {...props} /> );}function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) { return ( <DialogPrimitive.Description data-slot="dialog-description" className={cn('m-0 whitespace-pre-wrap text-t5 font-regular text-fg-neutral-muted', className)} {...props} /> );}/** 본문이 스크롤된 상태(scrolled)와 넘친 상태(overflow)를 추적해요. */function useScrollState(ref: React.RefObject<HTMLDivElement | null>) { const [scrolled, setScrolled] = React.useState(false); const [overflow, setOverflow] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el) return; const update = () => { setScrolled(el.scrollTop > 0); setOverflow(el.scrollHeight > el.clientHeight + 1); }; update(); el.addEventListener('scroll', update, { passive: true }); const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null; observer?.observe(el); for (const child of Array.from(el.children)) observer?.observe(child); return () => { el.removeEventListener('scroll', update); observer?.disconnect(); }; }, [ref]); return { scrolled, overflow };}function DialogBody({ className, ref, ...props }: React.ComponentProps<'div'>) { const innerRef = React.useRef<HTMLDivElement | null>(null); const { scrolled, overflow } = useScrollState(innerRef); const setRefs = React.useCallback( (node: HTMLDivElement | null) => { innerRef.current = node; if (typeof ref === 'function') ref(node); else if (ref) ref.current = node; }, [ref], ); return ( <div ref={setRefs} data-slot="dialog-body" data-scrolled={scrolled || undefined} data-overflow={overflow || undefined} tabIndex={overflow ? 0 : undefined} className={cn( 'box-border flex min-h-0 flex-1 flex-col overflow-y-auto px-x6', 'transition-shadow duration-(--rb-duration-color-transition) ease-easing', // scrolled: 상단 divider (stroke-neutral-muted, 1px) 'data-[scrolled]:not-first:shadow-[inset_0_1px_0_0_var(--rb-color-stroke-neutral-muted)]', // overflow: 하단 padding + scroll fog 'data-[overflow]:pb-x12 data-[overflow]:[mask-image:linear-gradient(to_top,transparent_0,black_var(--rb-dimension-x12))]', // 넘칠 때만 포커스를 받아요 (안쪽 포커스 링) 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', className, )} {...props} /> );}function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="dialog-footer" className={cn('flex shrink-0 flex-col items-stretch px-x6 pt-x4 pb-x6', className)} {...props} /> );}/** SEED dialog-close-button: 52px 터치 영역, 22px 아이콘, top x7 / right x6. */function DialogCloseButton({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { return ( <DialogPrimitive.Close data-slot="dialog-close-button" aria-label="닫기" className={cn( 'absolute top-x7 right-x6 m-[-15px] flex cursor-pointer items-center justify-center border-none p-[15px]', 'rounded-r3 bg-bg-transparent text-fg-neutral-subtle active:bg-bg-transparent-pressed', 'transition-[background-color,scale,outline-color] duration-(--rb-duration-color-transition) ease-easing active:scale-[0.95]', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', '[&_svg]:pointer-events-none [&_svg]:size-[22px] [&_svg]:shrink-0', className, )} {...props} > <XIcon aria-hidden /> </DialogPrimitive.Close> );}type DialogActionProps = ActionButtonProps;/** 누르면 다이얼로그를 닫는 Action Button 이에요. `onClick` 에서 `event.preventDefault()` 를 부르면 닫히지 않아요. */function DialogAction(props: DialogActionProps) { return ( <DialogPrimitive.Close asChild> <ActionButton data-slot="dialog-action" {...props} /> </DialogPrimitive.Close> );}export { Dialog, DialogTrigger, DialogPortal, DialogOverlay, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter, DialogCloseButton, DialogAction, dialogContentVariants,};export type { DialogProps, DialogContentProps, DialogActionProps };