/** * @title Callout * @description 사용자에게 중요한 정보나 팁을 시각적으로 강조하여 전달하는 메시지 컴포넌트예요. */import * as React from 'react';import { Slot } from 'radix-ui';import { cva, type VariantProps } from 'class-variance-authority';import { ChevronRight, X } from 'lucide-react';import { cn } from '@/lib/utils';// SEED Rootage spec: callout.yaml, callout-close-button.yamlconst calloutVariants = cva( [ 'box-border flex w-full min-h-[50px] items-center gap-x3 rounded-r2_5 border-none px-x3_5 py-x3_5 text-start antialiased', // prefix / suffix icon: x4 '[&>svg]:size-x4 [&>svg]:shrink-0', // actionable (button, a) '[&:is(button,a)]:cursor-pointer [&:is(button,a)]:active:scale-[0.98]', '[&:is(button,a)]:transition-[background-color,background-image,scale,outline-color] [&:is(button,a)]:duration-(--rb-duration-color-transition) [&:is(button,a)]:ease-easing', '[&:is(button,a)]:outline-2 [&:is(button,a)]:outline-offset-2 [&:is(button,a)]:outline-transparent [&:is(button,a)]:focus-visible:outline-stroke-focus-ring', ], { variants: { tone: { neutral: 'bg-bg-neutral-weak text-fg-neutral [&:is(button,a)]:active:bg-bg-neutral-weak-pressed', informative: 'bg-bg-informative-weak text-fg-informative-contrast [&:is(button,a)]:active:bg-bg-informative-weak-pressed', positive: 'bg-bg-positive-weak text-fg-positive-contrast [&:is(button,a)]:active:bg-bg-positive-weak-pressed', warning: 'bg-bg-warning-weak text-fg-warning-contrast [&:is(button,a)]:active:bg-bg-warning-weak-pressed', critical: 'bg-bg-critical-weak text-fg-critical-contrast [&:is(button,a)]:active:bg-bg-critical-weak-pressed', // SEED $gradient.glow-magic 대신 magic-weak 배경 + 눌림 오버레이 magic: 'bg-bg-magic-weak text-fg-neutral [&:is(button,a)]:active:bg-linear-to-r [&:is(button,a)]:active:from-bg-transparent-pressed [&:is(button,a)]:active:to-bg-transparent-pressed', }, }, defaultVariants: { tone: 'neutral', }, },);const calloutCloseButtonClassName = cn( // size x10, icon x4 → (x10 - x4) * -0.5 = -x3 '-m-x3 flex size-x10 shrink-0 grow-0 cursor-pointer items-center justify-center rounded-r2 border-none bg-bg-transparent p-0', '[&_svg]:size-x4 [&_svg]:shrink-0', 'transition-[background-color,scale,outline-color] duration-(--rb-duration-color-transition) ease-easing', 'active:scale-[0.95] active:bg-bg-transparent-pressed', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring',);export interface CalloutLinkProps extends React.ComponentProps<'a'> { asChild?: boolean;}function CalloutLink({ className, asChild = false, ...props }: CalloutLinkProps) { const Comp = asChild ? Slot.Root : 'a'; return ( <Comp data-slot="callout-link" className={cn( 'inline-block cursor-pointer rounded-r1 border-none bg-transparent p-0 text-t4 font-regular underline underline-offset-2', 'outline-2 outline-offset-2 outline-transparent transition-[outline-color] focus-visible:outline-stroke-focus-ring', className, )} {...props} /> );}interface CalloutContentProps { title?: React.ReactNode; description: React.ReactNode; linkProps?: CalloutLinkProps;}function CalloutContent({ title, description, linkProps }: CalloutContentProps) { return ( <div data-slot="callout-content" className="mr-auto text-t4"> {title && ( <span data-slot="callout-title" className="shrink-0 text-t4 font-bold after:whitespace-pre after:content-['__']"> {title} </span> )} <span data-slot="callout-description" className="text-t4 font-regular not-last:after:whitespace-pre not-last:after:content-['__']" > {description} </span> {linkProps && <CalloutLink {...linkProps} />} </div> );}type CalloutBaseProps = VariantProps<typeof calloutVariants> & { /** 앞에 표시할 아이콘이에요. Fill 타입 아이콘을 권장해요. */ prefixIcon?: React.ReactNode; /** 성격을 나타내는 짧은 제목이에요. 본문 앞에 인라인으로 붙어요. */ title?: React.ReactNode; /** 본문이에요. */ description: React.ReactNode;};export interface CalloutProps extends Omit<React.ComponentProps<'div'>, 'title' | 'children'>, CalloutBaseProps { /** 본문 뒤에 붙는 링크 텍스트 속성이에요. */ linkProps?: CalloutLinkProps;}/** 정보를 표시하는 Display Callout */function Callout({ className, tone, prefixIcon, title, description, linkProps, ...props }: CalloutProps) { return ( <div data-slot="callout" data-tone={tone ?? 'neutral'} className={cn(calloutVariants({ tone }), className)} {...props} > {prefixIcon} <CalloutContent title={title} description={description} linkProps={linkProps} /> </div> );}export interface ActionableCalloutProps extends Omit<React.ComponentProps<'button'>, 'title' | 'children'>, CalloutBaseProps {}/** 전체가 클릭 영역인 Actionable Callout. 링크 텍스트는 제공하지 않아요. */function ActionableCallout({ className, tone, prefixIcon, title, description, ...props }: ActionableCalloutProps) { return ( <button type="button" data-slot="callout" data-tone={tone ?? 'neutral'} data-interaction="actionable" className={cn(calloutVariants({ tone }), className)} {...props} > {prefixIcon} <CalloutContent title={title} description={description} /> <ChevronRight aria-hidden data-slot="callout-suffix-icon" /> </button> );}export interface DismissibleCalloutProps extends Omit<React.ComponentProps<'div'>, 'title' | 'children'>, CalloutBaseProps { linkProps?: CalloutLinkProps; /** 표시 여부(제어). */ open?: boolean; /** 처음 표시 여부(비제어). */ defaultOpen?: boolean; /** 표시 여부가 바뀔 때 호출돼요. */ onOpenChange?: (open: boolean) => void; /** 닫기 버튼으로 닫을 때 호출돼요. */ onDismiss?: () => void; /** 닫기 버튼의 접근성 라벨이에요. */ closeLabel?: string;}/** 스스로를 닫을 수 있는 Dismissible Callout */function DismissibleCallout({ className, tone, prefixIcon, title, description, linkProps, open: openProp, defaultOpen = true, onOpenChange, onDismiss, closeLabel = '닫기', ...props}: DismissibleCalloutProps) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); const open = openProp ?? uncontrolledOpen; if (!open) return null; return ( <div data-slot="callout" data-tone={tone ?? 'neutral'} data-interaction="dismissible" className={cn(calloutVariants({ tone }), className)} {...props} > {prefixIcon} <CalloutContent title={title} description={description} linkProps={linkProps} /> <button type="button" data-slot="callout-close-button" aria-label={closeLabel} className={calloutCloseButtonClassName} onClick={() => { if (openProp === undefined) setUncontrolledOpen(false); onOpenChange?.(false); onDismiss?.(); }} > <X aria-hidden /> </button> </div> );}export { Callout, ActionableCallout, DismissibleCallout, CalloutLink, calloutVariants };