/** * @title Empty State * @description 보여줄 데이터가 없을 때 이유와 다음 행동을 알려주는 빈 상태 화면이에요. */import * as React from 'react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/empty-state — empty-state.js, styled/*.js 스펙export interface RenderImageProps { maxImageWidth?: number; maxImageHeight?: number; imageWidth?: number; imageHeight?: number;}export type EmptyStateWidth = 'narrow' | 'wide';export interface EmptyStateProps extends Omit<React.ComponentProps<'div'>, 'children'> { /** 현재 상태를 짧게 설명하는 제목 (필수) */ header: string; /** 제목 요소의 heading 레벨 (1~6). 기본 4 */ headingLevel?: number; /** 제목 크기. 팝업처럼 좁은 곳에서는 `xsmall` */ headingSize?: 'xsmall' | 'medium'; /** 제목 아래 보조 설명 */ description?: React.ReactNode; /** 가로 폭. wide 464px, narrow 304px */ width?: EmptyStateWidth; /** 제목 위에 보여줄 이미지 URL */ imageUrl?: string; /** 이미지 최대 너비(px). 기본 160 */ maxImageWidth?: number; /** 이미지 최대 높이(px). 기본 160 */ maxImageHeight?: number; /** 이미지 고정 너비(px). 로딩 중 레이아웃 흔들림을 막을 때 */ imageWidth?: number; /** 이미지 고정 높이(px) */ imageHeight?: number; /** imageUrl 대신 이미지(SVG 일러스트 등)를 직접 그리는 render prop */ renderImage?: (props: RenderImageProps) => React.ReactNode; /** 주요 액션 (보통 primary Button) */ primaryAction?: React.ReactNode; /** 보조 액션 */ secondaryAction?: React.ReactNode; /** 외부 문서 링크 같은 3차 액션. 버튼 묶음 아래에 놓여요. */ tertiaryAction?: React.ReactNode; /** 액션 옆에 스피너를 보여줘요. */ isLoading?: boolean; /** 버튼 묶음의 접근성 이름. 기본 "Button group" */ buttonGroupLabel?: string; testId?: string;}function Spinner() { return ( <svg viewBox="0 0 16 16" className="size-6 animate-spin text-ds-icon-subtle" fill="none" role="img" aria-label="로딩 중"> <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" /> <path d="M14.5 8A6.5 6.5 0 0 0 8 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}function EmptyState({ buttonGroupLabel, description, header, headingLevel = 4, headingSize = 'medium', imageHeight, imageUrl, imageWidth, isLoading, maxImageHeight = 160, maxImageWidth = 160, primaryAction, renderImage, secondaryAction, width = 'wide', tertiaryAction, testId, className, ...props}: EmptyStateProps) { const level = headingLevel > 0 && headingLevel < 7 ? headingLevel : headingLevel > 6 ? 6 : 4; const Heading = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; const actions = primaryAction || secondaryAction || isLoading ? ( // 스피너 자리(24px + 16px)만큼 왼쪽 패딩을 줘서 버튼이 가운데 정렬되게 한다 <div data-slot="empty-state-actions" className="mb-ds-100 flex items-center justify-center ps-ds-500"> {primaryAction && secondaryAction ? ( <div role="group" aria-label={buttonGroupLabel || 'Button group'} data-testid={testId && `${testId}-button-group`} className="inline-flex gap-ds-100" > {secondaryAction} {primaryAction} </div> ) : ( primaryAction || secondaryAction )} <div className="ms-ds-200 flex w-ds-300 items-center">{isLoading && <Spinner />}</div> </div> ) : null; return ( <div data-slot="empty-state" data-width={width} data-testid={testId} className={cn( 'mx-auto my-ds-600 text-center', width === 'narrow' ? 'max-w-[304px]' : 'max-w-[464px]', className, )} {...props} > {imageUrl ? ( <img src={imageUrl} alt="" role="presentation" width={imageWidth ?? 'auto'} height={imageHeight ?? 'auto'} style={{ maxWidth: maxImageWidth, maxHeight: maxImageHeight }} className="mx-auto mt-0 mb-ds-300 block" /> ) : ( renderImage?.({ maxImageWidth, maxImageHeight, imageWidth, imageHeight }) )} <div className="pb-ds-200"> <Heading data-slot="empty-state-header" className={cn('text-ds-text', headingSize === 'xsmall' ? 'ds-font-heading-xsmall' : 'ds-font-heading-medium')} > {header} </Heading> </div> {description && ( <div className="pb-ds-300"> <p data-slot="empty-state-description" className="ds-font-body text-ds-text"> {description} </p> </div> )} {actions} {tertiaryAction} </div> );}/* ------------------------------------------------------------------------------------------------- * EmptyStateIllustration — imageUrl 대신 쓰는 선화 스타일 SVG 일러스트 (renderImage 와 함께) * -----------------------------------------------------------------------------------------------*/export type EmptyStateIllustrationVariant = 'search' | 'inbox' | 'board' | 'done' | 'error';export interface EmptyStateIllustrationProps extends Omit<React.ComponentProps<'svg'>, 'width' | 'height'> { variant?: EmptyStateIllustrationVariant; /** 최대 너비·높이(px). EmptyState renderImage 의 maxImageWidth 를 넘겨주면 돼요. */ size?: number;}const line = 'fill-none stroke-ds-border-bold';const strokeProps = { strokeWidth: 2.5, strokeLinecap: 'round', strokeLinejoin: 'round' } as const;function IllustrationArt({ variant }: { variant: EmptyStateIllustrationVariant }) { switch (variant) { case 'search': return ( <> <circle cx="80" cy="132" r="56" className="fill-ds-background-accent-blue-subtlest" /> <rect x="34" y="40" width="72" height="88" rx="8" className="fill-ds-surface stroke-ds-border-bold" {...strokeProps} /> <path d="M50 62h40M50 78h40M50 94h24" className={line} {...strokeProps} /> <circle cx="104" cy="104" r="24" className="fill-ds-background-accent-blue-subtler stroke-ds-border-bold" {...strokeProps} /> <path d="M121 121l20 20" className={line} strokeWidth={6} strokeLinecap="round" /> <path d="M96 98l16 12M112 98l-16 12" className="fill-none stroke-ds-icon-accent-blue" {...strokeProps} /> </> ); case 'inbox': return ( <> <circle cx="80" cy="132" r="56" className="fill-ds-background-accent-teal-subtlest" /> <path d="M28 92l20-48h64l20 48v40a8 8 0 0 1-8 8H36a8 8 0 0 1-8-8z" className="fill-ds-surface stroke-ds-border-bold" {...strokeProps} /> <path d="M28 92h32l8 16h24l8-16h32" className="fill-ds-background-accent-teal-subtler stroke-ds-border-bold" {...strokeProps} /> <path d="M80 20v20M60 26l6 14M100 26l-6 14" className="fill-none stroke-ds-icon-success" {...strokeProps} /> </> ); case 'board': return ( <> <circle cx="80" cy="132" r="56" className="fill-ds-background-accent-purple-subtlest" /> <rect x="20" y="36" width="120" height="96" rx="8" className="fill-ds-surface stroke-ds-border-bold" {...strokeProps} /> <path d="M60 36v96M100 36v96" className={line} {...strokeProps} /> <rect x="28" y="48" width="24" height="16" rx="3" className="fill-ds-background-accent-purple-subtler stroke-ds-border-bold" strokeWidth={2} /> <rect x="68" y="48" width="24" height="16" rx="3" className="fill-ds-background-accent-blue-subtler stroke-ds-border-bold" strokeWidth={2} /> <path d="M108 56h24" className="fill-none stroke-ds-border" strokeWidth={2.5} strokeDasharray="4 5" strokeLinecap="round" /> <path d="M120 100v24M108 112h24" className="fill-none stroke-ds-icon-discovery" {...strokeProps} /> </> ); case 'done': return ( <> <circle cx="80" cy="132" r="56" className="fill-ds-background-accent-green-subtlest" /> <rect x="36" y="32" width="88" height="104" rx="8" className="fill-ds-surface stroke-ds-border-bold" {...strokeProps} /> <rect x="60" y="24" width="40" height="16" rx="4" className="fill-ds-background-accent-green-subtler stroke-ds-border-bold" {...strokeProps} /> <path d="M52 66l6 6 10-12M52 94l6 6 10-12" className="fill-none stroke-ds-icon-success" {...strokeProps} /> <path d="M78 68h30M78 96h30" className={line} {...strokeProps} /> <path d="M132 28l4 8 8 4-8 4-4 8-4-8-8-4 8-4z" className="fill-ds-background-accent-yellow-subtler stroke-ds-border-bold" strokeWidth={2} strokeLinejoin="round" /> </> ); case 'error': return ( <> <circle cx="80" cy="132" r="56" className="fill-ds-background-accent-orange-subtlest" /> <path d="M80 28l60 104H20z" className="fill-ds-surface stroke-ds-border-bold" {...strokeProps} /> <path d="M80 64v36" className="fill-none stroke-ds-icon-warning" strokeWidth={6} strokeLinecap="round" /> <circle cx="80" cy="116" r="4" className="fill-ds-icon-warning" /> </> ); }}/** 빈 상태용 간단한 선화 일러스트. 장식용이라 스크린 리더에서 숨겨요. */function EmptyStateIllustration({ variant = 'search', size = 160, className, ...props }: EmptyStateIllustrationProps) { return ( <svg data-slot="empty-state-illustration" viewBox="0 0 160 160" width={size} height={size} aria-hidden focusable="false" className={cn('mx-auto mt-0 mb-ds-300 block max-w-full', className)} {...props} > <IllustrationArt variant={variant} /> </svg> );}export { EmptyState, EmptyStateIllustration };