From 94199fa7752557bf92e555eba0b9b5cb547fdd4a Mon Sep 17 00:00:00 2001 From: gbuomprisco Date: Tue, 9 Jul 2024 22:09:22 +0800 Subject: [PATCH] Add multi-step form and dot variant for stepper component This update includes the addition of a new multi-step form component for handling complex, multi-part forms. Alongside this, a dot variant for the stepper component has been introduced to provide increased UI/UX flexibility. The new multi-step form component includes validation, navigation between steps and conditional rendering for steps. The modification improves overall user experience in completing multistep forms and enhances the versatility of the stepper component. --- packages/ui/package.json | 3 +- packages/ui/src/makerkit/multi-step-form.tsx | 422 +++++++++++++++++++ packages/ui/src/makerkit/stepper.tsx | 32 +- 3 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/makerkit/multi-step-form.tsx 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',