Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nuxt/src/runtime/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export {
SignOutButton,
SignInWithMetamaskButton,
PricingTable,
UNSAFE_PortalProvider,
} from '@clerk/vue';
17 changes: 14 additions & 3 deletions packages/vue/src/components/ClerkHostRenderer.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -44,6 +45,7 @@ export const ClerkHostRenderer = defineComponent({
},
setup(props) {
const portalRef = ref<HTMLDivElement | null>(null);
const getContainer = usePortalRoot();
let isPortalMounted = false;

watchEffect(() => {
Expand All @@ -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;
});
Expand All @@ -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 },
Expand Down
52 changes: 52 additions & 0 deletions packages/vue/src/components/PortalProvider.ts
Original file line number Diff line number Diff line change
@@ -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
* <script setup>
* import { useTemplateRef } from 'vue';
* import { DialogContent } from 'reka-ui';
* import { UNSAFE_PortalProvider, UserButton } from '@clerk/vue';
*
* const dialogContentRef = useTemplateRef('dialogContentRef');
* </script>
*
* <template>
* <DialogContent ref="dialogContentRef">
* <UNSAFE_PortalProvider :getContainer="() => dialogContentRef?.$el">
* <UserButton />
* </UNSAFE_PortalProvider>
* </DialogContent>
* </template>
Comment on lines +18 to +32
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using Reka UI here as example (Radix in Vue ecosystem).

* ```
*/
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?.();
},
});
1 change: 1 addition & 0 deletions packages/vue/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
100 changes: 100 additions & 0 deletions packages/vue/src/composables/__tests__/usePortalRoot.test.ts
Original file line number Diff line number Diff line change
@@ -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: `
<OutsideComponent />
<UNSAFE_PortalProvider :getContainer="getContainer">
<InsideComponent />
</UNSAFE_PortalProvider>
`,
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: `
<UNSAFE_PortalProvider :getContainer="() => outerContainer">
<UNSAFE_PortalProvider :getContainer="() => innerContainer">
<TestComponent />
</UNSAFE_PortalProvider>
</UNSAFE_PortalProvider>
`,
setup() {
return { outerContainer, innerContainer };
},
});

expect(getByTestId('test').textContent).toBe('inner');
});
});
2 changes: 2 additions & 0 deletions packages/vue/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export { useSignUp } from './useSignUp';
export { useSessionList } from './useSessionList';

export { useOrganization } from './useOrganization';

export { usePortalRoot } from './usePortalRoot';
19 changes: 19 additions & 0 deletions packages/vue/src/composables/usePortalRoot.ts
Original file line number Diff line number Diff line change
@@ -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;
};
4 changes: 4 additions & 0 deletions packages/vue/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;