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';