diff --git a/.changeset/purple-apples-read.md b/.changeset/purple-apples-read.md new file mode 100644 index 00000000000..110f645d9d2 --- /dev/null +++ b/.changeset/purple-apples-read.md @@ -0,0 +1,13 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/chrome-extension': minor +'@clerk/react-router': minor +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/remix': minor +'@clerk/types': minor +'@clerk/vue': minor +--- + +Add new component diff --git a/.changeset/silver-tools-clean.md b/.changeset/silver-tools-clean.md new file mode 100644 index 00000000000..8580550fcad --- /dev/null +++ b/.changeset/silver-tools-clean.md @@ -0,0 +1,5 @@ +--- +'@clerk/testing': minor +--- + +Add Playwright testing helpers under unstable page-objects: `userAvatar.goTo()`, `userAvatar.waitForMounted()`, and `userAvatar.toBeVisible()` for . diff --git a/integration/templates/next-app-router/src/app/user-avatar/page.tsx b/integration/templates/next-app-router/src/app/user-avatar/page.tsx new file mode 100644 index 00000000000..0c7ea73f90b --- /dev/null +++ b/integration/templates/next-app-router/src/app/user-avatar/page.tsx @@ -0,0 +1,9 @@ +import { UserAvatar } from '@clerk/nextjs'; + +export default function Page() { + return ( +
+ Loading user avatar} /> +
+ ); +} diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx index 417a0511c73..b337553375d 100644 --- a/integration/templates/react-vite/src/main.tsx +++ b/integration/templates/react-vite/src/main.tsx @@ -15,6 +15,7 @@ import UserButtonCustomDynamicLabelsAndCustomPages from './custom-user-button/wi import UserButtonCustomTrigger from './custom-user-button-trigger'; import UserButtonCustomDynamicItems from './custom-user-button/with-dynamic-items.tsx'; import UserButton from './user-button'; +import UserAvatar from './user-avatar'; import Waitlist from './waitlist'; import OrganizationProfile from './organization-profile'; import OrganizationList from './organization-list'; @@ -68,6 +69,10 @@ const router = createBrowserRouter([ path: '/user-button', element: , }, + { + path: '/user-avatar', + element: , + }, { path: '/protected', element: , diff --git a/integration/templates/react-vite/src/user-avatar/index.tsx b/integration/templates/react-vite/src/user-avatar/index.tsx new file mode 100644 index 00000000000..d608db004a8 --- /dev/null +++ b/integration/templates/react-vite/src/user-avatar/index.tsx @@ -0,0 +1,11 @@ +import { UserAvatar } from '@clerk/clerk-react'; +import React from 'react'; + +export default function UserAvatarPage() { + return ( +
+

UserAvatar

+ Loading user avatar} /> +
+ ); +} diff --git a/integration/templates/vue-vite/src/router.ts b/integration/templates/vue-vite/src/router.ts index 31fc822e18e..6fd11280ae6 100644 --- a/integration/templates/vue-vite/src/router.ts +++ b/integration/templates/vue-vite/src/router.ts @@ -41,6 +41,11 @@ const routes = [ path: '/pricing-table', component: () => import('./views/PricingTable.vue'), }, + { + name: 'UserAvatar', + path: '/user-avatar', + component: () => import('./views/UserAvatar.vue'), + }, // This was added for billing tests { name: 'User', @@ -72,7 +77,7 @@ const router = createRouter({ router.beforeEach(async (to, _, next) => { const { isSignedIn, isLoaded } = useAuth(); - const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile']; + const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile', 'UserAvatar']; if (!isLoaded.value) { await waitForClerkJsLoaded(isLoaded); diff --git a/integration/templates/vue-vite/src/views/UserAvatar.vue b/integration/templates/vue-vite/src/views/UserAvatar.vue new file mode 100644 index 00000000000..35d46c4f5d7 --- /dev/null +++ b/integration/templates/vue-vite/src/views/UserAvatar.vue @@ -0,0 +1,10 @@ + + + diff --git a/integration/tests/components.test.ts b/integration/tests/components.test.ts index 5a36004a92d..bfcd75c210f 100644 --- a/integration/tests/components.test.ts +++ b/integration/tests/components.test.ts @@ -41,6 +41,12 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('component protected: true, fallback: 'Loading user profile', }, + { + name: 'UserAvatar', + path: '/user-avatar', + protected: true, + fallback: 'Loading user avatar', + }, { name: 'UserButton', path: '/user-button', diff --git a/integration/tests/user-avatar.test.ts b/integration/tests/user-avatar.test.ts new file mode 100644 index 00000000000..af29e01d015 --- /dev/null +++ b/integration/tests/user-avatar.test.ts @@ -0,0 +1,48 @@ +import { test } from '@playwright/test'; + +import { appConfigs } from '../presets'; +import type { FakeUser } from '../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; + +testAgainstRunningApps({ + withEnv: [appConfigs.envs.withEmailCodes], + withPattern: ['react.vite.withEmailCodes', 'vue.vite'], +})('UserAvatar component integration tests @generic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await app.teardown(); + await fakeUser.deleteIfExists(); + }); + + test.afterEach(async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.signOut(); + await u.page.context().clearCookies(); + }); + + test('UserAvatar loads and renders correctly when user is signed in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + await u.po.userAvatar.goTo(); + await u.po.userAvatar.toBeVisible(); + }); +}); diff --git a/integration/tests/vue/components.test.ts b/integration/tests/vue/components.test.ts index 070cfbd2d45..c803a6adc6b 100644 --- a/integration/tests/vue/components.test.ts +++ b/integration/tests/vue/components.test.ts @@ -38,6 +38,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te await expect(u.page.getByRole('link', { name: /Sign in/i })).toBeVisible(); }); + test('render UserAvatar component when user completes sign in flow', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.userAvatar.goTo(); + await u.po.userAvatar.toBeVisible(); + }); + test('render user button component when user completes sign in flow', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/sign-in'); diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index 209abfc6d51..43dd3fc5f45 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -29,6 +29,7 @@ exports[`public exports should not include a breaking change 1`] = ` "SignUpButton", "SignedIn", "SignedOut", + "UserAvatar", "UserButton", "UserProfile", "Waitlist", diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts index a6f82ee02c2..4c4ca13d711 100644 --- a/packages/chrome-extension/src/react/re-exports.ts +++ b/packages/chrome-extension/src/react/re-exports.ts @@ -24,6 +24,7 @@ export { SignUpButton, SignedIn, SignedOut, + UserAvatar, UserButton, UserProfile, Waitlist, diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 15d45afc722..6132a5f5025 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -1,5 +1,5 @@ -import type { Clerk as ClerkType } from '../'; import * as l from '../../localizations'; +import type { Clerk as ClerkType } from '../'; const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[]; @@ -25,6 +25,7 @@ const AVAILABLE_COMPONENTS = [ 'clerk', // While not a component, we want to support passing options to the Clerk class. 'signIn', 'signUp', + 'userAvatar', 'userButton', 'userProfile', 'createOrganization', @@ -86,6 +87,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component clerk: buildComponentControls('clerk'), signIn: buildComponentControls('signIn'), signUp: buildComponentControls('signUp'), + userAvatar: buildComponentControls('userAvatar'), userButton: buildComponentControls('userButton'), userProfile: buildComponentControls('userProfile'), createOrganization: buildComponentControls('createOrganization'), @@ -287,6 +289,9 @@ void (async () => { '/sign-up': () => { Clerk.mountSignUp(app, componentControls.signUp.getProps() ?? {}); }, + '/user-avatar': () => { + Clerk.mountUserAvatar(app, componentControls.userAvatar.getProps() ?? {}); + }, '/user-button': () => { Clerk.mountUserButton(app, componentControls.userButton.getProps() ?? {}); }, diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index d1cc06fadf6..557eb0a7b3e 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -73,6 +73,13 @@ >Sign Up +
  • + User Avatar +
  • { + this.assertComponentsReady(this.#componentControls); + const component = 'UserAvatar'; + void this.#componentControls.ensureMounted({ preloadHint: component }).then(controls => + controls.mountComponent({ + name: component, + appearanceKey: 'userAvatar', + node, + props, + }), + ); + + this.telemetry?.record(eventPrebuiltComponentMounted(component, props)); + }; + + public unmountUserAvatar = (node: HTMLDivElement): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => + controls.unmountComponent({ + node, + }), + ); + }; + public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); const component = 'SignUp'; diff --git a/packages/clerk-js/src/ui/components/UserAvatar/index.tsx b/packages/clerk-js/src/ui/components/UserAvatar/index.tsx new file mode 100644 index 00000000000..0894ffca0cb --- /dev/null +++ b/packages/clerk-js/src/ui/components/UserAvatar/index.tsx @@ -0,0 +1,26 @@ +import { useUser } from '@clerk/shared/react/index'; +import type { UserAvatarProps } from '@clerk/types'; + +import { useUserAvatarContext, withCoreUserGuard } from '@/ui/contexts'; +import { descriptors } from '@/ui/customizables'; +import { UserAvatar as InternalUserAvatar } from '@/ui/elements/UserAvatar'; +import { InternalThemeProvider } from '@/ui/styledSystem'; + +export const _UserAvatar = (props: UserAvatarProps) => { + const ctx = useUserAvatarContext(); + const { user } = useUser(); + + return ( + + theme.sizes.$7} + /> + + ); +}; + +export const UserAvatar = withCoreUserGuard(_UserAvatar); diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index ccde1a6e924..f82e5425e91 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -21,6 +21,7 @@ import { SignInContext, SignUpContext, SubscriberTypeContext, + UserAvatarContext, UserButtonContext, UserProfileContext, UserVerificationContext, @@ -50,6 +51,8 @@ export function ComponentContextProvider({ {children} ); + case 'UserAvatar': + return {children}; case 'UserButton': return ( diff --git a/packages/clerk-js/src/ui/contexts/components/UserAvatar.ts b/packages/clerk-js/src/ui/contexts/components/UserAvatar.ts new file mode 100644 index 00000000000..d9df16c19ab --- /dev/null +++ b/packages/clerk-js/src/ui/contexts/components/UserAvatar.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +import type { UserAvatarCtx } from '../../types'; + +export const UserAvatarContext = createContext(null); + +export const useUserAvatarContext = () => { + const context = useContext(UserAvatarContext); + + if (!context || context.componentName !== 'UserAvatar') { + throw new Error('Clerk: useUserAvatarContext called outside UserAvatar.'); + } + + const { componentName, ...ctx } = context; + + return { + ...ctx, + componentName, + }; +}; diff --git a/packages/clerk-js/src/ui/contexts/components/index.ts b/packages/clerk-js/src/ui/contexts/components/index.ts index 408688463e8..3afdda296bf 100644 --- a/packages/clerk-js/src/ui/contexts/components/index.ts +++ b/packages/clerk-js/src/ui/contexts/components/index.ts @@ -1,18 +1,20 @@ +export * from './ApiKeys'; +export * from './Checkout'; +export * from './CreateOrganization'; +export * from './GoogleOneTap'; +export * from './OAuthConsent'; +export * from './OrganizationList'; +export * from './OrganizationProfile'; +export * from './OrganizationSwitcher'; +export * from './Plans'; +export * from './PricingTable'; export * from './SignIn'; -export * from './SignUp'; export * from './SignOut'; +export * from './SignUp'; export * from './SubscriberType'; +export * from './SubscriptionDetails'; +export * from './UserAvatar'; +export * from './UserButton'; export * from './UserProfile'; export * from './UserVerification'; -export * from './UserButton'; -export * from './OrganizationSwitcher'; -export * from './OrganizationList'; -export * from './OrganizationProfile'; -export * from './CreateOrganization'; -export * from './GoogleOneTap'; export * from './Waitlist'; -export * from './PricingTable'; -export * from './Checkout'; -export * from './Plans'; -export * from './ApiKeys'; -export * from './OAuthConsent'; diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 1a6da88c12d..2500d89ccf9 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -219,6 +219,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'taskChooseOrganizationCreateOrganizationActionButton', 'taskChooseOrganizationPreviewButton', + 'userAvatarBox', + 'userAvatarImage', + 'userPreview', 'userPreviewAvatarContainer', 'userPreviewAvatarBox', diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index c137093cc40..727627e16dc 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -3,6 +3,7 @@ import { lazy } from 'react'; const componentImportPaths = { SignIn: () => import(/* webpackChunkName: "signin" */ './../components/SignIn'), SignUp: () => import(/* webpackChunkName: "signup" */ './../components/SignUp'), + UserAvatar: () => import(/* webpackChunkName: "useravatar" */ './../components/UserAvatar'), UserButton: () => import(/* webpackChunkName: "userbutton" */ './../components/UserButton'), UserProfile: () => import(/* webpackChunkName: "userprofile" */ './../components/UserProfile'), CreateOrganization: () => import(/* webpackChunkName: "createorganization" */ './../components/CreateOrganization'), @@ -47,15 +48,22 @@ export const SignUp = lazy(() => componentImportPaths.SignUp().then(module => ({ export const SignUpModal = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUpModal }))); +export const UserAvatar = lazy(() => + componentImportPaths.UserAvatar().then(module => ({ default: module.UserAvatar })), +); + export const UserButton = lazy(() => componentImportPaths.UserButton().then(module => ({ default: module.UserButton })), ); + export const UserProfile = lazy(() => componentImportPaths.UserProfile().then(module => ({ default: module.UserProfile })), ); + export const UserProfileModal = lazy(() => componentImportPaths.UserProfile().then(module => ({ default: module.UserProfileModal })), ); + export const CreateOrganization = lazy(() => componentImportPaths.CreateOrganization().then(module => ({ default: module.CreateOrganization })), ); @@ -132,6 +140,7 @@ export const preloadComponent = async (component: unknown) => { export const ClerkComponents = { SignIn, SignUp, + UserAvatar, UserButton, UserProfile, UserVerification, diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index ec0640fdc1f..f4cb6238221 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -20,6 +20,7 @@ import type { SignUpForceRedirectUrl, SignUpProps, TaskChooseOrganizationProps, + UserAvatarProps, UserButtonProps, UserProfileProps, WaitlistProps, @@ -35,6 +36,7 @@ export type { OrganizationSwitcherProps, SignInProps, SignUpProps, + UserAvatarProps, UserButtonProps, UserProfileProps, WaitlistProps, @@ -43,6 +45,7 @@ export type { export type AvailableComponentProps = | SignInProps | SignUpProps + | UserAvatarProps | UserProfileProps | UserButtonProps | OrganizationSwitcherProps @@ -90,6 +93,10 @@ export type UserButtonCtx = UserButtonProps & { mode?: ComponentMode; }; +export type UserAvatarCtx = UserAvatarProps & { + componentName: 'UserAvatar'; +}; + export type OrganizationProfileCtx = OrganizationProfileProps & { componentName: 'OrganizationProfile'; mode?: ComponentMode; @@ -159,6 +166,7 @@ export type PlanDetailsCtx = __internal_PlanDetailsProps & { export type AvailableComponentCtx = | SignInCtx | SignUpCtx + | UserAvatarCtx | UserButtonCtx | UserProfileCtx | UserVerificationCtx diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 446d428365b..a38813c8c7c 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -23,6 +23,7 @@ export { SignOutButton, SignUpButton, TaskChooseOrganization, + UserAvatar, UserButton, Waitlist, } from '@clerk/clerk-react'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index b776e0751e6..2e29bcd7568 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -35,6 +35,7 @@ export { SignUp, SignUpButton, TaskChooseOrganization, + UserAvatar, UserButton, UserProfile, Waitlist, 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 440ab515d9f..84aae121b9f 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -31,6 +31,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignedIn", "SignedOut", "TaskChooseOrganization", + "UserAvatar", "UserButton", "UserProfile", "Waitlist", diff --git a/packages/react/src/components/__tests__/UserAvatar.test.tsx b/packages/react/src/components/__tests__/UserAvatar.test.tsx new file mode 100644 index 00000000000..952d4c3bc7b --- /dev/null +++ b/packages/react/src/components/__tests__/UserAvatar.test.tsx @@ -0,0 +1,15 @@ +import type React from 'react'; +import { describe, expectTypeOf, test } from 'vitest'; + +import type { UserAvatar } from '..'; + +export type UserAvatarComponentProps = React.ComponentProps; + +describe('', () => { + describe('Type tests', () => { + test('rounded is a boolean', () => { + expectTypeOf({ rounded: true }).toMatchTypeOf(); + expectTypeOf<{ rounded: false }>().toMatchTypeOf(); + }); + }); +}); diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index d5a6ca33492..cbf9b77aba1 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -9,6 +9,7 @@ export { SignIn, SignUp, TaskChooseOrganization, + UserAvatar, UserButton, UserProfile, Waitlist, diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 08e5034f038..df694448d01 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -10,6 +10,7 @@ import type { SignInProps, SignUpProps, TaskChooseOrganizationProps, + UserAvatarProps, UserButtonProps, UserProfileProps, WaitlistProps, @@ -640,6 +641,34 @@ export const APIKeys = withClerk( { component: 'ApiKeys', renderWhileLoading: true }, ); +export const UserAvatar = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'UserAvatar', renderWhileLoading: true }, +); + export const TaskChooseOrganization = withClerk( ({ clerk, component, fallback, ...props }: WithClerkProp) => { const mountingStatus = useWaitForComponentMount(component); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index a53fd1d2984..9c2f42cac44 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -48,6 +48,7 @@ import type { TaskChooseOrganizationProps, TasksRedirectOptions, UnsubscribeCallback, + UserAvatarProps, UserButtonProps, UserProfileProps, WaitlistProps, @@ -132,6 +133,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private preOpenWaitlist?: null | WaitlistProps = null; private premountSignInNodes = new Map(); private premountSignUpNodes = new Map(); + private premountUserAvatarNodes = new Map(); private premountUserProfileNodes = new Map(); private premountUserButtonNodes = new Map(); private premountOrganizationProfileNodes = new Map(); @@ -621,6 +623,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.mountUserProfile(node, props); }); + this.premountUserAvatarNodes.forEach((props, node) => { + clerkjs.mountUserAvatar(node, props); + }); + this.premountUserButtonNodes.forEach((props, node) => { clerkjs.mountUserButton(node, props); }); @@ -973,6 +979,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + mountUserAvatar = (node: HTMLDivElement, props?: UserAvatarProps) => { + if (this.clerkjs && this.loaded) { + this.clerkjs.mountUserAvatar(node, props); + } else { + this.premountUserAvatarNodes.set(node, props); + } + }; + + unmountUserAvatar = (node: HTMLDivElement) => { + if (this.clerkjs && this.loaded) { + this.clerkjs.unmountUserAvatar(node); + } else { + this.premountUserAvatarNodes.delete(node); + } + }; + mountUserProfile = (node: HTMLDivElement, props?: UserProfileProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.mountUserProfile(node, props); diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index f985eae730e..7be84469806 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,6 +32,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignedIn", "SignedOut", "TaskChooseOrganization", + "UserAvatar", "UserButton", "UserProfile", "Waitlist", 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 28973325dad..f49dd011405 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 @@ -43,6 +43,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "SignedIn", "SignedOut", "TaskChooseOrganization", + "UserAvatar", "UserButton", "UserProfile", "Waitlist", diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts index 698a91b5677..01c5836e8ad 100644 --- a/packages/testing/src/playwright/unstable/page-objects/index.ts +++ b/packages/testing/src/playwright/unstable/page-objects/index.ts @@ -15,6 +15,7 @@ import { createSignInComponentPageObject } from './signIn'; import { createSignUpComponentPageObject } from './signUp'; import { createSubscriptionDetailsPageObject } from './subscriptionDetails'; import { createTestingTokenPageObject } from './testingToken'; +import { createUserAvatarPageObject } from './userAvatar'; import { createUserButtonPageObject } from './userButton'; import { createUserProfileComponentPageObject } from './userProfile'; import { createUserVerificationComponentPageObject } from './userVerification'; @@ -45,6 +46,7 @@ export const createPageObjects = ({ signIn: createSignInComponentPageObject(testArgs), signUp: createSignUpComponentPageObject(testArgs), testingToken: createTestingTokenPageObject(testArgs), + userAvatar: createUserAvatarPageObject(testArgs), userButton: createUserButtonPageObject(testArgs), userProfile: createUserProfileComponentPageObject(testArgs), userVerification: createUserVerificationComponentPageObject(testArgs), diff --git a/packages/testing/src/playwright/unstable/page-objects/userAvatar.ts b/packages/testing/src/playwright/unstable/page-objects/userAvatar.ts new file mode 100644 index 00000000000..3c5ea32efb3 --- /dev/null +++ b/packages/testing/src/playwright/unstable/page-objects/userAvatar.ts @@ -0,0 +1,24 @@ +import { expect } from '@playwright/test'; + +import type { EnhancedPage } from './app'; + +const SELECTOR = '.cl-userAvatarBox'; + +export const createUserAvatarPageObject = (testArgs: { page: EnhancedPage }) => { + const { page } = testArgs; + + const self = { + goTo: async (opts?: { searchParams: URLSearchParams }) => { + await page.goToRelative('/user-avatar', opts); + return self.waitForMounted(); + }, + waitForMounted: (selector = SELECTOR) => { + return page.waitForSelector(selector, { state: 'attached' }); + }, + toBeVisible: async (selector = SELECTOR) => { + return await expect(page.locator(selector).getByRole('img')).toBeVisible(); + }, + }; + + return self; +}; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 5ef794b80d1..a3d053f67f8 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -358,6 +358,9 @@ export type ElementsConfig = { taskChooseOrganizationCreateOrganizationActionButton: WithOptions; taskChooseOrganizationPreviewButton: WithOptions; + userAvatarBox: WithOptions; + userAvatarImage: WithOptions; + // TODO: Test this idea. Instead of userButtonUserPreview, have a userPreview__userButton instead // Same for other repeated selectors, eg avatar userPreview: WithOptions; @@ -967,6 +970,7 @@ export type CaptchaAppearanceOptions = { export type SignInTheme = Theme; export type SignUpTheme = Theme; export type UserButtonTheme = Theme; +export type UserAvatarTheme = Theme; export type UserProfileTheme = Theme; export type OrganizationSwitcherTheme = Theme; export type OrganizationListTheme = Theme; @@ -1001,6 +1005,10 @@ export type Appearance = T & * Theme overrides that only apply to the `` component */ signUp?: T; + /** + * Theme overrides that only apply to the `` component + */ + userAvatar?: T; /** * Theme overrides that only apply to the `` component */ diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 8730bea7d3f..d84eb1061e5 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -15,6 +15,7 @@ import type { SignUpTheme, SubscriptionDetailsTheme, TaskChooseOrganizationTheme, + UserAvatarTheme, UserButtonTheme, UserProfileTheme, UserVerificationTheme, @@ -407,6 +408,21 @@ export interface Clerk { */ unmountSignUp: (targetNode: HTMLDivElement) => void; + /** + * Mount a user avatar component at the target element. + * + * @param targetNode Target node to mount the UserAvatar component. + */ + mountUserAvatar: (targetNode: HTMLDivElement, userAvatarProps?: UserAvatarProps) => void; + + /** + * Unmount a user avatar component at the target element. + * If there is no component mounted at the target node, results in a noop. + * + * @param targetNode Target node to unmount the UserAvatar component from. + */ + unmountUserAvatar: (targetNode: HTMLDivElement) => void; + /** * Mount a user button component at the target element. * @@ -1623,6 +1639,11 @@ export type UserButtonProps = UserButtonProfileMode & { customMenuItems?: CustomMenuItem[]; }; +export type UserAvatarProps = { + appearance?: UserAvatarTheme; + rounded?: boolean; +}; + type PrimitiveKeys = { [K in keyof T]: T[K] extends string | boolean | number | null ? K : never; }[keyof T]; diff --git a/packages/vue/src/components/index.ts b/packages/vue/src/components/index.ts index 37827c8ad8a..94af70f0697 100644 --- a/packages/vue/src/components/index.ts +++ b/packages/vue/src/components/index.ts @@ -8,6 +8,7 @@ export { default as PricingTable } from './ui-components/PricingTable.vue'; export { UserProfile } from './ui-components/UserProfile'; export { OrganizationProfile } from './ui-components/OrganizationProfile'; export { OrganizationSwitcher } from './ui-components/OrganizationSwitcher'; +export { default as UserAvatar } from './ui-components/UserAvatar.vue'; export { UserButton } from './ui-components/UserButton'; export { ClerkLoaded, diff --git a/packages/vue/src/components/ui-components/UserAvatar.vue b/packages/vue/src/components/ui-components/UserAvatar.vue new file mode 100644 index 00000000000..c03a39dab51 --- /dev/null +++ b/packages/vue/src/components/ui-components/UserAvatar.vue @@ -0,0 +1,17 @@ + + +