/** * @title Modal Dialog * @description 페이지 위 레이어에 떠서 사용자의 응답이 필요한 내용을 보여주는 모달 다이얼로그예요. */import * as React from 'react';import { Dialog as DialogPrimitive } from 'radix-ui';import { CircleAlert, TriangleAlert, X } from 'lucide-react';import { cn } from '@/lib/utils';import { IconButton } from '@/components/ui/button';// Atlassian @atlaskit/modal-dialog 17 스펙// - 너비: small 400 · medium 600 · large 800 · x-large 968 (px)// - 30rem 이상: 위아래 60px 여백, 최대 너비 100vw-120px, radius.xlarge, shadow.overlay// - 30rem 미만: 화면을 꽉 채운다// - 등장 250ms cubic-bezier(0.4,0,0,1) 95→100% 확대 / 퇴장 200ms cubic-bezier(0.6,0,0.8,0.6)/* ------------------------------------------------------------------------------------------------- * Types * -----------------------------------------------------------------------------------------------*/export type WidthNames = 'small' | 'medium' | 'large' | 'x-large';export type Appearance = 'danger' | 'warning';export type KeyboardOrMouseEvent = React.MouseEvent<any> | React.KeyboardEvent<any> | KeyboardEvent;export type OnCloseHandler = (event: KeyboardOrMouseEvent) => void;export type OnCloseCompleteHandler = (element: HTMLElement) => void;export type OnOpenCompleteHandler = (node: HTMLElement, isAppearing: boolean) => void;export type OnStackChangeHandler = (stackIndex: number) => void;const widths: Record<WidthNames, number> = { small: 400, medium: 600, large: 800, 'x-large': 968,};function dialogWidth(input?: number | string) { if (!input) return 'auto'; if (typeof input === 'number') return `${input}px`; if (input in widths) return `${widths[input as WidthNames]}px`; return input;}function dialogHeight(input?: number | string) { if (!input) return 'auto'; return typeof input === 'number' ? `${input}px` : input;}/* ------------------------------------------------------------------------------------------------- * Context * -----------------------------------------------------------------------------------------------*/export interface ModalAttributes { /** 모달 제목 요소에 붙일 id. 직접 만든 제목에 `id={titleId}` 로 연결해요. */ titleId: string; /** 모달을 닫는 함수 */ onClose: OnCloseHandler | null; /** ModalDialog 에 `onClose` 가 주어졌는지 */ hasProvidedOnClose: boolean; testId?: string; isFullScreen: boolean;}const ModalContext = React.createContext<ModalAttributes | null>(null);const ScrollContext = React.createContext<boolean>(false);/** 모달 안에서 titleId, onClose 등을 꺼내 쓰는 훅이에요. */function useModal(): ModalAttributes { const context = React.useContext(ModalContext); if (!context) { throw new Error('useModal 은 ModalDialog 안에서만 쓸 수 있어요.'); } return context;}// ModalTransition 이 퇴장 애니메이션 동안 모달을 붙잡아 두기 위한 컨텍스트interface ExitingAttributes { isExiting: boolean; onExitFinish?: () => void;}const ExitingContext = React.createContext<ExitingAttributes>({ isExiting: false });/* ------------------------------------------------------------------------------------------------- * Modal stack — 여러 모달이 겹치면 뒤쪽 모달을 8px 씩 아래로 민다 * -----------------------------------------------------------------------------------------------*/let modalStack: string[] = [];const stackListeners = new Set<() => void>();const emitStack = () => stackListeners.forEach((listener) => listener());const subscribeStack = (listener: () => void) => { stackListeners.add(listener); return () => { stackListeners.delete(listener); };};function useModalStack(id: string, onStackChange?: OnStackChangeHandler) { React.useEffect(() => { modalStack = [id, ...modalStack]; emitStack(); return () => { modalStack = modalStack.filter((item) => item !== id); emitStack(); }; }, [id]); const stackIndex = React.useSyncExternalStore( subscribeStack, () => Math.max(modalStack.indexOf(id), 0), () => 0, ); const onStackChangeRef = React.useRef(onStackChange); onStackChangeRef.current = onStackChange; const previous = React.useRef(stackIndex); React.useEffect(() => { if (previous.current !== stackIndex) { previous.current = stackIndex; onStackChangeRef.current?.(stackIndex); } }, [stackIndex]); return stackIndex;}/* ------------------------------------------------------------------------------------------------- * ModalTransition * -----------------------------------------------------------------------------------------------*/export interface ModalTransitionProps { children?: React.ReactNode;}/** * 모달을 감싸서 열고 닫을 때 부드러운 전환을 줘요. * `{isOpen && <ModalDialog />}` 형태로 쓰면 닫힐 때도 퇴장 애니메이션이 끝날 때까지 모달을 유지해요. */function ModalTransition({ children }: ModalTransitionProps) { const child = React.Children.toArray(children).find(React.isValidElement) ?? null; const hasChild = child !== null; const lastChild = React.useRef<React.ReactNode>(child); const [exiting, setExiting] = React.useState<React.ReactNode>(null); const [prevHasChild, setPrevHasChild] = React.useState(hasChild); // 파생 상태: 자식이 사라지면 마지막 자식을 exiting 으로 붙잡아 둔다 if (prevHasChild !== hasChild) { setPrevHasChild(hasChild); setExiting(hasChild ? null : lastChild.current); } React.useLayoutEffect(() => { if (child) lastChild.current = child; }); const onExitFinish = React.useCallback(() => setExiting(null), []); const exitingValue = React.useMemo(() => ({ isExiting: true, onExitFinish }), [onExitFinish]); const enteringValue = React.useMemo(() => ({ isExiting: false }), []); if (child) { return <ExitingContext.Provider value={enteringValue}>{child}</ExitingContext.Provider>; } if (exiting) { return <ExitingContext.Provider value={exitingValue}>{exiting}</ExitingContext.Provider>; } return null;}/* ------------------------------------------------------------------------------------------------- * ModalDialog * -----------------------------------------------------------------------------------------------*/export interface ModalDialogProps { /** 열릴 때 포커스할 요소의 ref. 없으면 첫 번째 상호작용 요소(보통 닫기 버튼)로 포커스가 가요. */ autoFocus?: React.RefObject<HTMLElement | null>; children?: React.ReactNode; /** 모달 높이. 지정하지 않으면 내용만큼 커지다가 화면을 넘으면 본문이 스크롤돼요. */ height?: number | string; /** 모달 너비. 이름 있는 크기(`small`·`medium`·`large`·`x-large`)를 권장해요. */ width?: number | string | WidthNames; /** 모달이 닫혀야 할 때(Esc, 블랭킷 클릭, 닫기 버튼) 호출돼요. */ onClose?: OnCloseHandler; /** 닫힘 전환이 끝나면 호출돼요 (ModalTransition 안에서). */ onCloseComplete?: OnCloseCompleteHandler; /** 열림 전환이 끝나면 호출돼요. */ onOpenComplete?: OnOpenCompleteHandler; /** 모달 스택에서 위치가 바뀌면 호출돼요. */ onStackChange?: OnStackChangeHandler; /** `true` 면 모달 본문 대신 화면(viewport) 전체가 스크롤돼요. */ shouldScrollInViewport?: boolean; /** 블랭킷을 클릭하면 `onClose` 를 호출해요. */ shouldCloseOnOverlayClick?: boolean; /** Esc 를 누르면 `onClose` 를 호출해요. */ shouldCloseOnEscapePress?: boolean; /** 닫힌 뒤 포커스를 돌려줄지. ref 를 주면 그 요소로 돌려줘요. */ shouldReturnFocus?: boolean | React.RefObject<HTMLElement | null>; /** 블랭킷의 반투명 배경을 없애요. */ isBlanketHidden?: boolean; /** 모달 스택 위치를 직접 지정해요. 보통은 자동으로 계산돼요. */ stackIndex?: number; /** 보조 기술용 이름. ModalTitle 을 쓰지 않을 때만 지정해요. */ label?: string; testId?: string; className?: string;}interface InternalModalDialogProps extends ModalDialogProps { isFullScreen?: boolean;}function InternalModalDialog({ autoFocus, children, height, width = 'medium', onClose, onCloseComplete, onOpenComplete, onStackChange, shouldScrollInViewport = false, shouldCloseOnOverlayClick = true, shouldCloseOnEscapePress = true, shouldReturnFocus = true, isBlanketHidden = false, stackIndex: stackIndexOverride, label, testId, className, isFullScreen = false,}: InternalModalDialogProps) { const id = React.useId(); const titleId = `modal-dialog-title-${id}`; const defaultTestId = testId || 'modal-dialog'; const { isExiting, onExitFinish } = React.useContext(ExitingContext); const calculatedStackIndex = useModalStack(id, onStackChange); const stackIndex = stackIndexOverride || calculatedStackIndex; const isForeground = stackIndex === 0; const handleClose = React.useCallback( (event: KeyboardOrMouseEvent) => { if (isExiting) return; onClose?.(event); }, [isExiting, onClose], ); const context = React.useMemo<ModalAttributes>( () => ({ titleId, onClose: onClose ? handleClose : null, hasProvidedOnClose: Boolean(onClose), testId: defaultTestId, isFullScreen, }), [titleId, onClose, handleClose, defaultTestId, isFullScreen], ); // 퇴장 애니메이션이 없거나(reduced motion) 이벤트가 오지 않을 때를 대비한 안전장치 React.useEffect(() => { if (!isExiting || !onExitFinish) return; const timer = window.setTimeout(onExitFinish, 320); return () => window.clearTimeout(timer); }, [isExiting, onExitFinish]); // 블랭킷 클릭: 블랭킷에서 눌렀다 뗀 경우만 const mouseDownTarget = React.useRef<EventTarget | null>(null); const scrollMode = isFullScreen ? 'full-screen' : shouldScrollInViewport ? 'viewport' : 'body'; return ( <DialogPrimitive.Root open={!isExiting} onOpenChange={() => {}}> <DialogPrimitive.Portal> {/* Blanket (+ viewport 스크롤 컨테이너) */} <DialogPrimitive.Overlay data-slot="modal-dialog-blanket" data-testid={`${defaultTestId}--blanket`} aria-hidden={!isForeground || undefined} onMouseDown={(event) => { mouseDownTarget.current = event.target; }} onClick={(event) => { if ( shouldCloseOnOverlayClick && !isFullScreen && event.target === event.currentTarget && mouseDownTarget.current === event.target ) { handleClose(event); } }} className={cn( 'fixed inset-0 z-[510] overflow-y-auto overscroll-contain bg-ds-blanket', isBlanketHidden && 'bg-transparent', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:duration-250 data-[state=open]:ease-[cubic-bezier(0.4,0,0,1)]', 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-200 data-[state=closed]:ease-[cubic-bezier(0.6,0,0.8,0.6)]', 'motion-reduce:animate-none', )} > {/* Positioner */} <div data-slot="modal-dialog-positioner" data-testid={`${defaultTestId}--positioner`} style={ { '--modal-dialog-translate-y': `calc(${stackIndex}px * var(--ds-space-100))`, } as React.CSSProperties } className={cn( 'z-[510] flex w-full max-w-full flex-col transition-transform duration-350 ease-in-out motion-reduce:transition-none', stackIndex > 0 ? 'translate-y-(--modal-dialog-translate-y)' : 'transform-none', scrollMode === 'body' && 'fixed start-0 top-0 h-full min-[30rem]:pointer-events-none min-[30rem]:absolute min-[30rem]:inset-x-0 min-[30rem]:top-[60px]! min-[30rem]:mx-auto min-[30rem]:max-h-[calc(100vh-119px)] min-[30rem]:max-w-[calc(100vw-120px)]', scrollMode === 'viewport' && 'relative h-auto min-[30rem]:pointer-events-none min-[30rem]:mx-auto min-[30rem]:my-[60px]', scrollMode === 'full-screen' && 'fixed start-0 top-0 h-full', )} > {/* Radix 접근성 검사용 숨김 제목. 실제 이름은 Content 의 aria-labelledby / aria-label 로 연결돼요. */} <DialogPrimitive.Title hidden>{label}</DialogPrimitive.Title> <DialogPrimitive.Content data-slot="modal-dialog" data-testid={defaultTestId} data-modal-stack={stackIndex} aria-label={label} aria-labelledby={label ? undefined : titleId} aria-describedby={undefined} style={ { '--modal-dialog-width': dialogWidth(width), '--modal-dialog-height': dialogHeight(height), } as React.CSSProperties } onOpenAutoFocus={(event) => { if (autoFocus?.current) { event.preventDefault(); autoFocus.current.focus(); } }} onCloseAutoFocus={(event) => { if (shouldReturnFocus === true) return; event.preventDefault(); if (typeof shouldReturnFocus === 'object') shouldReturnFocus.current?.focus(); }} onEscapeKeyDown={(event) => { event.preventDefault(); if (shouldCloseOnEscapePress && isForeground) handleClose(event); }} onPointerDownOutside={(event) => event.preventDefault()} onInteractOutside={(event) => event.preventDefault()} onAnimationEnd={(event) => { if (event.target !== event.currentTarget) return; const node = event.currentTarget; if (node.dataset.state === 'open') { onOpenComplete?.(node, true); } else { onCloseComplete?.(node); onExitFinish?.(); } }} className={cn( // 기본: 30rem 미만에서는 화면을 꽉 채운다 'pointer-events-auto flex h-full min-h-0 w-full max-w-[100vw] max-h-screen shrink grow-0 basis-auto flex-col', 'bg-ds-surface-overlay text-ds-text [--ds-elevation-surface-current:var(--ds-surface-overlay)]', 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ds-border-focused', '[&>form:only-child]:flex [&>form:only-child]:min-h-0 [&>form:only-child]:max-h-[inherit] [&>form:only-child]:flex-col', // 30rem 이상 'min-[30rem]:mx-auto min-[30rem]:w-(--modal-dialog-width) min-[30rem]:max-w-[inherit] min-[30rem]:shadow-ds-overlay', !isFullScreen && 'min-[30rem]:rounded-ds-xlarge', scrollMode === 'viewport' ? 'min-h-screen max-h-none min-[30rem]:min-h-(--modal-dialog-height)' : 'min-[30rem]:h-(--modal-dialog-height) min-[30rem]:max-h-[inherit]', // 모션 (platform-dst-motion-uplift-modal): 대화상자는 크기만 95→100% (250ms) / 100→95% (200ms). // 페이드는 블랭킷을 포함한 바깥 래퍼가 담당하고, 전체 화면 모드에도 같은 모션을 쓴다. 'data-[state=open]:animate-in data-[state=open]:zoom-in-95 data-[state=open]:duration-250 data-[state=open]:ease-[cubic-bezier(0.4,0,0,1)]', 'data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=closed]:duration-200 data-[state=closed]:ease-[cubic-bezier(0.6,0,0.8,0.6)]', 'motion-reduce:animate-none', className, )} > <ModalContext.Provider value={context}> <ScrollContext.Provider value={shouldScrollInViewport}>{children}</ScrollContext.Provider> </ModalContext.Provider> </DialogPrimitive.Content> </div> </DialogPrimitive.Overlay> </DialogPrimitive.Portal> </DialogPrimitive.Root> );}/** * 페이지 위 레이어에 내용을 띄워요. 사용자는 모달을 닫기 전까지 뒤 페이지와 상호작용할 수 없어요. * 열고 닫는 애니메이션이 필요하면 `ModalTransition` 으로 감싸요. */function ModalDialog(props: ModalDialogProps) { return <InternalModalDialog {...props} />;}export type FullScreenModalDialogProps = Omit< ModalDialogProps, 'width' | 'height' | 'shouldScrollInViewport' | 'shouldCloseOnOverlayClick' | 'isBlanketHidden'>;/** 화면 전체를 채우는 모달이에요. 헤더와 닫기 버튼이 항상 보이도록 본문만 스크롤돼요. */function FullScreenModalDialog(props: FullScreenModalDialogProps) { return ( <InternalModalDialog {...props} isFullScreen width="100%" height="100%" shouldScrollInViewport={false} shouldCloseOnOverlayClick={false} /> );}/* ------------------------------------------------------------------------------------------------- * CloseButton * -----------------------------------------------------------------------------------------------*/export interface CloseButtonProps { /** 보조 기술용 이름. 기본값은 "모달 닫기" 예요. */ label?: string; onClick: (event: React.MouseEvent<HTMLElement>) => void; onBlur?: React.FocusEventHandler<HTMLButtonElement>; testId?: string;}/** 직접 만든 헤더에서 쓰는 닫기 버튼이에요. DOM 에서 헤더의 첫 요소가 되도록 배치해요. */function CloseButton({ label, onClick, onBlur, testId }: CloseButtonProps) { return ( <IconButton data-slot="modal-close-button" data-testid={testId && `${testId}--close-button`} appearance="subtle" icon={<X />} label={label || '모달 닫기'} onClick={onClick} onBlur={onBlur} /> );}/* ------------------------------------------------------------------------------------------------- * ModalHeader * -----------------------------------------------------------------------------------------------*/export interface ModalHeaderProps { children?: React.ReactNode; /** 헤더 끝에 닫기 버튼을 보여줘요. ModalDialog 에 `onClose` 가 있어야 보여요. */ hasCloseButton?: boolean; /** 닫기 버튼의 보조 기술용 이름 */ closeButtonLabel?: string; testId?: string; className?: string;}function ModalHeader({ children, hasCloseButton = false, closeButtonLabel, testId, className }: ModalHeaderProps) { const { onClose, hasProvidedOnClose, testId: modalTestId } = useModal(); const shouldShowCloseButton = hasCloseButton && hasProvidedOnClose && onClose; return ( <div data-slot="modal-header" data-testid={testId || (modalTestId && `${modalTestId}--header`)} className={cn( 'relative mb-ds-negative-025 flex items-center justify-between px-ds-300 pt-ds-300 pb-ds-200', className, )} > {shouldShowCloseButton ? ( // 닫기 버튼이 DOM 에서 먼저 오도록 row-reverse <div className="flex w-full flex-row-reverse justify-between gap-ds-200"> <div className="flex justify-end"> <CloseButton label={closeButtonLabel} onClick={onClose} testId={modalTestId} /> </div> <div className="flex min-w-0 items-center justify-start">{children}</div> </div> ) : ( children )} </div> );}/* ------------------------------------------------------------------------------------------------- * ModalTitle * -----------------------------------------------------------------------------------------------*/export interface ModalTitleProps { /** 제목 앞에 경고·위험 아이콘을 붙여요. 주요 버튼의 appearance 도 같이 맞춰요. */ appearance?: Appearance; children?: React.ReactNode; /** `true` 면 제목이 여러 줄로 줄바꿈돼요. `false` 면 한 줄로 말줄임해요. */ isMultiline?: boolean; testId?: string; className?: string;}const titleIconLabel: Record<Appearance, string> = { danger: '위험', warning: '경고' };function ModalTitle({ appearance, children, isMultiline = true, testId, className }: ModalTitleProps) { const { titleId, testId: modalTestId } = useModal(); const resolvedTestId = testId || (modalTestId && `${modalTestId}--title`); const Icon = appearance === 'danger' ? CircleAlert : TriangleAlert; return ( <h1 data-slot="modal-title" data-testid={resolvedTestId} className={cn('m-ds-0 flex min-w-0 gap-ds-100 text-ds-text ds-font-heading-medium', className)} > {appearance && ( // icon spacing="spacious": 24px 박스 안의 16px 아이콘 <span role="img" aria-label={titleIconLabel[appearance]} className={cn( 'inline-flex size-6 shrink-0 grow-0 items-center justify-center [&_svg]:size-4', appearance === 'danger' ? 'text-ds-icon-danger' : 'text-ds-icon-warning', )} > <Icon aria-hidden strokeWidth={2.25} /> </span> )} <span id={titleId} data-testid={resolvedTestId && `${resolvedTestId}-text`} className={cn( 'min-w-0 shrink grow basis-auto break-words ds-font-heading-medium', !isMultiline && 'truncate', )} > {children} </span> </h1> );}/* ------------------------------------------------------------------------------------------------- * ModalBody * -----------------------------------------------------------------------------------------------*/export interface ModalBodyProps { children: React.ReactNode; /** 좌우 패딩(24px)을 넣을지. 기본값은 `true` 예요. */ hasInlinePadding?: boolean; testId?: string; className?: string;}/** 본문이 넘치면 위아래 구분선(keyline)을 보여주는 스크롤 컨테이너 */function ScrollContainer({ testId, children }: { testId?: string; children: React.ReactNode }) { const ref = React.useRef<HTMLDivElement>(null); const [showTopKeyline, setTopKeyline] = React.useState(false); const [showBottomKeyline, setBottomKeyline] = React.useState(false); const [showContentFocus, setContentFocus] = React.useState(false); React.useEffect(() => { const target = ref.current; if (!target) return; let frame = 0; const update = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { const scrollableDistance = target.scrollHeight - target.clientHeight; setContentFocus(target.scrollHeight > target.clientHeight); if (target.previousElementSibling) setTopKeyline(target.scrollTop > 1); if (target.nextElementSibling) setBottomKeyline(target.scrollTop <= scrollableDistance - 1); }); }; update(); target.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null; observer?.observe(target); return () => { cancelAnimationFrame(frame); target.removeEventListener('scroll', update); window.removeEventListener('resize', update); observer?.disconnect(); }; }, []); return ( <div ref={ref} data-slot="modal-scroll-container" data-testid={testId && `${testId}--scrollable`} // 키보드 사용자도 스크롤할 수 있도록 넘칠 때만 포커스 가능 tabIndex={showContentFocus ? 0 : undefined} role={showContentFocus ? 'region' : undefined} aria-label={showContentFocus ? '스크롤 가능한 내용' : undefined} className={cn( 'm-ds-0 flex h-[unset] grow flex-col overflow-x-hidden overflow-y-auto', 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', 'border-solid border-transparent', showTopKeyline && 'border-t-(length:--ds-border-width) border-t-ds-border', showBottomKeyline && 'border-b-(length:--ds-border-width) border-b-ds-border', )} > {children} </div> );}function ModalBody({ children, hasInlinePadding = true, testId, className }: ModalBodyProps) { const { testId: modalTestId } = useModal(); const shouldScrollInViewport = React.useContext(ScrollContext); const resolvedTestId = testId || (modalTestId && `${modalTestId}--body`); const body = ( <div data-slot="modal-body" data-testid={resolvedTestId} className={cn( 'shrink grow basis-auto text-ds-text ds-font-body', !shouldScrollInViewport && 'py-ds-025', hasInlinePadding && 'px-ds-300', className, )} > {children} </div> ); if (shouldScrollInViewport) return body; return <ScrollContainer testId={testId || modalTestId}>{body}</ScrollContainer>;}/* ------------------------------------------------------------------------------------------------- * ModalFooter * -----------------------------------------------------------------------------------------------*/export interface ModalFooterProps { children?: React.ReactNode; testId?: string; className?: string;}function ModalFooter({ children, testId, className }: ModalFooterProps) { const { testId: modalTestId } = useModal(); return ( <div data-slot="modal-footer" data-testid={testId || (modalTestId && `${modalTestId}--footer`)} className={cn( 'relative -mt-ds-025 flex items-center justify-end gap-ds-100 px-ds-300 pt-ds-200 pb-ds-300', className, )} > {children} </div> );}export { ModalDialog, FullScreenModalDialog, ModalTransition, ModalHeader, ModalTitle, ModalBody, ModalFooter, CloseButton, useModal,};export default ModalDialog;