"use client" import { Children, createContext, HTMLAttributes, isValidElement, ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react" import { cn } from "@/lib/utils" // Types type StepperOrientation = "horizontal" | "vertical" type StepState = "active" | "completed" | "inactive" | "loading" type StepIndicators = { active?: React.ReactNode completed?: React.ReactNode inactive?: React.ReactNode loading?: React.ReactNode } interface StepperContextValue { activeStep: number setActiveStep: (step: number) => void stepsCount: number orientation: StepperOrientation registerTrigger: (node: HTMLButtonElement | null) => void triggerNodes: HTMLButtonElement[] focusNext: (currentIdx: number) => void focusPrev: (currentIdx: number) => void focusFirst: () => void focusLast: () => void indicators: StepIndicators } interface StepItemContextValue { step: number state: StepState isDisabled: boolean isLoading: boolean } const StepperContext = createContext(undefined) const StepItemContext = createContext( undefined ) function useStepper() { const ctx = useContext(StepperContext) if (!ctx) throw new Error("useStepper must be used within a Stepper") return ctx } function useStepItem() { const ctx = useContext(StepItemContext) if (!ctx) throw new Error("useStepItem must be used within a StepperItem") return ctx } interface StepperProps extends HTMLAttributes { defaultValue?: number value?: number onValueChange?: (value: number) => void orientation?: StepperOrientation indicators?: StepIndicators } function Stepper({ defaultValue = 1, value, onValueChange, orientation = "horizontal", className, children, indicators = {}, ...props }: StepperProps) { const [activeStep, setActiveStep] = useState(defaultValue) const [triggerNodes, setTriggerNodes] = useState([]) // Register/unregister triggers const registerTrigger = useCallback((node: HTMLButtonElement | null) => { setTriggerNodes((prev) => { if (node && !prev.includes(node)) { return [...prev, node] } else if (!node && prev.includes(node!)) { return prev.filter((n) => n !== node) } else { return prev } }) }, []) const handleSetActiveStep = useCallback( (step: number) => { if (value === undefined) { setActiveStep(step) } onValueChange?.(step) }, [value, onValueChange] ) const currentStep = value ?? activeStep // Keyboard navigation logic const focusTrigger = (idx: number) => { if (triggerNodes[idx]) triggerNodes[idx].focus() } const focusNext = (currentIdx: number) => focusTrigger((currentIdx + 1) % triggerNodes.length) const focusPrev = (currentIdx: number) => focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length) const focusFirst = () => focusTrigger(0) const focusLast = () => focusTrigger(triggerNodes.length - 1) // Context value const contextValue = useMemo( () => ({ activeStep: currentStep, setActiveStep: handleSetActiveStep, stepsCount: Children.toArray(children).filter( (child): child is ReactElement => isValidElement(child) && (child.type as { displayName?: string }).displayName === "StepperItem" ).length, orientation, registerTrigger, focusNext, focusPrev, focusFirst, focusLast, triggerNodes, indicators, }), [ currentStep, handleSetActiveStep, children, orientation, registerTrigger, triggerNodes, ] ) return (
{children}
) } interface StepperItemProps extends React.HTMLAttributes { step: number completed?: boolean disabled?: boolean loading?: boolean } function StepperItem({ step, completed = false, disabled = false, loading = false, className, children, ...props }: StepperItemProps) { const { activeStep } = useStepper() const state: StepState = completed || step < activeStep ? "completed" : activeStep === step ? "active" : "inactive" const isLoading = loading && step === activeStep return (
{children}
) } interface StepperTriggerProps extends React.ButtonHTMLAttributes { asChild?: boolean } function StepperTrigger({ asChild = false, className, children, tabIndex, ...props }: StepperTriggerProps) { const { state, isLoading } = useStepItem() const stepperCtx = useStepper() const { setActiveStep, activeStep, registerTrigger, triggerNodes, focusNext, focusPrev, focusFirst, focusLast, } = stepperCtx const { step, isDisabled } = useStepItem() const isSelected = activeStep === step const id = `stepper-tab-${step}` const panelId = `stepper-panel-${step}` // Register this trigger for keyboard navigation const btnRef = useRef(null) useEffect(() => { if (btnRef.current) { registerTrigger(btnRef.current) } }, [btnRef.current]) // Find our index among triggers for navigation const myIdx = useMemo( () => triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current), [triggerNodes, btnRef.current] ) const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowRight": case "ArrowDown": e.preventDefault() if (myIdx !== -1 && focusNext) focusNext(myIdx) break case "ArrowLeft": case "ArrowUp": e.preventDefault() if (myIdx !== -1 && focusPrev) focusPrev(myIdx) break case "Home": e.preventDefault() if (focusFirst) focusFirst() break case "End": e.preventDefault() if (focusLast) focusLast() break case "Enter": case " ": e.preventDefault() setActiveStep(step) break } } if (asChild) { return ( {children} ) } return ( ) } function StepperIndicator({ children, className, }: React.ComponentProps<"div">) { const { state, isLoading } = useStepItem() const { indicators } = useStepper() return (
{indicators && ((isLoading && indicators.loading) || (state === "completed" && indicators.completed) || (state === "active" && indicators.active) || (state === "inactive" && indicators.inactive)) ? (isLoading && indicators.loading) || (state === "completed" && indicators.completed) || (state === "active" && indicators.active) || (state === "inactive" && indicators.inactive) : children}
) } function StepperSeparator({ className }: React.ComponentProps<"div">) { const { state } = useStepItem() return (
) } function StepperTitle({ children, className }: React.ComponentProps<"h3">) { const { state } = useStepItem() return (

{children}

) } function StepperDescription({ children, className, }: React.ComponentProps<"div">) { const { state } = useStepItem() return (
{children}
) } function StepperNav({ children, className }: React.ComponentProps<"nav">) { const { activeStep, orientation } = useStepper() return ( ) } function StepperPanel({ children, className }: React.ComponentProps<"div">) { const { activeStep } = useStepper() return (
{children}
) } interface StepperContentProps extends React.ComponentProps<"div"> { value: number forceMount?: boolean } function StepperContent({ value, forceMount, children, className, }: StepperContentProps) { const { activeStep } = useStepper() const isActive = value === activeStep if (!forceMount && !isActive) { return null } return ( ) } export { useStepper, useStepItem, Stepper, StepperItem, StepperTrigger, StepperIndicator, StepperSeparator, StepperTitle, StepperDescription, StepperPanel, StepperContent, StepperNav, type StepperProps, type StepperItemProps, type StepperTriggerProps, type StepperContentProps, }