import { raise } from "@cv/utils"; import { cva } from "class-variance-authority"; import { Children, createContext, type ReactElement, type ReactNode, useContext, useState, } from "react"; import { z } from "zod/v4"; interface ProgressContextValue { currentStep: number; next: () => void; back: () => void; canGoNext: boolean; canGoBack: boolean; } const ProgressContext = createContext(null); export const useProgressContext = () => { return ( useContext(ProgressContext) ?? raise("useProgressContext must be used within a ProgressProvider") ); }; export const stepStateSchema = z.enum([ "selected", "completed", "pending", "error", "disabled", ]); export const stepPropsSchema = z.object({ name: z.string().min(1, "Step name is required"), children: z.any().optional(), index: z.number().int().nonnegative().optional(), state: stepStateSchema.optional(), disabled: z.boolean().optional(), }); export type StepProps = z.infer; export type StepState = z.infer; const validateStepProps = (props: unknown): StepProps | null => { const result = stepPropsSchema.safeParse(props); if (!result.success) { return null; } return result.data; }; const getStepPropsFromElement = (element: ReactElement): StepProps | null => { if ( !element || typeof element !== "object" || !("props" in element) || !element.props || typeof element.props !== "object" ) { return null; } return validateStepProps(element.props); }; const stepContainerVariants = cva( "flex items-center space-x-2 transition-colors duration-200", { variants: { state: { selected: "text-ctp-blue", completed: "text-ctp-green", pending: "text-ctp-subtext0", error: "text-ctp-red", disabled: "text-ctp-overlay0 opacity-50", }, }, defaultVariants: { state: "pending", }, }, ); const stepIndicatorVariants = cva( "h-8 w-8 rounded-full flex items-center justify-center transition-colors duration-200", { variants: { state: { selected: "bg-ctp-blue text-ctp-base", completed: "bg-ctp-green text-ctp-base", pending: "bg-ctp-surface0", error: "bg-ctp-red text-ctp-base", disabled: "bg-ctp-surface0 opacity-50", }, }, defaultVariants: { state: "pending", }, }, ); interface StepComponentProps { name: string; children?: ReactNode; state?: StepState; disabled?: boolean; } interface StepRenderProps { name: string; index: number; state: StepState; clickable: boolean; onClick?: () => void; } const Step = (_props: StepComponentProps) => { return null; }; Step.displayName = "Progress.Step"; const StepRenderer = ({ name, index, state, clickable, onClick, }: StepRenderProps) => { const stepNumber = index + 1; const isClickable = clickable && state !== "disabled"; return ( ); }; const ConnectorLine = ({ isCompleted }: { isCompleted: boolean }) => (
); interface ProgressProps { initialStep?: number; currentStep?: number; onStepChange?: (step: number) => void; onNext?: () => void; onBack?: () => void; children: ReactNode; } export const Progress = ({ initialStep = 0, currentStep: controlledStep, onStepChange, onNext, onBack, children, }: ProgressProps) => { const [internalStep, setInternalStep] = useState(initialStep); const isControlled = controlledStep !== undefined; const currentStep = isControlled ? controlledStep : internalStep; const steps = Children.toArray(children).filter( (child): child is ReactElement => child !== null && typeof child === "object" && "type" in child && child.type === Step, ); const totalSteps = steps.length; const canGoNext = currentStep < totalSteps - 1; const canGoBack = currentStep > 0; const goToStep = (step: number) => { if (isControlled) { onStepChange?.(step); } else { setInternalStep(step); } }; const handleNext = () => { if (!canGoNext) return; goToStep(currentStep + 1); onNext?.(); }; const handleBack = () => { if (!canGoBack) return; goToStep(currentStep - 1); onBack?.(); }; const currentStepElement = steps[currentStep]; const currentStepProps = currentStepElement ? getStepPropsFromElement(currentStepElement) : null; const currentStepContent = currentStepProps?.children ?? null; return (
{steps.map((stepElement, index) => { const stepProps = getStepPropsFromElement(stepElement); if (!stepProps) { return null; } const isDisabled = stepProps.disabled === true; const isCurrent = index === currentStep; const isCompleted = index < currentStep; const stepState: StepState = isDisabled ? "disabled" : stepProps.state ? stepProps.state : isCurrent ? "selected" : isCompleted ? "completed" : "pending"; const clickable = !isDisabled && onStepChange !== undefined; return (
goToStep(index)} /> {index < totalSteps - 1 && ( )}
); })}
{currentStepContent &&
{currentStepContent}
}
); }; Progress.Step = Step;