/** * @title Lozenge * @description 이슈의 상태처럼 빠르게 알아봐야 하는 중요한 속성을 짧은 라벨로 강조해요. */import * as React from 'react';import { ChevronDown } from 'lucide-react';import { cn } from '@/lib/utils';import { Badge, type BadgeProps } from '@/components/ui/badge';// Atlassian @atlaskit/lozenge — new/lozenge-base 스펙 (North Star 시각 언어)/** 레거시 appearance (Atlaskit ThemeAppearance) */export type ThemeAppearance = 'default' | 'inprogress' | 'moved' | 'new' | 'removed' | 'success';export type SemanticColor = 'success' | 'warning' | 'danger' | 'information' | 'neutral' | 'discovery';export type AccentColor = | 'accent-red' | 'accent-orange' | 'accent-yellow' | 'accent-lime' | 'accent-green' | 'accent-teal' | 'accent-blue' | 'accent-purple' | 'accent-magenta' | 'accent-gray';export type LozengeColor = SemanticColor | AccentColor;export type LozengeSpacing = 'default' | 'spacious';export type LozengeAppearance = ThemeAppearance | SemanticColor | AccentColor;/** 아이콘 컴포넌트 (lucide-react 아이콘을 그대로 넘겨요) */export type IconProp = React.ComponentType<{ className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;// 레거시 appearance → 새 색 이름 (utils.resolveLozengeColor)const legacyAppearanceMap: Record<string, LozengeColor> = { default: 'neutral', removed: 'danger', inprogress: 'information', new: 'discovery', moved: 'warning',};function resolveLozengeColor(appearance: LozengeAppearance = 'neutral'): LozengeColor { if (appearance.startsWith('accent-')) return appearance as AccentColor; return legacyAppearanceMap[appearance] ?? (appearance as LozengeColor);}type ColorStyle = { /** 배경·글자·테두리 */ base: string; /** 드롭다운 트리거 hover/pressed */ interactive: string; /** 선택된 드롭다운 트리거 배경 (pressed 토큰) */ selected: string; /** 아이콘 색 (text.*, neutral 은 text.subtle) */ icon: string;};const colorStyles: Record<LozengeColor, ColorStyle> = { success: { base: 'border-ds-border-success-subtle bg-ds-background-success-subtler text-ds-text-success-bolder', interactive: 'hover:bg-ds-background-success-subtler-hovered active:bg-ds-background-success-subtler-pressed', selected: 'bg-ds-background-success-subtler-pressed hover:bg-ds-background-success-subtler-pressed', icon: 'text-ds-text-success', }, warning: { base: 'border-ds-border-warning-subtle bg-ds-background-warning-subtler text-ds-text-warning-bolder', interactive: 'hover:bg-ds-background-warning-subtler-hovered active:bg-ds-background-warning-subtler-pressed', selected: 'bg-ds-background-warning-subtler-pressed hover:bg-ds-background-warning-subtler-pressed', icon: 'text-ds-text-warning', }, danger: { base: 'border-ds-border-danger-subtle bg-ds-background-danger-subtler text-ds-text-danger-bolder', interactive: 'hover:bg-ds-background-danger-subtler-hovered active:bg-ds-background-danger-subtler-pressed', selected: 'bg-ds-background-danger-subtler-pressed hover:bg-ds-background-danger-subtler-pressed', icon: 'text-ds-text-danger', }, information: { base: 'border-ds-border-information-subtle bg-ds-background-information-subtler text-ds-text-information-bolder', interactive: 'hover:bg-ds-background-information-subtler-hovered active:bg-ds-background-information-subtler-pressed', selected: 'bg-ds-background-information-subtler-pressed hover:bg-ds-background-information-subtler-pressed', icon: 'text-ds-text-information', }, neutral: { base: 'border-ds-border bg-ds-background-neutral text-ds-text', interactive: 'hover:bg-ds-background-neutral-hovered active:bg-ds-background-neutral-pressed', selected: 'bg-ds-background-neutral-pressed hover:bg-ds-background-neutral-pressed', icon: 'text-ds-text-subtle', }, discovery: { base: 'border-ds-border-discovery-subtle bg-ds-background-discovery-subtler text-ds-text-discovery-bolder', interactive: 'hover:bg-ds-background-discovery-subtler-hovered active:bg-ds-background-discovery-subtler-pressed', selected: 'bg-ds-background-discovery-subtler-pressed hover:bg-ds-background-discovery-subtler-pressed', icon: 'text-ds-text-discovery', }, 'accent-red': { base: 'border-ds-border-accent-red-subtle bg-ds-background-accent-red-subtler text-ds-text-accent-red-bolder', interactive: 'hover:bg-ds-background-accent-red-subtler-hovered active:bg-ds-background-accent-red-subtler-pressed', selected: 'bg-ds-background-accent-red-subtler-pressed hover:bg-ds-background-accent-red-subtler-pressed', icon: 'text-ds-text-accent-red', }, 'accent-orange': { base: 'border-ds-border-accent-orange-subtle bg-ds-background-accent-orange-subtler text-ds-text-accent-orange-bolder', interactive: 'hover:bg-ds-background-accent-orange-subtler-hovered active:bg-ds-background-accent-orange-subtler-pressed', selected: 'bg-ds-background-accent-orange-subtler-pressed hover:bg-ds-background-accent-orange-subtler-pressed', icon: 'text-ds-text-accent-orange', }, 'accent-yellow': { base: 'border-ds-border-accent-yellow-subtle bg-ds-background-accent-yellow-subtler text-ds-text-accent-yellow-bolder', interactive: 'hover:bg-ds-background-accent-yellow-subtler-hovered active:bg-ds-background-accent-yellow-subtler-pressed', selected: 'bg-ds-background-accent-yellow-subtler-pressed hover:bg-ds-background-accent-yellow-subtler-pressed', icon: 'text-ds-text-accent-yellow', }, 'accent-lime': { base: 'border-ds-border-accent-lime-subtle bg-ds-background-accent-lime-subtler text-ds-text-accent-lime-bolder', interactive: 'hover:bg-ds-background-accent-lime-subtler-hovered active:bg-ds-background-accent-lime-subtler-pressed', selected: 'bg-ds-background-accent-lime-subtler-pressed hover:bg-ds-background-accent-lime-subtler-pressed', icon: 'text-ds-text-accent-lime', }, 'accent-green': { base: 'border-ds-border-accent-green-subtle bg-ds-background-accent-green-subtler text-ds-text-accent-green-bolder', interactive: 'hover:bg-ds-background-accent-green-subtler-hovered active:bg-ds-background-accent-green-subtler-pressed', selected: 'bg-ds-background-accent-green-subtler-pressed hover:bg-ds-background-accent-green-subtler-pressed', icon: 'text-ds-text-accent-green', }, 'accent-teal': { base: 'border-ds-border-accent-teal-subtle bg-ds-background-accent-teal-subtler text-ds-text-accent-teal-bolder', interactive: 'hover:bg-ds-background-accent-teal-subtler-hovered active:bg-ds-background-accent-teal-subtler-pressed', selected: 'bg-ds-background-accent-teal-subtler-pressed hover:bg-ds-background-accent-teal-subtler-pressed', icon: 'text-ds-text-accent-teal', }, 'accent-blue': { base: 'border-ds-border-accent-blue-subtle bg-ds-background-accent-blue-subtler text-ds-text-accent-blue-bolder', interactive: 'hover:bg-ds-background-accent-blue-subtler-hovered active:bg-ds-background-accent-blue-subtler-pressed', selected: 'bg-ds-background-accent-blue-subtler-pressed hover:bg-ds-background-accent-blue-subtler-pressed', icon: 'text-ds-text-accent-blue', }, 'accent-purple': { base: 'border-ds-border-accent-purple-subtle bg-ds-background-accent-purple-subtler text-ds-text-accent-purple-bolder', interactive: 'hover:bg-ds-background-accent-purple-subtler-hovered active:bg-ds-background-accent-purple-subtler-pressed', selected: 'bg-ds-background-accent-purple-subtler-pressed hover:bg-ds-background-accent-purple-subtler-pressed', icon: 'text-ds-text-accent-purple', }, 'accent-magenta': { base: 'border-ds-border-accent-magenta-subtle bg-ds-background-accent-magenta-subtler text-ds-text-accent-magenta-bolder', interactive: 'hover:bg-ds-background-accent-magenta-subtler-hovered active:bg-ds-background-accent-magenta-subtler-pressed', selected: 'bg-ds-background-accent-magenta-subtler-pressed hover:bg-ds-background-accent-magenta-subtler-pressed', icon: 'text-ds-text-accent-magenta', }, 'accent-gray': { base: 'border-ds-border bg-ds-background-accent-gray-subtlest text-ds-text-accent-gray-bolder', interactive: 'hover:bg-ds-background-accent-gray-subtlest-hovered active:bg-ds-background-accent-gray-subtlest-pressed', selected: 'bg-ds-background-accent-gray-subtlest-pressed hover:bg-ds-background-accent-gray-subtlest-pressed', icon: 'text-ds-text-subtle', },};// 트레일링 메트릭 배지: 시맨틱 색은 bold 배지, 그 외는 neutralfunction getMetricBadgeAppearance(color: LozengeColor | 'inverse'): NonNullable<BadgeProps['appearance']> { switch (color) { case 'inverse': return 'inverse'; case 'success': return 'successBold'; case 'warning': return 'warningBold'; case 'danger': return 'dangerBold'; case 'information': return 'informationBold'; case 'discovery': return 'discoveryBold'; default: return 'neutral'; }}const containerClass = cn( 'relative box-border inline-flex h-5 shrink-0 items-center overflow-hidden align-middle', 'rounded-ds-small border-(length:--ds-border-width) border-solid px-ds-050 py-ds-025',);const spaciousClass = 'h-auto min-h-8 rounded-ds-medium px-ds-150 py-ds-050';function LoadingSpinner({ className }: { className?: string }) { return ( <svg viewBox="0 0 16 16" className={cn('animate-spin', className)} fill="none" aria-hidden> <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> );}interface LozengeBaseOwnProps { /** 표시할 내용. 한두 단어가 좋아요. */ children?: React.ReactNode; /** 색. 레거시 이름(default, inprogress, moved, new, removed, success)도 받아요. */ appearance?: LozengeAppearance; /** 라벨 앞 아이콘 컴포넌트 */ iconBefore?: IconProp; /** 높이·여백. spacious 는 높이 32px */ spacing?: LozengeSpacing; /** 최대 너비. 기본 200px, 넘으면 말줄임 */ maxWidth?: number | string; /** 라벨 끝에 붙는 숫자 배지 (accent 색에서는 표시하지 않아요) */ trailingMetric?: string; /** 트레일링 메트릭 배지의 색. 지정하지 않으면 lozenge 색을 따라가요. */ trailingMetricAppearance?: ThemeAppearance | SemanticColor | 'inverse'; /** @deprecated 새 시각 언어에서는 모든 lozenge 가 같은 굵기로 보여요. 분류용 라벨은 Tag 를 쓰세요. */ isBold?: boolean; /** @deprecated backgroundColor, color 만 지원해요. */ style?: Pick<React.CSSProperties, 'backgroundColor' | 'color'>; testId?: string; className?: string;}function useLozengeParts({ appearance, maxWidth = 200, spacing = 'default', trailingMetric, trailingMetricAppearance,}: Pick<LozengeBaseOwnProps, 'appearance' | 'maxWidth' | 'spacing' | 'trailingMetric' | 'trailingMetricAppearance'>) { const color = resolveLozengeColor(appearance); const isAccent = color.startsWith('accent-'); const maxWidthValue = typeof maxWidth === 'string' ? maxWidth : `${maxWidth}px`; const maxWidthIsPc = typeof maxWidth === 'string' && maxWidth.endsWith('%'); const hasMetric = trailingMetric != null && trailingMetric !== '' && !isAccent; const metricColor: LozengeColor | 'inverse' = trailingMetricAppearance ? trailingMetricAppearance === 'inverse' ? 'inverse' : resolveLozengeColor(trailingMetricAppearance) : color; return { color, hasMetric, metricColor, spacious: spacing === 'spacious', containerMaxWidth: maxWidthIsPc ? maxWidth : `min(${maxWidthValue}, 100%)`, textMaxWidth: maxWidthIsPc ? '100%' : `calc(${maxWidthValue} - var(--ds-space-100, 8px))`, };}// platform-dst-motion-uplift: 라벨(문자열)이 바뀌면 내용 너비를 200ms(easing.inout.bold) 로 이어 붙여요.// platform-dst-motion-lozenge-truncation-fix: 최종 상태에서 말줄임이 필요할 때만 애니메이션 중에도 말줄임표를 보여줘요.const RESIZE_DURATION_MS = 200;const RESIZE_EASING = 'cubic-bezier(0.4, 0, 0, 1)';function useLozengeResizing(childrenKey: string | undefined) { const contentRef = React.useRef<HTMLSpanElement>(null); const textRef = React.useRef<HTMLSpanElement>(null); const lastWidth = React.useRef<number | null>(null); const isInitialRender = React.useRef(true); const [resizing, setResizing] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); React.useLayoutEffect(() => { const el = contentRef.current; if (!el) return; if (isInitialRender.current) { isInitialRender.current = false; lastWidth.current = el.getBoundingClientRect().width; return; } const from = lastWidth.current; // 새 내용의 자연 너비 el.style.transition = ''; el.style.width = ''; const to = el.getBoundingClientRect().width; lastWidth.current = to; const reduced = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; if (from === null || from === to || reduced) return; const text = textRef.current; setIsTruncated(text ? text.scrollWidth > text.clientWidth : false); setResizing(true); el.style.width = `${from}px`; void el.offsetWidth; // reflow el.style.transition = `width ${RESIZE_DURATION_MS}ms ${RESIZE_EASING}`; el.style.width = `${to}px`; const finish = () => { el.style.transition = ''; el.style.width = ''; setResizing(false); }; const timer = window.setTimeout(finish, RESIZE_DURATION_MS); return () => window.clearTimeout(timer); }, [childrenKey]); return { contentRef, textRef, resizing, isTruncated };}function LozengeContent({ children, iconBefore: Icon, trailingMetric, testId, style, parts, isLoading, hasChevron,}: Pick<LozengeBaseOwnProps, 'children' | 'iconBefore' | 'trailingMetric' | 'testId' | 'style'> & { parts: ReturnType<typeof useLozengeParts>; isLoading?: boolean; hasChevron?: boolean;}) { const { color, spacious, hasMetric, metricColor, textMaxWidth } = parts; const { contentRef, textRef, resizing, isTruncated } = useLozengeResizing( typeof children === 'string' ? children : undefined, ); return ( <span ref={contentRef} data-slot="lozenge-content" data-testid={testId ? `${testId}--content` : undefined} className={cn( 'inline-flex max-w-full items-center gap-ds-050', spacious && 'gap-ds-075', hasChevron && 'transition-opacity duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', isLoading && 'opacity-0', )} > {Icon && ( <Icon aria-hidden className={cn( 'shrink-0 transition-[color] duration-200 ease-[cubic-bezier(0.4,0,0,1)]', spacious ? 'size-4' : 'size-3', colorStyles[color].icon, )} /> )} <span ref={textRef} data-slot="lozenge-text" data-testid={testId && `${testId}--text`} style={{ maxWidth: textMaxWidth, color: style?.color }} className={cn( 'overflow-hidden whitespace-nowrap ds-font-body-small', resizing && !isTruncated ? 'text-clip' : 'text-ellipsis', spacious && 'ds-font-body font-ds-medium', )} > {children} </span> {hasMetric && ( <span data-lozenge-metric-wrapper="" data-testid={testId && `${testId}--metric`} className="flex"> <Badge appearance={getMetricBadgeAppearance(metricColor)} max={false} // 중립 메트릭은 lozenge 배경 위에서 한 단계 진한 배경을 써요 className={cn(metricColor === 'neutral' && 'bg-ds-background-neutral-hovered')} > {trailingMetric} </Badge> </span> )} {hasChevron && <ChevronDown aria-hidden className="size-3 shrink-0" />} </span> );}export interface LozengeProps extends LozengeBaseOwnProps, Omit<React.ComponentProps<'span'>, keyof LozengeBaseOwnProps> {}/** * 상태 표시용 정적 라벨. 상호작용이 필요하면 LozengeDropdownTrigger 를 써요. */function Lozenge({ children, appearance = 'neutral', iconBefore, spacing = 'default', maxWidth = 200, trailingMetric, trailingMetricAppearance, // eslint-disable-next-line @typescript-eslint/no-unused-vars isBold, style, testId, className, ...props}: LozengeProps) { const parts = useLozengeParts({ appearance, maxWidth, spacing, trailingMetric, trailingMetricAppearance }); return ( <span data-slot="lozenge" data-appearance={parts.color} data-testid={testId} style={{ backgroundColor: style?.backgroundColor, maxWidth: parts.containerMaxWidth }} className={cn( containerClass, colorStyles[parts.color].base, parts.spacious && spaciousClass, !parts.spacious && parts.hasMetric && 'pe-px', className, )} {...props} > <LozengeContent iconBefore={iconBefore} trailingMetric={trailingMetric} testId={testId} style={style} parts={parts} > {children} </LozengeContent> </span> );}export interface LozengeDropdownTriggerProps extends LozengeBaseOwnProps, Omit<React.ComponentProps<'button'>, keyof LozengeBaseOwnProps | 'onClick'> { /** 드롭다운이 열려 있거나 선택된 상태 */ isSelected?: boolean; /** 로딩 중에는 스피너를 보여주고 클릭을 막아요. */ isLoading?: boolean; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;}/** * 상태 전환 메뉴 등을 여는 lozenge 모양 버튼. 끝에 아래 화살표가 붙어요. */function LozengeDropdownTrigger({ children, appearance = 'neutral', iconBefore, spacing = 'default', maxWidth = 200, trailingMetric, trailingMetricAppearance, // eslint-disable-next-line @typescript-eslint/no-unused-vars isBold, style, testId, className, isSelected = false, isLoading = false, onClick, 'aria-label': ariaLabel, ...props}: LozengeDropdownTriggerProps) { const parts = useLozengeParts({ appearance, maxWidth, spacing, trailingMetric, trailingMetricAppearance }); const colors = colorStyles[parts.color]; return ( <button type="button" data-slot="lozenge-dropdown-trigger" data-appearance={parts.color} data-selected={isSelected || undefined} data-testid={testId} aria-busy={isLoading || undefined} aria-disabled={isLoading || undefined} aria-label={isLoading ? '불러오는 중' : ariaLabel} onClick={isLoading ? undefined : onClick} style={{ backgroundColor: style?.backgroundColor, maxWidth: parts.containerMaxWidth }} className={cn( containerClass, 'cursor-pointer outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', // 상호작용 색 전환은 button.hovered(150ms), 로딩 중에는 200ms easing.inout.bold isLoading ? 'transition-[background-color,border-color] duration-200 ease-[cubic-bezier(0.4,0,0,1)]' : 'transition-[background-color,border-color] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', colors.base, !isLoading && colors.interactive, // 선택(열림) 상태는 pressed 배경을 유지해요 isSelected && colors.selected, isLoading && 'cursor-progress', parts.spacious && spaciousClass, className, )} {...props} > <LozengeContent iconBefore={iconBefore} trailingMetric={trailingMetric} testId={testId} style={style} parts={parts} isLoading={isLoading} hasChevron > {children} </LozengeContent> {isLoading && ( <span data-testid={testId ? `${testId}--loading-overlay` : undefined} className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden animate-in fade-in-0 duration-150" > <LoadingSpinner className={parts.spacious ? 'size-4' : 'size-3'} /> </span> )} </button> );}export { Lozenge, LozengeDropdownTrigger, resolveLozengeColor };