327 lines
8.9 KiB
TypeScript
327 lines
8.9 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import * as React from 'react'
|
||
|
|
import { motion, AnimatePresence } from 'motion/react'
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { ArrowLeft, ArrowRight, Check, Loader2 } from 'lucide-react'
|
||
|
|
|
||
|
|
export interface WizardStep {
|
||
|
|
id: string
|
||
|
|
title: string
|
||
|
|
description?: string
|
||
|
|
isOptional?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FormWizardContextValue {
|
||
|
|
currentStep: number
|
||
|
|
totalSteps: number
|
||
|
|
steps: WizardStep[]
|
||
|
|
goToStep: (step: number) => void
|
||
|
|
nextStep: () => void
|
||
|
|
prevStep: () => void
|
||
|
|
isFirstStep: boolean
|
||
|
|
isLastStep: boolean
|
||
|
|
canGoNext: boolean
|
||
|
|
setCanGoNext: (can: boolean) => void
|
||
|
|
}
|
||
|
|
|
||
|
|
const FormWizardContext = React.createContext<FormWizardContextValue | null>(null)
|
||
|
|
|
||
|
|
export function useFormWizard() {
|
||
|
|
const context = React.useContext(FormWizardContext)
|
||
|
|
if (!context) {
|
||
|
|
throw new Error('useFormWizard must be used within a FormWizard')
|
||
|
|
}
|
||
|
|
return context
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FormWizardProps {
|
||
|
|
steps: WizardStep[]
|
||
|
|
children: React.ReactNode
|
||
|
|
onComplete?: () => void | Promise<void>
|
||
|
|
isSubmitting?: boolean
|
||
|
|
submitLabel?: string
|
||
|
|
className?: string
|
||
|
|
showStepIndicator?: boolean
|
||
|
|
allowStepNavigation?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
export function FormWizard({
|
||
|
|
steps,
|
||
|
|
children,
|
||
|
|
onComplete,
|
||
|
|
isSubmitting = false,
|
||
|
|
submitLabel = 'Submit',
|
||
|
|
className,
|
||
|
|
showStepIndicator = true,
|
||
|
|
allowStepNavigation = false,
|
||
|
|
}: FormWizardProps) {
|
||
|
|
const [currentStep, setCurrentStep] = React.useState(0)
|
||
|
|
const [canGoNext, setCanGoNext] = React.useState(true)
|
||
|
|
const [direction, setDirection] = React.useState(0) // -1 for back, 1 for forward
|
||
|
|
|
||
|
|
const totalSteps = steps.length
|
||
|
|
const isFirstStep = currentStep === 0
|
||
|
|
const isLastStep = currentStep === totalSteps - 1
|
||
|
|
|
||
|
|
const goToStep = React.useCallback((step: number) => {
|
||
|
|
if (step >= 0 && step < totalSteps) {
|
||
|
|
setDirection(step > currentStep ? 1 : -1)
|
||
|
|
setCurrentStep(step)
|
||
|
|
}
|
||
|
|
}, [currentStep, totalSteps])
|
||
|
|
|
||
|
|
const nextStep = React.useCallback(() => {
|
||
|
|
if (currentStep < totalSteps - 1) {
|
||
|
|
setDirection(1)
|
||
|
|
setCurrentStep((prev) => prev + 1)
|
||
|
|
}
|
||
|
|
}, [currentStep, totalSteps])
|
||
|
|
|
||
|
|
const prevStep = React.useCallback(() => {
|
||
|
|
if (currentStep > 0) {
|
||
|
|
setDirection(-1)
|
||
|
|
setCurrentStep((prev) => prev - 1)
|
||
|
|
}
|
||
|
|
}, [currentStep])
|
||
|
|
|
||
|
|
const handleNext = async () => {
|
||
|
|
if (isLastStep && onComplete) {
|
||
|
|
await onComplete()
|
||
|
|
} else {
|
||
|
|
nextStep()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const contextValue: FormWizardContextValue = {
|
||
|
|
currentStep,
|
||
|
|
totalSteps,
|
||
|
|
steps,
|
||
|
|
goToStep,
|
||
|
|
nextStep,
|
||
|
|
prevStep,
|
||
|
|
isFirstStep,
|
||
|
|
isLastStep,
|
||
|
|
canGoNext,
|
||
|
|
setCanGoNext,
|
||
|
|
}
|
||
|
|
|
||
|
|
const childrenArray = React.Children.toArray(children)
|
||
|
|
const currentChild = childrenArray[currentStep]
|
||
|
|
|
||
|
|
const variants = {
|
||
|
|
enter: (direction: number) => ({
|
||
|
|
x: direction > 0 ? 100 : -100,
|
||
|
|
opacity: 0,
|
||
|
|
}),
|
||
|
|
center: {
|
||
|
|
x: 0,
|
||
|
|
opacity: 1,
|
||
|
|
},
|
||
|
|
exit: (direction: number) => ({
|
||
|
|
x: direction < 0 ? 100 : -100,
|
||
|
|
opacity: 0,
|
||
|
|
}),
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<FormWizardContext.Provider value={contextValue}>
|
||
|
|
<div className={cn('flex min-h-[600px] flex-col', className)}>
|
||
|
|
{showStepIndicator && (
|
||
|
|
<StepIndicator
|
||
|
|
steps={steps}
|
||
|
|
currentStep={currentStep}
|
||
|
|
allowNavigation={allowStepNavigation}
|
||
|
|
onStepClick={allowStepNavigation ? goToStep : undefined}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="relative flex-1 overflow-hidden">
|
||
|
|
<AnimatePresence initial={false} custom={direction} mode="wait">
|
||
|
|
<motion.div
|
||
|
|
key={currentStep}
|
||
|
|
custom={direction}
|
||
|
|
variants={variants}
|
||
|
|
initial="enter"
|
||
|
|
animate="center"
|
||
|
|
exit="exit"
|
||
|
|
transition={{
|
||
|
|
x: { type: 'spring', stiffness: 300, damping: 30 },
|
||
|
|
opacity: { duration: 0.2 },
|
||
|
|
}}
|
||
|
|
className="absolute inset-0"
|
||
|
|
>
|
||
|
|
<div className="h-full px-1 py-6">
|
||
|
|
{currentChild}
|
||
|
|
</div>
|
||
|
|
</motion.div>
|
||
|
|
</AnimatePresence>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center justify-between border-t pt-6">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
onClick={prevStep}
|
||
|
|
disabled={isFirstStep || isSubmitting}
|
||
|
|
className={cn(isFirstStep && 'invisible')}
|
||
|
|
>
|
||
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
|
|
Back
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
onClick={handleNext}
|
||
|
|
disabled={!canGoNext || isSubmitting}
|
||
|
|
>
|
||
|
|
{isSubmitting ? (
|
||
|
|
<>
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
Processing...
|
||
|
|
</>
|
||
|
|
) : isLastStep ? (
|
||
|
|
<>
|
||
|
|
<Check className="mr-2 h-4 w-4" />
|
||
|
|
{submitLabel}
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
Continue
|
||
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</FormWizardContext.Provider>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
interface StepIndicatorProps {
|
||
|
|
steps: WizardStep[]
|
||
|
|
currentStep: number
|
||
|
|
allowNavigation?: boolean
|
||
|
|
onStepClick?: (step: number) => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export function StepIndicator({
|
||
|
|
steps,
|
||
|
|
currentStep,
|
||
|
|
allowNavigation = false,
|
||
|
|
onStepClick,
|
||
|
|
}: StepIndicatorProps) {
|
||
|
|
return (
|
||
|
|
<div className="mb-8">
|
||
|
|
{/* Progress bar */}
|
||
|
|
<div className="mb-4">
|
||
|
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||
|
|
<span>Step {currentStep + 1} of {steps.length}</span>
|
||
|
|
<span>{Math.round(((currentStep + 1) / steps.length) * 100)}% complete</span>
|
||
|
|
</div>
|
||
|
|
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||
|
|
<motion.div
|
||
|
|
className="h-full bg-gradient-to-r from-primary to-primary/80"
|
||
|
|
initial={{ width: 0 }}
|
||
|
|
animate={{ width: `${((currentStep + 1) / steps.length) * 100}%` }}
|
||
|
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Step indicators */}
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
{steps.map((step, index) => {
|
||
|
|
const isCompleted = index < currentStep
|
||
|
|
const isCurrent = index === currentStep
|
||
|
|
const isClickable = allowNavigation && (isCompleted || isCurrent)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<React.Fragment key={step.id}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => isClickable && onStepClick?.(index)}
|
||
|
|
disabled={!isClickable}
|
||
|
|
className={cn(
|
||
|
|
'flex flex-col items-center gap-2',
|
||
|
|
isClickable && 'cursor-pointer',
|
||
|
|
!isClickable && 'cursor-default'
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'flex h-10 w-10 items-center justify-center rounded-full border-2 text-sm font-semibold transition-colors',
|
||
|
|
isCompleted && 'border-primary bg-primary text-primary-foreground',
|
||
|
|
isCurrent && 'border-primary text-primary',
|
||
|
|
!isCompleted && !isCurrent && 'border-muted text-muted-foreground'
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{isCompleted ? (
|
||
|
|
<Check className="h-5 w-5" />
|
||
|
|
) : (
|
||
|
|
<span>{index + 1}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
'hidden text-xs font-medium md:block',
|
||
|
|
isCurrent && 'text-primary',
|
||
|
|
!isCurrent && 'text-muted-foreground'
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{step.title}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
{index < steps.length - 1 && (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
'h-0.5 flex-1 mx-2',
|
||
|
|
index < currentStep ? 'bg-primary' : 'bg-muted'
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</React.Fragment>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
interface WizardStepContentProps {
|
||
|
|
children: React.ReactNode
|
||
|
|
title?: string
|
||
|
|
description?: string
|
||
|
|
className?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export function WizardStepContent({
|
||
|
|
children,
|
||
|
|
title,
|
||
|
|
description,
|
||
|
|
className,
|
||
|
|
}: WizardStepContentProps) {
|
||
|
|
return (
|
||
|
|
<div className={cn('flex h-full flex-col', className)}>
|
||
|
|
{(title || description) && (
|
||
|
|
<div className="mb-8 text-center">
|
||
|
|
{title && (
|
||
|
|
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
|
||
|
|
{title}
|
||
|
|
</h2>
|
||
|
|
)}
|
||
|
|
{description && (
|
||
|
|
<p className="mt-2 text-muted-foreground">
|
||
|
|
{description}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="flex-1">
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|