diff --git a/src/renderer/components/ui/form.tsx b/src/renderer/components/ui/form.tsx new file mode 100644 index 0000000..ca5791b --- /dev/null +++ b/src/renderer/components/ui/form.tsx @@ -0,0 +1,153 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" + +const Form = React.forwardRef< + HTMLFormElement, + React.ComponentProps<"form"> +>(({ className, ...props }, ref) => { + const methods = useFormContext() + return ( +
{})} {...props} /> + ) +}) +Form.displayName = "Form" + +// ============================================================ +// FormFieldContext - provides field-level context +// ============================================================ + +const FormFieldContext = React.createContext< + ControllerProps +>({} as never) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + const { name } = props + + return ( + }> + + + ) +} + +// ============================================================ +// useFormField - must be used inside a FormField +// ============================================================ + +function useFormField(): ControllerProps { + const fieldContext = React.useContext(FormFieldContext) + if (!fieldContext) { + throw new Error("useFormField must be used within a FormField") + } + return fieldContext +} + +// ============================================================ +// FormItem - wraps a labeled form control +// ============================================================ + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +FormItem.displayName = "FormItem" + +// ============================================================ +// FormLabel +// ============================================================ + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +FormLabel.displayName = LabelPrimitive.Root.displayName + +// ============================================================ +// FormControl - Slot bridge +// ============================================================ + +const FormControl = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => ( + +)) +FormControl.displayName = "FormControl" + +// ============================================================ +// FormDescription +// ============================================================ + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +FormDescription.displayName = "FormDescription" + +// ============================================================ +// FormMessage +// ============================================================ + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { error } = useFormField() + const body = error ? String(error.message ?? "") : children + if (!body) return null + return ( +

+ {body} +

+ ) +}) +FormMessage.displayName = "FormMessage" + +export { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + useFormField, +} diff --git a/tests/component/ui/form.test.tsx b/tests/component/ui/form.test.tsx new file mode 100644 index 0000000..952e32b --- /dev/null +++ b/tests/component/ui/form.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; + +const schema = z.object({ name: z.string().min(2) }); + +function Harness() { + const form = useForm<{ name: string }>({ + resolver: zodResolver(schema), + defaultValues: { name: '' }, + }); + return ( + + + ( + + Name + + + + + + )} + /> + + + ); +} + +describe('Form', () => { + it('renders a labeled form field', () => { + render(); + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file