/** * @title Avatar * @description 사람이나 프로젝트·스페이스 같은 대상을 이미지로 보여주는 아바타예요. */import * as React from 'react';import { Bot, Rocket, User } from 'lucide-react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/avatar 스펙 (avatar.tsx / avatar-content.tsx / internal/* 해독 값)export type AvatarAppearance = 'circle' | 'square' | 'hexagon';export type AvatarSize = 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge';export type AvatarPresenceType = 'online' | 'busy' | 'focus' | 'offline';export type AvatarStatusType = 'approved' | 'declined' | 'locked' | 'warning';type IndicatorSize = 'small' | 'medium' | 'large' | 'xlarge';export const AVATAR_SIZES: Record<AvatarSize, number> = { xxsmall: 16, xsmall: 16, small: 24, medium: 32, large: 40, xlarge: 96, xxlarge: 128,};export const AVATAR_RADIUS: Record<AvatarSize, number> = { xxsmall: 2, xsmall: 2, small: 2, medium: 3, large: 3, xlarge: 6, xxlarge: 12,};export const BORDER_WIDTH = 2;const HEXAGON_CLIP_PATH = 'polygon(45% 1.33975%,46.5798% .60307%,48.26352% .15192%,50% 0,51.73648% .15192%,53.4202% .60307%,55% 1.33975%,89.64102% 21.33975%,91.06889% 22.33956%,92.30146% 23.57212%,93.30127% 25%,94.03794% 26.5798%,94.48909% 28.26352%,94.64102% 30%,94.64102% 70%,94.48909% 71.73648%,94.03794% 73.4202%,93.30127% 75%,92.30146% 76.42788%,91.06889% 77.66044%,89.64102% 78.66025%,55% 98.66025%,53.4202% 99.39693%,51.73648% 99.84808%,50% 100%,48.26352% 99.84808%,46.5798% 99.39693%,45% 98.66025%,10.35898% 78.66025%,8.93111% 77.66044%,7.69854% 76.42788%,6.69873% 75%,5.96206% 73.4202%,5.51091% 71.73648%,5.35898% 70%,5.35898% 30%,5.51091% 28.26352%,5.96206% 26.5798%,6.69873% 25%,7.69854% 23.57212%,8.93111% 22.33956%,10.35898% 21.33975%)';// width/height (box-sizing: content-box)const sizeClass: Record<AvatarSize, string> = { xxsmall: 'size-4', xsmall: 'size-4', small: 'size-6', medium: 'size-8', large: 'size-10', xlarge: 'size-24', xxlarge: 'size-32',};// 이미지·더보기 표시의 모서리 (square 전용 크기별 값)export const avatarRadiusClass: Record<AvatarSize, string> = { xxsmall: 'rounded-ds-xsmall', xsmall: 'rounded-ds-xsmall', small: 'rounded-ds-xsmall', medium: 'rounded-ds-small', large: 'rounded-ds-small', xlarge: 'rounded-ds-medium', xxlarge: 'rounded-ds-xlarge',};// 상호작용 아바타 hover 모션 (motion.avatar.hovered: 250ms 스프링 linear() 곡선, scale 1.12)const avatarHoverMotionClass = 'transition-[transform] duration-(--ds-motion-avatar-hovered-duration) ease-(--ds-motion-avatar-hovered-curve) will-change-transform hover:[transform:scale(1.12)]';/* ------------------------------------------------------------------------------------------------- * Context — AvatarGroup 이 하위 아바타에 size 를 내려줄 때 사용 * -----------------------------------------------------------------------------------------------*/export const AvatarContext = React.createContext<{ size: AvatarSize } | undefined>(undefined);export const useAvatarContext = () => React.useContext(AvatarContext);/* ------------------------------------------------------------------------------------------------- * Presence / Status 아이콘 (8×8 SVG 원본 경로) * -----------------------------------------------------------------------------------------------*/function IconWrapper({ bgColor, children }: { bgColor?: string; children?: React.ReactNode }) { return ( <span role="presentation" style={bgColor ? { borderColor: bgColor, backgroundColor: bgColor } : undefined} className="box-border flex size-full content-center items-center overflow-hidden rounded-ds-full border-(length:--ds-border-width-selected) border-solid border-ds-surface-overlay bg-ds-surface-overlay" > {children} </span> );}const presenceGlyphs: Record<AvatarPresenceType, React.ReactNode> = { online: <circle className="fill-ds-icon-success" cx="4" cy="4" r="4" />, busy: ( <> <circle className="fill-ds-icon-danger" cx="4" cy="4" r="4" /> <path className="fill-ds-surface-overlay" d="M3.3,1.9l2.8,2.8c0.2,0.2,0.2,0.5,0,0.7L5.4,6.1c-0.2,0.2-0.5,0.2-0.7,0L1.9,3.3c-0.2-0.2-0.2-0.5,0-0.7l0.7-0.7C2.8,1.7,3.1,1.7,3.3,1.9z" /> </> ), focus: ( <path className="fill-ds-icon-discovery" d="M4,8 C1.790861,8 0,6.209139 0,4 C0,1.790861 1.790861,0 4,0 C6.209139,0 8,1.790861 8,4 C8,6.209139 6.209139,8 4,8 Z M4,6.66666667 C5.47275933,6.66666667 6.66666667,5.47275933 6.66666667,4 C6.66666667,2.52724067 5.47275933,1.33333333 4,1.33333333 C2.52724067,1.33333333 1.33333333,2.52724067 1.33333333,4 C1.33333333,5.47275933 2.52724067,6.66666667 4,6.66666667 Z M4,5.33333333 C3.26362033,5.33333333 2.66666667,4.73637967 2.66666667,4 C2.66666667,3.26362033 3.26362033,2.66666667 4,2.66666667 C4.73637967,2.66666667 5.33333333,3.26362033 5.33333333,4 C5.33333333,4.73637967 4.73637967,5.33333333 4,5.33333333 Z" /> ), offline: ( <> <path className="fill-ds-text-subtlest" d="M4,8 C6.209139,8 8,6.209139 8,4 C8,1.790861 6.209139,0 4,0 C1.790861,0 0,1.790861 0,4 C0,6.209139 1.790861,8 4,8 Z M4,6 C5.1045695,6 6,5.1045695 6,4 C6,2.8954305 5.1045695,2 4,2 C2.8954305,2 2,2.8954305 2,4 C2,5.1045695 2.8954305,6 4,6 Z" /> <path className="fill-ds-surface-overlay" d="M4,6 C5.1045695,6 6,5.1045695 6,4 C6,2.8954305 5.1045695,2 4,2 C2.8954305,2 2,2.8954305 2,4 C2,5.1045695 2.8954305,6 4,6 Z" /> </> ),};const statusGlyphs: Record<AvatarStatusType, React.ReactNode> = { approved: ( <> <circle className="fill-ds-icon-success" cx="4" cy="4" r="4" /> <path className="fill-ds-surface-overlay" d="M2.47140452,3.52859548 C2.21105499,3.26824595 1.78894501,3.26824595 1.52859548,3.52859548 C1.26824595,3.78894501 1.26824595,4.21105499 1.52859548,4.47140452 L2.86192881,5.80473785 C3.12227834,6.06508738 3.54438833,6.06508738 3.80473785,5.80473785 L6.47140452,3.13807119 C6.73175405,2.87772166 6.73175405,2.45561167 6.47140452,2.19526215 C6.21105499,1.93491262 5.78894501,1.93491262 5.52859548,2.19526215 L3.33333333,4.39052429 L2.47140452,3.52859548 Z" /> </> ), declined: ( <> <circle className="fill-ds-icon-danger" cx="4" cy="4" r="4" /> <path className="fill-ds-surface-overlay" d="M4.890661,4.0088336 L5.81806461,3.07802178 C6.06167933,2.83351177 6.06048933,2.43826992 5.81540668,2.19522442 C5.57032402,1.95217891 5.17415651,1.95336612 4.93054179,2.19787613 L4.00765946,3.12415007 L3.06906871,2.18377143 C2.82523777,1.93947602 2.42906937,1.93863765 2.18420182,2.18189887 C1.93933427,2.42516008 1.93849394,2.82040282 2.18232488,3.06469822 L3.12544091,4.00961077 L2.20275024,4.93569234 C1.95913552,5.18020236 1.96032551,5.5754442 2.20540817,5.81848971 C2.45049083,6.06153521 2.84665833,6.060348 3.09027306,5.81583799 L4.00844245,4.89429431 L4.9092123,5.79678001 C5.15304324,6.04107541 5.54921164,6.04191379 5.79407919,5.79865257 C6.03894674,5.55539135 6.03978708,5.16014862 5.79595614,4.91585321 L4.890661,4.0088336 Z" /> </> ), locked: ( <> <circle className="fill-ds-text-subtlest" cx="4" cy="4" r="4" /> <path className="fill-ds-surface-overlay" d="M4.13074827,1.21766493 L4.10368158,1.21766493 C3.36340745,1.21766493 2.76388015,1.80793503 2.76388015,2.5367787 L2.76388015,3.21632216 L3.44054754,3.21632216 L3.44054754,2.54344089 C3.44054754,2.17901906 3.74031119,1.88388401 4.11044825,1.88388401 L4.1239816,1.88388401 C4.49411866,1.88388401 4.79388232,2.17901906 4.79388232,2.54344089 L4.79388232,3.21632216 L5.47054971,3.21632216 L5.47054971,2.5367787 C5.47054971,1.80793503 4.8710224,1.21766493 4.13074827,1.21766493 M2.76388015,3.21632216 L3.44054754,3.21632216 L3.44054754,3.88254123 L2.76388015,3.88254123 L2.76388015,3.21632216 Z M4.79388232,3.21632216 L5.47054971,3.21632216 L5.47054971,3.88254123 L4.79388232,3.88254123 L4.79388232,3.21632216 Z M4.79401765,3.88254123 L3.44068287,3.88254123 L2.76401548,3.88254123 C2.39049508,3.88254123 2.08734809,4.18100738 2.08734809,4.54876031 L2.08734809,5.54808892 C2.08734809,6.10000287 2.53735205,6.54741753 3.09094491,6.54741753 L5.14375561,6.54741753 C5.69802683,6.54741753 6.14735243,6.10385072 6.14735243,5.54808892 L6.14735243,4.54876031 C6.14735243,4.18100738 5.84420544,3.88254123 5.47068504,3.88254123 L4.79401765,3.88254123 Z" /> </> ), warning: ( <> <circle className="fill-ds-icon-warning" cx="4" cy="4" r="4" /> <path className="fill-ds-surface-overlay" d="M4,1.33333333 C4.36918944,1.33333333 4.66666667,1.63081056 4.66666667,2 L4.66666667,4 C4.66666667,4.36918944 4.36918944,4.66666667 4,4.66666667 C3.63081056,4.66666667 3.33333333,4.36918944 3.33333333,4 L3.33333333,2 C3.33333333,1.63081056 3.63081056,1.33333333 4,1.33333333 Z M4,5.33333333 C4.36918944,5.33333333 4.66666667,5.63081056 4.66666667,6 C4.66666667,6.36918944 4.36918944,6.66666667 4,6.66666667 C3.63081056,6.66666667 3.33333333,6.36918944 3.33333333,6 C3.33333333,5.63081056 3.63081056,5.33333333 4,5.33333333 Z" /> </> ),};export interface PresenceProps { presence?: AvatarPresenceType; /** 아이콘 테두리·배경 색. 기본은 elevation.surface.overlay */ borderColor?: string; /** presence 대신 직접 그리는 내용 */ children?: React.ReactNode;}/** 아바타의 접속 상태(온라인·바쁨·집중·오프라인)를 보여줘요. */function Presence({ presence, borderColor, children }: PresenceProps) { return ( <IconWrapper bgColor={borderColor}> {presence ? ( <svg data-slot="avatar-presence" height="100%" width="100%" viewBox="0 0 8 8" xmlns="http://www.w3.org/2000/svg"> {presenceGlyphs[presence]} </svg> ) : ( children )} </IconWrapper> );}export interface StatusProps { status?: AvatarStatusType; borderColor?: string; children?: React.ReactNode;}/** 승인·거절·잠김 같은 맥락 정보를 아바타 오른쪽 위에 보여줘요. */function Status({ status, borderColor, children }: StatusProps) { return ( <IconWrapper bgColor={borderColor}> {status ? ( <svg data-slot="avatar-status" height="100%" width="100%" viewBox="0 0 8 8" xmlns="http://www.w3.org/2000/svg"> {statusGlyphs[status]} </svg> ) : ( children )} </IconWrapper> );}const indicatorSizeClass: Record<IndicatorSize, string> = { small: 'size-3', medium: 'size-[14px]', large: 'size-[15px]', xlarge: 'size-[18px]',};const presenceOffset: Record<AvatarAppearance, Record<IndicatorSize, string>> = { circle: { small: 'bottom-0 right-0', medium: 'bottom-0 right-0', large: 'bottom-px right-px', xlarge: 'bottom-[7px] right-[7px]' }, square: { small: '-bottom-1 -right-1', medium: '-bottom-1 -right-1', large: '-bottom-1 -right-1', xlarge: '-bottom-1 -right-1' }, hexagon: { small: '-bottom-px -right-px', medium: '-bottom-px -right-px', large: 'bottom-1 -right-1', xlarge: 'bottom-[17px] -right-[5px]' },};const statusOffset: Record<AvatarAppearance, Record<IndicatorSize, string>> = { circle: { small: 'end-0 top-0', medium: 'end-0 top-0', large: 'end-px top-px', xlarge: 'end-[7px] top-[7px]' }, square: { small: 'end-0 top-0', medium: 'end-0 top-0', large: 'end-0 top-0', xlarge: 'end-0 top-0' }, hexagon: { small: '-end-px -top-px', medium: '-end-px -top-px', large: '-end-1 top-1', xlarge: '-end-[5px] top-[17px]' },};/* ------------------------------------------------------------------------------------------------- * 기본 이미지 (src 가 없거나 불러오기 실패) * -----------------------------------------------------------------------------------------------*/function AvatarImage({ src, alt = '', appearance, size, imgLoading, testId,}: { src?: string; alt?: string; appearance: AvatarAppearance; size: AvatarSize; imgLoading?: 'lazy' | 'eager'; testId?: string;}) { const [hasImageErrored, setHasImageErrored] = React.useState(false); // src 가 바뀌면 오류 상태 초기화 React.useEffect(() => { setHasImageErrored(false); }, [src]); if (!src || hasImageErrored) { const Icon = appearance === 'circle' ? User : appearance === 'hexagon' ? Bot : Rocket; return ( <span data-slot="avatar-fallback" className="flex size-full items-center justify-center bg-ds-background-accent-gray-subtler text-ds-icon-subtle" > <Icon aria-label={alt || undefined} aria-hidden={alt ? undefined : true} role={alt ? 'img' : undefined} className="size-[62.5%]" strokeWidth={2} data-testid={testId && `${testId}--${appearance === 'circle' ? 'person' : appearance === 'hexagon' ? 'agent' : 'ship'}`} /> </span> ); } return ( <img data-slot="avatar-image" loading={imgLoading} src={src} alt={alt} aria-hidden={!alt ? true : undefined} data-testid={testId && `${testId}--image`} onError={() => setHasImageErrored(true)} className={cn( 'flex size-full flex-[1_1_100%] object-cover', avatarRadiusClass[size], appearance === 'circle' && 'rounded-ds-full', appearance === 'hexagon' && 'rounded-none', )} style={appearance === 'hexagon' ? { clipPath: HEXAGON_CLIP_PATH } : undefined} /> );}/* ------------------------------------------------------------------------------------------------- * Avatar * -----------------------------------------------------------------------------------------------*/export type AvatarClickEventHandler = (event: React.MouseEvent<HTMLElement>, analyticsEvent?: undefined) => void;export interface AvatarProps { /** circle 은 사람, square 는 프로젝트·저장소·스페이스, hexagon 은 AI 에이전트 */ appearance?: AvatarAppearance; size?: AvatarSize; /** 대체 텍스트이자 스크린 리더가 읽는 이름. 비우면 장식용으로 취급해요. */ name?: string; /** 상호작용 아바타의 aria-label. 없으면 name(+상태)을 써요. */ label?: string; src?: string; /** 오른쪽 아래 접속 상태. status 가 있으면 표시되지 않아요. */ presence?: AvatarPresenceType | React.ReactElement | null; /** 오른쪽 위 상태 아이콘. presence 보다 우선해요. */ status?: AvatarStatusType | React.ReactElement | null; /** 아바타 둘레 2px 테두리 색. 기본은 elevation.surface */ borderColor?: string; href?: string; target?: '_blank' | '_self' | '_top' | '_parent'; onClick?: AvatarClickEventHandler; isDisabled?: boolean; /** 겹쳐 쌓을 때의 z-index (AvatarGroup 이 넣어줘요) */ stackIndex?: number; tabIndex?: number; imgLoading?: 'lazy' | 'eager'; /** 바깥 컨테이너 요소. 기본 div */ as?: React.ElementType; /** 이미지 대신 직접 그릴 내용 */ children?: React.ReactNode; testId?: string; className?: string; 'aria-controls'?: string; 'aria-expanded'?: boolean; 'aria-haspopup'?: boolean | 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' | 'true' | 'false';}const Avatar = React.forwardRef<HTMLElement, AvatarProps>(function Avatar( { appearance = 'circle', size: sizeProp, name, label, src, presence, status, borderColor, href, target, onClick, isDisabled, stackIndex, tabIndex, imgLoading, as: Container = 'div', children, testId, className, 'aria-controls': ariaControls, 'aria-expanded': ariaExpanded, 'aria-haspopup': ariaHasPopup, }, ref,) { const context = useAvatarContext(); const size: AvatarSize = sizeProp || context?.size || 'medium'; const labelId = React.useId(); // presence/status 는 small~xlarge 에서만 표시 const isValidIconSize = size !== 'xxlarge' && size !== 'xxsmall' && size !== 'xsmall'; const indicatorSize: IndicatorSize = isValidIconSize ? (size as IndicatorSize) : 'small'; const customPresenceNode = React.isValidElement(presence) ? presence : null; const customStatusNode = React.isValidElement(status) ? status : null; const isPresence = isValidIconSize && !!presence && !status; const isStatus = isValidIconSize && !!status; const defaultLabel = [ name, isStatus && !customStatusNode && `(${status})`, isPresence && !customPresenceNode && `(${presence})`, ] .filter(Boolean) .join(' '); const isInteractive = Boolean(onClick || href || isDisabled || ariaHasPopup); const containerShouldBeImage = !isInteractive && Boolean(defaultLabel); const Inner = (href && !isDisabled ? 'a' : onClick || isDisabled || ariaHasPopup ? 'button' : 'span') as React.ElementType; const handleClick = (event: React.MouseEvent<HTMLElement>) => { if (isDisabled || typeof onClick !== 'function') return; onClick(event, undefined); }; const ringColor = borderColor ?? 'var(--ds-surface)'; const isHexagon = appearance === 'hexagon'; const content = ( <Inner ref={ref} data-slot="avatar-content" data-testid={testId ? `${testId}--inner` : undefined} aria-label={isInteractive ? label || defaultLabel : undefined} aria-controls={ariaControls} aria-expanded={ariaExpanded} aria-haspopup={ariaHasPopup} onClick={isInteractive ? handleClick : undefined} tabIndex={tabIndex} disabled={Inner === 'button' ? isDisabled : undefined} type={Inner === 'button' ? 'button' : undefined} {...(Inner === 'a' ? { href, target, rel: target === '_blank' ? 'noopener noreferrer' : undefined } : {})} style={{ '--avatar-border-color': ringColor } as React.CSSProperties} className={cn( 'm-ds-025 box-content flex flex-col items-stretch justify-center overflow-hidden border-none p-ds-0 [transform:translateZ(0)]', 'cursor-[inherit] bg-(--avatar-border-color) shadow-[0_0_0_2px_var(--avatar-border-color)]', 'transition-[transform,opacity] duration-200', // 상호작용 오버레이 "after:pointer-events-none after:absolute after:inset-0 after:size-full after:bg-transparent after:opacity-0 after:transition-opacity after:duration-200 after:content-['']", 'outline-0 outline-offset-2 outline-transparent focus-visible:shadow-none focus-visible:outline-2 focus-visible:outline-ds-border-focused', sizeClass[size], appearance === 'circle' && 'rounded-ds-full after:rounded-ds-full', appearance === 'square' && 'rounded-ds-tile', isHexagon && 'rounded-none shadow-none [clip-path:inherit] after:rounded-none focus-visible:outline-0', stackIndex !== undefined && 'relative', isInteractive && !isDisabled && 'cursor-pointer hover:after:bg-ds-interaction-hovered hover:after:opacity-100 active:after:bg-ds-interaction-pressed active:after:opacity-100', // platform-dst-motion-uplift: 상호작용 아바타는 hover 때 스프링 곡선으로 1.12배 커져요 (육각형은 바깥 레이어가 커짐) isInteractive && !isDisabled && !isHexagon && avatarHoverMotionClass, isDisabled && 'cursor-not-allowed after:bg-ds-surface after:opacity-(--ds-opacity-disabled)', )} > {children || ( <AvatarImage src={src} alt={!containerShouldBeImage && src ? name : undefined} appearance={appearance} size={size} imgLoading={imgLoading} testId={testId} /> )} </Inner> ); return ( <Container data-slot="avatar" data-appearance={appearance} data-size={size} data-testid={testId} role={containerShouldBeImage ? 'img' : undefined} aria-labelledby={containerShouldBeImage ? labelId : undefined} style={{ zIndex: stackIndex }} className={cn('relative inline-block align-middle outline-0', className)} > {isHexagon ? ( // 육각형은 테두리·포커스 링을 겹친 레이어의 배경색으로 그린다 <div data-slot="avatar-hexagon-focus" style={{ clipPath: HEXAGON_CLIP_PATH, '--avatar-border-color': ringColor } as React.CSSProperties} className={cn( '-mx-(--ds-border-width-selected) -my-[calc(var(--ds-border-width-selected)*1.25)] bg-transparent px-(--ds-border-width-selected) py-[calc(var(--ds-border-width-selected)*1.25)] has-[:focus-visible]:bg-ds-border-focused', isInteractive && !isDisabled && avatarHoverMotionClass, )} > <div className="-mx-[calc(var(--ds-border-width-selected)*.4)] -my-[calc(var(--ds-border-width-selected)*.5)] bg-(--avatar-border-color) px-[calc(var(--ds-border-width-selected)*.4)] py-[calc(var(--ds-border-width-selected)*.5)] [clip-path:inherit] has-[:focus-visible]:bg-ds-surface"> {content} </div> </div> ) : ( content )} {isPresence && ( <span aria-hidden="true" data-testid={testId && `${testId}--presence`} className={cn('pointer-events-none absolute', indicatorSizeClass[indicatorSize], presenceOffset[appearance][indicatorSize])} > <Presence presence={customPresenceNode ? undefined : (presence as AvatarPresenceType)}>{customPresenceNode}</Presence> </span> )} {isStatus && ( <span aria-hidden="true" data-testid={testId && `${testId}--status`} className={cn('absolute', indicatorSizeClass[indicatorSize], statusOffset[appearance][indicatorSize])} > <Status borderColor={borderColor} status={customStatusNode ? undefined : (status as AvatarStatusType)}> {customStatusNode} </Status> </span> )} {containerShouldBeImage && ( <span id={labelId} data-testid={testId && `${testId}--label`} hidden> {defaultLabel} </span> )} </Container> );});/* ------------------------------------------------------------------------------------------------- * AvatarItem — 아바타 옆에 이름·보조 텍스트를 붙인 행 * -----------------------------------------------------------------------------------------------*/export interface AvatarItemProps { avatar: React.ReactNode; primaryText?: React.ReactNode; secondaryText?: React.ReactNode; /** 행 배경색. 기본 transparent */ backgroundColor?: string; href?: string; target?: '_blank' | '_self' | '_top' | '_parent'; onClick?: AvatarClickEventHandler; isDisabled?: boolean; /** true 면 긴 텍스트를 말줄임하지 않아요. */ isTruncationDisabled?: boolean; label?: string; testId?: string; className?: string;}const AvatarItem = React.forwardRef<HTMLElement, AvatarItemProps>(function AvatarItem( { avatar, primaryText, secondaryText, backgroundColor = 'transparent', href, target, onClick, isDisabled, isTruncationDisabled, label, testId, className, }, ref,) { const Comp = (href && !isDisabled ? 'a' : onClick || isDisabled ? 'button' : 'span') as React.ElementType; const isInteractive = Boolean(onClick || href); return ( <Comp ref={ref} data-slot="avatar-item" data-testid={testId ? `${testId}--itemInner` : undefined} aria-label={isInteractive ? label : undefined} onClick={onClick} disabled={Comp === 'button' ? isDisabled : undefined} type={Comp === 'button' ? 'button' : undefined} {...(Comp === 'a' ? { href, target, rel: target === '_blank' ? 'noopener noreferrer' : undefined } : {})} style={{ '--avatar-item-bg-color': backgroundColor } as React.CSSProperties} className={cn( 'm-ds-0 box-border bg-(--avatar-item-bg-color) flex w-full items-center rounded-ds-small border-(length:--ds-border-width-selected) border-solid border-transparent p-ds-050', 'text-left font-ds-regular leading-none text-inherit no-underline outline-none', isInteractive && 'cursor-pointer hover:bg-ds-background-neutral-subtle-hovered focus-visible:border-ds-border-focused active:bg-ds-background-neutral-subtle-pressed', isDisabled && 'pointer-events-none cursor-not-allowed opacity-(--ds-opacity-disabled)', className, )} > {avatar} <div className="min-w-0 max-w-full flex-[1_1_100%] ps-ds-100"> <span className={cn('block ds-font-body text-ds-text', !isTruncationDisabled && 'truncate')}>{primaryText}</span> {secondaryText && ( <span className={cn('block ds-font-body-small text-ds-text-subtlest', !isTruncationDisabled && 'truncate')}> {secondaryText} </span> )} </div> </Comp> );});/* ------------------------------------------------------------------------------------------------- * Skeleton — 로딩 자리 표시 * -----------------------------------------------------------------------------------------------*/export interface SkeletonProps { appearance?: AvatarAppearance; size?: AvatarSize; /** 배경색. 기본 currentColor */ color?: string; /** strong 이면 불투명도 30%, 기본 15% */ weight?: 'normal' | 'strong'; className?: string;}function Skeleton({ appearance = 'circle', size = 'medium', color, weight, className }: SkeletonProps) { return ( <div data-slot="avatar-skeleton" style={{ backgroundColor: color ?? 'currentColor', clipPath: appearance === 'hexagon' ? HEXAGON_CLIP_PATH : undefined }} className={cn( 'inline-block rounded-ds-full border-(length:--ds-border-width-selected) border-solid border-transparent opacity-15', sizeClass[size], appearance === 'square' && 'rounded-ds-tile', appearance === 'hexagon' && 'rounded-none', weight === 'strong' && 'opacity-30', className, )} /> );}export { Avatar, AvatarItem, Presence, Status, Skeleton, Presence as AvatarPresence, Status as AvatarStatus, Skeleton as AvatarSkeleton };export default Avatar;