Files
MOPC-Portal/src/components/forms/form-wizard.tsx

327 lines
8.9 KiB
TypeScript
Raw Normal View History

'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>
)
}