/** * @title Progress Tracker * @description 여러 화면에 걸친 작업의 단계와 현재 위치를 보여줘요. */import * as React from 'react';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';// Atlassian @atlaskit/progress-tracker — progress-tracker / internal/{stage,marker,bar,link} 스펙export type ProgressTrackerSpacing = 'comfortable' | 'cozy' | 'compact';export type ProgressTrackerStatus = 'unvisited' | 'visited' | 'current' | 'disabled';export interface Stage { id: string; label: string; /** 이 단계에서 다음 단계까지 연결선이 채워진 비율 (0~100) */ percentageComplete: number; status: ProgressTrackerStatus; /** visited 단계라도 링크를 만들지 않아요. */ noLink?: boolean; href?: string; onClick?: () => void;}export type Stages = Stage[];export interface ProgressTrackerStageRenderProp { link: (props: { item: Stage }) => React.ReactElement;}const spacingOptions: Record<ProgressTrackerSpacing, string> = { comfortable: 'var(--ds-space-500, 40px)', cozy: 'var(--ds-space-200, 16px)', compact: 'var(--ds-space-050, 4px)',};const keyframes = `@keyframes steel-progress-tracker-fade{0%{opacity:.01}to{opacity:1}}`;const markerVariants = cva( [ 'absolute start-1/2 size-2 rounded-(--ds-space-100)', 'translate-x-[-50%] translate-y-[calc(var(--ds-space-250,20px)*-1)]', 'transition-[background-color] duration-(--ds--pt--ts) ease-(--ds--pt--te)', ], { variants: { status: { unvisited: 'bg-ds-background-neutral-bold', current: 'bg-ds-background-brand-bold', visited: 'bg-ds-background-brand-bold', disabled: 'bg-ds-background-disabled', }, }, },);const titleVariants = cva('mt-ds-250 text-center ds-font-body', { variants: { status: { unvisited: 'font-ds-regular text-ds-text-subtlest', current: 'font-ds-bold text-ds-text-brand', visited: 'font-ds-bold text-ds-text', disabled: 'font-ds-bold text-ds-text-disabled', }, },});function ProgressTrackerLink({ href, onClick, label, testId }: Stage & { testId?: string }) { return ( <a data-slot="progress-tracker-link" href={href || ''} onClick={onClick} data-testid={testId} className={cn( 'block rounded-ds-small text-ds-text no-underline [font:inherit]', 'hover:text-ds-link hover:underline active:text-ds-link-pressed', 'outline-2 outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', )} > {label} </a> );}interface ProgressTrackerStageProps { item: Stage; render: ProgressTrackerStageRenderProp; testId?: string; transitionDelay: number; transitionSpeed: number; transitionEasing: string;}function ProgressTrackerStage({ item, render, testId, transitionDelay, transitionSpeed, transitionEasing,}: ProgressTrackerStageProps) { const [transitioning, setTransitioning] = React.useState(false); const isFirstRender = React.useRef(true); // 상태나 진행률이 바뀌면 전환 애니메이션을 다시 재생한다. React.useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; } setTransitioning(true); const id = setTimeout(() => setTransitioning(false), transitionDelay + transitionSpeed); return () => clearTimeout(id); }, [item.status, item.percentageComplete, transitionDelay, transitionSpeed]); const showLink = item.status === 'visited' && !item.noLink; return ( <li data-slot="progress-tracker-stage" data-status={item.status} data-testid={testId} aria-current={item.status === 'current' ? 'step' : 'false'} style={ { '--ds--pt--ts': `${transitionSpeed}ms`, '--ds--pt--td': `${transitionDelay}ms`, '--ds--pt--te': transitionEasing, } as React.CSSProperties } className="m-0 list-none wrap-break-word" > <div className="relative w-full"> <div data-slot="progress-tracker-marker" data-testid={testId && `${testId}-marker`} className={markerVariants({ status: item.percentageComplete > 0 ? 'visited' : item.status })} /> <div className="opacity-100" style={{ animation: 'steel-progress-tracker-fade var(--ds--pt--ts) var(--ds--pt--te) forwards', animationDelay: 'var(--ds--pt--td)', animationPlayState: transitioning ? 'running' : 'paused', animationDuration: ['visited', 'disabled'].includes(item.status) ? '0ms' : undefined, }} > {/* 다음 단계 마커까지 이어지는 연결선 */} <div data-slot="progress-tracker-bar" data-testid={testId && `${testId}-bar`} style={{ width: `calc(${item.percentageComplete}% + ${item.percentageComplete / 100} * calc(var(--ds--pt--sp, var(--ds-space-200, 16px)) + var(--ds-space-050, 4px)))`, }} className={cn( 'absolute start-1/2 h-2 rounded-e-ds-full bg-ds-background-brand-bold', 'translate-y-[calc(var(--ds-space-250,20px)*-1)]', 'transition-[width] delay-(--ds--pt--td) duration-(--ds--pt--ts) ease-(--ds--pt--te)', )} /> </div> <div data-testid={testId && `${testId}-title`} className={titleVariants({ status: item.status })}> {showLink ? render.link({ item }) : item.label} </div> </div> </li> );}export interface ProgressTrackerProps { /** 순서대로 나열한 단계 데이터 */ items: Stages; /** 단계 사이 간격 */ spacing?: ProgressTrackerSpacing; /** @deprecated 링크 렌더링을 바꾸는 render prop */ render?: ProgressTrackerStageRenderProp; /** false 면 전환 애니메이션을 꺼요. */ animated?: boolean; testId?: string; /** 전체 트래커의 aria-label (예: "이슈 가져오기 진행 단계") */ label?: string; className?: string;}const defaultRender: ProgressTrackerStageRenderProp = { link: ({ item }) => <ProgressTrackerLink {...item} />,};function ProgressTracker({ items = [], spacing = 'cozy', render = defaultRender, animated = true, testId, label = 'Progress', className,}: ProgressTrackerProps) { const prevItems = React.useRef<Stages>(items.map((stage) => ({ ...stage, percentageComplete: 0 }))); const previousStages = items.map((stage) => prevItems.current.find((st) => st.id === stage.id) ?? stage); React.useEffect(() => { prevItems.current = items; }, [items]); const progressChanges = items.filter( (stage, i) => stage.percentageComplete !== previousStages[i].percentageComplete, ).length; const totalStepsForward = items.filter( (stage, i) => stage.percentageComplete > previousStages[i].percentageComplete, ).length; const totalStepsBack = items.filter( (stage, i) => stage.percentageComplete < previousStages[i].percentageComplete, ).length; let stepsForward = totalStepsForward; let stepsBack = totalStepsBack; const stages = items.map((stage, index) => { let transitionSpeed = 0; let transitionDelay = 0; const transitionEasing = progressChanges > 1 ? 'linear' : 'cubic-bezier(0.15,1,0.3,1)'; if (animated) { transitionSpeed = progressChanges > 1 ? 50 : 300; if (stage.percentageComplete < previousStages[index].percentageComplete) { // 뒤로 갈 때는 역순으로 하나씩 transitionDelay = (stepsBack - 1) * transitionSpeed; stepsBack -= 1; } else if (stage.percentageComplete > previousStages[index].percentageComplete) { // 앞으로 갈 때는 순서대로 하나씩 transitionDelay = (totalStepsForward - stepsForward) * transitionSpeed; stepsForward -= 1; } } return ( <ProgressTrackerStage key={stage.id} item={stage} render={render} testId={testId && `${testId}-stage-${index}`} transitionSpeed={transitionSpeed} transitionDelay={transitionDelay} transitionEasing={transitionEasing} /> ); }); return ( <> <style href="steel-progress-tracker" precedence="default"> {keyframes} </style> <ol data-slot="progress-tracker" data-testid={testId} aria-label={label} style={ { gridTemplateColumns: `repeat(${items.length}, 1fr)`, '--ds--pt--sp': spacingOptions[spacing], maxWidth: 8 * 10 * items.length * 2, } as React.CSSProperties } className={cn( 'mx-auto mt-ds-500 mb-0 grid w-full list-none gap-(--ds--pt--sp) p-ds-0', className, )} > {stages} </ol> </> );}export { ProgressTracker, ProgressTrackerLink };