/** * @title Side Panel * @description 화면 좌측 또는 우측 가장자리에서 슬라이드되어 나타나, 현재 맥락을 유지한 채 상세 정보나 부수적인 작업을 제공하는 패널이에요. */import * as React from 'react';import { Dialog as DialogPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { X } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: side-panel.yaml, side-panel-close-button.yamlconst sidePanelContentVariants = cva( [ 'fixed inset-y-0 z-50 flex flex-col break-all bg-bg-layer-floating outline-none will-change-transform', // md 미만: 화면 너비의 80%, md 이상: size 토큰 너비 (최대 80%) 'w-[80vw] max-w-[80vw] md:w-(--side-panel-width)', 'pb-[env(safe-area-inset-bottom)]', 'duration-(--rb-duration-d6) data-[state=open]:animate-in data-[state=open]:ease-enter-expressive data-[state=closed]:animate-out data-[state=closed]:ease-exit-expressive', ], { variants: { direction: { right: 'right-0 slide-in-from-right slide-out-to-right', left: 'left-0 slide-in-from-left slide-out-to-left', }, size: { small: '[--side-panel-width:480px]', medium: '[--side-panel-width:720px]', large: '[--side-panel-width:960px]', }, }, defaultVariants: { direction: 'right', size: 'medium', }, },);type SidePanelDirection = 'left' | 'right';type SidePanelSize = 'small' | 'medium' | 'large';const SidePanelContext = React.createContext<{ direction: SidePanelDirection; size: SidePanelSize; modal: boolean; dismissible: boolean;}>({ direction: 'right', size: 'medium', modal: true, dismissible: true });export interface SidePanelRootProps extends React.ComponentProps<typeof DialogPrimitive.Root> { /** 패널이 나타나는 방향 */ direction?: SidePanelDirection; /** 패널 너비. small 480px · medium 720px · large 960px */ size?: SidePanelSize; /** false 면 Escape 키와 외부 클릭으로 닫히지 않아요. */ dismissible?: boolean;}function SidePanelRoot({ direction = 'right', size = 'medium', modal = true, dismissible = true, ...props}: SidePanelRootProps) { const value = React.useMemo( () => ({ direction, size, modal, dismissible }), [direction, size, modal, dismissible], ); return ( <SidePanelContext.Provider value={value}> <DialogPrimitive.Root data-slot="side-panel" modal={modal} {...props} /> </SidePanelContext.Provider> );}export interface SidePanelTriggerProps extends React.ComponentProps<typeof DialogPrimitive.Trigger> {}function SidePanelTrigger(props: SidePanelTriggerProps) { return <DialogPrimitive.Trigger data-slot="side-panel-trigger" {...props} />;}export interface SidePanelCloseProps extends React.ComponentProps<typeof DialogPrimitive.Close> {}function SidePanelClose(props: SidePanelCloseProps) { return <DialogPrimitive.Close data-slot="side-panel-close" {...props} />;}export interface SidePanelContentProps extends Omit<React.ComponentProps<typeof DialogPrimitive.Content>, 'title'> { title?: React.ReactNode; description?: React.ReactNode; /** 헤더의 닫기 버튼을 보여줘요. */ showCloseButton?: boolean;}function SidePanelContent({ className, children, title, description, showCloseButton = true, onEscapeKeyDown, onInteractOutside, ...props}: SidePanelContentProps) { const { direction, size, modal, dismissible } = React.useContext(SidePanelContext); const shouldRenderHeader = Boolean(title || description || showCloseButton); return ( <DialogPrimitive.Portal> <DialogPrimitive.Overlay data-slot="side-panel-backdrop" className={cn( 'fixed inset-0 z-50 bg-bg-overlay duration-(--rb-duration-d6)', 'data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:ease-exit', )} /> <DialogPrimitive.Content data-slot="side-panel-content" data-direction={direction} {...(description ? {} : { 'aria-describedby': undefined })} onEscapeKeyDown={(event) => { if (!dismissible) event.preventDefault(); onEscapeKeyDown?.(event); }} onInteractOutside={(event) => { // Persistent(non-modal) 타입은 닫기 버튼이나 트리거로만 닫혀요. if (!dismissible || !modal) event.preventDefault(); onInteractOutside?.(event); }} className={cn(sidePanelContentVariants({ direction, size }), className)} {...props} > {shouldRenderHeader && ( <div data-slot="side-panel-header" data-show-close-button={showCloseButton || undefined} className={cn( 'relative box-border flex min-h-[calc(70px+env(safe-area-inset-top))] flex-col gap-x1_5 px-x6 pt-[calc(var(--rb-dimension-x6)+env(safe-area-inset-top))] pb-x4', // closeButton.fromRight(24px) + icon(22px) + closeButtonGap(x1_5) showCloseButton && 'pr-[calc(46px+var(--rb-dimension-x1_5))]', )} > {title && ( <DialogPrimitive.Title data-slot="side-panel-title" className="m-0 break-keep text-t8 font-bold text-fg-neutral" > {title} </DialogPrimitive.Title> )} {description && ( <DialogPrimitive.Description data-slot="side-panel-description" className="m-0 whitespace-pre-wrap text-t5 font-regular text-fg-neutral-muted" > {description} </DialogPrimitive.Description> )} {showCloseButton && ( <DialogPrimitive.Close data-slot="side-panel-close-button" aria-label="닫기" className={cn( // 52px 터치 영역, 22px 아이콘 — 패딩/음수 마진으로 시각적 위치를 아이콘 기준에 맞춰요. 'absolute top-[calc(28px+env(safe-area-inset-top))] right-[24px] m-[-15px] flex cursor-pointer items-center justify-center rounded-r3 border-none bg-bg-transparent p-[15px]', 'text-fg-neutral-subtle [&_svg]:size-[22px] [&_svg]:shrink-0', '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-transparent-pressed', )} > <X /> </DialogPrimitive.Close> )} </div> )} {children} </DialogPrimitive.Content> </DialogPrimitive.Portal> );}export interface SidePanelBodyProps extends React.ComponentProps<'div'> {}function SidePanelBody({ className, onScroll, ...props }: SidePanelBodyProps) { const [scrolled, setScrolled] = React.useState(false); return ( <div data-slot="side-panel-body" data-scrolled={scrolled || undefined} onScroll={(event) => { setScrolled(event.currentTarget.scrollTop > 0); onScroll?.(event); }} className={cn( 'box-border flex flex-1 flex-col overflow-y-auto px-x6 pb-x12', // 스크롤 시 헤더 아래 구분선 'transition-shadow duration-(--rb-duration-color-transition) ease-easing', 'not-first:data-[scrolled]:shadow-[inset_0_1px_0_0_var(--rb-color-stroke-neutral-muted)]', // 하단 Scroll Fog (높이 = paddingBottom) '[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} /> );}export interface SidePanelFooterProps extends React.ComponentProps<'div'> {}/** flex 레이아웃만 제공해요. 버튼 배치는 내부에서 직접 구성해요. */function SidePanelFooter({ className, ...props }: SidePanelFooterProps) { return ( <div data-slot="side-panel-footer" className={cn('flex flex-col px-x6 pt-x4 pb-x6', className)} {...props} /> );}export { SidePanelRoot, SidePanelTrigger, SidePanelClose, SidePanelContent, SidePanelBody, SidePanelFooter, sidePanelContentVariants,};