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; /** 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, errorIndex, className, }: StepProgressBarProps): React.JSX.Element => { // Track which step just completed for jelly + flash animation const prevIndexRef = useRef(currentIndex); const [justCompletedIndex, setJustCompletedIndex] = useState(null); useEffect(() => { const prev = prevIndexRef.current; prevIndexRef.current = currentIndex; // Animate the highest step that just became "done" if (currentIndex > prev && prev >= 0 && errorIndex === undefined) { const lastDoneIndex = Math.min(currentIndex - 1, steps.length - 1); setJustCompletedIndex(lastDoneIndex); const timer = setTimeout(() => setJustCompletedIndex(null), 500); return () => clearTimeout(timer); } }, [currentIndex, errorIndex, 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 = justCompletedIndex === index; // The connecting line between this step and the next const lineState: 'done' | 'active' | 'pending' = isDone && !isLast ? 'done' : isCurrent && !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}
)}
); })}
); };