diff --git a/packages/shadcn/registry-spec.json b/packages/shadcn/registry-spec.json index 2acebab60..cdf8e44ba 100644 --- a/packages/shadcn/registry-spec.json +++ b/packages/shadcn/registry-spec.json @@ -61,8 +61,8 @@ "type": "registry:block", "title": "Email Link Auth Form", "description": "A form allowing users to sign in via email link.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["input", "button", "form", "alert", "{{ DOMAIN }}/r/policies.json"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["input", "button", "field", "alert", "{{ DOMAIN }}/r/policies.json"], "files": [ { "path": "src/components/email-link-auth-form.tsx", @@ -126,8 +126,8 @@ "type": "registry:block", "title": "Forgot Password Auth Form", "description": "A form allowing users to reset their password via email.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["input", "button", "form", "{{ DOMAIN }}/r/policies.json"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["input", "button", "field", "{{ DOMAIN }}/r/policies.json"], "files": [ { "path": "src/components/forgot-password-auth-form.tsx", @@ -379,9 +379,9 @@ "type": "registry:block", "title": "Phone Auth Form", "description": "A form allowing users to authenticate using their phone number with SMS verification.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], "registryDependencies": [ - "form", + "field", "input", "button", "{{ DOMAIN }}/r/country-selector.json", @@ -457,8 +457,8 @@ "type": "registry:block", "title": "Sign In Auth Form", "description": "A form allowing users to sign in with email and password.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["input", "button", "form", "{{ DOMAIN }}/r/policies.json"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["input", "button", "field", "{{ DOMAIN }}/r/policies.json"], "files": [ { "path": "src/components/sign-in-auth-form.tsx", @@ -496,8 +496,8 @@ "type": "registry:block", "title": "Sign Up Auth Form", "description": "A form allowing users to sign up with email and password.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["input", "button", "form", "{{ DOMAIN }}/r/policies.json"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["input", "button", "field", "{{ DOMAIN }}/r/policies.json"], "files": [ { "path": "src/components/sign-up-auth-form.tsx", @@ -535,8 +535,8 @@ "type": "registry:block", "title": "SMS Multi-Factor Assertion Form", "description": "A form allowing users to complete SMS-based multi-factor authentication during sign-in.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["form", "input", "button", "input-otp"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["field", "input", "button", "input-otp"], "files": [ { "path": "src/components/sms-multi-factor-assertion-form.tsx", @@ -552,8 +552,8 @@ "type": "registry:block", "title": "SMS Multi-Factor Enrollment Form", "description": "A form allowing users to enroll SMS-based multi-factor authentication.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["form", "input", "button", "input-otp", "{{ DOMAIN }}/r/country-selector.json"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["field", "input", "button", "input-otp", "{{ DOMAIN }}/r/country-selector.json"], "files": [ { "path": "src/components/sms-multi-factor-enrollment-form.tsx", @@ -569,8 +569,8 @@ "type": "registry:block", "title": "TOTP Multi-Factor Assertion Form", "description": "A form allowing users to complete TOTP-based multi-factor authentication during sign-in.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["form", "button", "input-otp"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["field", "button", "input-otp"], "files": [ { "path": "src/components/totp-multi-factor-assertion-form.tsx", @@ -586,8 +586,8 @@ "type": "registry:block", "title": "TOTP Multi-Factor Enrollment Form", "description": "A form allowing users to enroll TOTP-based multi-factor authentication with QR code generation.", - "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}"], - "registryDependencies": ["form", "input", "button", "input-otp"], + "dependencies": ["{{ DEP | @firebase-oss/ui-react@beta }}", "react-hook-form", "@hookform/resolvers"], + "registryDependencies": ["field", "input", "button", "input-otp"], "files": [ { "path": "src/components/totp-multi-factor-enrollment-form.tsx", diff --git a/packages/shadcn/src/components/email-link-auth-form.test.tsx b/packages/shadcn/src/components/email-link-auth-form.test.tsx index 32110c633..6ee79d8e9 100644 --- a/packages/shadcn/src/components/email-link-auth-form.test.tsx +++ b/packages/shadcn/src/components/email-link-auth-form.test.tsx @@ -68,6 +68,21 @@ describe("", () => { expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); }); + it("should associate the email label with input via htmlFor/id", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector('[data-slot="field-label"][for="email"]')).toBeInTheDocument(); + expect(container.querySelector("input#email")).toBeInTheDocument(); + expect(container.querySelector("input#email")?.getAttribute("aria-invalid")).toBe("false"); + expect(container.querySelectorAll('[data-slot="field-error"]').length).toBe(0); + }); + it("should call the onEmailSent callback when the form is submitted successfully", async () => { const mockAction = vi.fn().mockResolvedValue(undefined); vi.mocked(useEmailLinkAuthFormAction).mockReturnValue(mockAction); diff --git a/packages/shadcn/src/components/email-link-auth-form.tsx b/packages/shadcn/src/components/email-link-auth-form.tsx index 4c4cdf4e4..7a098f6be 100644 --- a/packages/shadcn/src/components/email-link-auth-form.tsx +++ b/packages/shadcn/src/components/email-link-auth-form.tsx @@ -27,12 +27,12 @@ import { type EmailLinkAuthFormProps, } from "@firebase-oss/ui-react"; import { useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { Policies } from "@/components/policies"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; export type { EmailLinkAuthFormProps }; @@ -74,27 +74,25 @@ export function EmailLinkAuthForm(props: EmailLinkAuthFormProps) { } return ( -
+ - ( - - {getTranslation(ui, "labels", "emailAddress")} - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "emailAddress")} + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - +
); } diff --git a/packages/shadcn/src/components/forgot-password-auth-form.test.tsx b/packages/shadcn/src/components/forgot-password-auth-form.test.tsx index b363e14e1..802d89b92 100644 --- a/packages/shadcn/src/components/forgot-password-auth-form.test.tsx +++ b/packages/shadcn/src/components/forgot-password-auth-form.test.tsx @@ -65,6 +65,21 @@ describe("", () => { expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); }); + it("should associate the email label with input via htmlFor/id", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector('[data-slot="field-label"][for="email"]')).toBeInTheDocument(); + expect(container.querySelector("input#email")).toBeInTheDocument(); + expect(container.querySelector("input#email")?.getAttribute("aria-invalid")).toBe("false"); + expect(container.querySelectorAll('[data-slot="field-error"]').length).toBe(0); + }); + it("should render with back to sign in callback", () => { const onBackToSignInClickMock = vi.fn(); const mockUI = createMockUI({ diff --git a/packages/shadcn/src/components/forgot-password-auth-form.tsx b/packages/shadcn/src/components/forgot-password-auth-form.tsx index 0d4f106f1..ada00ea3b 100644 --- a/packages/shadcn/src/components/forgot-password-auth-form.tsx +++ b/packages/shadcn/src/components/forgot-password-auth-form.tsx @@ -23,12 +23,12 @@ import { useUI, type ForgotPasswordAuthFormProps, } from "@firebase-oss/ui-react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { FirebaseUIError, getTranslation } from "@firebase-oss/ui-core"; import { useState } from "react"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Policies } from "./policies"; @@ -69,32 +69,30 @@ export function ForgotPasswordAuthForm(props: ForgotPasswordAuthFormProps) { } return ( -
+ - ( - - {getTranslation(ui, "labels", "emailAddress")} - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "emailAddress")} + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} {props.onBackToSignInClick ? ( ) : null} - +
); } diff --git a/packages/shadcn/src/components/phone-auth-form.test.tsx b/packages/shadcn/src/components/phone-auth-form.test.tsx index 36fd8e8b9..5ec926dbd 100644 --- a/packages/shadcn/src/components/phone-auth-form.test.tsx +++ b/packages/shadcn/src/components/phone-auth-form.test.tsx @@ -225,7 +225,7 @@ describe("", () => { expect(container.querySelector("input[name='verificationCode']")).toBeInTheDocument(); }); - const description = container.querySelector('[data-slot="form-description"]'); + const description = container.querySelector('[data-slot="field-description"]'); expect(description).toBeInTheDocument(); expect(description).toHaveTextContent("Enter the verification code sent to your phone number"); }); diff --git a/packages/shadcn/src/components/phone-auth-form.tsx b/packages/shadcn/src/components/phone-auth-form.tsx index a1a67ff82..6a51188e4 100644 --- a/packages/shadcn/src/components/phone-auth-form.tsx +++ b/packages/shadcn/src/components/phone-auth-form.tsx @@ -28,7 +28,7 @@ import { import { useState } from "react"; import type { UserCredential } from "firebase/auth"; import { useRef } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { FirebaseUIError, @@ -38,7 +38,7 @@ import { type PhoneAuthVerifyFormSchema, } from "@firebase-oss/ui-core"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Policies } from "@/components/policies"; @@ -75,37 +75,35 @@ function VerifyPhoneNumberForm(props: VerifyPhoneNumberFormProps) { } return ( -
+ - ( - - {getTranslation(ui, "labels", "verificationCode")} - {getTranslation(ui, "prompts", "smsVerificationPrompt")} - - - - - - - - - - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - +
); } @@ -141,22 +139,20 @@ function PhoneNumberForm(props: PhoneNumberFormProps) { } return ( -
+ - ( - - {getTranslation(ui, "labels", "phoneNumber")} - -
- - -
-
- -
+ render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "phoneNumber")} +
+ + +
+ {fieldState.error && {fieldState.error.message}} +
)} />
@@ -164,9 +160,9 @@ function PhoneNumberForm(props: PhoneNumberFormProps) { - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - + ); } diff --git a/packages/shadcn/src/components/sign-in-auth-form.test.tsx b/packages/shadcn/src/components/sign-in-auth-form.test.tsx index c8d2710cb..b4b7a5672 100644 --- a/packages/shadcn/src/components/sign-in-auth-form.test.tsx +++ b/packages/shadcn/src/components/sign-in-auth-form.test.tsx @@ -208,6 +208,84 @@ describe("", () => { expect(await screen.findByText("Error: foo")).toBeInTheDocument(); }); + it("should associate labels with inputs via htmlFor/id", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const emailLabel = container.querySelector('[data-slot="field-label"][for="email"]'); + const emailInput = container.querySelector("input#email"); + expect(emailLabel).toBeInTheDocument(); + expect(emailInput).toBeInTheDocument(); + + const passwordLabel = container.querySelector('[data-slot="field-label"][for="password"]'); + const passwordInput = container.querySelector("input#password"); + expect(passwordLabel).toBeInTheDocument(); + expect(passwordInput).toBeInTheDocument(); + }); + + it("should set aria-invalid and data-invalid on validation error", async () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + signIn: "Sign In", + }, + errors: { + invalidEmail: "Please enter a valid email address", + weakPassword: "Password should be at least 6 characters", + }, + }), + }); + + const { container } = render( + + + + ); + + const emailInput = container.querySelector("input[name='email']") as HTMLInputElement; + const passwordInput = container.querySelector("input[name='password']") as HTMLInputElement; + const form = container.querySelector("form") as HTMLFormElement; + + expect(emailInput.getAttribute("aria-invalid")).toBe("false"); + expect(passwordInput.getAttribute("aria-invalid")).toBe("false"); + + await act(async () => { + fireEvent.submit(form); + }); + + await waitFor(() => { + expect(emailInput.getAttribute("aria-invalid")).toBe("true"); + expect(passwordInput.getAttribute("aria-invalid")).toBe("true"); + }); + + const fields = container.querySelectorAll('[data-slot="field"]'); + const invalidFields = Array.from(fields).filter((f) => f.getAttribute("data-invalid") === "true"); + expect(invalidFields.length).toBe(2); + + const errors = container.querySelectorAll('[role="alert"]'); + expect(errors.length).toBeGreaterThanOrEqual(2); + }); + + it("should not render field errors when fields are valid", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + const fieldErrors = container.querySelectorAll('[data-slot="field-error"]'); + expect(fieldErrors.length).toBe(0); + }); + it("should show validation errors only after submit and clear after typing valid values", async () => { const mockUI = createMockUI({ locale: registerLocale("test", { diff --git a/packages/shadcn/src/components/sign-in-auth-form.tsx b/packages/shadcn/src/components/sign-in-auth-form.tsx index 92e623365..39e28cf8f 100644 --- a/packages/shadcn/src/components/sign-in-auth-form.tsx +++ b/packages/shadcn/src/components/sign-in-auth-form.tsx @@ -23,11 +23,11 @@ import { useUI, type SignInAuthFormProps, } from "@firebase-oss/ui-react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { FirebaseUIError, getTranslation } from "@firebase-oss/ui-core"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Policies } from "./policies"; @@ -59,46 +59,42 @@ export function SignInAuthForm(props: SignInAuthFormProps) { } return ( -
+ - ( - - {getTranslation(ui, "labels", "emailAddress")} - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "emailAddress")} + + {fieldState.error && {fieldState.error.message}} + )} /> - ( - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "password")} {props.onForgotPasswordClick ? ( ) : null} - - - - - - + + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} {props.onSignUpClick ? ( <> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} {props.onSignInClick ? ( ) : null} - +
); } diff --git a/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx b/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx index ac8494ac1..88c042779 100644 --- a/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx +++ b/packages/shadcn/src/components/sms-multi-factor-assertion-form.test.tsx @@ -39,13 +39,13 @@ vi.mock("@/components/ui/input-otp", () => ({ }), })); -vi.mock("@/components/ui/form", async (importOriginal) => { - const mod = await importOriginal(); +vi.mock("@/components/ui/field", async (importOriginal) => { + const mod = await importOriginal(); return { ...mod, - FormItem: ({ children, ...props }: any) => React.createElement("div", { ...props }, children), - FormLabel: ({ children, ...props }: any) => React.createElement("label", { ...props }, children), - FormDescription: ({ children, ...props }: any) => React.createElement("p", { ...props }, children), + Field: ({ children, ...props }: any) => React.createElement("div", { ...props }, children), + FieldLabel: ({ children, ...props }: any) => React.createElement("label", { ...props }, children), + FieldDescription: ({ children, ...props }: any) => React.createElement("p", { ...props }, children), }; }); diff --git a/packages/shadcn/src/components/sms-multi-factor-assertion-form.tsx b/packages/shadcn/src/components/sms-multi-factor-assertion-form.tsx index 9a2b7ccfa..7f1651ad2 100644 --- a/packages/shadcn/src/components/sms-multi-factor-assertion-form.tsx +++ b/packages/shadcn/src/components/sms-multi-factor-assertion-form.tsx @@ -27,10 +27,10 @@ import { useSmsMultiFactorAssertionPhoneFormAction, useSmsMultiFactorAssertionVerifyFormAction, } from "@firebase-oss/ui-react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; @@ -63,14 +63,14 @@ function SmsMultiFactorAssertionPhoneForm(props: SmsMultiFactorAssertionPhoneFor return (
- - {getTranslation(ui, "labels", "phoneNumber")} - + + {getTranslation(ui, "labels", "phoneNumber")} + {getTranslation(ui, "messages", "mfaSmsAssertionPrompt", { phoneNumber: (props.hint as PhoneMultiFactorInfo).phoneNumber || "", })} - - + +
- {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - + ); } diff --git a/packages/shadcn/src/components/sms-multi-factor-enrollment-form.test.tsx b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.test.tsx index 0fd6c3a99..659328694 100644 --- a/packages/shadcn/src/components/sms-multi-factor-enrollment-form.test.tsx +++ b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.test.tsx @@ -152,7 +152,7 @@ describe("", () => { expect(screen.getByTestId("input-otp")).toBeInTheDocument(); }); - const description = container.querySelector('[data-slot="form-description"]'); + const description = container.querySelector('[data-slot="field-description"]'); expect(description).toBeInTheDocument(); expect(description).toHaveTextContent("smsVerificationPrompt"); @@ -227,7 +227,7 @@ describe("", () => { expect(screen.getByTestId("input-otp")).toBeInTheDocument(); }); - const description = container.querySelector('[data-slot="form-description"]'); + const description = container.querySelector('[data-slot="field-description"]'); expect(description).toBeInTheDocument(); expect(description).toHaveTextContent("smsVerificationPrompt"); @@ -276,7 +276,7 @@ describe("", () => { expect(screen.getByTestId("input-otp")).toBeInTheDocument(); }); - const description = container.querySelector('[data-slot="form-description"]'); + const description = container.querySelector('[data-slot="field-description"]'); expect(description).toBeInTheDocument(); expect(description).toHaveTextContent("smsVerificationPrompt"); diff --git a/packages/shadcn/src/components/sms-multi-factor-enrollment-form.tsx b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.tsx index cffa29e1c..f567641c9 100644 --- a/packages/shadcn/src/components/sms-multi-factor-enrollment-form.tsx +++ b/packages/shadcn/src/components/sms-multi-factor-enrollment-form.tsx @@ -32,10 +32,10 @@ import { useRecaptchaVerifier, useUI, } from "@firebase-oss/ui-react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldDescription, FieldError } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; @@ -73,44 +73,40 @@ function MultiFactorEnrollmentPhoneNumberForm(props: MultiFactorEnrollmentPhoneN }; return ( -
+ - ( - - {getTranslation(ui, "labels", "displayName")} - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "displayName")} + + {fieldState.error && {fieldState.error.message}} + )} /> - ( - - {getTranslation(ui, "labels", "phoneNumber")} - -
- - -
-
- -
+ render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "phoneNumber")} +
+ + +
+ {fieldState.error && {fieldState.error.message}} +
)} />
- {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - + ); } @@ -146,37 +142,35 @@ export function MultiFactorEnrollmentVerifyPhoneNumberForm(props: MultiFactorEnr }; return ( -
+ - ( - - {getTranslation(ui, "labels", "verificationCode")} - {getTranslation(ui, "prompts", "smsVerificationPrompt")} - - - - - - - - - - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "verificationCode")} + {getTranslation(ui, "prompts", "smsVerificationPrompt")} + + + + + + + + + + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - +
); } diff --git a/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx b/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx index 83fbb845f..67e0fba39 100644 --- a/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx +++ b/packages/shadcn/src/components/totp-multi-factor-assertion-form.test.tsx @@ -82,6 +82,35 @@ describe("", () => { expect(screen.getByRole("button", { name: "Verify Code" })).toBeInTheDocument(); }); + it("should associate the verification code label with input via htmlFor/id", () => { + const mockHint = { + uid: "test-uid", + factorId: TotpMultiFactorGenerator.FACTOR_ID, + displayName: "Test TOTP", + enrollmentTime: "2023-01-01T00:00:00Z", + }; + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + const { container } = render( + createFirebaseUIProvider({ + children: , + ui: mockUI, + }) + ); + + expect(container.querySelector('[data-slot="field-label"][for="verificationCode"]')).toBeInTheDocument(); + expect(container.querySelector("#verificationCode")).toBeInTheDocument(); + expect(container.querySelectorAll('[data-slot="field-error"]').length).toBe(0); + }); + it("should call onSuccess when verification is successful", async () => { const mockHint = { uid: "test-uid", diff --git a/packages/shadcn/src/components/totp-multi-factor-assertion-form.tsx b/packages/shadcn/src/components/totp-multi-factor-assertion-form.tsx index a4619449c..fb1d44f43 100644 --- a/packages/shadcn/src/components/totp-multi-factor-assertion-form.tsx +++ b/packages/shadcn/src/components/totp-multi-factor-assertion-form.tsx @@ -23,10 +23,10 @@ import { useUI, useTotpMultiFactorAssertionFormAction, } from "@firebase-oss/ui-react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; import { Button } from "@/components/ui/button"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; @@ -59,35 +59,33 @@ export function TotpMultiFactorAssertionForm(props: TotpMultiFactorAssertionForm }; return ( -
+ - ( - - {getTranslation(ui, "labels", "verificationCode")} - - - - - - - - - - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "verificationCode")} + + + + + + + + + + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - +
); } diff --git a/packages/shadcn/src/components/totp-multi-factor-enrollment-form.test.tsx b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.test.tsx index 5e85b707f..fe9d1924e 100644 --- a/packages/shadcn/src/components/totp-multi-factor-enrollment-form.test.tsx +++ b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.test.tsx @@ -71,7 +71,7 @@ describe("", () => { }) ); - expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(document.querySelector("input[name='displayName']")!).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Generate Secret" })).toBeInTheDocument(); }); @@ -100,7 +100,7 @@ describe("", () => { }) ); - fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Test TOTP" } }); + fireEvent.change(document.querySelector("input[name='displayName']")!, { target: { value: "Test TOTP" } }); fireEvent.click(screen.getByRole("button", { name: "Generate Secret" })); await waitFor(() => { @@ -132,7 +132,7 @@ describe("", () => { }) ); - fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Test TOTP" } }); + fireEvent.change(document.querySelector("input[name='displayName']")!, { target: { value: "Test TOTP" } }); fireEvent.click(screen.getByRole("button", { name: "Generate Secret" })); await waitFor(() => { @@ -163,7 +163,7 @@ describe("", () => { }) ); - fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "Test TOTP" } }); + fireEvent.change(document.querySelector("input[name='displayName']")!, { target: { value: "Test TOTP" } }); fireEvent.click(screen.getByRole("button", { name: "Generate Secret" })); await waitFor(() => { diff --git a/packages/shadcn/src/components/totp-multi-factor-enrollment-form.tsx b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.tsx index 5d45bebfd..7518da317 100644 --- a/packages/shadcn/src/components/totp-multi-factor-enrollment-form.tsx +++ b/packages/shadcn/src/components/totp-multi-factor-enrollment-form.tsx @@ -30,10 +30,10 @@ import { useMultiFactorTotpAuthVerifyFormSchema, useUI, } from "@firebase-oss/ui-react"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider, Controller } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"; @@ -65,27 +65,25 @@ function TotpMultiFactorSecretGenerationForm(props: TotpMultiFactorSecretGenerat }; return ( -
+ - ( - - {getTranslation(ui, "labels", "displayName")} - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "displayName")} + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - +
); } @@ -129,36 +127,34 @@ export function MultiFactorEnrollmentVerifyTotpForm(props: MultiFactorEnrollment {getTranslation(ui, "prompts", "mfaTotpQrCodePrompt")}

-
+ - ( - - {getTranslation(ui, "labels", "verificationCode")} - - - - - - - - - - - - - - + render={({ field, fieldState }) => ( + + {getTranslation(ui, "labels", "verificationCode")} + + + + + + + + + + + {fieldState.error && {fieldState.error.message}} + )} /> - {form.formState.errors.root && {form.formState.errors.root.message}} + {form.formState.errors.root && {form.formState.errors.root.message}} - +
); } diff --git a/packages/shadcn/src/components/ui/form.tsx b/packages/shadcn/src/components/ui/form.tsx deleted file mode 100644 index a21641e10..000000000 --- a/packages/shadcn/src/components/ui/form.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import * as React from "react"; -import type * as LabelPrimitive from "@radix-ui/react-label"; -import { Slot } from "@radix-ui/react-slot"; -import { - Controller, - FormProvider, - useFormContext, - useFormState, - type ControllerProps, - type FieldPath, - type FieldValues, -} 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 } = 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, - }; -}; - -type FormItemContextValue = { - id: string; -}; - -const FormItemContext = React.createContext({} as FormItemContextValue); - -function FormItem({ className, ...props }: React.ComponentProps<"div">) { - const id = React.useId(); - - return ( - -
- - ); -} - -function FormLabel({ className, ...props }: React.ComponentProps) { - const { error, formItemId } = useFormField(); - - return ( -