diff --git a/.changeset/wet-phones-camp.md b/.changeset/wet-phones-camp.md new file mode 100644 index 00000000000..a8fc88272a7 --- /dev/null +++ b/.changeset/wet-phones-camp.md @@ -0,0 +1,3 @@ +--- +'@clerk/shared': patch +--- 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 3020bc4407f..d3d6a591b0c 100644 --- a/packages/expo/src/index.ts +++ b/packages/expo/src/index.ts @@ -17,6 +17,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 1ab240a18f5..ec526c7d623 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -15,6 +15,7 @@ export { AuthenticateWithRedirectCallback, RedirectToCreateOrganization, RedirectToOrganizationProfile, + UNSAFE_PortalProvider, } from '@clerk/react'; export { MultisessionAppSupport } from '@clerk/react/internal'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index b9c24e9b7ce..8419a156b47 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 61bde896c00..0f4326f7071 100644 --- a/packages/nuxt/src/runtime/components/index.ts +++ b/packages/nuxt/src/runtime/components/index.ts @@ -25,4 +25,5 @@ export { SignOutButton, SignInWithMetamaskButton, PricingTable, + UNSAFE_PortalProvider, } from '@clerk/vue'; 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..768959aa1ce --- /dev/null +++ b/packages/shared/src/react/__tests__/PortalProvider.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +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 d83d5db8cfb..d98009b0089 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1422,9 +1422,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 */ @@ -1566,7 +1579,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 & { /** @@ -1608,7 +1628,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 & { /** @@ -1651,7 +1678,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 & { /** @@ -1677,7 +1711,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 = @@ -1900,7 +1941,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/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/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 aec8b0f21de..5e58e0e87d9 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 65c8398137f..dd19fafc2bd 100644 --- a/packages/vue/src/components/index.ts +++ b/packages/vue/src/components/index.ts @@ -30,3 +30,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; +}>;