/ src / components / ui / form.tsx
form.tsx
  1  import * as React from "react";
  2  import * as LabelPrimitive from "@radix-ui/react-label";
  3  import { Slot } from "@radix-ui/react-slot";
  4  import {
  5    Controller,
  6    ControllerProps,
  7    FieldPath,
  8    FieldValues,
  9    FormProvider,
 10    useFormContext,
 11  } from "react-hook-form";
 12  
 13  import { cn } from "../../lib/utils";
 14  import { Label } from "./label";
 15  
 16  const Form = FormProvider;
 17  
 18  type FormFieldContextValue<
 19    TFieldValues extends FieldValues = FieldValues,
 20    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 21  > = {
 22    name: TName;
 23  };
 24  
 25  const FormFieldContext = React.createContext<FormFieldContextValue>(
 26    {} as FormFieldContextValue,
 27  );
 28  
 29  const FormField = <
 30    TFieldValues extends FieldValues = FieldValues,
 31    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 32  >({
 33    ...props
 34  }: ControllerProps<TFieldValues, TName>) => {
 35    return (
 36      <FormFieldContext.Provider value={{ name: props.name }}>
 37        <Controller {...props} />
 38      </FormFieldContext.Provider>
 39    );
 40  };
 41  
 42  const useFormField = () => {
 43    const fieldContext = React.useContext(FormFieldContext);
 44    const itemContext = React.useContext(FormItemContext);
 45    const { getFieldState, formState } = useFormContext();
 46  
 47    const fieldState = getFieldState(fieldContext.name, formState);
 48  
 49    if (!fieldContext) {
 50      throw new Error("useFormField should be used within <FormField>");
 51    }
 52  
 53    const { id } = itemContext;
 54  
 55    return {
 56      id,
 57      name: fieldContext.name,
 58      formItemId: `${id}-form-item`,
 59      formDescriptionId: `${id}-form-item-description`,
 60      formMessageId: `${id}-form-item-message`,
 61      ...fieldState,
 62    };
 63  };
 64  
 65  type FormItemContextValue = {
 66    id: string;
 67  };
 68  
 69  const FormItemContext = React.createContext<FormItemContextValue>(
 70    {} as FormItemContextValue,
 71  );
 72  
 73  const FormItem = React.forwardRef<
 74    HTMLDivElement,
 75    React.HTMLAttributes<HTMLDivElement>
 76  >(({ className, ...props }, ref) => {
 77    const id = React.useId();
 78  
 79    return (
 80      <FormItemContext.Provider value={{ id }}>
 81        <div ref={ref} className={cn("space-y-2", className)} {...props} />
 82      </FormItemContext.Provider>
 83    );
 84  });
 85  FormItem.displayName = "FormItem";
 86  
 87  const FormLabel = React.forwardRef<
 88    React.ElementRef<typeof LabelPrimitive.Root>,
 89    React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
 90  >(({ className, ...props }, ref) => {
 91    const { error, formItemId } = useFormField();
 92  
 93    return (
 94      <Label
 95        ref={ref}
 96        className={cn(error && "text-red-500 dark:text-red-900", className)}
 97        htmlFor={formItemId}
 98        {...props}
 99      />
100    );
101  });
102  FormLabel.displayName = "FormLabel";
103  
104  const FormControl = React.forwardRef<
105    React.ElementRef<typeof Slot>,
106    React.ComponentPropsWithoutRef<typeof Slot>
107  >(({ ...props }, ref) => {
108    const { error, formItemId, formDescriptionId, formMessageId } =
109      useFormField();
110  
111    return (
112      <Slot
113        ref={ref}
114        id={formItemId}
115        aria-describedby={
116          !error
117            ? `${formDescriptionId}`
118            : `${formDescriptionId} ${formMessageId}`
119        }
120        aria-invalid={!!error}
121        {...props}
122      />
123    );
124  });
125  FormControl.displayName = "FormControl";
126  
127  const FormDescription = React.forwardRef<
128    HTMLParagraphElement,
129    React.HTMLAttributes<HTMLParagraphElement>
130  >(({ className, ...props }, ref) => {
131    const { formDescriptionId } = useFormField();
132  
133    return (
134      <p
135        ref={ref}
136        id={formDescriptionId}
137        className={cn(
138          "text-[0.8rem] text-slate-500 dark:text-slate-400",
139          className,
140        )}
141        {...props}
142      />
143    );
144  });
145  FormDescription.displayName = "FormDescription";
146  
147  const FormMessage = React.forwardRef<
148    HTMLParagraphElement,
149    React.HTMLAttributes<HTMLParagraphElement>
150  >(({ className, children, ...props }, ref) => {
151    const { error, formMessageId } = useFormField();
152    const body = error ? String(error?.message) : children;
153  
154    if (!body) {
155      return null;
156    }
157  
158    return (
159      <p
160        ref={ref}
161        id={formMessageId}
162        className={cn(
163          "text-[0.8rem] font-medium text-red-500 dark:text-red-900",
164          className,
165        )}
166        {...props}
167      >
168        {body}
169      </p>
170    );
171  });
172  FormMessage.displayName = "FormMessage";
173  
174  export {
175    useFormField,
176    Form,
177    FormItem,
178    FormLabel,
179    FormControl,
180    FormDescription,
181    FormMessage,
182    FormField,
183  };