{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "survey-dialog", "title": "Survey Dialog", "description": "A multi-step form dialog with forward/back navigation, per-step validation, and a progress bar.", "dependencies": [ "react-hook-form", "@hookform/resolvers", "zod", "lucide-react" ], "registryDependencies": [ "dialog", "button", "progress" ], "files": [ { "path": "registry/custom-ui/survey-dialog.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useForm, FormProvider, type FieldValues } from \"react-hook-form\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { z } from \"zod\";\nimport { Loader2Icon } from \"lucide-react\";\n\nimport {\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogFooter,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { cn } from \"@/lib/utils\";\n\n// Context for step components to trigger navigation\ninterface SurveyContextValue {\n goToNextStep: () => Promise;\n goBack: () => void;\n direction: \"forward\" | \"back\";\n isFirst: boolean;\n}\n\nconst SurveyContext = React.createContext(null);\n\nexport function useSurvey() {\n const context = React.useContext(SurveyContext);\n if (!context) {\n throw new Error(\"useSurvey must be used within a SurveyDialog\");\n }\n return context;\n}\n\nexport interface SurveyStep {\n title: string;\n fields: string[];\n render: () => React.ReactNode;\n hideFooter?: boolean;\n}\n\nexport interface SurveyDialogProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n title: string;\n steps: SurveyStep[];\n schema: z.ZodType;\n defaultValues: Partial;\n onComplete: (data: TOutput) => Promise;\n /** Merged onto `DialogContent` — use to override width/height per project. */\n className?: string;\n}\n\nexport function SurveyDialog({\n open,\n onOpenChange,\n title,\n steps,\n schema,\n defaultValues,\n onComplete,\n className,\n}: SurveyDialogProps) {\n const [step, setStep] = React.useState(0);\n const [direction, setDirection] = React.useState<\"forward\" | \"back\">(\n \"forward\",\n );\n const [isSubmitting, setIsSubmitting] = React.useState(false);\n const [error, setError] = React.useState(null);\n\n // Type assertions needed due to complex zodResolver generic constraints.\n // Type safety is maintained at the call site via TOutput inference.\n const methods = useForm({\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n resolver: zodResolver(schema as any) as any,\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n defaultValues: defaultValues as any,\n mode: \"onChange\",\n });\n\n const currentStep = steps[step];\n const isFirst = step === 0;\n const isLast = step === steps.length - 1;\n\n // Reset on close\n React.useEffect(() => {\n if (!open) {\n setStep(0);\n setDirection(\"forward\");\n setError(null);\n methods.reset();\n }\n }, [open, methods]);\n\n // Advance to next step (validates current step's fields first)\n const goToNextStep = React.useCallback(async () => {\n if (isLast) return;\n // Type assertion needed as step.fields are runtime strings, not typed paths\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const valid = await methods.trigger(steps[step].fields as any);\n if (valid) {\n setDirection(\"forward\");\n setStep((s) => s + 1);\n }\n }, [isLast, step, steps, methods]);\n\n // For the footer Next button\n async function handleNext() {\n await goToNextStep();\n }\n\n const goBack = React.useCallback(() => {\n setDirection(\"back\");\n setStep((s) => s - 1);\n }, []);\n\n const surveyContextValue = React.useMemo(\n () => ({ goToNextStep, goBack, direction, isFirst }),\n [goToNextStep, goBack, direction, isFirst],\n );\n\n async function handleSubmit() {\n setIsSubmitting(true);\n setError(null);\n try {\n const data = methods.getValues();\n await onComplete(data);\n onOpenChange(false);\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Submission failed\");\n } finally {\n setIsSubmitting(false);\n }\n }\n\n const progressValue = ((step + 1) / steps.length) * 100;\n\n return (\n \n \n {/* Fixed header */}\n \n {title}\n \n Step {step + 1} of {steps.length}: {currentStep.title}\n \n \n\n {/* Step progress */}\n \n\n {/* Scrollable content area — full-bleed scrollbar, padded content */}\n \n \n
\n \n {currentStep.render()}\n
\n \n
\n
\n\n {/* Error message */}\n {error &&

{error}

}\n\n {/* Fixed footer - hidden if step has hideFooter */}\n {!currentStep.hideFooter && (\n \n
\n {!isFirst && (\n \n Back\n \n )}\n
\n
\n {isLast ? (\n \n {isSubmitting && (\n \n )}\n Submit\n \n ) : (\n \n )}\n
\n
\n )}\n \n
\n );\n}\n", "type": "registry:ui", "target": "components/custom-ui/survey-dialog.tsx" } ], "type": "registry:ui" }