diff --git a/client/package-lock.json b/client/package-lock.json index fdaf9b22..88193fb1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "client", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", @@ -27,9 +28,11 @@ "react": "19.1.2", "react-dom": "19.1.2", "react-google-recaptcha": "^3.1.0", + "react-hook-form": "^7.67.0", "react-icons": "^5.5.0", "tailwind-merge": "^3.3.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^4.1.13" }, "devDependencies": { "@csstools/postcss-oklab-function": "^4.0.10", @@ -459,6 +462,18 @@ "version": "0.2.10", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -1946,6 +1961,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "license": "Apache-2.0", @@ -5798,6 +5819,22 @@ "react": ">=16.4.1" } }, + "node_modules/react-hook-form": { + "version": "7.67.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.67.0.tgz", + "integrity": "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.5.0", "license": "MIT", @@ -7142,6 +7179,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/client/package.json b/client/package.json index 430127d5..0afd0808 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "prepare": "cd .. && husky client/.husky" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", @@ -34,9 +35,11 @@ "react": "19.1.2", "react-dom": "19.1.2", "react-google-recaptcha": "^3.1.0", + "react-hook-form": "^7.67.0", "react-icons": "^5.5.0", "tailwind-merge": "^3.3.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zod": "^4.1.13" }, "devDependencies": { "@csstools/postcss-oklab-function": "^4.0.10", diff --git a/client/src/app/test/form/page.tsx b/client/src/app/test/form/page.tsx new file mode 100644 index 00000000..99832f32 --- /dev/null +++ b/client/src/app/test/form/page.tsx @@ -0,0 +1,265 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import React from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import InputField from "@/components/input"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +const FREQUENCIES = [ + { label: "Daily", value: "daily" }, + { label: "Weekly", value: "weekly" }, + { label: "Monthly", value: "monthly" }, +]; + +const AMENITIES = [ + "Audio", + "Video", + "White Board", + "HDMI", + "Projector", + "Speaker Phone", +]; + +const schema = z.object({ + firstname: z.string().min(1, "Firstname is required"), + lastname: z.string().min(1, "Lastname is required"), + middlename: z.string().optional(), + age: z + .string() + .min(1, "Age is required") + .regex(/^\d+$/, { message: "Age must be a non-negative integer" }) + .refine( + (val) => { + const num = Number(val); + return num >= 1 && num <= 120; + }, + { + message: "Age must be between 1 and 120", + }, + ), + frequency: z.enum(["daily", "weekly", "monthly"], { + error: () => ({ message: "Invalid frequency" }), + }), + amenities: z + .array( + z.enum(AMENITIES, { error: () => ({ message: "Invalid amenities" }) }), + ) + .optional(), + recurrenceDate: z.enum(["Monday", "Tuesday", "Wednesday"], { + error: () => ({ message: "Invalid recurrence date" }), + }), +}); + +export default function TestFormPage() { + const form = useForm>({ + resolver: zodResolver(schema), + mode: "onChange", + }); + + return ( +
+
{ + alert("submitted data:\n" + JSON.stringify(data)); + })} + > +
+ ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> +
+ + ( + + + + + + + )} + /> + + {/* Frequency */} + ( + + + + + + + )} + /> + + {/* Amenities (badges / multiselect) */} + ( + + + + + + + )} + /> + + {/* Recurrence Date (Radio Group) */} + ( + + + Recurrence Date * + + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ )} + /> + + {/* Buttons */} +
+ + +
+ +
+ ); +} diff --git a/client/src/components/ui/form.tsx b/client/src/components/ui/form.tsx new file mode 100644 index 00000000..c9fb7d61 --- /dev/null +++ b/client/src/components/ui/form.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Slot } from "@radix-ui/react-slot"; +import React from "react"; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, + type UseFormReturn, + useFormState, +} from "react-hook-form"; + +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +/** + * Helper: create context to share name within each field + */ +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +type FormItemContextValue = { + id: string; +}; + +/** + * Helper: create context to share id within each field + */ +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +type FormProps = { + form: UseFormReturn; +} & React.ComponentProps<"form">; + +/** + * Write `
` to use the form component to group a form + * Use the form props to pass zod schema, set default values, etc. + * ``` + * const form = useForm>({ + * resolver: zodResolver(schema), // pass zod schema + * mode: "onChange", // validate the form each time values change + * }); + * ``` + */ +const FormComponent = React.forwardRef>( + ({ form, className, children, ...props }, ref) => { + return ( + +
+ {children} +
+
+ ); + }, +); +FormComponent.displayName = "Form"; + +/** + * Wrapper to wrap each form field. Example: + * ``` + * ( + * + * + * + * + * + * + * )} + * ``` + */ +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +/** + * Custom hook to get states and other infos within a FormField + */ +const useFormCustom = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + 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, + }; +}; + +/** + * Wrapper to wrap each field's rendering components + */ +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); + }, +); +FormItem.displayName = "FormItem"; + +/** + * Optional if label is not included in input field + * If FormLabel is use, FormControl must be used to wrap the children to let label control the children + */ +const FormLabel = React.forwardRef< + HTMLLabelElement, + React.ComponentProps & { required?: boolean } +>(({ required = false, className, ...props }, ref) => { + const { error, formItemId } = useFormCustom(); + return ( +