/** * @title Popover * @description 트리거에 앵커링되어 화면 위에 떠 있는 컨테이너예요. Header/Body/Footer 구조로 부가 정보나 액션을 보여줘요. */'use client';import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { XIcon } from 'lucide-react';import { cn } from '@/lib/utils';import { ActionButton, type ActionButtonProps } from '@/components/ui/action-button';// SEED Rootage spec: popover.yaml, popover-close-button.yamltype PopoverSide = 'top' | 'bottom' | 'left' | 'right';type PopoverAlign = 'start' | 'center' | 'end';export type PopoverPlacement = PopoverSide | `${PopoverSide}-${Exclude<PopoverAlign, 'center'>}`;function parsePlacement(placement: PopoverPlacement): { side: PopoverSide; align: PopoverAlign } { const [side, align] = placement.split('-') as [PopoverSide, PopoverAlign | undefined]; return { side, align: align ?? 'center' };}interface PopoverContextValue { placement: PopoverPlacement; closeOnInteractOutside: boolean;}const PopoverContext = React.createContext<PopoverContextValue>({ placement: 'bottom', closeOnInteractOutside: true,});export interface PopoverRootProps extends React.ComponentProps<typeof PopoverPrimitive.Root> { /** 트리거 기준 위치예요. 화면 경계를 벗어나면 자동으로 뒤집히거나(flip) 이동해요(shift). */ placement?: PopoverPlacement; /** 바깥 영역을 누르거나 포커스가 밖으로 나가면 닫혀요. */ closeOnInteractOutside?: boolean;}function PopoverRoot({ placement = 'bottom', closeOnInteractOutside = true, ...props }: PopoverRootProps) { const value = React.useMemo(() => ({ placement, closeOnInteractOutside }), [placement, closeOnInteractOutside]); return ( <PopoverContext.Provider value={value}> <PopoverPrimitive.Root data-slot="popover" {...props} /> </PopoverContext.Provider> );}export interface PopoverTriggerProps extends React.ComponentProps<typeof PopoverPrimitive.Trigger> {}function PopoverTrigger(props: PopoverTriggerProps) { return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;}export interface PopoverAnchorProps extends React.ComponentProps<typeof PopoverPrimitive.Anchor> {}/** 트리거와 분리된 요소를 기준으로 위치를 잡아요. 열림 상태는 `open` 으로 직접 제어해요. */function PopoverAnchor(props: PopoverAnchorProps) { return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;}export interface PopoverContentProps extends Omit<React.ComponentProps<typeof PopoverPrimitive.Content>, 'title'> { /** Header 제목이에요. 없으면 `aria-label` 을 지정해요. */ title?: React.ReactNode; /** Header 설명이에요. */ description?: React.ReactNode; /** 우측 상단 닫기 버튼 표시 여부예요. */ showCloseButton?: boolean; /** 포털을 렌더링할 컨테이너예요. */ positionerContainer?: React.ComponentProps<typeof PopoverPrimitive.Portal>['container'];}function PopoverContent({ className, children, title, description, showCloseButton = true, positionerContainer, onInteractOutside, onFocusOutside, ...props}: PopoverContentProps) { const { placement, closeOnInteractOutside } = React.useContext(PopoverContext); const { side, align } = parsePlacement(placement); const titleId = React.useId(); const descriptionId = React.useId(); const shouldRenderHeader = Boolean(title || description || showCloseButton); return ( <PopoverPrimitive.Portal container={positionerContainer}> <PopoverPrimitive.Content data-slot="popover-content" side={side} align={align} // gutter: $dimension.x2, overflowPadding: $dimension.x4 sideOffset={8} collisionPadding={16} aria-labelledby={title ? titleId : undefined} aria-describedby={description ? descriptionId : undefined} onInteractOutside={(event) => { onInteractOutside?.(event); if (!closeOnInteractOutside) event.preventDefault(); }} onFocusOutside={(event) => { onFocusOutside?.(event); if (!closeOnInteractOutside) event.preventDefault(); }} className={cn( 'relative z-[99999] box-border flex flex-col rounded-r5 bg-bg-layer-floating shadow-s3 outline-none', 'min-w-[min(320px,var(--radix-popover-content-available-width,320px))]', 'max-w-[min(480px,var(--radix-popover-content-available-width,480px))]', 'max-h-[min(600px,var(--radix-popover-content-available-height,600px))]', 'origin-(--radix-popover-content-transform-origin)', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-(--rb-duration-d3) data-[state=open]:ease-enter', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-(--rb-duration-d2) data-[state=closed]:ease-exit', className, )} {...props} > {shouldRenderHeader && ( <PopoverHeader data-show-close-button={showCloseButton || undefined}> {title && <PopoverTitle id={titleId}>{title}</PopoverTitle>} {description && <PopoverDescription id={descriptionId}>{description}</PopoverDescription>} </PopoverHeader> )} {children} {showCloseButton && <PopoverCloseButton />} </PopoverPrimitive.Content> </PopoverPrimitive.Portal> );}function PopoverHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="popover-header" className={cn( 'box-border flex shrink-0 flex-col gap-x1_5 px-x6 pt-x6 pb-x4', // closeButton fromRight(24px) + icon size(22px) + closeButtonGap(x1_5) 'data-[show-close-button]:pr-[calc(24px+22px+var(--rb-dimension-x1_5))]', className, )} {...props} /> );}function PopoverTitle({ className, ...props }: React.ComponentProps<'h2'>) { return ( <h2 data-slot="popover-title" className={cn('m-0 text-t7 font-bold text-fg-neutral [word-break:keep-all]', className)} {...props} /> );}function PopoverDescription({ className, ...props }: React.ComponentProps<'p'>) { return ( <p data-slot="popover-description" className={cn('m-0 whitespace-pre-wrap text-t4 font-regular text-fg-neutral-muted', className)} {...props} /> );}function PopoverCloseButton({ className, ...props }: React.ComponentProps<typeof PopoverPrimitive.Close>) { return ( <PopoverPrimitive.Close data-slot="popover-close-button" aria-label="닫기" className={cn( // size 52px, icon 22px → padding 15px, margin -15px (아이콘 위치 기준 fromTop 27px / fromRight 24px) 'absolute top-[27px] right-[24px] -m-[15px] flex cursor-pointer items-center justify-center rounded-r3 border-none p-[15px]', 'bg-bg-transparent text-fg-neutral-subtle [&_svg]:size-[22px]', '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', className, )} {...props} > <XIcon aria-hidden /> </PopoverPrimitive.Close> );}export interface PopoverBodyProps extends React.ComponentProps<'div'> {}/** * 본문이 넘치면 스크롤돼요. 넘칠 때만 하단 scroll fog 가 보이고, Header 아래에서 스크롤하면 상단 divider 가 나타나요. */function PopoverBody({ className, onScroll, ...props }: PopoverBodyProps) { const ref = React.useRef<HTMLDivElement>(null); const [overflow, setOverflow] = React.useState(false); const [scrolled, setScrolled] = React.useState(false); const measure = React.useCallback(() => { const el = ref.current; if (!el) return; setOverflow(el.scrollHeight > el.clientHeight + 1); setScrolled(el.scrollTop > 0); }, []); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; measure(); const observer = new ResizeObserver(measure); observer.observe(el); for (const child of Array.from(el.children)) observer.observe(child); return () => observer.disconnect(); }, [measure]); return ( <div ref={ref} data-slot="popover-body" data-overflow={overflow || undefined} data-scrolled={scrolled || undefined} tabIndex={overflow ? 0 : undefined} onScroll={(event) => { onScroll?.(event); measure(); }} className={cn( 'box-border flex min-h-0 flex-1 flex-col overflow-y-auto px-x6 text-t4 text-fg-neutral', 'transition-[box-shadow] duration-(--rb-duration-color-transition) ease-easing', 'data-[scrolled]:not-first:shadow-[inset_0_1px_0_0_var(--rb-color-stroke-neutral-muted)]', '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} /> );}export interface PopoverFooterProps extends React.ComponentProps<'div'> {}function PopoverFooter({ className, ...props }: PopoverFooterProps) { return ( <div data-slot="popover-footer" className={cn('flex shrink-0 flex-col px-x6 pt-x4 pb-x6', className)} {...props} /> );}export interface PopoverActionProps extends ActionButtonProps {}/** 누르면 Popover 를 닫는 버튼이에요. `onClick` 에서 `e.preventDefault()` 를 호출하면 닫히지 않아요. */function PopoverAction(props: PopoverActionProps) { return ( <PopoverPrimitive.Close asChild> <ActionButton data-slot="popover-action" {...props} /> </PopoverPrimitive.Close> );}export { PopoverRoot, PopoverTrigger, PopoverAnchor, PopoverContent, PopoverBody, PopoverFooter, PopoverAction, PopoverCloseButton,};