/** * @title Page Banner * @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: page-banner.yaml, page-banner-close-button.yamlconst pageBannerVariants = cva( [ 'box-border flex w-full min-h-x10 items-start border-none px-x4 py-x2_5 text-start antialiased', // prefix icon: x4, 오른쪽 x2, 위쪽 (minHeight - size) / 2 - paddingY = 2px '[&_[data-slot=page-banner-prefix-icon]]:mr-x2 [&_[data-slot=page-banner-prefix-icon]]:mt-x0_5 [&_[data-slot=page-banner-prefix-icon]]:size-x4 [&_[data-slot=page-banner-prefix-icon]]:shrink-0', // suffix icon: x4, 왼쪽 x2, 세로 중앙 '[&_[data-slot=page-banner-suffix-icon]]:ml-x2 [&_[data-slot=page-banner-suffix-icon]]:size-x4 [&_[data-slot=page-banner-suffix-icon]]:shrink-0 [&_[data-slot=page-banner-suffix-icon]]:self-center', // actionable (button): content scale + inside focus ring '[&:is(button)]:cursor-pointer', '[&:is(button)]:transition-[background-color,outline-color] [&:is(button)]:duration-(--rb-duration-color-transition) [&:is(button)]:ease-easing', '[&:is(button)]:outline-2 [&:is(button)]:-outline-offset-2 [&:is(button)]:outline-transparent [&:is(button)]:focus-visible:outline-stroke-focus-ring', ], { variants: { variant: { weak: '', solid: '', }, tone: { neutral: '', informative: '', positive: '', warning: '', critical: '', /** variant=solid 와 함께 쓰지 않아요. */ magic: '', }, }, compoundVariants: [ { tone: 'neutral', variant: 'weak', className: 'bg-bg-neutral-weak text-fg-neutral [&:is(button)]:active:bg-bg-neutral-weak-pressed', }, { tone: 'neutral', variant: 'solid', className: 'bg-bg-neutral-solid text-fg-on-neutral-solid [&:is(button)]:active:bg-bg-neutral-solid-pressed', }, { tone: 'informative', variant: 'weak', className: 'bg-bg-informative-weak text-fg-informative-contrast [&:is(button)]:active:bg-bg-informative-weak-pressed', }, { tone: 'informative', variant: 'solid', className: 'bg-bg-informative-solid text-fg-on-informative-solid [&:is(button)]:active:bg-bg-informative-solid-pressed', }, { tone: 'positive', variant: 'weak', className: 'bg-bg-positive-weak text-fg-positive-contrast [&:is(button)]:active:bg-bg-positive-weak-pressed', }, { tone: 'positive', variant: 'solid', className: 'bg-bg-positive-solid text-fg-on-positive-solid [&:is(button)]:active:bg-bg-positive-solid-pressed', }, { tone: 'warning', variant: 'weak', className: 'bg-bg-warning-weak text-fg-warning-contrast [&:is(button)]:active:bg-bg-warning-weak-pressed', }, { tone: 'warning', variant: 'solid', className: 'bg-bg-warning-solid text-fg-on-warning-solid [&:is(button)]:active:bg-bg-warning-solid-pressed', }, { tone: 'critical', variant: 'weak', className: 'bg-bg-critical-weak text-fg-critical-contrast [&:is(button)]:active:bg-bg-critical-weak-pressed', }, { tone: 'critical', variant: 'solid', className: 'bg-bg-critical-solid text-fg-on-critical-solid [&:is(button)]:active:bg-bg-critical-solid-pressed', }, { // SEED $gradient.glow-magic 대신 magic-weak 배경 + 눌림 오버레이 tone: 'magic', className: 'bg-bg-magic-weak text-fg-neutral [&:is(button)]:active:bg-linear-to-r [&:is(button)]:active:from-bg-transparent-pressed [&:is(button)]:active:to-bg-transparent-pressed', }, ], defaultVariants: { variant: 'weak', tone: 'neutral', }, },);const pageBannerCloseButtonClassName = cn( // size x10, icon x4 → (x10 - x4) * -0.5 = -x3, 왼쪽은 -x3 + x2 = -x1 '-m-x3 -ml-x1 flex size-x10 shrink-0 grow-0 cursor-pointer items-center justify-center self-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 PageBannerButtonProps extends React.ComponentProps<'button'> { asChild?: boolean;}/** Page Banner 오른쪽에 놓는 텍스트 버튼. `PageBanner` 의 `suffix` 로 전달해요. */function PageBannerButton({ className, asChild = false, ...props }: PageBannerButtonProps) { const Comp = asChild ? Slot.Root : 'button'; return ( <Comp data-slot="page-banner-button" type={asChild ? undefined : 'button'} className={cn( 'flex shrink-0 cursor-pointer items-center rounded-r1 border-none bg-transparent text-t3 font-bold no-underline', // target height x10: (x10 - lineHeight t3) / 2 만큼 패딩을 주고 같은 양을 음수 마진으로 상쇄 'm-[calc((var(--rb-line-height-t3)-var(--rb-dimension-x10))/2)] p-[calc((var(--rb-dimension-x10)-var(--rb-line-height-t3))/2)]', 'transition-[scale,outline-color] duration-(--rb-duration-pressed-scale) ease-(--rb-timing-function-pressed-scale) active:scale-[0.95]', 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', className, )} {...props} /> );}interface PageBannerBodyProps { title?: React.ReactNode; description: React.ReactNode; suffix?: React.ReactNode;}function PageBannerContent({ title, description, suffix }: PageBannerBodyProps) { return ( <div data-slot="page-banner-content" className="flex grow flex-wrap items-center justify-between gap-x1_5" > <div data-slot="page-banner-body" className="grow text-t4"> {title && ( <span data-slot="page-banner-title" className="shrink-0 text-t4 font-bold after:whitespace-pre after:content-['__']" > {title} </span> )} <span data-slot="page-banner-description" className="text-t4 font-medium"> {description} </span> </div> {suffix} </div> );}function PrefixIcon({ children }: { children?: React.ReactNode }) { if (!children) return null; return ( <span data-slot="page-banner-prefix-icon" className="flex [&_svg]:size-full" aria-hidden> {children} </span> );}type PageBannerBaseProps = VariantProps<typeof pageBannerVariants> & { /** 앞에 표시할 아이콘이에요. Fill 타입 아이콘을 권장해요. */ prefixIcon?: React.ReactNode; /** 상태를 나타내는 짧은 제목이에요. 본문 앞에 인라인으로 붙어요. */ title?: React.ReactNode; /** 본문이에요. */ description: React.ReactNode;};export interface PageBannerProps extends Omit<React.ComponentProps<'div'>, 'title' | 'children'>, PageBannerBaseProps { /** 오른쪽에 놓을 요소예요. 보통 `PageBannerButton` 을 전달해요. */ suffix?: React.ReactNode;}/** 기본 Page Banner */function PageBanner({ className, variant, tone, prefixIcon, title, description, suffix, ...props }: PageBannerProps) { return ( <div data-slot="page-banner" data-tone={tone ?? 'neutral'} data-variant={variant ?? 'weak'} className={cn(pageBannerVariants({ variant, tone }), className)} {...props} > <PrefixIcon>{prefixIcon}</PrefixIcon> <PageBannerContent title={title} description={description} suffix={suffix} /> </div> );}export interface ActionablePageBannerProps extends Omit<React.ComponentProps<'button'>, 'title' | 'children'>, PageBannerBaseProps {}/** 전체가 클릭 영역인 Actionable Page Banner. 누르면 배경은 그대로 두고 내용만 줄어들어요. */function ActionablePageBanner({ className, variant, tone, prefixIcon, title, description, ...props}: ActionablePageBannerProps) { return ( <button type="button" data-slot="page-banner" data-tone={tone ?? 'neutral'} data-variant={variant ?? 'weak'} data-interaction="actionable" className={cn(pageBannerVariants({ variant, tone }), 'group', className)} {...props} > {/* SEED scaleScope: content — 배경은 고정, 내용만 축소 */} <span data-slot="page-banner-content-scale" className="flex min-w-0 grow items-start transition-[scale] duration-(--rb-duration-pressed-scale) ease-(--rb-timing-function-pressed-scale) group-active:scale-[0.98]" > <PrefixIcon>{prefixIcon}</PrefixIcon> <PageBannerContent title={title} description={description} /> <ChevronRight aria-hidden data-slot="page-banner-suffix-icon" /> </span> </button> );}export interface DismissiblePageBannerProps extends Omit<React.ComponentProps<'div'>, 'title' | 'children'>, PageBannerBaseProps { /** 표시 여부(제어). */ open?: boolean; /** 처음 표시 여부(비제어). */ defaultOpen?: boolean; /** 표시 여부가 바뀔 때 호출돼요. */ onOpenChange?: (open: boolean) => void; /** 닫기 버튼으로 닫을 때 호출돼요. */ onDismiss?: () => void; /** 닫기 버튼의 접근성 라벨이에요. */ closeLabel?: string;}/** 닫기 버튼이 있는 Dismissible Page Banner */function DismissiblePageBanner({ className, variant, tone, prefixIcon, title, description, open: openProp, defaultOpen = true, onOpenChange, onDismiss, closeLabel = '닫기', ...props}: DismissiblePageBannerProps) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen); const open = openProp ?? uncontrolledOpen; if (!open) return null; return ( <div data-slot="page-banner" data-tone={tone ?? 'neutral'} data-variant={variant ?? 'weak'} data-interaction="dismissible" className={cn(pageBannerVariants({ variant, tone }), className)} {...props} > <PrefixIcon>{prefixIcon}</PrefixIcon> <PageBannerContent title={title} description={description} /> <button type="button" data-slot="page-banner-close-button" aria-label={closeLabel} className={pageBannerCloseButtonClassName} onClick={() => { if (openProp === undefined) setUncontrolledOpen(false); onOpenChange?.(false); onDismiss?.(); }} > <X aria-hidden /> </button> </div> );}export { PageBanner, PageBannerButton, ActionablePageBanner, DismissiblePageBanner, pageBannerVariants };