diff --git a/.changeset/wicked-friends-exist.md b/.changeset/wicked-friends-exist.md new file mode 100644 index 00000000000..7a7af6634a2 --- /dev/null +++ b/.changeset/wicked-friends-exist.md @@ -0,0 +1,42 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/react-router': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +'@clerk/astro': minor +'@clerk/react': minor +'@clerk/expo': minor +'@clerk/nuxt': minor +'@clerk/vue': minor +'@clerk/ui': minor +--- + +Introduce `` component which allows you to specify a custom container for Clerk floating UI elements (popovers, modals, tooltips, etc.) that use portals. Only Clerk components within the provider will be affected, components outside the provider will continue to use the default document.body for portals. + +This is particularly useful when using Clerk components inside external UI libraries like [Radix Dialog](https://www.radix-ui.com/primitives/docs/components/dialog) or [React Aria Components](https://react-spectrum.adobe.com/react-aria/components.html), where portaled elements need to render within the dialog's container to remain interact-able. + +```tsx +'use client'; + +import { useRef } from 'react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { UNSAFE_PortalProvider, UserButton } from '@clerk/nextjs'; + +export function UserDialog() { + const containerRef = useRef(null); + + return ( + + Open Dialog + + + + containerRef.current}> + + + + + + ); +} +``` diff --git a/packages/astro/src/react/index.ts b/packages/astro/src/react/index.ts index c16086cc435..eb5f40d788c 100644 --- a/packages/astro/src/react/index.ts +++ b/packages/astro/src/react/index.ts @@ -8,6 +8,7 @@ import { SubscriptionDetailsButton, type SubscriptionDetailsButtonProps } from ' export * from './uiComponents'; export * from './controlComponents'; export * from './hooks'; +export { UNSAFE_PortalProvider } from '@clerk/shared/react'; export { SignInButton, SignOutButton, SignUpButton }; export { SubscriptionDetailsButton as __experimental_SubscriptionDetailsButton, diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts index 8974c7371d6..42372a05c14 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -13,6 +13,7 @@ export { getClerkInstance } from './provider/singleton'; export * from './provider/ClerkProvider'; export * from './hooks'; export * from './components'; +export { UNSAFE_PortalProvider } from '@clerk/react'; // Override Clerk React error thrower to show that errors come from @clerk/expo setErrorThrowerOptions({ packageName: PACKAGE_NAME }); diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts index 544c2e10145..9949c35ef16 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -12,6 +12,7 @@ export { RedirectToSignUp, RedirectToTasks, RedirectToUserProfile, + UNSAFE_PortalProvider, Show, } from '@clerk/react'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index c4123f6729c..65992dd42f1 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -8,6 +8,7 @@ export { ClerkFailed, ClerkLoaded, ClerkLoading, + UNSAFE_PortalProvider, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, diff --git a/packages/nuxt/src/runtime/components/index.ts b/packages/nuxt/src/runtime/components/index.ts index 5d4cf17560a..60a99d69241 100644 --- a/packages/nuxt/src/runtime/components/index.ts +++ b/packages/nuxt/src/runtime/components/index.ts @@ -23,4 +23,5 @@ export { SignOutButton, SignInWithMetamaskButton, PricingTable, + UNSAFE_PortalProvider, } from '@clerk/vue'; diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index e8f2ed3c6c2..44fcb014fe2 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,6 +44,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "TaskChooseOrganization", "TaskResetPassword", + "UNSAFE_PortalProvider", "UserAvatar", "UserButton", "UserProfile", diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index e6e89242bfb..a63e39894f7 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -1,3 +1,4 @@ export * from './ReactRouterClerkProvider'; export type { WithClerkState } from './types'; export { SignIn, SignUp, OrganizationProfile, UserProfile } from './uiComponents'; +export { UNSAFE_PortalProvider } from '@clerk/react'; diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx index 70ace96b3af..b8eee8d44bc 100644 --- a/packages/react/src/components/withClerk.tsx +++ b/packages/react/src/components/withClerk.tsx @@ -1,3 +1,4 @@ +import { usePortalRoot } from '@clerk/shared/react'; import type { LoadedClerk, Without } from '@clerk/shared/types'; import React from 'react'; @@ -19,6 +20,7 @@ export const withClerk =

( useAssertWrappedByClerkProvider(displayName || 'withClerk'); const clerk = useIsomorphicClerkContext(); + const getContainer = usePortalRoot(); if (!clerk.loaded && !options?.renderWhileLoading) { return null; @@ -26,6 +28,7 @@ export const withClerk =

( return ( HTMLElement | null; +}>; + +const [PortalContext, , usePortalContextWithoutGuarantee] = createContextAndHook<{ + getContainer: () => HTMLElement | null; +}>('PortalProvider'); + +/** + * UNSAFE_PortalProvider allows you to specify a custom container for Clerk floating UI elements + * (popovers, modals, tooltips, etc.) that use portals. + * + * Only components within this provider will be affected. Components outside the provider + * will continue to use the default document.body for portals. + * + * This is particularly useful when using Clerk components inside external UI libraries + * like Radix Dialog or React Aria Components, where portaled elements need to render + * within the dialog's container to remain interactable. + * + * @example + * ```tsx + * function Example() { + * const containerRef = useRef(null); + * return ( + * + * containerRef.current}> + * + * + * + * ); + * } + * ``` + */ +export const UNSAFE_PortalProvider = ({ children, getContainer }: PortalProviderProps) => { + const contextValue = React.useMemo(() => ({ value: { getContainer } }), [getContainer]); + + return {children}; +}; + +/** + * Hook to get the current portal root container. + * Returns the getContainer function from context if inside a PortalProvider, + * otherwise returns a function that returns null (default behavior). + */ +export const usePortalRoot = (): (() => HTMLElement | null) => { + const contextValue = usePortalContextWithoutGuarantee(); + + if (contextValue && 'getContainer' in contextValue && contextValue.getContainer) { + return contextValue.getContainer; + } + + // Return a function that returns null when not inside a PortalProvider + return () => null; +}; diff --git a/packages/shared/src/react/__tests__/PortalProvider.test.tsx b/packages/shared/src/react/__tests__/PortalProvider.test.tsx new file mode 100644 index 00000000000..25a4a61e424 --- /dev/null +++ b/packages/shared/src/react/__tests__/PortalProvider.test.tsx @@ -0,0 +1,103 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { UNSAFE_PortalProvider, usePortalRoot } from '../PortalProvider'; + +describe('UNSAFE_PortalProvider', () => { + it('provides getContainer to children via context', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return

{portalRoot === getContainer ? 'found' : 'not-found'}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('test').textContent).toBe('found'); + }); + + it('only affects components within the provider', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const InsideComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === container ? 'container' : 'null'}
; + }; + + const OutsideComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === null ? 'null' : 'container'}
; + }; + + render( + <> + + + + + , + ); + + expect(screen.getByTestId('inside').textContent).toBe('container'); + expect(screen.getByTestId('outside').textContent).toBe('null'); + }); +}); + +describe('usePortalRoot', () => { + it('returns getContainer from context when inside PortalProvider', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === container ? 'found' : 'not-found'}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('test').textContent).toBe('found'); + }); + + it('returns a function that returns null when outside PortalProvider', () => { + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === null ? 'null' : 'not-null'}
; + }; + + render(); + + expect(screen.getByTestId('test').textContent).toBe('null'); + }); + + it('supports nested providers with innermost taking precedence', () => { + const outerContainer = document.createElement('div'); + const innerContainer = document.createElement('div'); + + const TestComponent = () => { + const portalRoot = usePortalRoot(); + return
{portalRoot() === innerContainer ? 'inner' : 'outer'}
; + }; + + render( + outerContainer}> + innerContainer}> + + + , + ); + + expect(screen.getByTestId('test').textContent).toBe('inner'); + }); +}); diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index b05c94db6bd..b865b1602d4 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -20,3 +20,5 @@ export { } from './contexts'; export * from './billing/payment-element'; + +export { UNSAFE_PortalProvider, usePortalRoot } from './PortalProvider'; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 33b15d3bfe8..9d3f7945f38 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1440,9 +1440,22 @@ export interface TransferableOption { transferable?: boolean; } -export type SignInModalProps = WithoutRouting; +export type SignInModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type __internal_UserVerificationProps = RoutingOptions & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; /** * Non-awaitable callback for when verification is completed successfully */ @@ -1584,7 +1597,14 @@ export type SignUpProps = RoutingOptions & { SignInForceRedirectUrl & AfterSignOutUrl; -export type SignUpModalProps = WithoutRouting; +export type SignUpModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type UserProfileProps = RoutingOptions & { /** @@ -1626,7 +1646,14 @@ export type UserProfileProps = RoutingOptions & { }; }; -export type UserProfileModalProps = WithoutRouting; +export type UserProfileModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type OrganizationProfileProps = RoutingOptions & { /** @@ -1669,7 +1696,14 @@ export type OrganizationProfileProps = RoutingOptions & { }; }; -export type OrganizationProfileModalProps = WithoutRouting; +export type OrganizationProfileModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; export type CreateOrganizationProps = RoutingOptions & { /** @@ -1695,7 +1729,14 @@ export type CreateOrganizationProps = RoutingOptions & { appearance?: ClerkAppearanceTheme; }; -export type CreateOrganizationModalProps = WithoutRouting; +export type CreateOrganizationModalProps = WithoutRouting & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; type UserProfileMode = 'modal' | 'navigation'; type UserButtonProfileMode = @@ -1918,7 +1959,14 @@ export type WaitlistProps = { signInUrl?: string; }; -export type WaitlistModalProps = WaitlistProps; +export type WaitlistModalProps = WaitlistProps & { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Radix Dialog, React Aria Components) instead of document.body. + */ + getContainer?: () => HTMLElement | null; +}; type PricingTableDefaultProps = { /** diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 21c0511015a..38ecdeb2e26 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -49,6 +49,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignUpButton", "TaskChooseOrganization", "TaskResetPassword", + "UNSAFE_PortalProvider", "UserAvatar", "UserButton", "UserProfile", diff --git a/packages/tanstack-react-start/src/client/index.ts b/packages/tanstack-react-start/src/client/index.ts index 09ce51e6a72..edd5e28a1b1 100644 --- a/packages/tanstack-react-start/src/client/index.ts +++ b/packages/tanstack-react-start/src/client/index.ts @@ -1,2 +1,3 @@ export * from './ClerkProvider'; export { SignIn, SignUp, OrganizationProfile, OrganizationList, UserProfile } from './uiComponents'; +export { UNSAFE_PortalProvider } from '@clerk/react'; diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 6aa64ab7a8a..e58549fa822 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -1,10 +1,10 @@ { "files": [ - { "path": "./dist/ui.browser.js", "maxSize": "19.5KB" }, - { "path": "./dist/ui.legacy.browser.js", "maxSize": "54KB" }, + { "path": "./dist/ui.browser.js", "maxSize": "34KB" }, + { "path": "./dist/ui.legacy.browser.js", "maxSize": "71KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "128KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "118KB" }, { "path": "./dist/signin*.js", "maxSize": "16KB" }, { "path": "./dist/signup*.js", "maxSize": "11KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx index 719187bfadd..73bcdaaa4fa 100644 --- a/packages/ui/src/Components.tsx +++ b/packages/ui/src/Components.tsx @@ -466,6 +466,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('signIn')} onExternalNavigate={() => componentsControls.closeModal('signIn')} startPath={buildVirtualRouterUrl({ base: '/sign-in', path: urlStateParam?.path })} + getContainer={signInModal?.getContainer} componentName={'SignInModal'} > @@ -483,6 +484,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('signUp')} onExternalNavigate={() => componentsControls.closeModal('signUp')} startPath={buildVirtualRouterUrl({ base: '/sign-up', path: urlStateParam?.path })} + getContainer={signUpModal?.getContainer} componentName={'SignUpModal'} > @@ -503,6 +505,7 @@ const Components = (props: ComponentsProps) => { base: '/user', path: userProfileModal?.__experimental_startPath || urlStateParam?.path, })} + getContainer={userProfileModal?.getContainer} componentName={'UserProfileModal'} modalContainerSx={{ alignItems: 'center' }} modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} @@ -520,6 +523,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('userVerification')} onExternalNavigate={() => componentsControls.closeModal('userVerification')} startPath={buildVirtualRouterUrl({ base: '/user-verification', path: urlStateParam?.path })} + getContainer={userVerificationModal?.getContainer} componentName={'UserVerificationModal'} modalContainerSx={{ alignItems: 'center' }} > @@ -539,6 +543,7 @@ const Components = (props: ComponentsProps) => { base: '/organizationProfile', path: organizationProfileModal?.__experimental_startPath || urlStateParam?.path, })} + getContainer={organizationProfileModal?.getContainer} componentName={'OrganizationProfileModal'} modalContainerSx={{ alignItems: 'center' }} modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} @@ -556,6 +561,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('createOrganization')} onExternalNavigate={() => componentsControls.closeModal('createOrganization')} startPath={buildVirtualRouterUrl({ base: '/createOrganization', path: urlStateParam?.path })} + getContainer={createOrganizationModal?.getContainer} componentName={'CreateOrganizationModal'} modalContainerSx={{ alignItems: 'center' }} modalContentSx={t => ({ height: `min(${t.sizes.$120}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} @@ -573,6 +579,7 @@ const Components = (props: ComponentsProps) => { onClose={() => componentsControls.closeModal('waitlist')} onExternalNavigate={() => componentsControls.closeModal('waitlist')} startPath={buildVirtualRouterUrl({ base: '/waitlist', path: urlStateParam?.path })} + getContainer={waitlistModal?.getContainer} componentName={'WaitlistModal'} > diff --git a/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx new file mode 100644 index 00000000000..9a73acdcda4 --- /dev/null +++ b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import React from 'react'; +import { render } from '@testing-library/react'; + +import { UNSAFE_PortalProvider } from '@clerk/react'; + +import { APIKeyModal } from '../APIKeyModal'; + +describe('APIKeyModal modalRoot behavior', () => { + it('renders modal inside modalRoot when provided', () => { + const modalRoot = React.createRef(); + const container = document.createElement('div'); + modalRoot.current = container; + document.body.appendChild(container); + + const { container: testContainer } = render( + {}} + handleClose={() => {}} + canCloseModal={true} + > +
Test Content
+
, + ); + + // The modal should render inside the modalRoot container, not document.body + // We can verify this by checking that the modal content is within the container + expect(container.querySelector('[data-testid="modal-content"]')).toBeInTheDocument(); + + document.body.removeChild(container); + }); + + it('applies scoped portal container styles when modalRoot provided', () => { + const modalRoot = React.createRef(); + const container = document.createElement('div'); + modalRoot.current = container; + document.body.appendChild(container); + + const { container: testContainer } = render( + {}} + handleClose={() => {}} + canCloseModal={true} + > +
Test
+
, + ); + + // The modal should have scoped styles (position: absolute) when modalRoot is provided + const modalElement = container.querySelector('[data-clerk-element="modalBackdrop"]'); + expect(modalElement).toBeTruthy(); + + document.body.removeChild(container); + }); + + it('modalRoot takes precedence over PortalProvider context', () => { + const modalRoot = React.createRef(); + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + modalRoot.current = container1; + document.body.appendChild(container1); + document.body.appendChild(container2); + + const getContainer = () => container2; + + const { container: testContainer } = render( + + {}} + handleClose={() => {}} + canCloseModal={true} + > +
Test Content
+
+
, + ); + + // The modal should render in container1 (modalRoot), not container2 (PortalProvider) + expect(container1.querySelector('[data-testid="modal-content"]')).toBeInTheDocument(); + expect(container2.querySelector('[data-testid="modal-content"]')).not.toBeInTheDocument(); + + document.body.removeChild(container1); + document.body.removeChild(container2); + }); +}); diff --git a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 886df9d9ef0..40f39578b47 100644 --- a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -1,4 +1,4 @@ -import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/shared/react'; +import { useClerk, useOrganization, useOrganizationList, usePortalRoot, useUser } from '@clerk/shared/react'; import type { OrganizationResource } from '@clerk/shared/types'; import React from 'react'; @@ -25,6 +25,7 @@ export const OrganizationSwitcherPopover = React.forwardRef { @@ -88,6 +89,7 @@ export const OrganizationSwitcherPopover = React.forwardRef { ); }); }); + + describe('OrganizationSwitcher with PortalProvider', () => { + it('passes getContainer to openOrganizationProfile', async () => { + const container = document.createElement('div'); + const getContainer = () => container; + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button')); + const manageButton = await waitFor(() => getByRole('menuitem', { name: /manage organization/i })); + await userEvent.click(manageButton); + + expect(fixtures.clerk.openOrganizationProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + }); + + it('passes getContainer to openCreateOrganization', async () => { + const container = document.createElement('div'); + const getContainer = () => container; + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: 'Open organization switcher' })); + const createButton = await waitFor(() => getByRole('menuitem', { name: 'Create organization' })); + await userEvent.click(createButton); + + expect(fixtures.clerk.openCreateOrganization).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + }); + }); }); diff --git a/packages/ui/src/components/PricingTable/PricingTable.tsx b/packages/ui/src/components/PricingTable/PricingTable.tsx index be84d597b71..9ced0c884ff 100644 --- a/packages/ui/src/components/PricingTable/PricingTable.tsx +++ b/packages/ui/src/components/PricingTable/PricingTable.tsx @@ -1,4 +1,4 @@ -import { useClerk } from '@clerk/shared/react'; +import { useClerk, usePortalRoot } from '@clerk/shared/react'; import type { BillingPlanResource, BillingSubscriptionPlanPeriod, PricingTableProps } from '@clerk/shared/types'; import { useEffect, useMemo, useState } from 'react'; @@ -10,6 +10,7 @@ import { PricingTableMatrix } from './PricingTableMatrix'; const PricingTableRoot = (props: PricingTableProps) => { const clerk = useClerk(); + const getContainer = usePortalRoot(); const { mode = 'mounted', signInMode = 'redirect' } = usePricingTableContext(); const isCompact = mode === 'modal'; const { data: subscription, subscriptionItems } = useSubscription(); @@ -52,7 +53,7 @@ const PricingTableRoot = (props: PricingTableProps) => { const selectPlan = (plan: BillingPlanResource, event?: React.MouseEvent) => { if (!clerk.isSignedIn) { if (signInMode === 'modal') { - return clerk.openSignIn(); + return clerk.openSignIn({ getContainer }); } return clerk.redirectToSignIn(); } diff --git a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx index cc5c292751f..64ed3ac95e3 100644 --- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx @@ -1,4 +1,7 @@ import { describe, expect, it } from 'vitest'; +import React from 'react'; + +import { UNSAFE_PortalProvider } from '@clerk/react'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, waitFor } from '@/test/utils'; @@ -84,6 +87,33 @@ describe('UserButton', () => { it.todo('navigates to sign in url when "Add account" is clicked'); + describe('UserButton with PortalProvider', () => { + it('passes getContainer to openUserProfile when wrapped in PortalProvider', async () => { + const container = document.createElement('div'); + const getContainer = () => container; + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ + first_name: 'First', + last_name: 'Last', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + + const { getByText, getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: 'Open user menu' })); + await userEvent.click(getByText('Manage account')); + + expect(fixtures.clerk.openUserProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + }); + }); + describe('Multi Session Popover', () => { const initConfig = createFixtures.config(f => { f.withMultiSessionMode(); diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx index 709fd29ecce..8b045f99f7a 100644 --- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx +++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx @@ -1,6 +1,6 @@ import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; -import { useClerk } from '@clerk/shared/react'; +import { useClerk, usePortalRoot } from '@clerk/shared/react'; import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types'; import { useEnvironment } from '@/ui/contexts'; @@ -27,6 +27,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user }); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); + const getContainer = usePortalRoot(); const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => { if (otherSessions.length === 0) { @@ -46,7 +47,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { })(); }); } - openUserProfile(opts.userProfileProps); + openUserProfile({ getContainer, ...opts.userProfileProps }); return opts.actionCompleteCallback?.(); }; @@ -60,6 +61,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { }); } openUserProfile({ + getContainer, ...opts.userProfileProps, ...(__experimental_startPath && { __experimental_startPath }), }); diff --git a/packages/ui/src/elements/Drawer.tsx b/packages/ui/src/elements/Drawer.tsx index 29fb30caa35..317ce0ca0f3 100644 --- a/packages/ui/src/elements/Drawer.tsx +++ b/packages/ui/src/elements/Drawer.tsx @@ -1,4 +1,4 @@ -import { useSafeLayoutEffect } from '@clerk/shared/react/index'; +import { usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react/index'; import type { UseDismissProps, UseFloatingOptions, UseRoleProps } from '@floating-ui/react'; import { FloatingFocusManager, @@ -88,6 +88,8 @@ function Root({ dismissProps, }: RootProps) { const direction = useDirection(); + const portalRoot = usePortalRoot(); + const effectivePortalRoot = portalProps?.root ?? portalRoot?.() ?? undefined; const { refs, context } = useFloating({ open, @@ -110,14 +112,19 @@ function Root({ isOpen: open, setIsOpen: onOpenChange, strategy, - portalProps: portalProps || {}, + portalProps: { ...portalProps, root: effectivePortalRoot }, refs, context, getFloatingProps, direction, }} > - {children} + + {children} + ); } diff --git a/packages/ui/src/elements/Menu.tsx b/packages/ui/src/elements/Menu.tsx index 0788ffdda6a..0579758d082 100644 --- a/packages/ui/src/elements/Menu.tsx +++ b/packages/ui/src/elements/Menu.tsx @@ -199,6 +199,7 @@ export const MenuItem = (props: MenuItemProps) => { justifyContent: 'start', borderRadius: theme.radii.$sm, padding: `${theme.space.$1} ${theme.space.$3}`, + whiteSpace: 'nowrap', }), sx, ]} diff --git a/packages/ui/src/elements/Modal.tsx b/packages/ui/src/elements/Modal.tsx index 46934f2a5d8..588db619795 100644 --- a/packages/ui/src/elements/Modal.tsx +++ b/packages/ui/src/elements/Modal.tsx @@ -1,4 +1,4 @@ -import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react'; +import { createContextAndHook, usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react'; import React, { useRef } from 'react'; import { descriptors, Flex } from '../customizables'; @@ -27,6 +27,7 @@ export const Modal = withFloatingTree((props: ModalProps) => { const { disableScrollLock, enableScrollLock } = useScrollLock(); const { handleClose, handleOpen, contentSx, containerSx, canCloseModal, id, style, portalRoot, initialFocusRef } = props; + const portalRootFromContext = usePortalRoot(); const overlayRef = useRef(null); const { floating, isOpen, context, nodeId, toggle } = usePopover({ defaultOpen: true, @@ -52,13 +53,15 @@ export const Modal = withFloatingTree((props: ModalProps) => { }; }, []); + const effectivePortalRoot = portalRoot ?? portalRootFromContext?.() ?? undefined; + return ( diff --git a/packages/ui/src/elements/Popover.tsx b/packages/ui/src/elements/Popover.tsx index 826adc7860f..770ca00a9dc 100644 --- a/packages/ui/src/elements/Popover.tsx +++ b/packages/ui/src/elements/Popover.tsx @@ -1,3 +1,4 @@ +import { usePortalRoot } from '@clerk/shared/react'; import type { FloatingContext, ReferenceType } from '@floating-ui/react'; import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react'; import type { PropsWithChildren } from 'react'; @@ -35,10 +36,13 @@ export const Popover = (props: PopoverProps) => { children, } = props; + const portalRoot = usePortalRoot(); + const effectiveRoot = root ?? portalRoot?.() ?? undefined; + if (portal) { return ( - + {isOpen && ( (function TooltipContent({ style, text, sx, ...props }, propRef) { const context = useTooltipContext(); const ref = useMergeRefs([context.refs.setFloating, propRef]); + const portalRoot = usePortalRoot(); + const effectiveRoot = portalRoot?.() ?? undefined; if (!context.isMounted) { return null; } return ( - + { appearanceKey={props.appearanceKey} appearance={props.componentAppearance} > - - > - } - props={props.componentProps} - componentName={props.componentName} - /> + + + > + } + props={props.componentProps} + componentName={props.componentName} + /> + ); }; @@ -113,6 +116,7 @@ type LazyModalRendererProps = React.PropsWithChildren< canCloseModal?: boolean; modalId?: string; modalStyle?: React.CSSProperties; + getContainer: () => HTMLElement | null; } & AppearanceProviderProps >; @@ -126,27 +130,29 @@ export const LazyModalRenderer = (props: LazyModalRendererProps) => { > - - {props.startPath ? ( - - - {props.children} - - - ) : ( - props.children - )} - + + + {props.startPath ? ( + + + {props.children} + + + ) : ( + props.children + )} + + diff --git a/packages/vue/src/components/ClerkHostRenderer.ts b/packages/vue/src/components/ClerkHostRenderer.ts index e3e9f692837..a24afaf52cf 100644 --- a/packages/vue/src/components/ClerkHostRenderer.ts +++ b/packages/vue/src/components/ClerkHostRenderer.ts @@ -1,6 +1,7 @@ import type { PropType } from 'vue'; import { defineComponent, h, onUnmounted, ref, watch, watchEffect } from 'vue'; +import { usePortalRoot } from '../composables/usePortalRoot'; import type { CustomPortalsRendererProps } from '../types'; import { ClerkLoaded } from './controlComponents'; @@ -44,6 +45,7 @@ export const ClerkHostRenderer = defineComponent({ }, setup(props) { const portalRef = ref(null); + const getContainer = usePortalRoot(); let isPortalMounted = false; watchEffect(() => { @@ -52,11 +54,16 @@ export const ClerkHostRenderer = defineComponent({ return; } + const propsWithContainer = { + ...props.props, + getContainer, + }; + if (props.mount) { - props.mount(portalRef.value, props.props); + props.mount(portalRef.value, propsWithContainer); } if (props.open) { - props.open(props.props); + props.open(propsWithContainer); } isPortalMounted = true; }); @@ -65,7 +72,11 @@ export const ClerkHostRenderer = defineComponent({ () => props.props, newProps => { if (isPortalMounted && props.updateProps && portalRef.value) { - props.updateProps({ node: portalRef.value, props: newProps }); + const propsWithContainer = { + ...newProps, + getContainer, + }; + props.updateProps({ node: portalRef.value, props: propsWithContainer }); } }, { deep: true }, diff --git a/packages/vue/src/components/PortalProvider.ts b/packages/vue/src/components/PortalProvider.ts new file mode 100644 index 00000000000..ec49c81c856 --- /dev/null +++ b/packages/vue/src/components/PortalProvider.ts @@ -0,0 +1,52 @@ +import { defineComponent, type PropType, provide } from 'vue'; + +import { PortalInjectionKey } from '../keys'; + +/** + * UNSAFE_PortalProvider allows you to specify a custom container for Clerk floating UI elements + * (popovers, modals, tooltips, etc.) that use portals. + * + * Only components within this provider will be affected. Components outside the provider + * will continue to use the default document.body for portals. + * + * This is particularly useful when using Clerk components inside external UI libraries + * like Reka UI Dialog, where portaled elements need to render within the dialog's + * container to remain interactable. + * + * @example + * ```vue + * + * + * + * ``` + */ +export const UNSAFE_PortalProvider = defineComponent({ + name: 'UNSAFE_PortalProvider', + props: { + /** + * Function that returns the container element where portals should be rendered. + * This allows Clerk components to render inside external dialogs/popovers + * (e.g., Reka UI Dialog) instead of document.body. + */ + getContainer: { + type: Function as PropType<() => HTMLElement | null>, + required: true, + }, + }, + setup(props, { slots }) { + provide(PortalInjectionKey, { getContainer: props.getContainer }); + return () => slots.default?.(); + }, +}); diff --git a/packages/vue/src/components/index.ts b/packages/vue/src/components/index.ts index 2aaa15af860..ff0eda5f804 100644 --- a/packages/vue/src/components/index.ts +++ b/packages/vue/src/components/index.ts @@ -28,3 +28,4 @@ export { default as SignInButton } from './SignInButton.vue'; export { default as SignUpButton } from './SignUpButton.vue'; export { default as SignOutButton } from './SignOutButton.vue'; export { default as SignInWithMetamaskButton } from './SignInWithMetamaskButton.vue'; +export { UNSAFE_PortalProvider } from './PortalProvider'; diff --git a/packages/vue/src/composables/__tests__/usePortalRoot.test.ts b/packages/vue/src/composables/__tests__/usePortalRoot.test.ts new file mode 100644 index 00000000000..47f7d601a2e --- /dev/null +++ b/packages/vue/src/composables/__tests__/usePortalRoot.test.ts @@ -0,0 +1,100 @@ +import { render } from '@testing-library/vue'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, h } from 'vue'; + +import { UNSAFE_PortalProvider } from '../../components/PortalProvider'; +import { usePortalRoot } from '../usePortalRoot'; + +describe('usePortalRoot', () => { + it('returns getContainer from context when inside PortalProvider', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const TestComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'test' }, portalRoot() === container ? 'found' : 'not-found'); + }, + }); + + const { getByTestId } = render(h(UNSAFE_PortalProvider, { getContainer }, () => h(TestComponent))); + + expect(getByTestId('test').textContent).toBe('found'); + }); + + it('returns a function that returns null when outside PortalProvider', () => { + const TestComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'test' }, portalRoot() === null ? 'null' : 'not-null'); + }, + }); + + const { getByTestId } = render(TestComponent); + + expect(getByTestId('test').textContent).toBe('null'); + }); + + it('only affects components within the provider', () => { + const container = document.createElement('div'); + const getContainer = () => container; + + const InsideComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'inside' }, portalRoot() === container ? 'container' : 'null'); + }, + }); + + const OutsideComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'outside' }, portalRoot() === null ? 'null' : 'container'); + }, + }); + + const { getByTestId } = render({ + components: { InsideComponent, OutsideComponent, UNSAFE_PortalProvider }, + template: ` + + + + + `, + setup() { + return { getContainer }; + }, + }); + + expect(getByTestId('inside').textContent).toBe('container'); + expect(getByTestId('outside').textContent).toBe('null'); + }); + + it('supports nested providers with innermost taking precedence', () => { + const outerContainer = document.createElement('div'); + const innerContainer = document.createElement('div'); + + const TestComponent = defineComponent({ + setup() { + const portalRoot = usePortalRoot(); + return () => h('div', { 'data-testid': 'test' }, portalRoot() === innerContainer ? 'inner' : 'outer'); + }, + }); + + const { getByTestId } = render({ + components: { TestComponent, UNSAFE_PortalProvider }, + template: ` + + + + + + `, + setup() { + return { outerContainer, innerContainer }; + }, + }); + + expect(getByTestId('test').textContent).toBe('inner'); + }); +}); diff --git a/packages/vue/src/composables/index.ts b/packages/vue/src/composables/index.ts index 9ca30fcc06c..7adac21c765 100644 --- a/packages/vue/src/composables/index.ts +++ b/packages/vue/src/composables/index.ts @@ -13,3 +13,5 @@ export { useSignUp } from './useSignUp'; export { useSessionList } from './useSessionList'; export { useOrganization } from './useOrganization'; + +export { usePortalRoot } from './usePortalRoot'; diff --git a/packages/vue/src/composables/usePortalRoot.ts b/packages/vue/src/composables/usePortalRoot.ts new file mode 100644 index 00000000000..03adf0cf453 --- /dev/null +++ b/packages/vue/src/composables/usePortalRoot.ts @@ -0,0 +1,19 @@ +import { inject } from 'vue'; + +import { PortalInjectionKey } from '../keys'; + +/** + * Composable to get the current portal root container. + * Returns the getContainer function from context if inside a PortalProvider, + * otherwise returns a function that returns null (default behavior). + */ +export const usePortalRoot = (): (() => HTMLElement | null) => { + const context = inject(PortalInjectionKey, null); + + if (context && context.getContainer) { + return context.getContainer; + } + + // Return a function that returns null when not inside a PortalProvider + return () => null; +}; diff --git a/packages/vue/src/keys.ts b/packages/vue/src/keys.ts index 7a5ca1f5d38..e012c639c3b 100644 --- a/packages/vue/src/keys.ts +++ b/packages/vue/src/keys.ts @@ -19,3 +19,7 @@ export const UserProfileInjectionKey = Symbol('UserProfile') as InjectionKey<{ export const OrganizationProfileInjectionKey = Symbol('OrganizationProfile') as InjectionKey<{ addCustomPage(params: AddCustomPagesParams): void; }>; + +export const PortalInjectionKey = Symbol('Portal') as InjectionKey<{ + getContainer: () => HTMLElement | null; +}>;