diff --git a/.changeset/smooth-worms-retire.md b/.changeset/smooth-worms-retire.md
new file mode 100644
index 00000000000..2056501396f
--- /dev/null
+++ b/.changeset/smooth-worms-retire.md
@@ -0,0 +1,25 @@
+---
+"@clerk/clerk-js": minor
+"@clerk/clerk-react": minor
+"@clerk/types": minor
+---
+
+Introduce support for custom menu items in ``.
+
+- Use `` as a child component to wrap custom menu items.
+- Use `` for creating external or internal links.
+- Use `` for opening a specific custom page of "UserProfile" or to trigger your own custom logic via `onClick`.
+- If needed, reorder existing items like `manageAccount` and `signOut`
+
+New usage example:
+
+```jsx
+
+
+ } href='/terms' />
+ } open='help' /> // Navigate to `/help` page when UserProfile opens as a modal. (Requires a custom page to have been set in `/help`)
+ } />
+ } onClick={() => setModal(true)} />
+
+
+```
\ No newline at end of file
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx
index adaf880b139..75c6e4848b4 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OtherOrganizationActions.tsx
@@ -7,7 +7,6 @@ import { Add } from '../../icons';
import { UserInvitationSuggestionList } from './UserInvitationSuggestionList';
import type { UserMembershipListProps } from './UserMembershipList';
import { UserMembershipList } from './UserMembershipList';
-
export interface OrganizationActionListProps extends UserMembershipListProps {
onCreateOrganizationClick: React.MouseEventHandler;
}
diff --git a/packages/clerk-js/src/ui/components/UserButton/SessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/SessionActions.tsx
index 51dc26ec069..867ab5345ab 100644
--- a/packages/clerk-js/src/ui/components/UserButton/SessionActions.tsx
+++ b/packages/clerk-js/src/ui/components/UserButton/SessionActions.tsx
@@ -1,20 +1,53 @@
import type { ActiveSessionResource } from '@clerk/types';
import type { ElementDescriptor, ElementId } from '../../../ui/customizables/elementDescriptors';
+import { useRouter } from '../../../ui/router';
+import { USER_BUTTON_ITEM_ID } from '../../constants';
+import { useUserButtonContext } from '../../contexts';
import type { LocalizationKey } from '../../customizables';
import { descriptors, Flex, localizationKeys } from '../../customizables';
import { Action, Actions, PreviewButton, SmallAction, SmallActions, UserPreview } from '../../elements';
import { Add, CogFilled, SignOut, SwitchArrowRight } from '../../icons';
import type { ThemableCssProp } from '../../styledSystem';
+import type { DefaultItemIds, MenuItem } from '../../utils/createCustomMenuItems';
type SingleSessionActionsProps = {
handleManageAccountClicked: () => Promise | void;
handleSignOutSessionClicked: (session: ActiveSessionResource) => () => Promise | void;
+ handleUserProfileActionClicked: (startPath?: string) => Promise | void;
session: ActiveSessionResource;
+ completedCallback: () => void;
};
export const SingleSessionActions = (props: SingleSessionActionsProps) => {
- const { handleManageAccountClicked, handleSignOutSessionClicked, session } = props;
+ const { navigate } = useRouter();
+ const { handleManageAccountClicked, handleSignOutSessionClicked, handleUserProfileActionClicked, session } = props;
+
+ const { menutItems } = useUserButtonContext();
+
+ const commonActionSx: ThemableCssProp = t => ({
+ borderTopWidth: t.borderWidths.$normal,
+ borderTopStyle: t.borderStyles.$solid,
+ borderTopColor: t.colors.$neutralAlpha100,
+ padding: `${t.space.$4} ${t.space.$5}`,
+ });
+
+ const handleActionClick = async (menuItem: MenuItem) => {
+ if (menuItem?.path) {
+ await navigate(menuItem.path);
+ return props?.completedCallback();
+ }
+ if (menuItem.id === USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT) {
+ return await handleManageAccountClicked();
+ }
+
+ if (menuItem?.open) {
+ return handleUserProfileActionClicked(menuItem.open);
+ }
+
+ menuItem.onClick?.();
+ return props?.completedCallback();
+ };
return (
{
borderTopColor: t.colors.$neutralAlpha100,
})}
>
- ({
- borderTopWidth: t.borderWidths.$normal,
- borderTopStyle: t.borderStyles.$solid,
- borderTopColor: t.colors.$neutralAlpha100,
- padding: `${t.space.$4} ${t.space.$5}`,
- })}
- />
- ({
- borderBottomLeftRadius: t.radii.$lg,
- borderBottomRightRadius: t.radii.$lg,
- padding: `${t.space.$4} ${t.space.$5}`,
- }),
- ]}
- />
+ {menutItems?.map((item: MenuItem) => {
+ const isDefaultItem = Object.values(USER_BUTTON_ITEM_ID).includes(item.id);
+ let itemDescriptors;
+
+ // We are using different element descriptors for default menu items vs custom items
+ // to maintain backwards compatibility and avoid breaking existing descriptor usage.
+ // This check ensures that default items use their specific descriptors, while custom
+ // items use the generic custom item descriptors.
+ if (isDefaultItem) {
+ itemDescriptors = {
+ elementDescriptor: descriptors.userButtonPopoverActionButton,
+ elementId: descriptors.userButtonPopoverActionButton.setId(item.id as DefaultItemIds),
+ iconBoxElementDescriptor: descriptors.userButtonPopoverActionButtonIconBox,
+ iconBoxElementId: descriptors.userButtonPopoverActionButtonIconBox.setId(item.id as DefaultItemIds),
+ iconElementDescriptor: descriptors.userButtonPopoverActionButtonIcon,
+ iconElementId: descriptors.userButtonPopoverActionButtonIcon.setId(item.id as DefaultItemIds),
+ };
+ } else {
+ itemDescriptors = {
+ elementDescriptor: descriptors.userButtonPopoverCustomItemButton,
+ elementId: descriptors.userButtonPopoverCustomItemButton.setId(item.id),
+ iconBoxElementDescriptor: descriptors.userButtonPopoverCustomItemButtonIconBox,
+ iconBoxElementId: descriptors.userButtonPopoverCustomItemButtonIconBox.setId(item.id),
+ iconElementDescriptor: descriptors.userButtonPopoverActionItemButtonIcon,
+ iconElementId: descriptors.userButtonPopoverActionItemButtonIcon.setId(item.id),
+ };
+ }
+
+ return (
+ handleActionClick(item)
+ }
+ sx={commonActionSx}
+ iconSx={t => ({
+ width: t.sizes.$4,
+ height: t.sizes.$4,
+ })}
+ />
+ );
+ })}
);
};
@@ -71,55 +116,151 @@ type MultiSessionActionsProps = {
handleSignOutSessionClicked: (session: ActiveSessionResource) => () => Promise | void;
handleSessionClicked: (session: ActiveSessionResource) => () => Promise | void;
handleAddAccountClicked: () => Promise | void;
+ handleUserProfileActionClicked: (startPath?: string) => Promise | void;
session: ActiveSessionResource;
otherSessions: ActiveSessionResource[];
+ completedCallback: () => void;
};
export const MultiSessionActions = (props: MultiSessionActionsProps) => {
+ const { navigate } = useRouter();
const {
handleManageAccountClicked,
handleSignOutSessionClicked,
handleSessionClicked,
handleAddAccountClicked,
+ handleUserProfileActionClicked,
session,
otherSessions,
} = props;
+ const { menutItems } = useUserButtonContext();
+
+ const handleActionClick = async (route: MenuItem) => {
+ if (route?.path) {
+ await navigate(route.path);
+ return props?.completedCallback();
+ }
+ if (route.id === USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT) {
+ return await handleManageAccountClicked();
+ }
+
+ if (route?.open) {
+ return handleUserProfileActionClicked(route.open);
+ }
+
+ route.onClick?.();
+ return props?.completedCallback();
+ };
+
+ const hasOnlyDefaultItems = menutItems.every((item: MenuItem) =>
+ Object.values(USER_BUTTON_ITEM_ID).includes(item.id),
+ );
+
return (
<>
-
- ({ marginLeft: t.space.$12, padding: `0 ${t.space.$5} ${t.space.$4}`, gap: t.space.$2 })}
+ {hasOnlyDefaultItems ? (
+
-
-
-
-
+ ({ marginLeft: t.space.$12, padding: `0 ${t.space.$5} ${t.space.$4}`, gap: t.space.$2 })}
+ >
+
+
+
+
+ ) : (
+ ({
+ gap: t.space.$1,
+ paddingBottom: t.space.$2,
+ })}
+ >
+ {menutItems?.map((item: MenuItem) => {
+ const isDefaultItem = Object.values(USER_BUTTON_ITEM_ID).includes(item.id);
+ let itemDescriptors;
+
+ // We are using different element descriptors for default menu items vs custom items
+ // to maintain backwards compatibility and avoid breaking existing descriptor usage.
+ // This check ensures that default items use their specific descriptors, while custom
+ // items use the generic custom item descriptors.
+ if (isDefaultItem) {
+ itemDescriptors = {
+ elementDescriptor: descriptors.userButtonPopoverActionButton,
+ elementId: descriptors.userButtonPopoverActionButton.setId(item.id as DefaultItemIds),
+ iconBoxElementDescriptor: descriptors.userButtonPopoverActionButtonIconBox,
+ iconBoxElementId: descriptors.userButtonPopoverActionButtonIconBox.setId(item.id as DefaultItemIds),
+ iconElementDescriptor: descriptors.userButtonPopoverActionButtonIcon,
+ iconElementId: descriptors.userButtonPopoverActionButtonIcon.setId(item.id as DefaultItemIds),
+ };
+ } else {
+ itemDescriptors = {
+ elementDescriptor: descriptors.userButtonPopoverCustomItemButton,
+ elementId: descriptors.userButtonPopoverCustomItemButton.setId(item.id),
+ iconBoxElementDescriptor: descriptors.userButtonPopoverCustomItemButtonIconBox,
+ iconBoxElementId: descriptors.userButtonPopoverCustomItemButtonIconBox.setId(item.id),
+ iconElementDescriptor: descriptors.userButtonPopoverActionItemButtonIcon,
+ iconElementId: descriptors.userButtonPopoverActionItemButtonIcon.setId(item.id),
+ };
+ }
+ return (
+ handleActionClick(item)
+ }
+ sx={t => ({
+ border: 0,
+ padding: `${t.space.$2} ${t.space.$5}`,
+ gap: t.space.$3x5,
+ })}
+ iconSx={t => ({
+ width: t.sizes.$4,
+ height: t.sizes.$4,
+ })}
+ iconBoxSx={t => ({
+ minHeight: t.sizes.$4,
+ minWidth: t.sizes.$4,
+ alignItems: 'center',
+ })}
+ />
+ );
+ })}
+
+ )}
+
({
diff --git a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx
index de9957d0940..10b737e30b9 100644
--- a/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx
+++ b/packages/clerk-js/src/ui/components/UserButton/UserButtonPopover.tsx
@@ -22,6 +22,7 @@ export const UserButtonPopover = React.forwardRef
) : (
)}
diff --git a/packages/clerk-js/src/ui/constants.ts b/packages/clerk-js/src/ui/constants.ts
index 536e8ba4668..f897754e188 100644
--- a/packages/clerk-js/src/ui/constants.ts
+++ b/packages/clerk-js/src/ui/constants.ts
@@ -7,3 +7,8 @@ export const ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID = {
GENERAL: 'general',
MEMBERS: 'members',
};
+
+export const USER_BUTTON_ITEM_ID = {
+ MANAGE_ACCOUNT: 'manageAccount',
+ SIGN_OUT: 'signOut',
+};
diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
index 72853b0758b..60cc4659156 100644
--- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
+++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
@@ -25,7 +25,11 @@ import type {
UserProfileCtx,
} from '../types';
import type { CustomPageContent } from '../utils';
-import { createOrganizationProfileCustomPages, createUserProfileCustomPages } from '../utils';
+import {
+ createOrganizationProfileCustomPages,
+ createUserButtonCustomMenuItems,
+ createUserProfileCustomPages,
+} from '../utils';
const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ });
@@ -234,7 +238,7 @@ export const useUserProfileContext = (): UserProfileContextType => {
};
export const useUserButtonContext = () => {
- const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as UserButtonCtx;
+ const { componentName, customMenuItems, ...ctx } = (React.useContext(ComponentContext) || {}) as UserButtonCtx;
const clerk = useClerk();
const { navigate } = useRouter();
const { displayConfig } = useEnvironment();
@@ -270,6 +274,10 @@ export const useUserButtonContext = () => {
const userProfileMode = !!ctx.userProfileUrl && !ctx.userProfileMode ? 'navigation' : ctx.userProfileMode;
+ const menuItems = useMemo(() => {
+ return createUserButtonCustomMenuItems(customMenuItems || [], clerk);
+ }, []);
+
return {
...ctx,
componentName,
@@ -282,6 +290,7 @@ export const useUserButtonContext = () => {
afterSignOutUrl,
afterSwitchSessionUrl,
userProfileMode: userProfileMode || 'modal',
+ menutItems: menuItems,
};
};
diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
index 9a0bb68e5b6..169877dd70b 100644
--- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
+++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
@@ -126,6 +126,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'userButtonPopoverActionButton',
'userButtonPopoverActionButtonIconBox',
'userButtonPopoverActionButtonIcon',
+ 'userButtonPopoverCustomItemButton',
+ 'userButtonPopoverCustomItemButtonIconBox',
+ 'userButtonPopoverActionItemButtonIcon',
'userButtonPopoverFooter',
'userButtonPopoverFooterPagesLink',
diff --git a/packages/clerk-js/src/ui/elements/Actions.tsx b/packages/clerk-js/src/ui/elements/Actions.tsx
index 2c6d22fe69c..e9215b21e17 100644
--- a/packages/clerk-js/src/ui/elements/Actions.tsx
+++ b/packages/clerk-js/src/ui/elements/Actions.tsx
@@ -33,7 +33,7 @@ export const SmallActions = (props: PropsOfComponent) => {
type ActionProps = Omit, 'label'> & {
icon: React.ComponentType;
trailing?: React.ReactNode;
- label: LocalizationKey;
+ label: string | LocalizationKey;
iconBoxElementDescriptor?: ElementDescriptor;
iconBoxElementId?: ElementId;
iconBoxSx?: ThemableCssProp;
diff --git a/packages/clerk-js/src/ui/utils/__tests__/createCustomMenuItems.test.ts b/packages/clerk-js/src/ui/utils/__tests__/createCustomMenuItems.test.ts
new file mode 100644
index 00000000000..4a2bd75d17d
--- /dev/null
+++ b/packages/clerk-js/src/ui/utils/__tests__/createCustomMenuItems.test.ts
@@ -0,0 +1,165 @@
+import type { CustomMenuItem } from '@clerk/types';
+
+import { createUserButtonCustomMenuItems as cUBCMI } from '../createCustomMenuItems';
+
+const createUserButtonCustomMenuItems = (arr: any) => cUBCMI(arr, { sdkMetadata: { environment: 'test' } } as any);
+
+describe('createCustomMenuItems', () => {
+ describe('createUserButtonCustomMenuItems', () => {
+ it('should return the default menu items if no custom items are passed', () => {
+ const menuItems = createUserButtonCustomMenuItems([]);
+ expect(menuItems.length).toEqual(2);
+ expect(menuItems[0].id).toEqual('manageAccount');
+ expect(menuItems[1].id).toEqual('signOut');
+ });
+
+ it('should return custom menu items after manageAccount items', () => {
+ const customMenuItems: CustomMenuItem[] = [
+ {
+ label: 'Custom1',
+ onClick: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Custom2',
+ href: '/custom2',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const menuItems = createUserButtonCustomMenuItems(customMenuItems);
+ expect(menuItems.length).toEqual(4);
+ expect(menuItems[0].id).toEqual('manageAccount');
+ expect(menuItems[1].name).toEqual('Custom1');
+ expect(menuItems[2].name).toEqual('Custom2');
+ expect(menuItems[3].id).toEqual('signOut');
+ });
+
+ it('should reorder the default menu items when their label is used to target them', () => {
+ const customMenuItems: CustomMenuItem[] = [
+ {
+ label: 'Custom1',
+ onClick: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'signOut' },
+ { label: 'manageAccount' },
+ ];
+ const menuItems = createUserButtonCustomMenuItems(customMenuItems);
+ expect(menuItems.length).toEqual(3);
+ expect(menuItems[0].name).toEqual('Custom1');
+ expect(menuItems[1].id).toEqual('signOut');
+ expect(menuItems[2].id).toEqual('manageAccount');
+ });
+
+ it('sanitizes the path for external links', () => {
+ const customMenuItems: CustomMenuItem[] = [
+ {
+ label: 'Link1',
+ href: 'https://www.fullurl.com',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link2',
+ href: '/url-with-slash',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link3',
+ href: 'url-without-slash',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const menuItems = createUserButtonCustomMenuItems(customMenuItems);
+ expect(menuItems.length).toEqual(5);
+ expect(menuItems[0].id).toEqual('manageAccount');
+ expect(menuItems[1].path).toEqual('https://www.fullurl.com');
+ expect(menuItems[2].path).toEqual('/url-with-slash');
+ expect(menuItems[3].path).toEqual('/url-without-slash');
+ expect(menuItems[4].id).toEqual('signOut');
+ });
+
+ it('should handle custom menu items with "open" property', () => {
+ const customMenuItems: CustomMenuItem[] = [
+ {
+ label: 'Custom Open Item',
+ open: 'members',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const menuItems = createUserButtonCustomMenuItems(customMenuItems);
+ expect(menuItems.length).toEqual(3);
+ expect(menuItems[0].id).toEqual('manageAccount');
+ expect(menuItems[1].name).toEqual('Custom Open Item');
+ expect(menuItems[1].open).toEqual('members');
+ expect(menuItems[2].id).toEqual('signOut');
+ });
+
+ it('should handle a mix of custom menu item types', () => {
+ const customMenuItems: CustomMenuItem[] = [
+ {
+ label: 'Custom Click Item',
+ onClick: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Custom Open Item',
+ open: 'example',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Custom Link Item',
+ href: '/custom-link',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const menuItems = createUserButtonCustomMenuItems(customMenuItems);
+ expect(menuItems.length).toEqual(5);
+ expect(menuItems[0].id).toEqual('manageAccount');
+ expect(menuItems[1].name).toEqual('Custom Click Item');
+ expect(menuItems[1].onClick).toBeDefined();
+ expect(menuItems[2].name).toEqual('Custom Open Item');
+ expect(menuItems[2].open).toEqual('example');
+ expect(menuItems[3].name).toEqual('Custom Link Item');
+ expect(menuItems[3].path).toEqual('/custom-link');
+ expect(menuItems[4].id).toEqual('signOut');
+ });
+
+ it('should ignore invalid custom menu items', () => {
+ const customMenuItems: CustomMenuItem[] = [
+ {
+ label: 'Valid Click Item',
+ onClick: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Invalid Item',
+ // @ts-ignore - Testing invalid item
+ invalidProp: true,
+ },
+ {
+ label: 'Valid Open Item',
+ open: 'example',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const menuItems = createUserButtonCustomMenuItems(customMenuItems);
+ expect(menuItems.length).toEqual(4);
+ expect(menuItems[0].id).toEqual('manageAccount');
+ expect(menuItems[1].name).toEqual('Valid Click Item');
+ expect(menuItems[2].name).toEqual('Valid Open Item');
+ expect(menuItems[3].id).toEqual('signOut');
+ });
+ });
+});
diff --git a/packages/clerk-js/src/ui/utils/createCustomMenuItems.tsx b/packages/clerk-js/src/ui/utils/createCustomMenuItems.tsx
new file mode 100644
index 00000000000..8221270998b
--- /dev/null
+++ b/packages/clerk-js/src/ui/utils/createCustomMenuItems.tsx
@@ -0,0 +1,226 @@
+import type { CustomMenuItem, LoadedClerk } from '@clerk/types';
+
+import { USER_BUTTON_ITEM_ID } from '../constants';
+import type { LocalizationKey } from '../customizables';
+import { CogFilled, SignOut } from '../icons';
+import { localizationKeys } from '../localization';
+import { ExternalElementMounter } from './ExternalElementMounter';
+import { isDevelopmentSDK } from './runtimeEnvironment';
+import { sanitizeCustomLinkURL } from './sanitizeCustomLinkURL';
+
+export type DefaultItemIds = 'manageAccount' | 'addAccount' | 'signOut' | 'signOutAll';
+
+export type MenuItem = {
+ id: string;
+ name: LocalizationKey | string;
+ icon: React.ComponentType;
+ onClick?: () => void;
+ open?: string;
+ path?: string;
+};
+
+type MenuReorderItem = {
+ label: 'manageAccount' | 'signOut';
+};
+
+type CustomMenuAction = {
+ label: string;
+ onClick: () => void;
+ mountIcon: (el: HTMLDivElement) => void;
+ unmountIcon: (el?: HTMLDivElement) => void;
+ mount: (el: HTMLDivElement) => void;
+ unmount: (el?: HTMLDivElement) => void;
+};
+
+type CustomMenuActionWithOpen = {
+ label: string;
+ open: string;
+ mountIcon: (el: HTMLDivElement) => void;
+ unmountIcon: (el?: HTMLDivElement) => void;
+ mount: (el: HTMLDivElement) => void;
+ unmount: (el?: HTMLDivElement) => void;
+};
+
+type CustomMenuLink = {
+ label: string;
+ href: string;
+ mountIcon: (el: HTMLDivElement) => void;
+ unmountIcon: (el?: HTMLDivElement) => void;
+};
+
+type CreateCustomMenuItemsParams = {
+ customMenuItems: CustomMenuItem[];
+ getDefaultMenuItems: () => { INITIAL_MENU_ITEMS: MenuItem[]; validReorderItemLabels: string[] };
+};
+
+export const createUserButtonCustomMenuItems = (customMenuItems: CustomMenuItem[], clerk: LoadedClerk) => {
+ return createCustomMenuItems({ customMenuItems, getDefaultMenuItems: getUserButtonDefaultMenuItems }, clerk);
+};
+
+const createCustomMenuItems = (
+ { customMenuItems, getDefaultMenuItems }: CreateCustomMenuItemsParams,
+ clerk: LoadedClerk,
+) => {
+ const { INITIAL_MENU_ITEMS, validReorderItemLabels } = getDefaultMenuItems();
+
+ const validCustomMenuItems = customMenuItems.filter(item => {
+ if (!isValidCustomMenuItem(item, validReorderItemLabels)) {
+ if (isDevelopmentSDK(clerk)) {
+ console.error('Clerk: Invalid custom menu item:', item);
+ }
+ return false;
+ }
+ return true;
+ });
+
+ if (isDevelopmentSDK(clerk)) {
+ checkForDuplicateUsageOfReorderingItems(customMenuItems, validReorderItemLabels);
+ warnForDuplicateLabels(validCustomMenuItems);
+ }
+
+ const { menuItems } = getMenuItems({ customMenuItems: validCustomMenuItems, defaultMenuItems: INITIAL_MENU_ITEMS });
+
+ return menuItems;
+};
+
+const getMenuItems = ({
+ customMenuItems,
+ defaultMenuItems,
+}: {
+ customMenuItems: CustomMenuItem[];
+ defaultMenuItems: MenuItem[];
+}) => {
+ let remainingDefaultItems: MenuItem[] = defaultMenuItems.map(r => r);
+
+ const items: MenuItem[] = customMenuItems.map((ci: CustomMenuItem, index) => {
+ if (isCustomMenuLink(ci)) {
+ return {
+ name: ci.label,
+ id: `custom-menutItem-${index}`,
+ icon: props => (
+
+ ),
+ path: sanitizeCustomLinkURL(ci.href),
+ };
+ }
+
+ if (isCustomMenuItem(ci)) {
+ return {
+ name: ci.label,
+ id: `custom-menutItem-${index}`,
+ icon: props => (
+
+ ),
+ onClick: ci?.onClick,
+ };
+ }
+
+ if (isCustomMenuItemWithOpen(ci)) {
+ return {
+ name: ci.label,
+ id: `custom-menutItem-${index}`,
+ icon: props => (
+
+ ),
+ open: ci.open,
+ };
+ }
+
+ const reorderItem = defaultMenuItems.find(item => item.id === ci.label) as MenuItem;
+ remainingDefaultItems = remainingDefaultItems.filter(item => item.id !== ci.label);
+
+ return { ...reorderItem };
+ });
+
+ const allItems = [...items, ...remainingDefaultItems];
+
+ const hasReorderedManageAccount = customMenuItems.some(item => item.label === USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT);
+ // Ensure that the "Manage account" item is always at the top of the list if it's not included in the custom items
+ if (!hasReorderedManageAccount) {
+ allItems.sort(a => {
+ if (a.id === USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT) {
+ return -1;
+ }
+ return 0;
+ });
+ }
+
+ return { menuItems: allItems };
+};
+
+const getUserButtonDefaultMenuItems = () => {
+ const INITIAL_MENU_ITEMS = [
+ {
+ name: localizationKeys('userButton.action__manageAccount'),
+ id: USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT as 'manageAccount',
+ icon: CogFilled as React.ComponentType,
+ },
+ {
+ name: localizationKeys('userButton.action__signOut'),
+ id: USER_BUTTON_ITEM_ID.SIGN_OUT as 'signOut',
+ icon: SignOut as React.ComponentType,
+ },
+ ];
+
+ const validReorderItemLabels: string[] = INITIAL_MENU_ITEMS.map(r => r.id);
+
+ return { INITIAL_MENU_ITEMS, validReorderItemLabels };
+};
+
+const isValidCustomMenuItem = (item: CustomMenuItem, validReorderItemLabels: string[]): item is CustomMenuItem => {
+ return (
+ (isCustomMenuLink(item) ||
+ isCustomMenuItem(item) ||
+ isCustomMenuItemWithOpen(item) ||
+ isReorderItem(item, validReorderItemLabels)) &&
+ typeof item.label === 'string'
+ );
+};
+
+const isCustomMenuItem = (ci: CustomMenuItem): ci is CustomMenuAction => {
+ return !!ci.label && !!ci.onClick && !ci.mount && !ci.unmount && !!ci.mountIcon && !!ci.unmountIcon;
+};
+
+const isCustomMenuItemWithOpen = (ci: CustomMenuItem): ci is CustomMenuActionWithOpen => {
+ return !!ci.label && !!ci.open && !ci.mount && !ci.unmount && !!ci.mountIcon && !!ci.unmountIcon;
+};
+
+const isCustomMenuLink = (ci: CustomMenuItem): ci is CustomMenuLink => {
+ return !!ci.href && !!ci.label && !ci.mount && !ci.unmount && !!ci.mountIcon && !!ci.unmountIcon;
+};
+
+const isReorderItem = (ci: CustomMenuItem, validItems: string[]): ci is MenuReorderItem => {
+ return !ci.mount && !ci.unmount && !ci.mountIcon && !ci.unmountIcon && validItems.some(v => v === ci.label);
+};
+
+const checkForDuplicateUsageOfReorderingItems = (customMenuItems: CustomMenuItem[], validReorderItems: string[]) => {
+ const reorderItems = customMenuItems.filter(ci => isReorderItem(ci, validReorderItems));
+ reorderItems.reduce((acc, ci) => {
+ if (acc.includes(ci.label)) {
+ console.error(
+ `Clerk: The "${ci.label}" item is used more than once when reordering pages. This may cause unexpected behavior.`,
+ );
+ }
+ return [...acc, ci.label];
+ }, [] as string[]);
+};
+
+const warnForDuplicateLabels = (items: CustomMenuItem[]) => {
+ const labels = items.map(item => item.label);
+ const duplicates = labels.filter((label, index) => labels.indexOf(label) !== index);
+ if (duplicates.length > 0) {
+ console.warn(`Clerk: Duplicate custom menu item labels found: ${duplicates.join(', ')}`);
+ }
+};
diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx
index 4145c8e06ef..ecfb19baacf 100644
--- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx
+++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx
@@ -7,6 +7,7 @@ import { Organization, TickShield, User, Users } from '../icons';
import { localizationKeys } from '../localization';
import { ExternalElementMounter } from './ExternalElementMounter';
import { isDevelopmentSDK } from './runtimeEnvironment';
+import { sanitizeCustomLinkURL } from './sanitizeCustomLinkURL';
export type CustomPageContent = {
url: string;
@@ -221,16 +222,6 @@ const sanitizeCustomPageURL = (url: string): string => {
return (url as string).charAt(0) === '/' && (url as string).length > 1 ? (url as string).substring(1) : url;
};
-const sanitizeCustomLinkURL = (url: string): string => {
- if (!url) {
- throw new Error('Clerk: URL is required for custom links');
- }
- if (isValidUrl(url)) {
- return url;
- }
- return (url as string).charAt(0) === '/' ? url : `/${url}`;
-};
-
const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => {
if (routes[0].external) {
throw new Error('Clerk: The first route cannot be a custom external link component');
diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts
index 1ac005eb8a1..b7ada1d1e6c 100644
--- a/packages/clerk-js/src/ui/utils/index.ts
+++ b/packages/clerk-js/src/ui/utils/index.ts
@@ -23,3 +23,4 @@ export * from './passwordUtils';
export * from './createCustomPages';
export * from './ExternalElementMounter';
export * from './colorOptionToHslaScale';
+export * from './createCustomMenuItems';
diff --git a/packages/clerk-js/src/ui/utils/sanitizeCustomLinkURL.ts b/packages/clerk-js/src/ui/utils/sanitizeCustomLinkURL.ts
new file mode 100644
index 00000000000..0e796bda554
--- /dev/null
+++ b/packages/clerk-js/src/ui/utils/sanitizeCustomLinkURL.ts
@@ -0,0 +1,11 @@
+import { isValidUrl } from '../../utils';
+
+export const sanitizeCustomLinkURL = (url: string): string => {
+ if (!url) {
+ throw new Error('Clerk: URL is required for custom links');
+ }
+ if (isValidUrl(url)) {
+ return url;
+ }
+ return (url as string).charAt(0) === '/' ? url : `/${url}`;
+};
diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx
index f5c17e28662..919ec0d1736 100644
--- a/packages/react/src/components/uiComponents.tsx
+++ b/packages/react/src/components/uiComponents.tsx
@@ -18,6 +18,9 @@ import React, { createElement } from 'react';
import {
organizationProfileLinkRenderedError,
organizationProfilePageRenderedError,
+ userButtonMenuActionRenderedError,
+ userButtonMenuItemsRenderedError,
+ userButtonMenuLinkRenderedError,
userProfileLinkRenderedError,
userProfilePageRenderedError,
} from '../errors/messages';
@@ -26,11 +29,13 @@ import type {
OpenProps,
OrganizationProfileLinkProps,
OrganizationProfilePageProps,
+ UserButtonActionProps,
+ UserButtonLinkProps,
UserProfileLinkProps,
UserProfilePageProps,
WithClerkProp,
} from '../types';
-import { useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils';
+import { useOrganizationProfileCustomPages, useUserButtonCustomMenuItems, useUserProfileCustomPages } from '../utils';
import { withClerk } from './withClerk';
type UserProfileExportType = typeof _UserProfile & {
@@ -41,6 +46,9 @@ type UserProfileExportType = typeof _UserProfile & {
type UserButtonExportType = typeof _UserButton & {
UserProfilePage: typeof UserProfilePage;
UserProfileLink: typeof UserProfileLink;
+ MenuItems: typeof MenuItems;
+ Action: typeof MenuAction;
+ Link: typeof MenuLink;
};
type UserButtonPropsWithoutCustomPages = Without & {
@@ -105,14 +113,16 @@ class Portal extends React.PureComponent {
if (!isMountProps(_prevProps) || !isMountProps(this.props)) {
return;
}
+
// Remove children and customPages from props before comparing
// children might hold circular references which deepEqual can't handle
- // and the implementation of customPages relies on props getting new references
- const prevProps = without(_prevProps.props, 'customPages', 'children');
- const newProps = without(this.props.props, 'customPages', 'children');
+ // and the implementation of customPages or customMenuItems relies on props getting new references
+ const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children');
+ const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children');
// instead, we simply use the length of customPages to determine if it changed or not
const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length;
- if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged) {
+ const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length;
+ if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) {
this.props.updateProps({ node: this.portalRef.current, props: this.props.props });
}
}
@@ -146,6 +156,8 @@ class Portal extends React.PureComponent {
{isMountProps(this.props) &&
this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))}
+ {isMountProps(this.props) &&
+ this.props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))}
>
);
}
@@ -208,22 +220,43 @@ const _UserButton = withClerk(
({ clerk, ...props }: WithClerkProp>) => {
const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children);
const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages });
+ const { customMenuItems, customMenuItemsPortals } = useUserButtonCustomMenuItems(props.children);
+
return (
);
},
'UserButton',
);
+export function MenuItems({ children }: PropsWithChildren) {
+ logErrorInDevMode(userButtonMenuItemsRenderedError);
+ return <>{children}>;
+}
+
+export function MenuAction({ children }: PropsWithChildren) {
+ logErrorInDevMode(userButtonMenuActionRenderedError);
+ return <>{children}>;
+}
+
+export function MenuLink({ children }: PropsWithChildren) {
+ logErrorInDevMode(userButtonMenuLinkRenderedError);
+ return <>{children}>;
+}
+
export const UserButton: UserButtonExportType = Object.assign(_UserButton, {
UserProfilePage,
UserProfileLink,
+ MenuItems,
+ Action: MenuAction,
+ Link: MenuLink,
});
export function OrganizationProfilePage({ children }: PropsWithChildren) {
@@ -272,6 +305,7 @@ const _OrganizationSwitcher = withClerk(
({ clerk, ...props }: WithClerkProp>) => {
const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children);
const organizationProfileProps = Object.assign(props.organizationProfileProps || {}, { customPages });
+
return (
export const incompatibleRoutingWithPathProvidedError = (componentName: string) =>
`The \`path\` prop will only be respected when the Clerk component uses path-based routing. To resolve this error, pass \`routing='path'\` to the <${componentName}/> component, or drop the \`path\` prop to switch to hash-based routing. For more details please refer to our docs: https://clerk.com/docs`;
+
+export const userButtonIgnoredComponent = ` can only accept , and as its children. Any other provided component will be ignored.`;
+
+export const customMenuItemsIgnoredComponent =
+ ' component can only accept and as its children. Any other provided component will be ignored.';
+
+export const userButtonMenuItemsRenderedError =
+ ' component needs to be a direct child of ``.';
+
+export const userButtonMenuActionRenderedError =
+ ' component needs to be a direct child of ``.';
+
+export const userButtonMenuLinkRenderedError =
+ ' component needs to be a direct child of ``.';
+
+export const userButtonMenuItemLinkWrongProps =
+ 'Missing props. component requires the following props: href, label and labelIcon.';
+
+export const userButtonMenuItemsActionWrongsProps =
+ 'Missing props. component requires the following props: label.';
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index 9c4ccd7d08f..f9aef9efa1c 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -55,6 +55,7 @@ export interface MountProps {
updateProps: (props: any) => void;
props?: any;
customPagesPortals?: any[];
+ customMenuItemsPortals?: any[];
}
export interface OpenProps {
@@ -127,3 +128,31 @@ export type UserProfileLinkProps = {
export type OrganizationProfilePageProps = PageProps<'general' | 'members'>;
export type OrganizationProfileLinkProps = UserProfileLinkProps;
+
+type ButtonActionProps =
+ | {
+ label: string;
+ labelIcon: React.ReactNode;
+ onClick: () => void;
+ open?: never;
+ }
+ | {
+ label: T;
+ labelIcon?: never;
+ onClick?: never;
+ open?: never;
+ }
+ | {
+ label: string;
+ labelIcon: React.ReactNode;
+ onClick?: never;
+ open: string;
+ };
+
+export type UserButtonActionProps = ButtonActionProps<'manageAccount' | 'signOut'>;
+
+export type UserButtonLinkProps = {
+ href: string;
+ label: string;
+ labelIcon: React.ReactNode;
+};
diff --git a/packages/react/src/utils/__tests__/useCustomMenuItems.test.tsx b/packages/react/src/utils/__tests__/useCustomMenuItems.test.tsx
new file mode 100644
index 00000000000..a7c7528f078
--- /dev/null
+++ b/packages/react/src/utils/__tests__/useCustomMenuItems.test.tsx
@@ -0,0 +1,137 @@
+import { renderHook } from '@testing-library/react';
+import React from 'react';
+
+import { MenuAction, MenuItems, MenuLink } from '../../components/uiComponents';
+import { useUserButtonCustomMenuItems } from '../useCustomMenuItems';
+
+// Mock the logErrorInDevMode function
+jest.mock('@clerk/shared', () => ({
+ logErrorInDevMode: jest.fn(),
+}));
+
+describe('useUserButtonCustomMenuItems', () => {
+ it('should return empty arrays when no children are provided', () => {
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(null));
+ expect(result.current.customMenuItems).toEqual([]);
+ expect(result.current.customMenuItemsPortals).toEqual([]);
+ });
+
+ it('should process valid MenuAction items', () => {
+ const children = (
+
+ Icon}
+ onClick={() => {}}
+ />
+
+ );
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(children));
+ expect(result.current.customMenuItems).toHaveLength(1);
+ expect(result.current.customMenuItems[0].label).toBe('Custom Action');
+ expect(result.current.customMenuItems[0].onClick).toBeDefined();
+ expect(result.current.customMenuItemsPortals).toHaveLength(1);
+ });
+
+ it('should process valid MenuLink items', () => {
+ const children = (
+
+ Icon}
+ href='https://example.com'
+ />
+
+ );
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(children));
+ expect(result.current.customMenuItems).toHaveLength(1);
+ expect(result.current.customMenuItems[0].label).toBe('Custom Link');
+ expect(result.current.customMenuItems[0].href).toBe('https://example.com');
+ expect(result.current.customMenuItemsPortals).toHaveLength(1);
+ });
+
+ it('should process reorder items', () => {
+ const children = (
+
+
+
+
+ );
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(children));
+ expect(result.current.customMenuItems).toHaveLength(2);
+ expect(result.current.customMenuItems[0].label).toBe('manageAccount');
+ expect(result.current.customMenuItems[1].label).toBe('signOut');
+ expect(result.current.customMenuItemsPortals).toHaveLength(0);
+ });
+
+ it('should ignore invalid children', () => {
+ const children = (
+
+ Invalid Child
+
+ );
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(children));
+ expect(result.current.customMenuItems).toHaveLength(0);
+ expect(result.current.customMenuItemsPortals).toHaveLength(0);
+ });
+
+ it('should process MenuAction items with open attribute', () => {
+ const children = (
+
+ Icon}
+ open='/profile'
+ />
+
+ );
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(children));
+ expect(result.current.customMenuItems).toHaveLength(1);
+ expect(result.current.customMenuItems[0].label).toBe('Open Profile');
+ expect(result.current.customMenuItems[0].open).toBe('/profile');
+ expect(result.current.customMenuItemsPortals).toHaveLength(1);
+ });
+
+ it('should prepend "/" to open attribute if not present', () => {
+ const children = (
+
+ Icon}
+ open='settings'
+ />
+
+ );
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(children));
+ expect(result.current.customMenuItems).toHaveLength(1);
+ expect(result.current.customMenuItems[0].open).toBe('/settings');
+ });
+
+ it('should process valid MenuAction items and call onClick when triggered', () => {
+ const mockOnClick = jest.fn();
+ const children = (
+
+ Icon}
+ onClick={mockOnClick}
+ />
+
+ );
+ const { result } = renderHook(() => useUserButtonCustomMenuItems(children));
+
+ expect(result.current.customMenuItems).toHaveLength(1);
+
+ const menuItem = result.current.customMenuItems[0];
+ expect(menuItem).toBeDefined();
+ expect(menuItem.label).toBe('Custom Action');
+ expect(menuItem.onClick).toBeDefined();
+
+ expect(result.current.customMenuItemsPortals).toHaveLength(1);
+
+ if (menuItem.onClick) {
+ menuItem.onClick();
+ expect(mockOnClick).toHaveBeenCalledTimes(1);
+ }
+ });
+});
diff --git a/packages/react/src/utils/componentValidation.ts b/packages/react/src/utils/componentValidation.ts
new file mode 100644
index 00000000000..88b74122db8
--- /dev/null
+++ b/packages/react/src/utils/componentValidation.ts
@@ -0,0 +1,5 @@
+import React from 'react';
+
+export const isThatComponent = (v: any, component: React.ReactNode): v is React.ReactNode => {
+ return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === component;
+};
diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts
index c65c5835502..d776edbffe6 100644
--- a/packages/react/src/utils/index.ts
+++ b/packages/react/src/utils/index.ts
@@ -4,3 +4,4 @@ export { loadClerkJsScript } from './loadClerkJsScript';
export * from './useMaxAllowedInstancesGuard';
export * from './useCustomElementPortal';
export * from './useCustomPages';
+export * from './useCustomMenuItems';
diff --git a/packages/react/src/utils/useCustomMenuItems.tsx b/packages/react/src/utils/useCustomMenuItems.tsx
new file mode 100644
index 00000000000..239a1e0bbf8
--- /dev/null
+++ b/packages/react/src/utils/useCustomMenuItems.tsx
@@ -0,0 +1,203 @@
+import { logErrorInDevMode } from '@clerk/shared';
+import type { CustomMenuItem } from '@clerk/types';
+import type { ReactElement } from 'react';
+import React from 'react';
+
+import { MenuAction, MenuItems, MenuLink, UserProfileLink, UserProfilePage } from '../components/uiComponents';
+import {
+ customMenuItemsIgnoredComponent,
+ userButtonIgnoredComponent,
+ userButtonMenuItemLinkWrongProps,
+ userButtonMenuItemsActionWrongsProps,
+} from '../errors/messages';
+import type { UserButtonActionProps, UserButtonLinkProps } from '../types';
+import { isThatComponent } from './componentValidation';
+import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal';
+import { useCustomElementPortal } from './useCustomElementPortal';
+
+export const useUserButtonCustomMenuItems = (children: React.ReactNode | React.ReactNode[]) => {
+ const reorderItemsLabels = ['manageAccount', 'signOut'];
+ return useCustomMenuItems({
+ children,
+ reorderItemsLabels,
+ MenuItemsComponent: MenuItems,
+ MenuActionComponent: MenuAction,
+ MenuLinkComponent: MenuLink,
+ UserProfileLinkComponent: UserProfileLink,
+ UserProfilePageComponent: UserProfilePage,
+ });
+};
+
+type UseCustomMenuItemsParams = {
+ children: React.ReactNode | React.ReactNode[];
+ MenuItemsComponent?: any;
+ MenuActionComponent?: any;
+ MenuLinkComponent?: any;
+ UserProfileLinkComponent?: any;
+ UserProfilePageComponent?: any;
+ reorderItemsLabels: string[];
+};
+
+type CustomMenuItemType = UserButtonActionProps | UserButtonLinkProps;
+
+const useCustomMenuItems = ({
+ children,
+ MenuItemsComponent,
+ MenuActionComponent,
+ MenuLinkComponent,
+ UserProfileLinkComponent,
+ UserProfilePageComponent,
+ reorderItemsLabels,
+}: UseCustomMenuItemsParams) => {
+ const validChildren: CustomMenuItemType[] = [];
+ const customMenuItems: CustomMenuItem[] = [];
+ const customMenuItemsPortals: React.ComponentType[] = [];
+
+ React.Children.forEach(children, child => {
+ if (
+ !isThatComponent(child, MenuItemsComponent) &&
+ !isThatComponent(child, UserProfileLinkComponent) &&
+ !isThatComponent(child, UserProfilePageComponent)
+ ) {
+ if (child) {
+ logErrorInDevMode(userButtonIgnoredComponent);
+ }
+ return;
+ }
+
+ // Ignore UserProfileLinkComponent and UserProfilePageComponent
+ if (isThatComponent(child, UserProfileLinkComponent) || isThatComponent(child, UserProfilePageComponent)) {
+ return;
+ }
+
+ // Menu items children
+ const { props } = child as ReactElement;
+
+ React.Children.forEach(props.children, child => {
+ if (!isThatComponent(child, MenuActionComponent) && !isThatComponent(child, MenuLinkComponent)) {
+ if (child) {
+ logErrorInDevMode(customMenuItemsIgnoredComponent);
+ }
+
+ return;
+ }
+
+ const { props } = child as ReactElement;
+
+ const { label, labelIcon, href, onClick, open } = props;
+
+ if (isThatComponent(child, MenuActionComponent)) {
+ if (isReorderItem(props, reorderItemsLabels)) {
+ // This is a reordering item
+ validChildren.push({ label });
+ } else if (isCustomMenuItem(props)) {
+ const baseItem = {
+ label,
+ labelIcon,
+ };
+
+ if (onClick !== undefined) {
+ validChildren.push({
+ ...baseItem,
+ onClick,
+ });
+ } else if (open !== undefined) {
+ validChildren.push({
+ ...baseItem,
+ open: open.startsWith('/') ? open : `/${open}`,
+ });
+ } else {
+ // Handle the case where neither onClick nor open is defined
+ logErrorInDevMode('Custom menu item must have either onClick or open property');
+ return;
+ }
+ } else {
+ logErrorInDevMode(userButtonMenuItemsActionWrongsProps);
+ return;
+ }
+ }
+
+ if (isThatComponent(child, MenuLinkComponent)) {
+ if (isExternalLink(props)) {
+ validChildren.push({ label, labelIcon, href });
+ } else {
+ logErrorInDevMode(userButtonMenuItemLinkWrongProps);
+ return;
+ }
+ }
+ });
+ });
+
+ const customMenuItemLabelIcons: UseCustomElementPortalParams[] = [];
+ const customLinkLabelIcons: UseCustomElementPortalParams[] = [];
+ validChildren.forEach((mi, index) => {
+ if (isCustomMenuItem(mi)) {
+ customMenuItemLabelIcons.push({ component: mi.labelIcon, id: index });
+ }
+ if (isExternalLink(mi)) {
+ customLinkLabelIcons.push({ component: mi.labelIcon, id: index });
+ }
+ });
+
+ const customMenuItemLabelIconsPortals = useCustomElementPortal(customMenuItemLabelIcons);
+ const customLinkLabelIconsPortals = useCustomElementPortal(customLinkLabelIcons);
+
+ validChildren.forEach((mi, index) => {
+ if (isReorderItem(mi, reorderItemsLabels)) {
+ customMenuItems.push({
+ label: mi.label,
+ });
+ }
+ if (isCustomMenuItem(mi)) {
+ const {
+ portal: iconPortal,
+ mount: mountIcon,
+ unmount: unmountIcon,
+ } = customMenuItemLabelIconsPortals.find(p => p.id === index) as UseCustomElementPortalReturn;
+ const menuItem: CustomMenuItem = {
+ label: mi.label,
+ mountIcon,
+ unmountIcon,
+ };
+
+ if ('onClick' in mi) {
+ menuItem.onClick = mi.onClick;
+ } else if ('open' in mi) {
+ menuItem.open = mi.open;
+ }
+ customMenuItems.push(menuItem);
+ customMenuItemsPortals.push(iconPortal);
+ }
+ if (isExternalLink(mi)) {
+ const {
+ portal: iconPortal,
+ mount: mountIcon,
+ unmount: unmountIcon,
+ } = customLinkLabelIconsPortals.find(p => p.id === index) as UseCustomElementPortalReturn;
+ customMenuItems.push({
+ label: mi.label,
+ href: mi.href,
+ mountIcon,
+ unmountIcon,
+ });
+ customMenuItemsPortals.push(iconPortal);
+ }
+ });
+
+ return { customMenuItems, customMenuItemsPortals };
+};
+
+const isReorderItem = (childProps: any, validItems: string[]): boolean => {
+ const { children, label, onClick, labelIcon } = childProps;
+ return !children && !onClick && !labelIcon && validItems.some(v => v === label);
+};
+
+const isCustomMenuItem = (childProps: any): childProps is UserButtonActionProps => {
+ const { label, labelIcon, onClick, open } = childProps;
+ return !!labelIcon && !!label && (typeof onClick === 'function' || typeof open === 'string');
+};
+
+const isExternalLink = (childProps: any): childProps is UserButtonLinkProps => {
+ const { label, href, labelIcon } = childProps;
+ return !!href && !!labelIcon && !!label;
+};
diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx
index 848a8fe662a..4274fe34fca 100644
--- a/packages/react/src/utils/useCustomPages.tsx
+++ b/packages/react/src/utils/useCustomPages.tsx
@@ -4,6 +4,7 @@ import type { ReactElement } from 'react';
import React from 'react';
import {
+ MenuItems,
OrganizationProfileLink,
OrganizationProfilePage,
UserProfileLink,
@@ -11,13 +12,10 @@ import {
} from '../components/uiComponents';
import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors/messages';
import type { UserProfilePageProps } from '../types';
+import { isThatComponent } from './componentValidation';
import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal';
import { useCustomElementPortal } from './useCustomElementPortal';
-const isThatComponent = (v: any, component: React.ReactNode): v is React.ReactNode => {
- return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === component;
-};
-
export const useUserProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => {
const reorderItemsLabels = ['account', 'security'];
return useCustomPages({
@@ -25,6 +23,7 @@ export const useUserProfileCustomPages = (children: React.ReactNode | React.Reac
reorderItemsLabels,
LinkComponent: UserProfileLink,
PageComponent: UserProfilePage,
+ MenuItemsComponent: MenuItems,
componentName: 'UserProfile',
});
};
@@ -44,6 +43,7 @@ type UseCustomPagesParams = {
children: React.ReactNode | React.ReactNode[];
LinkComponent: any;
PageComponent: any;
+ MenuItemsComponent?: any;
reorderItemsLabels: string[];
componentName: string;
};
@@ -54,13 +54,18 @@ const useCustomPages = ({
children,
LinkComponent,
PageComponent,
+ MenuItemsComponent,
reorderItemsLabels,
componentName,
}: UseCustomPagesParams) => {
const validChildren: CustomPageWithIdType[] = [];
React.Children.forEach(children, child => {
- if (!isThatComponent(child, PageComponent) && !isThatComponent(child, LinkComponent)) {
+ if (
+ !isThatComponent(child, PageComponent) &&
+ !isThatComponent(child, LinkComponent) &&
+ !isThatComponent(child, MenuItemsComponent)
+ ) {
if (child) {
logErrorInDevMode(customPagesIgnoredComponent(componentName));
}
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index 2276dea23b6..d09c12a1e4d 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -246,6 +246,9 @@ export type ElementsConfig = {
userButtonPopoverActionButton: WithOptions<'manageAccount' | 'addAccount' | 'signOut' | 'signOutAll'>;
userButtonPopoverActionButtonIconBox: WithOptions<'manageAccount' | 'addAccount' | 'signOut' | 'signOutAll'>;
userButtonPopoverActionButtonIcon: WithOptions<'manageAccount' | 'addAccount' | 'signOut' | 'signOutAll'>;
+ userButtonPopoverCustomItemButton: WithOptions;
+ userButtonPopoverCustomItemButtonIconBox: WithOptions;
+ userButtonPopoverActionItemButtonIcon: WithOptions;
userButtonPopoverFooter: WithOptions;
userButtonPopoverFooterPagesLink: WithOptions<'terms' | 'privacy'>;
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index 62ce64ccb46..177e63394b4 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -12,6 +12,7 @@ import type {
UserProfileTheme,
} from './appearance';
import type { ClientResource } from './client';
+import type { CustomMenuItem } from './customMenuItems';
import type { CustomPage } from './customPages';
import type { InstanceType } from './instance';
import type { DisplayThemeJSON } from './json';
@@ -927,6 +928,11 @@ export type UserButtonProps = UserButtonProfileMode & {
* e.g.
*/
userProfileProps?: Pick;
+
+ /*
+ * Provide custom menu actions and links to be rendered inside the UserButton.
+ */
+ customMenuItems?: CustomMenuItem[];
};
type PrimitiveKeys = {
diff --git a/packages/types/src/customMenuItems.ts b/packages/types/src/customMenuItems.ts
new file mode 100644
index 00000000000..2399328e6f4
--- /dev/null
+++ b/packages/types/src/customMenuItems.ts
@@ -0,0 +1,10 @@
+export type CustomMenuItem = {
+ label: string;
+ href?: string;
+ onClick?: () => void;
+ open?: string;
+ mountIcon?: (el: HTMLDivElement) => void;
+ unmountIcon?: (el?: HTMLDivElement) => void;
+ mount?: (el: HTMLDivElement) => void;
+ unmount?: (el?: HTMLDivElement) => void;
+};
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 123e6cc0482..de5dc268ff0 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -57,3 +57,4 @@ export * from './web3Wallet';
export * from './customPages';
export * from './pagination';
export * from './passkey';
+export * from './customMenuItems';