agent-ecosystem/src/renderer/components/team/StepProgressBar.tsx
2026-05-04 17:21:05 +03:00

176 lines
6.6 KiB
TypeScript

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<number | null>(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 (
<div className={cn('flex items-start justify-center', className)}>
{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 (
<div
key={step.key}
className="flex items-start"
style={{ flex: isLast ? '0 0 auto' : '1 1 0%' }}
>
{/* Step circle + label column */}
<div className="flex flex-col items-center" style={{ width: 56 }}>
{/* Circle wrapper - holds flash overlay */}
<div className="relative flex items-center justify-center">
{/* Green flash burst on completion */}
{isJustCompleted && isDone && (
<div
className="absolute size-7 rounded-full bg-[var(--stepper-done)]"
style={{ animation: 'stepper-flash 0.4s ease-out forwards' }}
/>
)}
{/* Circle */}
<div
className={cn(
'relative flex items-center justify-center rounded-full transition-all duration-300',
'size-7',
isError &&
'bg-[var(--stepper-error)] shadow-[0_0_8px_var(--stepper-error-glow)]',
isDone && 'bg-[var(--stepper-done)] shadow-[0_0_8px_var(--stepper-done-glow)]',
isCurrent && 'border-2 border-[var(--stepper-current)] bg-transparent',
!isDone &&
!isCurrent &&
!isError &&
'border border-[var(--stepper-pending-border)] bg-[var(--stepper-pending)]'
)}
style={
isJustCompleted && isDone
? { animation: 'stepper-jelly 0.45s ease-out' }
: isAnimatingCurrent
? { animation: 'stepper-pulse-ring 2s ease-in-out infinite' }
: undefined
}
>
{isError ? (
<X className="size-3.5 text-white" strokeWidth={3} />
) : isDone ? (
<Check className="size-3.5 text-white" strokeWidth={3} />
) : (
<span
className={cn(
'text-[11px] font-semibold leading-none',
isCurrent
? 'text-[var(--stepper-current)]'
: 'text-[var(--stepper-pending-text)]'
)}
>
{index + 1}
</span>
)}
</div>
</div>
{/* Label */}
<span
className={cn(
'mt-1.5 text-center text-[10px] leading-tight transition-colors duration-300',
isError
? 'font-medium text-[var(--stepper-label-error)]'
: isDone || isCurrent
? 'font-medium text-[var(--stepper-label-active)]'
: 'text-[var(--stepper-label)]'
)}
>
{step.label}
</span>
</div>
{/* Connecting line */}
{!isLast && (
<div
className="relative mt-3.5 h-[2px] flex-1 overflow-hidden"
style={{ minWidth: 16 }}
>
{/* Background track */}
<div className="absolute inset-0 rounded-full bg-[var(--stepper-line)]" />
{lineState === 'done' ? (
<div className="absolute inset-0 rounded-full bg-[var(--stepper-line-done)]" />
) : lineState === 'active' ? (
<div
className="absolute top-0 h-full rounded-full bg-[var(--stepper-line-done)]"
style={{
width: '40%',
animation: 'stepper-line-sweep 1.2s ease-in-out infinite',
}}
/>
) : null}
</div>
)}
</div>
);
})}
</div>
);
};