import { useEffect, useRef, useState } from 'react'; import { cn } from '@renderer/lib/utils'; import { Check, X } from 'lucide-react'; export interface StepProgressBarStep { key: string; label: string; } export interface StepProgressBarProps { steps: StepProgressBarStep[]; /** 0-based index of the current step, -1 if not started */ currentIndex: number; /** Whether the current step should show in-progress animations */ active?: boolean; /** If set, this step shows a red error indicator instead of active/pending */ errorIndex?: number; className?: string; } /** * Circular step progress indicator with animated connecting lines. * * - Completed steps: green circle with checkmark + jelly bounce on completion * - Current step: green outlined circle with pulsing ring + number * - Error step: red circle with X icon * - Pending steps: gray circle with number */ export const StepProgressBar = ({ steps, currentIndex, active = true, errorIndex, className, }: StepProgressBarProps): React.JSX.Element => { const prevIndexRef = useRef(currentIndex); const [justCompletedIndex, setJustCompletedIndex] = useState(null); const canAnimate = active && errorIndex === undefined; useEffect(() => { const prev = prevIndexRef.current; prevIndexRef.current = currentIndex; if (!canAnimate) { const clearTimer = window.setTimeout(() => setJustCompletedIndex(null), 0); return () => window.clearTimeout(clearTimer); } if (currentIndex > prev && prev >= 0) { const lastDoneIndex = Math.min(currentIndex - 1, steps.length - 1); const startTimer = window.setTimeout(() => setJustCompletedIndex(lastDoneIndex), 0); const clearTimer = window.setTimeout(() => setJustCompletedIndex(null), 500); return () => { window.clearTimeout(startTimer); window.clearTimeout(clearTimer); }; } }, [canAnimate, currentIndex, steps.length]); return (
{steps.map((step, index) => { const isError = errorIndex !== undefined && index === errorIndex; const isDone = !isError && currentIndex >= 0 && index < currentIndex; const isCurrent = !isError && currentIndex >= 0 && index === currentIndex; const isLast = index === steps.length - 1; const isJustCompleted = canAnimate && justCompletedIndex === index; const isAnimatingCurrent = canAnimate && isCurrent; // The connecting line between this step and the next const lineState: 'done' | 'active' | 'pending' = isDone && !isLast ? 'done' : isAnimatingCurrent && !isLast ? 'active' : 'pending'; return (
{/* Step circle + label column */}
{/* Circle wrapper - holds flash overlay */}
{/* Green flash burst on completion */} {isJustCompleted && isDone && (
)} {/* Circle */}
{isError ? ( ) : isDone ? ( ) : ( {index + 1} )}
{/* Label */} {step.label}
{/* Connecting line */} {!isLast && (
{/* Background track */}
{lineState === 'done' ? (
) : lineState === 'active' ? (
) : null}
)}
); })}
); };