diff --git a/package.json b/package.json index 6585664..cf9b653 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^4.1.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3", @@ -32,6 +33,7 @@ "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "rehype-autolink-headings": "^7.1.0", "rehype-pretty-code": "^0.14.0", "rehype-slug": "^6.0.0", @@ -40,7 +42,8 @@ "shiki": "^2.4.2", "sonner": "^2.0.0", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.2" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e8b700..54213a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^4.1.0 + version: 4.1.0(react-hook-form@7.54.2(react@19.0.0)) '@radix-ui/react-accordion': specifier: ^1.2.3 version: 1.2.3(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -77,6 +80,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@19.0.0) rehype-autolink-headings: specifier: ^7.1.0 version: 7.1.0 @@ -104,6 +110,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.17) + zod: + specifier: ^3.24.2 + version: 3.24.2 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -421,6 +430,11 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@hookform/resolvers@4.1.0': + resolution: {integrity: sha512-fX/uHKb+OOCpACLc6enuTQsf0ZpRrKbeBBPETg5PCPLCIYV6osP2Bw6ezuclM61lH+wBF9eXcuC0+BFh9XOEnQ==} + peerDependencies: + react-hook-form: ^7.0.0 + '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2057,6 +2071,12 @@ packages: peerDependencies: react: ^19.0.0 + react-hook-form@7.54.2: + resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2733,6 +2753,11 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@hookform/resolvers@4.1.0(react-hook-form@7.54.2(react@19.0.0))': + dependencies: + caniuse-lite: 1.0.30001699 + react-hook-form: 7.54.2(react@19.0.0) + '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.0.4 @@ -4642,6 +4667,10 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-hook-form@7.54.2(react@19.0.0): + dependencies: + react: 19.0.0 + react-remove-scroll-bar@2.3.8(@types/react@19.0.9)(react@19.0.0): dependencies: react: 19.0.0 diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..ce264ae --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +