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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/smooth-worms-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@clerk/clerk-js": minor
"@clerk/clerk-react": minor
"@clerk/types": minor
---

Introduce support for custom menu items in `<UserButton/>`.

- Use `<UserButton.MenuItems>` as a child component to wrap custom menu items.
- Use `<UserButton.Link/>` for creating external or internal links.
- Use `<UserButton.Action/>` 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
<UserButton>
<UserButton.MenuItems>
<UserButton.Link label='Terms' labelIcon={<Icon />} href='/terms' />
<UserButton.Action label='Help' labelIcon={<Icon />} open='help' /> // Navigate to `/help` page when UserProfile opens as a modal. (Requires a custom page to have been set in `/help`)
<UserButton.Action label='manageAccount' labelIcon={<Icon />} />
<UserButton.Action label='Chat Modal' labelIcon={<Icon />} onClick={() => setModal(true)} />
</UserButton.MenuItems>
</UserButton>
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
277 changes: 209 additions & 68 deletions packages/clerk-js/src/ui/components/UserButton/SessionActions.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown> | void;
handleSignOutSessionClicked: (session: ActiveSessionResource) => () => Promise<unknown> | void;
handleUserProfileActionClicked: (startPath?: string) => Promise<unknown> | 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 (
<Actions
Expand All @@ -27,41 +60,53 @@ export const SingleSessionActions = (props: SingleSessionActionsProps) => {
borderTopColor: t.colors.$neutralAlpha100,
})}
>
<Action
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('manageAccount')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('manageAccount')}
icon={CogFilled}
label={localizationKeys('userButton.action__manageAccount')}
onClick={handleManageAccountClicked}
sx={t => ({
borderTopWidth: t.borderWidths.$normal,
borderTopStyle: t.borderStyles.$solid,
borderTopColor: t.colors.$neutralAlpha100,
padding: `${t.space.$4} ${t.space.$5}`,
})}
/>
<Action
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('signOut')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('signOut')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('signOut')}
icon={SignOut}
label={localizationKeys('userButton.action__signOut')}
onClick={handleSignOutSessionClicked(session)}
sx={[
t => ({
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 (
<Action
key={item.id}
{...itemDescriptors}
icon={item.icon}
label={item.name}
onClick={
item.id === USER_BUTTON_ITEM_ID.SIGN_OUT
? handleSignOutSessionClicked(session)
: () => handleActionClick(item)
}
sx={commonActionSx}
iconSx={t => ({
width: t.sizes.$4,
height: t.sizes.$4,
})}
/>
);
})}
</Actions>
);
};
Expand All @@ -71,55 +116,151 @@ type MultiSessionActionsProps = {
handleSignOutSessionClicked: (session: ActiveSessionResource) => () => Promise<unknown> | void;
handleSessionClicked: (session: ActiveSessionResource) => () => Promise<unknown> | void;
handleAddAccountClicked: () => Promise<unknown> | void;
handleUserProfileActionClicked: (startPath?: string) => Promise<unknown> | 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 (
<>
<SmallActions
role='menu'
elementDescriptor={descriptors.userButtonPopoverActions}
elementId={descriptors.userButtonPopoverActions.setId('multiSession')}
>
<Flex
justify='between'
sx={t => ({ marginLeft: t.space.$12, padding: `0 ${t.space.$5} ${t.space.$4}`, gap: t.space.$2 })}
{hasOnlyDefaultItems ? (
<SmallActions
role='menu'
elementDescriptor={descriptors.userButtonPopoverActions}
elementId={descriptors.userButtonPopoverActions.setId('multiSession')}
>
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('manageAccount')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('manageAccount')}
icon={CogFilled}
label={localizationKeys('userButton.action__manageAccount')}
onClick={handleManageAccountClicked}
/>
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('signOut')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('signOut')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('signOut')}
icon={SignOut}
label={localizationKeys('userButton.action__signOut')}
onClick={handleSignOutSessionClicked(session)}
/>
</Flex>
</SmallActions>
<Flex
justify='between'
sx={t => ({ marginLeft: t.space.$12, padding: `0 ${t.space.$5} ${t.space.$4}`, gap: t.space.$2 })}
>
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('manageAccount')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('manageAccount')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('manageAccount')}
icon={CogFilled}
label={localizationKeys('userButton.action__manageAccount')}
onClick={handleManageAccountClicked}
/>
<SmallAction
elementDescriptor={descriptors.userButtonPopoverActionButton}
elementId={descriptors.userButtonPopoverActionButton.setId('signOut')}
iconBoxElementDescriptor={descriptors.userButtonPopoverActionButtonIconBox}
iconBoxElementId={descriptors.userButtonPopoverActionButtonIconBox.setId('signOut')}
iconElementDescriptor={descriptors.userButtonPopoverActionButtonIcon}
iconElementId={descriptors.userButtonPopoverActionButtonIcon.setId('signOut')}
icon={SignOut}
label={localizationKeys('userButton.action__signOut')}
onClick={handleSignOutSessionClicked(session)}
/>
</Flex>
</SmallActions>
) : (
<SmallActions
role='menu'
elementDescriptor={descriptors.userButtonPopoverActions}
elementId={descriptors.userButtonPopoverActions.setId('multiSession')}
sx={t => ({
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 (
<Action
key={item.id}
{...itemDescriptors}
icon={item.icon}
label={item.name}
onClick={
item.id === USER_BUTTON_ITEM_ID.SIGN_OUT
? handleSignOutSessionClicked(session)
: () => 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',
})}
/>
);
})}
</SmallActions>
)}

<Actions
role='menu'
sx={t => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
handleSessionClicked,
handleSignOutAllClicked,
handleSignOutSessionClicked,
handleUserProfileActionClicked,
otherSessions,
} = useMultisessionActions({ ...useUserButtonContext(), actionCompleteCallback: close, user });

Expand All @@ -47,7 +48,9 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
<SingleSessionActions
handleManageAccountClicked={handleManageAccountClicked}
handleSignOutSessionClicked={handleSignOutSessionClicked}
handleUserProfileActionClicked={handleUserProfileActionClicked}
session={session}
completedCallback={close}
/>
) : (
<MultiSessionActions
Expand All @@ -57,6 +60,8 @@ export const UserButtonPopover = React.forwardRef<HTMLDivElement, UserButtonPopo
handleSignOutSessionClicked={handleSignOutSessionClicked}
handleSessionClicked={handleSessionClicked}
handleAddAccountClicked={handleAddAccountClicked}
handleUserProfileActionClicked={handleUserProfileActionClicked}
completedCallback={close}
/>
)}
</PopoverCard.Content>
Expand Down
5 changes: 5 additions & 0 deletions packages/clerk-js/src/ui/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Loading