diff --git a/examples/shadcn/src/components/sign-in-auth-form.tsx b/examples/shadcn/src/components/sign-in-auth-form.tsx index 7a46476b..93c2bef6 100644 --- a/examples/shadcn/src/components/sign-in-auth-form.tsx +++ b/examples/shadcn/src/components/sign-in-auth-form.tsx @@ -1,7 +1,7 @@ "use client"; import type { SignInAuthFormSchema } from "@firebase-ui/core"; -import { useSignInAuthFormAction, useSignInAuthFormSchema, useUI, SignInAuthFormProps } from "@firebase-ui/react"; +import { useSignInAuthFormAction, useSignInAuthFormSchema, useUI, type SignInAuthFormProps } from "@firebase-ui/react"; import { useForm } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { FirebaseUIError, getTranslation } from "@firebase-ui/core"; diff --git a/examples/shadcn/src/components/ui/form.tsx b/examples/shadcn/src/components/ui/form.tsx index 018ddba2..a21641e1 100644 --- a/examples/shadcn/src/components/ui/form.tsx +++ b/examples/shadcn/src/components/ui/form.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import * as LabelPrimitive from "@radix-ui/react-label"; +import type * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { Controller, diff --git a/packages/react/src/auth/index.ts b/packages/react/src/auth/index.ts index 6026b08f..3ffbb185 100644 --- a/packages/react/src/auth/index.ts +++ b/packages/react/src/auth/index.ts @@ -19,6 +19,7 @@ export { type EmailLinkAuthFormProps, useEmailLinkAuthFormAction, useEmailLinkAuthForm, + useEmailLinkAuthFormCompleteSignIn, } from "./forms/email-link-auth-form"; export { ForgotPasswordAuthForm, @@ -45,6 +46,7 @@ export { type SignUpAuthFormProps, useSignUpAuthForm, useSignUpAuthFormAction, + useRequireDisplayName, } from "./forms/sign-up-auth-form"; export { EmailLinkAuthScreen, type EmailLinkAuthScreenProps } from "./screens/email-link-auth-screen"; diff --git a/packages/shadcn/build.ts b/packages/shadcn/build.ts index cd29f553..45c73694 100644 --- a/packages/shadcn/build.ts +++ b/packages/shadcn/build.ts @@ -30,7 +30,12 @@ if (fs.existsSync(publicRDir)) { } try { - execSync(`shadcn build -o ${publicDir}`, { stdio: "inherit" }); + try { + execSync(`./node_modules/.bin/shadcn build -o ${publicDir}`, { stdio: "inherit" }); + } catch (error) { + console.error("shadcn build failed:", error); + process.exit(1); + } } finally { execSync("rm registry.json"); } diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index c975ccc5..0b6e360c 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -37,6 +37,7 @@ "@firebase-ui/react": "workspace:*", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", diff --git a/packages/shadcn/registry-spec.json b/packages/shadcn/registry-spec.json index 03d003a5..665945f6 100644 --- a/packages/shadcn/registry-spec.json +++ b/packages/shadcn/registry-spec.json @@ -43,6 +43,311 @@ "type": "registry:component" } ] + }, + { + "name": "sign-up-auth-screen", + "type": "registry:block", + "title": "Sign Up Auth Screen", + "description": "A screen allowing users to sign up with email and password.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["separator", "card", "{{ DOMAIN }}/sign-up-auth-form.json"], + "files": [ + { + "path": "src/registry/sign-up-auth-screen.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "sign-up-auth-form", + "type": "registry:block", + "title": "Sign Up Auth Form", + "description": "A form allowing users to sign up with email and password.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["input", "button", "form", "{{ DOMAIN }}/policies.json"], + "files": [ + { + "path": "src/registry/sign-up-auth-form.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "forgot-password-auth-screen", + "type": "registry:block", + "title": "Forgot Password Auth Screen", + "description": "A screen allowing users to reset their password via email.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["card", "{{ DOMAIN }}/forgot-password-auth-form.json"], + "files": [ + { + "path": "src/registry/forgot-password-auth-screen.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "forgot-password-auth-form", + "type": "registry:block", + "title": "Forgot Password Auth Form", + "description": "A form allowing users to reset their password via email.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["input", "button", "form", "{{ DOMAIN }}/policies.json"], + "files": [ + { + "path": "src/registry/forgot-password-auth-form.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "email-link-auth-screen", + "type": "registry:block", + "title": "Email Link Auth Screen", + "description": "A screen allowing users to sign in via email link.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["separator", "card", "{{ DOMAIN }}/email-link-auth-form.json"], + "files": [ + { + "path": "src/registry/email-link-auth-screen.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "email-link-auth-form", + "type": "registry:block", + "title": "Email Link Auth Form", + "description": "A form allowing users to sign in via email link.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["input", "button", "form", "{{ DOMAIN }}/policies.json"], + "files": [ + { + "path": "src/registry/email-link-auth-form.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "oauth-button", + "type": "registry:block", + "title": "OAuth Button", + "description": "A button component for OAuth authentication providers.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["button"], + "files": [ + { + "path": "src/registry/oauth-button.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "google-sign-in-button", + "type": "registry:block", + "title": "Google Sign In Button", + "description": "A button component for Google OAuth authentication.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["{{ DOMAIN }}/oauth-button.json"], + "files": [ + { + "path": "src/registry/google-sign-in-button.tsx", + "type": "registry:component" + } + ], + "css": { + "@layer components": { + "button[data-provider='google.com'][data-themed='true']": { + "--google-primary": "#131314", + "--color-primary": "var(--google-primary)", + "--color-primary-hover": "--alpha(var(--google-primary) / 85%)", + "--color-primary-surface": "#FFFFFF", + "--color-border": "var(--google-primary)" + }, + "button[data-provider='google.com'][data-themed='neutral']": { + "--google-primary": "#F2F2F2", + "--color-primary": "var(--google-primary)", + "--color-primary-hover": "--alpha(var(--google-primary) / 85%)", + "--color-primary-surface": "#1F1F1F", + "--color-border": "transparent" + } + }, + "@variant dark": { + "button[data-provider='google.com'][data-themed='true']": { + "--google-primary": "#FFFFFF", + "--color-primary": "var(--google-primary)", + "--color-primary-hover": "--alpha(var(--google-primary) / 85%)", + "--color-primary-surface": "#1F1F1F", + "--color-border": "#747775" + } + } + } + }, + { + "name": "facebook-sign-in-button", + "type": "registry:block", + "title": "Facebook Sign In Button", + "description": "A button component for Facebook OAuth authentication.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["{{ DOMAIN }}/oauth-button.json"], + "files": [ + { + "path": "src/registry/facebook-sign-in-button.tsx", + "type": "registry:component" + } + ], + "css": { + "@layer components": { + "button[data-provider='facebook.com'][data-themed='true']": { + "--facebook-primary": "#1877F2", + "--color-primary": "var(--facebook-primary)", + "--color-primary-hover": "--alpha(var(--facebook-primary) / 85%)", + "--color-primary-surface": "var(--color-white)", + "--color-border": "var(--facebook-primary)" + } + } + } + }, + { + "name": "github-sign-in-button", + "type": "registry:block", + "title": "GitHub Sign In Button", + "description": "A button component for GitHub OAuth authentication.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["{{ DOMAIN }}/oauth-button.json"], + "files": [ + { + "path": "src/registry/github-sign-in-button.tsx", + "type": "registry:component" + } + ], + "css": { + "@layer components": { + "button[data-provider='github.com'][data-themed='true']": { + "--github-primary": "#000000", + "--color-primary": "var(--github-primary)", + "--color-primary-hover": "--alpha(var(--github-primary) / 85%)", + "--color-primary-surface": "#FFFFFF", + "--color-border": "var(--github-primary)" + } + }, + "@variant dark": { + "button[data-provider='github.com'][data-themed='true']": { + "--github-primary": "var(--color-white)", + "--color-primary": "var(--github-primary)", + "--color-primary-hover": "--alpha(var(--github-primary) / 85%)", + "--color-primary-surface": "var(--color-black)", + "--color-border": "var(--color-white)" + } + } + } + }, + { + "name": "apple-sign-in-button", + "type": "registry:block", + "title": "Apple Sign In Button", + "description": "A button component for Apple OAuth authentication.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["{{ DOMAIN }}/oauth-button.json"], + "files": [ + { + "path": "src/registry/apple-sign-in-button.tsx", + "type": "registry:component" + } + ], + "css": { + "@layer components": { + "button[data-provider='apple.com'][data-themed='true']": { + "--apple-primary": "#000000", + "--color-primary": "var(--apple-primary)", + "--color-primary-hover": "--alpha(var(--apple-primary) / 85%)", + "--color-primary-surface": "#FFFFFF", + "--color-border": "var(--apple-primary)" + } + }, + "@variant dark": { + "button[data-provider='apple.com'][data-themed='true']": { + "--apple-primary": "var(--color-white)", + "--color-primary": "var(--apple-primary)", + "--color-primary-hover": "--alpha(var(--apple-primary) / 85%)", + "--color-primary-surface": "var(--color-black)", + "--color-border": "var(--color-white)" + } + } + } + }, + { + "name": "microsoft-sign-in-button", + "type": "registry:block", + "title": "Microsoft Sign In Button", + "description": "A button component for Microsoft OAuth authentication.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["{{ DOMAIN }}/oauth-button.json"], + "files": [ + { + "path": "src/registry/microsoft-sign-in-button.tsx", + "type": "registry:component" + } + ], + "css": { + "@layer components": { + "button[data-provider='microsoft.com'][data-themed='true']": { + "--microsoft-primary": "#2F2F2F", + "--color-primary": "var(--microsoft-primary)", + "--color-primary-hover": "--alpha(var(--microsoft-primary) / 85%)", + "--color-primary-surface": "#FFFFFF", + "--color-border": "var(--microsoft-primary)" + } + }, + "@variant dark": { + "button[data-provider='microsoft.com'][data-themed='true']": { + "--microsoft-primary": "var(--color-white)", + "--color-primary": "var(--microsoft-primary)", + "--color-primary-hover": "--alpha(var(--microsoft-primary) / 85%)", + "--color-primary-surface": "#5E5E5E", + "--color-border": "var(--color-white)" + } + } + } + }, + { + "name": "twitter-sign-in-button", + "type": "registry:block", + "title": "Twitter Sign In Button", + "description": "A button component for Twitter OAuth authentication.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["{{ DOMAIN }}/oauth-button.json"], + "files": [ + { + "path": "src/registry/twitter-sign-in-button.tsx", + "type": "registry:component" + } + ], + "css": { + "@layer components": { + "button[data-provider='twitter.com'][data-themed='true']": { + "--twitter-primary": "#1DA1F2", + "--color-primary": "var(--twitter-primary)", + "--color-primary-hover": "--alpha(var(--twitter-primary) / 85%)", + "--color-primary-surface": "#FFFFFF", + "--color-border": "var(--twitter-primary)" + } + } + } + }, + { + "name": "country-selector", + "type": "registry:block", + "title": "Country Selector", + "description": "A country selector component for phone number input with country codes and flags.", + "dependencies": ["{{ DEP | @firebase-ui/react }}"], + "registryDependencies": ["select"], + "files": [ + { + "path": "src/registry/country-selector.tsx", + "type": "registry:component" + } + ] } ] } diff --git a/packages/shadcn/src/components/ui/form.tsx b/packages/shadcn/src/components/ui/form.tsx index 018ddba2..a21641e1 100644 --- a/packages/shadcn/src/components/ui/form.tsx +++ b/packages/shadcn/src/components/ui/form.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import * as LabelPrimitive from "@radix-ui/react-label"; +import type * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { Controller, diff --git a/packages/shadcn/src/components/ui/select.tsx b/packages/shadcn/src/components/ui/select.tsx new file mode 100644 index 00000000..35e252a5 --- /dev/null +++ b/packages/shadcn/src/components/ui/select.tsx @@ -0,0 +1,160 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Select({ ...props }: React.ComponentProps) { + return ; +} + +function SelectGroup({ ...props }: React.ComponentProps) { + return ; +} + +function SelectValue({ ...props }: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default"; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ className, children, ...props }: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ className, ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/packages/shadcn/src/registry/apple-sign-in-button.test.tsx b/packages/shadcn/src/registry/apple-sign-in-button.test.tsx new file mode 100644 index 00000000..b381805e --- /dev/null +++ b/packages/shadcn/src/registry/apple-sign-in-button.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { AppleSignInButton } from "./apple-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { OAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{children}
+
+ ), +})); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + AppleLogo: ({ className, ...props }: any) => ( + + Apple Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Apple provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("apple.com"); + expect(screen.getByTestId("apple-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Apple")).toBeInTheDocument(); + }); + + it("renders with custom Apple provider", () => { + const customProvider = new OAuthProvider("apple.com"); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("apple.com"); + expect(screen.getByTestId("apple-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Apple")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders Apple logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + const appleLogo = screen.getByTestId("apple-logo"); + expect(appleLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Custom Apple Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Apple Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Apple logo and the text + expect(childrenContainer.querySelector('[data-testid="apple-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Apple"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithApple: "Sign in with Apple", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); +}); diff --git a/packages/shadcn/src/registry/apple-sign-in-button.tsx b/packages/shadcn/src/registry/apple-sign-in-button.tsx new file mode 100644 index 00000000..f600032b --- /dev/null +++ b/packages/shadcn/src/registry/apple-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { OAuthProvider } from "firebase/auth"; +import { getTranslation } from "@firebase-ui/core"; +import { useUI, type AppleSignInButtonProps, AppleLogo } from "@firebase-ui/react"; + +import { OAuthButton } from "@/registry/oauth-button"; + +export type { AppleSignInButtonProps }; + +export function AppleSignInButton({ provider, themed }: AppleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithApple")} + + ); +} diff --git a/packages/shadcn/src/registry/country-selector.test.tsx b/packages/shadcn/src/registry/country-selector.test.tsx new file mode 100644 index 00000000..6b9befb3 --- /dev/null +++ b/packages/shadcn/src/registry/country-selector.test.tsx @@ -0,0 +1,324 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { countryCodes } from "@firebase-ui/core"; +import { CountrySelector } from "./country-selector"; +import { createMockUI, createFirebaseUIProvider } from "../../tests/utils"; +import { FirebaseUIProvider } from "@firebase-ui/react"; +import { useCountries, useDefaultCountry } from "@firebase-ui/react"; +import type { RefObject } from "react"; +import type { CountrySelectorRef } from "@firebase-ui/react"; + +// Mock the shadcn Select components +vi.mock("@/components/ui/select", () => ({ + Select: ({ children, value, onValueChange }: any) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children }: any) => ( + + ), + SelectValue: ({ children }: any) => {children}, + SelectContent: ({ children }: any) => ( +
+ {children} +
+ ), + SelectItem: ({ children, value }: any) => ( +
+ {children} +
+ ), +})); + +describe("useCountries", () => { + it("should return allowed countries from behavior", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current).toHaveLength(3); + expect(result.current.map((c) => c.code)).toEqual(["CA", "GB", "US"]); + }); + + it("should return all countries when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + const { result } = renderHook(() => useCountries(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.length).toBeGreaterThan(100); // Should have many countries + }); +}); + +describe("useDefaultCountry", () => { + it("should return US as default country", () => { + const mockUI = createMockUI(); + + const { result } = renderHook(() => useDefaultCountry(), { + wrapper: ({ children }) => createFirebaseUIProvider({ children, ui: mockUI }), + }); + + expect(result.current.code).toBe("US"); + }); +}); + +describe("", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders with the default country", () => { + render( + + + + ); + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "US"); + expect(screen.getByTestId("select-value")).toHaveTextContent("🇺🇸 +1"); + }); + + it("renders country options in the dropdown", () => { + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems).toHaveLength(3); + + // Check that items have correct values + expect(selectItems[0]).toHaveAttribute("data-value", "CA"); + expect(selectItems[1]).toHaveAttribute("data-value", "GB"); + expect(selectItems[2]).toHaveAttribute("data-value", "US"); + }); + + it("displays country information correctly in options", () => { + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + + // Check that each option shows dial code and country name + selectItems.forEach((item) => { + const text = item.textContent; + expect(text).toMatch(/^\+\d+ \([^)]+\)$/); // Format: +123 (Country Name) + }); + }); + + it("changes selection when a different country is selected", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + // Use the ref to change the country + ref.current?.setCountry("GB"); + + await waitFor(() => { + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "GB"); + }); + }); + + it("renders only allowed countries in the dropdown", () => { + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems).toHaveLength(3); + + const values = selectItems.map((item) => item.getAttribute("data-value")); + expect(values).toEqual(["CA", "GB", "US"]); + }); + + it("handles country selection with setCountry callback", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + // Use the ref to change the country + ref.current?.setCountry("CA"); + + await waitFor(() => { + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "CA"); + }); + }); + it("should work with all countries when no behavior is set", () => { + const mockUI = createMockUI({ + behaviors: [], + }); + + render( + + + + ); + + const selectItems = screen.getAllByTestId("select-item"); + expect(selectItems.length).toBeGreaterThan(100); // Should have many countries + }); + + it("should display correct emoji and dial code in trigger", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + render( + + + + ); + + const selectValues = screen.getAllByTestId("select-value"); + const triggerValue = selectValues.find((el) => el.closest('[data-testid="select-trigger"]')); + expect(triggerValue).toHaveTextContent("🇺🇸 +1"); + }); + + it("should update display when country changes", async () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + // Change to Canada + ref.current?.setCountry("CA"); + + await waitFor(() => { + // Verify that the select component receives the updated value + const selects = screen.getAllByTestId("select"); + const selectWithCA = selects.find((el) => el.getAttribute("data-value") === "CA"); + expect(selectWithCA).toBeDefined(); + }); + }); +}); + +describe("CountrySelectorRef", () => { + const mockUI = createMockUI({ + behaviors: [countryCodes({ allowedCountries: ["US", "GB", "CA"] })], + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should expose getCountry and setCountry methods", () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + expect(ref.current).toBeDefined(); + expect(typeof ref.current?.getCountry).toBe("function"); + expect(typeof ref.current?.setCountry).toBe("function"); + }); + + it("should return current selected country via getCountry", () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("US"); + expect(currentCountry?.name).toBe("United States"); + }); + + it("should set country via setCountry", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + ref.current?.setCountry("GB"); + + await waitFor(() => { + const select = screen.getByTestId("select"); + expect(select).toHaveAttribute("data-value", "GB"); + }); + }); + + it("should update getCountry after setCountry", async () => { + const ref: RefObject = { current: null as unknown as CountrySelectorRef }; + + render( + + + + ); + + ref.current?.setCountry("CA"); + + await waitFor(() => { + const currentCountry = ref.current?.getCountry(); + expect(currentCountry?.code).toBe("CA"); + expect(currentCountry?.name).toBe("Canada"); + }); + }); +}); diff --git a/packages/shadcn/src/registry/country-selector.tsx b/packages/shadcn/src/registry/country-selector.tsx new file mode 100644 index 00000000..310821fa --- /dev/null +++ b/packages/shadcn/src/registry/country-selector.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; +import type { CountryCode, CountryData } from "@firebase-ui/core"; +import { + type CountrySelectorRef, + type CountrySelectorProps, + useCountries, + useDefaultCountry, +} from "@firebase-ui/react"; + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +export type { CountrySelectorRef }; + +export const CountrySelector = forwardRef((props, ref) => { + const countries = useCountries(); + const defaultCountry = useDefaultCountry(); + const [selected, setSelected] = useState(defaultCountry); + + const setCountry = useCallback( + (code: CountryCode) => { + const foundCountry = countries.find((country) => country.code === code); + setSelected(foundCountry!); + }, + [countries] + ); + + useImperativeHandle( + ref, + () => ({ + getCountry: () => selected, + setCountry, + }), + [selected, setCountry] + ); + + return ( + + ); +}); + +CountrySelector.displayName = "CountrySelector"; diff --git a/packages/shadcn/src/registry/email-link-auth-form.test.tsx b/packages/shadcn/src/registry/email-link-auth-form.test.tsx new file mode 100644 index 00000000..f93e4306 --- /dev/null +++ b/packages/shadcn/src/registry/email-link-auth-form.test.tsx @@ -0,0 +1,238 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { EmailLinkAuthForm } from "./email-link-auth-form"; +import { act } from "react"; +import { useEmailLinkAuthFormAction } from "@firebase-ui/react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "@firebase-ui/react"; +import { UserCredential } from "firebase/auth"; +import { completeEmailLinkSignIn } from "@firebase-ui/core"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + sendSignInLinkToEmail: vi.fn(), + completeEmailLinkSignIn: vi.fn(), + }; +}); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useEmailLinkAuthFormAction: vi.fn(), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should call the onEmailSent callback when the form is submitted successfully", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useEmailLinkAuthFormAction).mockReturnValue(mockAction); + const onEmailSentMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + sendSignInLink: "Send Sign In Link", + }, + errors: { + invalidEmail: "Invalid email", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ email: "test@example.com" }); + expect(onEmailSentMock).toHaveBeenCalled(); + }); + + it("should display error message when form submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("foo")); + + vi.mocked(useEmailLinkAuthFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + sendSignInLink: "Send Sign In Link", + }, + }), + }); + + const { container } = render( + + + + ); + + const emailInput = container.querySelector("input[name='email']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: foo")).toBeInTheDocument(); + }); + + it("should show success message after successful submission", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useEmailLinkAuthFormAction).mockReturnValue(mockAction); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + sendSignInLink: "Send Sign In Link", + }, + messages: { + signInLinkSent: "Sign in link sent to your email", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(screen.getByText("Sign in link sent to your email")).toBeInTheDocument(); + }); + + // Form should no longer be visible + expect(container.querySelector("form")).not.toBeInTheDocument(); + }); + + it("should not show success message initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + signInLinkSent: "Sign in link sent to your email", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(screen.queryByText("Sign in link sent to your email")).not.toBeInTheDocument(); + expect(container.querySelector("form")).toBeInTheDocument(); + }); + + it("should attempt to complete email link sign-in on mount", () => { + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn); + const mockUI = createMockUI(); + + render( + + + + ); + + expect(completeEmailLinkSignInMock).toHaveBeenCalled(); + }); + + it("should call onSignIn when email link sign-in is completed successfully", async () => { + const mockCredential = { credential: true } as unknown as UserCredential; + const completeEmailLinkSignInMock = vi.mocked(completeEmailLinkSignIn).mockResolvedValue(mockCredential); + const onSignInMock = vi.fn(); + const mockUI = createMockUI(); + + render( + + + + ); + + await act(async () => { + // Wait for the useEffect to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(completeEmailLinkSignInMock).toHaveBeenCalledWith(mockUI.get(), window.location.href); + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); +}); diff --git a/packages/shadcn/src/registry/email-link-auth-form.tsx b/packages/shadcn/src/registry/email-link-auth-form.tsx new file mode 100644 index 00000000..20e28c68 --- /dev/null +++ b/packages/shadcn/src/registry/email-link-auth-form.tsx @@ -0,0 +1,82 @@ +"use client"; + +import type { EmailLinkAuthFormSchema } from "@firebase-ui/core"; +import { + useUI, + useEmailLinkAuthFormAction, + useEmailLinkAuthFormSchema, + useEmailLinkAuthFormCompleteSignIn, + type EmailLinkAuthFormProps, +} from "@firebase-ui/react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { FirebaseUIError, getTranslation } from "@firebase-ui/core"; +import { useState } from "react"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "./policies"; + +export type { EmailLinkAuthFormProps }; + +export function EmailLinkAuthForm(props: EmailLinkAuthFormProps) { + const { onEmailSent, onSignIn } = props; + const ui = useUI(); + const schema = useEmailLinkAuthFormSchema(); + const action = useEmailLinkAuthFormAction(); + const [emailSent, setEmailSent] = useState(false); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + }, + }); + + useEmailLinkAuthFormCompleteSignIn(onSignIn); + + async function onSubmit(values: EmailLinkAuthFormSchema) { + try { + await action(values); + setEmailSent(true); + onEmailSent?.(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + if (emailSent) { + return ( +
+
{getTranslation(ui, "messages", "signInLinkSent")}
+
+ ); + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} diff --git a/packages/shadcn/src/registry/email-link-auth-screen.test.tsx b/packages/shadcn/src/registry/email-link-auth-screen.test.tsx new file mode 100644 index 00000000..48879eca --- /dev/null +++ b/packages/shadcn/src/registry/email-link-auth-screen.test.tsx @@ -0,0 +1,147 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { EmailLinkAuthScreen } from "./email-link-auth-screen"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./email-link-auth-form", () => ({ + EmailLinkAuthForm: ({ onEmailSent, onSignIn }: any) => ( +
+
EmailLinkAuthForm
+ {onEmailSent &&
onEmailSent provided
} + {onSignIn &&
onSignIn provided
} +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + }); + + it("should render with children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + +
Child Component
+
+
+ ); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + expect(screen.getByText("or")).toBeInTheDocument(); + expect(screen.getByTestId("child-component")).toBeInTheDocument(); + }); + + it("should pass props to EmailLinkAuthForm", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + }), + }); + + const onEmailSentMock = vi.fn(); + const onSignInMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onEmailSent-prop")).toBeInTheDocument(); + expect(screen.getByTestId("onSignIn-prop")).toBeInTheDocument(); + }); + + it("should not render separator when no children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + signIn: "Sign In", + }, + prompts: { + signInToAccount: "Sign in to your account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Sign In")).toBeInTheDocument(); + expect(screen.getByText("Sign in to your account")).toBeInTheDocument(); + expect(screen.getByTestId("email-link-auth-form")).toBeInTheDocument(); + expect(screen.queryByText("or")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/registry/email-link-auth-screen.tsx b/packages/shadcn/src/registry/email-link-auth-screen.tsx new file mode 100644 index 00000000..2e2aa706 --- /dev/null +++ b/packages/shadcn/src/registry/email-link-auth-screen.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useUI, type EmailLinkAuthScreenProps } from "@firebase-ui/react"; +import { getTranslation } from "@firebase-ui/core"; + +import { EmailLinkAuthForm } from "@/registry/email-link-auth-form"; +import { Separator } from "@/components/ui/separator"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export type { EmailLinkAuthScreenProps }; + +export function EmailLinkAuthScreen({ children, ...props }: EmailLinkAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "signIn"); + const subtitleText = getTranslation(ui, "prompts", "signInToAccount"); + + return ( + + + {titleText} + {subtitleText} + + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
{children}
+ + ) : null} +
+
+ ); +} diff --git a/packages/shadcn/src/registry/facebook-sign-in-button.test.tsx b/packages/shadcn/src/registry/facebook-sign-in-button.test.tsx new file mode 100644 index 00000000..26b33f53 --- /dev/null +++ b/packages/shadcn/src/registry/facebook-sign-in-button.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { FacebookSignInButton } from "./facebook-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FacebookAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{children}
+
+ ), +})); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + FacebookLogo: ({ className, ...props }: any) => ( + + Facebook Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Facebook provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("facebook.com"); + expect(screen.getByTestId("facebook-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Facebook")).toBeInTheDocument(); + }); + + it("renders with custom Facebook provider", () => { + const customProvider = new FacebookAuthProvider(); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("facebook.com"); + expect(screen.getByTestId("facebook-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Facebook")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders Facebook logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const facebookLogo = screen.getByTestId("facebook-logo"); + expect(facebookLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Custom Facebook Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Facebook Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Facebook logo and the text + expect(childrenContainer.querySelector('[data-testid="facebook-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Facebook"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithFacebook: "Sign in with Facebook", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); +}); diff --git a/packages/shadcn/src/registry/facebook-sign-in-button.tsx b/packages/shadcn/src/registry/facebook-sign-in-button.tsx new file mode 100644 index 00000000..6708ab60 --- /dev/null +++ b/packages/shadcn/src/registry/facebook-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { FacebookAuthProvider } from "firebase/auth"; +import { getTranslation } from "@firebase-ui/core"; +import { useUI, type FacebookSignInButtonProps, FacebookLogo } from "@firebase-ui/react"; + +import { OAuthButton } from "@/registry/oauth-button"; + +export type { FacebookSignInButtonProps }; + +export function FacebookSignInButton({ provider, themed }: FacebookSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithFacebook")} + + ); +} diff --git a/packages/shadcn/src/registry/forgot-password-auth-form.test.tsx b/packages/shadcn/src/registry/forgot-password-auth-form.test.tsx new file mode 100644 index 00000000..b450c9b5 --- /dev/null +++ b/packages/shadcn/src/registry/forgot-password-auth-form.test.tsx @@ -0,0 +1,228 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { ForgotPasswordAuthForm } from "./forgot-password-auth-form"; +import { act } from "react"; +import { useForgotPasswordAuthFormAction } from "@firebase-ui/react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + sendPasswordResetEmail: vi.fn(), + }; +}); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useForgotPasswordAuthFormAction: vi.fn(), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should render with back to sign in callback", () => { + const onBackToSignInClickMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + backToSignIn: "backToSignIn", + }, + }), + }); + + const { container } = render( + + + + ); + + const button = container.querySelector("button[type='button']"); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("backToSignIn"); + + act(() => { + fireEvent.click(button!); + }); + + expect(onBackToSignInClickMock).toHaveBeenCalled(); + }); + + it("should call the onPasswordSent callback when the form is submitted successfully", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useForgotPasswordAuthFormAction).mockReturnValue(mockAction); + const onPasswordSentMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + resetPassword: "Reset Password", + }, + errors: { + invalidEmail: "Invalid email", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ email: "test@example.com" }); + expect(onPasswordSentMock).toHaveBeenCalled(); + }); + + it("should display error message when form submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("foo")); + + vi.mocked(useForgotPasswordAuthFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + resetPassword: "Reset Password", + }, + }), + }); + + const { container } = render( + + + + ); + + const emailInput = container.querySelector("input[name='email']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: foo")).toBeInTheDocument(); + }); + + it("should show success message after successful submission", async () => { + const mockAction = vi.fn().mockResolvedValue(undefined); + vi.mocked(useForgotPasswordAuthFormAction).mockReturnValue(mockAction); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + resetPassword: "Reset Password", + }, + messages: { + checkEmailForReset: "Check your email for reset instructions", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + const emailInput = container.querySelector("input[name='email']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(screen.getByText("Check your email for reset instructions")).toBeInTheDocument(); + }); + + // Form should no longer be visible + expect(container.querySelector("form")).not.toBeInTheDocument(); + }); + + it("should not show success message initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + messages: { + checkEmailForReset: "Check your email for reset instructions", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(screen.queryByText("Check your email for reset instructions")).not.toBeInTheDocument(); + expect(container.querySelector("form")).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/registry/forgot-password-auth-form.tsx b/packages/shadcn/src/registry/forgot-password-auth-form.tsx new file mode 100644 index 00000000..e050d262 --- /dev/null +++ b/packages/shadcn/src/registry/forgot-password-auth-form.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { ForgotPasswordAuthFormSchema } from "@firebase-ui/core"; +import { + useForgotPasswordAuthFormAction, + useForgotPasswordAuthFormSchema, + useUI, + type ForgotPasswordAuthFormProps, +} from "@firebase-ui/react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { FirebaseUIError, getTranslation } from "@firebase-ui/core"; +import { useState } from "react"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "./policies"; + +export type { ForgotPasswordAuthFormProps }; + +export function ForgotPasswordAuthForm(props: ForgotPasswordAuthFormProps) { + const ui = useUI(); + const schema = useForgotPasswordAuthFormSchema(); + const action = useForgotPasswordAuthFormAction(); + const [emailSent, setEmailSent] = useState(false); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + }, + }); + + async function onSubmit(values: ForgotPasswordAuthFormSchema) { + try { + await action(values); + setEmailSent(true); + props.onPasswordSent?.(); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + if (emailSent) { + return ( +
+
{getTranslation(ui, "messages", "checkEmailForReset")}
+
+ ); + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onBackToSignInClick ? ( + + ) : null} + + + ); +} diff --git a/packages/shadcn/src/registry/forgot-password-auth-screen.test.tsx b/packages/shadcn/src/registry/forgot-password-auth-screen.test.tsx new file mode 100644 index 00000000..fd65e746 --- /dev/null +++ b/packages/shadcn/src/registry/forgot-password-auth-screen.test.tsx @@ -0,0 +1,90 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { ForgotPasswordAuthScreen } from "./forgot-password-auth-screen"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./forgot-password-auth-form", () => ({ + ForgotPasswordAuthForm: ({ onPasswordSent, onBackToSignInClick }: any) => ( +
+
ForgotPasswordAuthForm
+ {onPasswordSent &&
onPasswordSent provided
} + {onBackToSignInClick &&
onBackToSignInClick provided
} +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "Reset Password", + }, + prompts: { + enterEmailToReset: "Enter your email to reset your password", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Reset Password")).toBeInTheDocument(); + expect(screen.getByText("Enter your email to reset your password")).toBeInTheDocument(); + expect(screen.getByTestId("forgot-password-auth-form")).toBeInTheDocument(); + }); + + it("should pass props to ForgotPasswordAuthForm", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + resetPassword: "Reset Password", + }, + prompts: { + enterEmailToReset: "Enter your email to reset your password", + }, + }), + }); + + const onPasswordSentMock = vi.fn(); + const onBackToSignInClickMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onPasswordSent-prop")).toBeInTheDocument(); + expect(screen.getByTestId("onBackToSignInClick-prop")).toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/registry/forgot-password-auth-screen.tsx b/packages/shadcn/src/registry/forgot-password-auth-screen.tsx new file mode 100644 index 00000000..3baffda5 --- /dev/null +++ b/packages/shadcn/src/registry/forgot-password-auth-screen.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useUI, type ForgotPasswordAuthScreenProps } from "@firebase-ui/react"; +import { getTranslation } from "@firebase-ui/core"; + +import { ForgotPasswordAuthForm } from "@/registry/forgot-password-auth-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export type { ForgotPasswordAuthScreenProps }; + +export function ForgotPasswordAuthScreen(props: ForgotPasswordAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "resetPassword"); + const subtitleText = getTranslation(ui, "prompts", "enterEmailToReset"); + + return ( + + + {titleText} + {subtitleText} + + + + + + ); +} diff --git a/packages/shadcn/src/registry/github-sign-in-button.test.tsx b/packages/shadcn/src/registry/github-sign-in-button.test.tsx new file mode 100644 index 00000000..49a7feac --- /dev/null +++ b/packages/shadcn/src/registry/github-sign-in-button.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { GitHubSignInButton } from "./github-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { GithubAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{children}
+
+ ), +})); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + GitHubLogo: ({ className, ...props }: any) => ( + + GitHub Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default GitHub provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("github.com"); + expect(screen.getByTestId("github-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with GitHub")).toBeInTheDocument(); + }); + + it("renders with custom GitHub provider", () => { + const customProvider = new GithubAuthProvider(); + customProvider.addScope("user:email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("github.com"); + expect(screen.getByTestId("github-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with GitHub")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders GitHub logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const githubLogo = screen.getByTestId("github-logo"); + expect(githubLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Custom GitHub Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom GitHub Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the GitHub logo and the text + expect(childrenContainer.querySelector('[data-testid="github-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with GitHub"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGitHub: "Sign in with GitHub", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); +}); diff --git a/packages/shadcn/src/registry/github-sign-in-button.tsx b/packages/shadcn/src/registry/github-sign-in-button.tsx new file mode 100644 index 00000000..80f70ca8 --- /dev/null +++ b/packages/shadcn/src/registry/github-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { GithubAuthProvider } from "firebase/auth"; +import { getTranslation } from "@firebase-ui/core"; +import { useUI, type GitHubSignInButtonProps, GitHubLogo } from "@firebase-ui/react"; + +import { OAuthButton } from "@/registry/oauth-button"; + +export type { GitHubSignInButtonProps }; + +export function GitHubSignInButton({ provider, themed }: GitHubSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGitHub")} + + ); +} diff --git a/packages/shadcn/src/registry/google-sign-in-button.test.tsx b/packages/shadcn/src/registry/google-sign-in-button.test.tsx new file mode 100644 index 00000000..1f6ec87b --- /dev/null +++ b/packages/shadcn/src/registry/google-sign-in-button.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { GoogleSignInButton } from "./google-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { GoogleAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed }: any) => ( +
+
{provider.providerId}
+
{themed}
+
{children}
+
+ ), +})); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + GoogleLogo: ({ className, ...props }: any) => ( + + Google Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Google provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("google.com"); + expect(screen.getByTestId("google-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); + }); + + it("renders with custom Google provider", () => { + const customProvider = new GoogleAuthProvider(); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("google.com"); + expect(screen.getByTestId("google-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("neutral"); + }); + + it("renders Google logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const googleLogo = screen.getByTestId("google-logo"); + expect(googleLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Custom Google Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Google Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Google logo and the text + expect(childrenContainer.querySelector('[data-testid="google-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Google"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithGoogle: "Sign in with Google", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent(""); + }); +}); diff --git a/packages/shadcn/src/registry/google-sign-in-button.tsx b/packages/shadcn/src/registry/google-sign-in-button.tsx new file mode 100644 index 00000000..7d5c213a --- /dev/null +++ b/packages/shadcn/src/registry/google-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { GoogleAuthProvider } from "firebase/auth"; +import { getTranslation } from "@firebase-ui/core"; +import { useUI, type GoogleSignInButtonProps, GoogleLogo } from "@firebase-ui/react"; + +import { OAuthButton } from "@/registry/oauth-button"; + +export type { GoogleSignInButtonProps }; + +export function GoogleSignInButton({ provider, themed }: GoogleSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithGoogle")} + + ); +} diff --git a/packages/shadcn/src/registry/microsoft-sign-in-button.test.tsx b/packages/shadcn/src/registry/microsoft-sign-in-button.test.tsx new file mode 100644 index 00000000..26bf2053 --- /dev/null +++ b/packages/shadcn/src/registry/microsoft-sign-in-button.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { MicrosoftSignInButton } from "./microsoft-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { OAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{children}
+
+ ), +})); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + MicrosoftLogo: ({ className, ...props }: any) => ( + + Microsoft Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Microsoft provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("microsoft.com"); + expect(screen.getByTestId("microsoft-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Microsoft")).toBeInTheDocument(); + }); + + it("renders with custom Microsoft provider", () => { + const customProvider = new OAuthProvider("microsoft.com"); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("microsoft.com"); + expect(screen.getByTestId("microsoft-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Microsoft")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders Microsoft logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const microsoftLogo = screen.getByTestId("microsoft-logo"); + expect(microsoftLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Custom Microsoft Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Microsoft Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Microsoft logo and the text + expect(childrenContainer.querySelector('[data-testid="microsoft-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Microsoft"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithMicrosoft: "Sign in with Microsoft", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); +}); diff --git a/packages/shadcn/src/registry/microsoft-sign-in-button.tsx b/packages/shadcn/src/registry/microsoft-sign-in-button.tsx new file mode 100644 index 00000000..69c46010 --- /dev/null +++ b/packages/shadcn/src/registry/microsoft-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { OAuthProvider } from "firebase/auth"; +import { getTranslation } from "@firebase-ui/core"; +import { useUI, type MicrosoftSignInButtonProps, MicrosoftLogo } from "@firebase-ui/react"; + +import { OAuthButton } from "@/registry/oauth-button"; + +export type { MicrosoftSignInButtonProps }; + +export function MicrosoftSignInButton({ provider, themed }: MicrosoftSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithMicrosoft")} + + ); +} diff --git a/packages/shadcn/src/registry/oauth-button.test.tsx b/packages/shadcn/src/registry/oauth-button.test.tsx new file mode 100644 index 00000000..978c9172 --- /dev/null +++ b/packages/shadcn/src/registry/oauth-button.test.tsx @@ -0,0 +1,258 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { OAuthButton } from "./oauth-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import type { AuthProvider, UserCredential } from "firebase/auth"; +import { ComponentProps } from "react"; + +import { signInWithProvider } from "@firebase-ui/core"; +import { FirebaseError } from "firebase/app"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...(mod as object), + signInWithProvider: vi.fn(), + }; +}); + +vi.mock("@/components/ui/button", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + Button: (props: ComponentProps<"button">) => , + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + const mockGoogleProvider = { providerId: "google.com" } as AuthProvider; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders a button with the provided children", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toBeDefined(); + expect(button.textContent).toBe("Sign in with Google"); + }); + + it("applies correct attributes", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button.getAttribute("type")).toBe("button"); + expect(button.getAttribute("data-provider")).toBe("google.com"); + }); + + it("applies themed attribute when provided", () => { + const ui = createMockUI(); + + render( + + + Sign in with Google + + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button.getAttribute("data-themed")).toBe("neutral"); + }); + + it("is disabled when UI state is not idle", () => { + const ui = createMockUI(); + ui.setKey("state", "pending"); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).toHaveAttribute("disabled"); + }); + + it("is enabled when UI state is idle", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + expect(button).not.toHaveAttribute("disabled"); + }); + + it("calls signInWithProvider when clicked", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + expect(mockSignInWithProvider).toHaveBeenCalledTimes(1); + expect(mockSignInWithProvider).toHaveBeenCalledWith(expect.anything(), mockGoogleProvider); + }); + + it("displays FirebaseUIError message when FirebaseUIError occurs", async () => { + const { FirebaseUIError } = await import("@firebase-ui/core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + const mockError = new FirebaseUIError( + ui.get(), + new FirebaseError("auth/user-not-found", "No account found with this email address") + ); + mockSignInWithProvider.mockRejectedValue(mockError); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + // Next tick - wait for the mock to resolve + await new Promise((resolve) => setTimeout(resolve, 0)); + + const errorMessage = screen.getByText("No account found with this email address"); + expect(errorMessage).toBeDefined(); + + // Make sure we use the shadcn theme name, rather than a "text-red-500" + expect(errorMessage.className).toContain("text-destructive"); + }); + + it("displays unknown error message when non-Firebase error occurs", async () => { + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const regularError = new Error("Regular error"); + mockSignInWithProvider.mockRejectedValue(regularError); + + // Mock console.error to prevent test output noise + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const ui = createMockUI({ + locale: registerLocale("test", { + errors: { + unknownError: "unknownError", + }, + }), + }); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + fireEvent.click(button); + + // Wait for error to appear + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consoleErrorSpy).toHaveBeenCalledWith(regularError); + + const errorMessage = screen.getByText("unknownError"); + expect(errorMessage).toBeDefined(); + + // Make sure we use the shadcn theme name, rather than a "text-red-500" + expect(errorMessage.className).toContain("text-destructive"); + + // Restore console.error + consoleErrorSpy.mockRestore(); + }); + + it("clears error when button is clicked again", async () => { + const { FirebaseUIError } = await import("@firebase-ui/core"); + const mockSignInWithProvider = vi.mocked(signInWithProvider); + const ui = createMockUI(); + + // First call fails, second call succeeds + mockSignInWithProvider + .mockRejectedValueOnce( + new FirebaseUIError(ui.get(), new FirebaseError("auth/wrong-password", "Incorrect password")) + ) + .mockResolvedValueOnce({} as UserCredential); + + render( + + Sign in with Google + + ); + + const button = screen.getByTestId("oauth-button"); + + // First click - should show error + fireEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const errorMessage = screen.getByText("Incorrect password"); + expect(errorMessage).toBeDefined(); + + // Second click - should clear error + fireEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(screen.queryByText("Incorrect password")).toBeNull(); + }); + + it("does not display error message initially", () => { + const ui = createMockUI(); + + render( + + Sign in with Google + + ); + + expect(screen.queryByText("No account found with this email address")).toBeNull(); + }); +}); diff --git a/packages/shadcn/src/registry/oauth-button.tsx b/packages/shadcn/src/registry/oauth-button.tsx new file mode 100644 index 00000000..dd5d3740 --- /dev/null +++ b/packages/shadcn/src/registry/oauth-button.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useUI, type OAuthButtonProps, useSignInWithProvider } from "@firebase-ui/react"; +import { Button } from "@/components/ui/button"; + +export type { OAuthButtonProps }; + +export function OAuthButton({ provider, children, themed }: OAuthButtonProps) { + const ui = useUI(); + + const { error, callback } = useSignInWithProvider(provider); + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/packages/shadcn/src/registry/phone-auth-form.test.tsx b/packages/shadcn/src/registry/phone-auth-form.test.tsx new file mode 100644 index 00000000..13e50465 --- /dev/null +++ b/packages/shadcn/src/registry/phone-auth-form.test.tsx @@ -0,0 +1,412 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { PhoneAuthForm } from "./phone-auth-form"; +import { act } from "react"; +import { usePhoneNumberFormAction, useVerifyPhoneNumberFormAction, useUI } from "@firebase-ui/react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "@firebase-ui/react"; +import { UserCredential } from "firebase/auth"; +import { FirebaseUIError } from "@firebase-ui/core"; +import { FirebaseError } from "firebase/app"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + verifyPhoneNumber: vi.fn(), + confirmPhoneNumber: vi.fn(), + formatPhoneNumber: vi.fn((phoneNumber, country) => `${country.dialCode}${phoneNumber}`), + getTranslation: vi.fn((_, category, key) => { + if (category === "labels" && key === "sendCode") return "Send Code"; + if (category === "labels" && key === "phoneNumber") return "Phone Number"; + if (category === "labels" && key === "verificationCode") return "Verification Code"; + if (category === "labels" && key === "verifyCode") return "Verify Code"; + return key; + }), + }; +}); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + usePhoneNumberFormAction: vi.fn(), + useVerifyPhoneNumberFormAction: vi.fn(), + useUI: vi.fn(), + usePhoneAuthNumberFormSchema: vi.fn().mockReturnValue({}), + usePhoneAuthVerifyFormSchema: vi.fn().mockReturnValue({}), + useRecaptchaVerifier: vi.fn().mockReturnValue({}), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +vi.mock("./country-selector", () => ({ + CountrySelector: vi.fn().mockImplementation(({ ref }) => { + const CountrySelectorComponent = React.forwardRef((_, forwardedRef) => { + React.useImperativeHandle(forwardedRef, () => ({ + getCountry: () => ({ + code: "US", + name: "United States", + dialCode: "+1", + emoji: "🇺🇸", + }), + })); + return
Country Selector
; + }); + CountrySelectorComponent.displayName = "CountrySelector"; + return ; + }), +})); + +import React from "react"; + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the phone number form initially", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='phoneNumber']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + expect(screen.getByTestId("country-selector")).toBeInTheDocument(); + expect(screen.getByTestId("policies")).toBeInTheDocument(); + }); + + it("should transition to verification form after phone number submission", async () => { + const mockVerificationId = "test-verification-id"; + const mockAction = vi.fn().mockResolvedValue(mockVerificationId); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + const { container } = render( + + + + ); + + // Initially should show phone number form + expect(container.querySelector("input[name='phoneNumber']")).toBeInTheDocument(); + expect(container.querySelector("input[name='verificationCode']")).not.toBeInTheDocument(); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(container.querySelector("input[name='verificationCode']")).toBeInTheDocument(); + expect(container.querySelector("input[name='phoneNumber']")).not.toBeInTheDocument(); + }); + + it("should call onSignIn callback when verification is successful", async () => { + const mockVerificationId = "test-verification-id"; + const mockCredential = { credential: true } as unknown as UserCredential; + const mockPhoneAction = vi.fn().mockResolvedValue(mockVerificationId); + const mockVerifyAction = vi.fn().mockResolvedValue(mockCredential); + + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockPhoneAction); + vi.mocked(useVerifyPhoneNumberFormAction).mockReturnValue(mockVerifyAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + const onSignInMock = vi.fn(); + + const { container } = render( + + + + ); + + // Submit phone number + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockPhoneAction).toHaveBeenCalled(); + }); + + // Submit verification code + const verificationInput = container.querySelector("input[name='verificationCode']")!; + const verifyButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(verificationInput, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.click(verifyButton); + }); + + await waitFor(() => { + expect(mockVerifyAction).toHaveBeenCalled(); + }); + + expect(onSignInMock).toHaveBeenCalledWith(mockCredential); + }); + + it("should display error message when phone number submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("Phone verification failed")); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const { container } = render( + + + + ); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: Phone verification failed")).toBeInTheDocument(); + }); + + it("should display error message when verification fails", async () => { + const mockVerificationId = "test-verification-id"; + const mockPhoneAction = vi.fn().mockResolvedValue(mockVerificationId); + const mockVerifyAction = vi.fn().mockRejectedValue(new Error("Invalid verification code")); + + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockPhoneAction); + vi.mocked(useVerifyPhoneNumberFormAction).mockReturnValue(mockVerifyAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + verificationCode: "Verification Code", + verifyCode: "Verify Code", + }, + }), + }); + + const { container } = render( + + + + ); + + // Submit phone number first + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockPhoneAction).toHaveBeenCalled(); + }); + + // Now submit verification code + const verificationInput = container.querySelector("input[name='verificationCode']")!; + const verifyButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(verificationInput, { target: { value: "123456" } }); + }); + + await act(async () => { + fireEvent.click(verifyButton); + }); + + expect(await screen.findByText("Error: Invalid verification code")).toBeInTheDocument(); + }); + + it("should handle FirebaseUIError with proper error message", async () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const firebaseError = new FirebaseUIError( + mockUI.get(), + new FirebaseError("auth/invalid-phone-number", "Invalid phone number format") + ); + const mockAction = vi.fn().mockRejectedValue(firebaseError); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const { container } = render( + + + + ); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "invalid" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: Invalid phone number format")).toBeInTheDocument(); + }); + + it("should disable submit button when UI state is not idle", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + // Set UI state to loading + mockUI.setKey("state", "pending"); + + const { container } = render( + + + + ); + + const submitButton = container.querySelector("button[type='submit']")!; + expect(submitButton).toBeDisabled(); + }); + + it("should format phone number with country code before submission", async () => { + const mockVerificationId = "test-verification-id"; + const mockAction = vi.fn().mockResolvedValue(mockVerificationId); + vi.mocked(usePhoneNumberFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + sendCode: "Send Code", + phoneNumber: "Phone Number", + }, + }), + }); + + const { container } = render( + + + + ); + + const phoneInput = container.querySelector("input[name='phoneNumber']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + act(() => { + fireEvent.change(phoneInput, { target: { value: "1234567890" } }); + }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + // Should be called with formatted phone number + expect(mockAction).toHaveBeenCalledWith({ + phoneNumber: "+11234567890", // formatted with country code + recaptchaVerifier: expect.any(Object), + }); + }); +}); diff --git a/packages/shadcn/src/registry/phone-auth-form.tsx b/packages/shadcn/src/registry/phone-auth-form.tsx new file mode 100644 index 00000000..a13b284b --- /dev/null +++ b/packages/shadcn/src/registry/phone-auth-form.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { + CountrySelector, + type PhoneAuthFormProps, + usePhoneAuthNumberFormSchema, + usePhoneAuthVerifyFormSchema, + usePhoneNumberFormAction, + useRecaptchaVerifier, + useUI, + useVerifyPhoneNumberFormAction, +} from "@firebase-ui/react"; +import { useState } from "react"; +import type { UserCredential } from "firebase/auth"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { + FirebaseUIError, + formatPhoneNumber, + getTranslation, + type PhoneAuthNumberFormSchema, + type PhoneAuthVerifyFormSchema, +} from "@firebase-ui/core"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "@/registry/policies"; +import { type CountrySelectorRef } from "@/registry/country-selector"; + +type VerifyPhoneNumberFormProps = { + verificationId: string; + onSuccess: (credential: UserCredential) => void; +}; + +function VerifyPhoneNumberForm(props: VerifyPhoneNumberFormProps) { + const ui = useUI(); + const schema = usePhoneAuthVerifyFormSchema(); + const action = useVerifyPhoneNumberFormAction(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + verificationId: props.verificationId, + verificationCode: "", + }, + }); + + async function onSubmit(values: PhoneAuthVerifyFormSchema) { + try { + const credential = await action(values); + props.onSuccess(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "verificationCode")} + + + + + + )} + /> + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +type PhoneNumberFormProps = { + onSubmit: (verificationId: string) => void; +}; + +function PhoneNumberForm(props: PhoneNumberFormProps) { + const ui = useUI(); + const recaptchaContainerRef = useRef(null); + const recaptchaVerifier = useRecaptchaVerifier(recaptchaContainerRef); + const countrySelector = useRef(null); + const action = usePhoneNumberFormAction(); + const schema = usePhoneAuthNumberFormSchema(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + phoneNumber: "", + }, + }); + + async function onSubmit(values: PhoneAuthNumberFormSchema) { + try { + const formatted = formatPhoneNumber(values.phoneNumber, countrySelector.current!.getCountry()); + const verificationId = await action({ phoneNumber: formatted, recaptchaVerifier: recaptchaVerifier! }); + props.onSubmit(verificationId); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "phoneNumber")} + +
+ + +
+
+ +
+ )} + /> +
+ + + {form.formState.errors.root && {form.formState.errors.root.message}} + + + ); +} + +export type { PhoneAuthFormProps }; + +export function PhoneAuthForm(props: PhoneAuthFormProps) { + const [verificationId, setVerificationId] = useState(null); + + if (!verificationId) { + return ; + } + + return ( + { + props.onSignIn?.(credential); + }} + /> + ); +} diff --git a/packages/shadcn/src/registry/policies.test.tsx b/packages/shadcn/src/registry/policies.test.tsx index a315aaeb..80536072 100644 --- a/packages/shadcn/src/registry/policies.test.tsx +++ b/packages/shadcn/src/registry/policies.test.tsx @@ -29,14 +29,6 @@ vi.mock("@firebase-ui/core", async (importOriginal) => { }; }); -vi.mock("@firebase-ui/react", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - useSignInAuthFormAction: vi.fn(), - }; -}); - describe("", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/shadcn/src/registry/sign-in-auth-form.tsx b/packages/shadcn/src/registry/sign-in-auth-form.tsx index 7a46476b..93c2bef6 100644 --- a/packages/shadcn/src/registry/sign-in-auth-form.tsx +++ b/packages/shadcn/src/registry/sign-in-auth-form.tsx @@ -1,7 +1,7 @@ "use client"; import type { SignInAuthFormSchema } from "@firebase-ui/core"; -import { useSignInAuthFormAction, useSignInAuthFormSchema, useUI, SignInAuthFormProps } from "@firebase-ui/react"; +import { useSignInAuthFormAction, useSignInAuthFormSchema, useUI, type SignInAuthFormProps } from "@firebase-ui/react"; import { useForm } from "react-hook-form"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { FirebaseUIError, getTranslation } from "@firebase-ui/core"; diff --git a/packages/shadcn/src/registry/sign-in-auth-screen.test.tsx b/packages/shadcn/src/registry/sign-in-auth-screen.test.tsx index d4ebd62e..2bfd6a68 100644 --- a/packages/shadcn/src/registry/sign-in-auth-screen.test.tsx +++ b/packages/shadcn/src/registry/sign-in-auth-screen.test.tsx @@ -21,20 +21,16 @@ import { createMockUI, createFirebaseUIProvider } from "../../tests/utils"; import { registerLocale } from "@firebase-ui/translations"; import { FirebaseUIProvider } from "@firebase-ui/react"; -vi.mock("./sign-in-auth-form", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - SignInAuthForm: (props: any) => { - const OriginalForm = mod.SignInAuthForm; - return ( -
- -
- ); - }, - }; -}); +vi.mock("./sign-in-auth-form", () => ({ + SignInAuthForm: ({ onSignIn, onForgotPasswordClick, onRegisterClick }: any) => ( +
+
SignInAuthForm
+ {onSignIn &&
onSignIn provided
} + {onForgotPasswordClick &&
onForgotPasswordClick provided
} + {onRegisterClick &&
onRegisterClick provided
} +
+ ), +})); vi.mock("@/components/ui/card", () => ({ Card: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/packages/shadcn/src/registry/sign-in-auth-screen.tsx b/packages/shadcn/src/registry/sign-in-auth-screen.tsx index 17050859..99357ba5 100644 --- a/packages/shadcn/src/registry/sign-in-auth-screen.tsx +++ b/packages/shadcn/src/registry/sign-in-auth-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useUI, SignInAuthScreenProps } from "@firebase-ui/react"; +import { useUI, type SignInAuthScreenProps } from "@firebase-ui/react"; import { getTranslation } from "@firebase-ui/core"; import { SignInAuthForm } from "@/registry/sign-in-auth-form"; diff --git a/packages/shadcn/src/registry/sign-up-auth-form.test.tsx b/packages/shadcn/src/registry/sign-up-auth-form.test.tsx new file mode 100644 index 00000000..48de2579 --- /dev/null +++ b/packages/shadcn/src/registry/sign-up-auth-form.test.tsx @@ -0,0 +1,298 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { SignUpAuthForm } from "./sign-up-auth-form"; +import { act } from "react"; +import { useSignUpAuthFormAction, useRequireDisplayName } from "@firebase-ui/react"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "@firebase-ui/react"; +import { UserCredential } from "firebase/auth"; + +vi.mock("@firebase-ui/core", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + createUserWithEmailAndPassword: vi.fn(), + }; +}); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useSignUpAuthFormAction: vi.fn(), + useRequireDisplayName: vi.fn(), + }; +}); + +vi.mock("./policies", () => ({ + Policies: () =>
Policies
, +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the form correctly", () => { + const mockUI = createMockUI(); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("input[name='password']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should render with back to sign in callback", () => { + const onBackToSignInClickMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + prompts: { + haveAccount: "haveAccount", + }, + labels: { + signIn: "signIn", + }, + }), + }); + + const { container } = render( + + + + ); + + const button = container.querySelector("button[type='button']"); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent("haveAccount signIn"); + + act(() => { + fireEvent.click(button!); + }); + + expect(onBackToSignInClickMock).toHaveBeenCalled(); + }); + + it("should call the onSignUp callback when the form is submitted", async () => { + const mockAction = vi.fn().mockResolvedValue({} as unknown as UserCredential); + vi.mocked(useSignUpAuthFormAction).mockReturnValue(mockAction); + const onSignUpMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + createAccount: "Create Account", + }, + errors: { + invalidEmail: "Invalid email", + weakPassword: "Password too weak", + }, + }), + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + const passwordInput = container.querySelector("input[name='password']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput!, { target: { value: "password123" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + displayName: undefined, + }); + expect(onSignUpMock).toHaveBeenCalled(); + }); + + it("should display error message when form submission fails", async () => { + const mockAction = vi.fn().mockRejectedValue(new Error("foo")); + + vi.mocked(useSignUpAuthFormAction).mockReturnValue(mockAction); + + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + createAccount: "Create Account", + }, + }), + }); + + const { container } = render( + + + + ); + + const emailInput = container.querySelector("input[name='email']")!; + const passwordInput = container.querySelector("input[name='password']")!; + const submitButton = container.querySelector("button[type='submit']")!; + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput, { target: { value: "somepassword" } }); + + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(await screen.findByText("Error: foo")).toBeInTheDocument(); + }); + + it("should render displayName field when requireDisplayName is true", () => { + vi.mocked(useRequireDisplayName).mockReturnValue(true); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + displayName: "Display Name", + createAccount: "Create Account", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("input[name='password']")).toBeInTheDocument(); + expect(container.querySelector("input[name='displayName']")).toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should not render displayName field when requireDisplayName is false", () => { + vi.mocked(useRequireDisplayName).mockReturnValue(false); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + displayName: "Display Name", + createAccount: "Create Account", + }, + }), + behaviors: [], // Explicitly set empty behaviors array + }); + + const { container } = render( + + + + ); + + expect(container.querySelector("input[name='email']")).toBeInTheDocument(); + expect(container.querySelector("input[name='password']")).toBeInTheDocument(); + expect(container.querySelector("input[name='displayName']")).not.toBeInTheDocument(); + expect(container.querySelector("button[type='submit']")).toBeInTheDocument(); + }); + + it("should call the onSignUp callback with displayName when requireDisplayName is true", async () => { + vi.mocked(useRequireDisplayName).mockReturnValue(true); + const mockAction = vi.fn().mockResolvedValue({} as unknown as UserCredential); + vi.mocked(useSignUpAuthFormAction).mockReturnValue(mockAction); + const onSignUpMock = vi.fn(); + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + emailAddress: "Email Address", + password: "Password", + displayName: "Display Name", + createAccount: "Create Account", + }, + }), + behaviors: [ + { + requireDisplayName: { type: "callable" as const, handler: vi.fn() }, + }, + ], + }); + + const { container } = render( + + + + ); + + const form = container.querySelector("form"); + expect(form).toBeInTheDocument(); + + const emailInput = container.querySelector("input[name='email']"); + const passwordInput = container.querySelector("input[name='password']"); + const displayNameInput = container.querySelector("input[name='displayName']"); + + act(() => { + fireEvent.change(emailInput!, { target: { value: "test@example.com" } }); + fireEvent.change(passwordInput!, { target: { value: "password123" } }); + fireEvent.change(displayNameInput!, { target: { value: "John Doe" } }); + }); + + await act(async () => { + fireEvent.submit(form!); + }); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalled(); + }); + + expect(mockAction).toHaveBeenCalledWith({ + email: "test@example.com", + password: "password123", + displayName: "John Doe", + }); + expect(onSignUpMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/shadcn/src/registry/sign-up-auth-form.tsx b/packages/shadcn/src/registry/sign-up-auth-form.tsx new file mode 100644 index 00000000..dc8ea78c --- /dev/null +++ b/packages/shadcn/src/registry/sign-up-auth-form.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { SignUpAuthFormSchema } from "@firebase-ui/core"; +import { + useSignUpAuthFormAction, + useSignUpAuthFormSchema, + useUI, + type SignUpAuthFormProps, + useRequireDisplayName, +} from "@firebase-ui/react"; +import { useForm } from "react-hook-form"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; +import { FirebaseUIError, getTranslation } from "@firebase-ui/core"; + +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Policies } from "./policies"; + +export type { SignUpAuthFormProps }; + +export function SignUpAuthForm(props: SignUpAuthFormProps) { + const ui = useUI(); + const schema = useSignUpAuthFormSchema(); + const action = useSignUpAuthFormAction(); + const requireDisplayName = useRequireDisplayName(); + + const form = useForm({ + resolver: standardSchemaResolver(schema), + defaultValues: { + email: "", + password: "", + displayName: requireDisplayName ? "" : undefined, + }, + }); + + async function onSubmit(values: SignUpAuthFormSchema) { + try { + const credential = await action(values); + props.onSignUp?.(credential); + } catch (error) { + const message = error instanceof FirebaseUIError ? error.message : String(error); + form.setError("root", { message }); + } + } + + return ( +
+ + ( + + {getTranslation(ui, "labels", "emailAddress")} + + + + + + )} + /> + ( + + {getTranslation(ui, "labels", "password")} + + + + + + )} + /> + {requireDisplayName ? ( + ( + + {getTranslation(ui, "labels", "displayName")} + + + + + + )} + /> + ) : null} + + + {form.formState.errors.root && {form.formState.errors.root.message}} + {props.onBackToSignInClick ? ( + + ) : null} + + + ); +} diff --git a/packages/shadcn/src/registry/sign-up-auth-screen.test.tsx b/packages/shadcn/src/registry/sign-up-auth-screen.test.tsx new file mode 100644 index 00000000..1df34a84 --- /dev/null +++ b/packages/shadcn/src/registry/sign-up-auth-screen.test.tsx @@ -0,0 +1,147 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { SignUpAuthScreen } from "./sign-up-auth-screen"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./sign-up-auth-form", () => ({ + SignUpAuthForm: ({ onSignUp, onBackToSignInClick }: any) => ( +
+
SignUpAuthForm
+ {onSignUp &&
onSignUp provided
} + {onBackToSignInClick &&
onBackToSignInClick provided
} +
+ ), +})); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render the screen correctly", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + register: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Register")).toBeInTheDocument(); + expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + expect(screen.getByTestId("sign-up-auth-form")).toBeInTheDocument(); + }); + + it("should render with children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + register: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + +
Child Component
+
+
+ ); + + expect(screen.getByText("Register")).toBeInTheDocument(); + expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + expect(screen.getByTestId("sign-up-auth-form")).toBeInTheDocument(); + expect(screen.getByText("or")).toBeInTheDocument(); + expect(screen.getByTestId("child-component")).toBeInTheDocument(); + }); + + it("should pass props to SignUpAuthForm", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + register: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + }), + }); + + const onSignUpMock = vi.fn(); + const onBackToSignInClickMock = vi.fn(); + + render( + + + + ); + + expect(screen.getByTestId("onSignUp-prop")).toBeInTheDocument(); + expect(screen.getByTestId("onBackToSignInClick-prop")).toBeInTheDocument(); + }); + + it("should not render separator when no children", () => { + const mockUI = createMockUI({ + locale: registerLocale("test", { + labels: { + register: "Register", + }, + prompts: { + enterDetailsToCreate: "Enter your details to create an account", + }, + messages: { + dividerOr: "or", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Register")).toBeInTheDocument(); + expect(screen.getByText("Enter your details to create an account")).toBeInTheDocument(); + expect(screen.getByTestId("sign-up-auth-form")).toBeInTheDocument(); + expect(screen.queryByText("or")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shadcn/src/registry/sign-up-auth-screen.tsx b/packages/shadcn/src/registry/sign-up-auth-screen.tsx new file mode 100644 index 00000000..29c57bd6 --- /dev/null +++ b/packages/shadcn/src/registry/sign-up-auth-screen.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useUI, type SignUpAuthScreenProps } from "@firebase-ui/react"; +import { getTranslation } from "@firebase-ui/core"; + +import { SignUpAuthForm } from "@/registry/sign-up-auth-form"; +import { Separator } from "@/components/ui/separator"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export type { SignUpAuthScreenProps }; + +export function SignUpAuthScreen({ children, ...props }: SignUpAuthScreenProps) { + const ui = useUI(); + + const titleText = getTranslation(ui, "labels", "register"); + const subtitleText = getTranslation(ui, "prompts", "enterDetailsToCreate"); + + return ( + + + {titleText} + {subtitleText} + + + + {children ? ( + <> + {getTranslation(ui, "messages", "dividerOr")} +
{children}
+ + ) : null} +
+
+ ); +} diff --git a/packages/shadcn/src/registry/twitter-sign-in-button.test.tsx b/packages/shadcn/src/registry/twitter-sign-in-button.test.tsx new file mode 100644 index 00000000..ad175ea5 --- /dev/null +++ b/packages/shadcn/src/registry/twitter-sign-in-button.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { TwitterSignInButton } from "./twitter-sign-in-button"; +import { createMockUI } from "../../tests/utils"; +import { registerLocale } from "@firebase-ui/translations"; +import { TwitterAuthProvider } from "firebase/auth"; +import { FirebaseUIProvider } from "@firebase-ui/react"; + +vi.mock("./oauth-button", () => ({ + OAuthButton: ({ provider, children, themed }: any) => ( +
+
{provider.providerId}
+
{String(themed)}
+
{children}
+
+ ), +})); + +vi.mock("@firebase-ui/react", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + TwitterLogo: ({ className, ...props }: any) => ( + + Twitter Logo + + ), + }; +}); + +afterEach(() => { + cleanup(); +}); + +describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with default Twitter provider", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("twitter.com"); + expect(screen.getByTestId("twitter-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Twitter")).toBeInTheDocument(); + }); + + it("renders with custom Twitter provider", () => { + const customProvider = new TwitterAuthProvider(); + customProvider.addScope("email"); + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("oauth-button")).toBeInTheDocument(); + expect(screen.getByTestId("provider-id")).toHaveTextContent("twitter.com"); + expect(screen.getByTestId("twitter-logo")).toBeInTheDocument(); + expect(screen.getByText("Sign in with Twitter")).toBeInTheDocument(); + }); + + it("passes themed prop to OAuthButton", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).toHaveTextContent("true"); + }); + + it("renders Twitter logo with correct props", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const twitterLogo = screen.getByTestId("twitter-logo"); + expect(twitterLogo).toBeInTheDocument(); + }); + + it("uses correct translation for button text", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Custom Twitter Sign In Text", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByText("Custom Twitter Sign In Text")).toBeInTheDocument(); + }); + + it("renders children correctly", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + const childrenContainer = screen.getByTestId("children"); + expect(childrenContainer).toBeInTheDocument(); + + // Should contain both the Twitter logo and the text + expect(childrenContainer.querySelector('[data-testid="twitter-logo"]')).toBeInTheDocument(); + expect(childrenContainer).toHaveTextContent("Sign in with Twitter"); + }); + + it("handles missing themed prop", () => { + const ui = createMockUI({ + locale: registerLocale("test", { + labels: { + signInWithTwitter: "Sign in with Twitter", + }, + }), + }); + + render( + + + + ); + + expect(screen.getByTestId("themed")).not.toHaveTextContent("true"); + }); +}); diff --git a/packages/shadcn/src/registry/twitter-sign-in-button.tsx b/packages/shadcn/src/registry/twitter-sign-in-button.tsx new file mode 100644 index 00000000..8f42bbb4 --- /dev/null +++ b/packages/shadcn/src/registry/twitter-sign-in-button.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { TwitterAuthProvider } from "firebase/auth"; +import { getTranslation } from "@firebase-ui/core"; +import { useUI, type TwitterSignInButtonProps, TwitterLogo } from "@firebase-ui/react"; + +import { OAuthButton } from "@/registry/oauth-button"; + +export type { TwitterSignInButtonProps }; + +export function TwitterSignInButton({ provider, themed }: TwitterSignInButtonProps) { + const ui = useUI(); + + return ( + + + {getTranslation(ui, "labels", "signInWithTwitter")} + + ); +} diff --git a/packages/shadcn/tests/utils.tsx b/packages/shadcn/tests/utils.tsx index e830937c..12ee0438 100644 --- a/packages/shadcn/tests/utils.tsx +++ b/packages/shadcn/tests/utils.tsx @@ -2,9 +2,10 @@ import type { FirebaseApp } from "firebase/app"; import type { Auth } from "firebase/auth"; import { enUs } from "@firebase-ui/translations"; import { FirebaseUIProvider } from "@firebase-ui/react"; -import { Behavior, FirebaseUI, FirebaseUIConfigurationOptions, initializeUI } from "@firebase-ui/core"; +import { Behavior, FirebaseUIOptions, initializeUI } from "@firebase-ui/core"; +import { FirebaseUIStore } from "@firebase-ui/core"; -export function createMockUI(overrides?: Partial): FirebaseUI { +export function createMockUI(overrides?: Partial) { return initializeUI({ app: {} as FirebaseApp, auth: {} as Auth, @@ -14,10 +15,10 @@ export function createMockUI(overrides?: Partial }); } -export const createFirebaseUIProvider = ({ children, ui }: { children: React.ReactNode; ui: FirebaseUI }) => ( +export const createFirebaseUIProvider = ({ children, ui }: { children: React.ReactNode; ui: FirebaseUIStore }) => ( {children} ); -export function CreateFirebaseUIProvider({ children, ui }: { children: React.ReactNode; ui: FirebaseUI }) { +export function CreateFirebaseUIProvider({ children, ui }: { children: React.ReactNode; ui: FirebaseUIStore }) { return {children}; } diff --git a/packages/shadcn/tsconfig.json b/packages/shadcn/tsconfig.json index af5f0564..2a150020 100644 --- a/packages/shadcn/tsconfig.json +++ b/packages/shadcn/tsconfig.json @@ -7,7 +7,9 @@ "baseUrl": ".", "paths": { "@/*": ["./src/*"], - "@/tests/*": ["./tests/*"] + "@/tests/*": ["./tests/*"], + "@firebase-ui/core": ["../core/src/index.ts"], + "@firebase-ui/react": ["../react/src/index.ts"] } }, "include": ["src", "tests", "vite.config.ts", "setup-test.ts"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70b85d08..da6b879a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,7 @@ catalogs: version: 7.8.2 tailwindcss: specifier: ^4.1.13 - version: 4.1.13 + version: 4.1.14 tsup: specifier: ^8.5.0 version: 8.5.0 @@ -470,7 +470,7 @@ importers: version: 5.0.4(vite@7.1.5(@types/node@24.3.1)(jiti@2.6.1)(less@4.4.1)(lightningcss@1.30.1)(sass@1.92.1)(terser@5.43.1)(tsx@4.20.6)) tailwindcss: specifier: 'catalog:' - version: 4.1.13 + version: 4.1.14 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -726,6 +726,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -792,7 +795,7 @@ importers: version: 2.9.3-canary.0(@types/node@24.3.1)(typescript@5.9.2) tailwindcss: specifier: 'catalog:' - version: 4.1.13 + version: 4.1.14 tsx: specifier: ^4.20.6 version: 4.20.6 @@ -826,7 +829,7 @@ importers: version: 6.0.1 tailwindcss: specifier: 'catalog:' - version: 4.1.13 + version: 4.1.14 tsup: specifier: 'catalog:' version: 8.5.0(@microsoft/api-extractor@7.52.12(@types/node@24.3.1))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.2) @@ -2299,6 +2302,21 @@ packages: '@firebase/webchannel-wrapper@1.0.3': resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@grpc/grpc-js@1.9.15': resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==} engines: {node: ^8.13.0 || >=10.10.0} @@ -3216,6 +3234,38 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -3225,6 +3275,68 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-label@2.1.7': resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} peerDependencies: @@ -3238,6 +3350,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -3251,6 +3389,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.7': resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} peerDependencies: @@ -3273,6 +3424,94 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/binding-android-arm64@1.0.0-beta.32': resolution: {integrity: sha512-Gs+313LfR4Ka3hvifdag9r44WrdKQaohya7ZXUXzARF7yx0atzFlVZjsvxtKAw1Vmtr4hB/RjUD1jf73SW7zDw==} cpu: [arm64] @@ -4548,6 +4787,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -5245,6 +5488,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -5852,6 +6098,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-own-enumerable-keys@1.0.0: resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} engines: {node: '>=14.16'} @@ -7750,6 +8000,26 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-router@7.9.3: resolution: {integrity: sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==} engines: {node: '>=20.0.0'} @@ -7760,6 +8030,16 @@ packages: react-dom: optional: true + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.1.1: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} @@ -8799,6 +9079,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.5.0: resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} peerDependencies: @@ -9395,13 +9695,13 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.0(@angular/compiler-cli@20.3.0(@angular/compiler@20.3.0)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)) + '@ngtools/webpack': 20.3.0(@angular/compiler-cli@20.3.0(@angular/compiler@20.3.0)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) - babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)) + babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) browserslist: 4.25.4 - copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9)) - css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9)) + copy-webpack-plugin: 13.0.1(webpack@5.101.2) + css-loader: 7.1.2(webpack@5.101.2) esbuild-wasm: 0.25.9 fast-glob: 3.3.3 http-proxy-middleware: 3.0.5 @@ -9409,22 +9709,22 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.4.0 - less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)) - license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9)) + less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2) + license-webpack-plugin: 4.0.2(webpack@5.101.2) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9)) + mini-css-extract-plugin: 2.9.4(webpack@5.101.2) open: 10.2.0 ora: 8.2.0 picomatch: 4.0.3 piscina: 5.1.3 postcss: 8.5.6 - postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)) + postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2) resolve-url-loader: 5.0.0 rxjs: 7.8.2 sass: 1.90.0 - sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)) + sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2) semver: 7.7.2 - source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9)) + source-map-loader: 5.0.0(webpack@5.101.2) source-map-support: 0.5.21 terser: 5.43.1 tree-kill: 1.2.2 @@ -9434,7 +9734,7 @@ snapshots: webpack-dev-middleware: 7.4.2(webpack@5.101.2(esbuild@0.25.9)) webpack-dev-server: 5.2.2(webpack@5.101.2(esbuild@0.25.9)) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9)) + webpack-subresource-integrity: 5.1.0(webpack@5.101.2) optionalDependencies: '@angular/core': 20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.0(@angular/animations@20.3.2(@angular/core@20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.0(@angular/core@20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1)) @@ -9485,13 +9785,13 @@ snapshots: '@babel/preset-env': 7.28.3(@babel/core@7.28.3) '@babel/runtime': 7.28.3 '@discoveryjs/json-ext': 0.6.3 - '@ngtools/webpack': 20.3.0(@angular/compiler-cli@20.3.0(@angular/compiler@20.3.0)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)) + '@ngtools/webpack': 20.3.0(@angular/compiler-cli@20.3.0(@angular/compiler@20.3.0)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2) ansi-colors: 4.1.3 autoprefixer: 10.4.21(postcss@8.5.6) - babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)) + babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2) browserslist: 4.25.4 - copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9)) - css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9)) + copy-webpack-plugin: 13.0.1(webpack@5.101.2) + css-loader: 7.1.2(webpack@5.101.2) esbuild-wasm: 0.25.9 fast-glob: 3.3.3 http-proxy-middleware: 3.0.5 @@ -9499,22 +9799,22 @@ snapshots: jsonc-parser: 3.3.1 karma-source-map-support: 1.4.0 less: 4.4.0 - less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)) - license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9)) + less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2) + license-webpack-plugin: 4.0.2(webpack@5.101.2) loader-utils: 3.3.1 - mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9)) + mini-css-extract-plugin: 2.9.4(webpack@5.101.2) open: 10.2.0 ora: 8.2.0 picomatch: 4.0.3 piscina: 5.1.3 postcss: 8.5.6 - postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)) + postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2) resolve-url-loader: 5.0.0 rxjs: 7.8.2 sass: 1.90.0 - sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)) + sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2) semver: 7.7.2 - source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9)) + source-map-loader: 5.0.0(webpack@5.101.2) source-map-support: 0.5.21 terser: 5.43.1 tree-kill: 1.2.2 @@ -9524,7 +9824,7 @@ snapshots: webpack-dev-middleware: 7.4.2(webpack@5.101.2(esbuild@0.25.9)) webpack-dev-server: 5.2.2(webpack@5.101.2(esbuild@0.25.9)) webpack-merge: 6.0.1 - webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9)) + webpack-subresource-integrity: 5.1.0(webpack@5.101.2) optionalDependencies: '@angular/core': 20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1) '@angular/platform-browser': 20.3.0(@angular/animations@20.3.2(@angular/core@20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.0(@angular/core@20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.0(@angular/compiler@20.3.0)(rxjs@7.8.2)(zone.js@0.15.1)) @@ -11271,6 +11571,23 @@ snapshots: '@firebase/webchannel-wrapper@1.0.3': {} + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + + '@floating-ui/utils@0.2.10': {} + '@grpc/grpc-js@1.9.15': dependencies: '@grpc/proto-loader': 0.7.15 @@ -12169,7 +12486,7 @@ snapshots: '@next/swc-win32-x64-msvc@15.1.7': optional: true - '@ngtools/webpack@20.3.0(@angular/compiler-cli@20.3.0(@angular/compiler@20.3.0)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9))': + '@ngtools/webpack@20.3.0(@angular/compiler-cli@20.3.0(@angular/compiler@20.3.0)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2)': dependencies: '@angular/compiler-cli': 20.3.0(@angular/compiler@20.3.0)(typescript@5.9.2) typescript: 5.9.2 @@ -12352,12 +12669,86 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.16)(react@19.1.1)': dependencies: react: 19.1.1 optionalDependencies: '@types/react': 19.1.16 + '@radix-ui/react-context@1.1.2(@types/react@19.1.16)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-direction@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.1.16)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + + '@radix-ui/react-id@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -12367,6 +12758,34 @@ snapshots: '@types/react': 19.1.16 '@types/react-dom': 19.1.9(@types/react@19.1.16) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/rect': 1.1.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.16)(react@19.1.1) @@ -12376,6 +12795,35 @@ snapshots: '@types/react': 19.1.16 '@types/react-dom': 19.1.9(@types/react@19.1.16) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + aria-hidden: 1.2.6 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-remove-scroll: 2.7.1(@types/react@19.1.16)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -12392,6 +12840,71 @@ snapshots: optionalDependencies: '@types/react': 19.1.16 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.16)(react@19.1.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.16)(react@19.1.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.16)(react@19.1.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.1.16)(react@19.1.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.16)(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.16 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.16))(@types/react@19.1.16)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + '@types/react-dom': 19.1.9(@types/react@19.1.16) + + '@radix-ui/rect@1.1.1': {} + '@rolldown/binding-android-arm64@1.0.0-beta.32': optional: true @@ -13766,6 +14279,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -13890,7 +14407,7 @@ snapshots: transitivePeerDependencies: - supports-color - babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)): + babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2): dependencies: '@babel/core': 7.28.3 find-up: 5.0.0 @@ -14320,7 +14837,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@13.0.1(webpack@5.101.2(esbuild@0.25.9)): + copy-webpack-plugin@13.0.1(webpack@5.101.2): dependencies: glob-parent: 6.0.2 normalize-path: 3.0.0 @@ -14366,7 +14883,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@7.1.2(webpack@5.101.2(esbuild@0.25.9)): + css-loader@7.1.2(webpack@5.101.2): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -14512,6 +15029,8 @@ snapshots: detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} + detect-node@2.1.0: {} devalue@5.3.2: {} @@ -15398,6 +15917,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-own-enumerable-keys@1.0.0: {} get-package-type@0.1.0: {} @@ -16499,7 +17020,7 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.3 - less-loader@12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)): + less-loader@12.3.0(less@4.4.0)(webpack@5.101.2): dependencies: less: 4.4.0 optionalDependencies: @@ -16542,7 +17063,7 @@ snapshots: libphonenumber-js@1.12.23: {} - license-webpack-plugin@4.0.2(webpack@5.101.2(esbuild@0.25.9)): + license-webpack-plugin@4.0.2(webpack@5.101.2): dependencies: webpack-sources: 3.3.3 optionalDependencies: @@ -16819,7 +17340,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.9.4(webpack@5.101.2(esbuild@0.25.9)): + mini-css-extract-plugin@2.9.4(webpack@5.101.2): dependencies: schema-utils: 4.3.2 tapable: 2.2.3 @@ -17502,7 +18023,7 @@ snapshots: postcss: 8.5.6 tsx: 4.20.6 - postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)): + postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2): dependencies: cosmiconfig: 9.0.0(typescript@5.9.2) jiti: 1.21.7 @@ -17677,6 +18198,25 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.1.16)(react@19.1.1): + dependencies: + react: 19.1.1 + react-style-singleton: 2.2.3(@types/react@19.1.16)(react@19.1.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.16 + + react-remove-scroll@2.7.1(@types/react@19.1.16)(react@19.1.1): + dependencies: + react: 19.1.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.1.16)(react@19.1.1) + react-style-singleton: 2.2.3(@types/react@19.1.16)(react@19.1.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.1.16)(react@19.1.1) + use-sidecar: 1.1.3(@types/react@19.1.16)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.16 + react-router@7.9.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: cookie: 1.0.2 @@ -17685,6 +18225,14 @@ snapshots: optionalDependencies: react-dom: 19.1.1(react@19.1.1) + react-style-singleton@2.2.3(@types/react@19.1.16)(react@19.1.1): + dependencies: + get-nonce: 1.0.1 + react: 19.1.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.16 + react@19.1.1: {} readable-stream@2.3.8: @@ -17944,7 +18492,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)): + sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -18304,7 +18852,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.101.2(esbuild@0.25.9)): + source-map-loader@5.0.0(webpack@5.101.2): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -18969,6 +19517,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.1.16)(react@19.1.1): + dependencies: + react: 19.1.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.16 + + use-sidecar@1.1.3(@types/react@19.1.16)(react@19.1.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.1.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.16 + use-sync-external-store@1.5.0(react@19.1.1): dependencies: react: 19.1.1 @@ -19371,7 +19934,7 @@ snapshots: webpack-sources@3.3.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.101.2(esbuild@0.25.9)): + webpack-subresource-integrity@5.1.0(webpack@5.101.2): dependencies: typed-assert: 1.0.9 webpack: 5.101.2(esbuild@0.25.9)