import React, { Dispatch, SetStateAction, useRef, useEffect } from "react"; import { Slot } from "@radix-ui/react-slot"; import { z, ZodError, ZodIssue, ZodIssueCode } from "zod"; import styles from "./Form.module.css"; export function useForm>({ defaultValues = {}, schema, }: { defaultValues?: Partial>; schema: T; }): { values: z.infer; defaultValues: Partial>; setValues: Dispatch>>; errors: FormErrors; validateField: (path: string, options?: { shallow?: boolean }) => void; validateForm: () => boolean; setFieldError: (path: string, errorMessage: string) => void; handleSubmit: ( onSubmit: (data: z.infer) => void, ) => (e: React.FormEvent) => void; } { const [values, setValues] = React.useState>( defaultValues as Record, ); const [errors, setErrors] = React.useState({}); const setFieldError = React.useCallback( (path: string, errorMessage: string) => { setErrors((prev) => { return setValueByPath(prev, path, errorMessage); }); }, [], ); const isExtraPropertyError = (issue: ZodIssue): boolean => { return issue.code === ZodIssueCode.unrecognized_keys; }; const validateForm = React.useCallback((): boolean => { if (!schema) return true; try { makeStrict(schema).parse(values); setErrors({}); return true; } catch (err) { if (err instanceof ZodError) { const extraPropertyErrors = err.errors.filter(isExtraPropertyError); const validationErrors = err.errors.filter( (issue) => !isExtraPropertyError(issue), ); if (extraPropertyErrors.length > 0) { throw new Error( "Extra properties detected in form values:" + extraPropertyErrors.map((e) => e.message).join(", ") + ". Either update the schema or remove these values", ); } const tree: any = {}; validationErrors.forEach((issue: ZodIssue) => { const path = issue.path.join("."); const errorMessage = typeof issue.message === "string" ? issue.message : String(issue.message); Object.assign(tree, {}); const tmp = setValueByPath(tree, path, errorMessage); Object.assign(tree, tmp); }); setErrors(tree); if (validationErrors.length === 0 && extraPropertyErrors.length > 0) { throw new Error( `Form contains extra properties: ${extraPropertyErrors .map((e) => e.message) .join(", ")}`, ); } } console.error("Form validation failed", err); return false; } }, [schema, values]); const validateField = React.useCallback( (path: string, options?: { shallow?: boolean }) => { if (!schema) return; const result = schema.safeParse(values); setErrors((prev: FormErrors) => { let next = { ...prev }; if (options?.shallow) { if (getValueByPath(next, path) !== undefined) { next = deleteValueByPath(next, path, { shallow: true }); } } else { next = deleteValueByPath(next, path); } if (!result.success) { result.error.errors.forEach((issue) => { const ip = issue.path.join("."); const shouldIncludeError = options?.shallow ? ip === path : ip === path || ip.startsWith(path + "."); if (shouldIncludeError) { const errorMessage = typeof issue.message === "string" ? issue.message : String(issue.message); next = setValueByPath(next, ip, errorMessage); } }); } return next; }); }, [schema, values], ); type Data = z.infer; const handleSubmit = React.useCallback( (onSubmit: (data: Data) => void) => { return (e: React.FormEvent) => { e.preventDefault(); if (validateForm()) { onSubmit(values as Data); } }; }, [validateForm, values], ); const oldValueRef = useRef(values); useEffect(() => { const diffKeys: string[] = []; for (const key of Object.keys(values)) { if ( values[key] !== oldValueRef.current[key] && Array.isArray(values[key]) && Array.isArray(oldValueRef.current[key]) && values[key].length !== oldValueRef.current[key].length ) { diffKeys.push(key); } } oldValueRef.current = values; for (const k of diffKeys) { validateField(k, { shallow: true }); } }, [values, validateField]); return { values, errors, setValues, validateField, validateForm, setFieldError, handleSubmit, defaultValues, }; } export function Form( props: { children: React.ReactNode; } & FormContextValue, ) { const { children, values, errors, setValues, validateField, validateForm, setFieldError, } = props; return ( {children} ); } export const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes & { name: string } >(({ name, className, ...props }, ref) => { const id = React.useId(); return (
); }); FormItem.displayName = "FormItem"; export const FormLabel = React.forwardRef< HTMLLabelElement, React.LabelHTMLAttributes >(({ className, ...props }, ref) => { const { formItemId, error } = useFormField(); return (