diff --git a/packages/ui/package.json b/packages/ui/package.json index 3eaedd2..a7015ef 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -105,6 +105,7 @@ } }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@mdx-js/mdx": "^3.1.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.8", @@ -139,12 +140,14 @@ "input-otp": "^1.4.2", "lucide-react": "^0.468.0", "react-day-picker": "^9.13.0", + "react-hook-form": "^7.73.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.3.3", "react-syntax-highlighter": "^16.1.1", "sonner": "^1.7.4", "tailwind-merge": "^2.5.5", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zod": "^4.3.6" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", diff --git a/packages/ui/src/components/form/form.mdx b/packages/ui/src/components/form/form.mdx index d4203e1..70e51e2 100644 --- a/packages/ui/src/components/form/form.mdx +++ b/packages/ui/src/components/form/form.mdx @@ -5,7 +5,7 @@ import * as Stories from './form.stories' # Form -A lightweight validation wrapper for composing labels, descriptions, controls, and messages with consistent ARIA wiring. +A react-hook-form powered validation wrapper that keeps labels, descriptions, controls, and field errors in sync with accessible ARIA wiring. @@ -15,6 +15,12 @@ A lightweight validation wrapper for composing labels, descriptions, controls, a pnpm dlx shadcn@latest add https://ui.vllnt.com/r/form.json ``` +## Dependencies + +```bash +pnpm add react-hook-form @hookform/resolvers zod +``` + ## Import ```tsx @@ -22,6 +28,7 @@ import { Form, FormControl, FormDescription, + FormField, FormItem, FormLabel, FormMessage, @@ -31,25 +38,44 @@ import { ## Usage ```tsx -
- - Email - - - - Use your work email address. - Please enter a valid email. - +const schema = z.object({ + email: z.string().email(), +}) + + { + await save(values) + form.reset(values) + }} + schema={schema} +> + {(form) => ( + ( + + Email + + + + Use your work email address. + + + )} + /> + )} ``` -## Validation state +## Server-side errors -Set `invalid` on `Form` to add `aria-invalid`, append the message id to `aria-describedby`, and expose the message as an alert. +Call `form.setError()` in `onValidSubmit` when the API rejects a field. `FormMessage` automatically renders the latest field error. - + ## API Reference diff --git a/packages/ui/src/components/form/form.stories.tsx b/packages/ui/src/components/form/form.stories.tsx index ee6e306..64e8291 100644 --- a/packages/ui/src/components/form/form.stories.tsx +++ b/packages/ui/src/components/form/form.stories.tsx @@ -1,53 +1,117 @@ +import * as React from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { z } from "zod"; +import { Button } from "../button"; import { Input } from "../input"; import { Form, FormControl, FormDescription, + FormField, FormItem, FormLabel, FormMessage, } from "./form"; +const profileSchema = z.object({ + email: z.string().email("Enter a valid email address."), + name: z.string().min(2, "Enter at least 2 characters."), +}); + +type ProfileValues = z.infer; + +type ProfileFormExampleProps = { + serverError?: boolean; +}; + +function ProfileFormExample({ + serverError = false, +}: ProfileFormExampleProps) { + const [submitted, setSubmitted] = React.useState(null); + + return ( + + className="w-full max-w-md rounded-lg border border-border bg-card p-6" + defaultValues={{ email: "", name: "" }} + onValidSubmit={async (values, form) => { + setSubmitted(null); + await Promise.resolve(); + + if (serverError) { + form.setError("email", { + message: "This email is already in use.", + type: "server", + }); + return; + } + + setSubmitted(values); + }} + schema={profileSchema} + > + {(form) => ( + <> + ( + + Email + + + + + Use your work email address for notifications. + + + + )} + /> + ( + + Name + + + + + We will use this name in collaborator mentions. + + + + )} + /> +
+ + {submitted ? ( +

+ Submitted for {submitted.name}. +

+ ) : null} +
+ + )} + + ); +} + const meta = { - component: Form, + component: ProfileFormExample, title: "Core/Form", -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; -export const Default: Story = { - render: () => ( -
-
- - Email - - - - Use your work email address. - We will never share your email. - -
-
- ), -}; +export const Default: Story = {}; -export const Invalid: Story = { - render: () => ( -
-
- - Email - - - - This email will be used for account recovery. - Please enter a valid email address. - -
-
- ), +export const ServerError: Story = { + args: { + serverError: true, + }, }; diff --git a/packages/ui/src/components/form/form.test.tsx b/packages/ui/src/components/form/form.test.tsx index 210fa8c..bf11535 100644 --- a/packages/ui/src/components/form/form.test.tsx +++ b/packages/ui/src/components/form/form.test.tsx @@ -1,22 +1,36 @@ import * as React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; +import { useForm } from "react-hook-form"; import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { Button } from "../button"; import { Input } from "../input"; import { Form, FormControl, FormDescription, + FormField, FormItem, FormLabel, FormMessage, } from "./form"; +const emailSchema = z.object({ + email: z.email("Enter a valid email address."), +}); + +type EmailValues = z.infer; + +type NativeSubmitEvent = Parameters< + NonNullable["onSubmit"]> +>[0]; + describe("Form", () => { - it("renders a native form element and forwards props, ref, and submit", () => { + it("renders a native form element and forwards props, ref, and submit", async () => { const handleSubmit = vi.fn(); const ref = React.createRef(); const { container } = render( @@ -24,10 +38,7 @@ describe("Form", () => { className="custom-form" data-testid="login-form" name="login" - onSubmit={(event) => { - event.preventDefault(); - handleSubmit(event.currentTarget); - }} + onSubmit={handleSubmit} ref={ref} > @@ -53,12 +64,61 @@ describe("Form", () => { } fireEvent.submit(form); - expect(handleSubmit).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(handleSubmit).toHaveBeenCalledTimes(1); + }); + const submittedEvent = handleSubmit.mock.calls[0]?.[0] as + | NativeSubmitEvent + | undefined; + expect(submittedEvent).toBeTruthy(); + expect(submittedEvent?.target).toBe(form); + }); + + it("skips validated submission when the native submit handler prevents default", async () => { + const nativeSubmit = vi.fn((event: NativeSubmitEvent) => { + event.preventDefault(); + }); + const validSubmit = vi.fn(); + + render( + + defaultValues={{ email: "person@example.com" }} + onSubmit={nativeSubmit} + onValidSubmit={validSubmit} + schema={emailSchema} + > + {(form) => ( + <> + ( + + Email + + + + + + )} + /> + + + )} + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(nativeSubmit).toHaveBeenCalledTimes(1); + }); + expect(validSubmit).not.toHaveBeenCalled(); }); it("omits aria-describedby when no description or message is rendered", () => { render( -
+ Email @@ -74,7 +134,7 @@ describe("Form", () => { it("preserves caller aria-describedby when description and message are absent", () => { render( - + Email @@ -90,7 +150,7 @@ describe("Form", () => { it("does not link the message id when invalid but no FormMessage is rendered", () => { render( - + Email @@ -108,7 +168,7 @@ describe("Form", () => { it("does not render or link empty message content", () => { render( - + Email @@ -126,7 +186,7 @@ describe("Form", () => { it("does not link the description id when no FormDescription is rendered", () => { render( - + Email @@ -144,7 +204,7 @@ describe("Form", () => { it("renders description and message ids in server markup on the first pass", () => { const markup = renderToStaticMarkup( - + Email @@ -163,7 +223,7 @@ describe("Form", () => { it("ignores native id overrides that would break form associations", () => { render( - + Email @@ -192,31 +252,108 @@ describe("Form", () => { ); }); - it("wires the label to the generated control id", () => { + it("wires labels, descriptions, and validation errors through form context", async () => { + const handleSubmit = vi.fn(); + render( - - - Email - - - - + + defaultValues={{ email: "" }} + onValidSubmit={handleSubmit} + schema={emailSchema} + > + {(form) => ( + <> + ( + + Email + + + + + Use your work email address. + + + + )} + /> + + + )} , ); - const input = screen.getByRole("textbox"); + const input = screen.getByRole("textbox", { name: "Email" }); const label = screen.getByText("Email"); + const description = screen.getByText("Use your work email address."); expect(label).toHaveAttribute("for", input.id); + expect(input).toHaveAttribute("aria-describedby", description.id); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + const message = await screen.findByRole("alert"); + + expect(message).toHaveTextContent("Enter a valid email address."); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toHaveAttribute( + "aria-describedby", + `${description.id} ${message.id}`, + ); + expect(handleSubmit).not.toHaveBeenCalled(); }); - it("applies invalid aria wiring to the control and message", () => { + it("runs schema validation even without explicit submit callbacks", async () => { render( -
+ defaultValues={{ email: "" }} schema={emailSchema}> + {(form) => ( + <> + ( + + Email + + + + + Use your work email address. + + + + )} + /> + + + )} + , + ); + + const input = screen.getByRole("textbox", { name: "Email" }); + const description = screen.getByText("Use your work email address."); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + const message = await screen.findByRole("alert"); + + expect(message).toHaveTextContent("Enter a valid email address."); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toHaveAttribute( + "aria-describedby", + `${description.id} ${message.id}`, + ); + }); + + it("supports legacy invalid layouts without FormField context", () => { + render( +
Email - + Use your work email address. Please enter a valid email. @@ -224,21 +361,170 @@ describe("Form", () => { , ); - const input = screen.getByRole("textbox"); + const input = screen.getByRole("textbox", { name: "Email" }); const description = screen.getByText("Use your work email address."); - const message = screen.getByRole("alert"); + const message = screen.getByText("Please enter a valid email."); - expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute( "aria-describedby", `${description.id} ${message.id}`, ); - expect(message).toHaveTextContent("Please enter a valid email."); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(message).toHaveAttribute("role", "alert"); + }); + + it("supports server-side field errors via setError", async () => { + render( + + defaultValues={{ email: "person@example.com" }} + onValidSubmit={async (_values, form) => { + form.setError("email", { + message: "This email is already in use.", + type: "server", + }); + }} + schema={emailSchema} + > + {(form) => ( + <> + ( + + Email + + + + + We will send invitations here. + + + + )} + /> + + + )} + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "This email is already in use.", + ); + }); + + it("omits form message ids from aria-describedby when an error has no visible message", async () => { + function FormHarness() { + const form = useForm({ + defaultValues: { email: "person@example.com" }, + }); + + return ( +
+ ( + + Email + + + + + We will send invitations here. + + + + )} + /> + + + ); + } + + render(); + + const input = screen.getByRole("textbox", { name: "Email" }); + const description = screen.getByText("We will send invitations here."); + + fireEvent.click( + screen.getByRole("button", { name: "Trigger silent error" }), + ); + + await waitFor(() => { + expect(input).toHaveAttribute("aria-describedby", description.id); + expect(screen.queryByRole("alert")).toBeNull(); + }); + }); + + it("exposes submitting state to children and disables controls while pending", async () => { + let resolveSubmit: (() => void) | undefined; + const handlePendingSubmit = () => + new Promise((resolve) => { + resolveSubmit = resolve; + }); + + render( + + defaultValues={{ email: "person@example.com" }} + onValidSubmit={handlePendingSubmit} + schema={emailSchema} + > + {(form) => ( + <> + ( + + Email + + + + + + )} + /> + + + )} + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled(); + expect(screen.getByRole("textbox", { name: "Email" })).toBeDisabled(); + }); + + if (resolveSubmit === undefined) { + throw new Error("Expected submit promise resolver to be captured."); + } + + resolveSubmit(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled(); + expect(screen.getByRole("textbox", { name: "Email" })).toBeEnabled(); + }); }); it("propagates disabled and required state to native controls", () => { render( -
+ Email @@ -258,7 +544,7 @@ describe("Form", () => { it("preserves control-level disabled and required props when the form is not flagged", () => { render( - + Email @@ -278,7 +564,7 @@ describe("Form", () => { it("allows a form item to override invalid state independently", () => { render( - + Primary email @@ -314,7 +600,7 @@ describe("Form", () => { it("links wrapped description and message content", () => { render( - + Email @@ -343,7 +629,7 @@ describe("Form", () => { it("supports fragment-wrapped helper content", () => { render( - + Email @@ -370,7 +656,7 @@ describe("Form", () => { it("keeps helper text in aria-describedby without linking a valid message", () => { render( - + Email @@ -400,7 +686,7 @@ describe("Form", () => { it("creates unique aria wiring for each form item", () => { render( - + First name @@ -424,13 +710,12 @@ describe("Form", () => { const lastInput = screen.getByRole("textbox", { name: "Last name" }); const firstDescription = screen.getByText("Given name"); const lastDescription = screen.getByText("Family name"); - const [firstMessage, secondMessage] = screen.getAllByText("Required"); - - expect(firstMessage).toBeDefined(); - expect(secondMessage).toBeDefined(); + const messages = screen.getAllByText("Required"); + expect(messages).toHaveLength(2); + const [firstMessage, secondMessage] = messages; if (!firstMessage || !secondMessage) { - throw new Error("Expected both required messages to be rendered."); + throw new Error("Expected two message elements."); } expect(firstInput.id).not.toBe(lastInput.id); @@ -446,6 +731,7 @@ describe("Form", () => { controlId="field" descriptionId="field-description" messageId="field-message" + onSubmit={vi.fn()} > Primary email @@ -470,13 +756,12 @@ describe("Form", () => { const backupInput = screen.getByRole("textbox", { name: "Backup email" }); const primaryDescription = screen.getByText("Primary contact"); const backupDescription = screen.getByText("Secondary contact"); - const [primaryMessage, backupMessage] = screen.getAllByText("Required"); - - expect(primaryMessage).toBeDefined(); - expect(backupMessage).toBeDefined(); + const messages = screen.getAllByText("Required"); + expect(messages).toHaveLength(2); + const [primaryMessage, backupMessage] = messages; if (!primaryMessage || !backupMessage) { - throw new Error("Expected both required messages to be rendered."); + throw new Error("Expected two message elements."); } expect(primaryInput.id).toMatch(/^field-control-/); @@ -491,7 +776,7 @@ describe("Form", () => { it("keeps partial custom id overrides scoped to their role", () => { render( - + Email diff --git a/packages/ui/src/components/form/form.tsx b/packages/ui/src/components/form/form.tsx index e573939..c2d62a8 100644 --- a/packages/ui/src/components/form/form.tsx +++ b/packages/ui/src/components/form/form.tsx @@ -2,11 +2,83 @@ import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + type ControllerProps, + type DefaultValues, + type FieldPath, + type FieldValues, + FormProvider, + type Resolver, + type SubmitErrorHandler, + useForm, + useFormContext, + type UseFormReturn, +} from "react-hook-form"; import { cn } from "../../lib/utils"; import { Label } from "../label"; +type FormInstance = UseFormReturn< + TFieldValues, + unknown, + TFieldValues +>; + +type FormRenderChildren = + | ((form: FormInstance) => React.ReactNode) + | React.ReactNode; + +type FormSubmitHandler = ( + values: TFieldValues, + form: FormInstance, +) => Promise | void; + +type FormErrorHandler = ( + errors: Parameters>[0], + form: FormInstance, +) => Promise | void; + +type BaseFormProps = Omit< + React.ComponentPropsWithoutRef<"form">, + "children" +> & { + children?: FormRenderChildren; + controlId?: string; + descriptionId?: string; + disabled?: boolean; + invalid?: boolean; + messageId?: string; + onError?: FormErrorHandler; + onValidSubmit?: FormSubmitHandler; + required?: boolean; +}; + +type ManagedFormProps = { + defaultValues?: DefaultValues; + form?: undefined; + resolver?: Resolver; + schema?: Parameters[0]; + values?: TFieldValues; +}; + +type ProvidedFormProps = { + defaultValues?: never; + form: FormInstance; + resolver?: never; + schema?: never; + values?: never; +}; + +export type FormProps = + BaseFormProps & + (ManagedFormProps | ProvidedFormProps); + +type FormNativeSubmitHandler = + React.ComponentPropsWithoutRef<"form">["onSubmit"]; + type FormRootContextValue = { controlId?: string; descriptionId?: string; @@ -16,12 +88,18 @@ type FormRootContextValue = { required: boolean; }; +type FormFieldContextValue = { + name: string; +}; + type FormItemContextValue = { controlId: string; descriptionId: string; disabled: boolean; hasDescription: boolean; hasMessage: boolean; + hasMessageSlot: boolean; + id: string; invalid: boolean; messageId: string; required: boolean; @@ -31,6 +109,10 @@ const FormRootContext = React.createContext( undefined, ); +const FormFieldContext = React.createContext( + undefined, +); + const FormItemContext = React.createContext( undefined, ); @@ -117,6 +199,23 @@ function hasVisibleContent(children: React.ReactNode): boolean { }); } +function hasFormChild( + children: React.ReactNode, + name: "FormDescription" | "FormMessage", +): boolean { + return React.Children.toArray(children).some((child) => { + if (isNamedFormChild(child, name)) { + return true; + } + + if (React.isValidElement<{ children?: React.ReactNode }>(child)) { + return hasFormChild(child.props.children, name); + } + + return false; + }); +} + function hasRenderedFormChild( children: React.ReactNode, name: "FormDescription" | "FormMessage", @@ -136,55 +235,258 @@ function hasRenderedFormChild( }); } -export type FormProps = React.ComponentPropsWithoutRef<"form"> & { - controlId?: string; - descriptionId?: string; - disabled?: boolean; - invalid?: boolean; - messageId?: string; - required?: boolean; -}; +function createManagedSubmitHandler( + form: FormInstance, + options: { + onError?: FormErrorHandler; + onValidSubmit?: FormSubmitHandler; + shouldValidate: boolean; + }, +): ReturnType["handleSubmit"]> | undefined { + const { onError, onValidSubmit, shouldValidate } = options; -const Form = React.forwardRef( - ( - { - className, + if (!shouldValidate) { + return undefined; + } + + return form.handleSubmit( + async (submittedValues) => { + if (onValidSubmit !== undefined) { + await onValidSubmit(submittedValues, form); + } + }, + async (errors) => { + if (onError !== undefined) { + await onError(errors, form); + } + }, + ); +} + +function createSubmitHandler( + nativeSubmit: FormNativeSubmitHandler, + handleValidatedSubmit: + | ((event?: React.BaseSyntheticEvent) => Promise) + | undefined, +): FormNativeSubmitHandler { + return async (event) => { + nativeSubmit?.(event); + + if (handleValidatedSubmit && !event.defaultPrevented) { + await handleValidatedSubmit(event); + } + }; +} + +function useFormRootContextValue( + value: FormRootContextValue, +): FormRootContextValue { + const { controlId, descriptionId, disabled, invalid, messageId, required } = + value; + + return React.useMemo( + () => ({ controlId, descriptionId, - disabled = false, - invalid = false, + disabled, + invalid, messageId, - required = false, - ...props - }, - ref, - ) => { - const value = React.useMemo( - () => ({ - controlId, - descriptionId, - disabled, - invalid, - messageId, - required, - }), - [controlId, descriptionId, disabled, invalid, messageId, required], - ); + required, + }), + [controlId, descriptionId, disabled, invalid, messageId, required], + ); +} - return ( - +type FormMarkupProps = { + children: FormProps["children"]; + className?: string; + disabled: boolean; + form: FormInstance; + formProps: Omit< + React.ComponentPropsWithoutRef<"form">, + "children" | "className" | "onSubmit" + >; + formRef: React.ForwardedRef; + handleValidatedSubmit?: ReturnType< + FormInstance["handleSubmit"] + >; + invalid: boolean; + onSubmit: FormNativeSubmitHandler; + rootContextValue: FormRootContextValue; +}; + +function FormMarkup({ + children, + className, + disabled, + form, + formProps, + formRef, + handleValidatedSubmit, + invalid, + onSubmit, + rootContextValue, +}: FormMarkupProps) { + return ( + + - - ); - }, -); -Form.displayName = "Form"; + data-submitting={form.formState.isSubmitting ? "true" : undefined} + onSubmit={ + onSubmit === undefined && handleValidatedSubmit === undefined + ? undefined + : createSubmitHandler(onSubmit, handleValidatedSubmit) + } + ref={formRef} + {...formProps} + > + {typeof children === "function" ? children(form) : children} + + + + ); +} + +function FormInner( + { + children, + className, + controlId, + defaultValues, + descriptionId, + disabled = false, + form: providedForm, + invalid = false, + messageId, + onError, + onSubmit, + onValidSubmit, + required = false, + resolver, + schema, + values, + ...props + }: FormProps, + ref: React.ForwardedRef, +) { + const internalForm = useForm({ + defaultValues, + resolver: + schema === undefined + ? resolver + : (zodResolver(schema) as Resolver), + values, + }); + const form: FormInstance = providedForm ?? internalForm; + const isManagedForm = + providedForm !== undefined || + resolver !== undefined || + schema !== undefined; + const rootContextValue = useFormRootContextValue({ + controlId, + descriptionId, + disabled, + invalid, + messageId, + required, + }); + const handleValidatedSubmit = createManagedSubmitHandler(form, { + onError, + onValidSubmit, + shouldValidate: + isManagedForm || onValidSubmit !== undefined || onError !== undefined, + }); + + return ( + + {children} + + ); +} + +const FormBase = React.forwardRef(FormInner); +FormBase.displayName = "Form"; + +const Form = FormBase as ( + props: FormProps & React.RefAttributes, +) => React.ReactElement; + +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ ...props }: ControllerProps) { + const fieldContextValue = React.useMemo( + () => ({ name: props.name }), + [props.name], + ); + + return ( + + + + ); +} + +function useFormField() { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = useFormItemContext("useFormField"); + const { formState, getFieldState } = useFormContext(); + + if (fieldContext === undefined) { + return { + disabled: itemContext.disabled, + error: undefined, + formDescriptionId: itemContext.descriptionId, + formItemId: itemContext.controlId, + formMessageId: itemContext.messageId, + hasDescription: itemContext.hasDescription, + hasMessage: itemContext.hasMessage, + hasMessageSlot: itemContext.hasMessageSlot, + id: itemContext.id, + invalid: itemContext.invalid, + isDirty: false, + isTouched: false, + isValidating: false, + name: "", + required: itemContext.required, + }; + } + + const fieldState = getFieldState(fieldContext.name, formState); + + return { + disabled: itemContext.disabled, + error: fieldState.error, + formDescriptionId: itemContext.descriptionId, + formItemId: itemContext.controlId, + formMessageId: itemContext.messageId, + hasDescription: itemContext.hasDescription, + hasMessage: itemContext.hasMessage, + hasMessageSlot: itemContext.hasMessageSlot, + id: itemContext.id, + invalid: itemContext.invalid || fieldState.invalid, + isDirty: fieldState.isDirty, + isTouched: fieldState.isTouched, + isValidating: fieldState.isValidating, + name: fieldContext.name, + required: itemContext.required, + }; +} const FormItem = React.forwardRef< HTMLDivElement, @@ -216,6 +518,7 @@ const FormItem = React.forwardRef< const generatedId = React.useId(); const hasDescription = hasRenderedFormChild(children, "FormDescription"); const hasMessage = hasRenderedFormChild(children, "FormMessage"); + const hasMessageSlot = hasFormChild(children, "FormMessage"); const effectiveDisabled = itemDisabled ?? disabled; const effectiveInvalid = itemInvalid ?? invalid; @@ -232,6 +535,8 @@ const FormItem = React.forwardRef< disabled: effectiveDisabled, hasDescription, hasMessage, + hasMessageSlot, + id: generatedId, invalid: effectiveInvalid, messageId: resolveItemId(messageIdBase, generatedId, "message"), required: effectiveRequired, @@ -245,6 +550,7 @@ const FormItem = React.forwardRef< generatedId, hasDescription, hasMessage, + hasMessageSlot, messageIdBase, ], ); @@ -264,13 +570,13 @@ const FormLabel = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, htmlFor, ...props }, ref) => { - const { controlId, invalid } = useFormItemContext("FormLabel"); + const { formItemId, invalid } = useFormField(); return (