'use client'; import * as React from 'react'; import { createContext, useContext } 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 React.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] = React.useState(defaultValue); const [triggerNodes, setTriggerNodes] = React.useState([]); // Register/unregister triggers const registerTrigger = React.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 = React.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 = React.useMemo( () => ({ activeStep: currentStep, setActiveStep: handleSetActiveStep, stepsCount: React.Children.toArray(children).filter( (child): child is React.ReactElement => React.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 = React.useRef(null); React.useEffect(() => { if (btnRef.current) { registerTrigger(btnRef.current); } }, [btnRef.current]); // Find our index among triggers for navigation const myIdx = React.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, };