{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"A lightweight, composable Form component with animated error messages and full accessibility support.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Check } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type React from \"react\";\nimport {\n  cloneElement,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  DURATION_INSTANT,\n  SPRING_DEFAULT,\n  SPRING_SNAPPY,\n} from \"@/components/smoothui/lib/animation\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst STAGGER_DELAY = 0.04;\nconst SHAKE_KEYFRAMES = [0, -6, 5, -4, 3, -1, 0];\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type FormErrors = Record<string, string | undefined>;\n\nexport interface FormProps extends React.ComponentProps<\"form\"> {\n  /** Form contents */\n  children: React.ReactNode;\n  /** Optional CSS class */\n  className?: string;\n  /** External errors object (e.g. from react-hook-form's `formState.errors`) */\n  errors?: FormErrors;\n  /** Callback invoked on native form submit with current errors map */\n  onFormSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;\n}\n\nexport interface FormFieldProps {\n  /** Field contents (label, input, message) */\n  children: React.ReactNode;\n  /** Optional CSS class for the field wrapper */\n  className?: string;\n  /** Unique field name — used to look up errors */\n  name: string;\n}\n\nexport interface FormLabelProps extends React.ComponentProps<\"label\"> {\n  /** Label text */\n  children: React.ReactNode;\n  /** Optional CSS class */\n  className?: string;\n}\n\nexport interface FormMessageProps {\n  /** Override the error message (otherwise pulled from FormField context) */\n  children?: React.ReactNode;\n  /** Optional CSS class */\n  className?: string;\n}\n\nexport interface FormDescriptionProps extends React.ComponentProps<\"p\"> {\n  /** Description text */\n  children: React.ReactNode;\n  /** Optional CSS class */\n  className?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Context\n// ---------------------------------------------------------------------------\n\ninterface FormContextValue {\n  errors: FormErrors;\n  prevErrors: FormErrors;\n  submitCount: number;\n}\n\ninterface FormFieldContextValue {\n  error: string | undefined;\n  fieldIndex: number;\n  formDescriptionId: string;\n  formItemId: string;\n  formMessageId: string;\n  id: string;\n  name: string;\n  prevError: string | undefined;\n  submitCount: number;\n}\n\nconst FormContext = createContext<FormContextValue>({\n  errors: {},\n  prevErrors: {},\n  submitCount: 0,\n});\nconst FormFieldContext = createContext<FormFieldContextValue | null>(null);\n\nconst useFormCtx = () => useContext(FormContext);\n\nconst useFormFieldCtx = () => {\n  const ctx = useContext(FormFieldContext);\n  if (!ctx) {\n    throw new Error(\"FormLabel / FormMessage must be used inside <FormField>\");\n  }\n  return ctx;\n};\n\n// ---------------------------------------------------------------------------\n// Form\n// ---------------------------------------------------------------------------\n\nexport default function Form({\n  errors = {},\n  onFormSubmit,\n  className,\n  children,\n  ...props\n}: FormProps) {\n  const [submitCount, setSubmitCount] = useState(0);\n  const prevErrorsRef = useRef<FormErrors>({});\n  const [prevErrors, setPrevErrors] = useState<FormErrors>({});\n\n  const ctxValue = useMemo(\n    () => ({ errors, prevErrors, submitCount }),\n    [errors, submitCount, prevErrors]\n  );\n\n  const handleSubmit = useCallback(\n    (e: React.FormEvent<HTMLFormElement>) => {\n      setPrevErrors(prevErrorsRef.current);\n      prevErrorsRef.current = errors;\n      setSubmitCount((c) => c + 1);\n      if (onFormSubmit) {\n        onFormSubmit(e);\n      }\n    },\n    [onFormSubmit, errors]\n  );\n\n  return (\n    <FormContext.Provider value={ctxValue}>\n      <form\n        className={cn(\"grid gap-3\", className)}\n        noValidate\n        onSubmit={handleSubmit}\n        {...props}\n      >\n        {children}\n      </form>\n    </FormContext.Provider>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// FormField — staggered entrance + validation shake\n// ---------------------------------------------------------------------------\n\nlet fieldCounter = 0;\n\nexport function FormField({ name, className, children }: FormFieldProps) {\n  const { errors, submitCount, prevErrors } = useFormCtx();\n  const id = useId();\n  const error = errors[name];\n  const prevError = prevErrors[name];\n\n  // Stable field index for stagger animation\n  const fieldIndexRef = useRef<number | null>(null);\n  if (fieldIndexRef.current === null) {\n    fieldIndexRef.current = fieldCounter;\n    fieldCounter += 1;\n  }\n\n  // Reset counter on unmount of the first field (index 0)\n  useEffect(\n    () => () => {\n      if (fieldIndexRef.current === 0) {\n        fieldCounter = 0;\n      }\n    },\n    []\n  );\n\n  const ctxValue = useMemo(\n    () => ({\n      error,\n      fieldIndex: fieldIndexRef.current ?? 0,\n      formDescriptionId: `${id}-form-item-description`,\n      formItemId: `${id}-form-item`,\n      formMessageId: `${id}-form-item-message`,\n      id,\n      name,\n      prevError,\n      submitCount,\n    }),\n    [name, id, error, submitCount, prevError]\n  );\n\n  return (\n    <FormFieldContext.Provider value={ctxValue}>\n      <FormFieldInner className={className}>{children}</FormFieldInner>\n    </FormFieldContext.Provider>\n  );\n}\n\n/** Inner component that can consume FormFieldContext */\nfunction FormFieldInner({\n  className,\n  children,\n}: {\n  className?: string;\n  children: React.ReactNode;\n}) {\n  const shouldReduceMotion = useReducedMotion();\n  const { error, fieldIndex, submitCount, prevError } = useFormFieldCtx();\n\n  // Shake when a new error appears on submit\n  const shouldShake = error && submitCount > 0;\n  const [shakeKey, setShakeKey] = useState(0);\n\n  useEffect(() => {\n    if (shouldShake) {\n      setShakeKey((k) => k + 1);\n    }\n  }, [shouldShake, submitCount]);\n\n  return (\n    <motion.div\n      animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n      className={cn(\"grid gap-1.5\", className)}\n      data-slot=\"form-field\"\n      initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 8 }}\n      transition={\n        shouldReduceMotion\n          ? DURATION_INSTANT\n          : {\n              ...SPRING_DEFAULT,\n              delay: fieldIndex * STAGGER_DELAY,\n            }\n      }\n    >\n      <motion.div\n        animate={\n          shouldShake && !shouldReduceMotion ? { x: SHAKE_KEYFRAMES } : { x: 0 }\n        }\n        className=\"grid gap-1.5\"\n        key={shakeKey}\n        transition={\n          shouldReduceMotion\n            ? DURATION_INSTANT\n            : { duration: 0.4, ease: [0.36, 0.07, 0.19, 0.97] }\n        }\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// FormLabel\n// ---------------------------------------------------------------------------\n\nexport function FormLabel({ className, children, ...props }: FormLabelProps) {\n  const { formItemId, error } = useFormFieldCtx();\n\n  return (\n    <label\n      className={cn(\n        \"font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n        error && \"text-destructive\",\n        className\n      )}\n      data-slot=\"form-label\"\n      htmlFor={formItemId}\n      {...props}\n    >\n      {children}\n    </label>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// FormControl — renders a wrapper with animated focus ring\n// ---------------------------------------------------------------------------\n\nexport function FormControl({\n  children,\n  className,\n}: {\n  children: React.ReactNode;\n  className?: string;\n}) {\n  const shouldReduceMotion = useReducedMotion();\n  const { formItemId, formDescriptionId, formMessageId, error } =\n    useFormFieldCtx();\n  const [isFocused, setIsFocused] = useState(false);\n\n  return (\n    <motion.div\n      animate={\n        shouldReduceMotion\n          ? {}\n          : {\n              boxShadow: isFocused\n                ? \"0 0 0 3px hsl(var(--ring) / 0.3)\"\n                : \"0 0 0 0px hsl(var(--ring) / 0)\",\n            }\n      }\n      className={cn(\"rounded-md\", className)}\n      data-slot=\"form-control\"\n      onBlur={() => setIsFocused(false)}\n      onFocus={() => setIsFocused(true)}\n      transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_SNAPPY}\n    >\n      {cloneChildWithA11y(children, {\n        \"aria-describedby\": error\n          ? `${formDescriptionId} ${formMessageId}`\n          : formDescriptionId,\n        \"aria-invalid\": error ? true : undefined,\n        id: formItemId,\n      })}\n    </motion.div>\n  );\n}\n\nfunction cloneChildWithA11y(\n  children: React.ReactNode,\n  a11yProps: Record<string, unknown>\n): React.ReactNode {\n  const child = Array.isArray(children) ? children[0] : children;\n  if (child && typeof child === \"object\" && \"type\" in child) {\n    const element = child as React.ReactElement<Record<string, unknown>>;\n    // biome-ignore lint/suspicious/noExplicitAny: cloneElement requires flexible typing\n    return cloneElement(element as any, a11yProps);\n  }\n  return children;\n}\n\n// ---------------------------------------------------------------------------\n// FormDescription\n// ---------------------------------------------------------------------------\n\nexport function FormDescription({\n  className,\n  children,\n  ...props\n}: FormDescriptionProps) {\n  const { formDescriptionId } = useFormFieldCtx();\n\n  return (\n    <p\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      data-slot=\"form-description\"\n      id={formDescriptionId}\n      {...props}\n    >\n      {children}\n    </p>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// FormMessage — animated error message with success state\n// ---------------------------------------------------------------------------\n\nexport function FormMessage({ className, children }: FormMessageProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const { error, formMessageId, submitCount, prevError } = useFormFieldCtx();\n\n  const body = children ?? error;\n\n  // Show success checkmark when error was just cleared after a submit\n  const wasError = prevError && !error && submitCount > 0;\n\n  return (\n    <div>\n      <AnimatePresence mode=\"wait\">\n        {body ? (\n          <motion.p\n            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n            className={cn(\"text-destructive text-sm\", className)}\n            data-slot=\"form-message\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, y: -4 }\n            }\n            id={formMessageId}\n            initial={\n              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -4 }\n            }\n            key={typeof body === \"string\" ? body : \"message\"}\n            role=\"alert\"\n            transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT}\n          >\n            {body}\n          </motion.p>\n        ) : wasError ? (\n          <motion.div\n            animate={\n              shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }\n            }\n            className=\"flex items-center gap-1 text-sm\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, scale: 0.9 }\n            }\n            initial={\n              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }\n            }\n            key=\"success\"\n            transition={\n              shouldReduceMotion\n                ? DURATION_INSTANT\n                : {\n                    damping: 20,\n                    duration: 0.25,\n                    stiffness: 300,\n                    type: \"spring\" as const,\n                  }\n            }\n          >\n            <motion.span\n              animate={shouldReduceMotion ? {} : { scale: 1 }}\n              initial={shouldReduceMotion ? {} : { scale: 0 }}\n              transition={\n                shouldReduceMotion\n                  ? DURATION_INSTANT\n                  : {\n                      damping: 15,\n                      delay: 0.05,\n                      duration: 0.2,\n                      stiffness: 400,\n                      type: \"spring\" as const,\n                    }\n              }\n            >\n              <Check className=\"size-3.5 text-emerald-500\" />\n            </motion.span>\n            <span className=\"text-emerald-500\">Looks good</span>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/form/index.tsx","type":"registry:ui"}],"name":"form","registryDependencies":["https://smoothui.dev/r/lib.json"],"title":"Form","type":"registry:ui"}