/** * @title Inline Message * @description 아이콘과 짧은 문구로 상황을 알리고, 누르면 팝업으로 자세한 내용을 보여주는 메시지예요. */import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { CircleCheck, Info, OctagonAlert, TriangleAlert } from 'lucide-react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/inline-message — components/inline-message, message-icon 스펙export type InlineMessageAppearance = 'connectivity' | 'confirmation' | 'info' | 'warning' | 'error';export type InlineMessageSpacing = 'spacious' | 'compact';export type InlineMessagePlacement = | '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';const typesMapping: Record<InlineMessageAppearance, { Icon: React.ElementType; defaultLabel: string; color: string }> = { connectivity: { Icon: TriangleAlert, defaultLabel: '연결 상태 메시지', color: 'text-ds-icon-brand' }, confirmation: { Icon: CircleCheck, defaultLabel: '완료 메시지', color: 'text-ds-icon-success' }, info: { Icon: Info, defaultLabel: '정보 메시지', color: 'text-ds-icon-discovery' }, warning: { Icon: TriangleAlert, defaultLabel: '경고 메시지', color: 'text-ds-icon-warning' }, error: { Icon: OctagonAlert, defaultLabel: '오류 메시지', color: 'text-ds-icon-danger' },};function toRadixPlacement(placement: InlineMessagePlacement): { side: 'top' | 'right' | 'bottom' | 'left'; align: 'start' | 'center' | 'end';} { const [rawSide, rawAlign] = placement.split('-') as [string, string | undefined]; const side = rawSide === 'auto' ? 'bottom' : (rawSide as 'top' | 'right' | 'bottom' | 'left'); const align = rawAlign === 'start' ? 'start' : rawAlign === 'end' ? 'end' : 'center'; return { side, align };}export interface InlineMessageProps { /** 팝업 안에 보여줄 내용 */ children?: React.ReactNode; /** 팝업이 열리는 위치 */ placement?: InlineMessagePlacement; /** 제목 뒤에 이어지는 보조 문구 */ secondaryText?: React.ReactNode; /** 먼저 보이는 굵은 제목 */ title?: React.ReactNode; /** 제목 앞 아이콘 종류 */ appearance?: InlineMessageAppearance; /** 아이콘 영역 크기. spacious 는 24px, compact 는 16px 이에요. */ spacing?: InlineMessageSpacing; /** 제목·보조 문구가 없을 때 꼭 지정하는 아이콘 접근성 이름 */ iconLabel?: string; /** 열림 상태 (제어 모드) */ isOpen?: boolean; /** 열림 상태가 바뀔 때 호출 */ onOpenChange?: (isOpen: boolean) => void; testId?: string; className?: string;}function InlineMessage({ placement = 'bottom-start', secondaryText = '', title = '', appearance = 'connectivity', spacing = 'spacious', children, iconLabel, isOpen: isOpenProp, onOpenChange, testId, className,}: InlineMessageProps) { const [isOpenState, setIsOpenState] = React.useState(false); const isOpen = isOpenProp ?? isOpenState; const setOpen = React.useCallback( (next: boolean) => { if (isOpenProp === undefined) setIsOpenState(next); onOpenChange?.(next); }, [isOpenProp, onOpenChange], ); const titleId = React.useId(); const { side, align } = toRadixPlacement(placement); const { Icon, defaultLabel, color } = typesMapping[appearance] ?? typesMapping.connectivity; return ( <div data-slot="inline-message" data-appearance={appearance} data-testid={testId} className={cn('inline-block max-w-full', className)} > <PopoverPrimitive.Root open={isOpen} onOpenChange={setOpen}> <PopoverPrimitive.Trigger id={titleId} data-testid={testId && `${testId}--button`} data-ds--inline-message--button="" className={cn( 'relative inline-flex h-auto w-auto max-w-full cursor-pointer items-baseline whitespace-nowrap align-baseline', 'rounded-ds-medium bg-ds-background-neutral-subtle p-ds-0 ds-font-body text-ds-text-subtle', 'hover:underline hover:decoration-current hover:decoration-solid', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', )} > <span className="inline-flex min-w-0 items-center gap-ds-050"> <span data-ds--inline-message--icon="" data-slot="inline-message-icon" className={cn( 'flex flex-none items-center justify-center', spacing === 'spacious' ? 'size-6' : 'size-4', color, )} > <Icon className="size-4" role="img" aria-label={iconLabel || defaultLabel} /> </span> {(title || secondaryText) && ( <span className="inline-flex min-w-0 gap-ds-100"> {title ? ( <span data-testid={testId && `${testId}--title`} className="ds-font-body font-ds-medium text-ds-text"> {title} </span> ) : null} {secondaryText ? ( <span data-testid={testId && `${testId}--text`} className="truncate ds-font-body font-ds-medium text-ds-text-subtlest" > {secondaryText} </span> ) : null} </span> )} </span> </PopoverPrimitive.Trigger> <PopoverPrimitive.Portal> <PopoverPrimitive.Content role="dialog" aria-labelledby={titleId} side={side} align={align} sideOffset={8} collisionPadding={8} data-testid={testId && `${testId}--popup`} data-slot="inline-message-content" className={cn( 'z-[400] max-h-[28pc] max-w-[28pc] overflow-auto rounded-ds-large bg-ds-surface-overlay px-ds-300 py-ds-200 shadow-ds-overlay', 'ds-font-body text-ds-text outline-none', // @atlaskit/popup motion uplift: 배치 방향에서 8px 슬라이드 + 페이드 인(150ms), 4px 슬라이드 + 페이드 아웃(100ms) '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', '[&_p]:my-ds-0 [&_p+p]:mt-ds-150', )} > {children} </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> </div> );}export { InlineMessage };