/** * @title Popup * @description 트리거 옆 오버레이에 짧은 내용이나 작은 상호작용을 띄우는 팝업이에요. */import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { cn } from '@/lib/utils';// Atlassian @atlaskit/popup — popper-wrapper(DefaultPopupComponent), use-close-manager, use-focus-manager 스펙type Placement = | 'auto' | 'auto-start' | 'auto-end' | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end';function toRadixPlacement(placement: Placement) { const [main, cross] = placement.split('-') as [string, string | undefined]; const side = (main === 'auto' ? 'bottom' : main) as 'top' | 'bottom' | 'left' | 'right'; const align = (cross ?? 'center') as 'start' | 'center' | 'end'; return { side, align };}type CloseEvent = Event | React.SyntheticEvent | null;export interface TriggerProps { /** 트리거 요소에 꼭 연결해야 하는 ref */ ref: React.Ref<HTMLElement>; 'aria-controls'?: string; 'aria-expanded': boolean; 'aria-haspopup': boolean | 'dialog' | 'menu';}export interface ContentProps { /** 현재 열림 상태 */ isOpen: boolean; /** 팝업을 닫아요. */ onClose?: () => void; /** 내용 크기가 바뀌었을 때 위치를 다시 계산해요. (Radix 가 자동으로 처리해서 호환용) */ update: () => void; /** 열릴 때 포커스를 줄 요소를 지정해요. */ setInitialFocusRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>;}export interface PopupComponentProps extends React.ComponentProps<'div'> { shouldFitContainer?: boolean; shouldRenderToParent?: boolean;}export interface PopupProps { /** 열림 상태 (제어 모드) */ isOpen: boolean; /** 바깥 클릭·Esc 로 닫힐 때 호출돼요. 여기서 isOpen 을 false 로 바꿔요. */ onClose?: (event: CloseEvent) => void; /** 팝업 내용 */ content: (props: ContentProps) => React.ReactNode; /** 트리거. 받은 props 를 반드시 트리거 요소에 펼쳐 넣어요. */ trigger: (props: TriggerProps) => React.ReactNode; /** 위치. auto 는 공간이 넉넉한 쪽을 골라요. */ placement?: Placement; /** [가로축, 세로축] 간격 (기본 [0, 8]) */ offset?: [number, number]; /** 공간이 부족하면 반대쪽으로 뒤집어요. */ shouldFlip?: boolean; /** 포털 대신 트리거 바로 뒤에 렌더링해요. */ shouldRenderToParent?: boolean; /** 트리거 너비에 맞춰요. */ shouldFitContainer?: boolean; /** 열릴 때 첫 포커스 가능 요소에 포커스를 줘요. */ autoFocus?: boolean; /** 닫힐 때 트리거로 포커스를 돌려줘요. */ shouldReturnFocus?: boolean; /** 팝업의 역할 (주로 'dialog' 나 'menu') */ role?: string; /** dialog 역할일 때 접근성 이름 */ label?: string; /** 접근성 이름으로 쓸 제목 요소 id */ titleId?: string; /** 기본 컨테이너 대신 쓸 컴포넌트 */ popupComponent?: React.ComponentType<PopupComponentProps>; zIndex?: number; id?: string; testId?: string; className?: string;}/** * platform-dst-motion-uplift-popup: 배치 방향에서 열림 150ms 슬라이드 8px + 페이드, 닫힘 100ms 슬라이드 4px + 페이드. * ADS 는 popupComponent 를 Motion 으로 감싸므로 커스텀 컨테이너에도 같은 모션을 입힌다. */const popupMotionClass = cn( 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-150 data-[state=open]:ease-[cubic-bezier(0.4,1,0.6,1)]', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-100 data-[state=closed]:ease-[cubic-bezier(0.6,0,0.8,0.6)]', 'data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2', 'data-[side=bottom]:slide-out-to-top-1 data-[side=top]:slide-out-to-bottom-1 data-[side=left]:slide-out-to-right-1 data-[side=right]:slide-out-to-left-1', 'motion-reduce:animate-none',);/** 기본 팝업 컨테이너: surface-overlay · shadow-overlay · radius-large */const popupContainerClass = cn( 'box-border block rounded-ds-large bg-ds-surface-overlay text-ds-text shadow-ds-overlay outline-none', '[--ds-elevation-surface-current:var(--ds-surface-overlay)]', popupMotionClass,);/** 트리거 옆에 떠서 짧은 내용이나 작은 상호작용을 보여줘요. */function Popup({ isOpen, onClose, content, trigger, placement = 'auto', offset = [0, 8], shouldFlip = true, shouldRenderToParent = false, shouldFitContainer = false, autoFocus = true, shouldReturnFocus = true, role, label, titleId, popupComponent, zIndex = 400, id, testId, className,}: PopupProps) { const generatedId = React.useId(); const contentId = id ?? `popup-${generatedId}`; const triggerRef = React.useRef<HTMLElement>(null); // 첫 포커스 대상은 ref 로 들고 있어야 열리는 순간(onOpenAutoFocus)에 최신 값을 읽을 수 있다 const initialFocusRef = React.useRef<HTMLElement | null>(null); const setInitialFocusRef = React.useCallback<React.Dispatch<React.SetStateAction<HTMLElement | null>>>((value) => { initialFocusRef.current = typeof value === 'function' ? value(initialFocusRef.current) : value; }, []); const { side, align } = toRadixPlacement(placement); const close = React.useCallback((event: CloseEvent) => onClose?.(event), [onClose]); // Radix 는 Esc·바깥 클릭 뒤 onOpenChange(false) 를 부른다. 원인 이벤트를 기억해 onClose 에 한 번만 넘긴다. const lastEvent = React.useRef<CloseEvent>(null); const triggerNode = trigger({ ref: triggerRef, 'aria-controls': isOpen ? contentId : undefined, 'aria-expanded': isOpen, 'aria-haspopup': role === 'menu' ? 'menu' : role === 'dialog' ? 'dialog' : true, }); const body = content({ isOpen, onClose: () => close(null), update: () => {}, setInitialFocusRef, }); const Container = popupComponent; const popupContent = ( <PopoverPrimitive.Content asChild id={contentId} side={side} align={align} sideOffset={offset[1]} alignOffset={offset[0]} avoidCollisions={shouldFlip} collisionPadding={8} role={role} aria-label={label} aria-labelledby={titleId} data-testid={testId} style={{ zIndex }} onOpenAutoFocus={(e) => { if (!autoFocus) { e.preventDefault(); return; } if (initialFocusRef.current) { e.preventDefault(); initialFocusRef.current.focus(); } }} onCloseAutoFocus={(e) => { if (!shouldReturnFocus) e.preventDefault(); }} onEscapeKeyDown={(e) => { lastEvent.current = e; }} // 트리거 클릭은 트리거 자신의 onClick 이 처리한다 (토글) onInteractOutside={(e) => { const target = e.target as Node | null; if (target && triggerRef.current?.contains(target)) { e.preventDefault(); return; } lastEvent.current = e; }} > {Container ? ( <Container data-slot="popup" shouldFitContainer={shouldFitContainer} shouldRenderToParent={shouldRenderToParent} className={cn(popupMotionClass, shouldFitContainer && 'w-(--radix-popover-trigger-width)', className)} > {body} </Container> ) : ( <div data-slot="popup" className={cn( popupContainerClass, // 포털로 띄우면 넘치는 내용은 스크롤 !shouldRenderToParent && 'max-h-(--radix-popover-content-available-height) overflow-auto', shouldFitContainer && 'w-(--radix-popover-trigger-width)', className, )} > {body} </div> )} </PopoverPrimitive.Content> ); return ( <PopoverPrimitive.Root open={isOpen} modal={false} onOpenChange={(open) => { if (!open) { close(lastEvent.current); lastEvent.current = null; } }} > <PopoverPrimitive.Anchor asChild>{triggerNode}</PopoverPrimitive.Anchor> {shouldRenderToParent ? popupContent : <PopoverPrimitive.Portal>{popupContent}</PopoverPrimitive.Portal>} </PopoverPrimitive.Root> );}export { Popup };export type { Placement as PopupPlacement };