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/web/uiComponents.tsx b/packages/expo/src/web/uiComponents.tsx index 922994d77ad..6c9418390ea 100644 --- a/packages/expo/src/web/uiComponents.tsx +++ b/packages/expo/src/web/uiComponents.tsx @@ -11,6 +11,7 @@ import { SignOutButton as BaseSignOutButton, SignUp as BaseSignUp, SignUpButton as BaseSignUpButton, + UNSAFE_PortalProvider as BaseUNSAFE_PortalProvider, UserAvatar as BaseUserAvatar, UserButton as BaseUserButton, UserProfile as BaseUserProfile, @@ -55,3 +56,5 @@ export const SignInWithMetamaskButton: typeof BaseSignInWithMetamaskButton = export const GoogleOneTap: typeof BaseGoogleOneTap = WrapComponent(BaseGoogleOneTap); export const Waitlist: typeof BaseWaitlist = WrapComponent(BaseWaitlist); export const PricingTable: typeof BasePricingTable = WrapComponent(BasePricingTable); + +export const UNSAFE_PortalProvider: typeof BaseUNSAFE_PortalProvider = WrapComponent(BaseUNSAFE_PortalProvider); 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 09fcc081fde..09fb42c47a4 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 8ea72969a9e..87dc4845653 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}; +}; + +UNSAFE_PortalProvider.displayName = 'UNSAFE_PortalProvider'; + +/** + * 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 c354a0bef83..6e0d04985b8 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 a7a6235382b..0a66bb73c36 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -1,7 +1,7 @@ { "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": "72KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, { "path": "./dist/ui-common*.js", "maxSize": "129KB" }, 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..00904507c08 --- /dev/null +++ b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx @@ -0,0 +1,103 @@ +import { UNSAFE_PortalProvider } from '@clerk/shared/react'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { AppearanceProvider } from '@/ui/customizables'; +import { InternalThemeProvider } from '@/ui/styledSystem'; + +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 + > +
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 + > +
Test
+
+
+
, + ); + + // The modal should have scoped styles (position: absolute) when modalRoot is provided + // The backdrop element should be rendered with the modalBackdrop class + const modalElement = container.querySelector('.cl-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 + > +
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'); + document.body.appendChild(container); + const getContainer = () => container; + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + props.setProps({ hidePersonal: true }); + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button')); + const manageButton = await waitFor(() => screen.getByRole('menuitem', { name: /manage/i })); + await userEvent.click(manageButton); + + expect(fixtures.clerk.openOrganizationProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + + document.body.removeChild(container); + }); + + it('passes getContainer to openCreateOrganization', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const getContainer = () => container; + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + create_organization_enabled: true, + }); + }); + + props.setProps({ hidePersonal: true }); + const { getByRole, userEvent } = render( + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: 'Open organization switcher' })); + const createButton = await waitFor(() => screen.getByRole('menuitem', { name: 'Create organization' })); + await userEvent.click(createButton); + + expect(fixtures.clerk.openCreateOrganization).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + + document.body.removeChild(container); + }); + }); }); 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..50bc2f243b8 100644 --- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx @@ -1,7 +1,9 @@ +import { UNSAFE_PortalProvider } from '@clerk/shared/react'; +import React from 'react'; import { describe, expect, it } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { render, waitFor } from '@/test/utils'; +import { render, screen, waitFor } from '@/test/utils'; import { UserButton } from '../'; @@ -84,6 +86,39 @@ 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'); + document.body.appendChild(container); + 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 waitFor(() => { + expect(screen.getByText('Manage account')).toBeInTheDocument(); + }); + await userEvent.click(screen.getByText('Manage account')); + + expect(fixtures.clerk.openUserProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer })); + + document.body.removeChild(container); + }); + }); + 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/customizables/__tests__/sanitizeDomProps.test.tsx b/packages/ui/src/customizables/__tests__/sanitizeDomProps.test.tsx new file mode 100644 index 00000000000..4402b70752a --- /dev/null +++ b/packages/ui/src/customizables/__tests__/sanitizeDomProps.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { render, screen } from '@/test/utils'; + +import { sanitizeDomProps } from '../sanitizeDomProps'; + +// Mock component that captures props to verify what gets passed through +const TestComponent = React.forwardRef>((props, ref) => { + // Extract data-testid to prevent it from overwriting our test id and exclude it from tracking + const { 'data-testid': _, ...propsToTrack } = props; + // Store props on the element's dataset for inspection + return ( +
+ ); +}); +TestComponent.displayName = 'TestComponent'; + +describe('sanitizeDomProps', () => { + it('filters out elementId prop', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + render( + , + ); + + const element = screen.getByTestId('test-component'); + const props = JSON.parse(element.getAttribute('data-props') || '[]'); + + expect(props).not.toContain('elementId'); + expect(element).not.toHaveAttribute('elementId'); + }); + + it('filters out elementDescriptor prop', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + render( + , + ); + + const element = screen.getByTestId('test-component'); + const props = JSON.parse(element.getAttribute('data-props') || '[]'); + + expect(props).not.toContain('elementDescriptor'); + expect(element).not.toHaveAttribute('elementDescriptor'); + }); + + it('filters out localizationKey prop', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + render( + , + ); + + const element = screen.getByTestId('test-component'); + const props = JSON.parse(element.getAttribute('data-props') || '[]'); + + expect(props).not.toContain('localizationKey'); + expect(element).not.toHaveAttribute('localizationKey'); + }); + + it('filters out getContainer prop', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + const getContainer = () => document.body; + + render( + , + ); + + const element = screen.getByTestId('test-component'); + const props = JSON.parse(element.getAttribute('data-props') || '[]'); + + expect(props).not.toContain('getContainer'); + expect(element).not.toHaveAttribute('getContainer'); + }); + + it('filters out all non-DOM props at once', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + const getContainer = () => document.body; + + render( + , + ); + + const element = screen.getByTestId('test-component'); + const props = JSON.parse(element.getAttribute('data-props') || '[]'); + + // Should not contain filtered props + expect(props).not.toContain('elementId'); + expect(props).not.toContain('elementDescriptor'); + expect(props).not.toContain('localizationKey'); + expect(props).not.toContain('getContainer'); + + // Should contain valid DOM props (data-testid is excluded from tracking to preserve test id) + expect(props).toContain('className'); + expect(props).toContain('id'); + + // Verify attributes on DOM element + expect(element).not.toHaveAttribute('elementId'); + expect(element).not.toHaveAttribute('elementDescriptor'); + expect(element).not.toHaveAttribute('localizationKey'); + expect(element).not.toHaveAttribute('getContainer'); + expect(element).toHaveClass('test-class'); + expect(element).toHaveAttribute('id', 'dom-id'); + expect(element).toHaveAttribute('data-testid', 'test-component'); + }); + + it('passes through valid DOM props', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + + render( + {}} + />, + ); + + const element = screen.getByTestId('test-component'); + + expect(element).toHaveClass('my-class'); + expect(element).toHaveAttribute('id', 'my-id'); + expect(element).toHaveAttribute('style'); + expect(element).toHaveAttribute('data-custom', 'custom-value'); + expect(element).toHaveAttribute('aria-label', 'test label'); + expect(element).toHaveProperty('onclick'); + }); + + it('preserves ref forwarding', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + const ref = React.createRef(); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toBe(screen.getByTestId('test-component')); + }); + + it('handles getContainer function prop without errors', () => { + const SanitizedComponent = sanitizeDomProps(TestComponent); + const container = document.createElement('div'); + const getContainer = () => container; + + // Should not throw or cause React warnings + expect(() => { + render(); + }).not.toThrow(); + + const element = screen.getByTestId('test-component'); + expect(element).not.toHaveAttribute('getContainer'); + }); +}); diff --git a/packages/ui/src/customizables/sanitizeDomProps.tsx b/packages/ui/src/customizables/sanitizeDomProps.tsx index 5eee59b5ef0..088a1ea6224 100644 --- a/packages/ui/src/customizables/sanitizeDomProps.tsx +++ b/packages/ui/src/customizables/sanitizeDomProps.tsx @@ -6,7 +6,7 @@ import React from 'react'; */ export const sanitizeDomProps =

>(Component: P): P => { const component = React.forwardRef((props: any, ref) => { - const { elementId, elementDescriptor, localizationKey, ...restProps } = props; + const { elementId, elementDescriptor, localizationKey, getContainer, ...restProps } = props; return ( - {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; +}>;