diff --git a/packages/ui/package.json b/packages/ui/package.json index d1cdad864..3b2e4d45c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -120,7 +120,8 @@ "./stepper": "./src/makerkit/stepper.tsx", "./cookie-banner": "./src/makerkit/cookie-banner.tsx", "./card-button": "./src/makerkit/card-button.tsx", - "./version-updater": "./src/makerkit/version-updater.tsx" + "./version-updater": "./src/makerkit/version-updater.tsx", + "./multi-step-form": "./src/makerkit/multi-step-form.tsx" }, "typesVersions": { "*": { diff --git a/packages/ui/src/makerkit/multi-step-form.tsx b/packages/ui/src/makerkit/multi-step-form.tsx new file mode 100644 index 000000000..9b245ce2d --- /dev/null +++ b/packages/ui/src/makerkit/multi-step-form.tsx @@ -0,0 +1,422 @@ +'use client'; + +import React, { + HTMLProps, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { Slot, Slottable } from '@radix-ui/react-slot'; +import { Path, UseFormReturn } from 'react-hook-form'; +import { z } from 'zod'; + +import { cn } from '../utils'; + +interface MultiStepFormProps { + schema: T; + form: UseFormReturn>; + onSubmit: (data: z.infer) => void; + useStepTransition?: boolean; + className?: string; +} + +type StepProps = React.PropsWithChildren< + { + name: string; + asChild?: boolean; + } & React.HTMLProps +>; + +const MultiStepFormContext = createContext | null>(null); + +/** + * @name MultiStepForm + * @description Multi-step form component for React + * @param schema + * @param form + * @param onSubmit + * @param children + * @param className + * @constructor + */ +export function MultiStepForm({ + schema, + form, + onSubmit, + children, + className, +}: React.PropsWithChildren>) { + const steps = useMemo( + () => + React.Children.toArray(children).filter( + (child): child is React.ReactElement => + React.isValidElement(child) && child.type === MultiStepFormStep, + ), + [children], + ); + + const header = useMemo(() => { + return React.Children.toArray(children).find( + (child) => + React.isValidElement(child) && child.type === MultiStepFormHeader, + ); + }, [children]); + + const footer = useMemo(() => { + return React.Children.toArray(children).find( + (child) => + React.isValidElement(child) && child.type === MultiStepFormFooter, + ); + }, [children]); + + const stepNames = steps.map((step) => step.props.name); + const multiStepForm = useMultiStepForm(schema, form, stepNames); + + return ( + +
+ {header} + +
+ {steps.map((step, index) => { + const isActive = index === multiStepForm.currentStepIndex; + + return ( + + {step} + + ); + })} +
+ + {footer} +
+
+ ); +} + +export function MultiStepFormContextProvider(props: { + children: (context: ReturnType) => React.ReactNode; +}) { + const ctx = useMultiStepFormContext(); + + if (Array.isArray(props.children)) { + const [child] = props.children; + + return ( + child as (context: ReturnType) => React.ReactNode + )(ctx); + } + + return props.children(ctx); +} + +export const MultiStepFormStep = React.forwardRef< + HTMLDivElement, + React.PropsWithChildren< + { + asChild?: boolean; + } & HTMLProps + > +>(function MultiStepFormStep({ children, asChild, ...props }, ref) { + const Cmp = asChild ? Slot : 'div'; + + return ( + + {children} + + ); +}); + +export function useMultiStepFormContext() { + const context = useContext(MultiStepFormContext) as ReturnType< + typeof useMultiStepForm + >; + + if (!context) { + throw new Error( + 'useMultiStepFormContext must be used within a MultiStepForm', + ); + } + + return context; +} + +/** + * @name useMultiStepForm + * @description Hook for multi-step forms + * @param schema + * @param form + * @param stepNames + */ +export function useMultiStepForm( + schema: Schema, + form: UseFormReturn>, + stepNames: string[], +) { + const [state, setState] = useState({ + currentStepIndex: 0, + direction: undefined as 'forward' | 'backward' | undefined, + }); + + const isStepValid = useCallback(() => { + const currentStepName = stepNames[state.currentStepIndex] as Path< + z.TypeOf + >; + + if (schema instanceof z.ZodObject) { + const currentStepSchema = schema.shape[currentStepName] as z.ZodType; + + // the user may not want to validate the current step + // or the step doesn't contain any form field + if (!currentStepSchema) { + return true; + } + + const currentStepData = form.getValues(currentStepName) ?? {}; + const result = currentStepSchema.safeParse(currentStepData); + + return result.success; + } + + throw new Error(`Unsupported schema type: ${schema.constructor.name}`); + }, [schema, form, stepNames, state.currentStepIndex]); + + const nextStep = useCallback( + (e: Ev) => { + // prevent form submission when the user presses Enter + // or if the user forgets [type="button"] on the button + e.preventDefault(); + + const isValid = isStepValid(); + + if (!isValid) { + const currentStepName = stepNames[state.currentStepIndex] as Path< + z.TypeOf + >; + + if (schema instanceof z.ZodObject) { + const currentStepSchema = schema.shape[currentStepName] as z.ZodType; + + if (currentStepSchema) { + const fields = Object.keys( + (currentStepSchema as z.ZodObject).shape, + ); + + const keys = fields.map((field) => `${currentStepName}.${field}`); + + // trigger validation for all fields in the current step + for (const key of keys) { + void form.trigger(key as Path>); + } + + return; + } + } + } + + if (isValid && state.currentStepIndex < stepNames.length - 1) { + setState((prevState) => { + return { + ...prevState, + direction: 'forward', + currentStepIndex: prevState.currentStepIndex + 1, + }; + }); + } + }, + [isStepValid, state.currentStepIndex, stepNames, schema, form], + ); + + const prevStep = useCallback( + (e: Ev) => { + // prevent form submission when the user presses Enter + // or if the user forgets [type="button"] on the button + e.preventDefault(); + + if (state.currentStepIndex > 0) { + setState((prevState) => { + return { + ...prevState, + direction: 'backward', + currentStepIndex: prevState.currentStepIndex - 1, + }; + }); + } + }, + [state.currentStepIndex], + ); + + const goToStep = useCallback( + (index: number) => { + if (index >= 0 && index < stepNames.length && isStepValid()) { + setState((prevState) => { + return { + ...prevState, + direction: + index > prevState.currentStepIndex ? 'forward' : 'backward', + currentStepIndex: index, + }; + }); + } + }, + [isStepValid, stepNames.length], + ); + + const isValid = form.formState.isValid; + const errors = form.formState.errors; + + return useMemo( + () => ({ + form, + currentStep: stepNames[state.currentStepIndex] as string, + currentStepIndex: state.currentStepIndex, + totalSteps: stepNames.length, + isFirstStep: state.currentStepIndex === 0, + isLastStep: state.currentStepIndex === stepNames.length - 1, + nextStep, + prevStep, + goToStep, + direction: state.direction, + isStepValid, + isValid, + errors, + }), + [ + form, + stepNames, + state.currentStepIndex, + state.direction, + nextStep, + prevStep, + goToStep, + isStepValid, + isValid, + errors, + ], + ); +} + +export const MultiStepFormHeader = React.forwardRef< + HTMLDivElement, + React.PropsWithChildren< + { + asChild?: boolean; + } & HTMLProps + > +>(function MultiStepFormHeader({ children, asChild, ...props }, ref) { + const Cmp = asChild ? Slot : 'div'; + + return ( + + {children} + + ); +}); + +export const MultiStepFormFooter = React.forwardRef< + HTMLDivElement, + React.PropsWithChildren< + { + asChild?: boolean; + } & HTMLProps + > +>(function MultiStepFormFooter({ children, asChild, ...props }, ref) { + const Cmp = asChild ? Slot : 'div'; + + return ( + + {children} + + ); +}); + +/** + * @name createStepSchema + * @description Create a schema for a multi-step form + * @param steps + */ +export function createStepSchema>( + steps: T, +) { + return z.object(steps); +} + +interface AnimatedStepProps { + direction: 'forward' | 'backward' | undefined; + isActive: boolean; + index: number; + currentIndex: number; +} + +function AnimatedStep({ + isActive, + direction, + children, + index, + currentIndex, +}: React.PropsWithChildren) { + const [shouldRender, setShouldRender] = useState(isActive); + const stepRef = useRef(null); + + useEffect(() => { + if (isActive) { + setShouldRender(true); + } else { + const timer = setTimeout(() => setShouldRender(false), 300); + + return () => clearTimeout(timer); + } + }, [isActive]); + + useEffect(() => { + if (isActive && stepRef.current) { + const focusableElement = stepRef.current.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + if (focusableElement) { + (focusableElement as HTMLElement).focus(); + } + } + }, [isActive]); + + if (!shouldRender) { + return null; + } + + const baseClasses = + ' top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95'; + + const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute'; + + const transformClasses = cn('translate-x-0', isActive ? {} : { + '-translate-x-full': direction === 'forward' || index < currentIndex, + 'translate-x-full': direction === 'backward' || index > currentIndex, + }); + + + const className = cn(baseClasses, visibilityClasses, transformClasses); + + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/makerkit/stepper.tsx b/packages/ui/src/makerkit/stepper.tsx index 607ab41e2..6fa8366fa 100644 --- a/packages/ui/src/makerkit/stepper.tsx +++ b/packages/ui/src/makerkit/stepper.tsx @@ -8,7 +8,7 @@ import { cn } from '../utils'; import { If } from './if'; import { Trans } from './trans'; -type Variant = 'numbers' | 'default'; +type Variant = 'numbers' | 'default' | 'dots'; const classNameBuilder = getClassNameBuilder(); @@ -39,9 +39,11 @@ export function Stepper(props: { }); const isNumberVariant = variant === 'numbers'; + const isDotsVariant = variant === 'dots'; const labelClassName = cn({ ['text-xs px-1.5 py-2']: !isNumberVariant, + ['hidden']: isDotsVariant, }); const { label, number } = getStepLabel(labelOrKey, index); @@ -70,9 +72,10 @@ export function Stepper(props: { return null; } - const containerClassName = cn({ + const containerClassName = cn('w-full', { ['flex justify-between']: variant === 'numbers', ['flex space-x-0.5']: variant === 'default', + ['flex space-x-2.5 self-center']: variant === 'dots', }); return ( @@ -89,6 +92,7 @@ function getClassNameBuilder() { default: `flex flex-col h-[2.5px] w-full transition-all duration-500`, numbers: 'w-9 h-9 font-bold rounded-full flex items-center justify-center text-sm border', + dots: 'w-2.5 h-2.5 rounded-full bg-muted transition-colors', }, selected: { true: '', @@ -138,6 +142,30 @@ function getClassNameBuilder() { selected: false, className: 'text-muted-foreground', }, + { + variant: 'dots', + selected: true, + complete: true, + className: 'bg-primary', + }, + { + variant: 'dots', + selected: false, + complete: true, + className: 'bg-primary', + }, + { + variant: 'dots', + selected: true, + complete: false, + className: 'bg-primary', + }, + { + variant: 'dots', + selected: false, + complete: false, + className: 'bg-muted', + } ], defaultVariants: { variant: 'default',