From 7c6439dcdc836d256627cc5e47c0cf216635ad69 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 3 Aug 2026 19:35:42 -0600 Subject: [PATCH 01/10] feat(ui): add Mosaic profile icons --- .../swingset/src/stories/icon.stories.tsx | 7 +++++- .../src/mosaic/components/icon/icon.styles.ts | 1 + .../src/mosaic/components/icon/icon.test.tsx | 13 +++++++++++ .../ui/src/mosaic/components/icon/icon.tsx | 2 +- packages/ui/src/mosaic/icons/registry.tsx | 23 +++++++++++++++++-- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/swingset/src/stories/icon.stories.tsx b/packages/swingset/src/stories/icon.stories.tsx index d3bc7eed11c..0bc34de25ff 100644 --- a/packages/swingset/src/stories/icon.stories.tsx +++ b/packages/swingset/src/stories/icon.stories.tsx @@ -17,7 +17,7 @@ export const meta: StoryMeta = { styleEngine: 'stylex', styles: { _variants: { - size: { sm: {}, md: {}, lg: {} }, + size: { xs: {}, sm: {}, md: {}, lg: {} }, }, _defaultVariants: { size: 'md', @@ -44,6 +44,11 @@ export function Default(props: Record) { export function Sizes(props: Record) { return (
+ { expect(svg).toHaveStyle({ marginTop: '8px' }); }); + it('renders the pen with its native 12px geometry', () => { + const { container } = wrap( + , + ); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('data-size', 'xs'); + expect(svg).toHaveAttribute('viewBox', '0 0 12 12'); + expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor'); + }); + it('emits no placement attribute when the icon is not placed', () => { const { container } = wrap(); expect(container.querySelector('svg')).not.toHaveAttribute('data-icon'); diff --git a/packages/ui/src/mosaic/components/icon/icon.tsx b/packages/ui/src/mosaic/components/icon/icon.tsx index b5f756068b4..f8716d1d757 100644 --- a/packages/ui/src/mosaic/components/icon/icon.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.tsx @@ -11,7 +11,7 @@ import { sizes, styles } from './icon.styles'; export interface IconProps extends React.ComponentPropsWithRef<'svg'> { name: IconName; - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; placement?: 'inline-start' | 'inline-end'; } diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 8269dd519fa..ec7529341a7 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -9,11 +9,11 @@ type IconComponent = React.ForwardRefExoticComponent< * Builds a glyph from its inner `` markup. Glyphs omit `width`/`height` so the `Icon` recipe * controls size, and use `currentColor` so they inherit text color. Grow the set on demand. */ -function glyph(children: React.ReactNode): IconComponent { +function glyph(children: React.ReactNode, viewBox = '0 0 16 16'): IconComponent { return React.forwardRef>((props, ref) => ( , ); +const ArrowRightTop = glyph( + , +); + +const Pen = glyph( + , + '0 0 12 12', +); + const LogOut = glyph( ; From 257acfeea7c81a9d502f3b338eb0ff0d0a3a2886 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 3 Aug 2026 19:36:19 -0600 Subject: [PATCH 02/10] feat(ui): add Mosaic user profile panel --- .../mosaic-user-profile-profile-panel.md | 2 + .../user-profile-profile-panel.view.test.tsx | 113 ++++ .../user-profile-profile-panel.styles.ts | 100 ++++ .../user-profile-profile-panel.view.tsx | 497 ++++++++++++++++++ 4 files changed, 712 insertions(+) create mode 100644 .changeset/mosaic-user-profile-profile-panel.md create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx diff --git a/.changeset/mosaic-user-profile-profile-panel.md b/.changeset/mosaic-user-profile-profile-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-profile-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx new file mode 100644 index 00000000000..2124b06a8ec --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -0,0 +1,113 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +const props: UserProfileProfilePanelViewProps = { + name: 'Preston Booth', + username: 'prestonxyz', + emails: [ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true }, + { id: 'email_2', value: 'item2@clerk.dev' }, + ], + phones: [{ id: 'phone_1', value: '+1 801-888-8181' }], +}; + +function renderView(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('UserProfileProfilePanelView', () => { + it('composes the profile content without profile navigation', () => { + renderView({ onEditProfilePicture: vi.fn() }); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Account' }).nextElementSibling).toHaveClass('cl-card-root'); + expect(screen.getByDisplayValue('Preston Booth')).toBeInTheDocument(); + expect(screen.getByDisplayValue('prestonxyz')).toBeInTheDocument(); + expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + const editProfilePicture = screen.getByRole('button', { name: 'Edit profile picture' }); + expect(editProfilePicture).toHaveAttribute('data-size', 'lg'); + expect(editProfilePicture.querySelector('.cl-avatar')).not.toBeNull(); + expect(screen.queryByRole('tab')).toBeNull(); + expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); + }); + + it('edits the profile picture when the overhanging pen treatment is clicked', async () => { + const onEditProfilePicture = vi.fn(); + const user = userEvent.setup(); + renderView({ onEditProfilePicture }); + + const button = screen.getByRole('button', { name: 'Edit profile picture' }); + const penTreatment = button.querySelector('svg')?.parentElement; + expect(penTreatment).not.toBeNull(); + await user.click(penTreatment!); + + expect(onEditProfilePicture).toHaveBeenCalledOnce(); + }); + + it('renders connected accounts and the danger zone when provided', async () => { + const onConnectAccount = vi.fn(); + const onManageConnectedAccount = vi.fn(); + const onDeleteAccount = vi.fn(); + const user = userEvent.setup(); + renderView({ + connectedAccounts: [ + { id: 'google', provider: 'Google', identifier: 'test@google.com' }, + { id: 'apple', provider: 'Apple', connected: false }, + ], + onConnectAccount, + onManageConnectedAccount, + onDeleteAccount, + }); + + expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + + expect(onManageConnectedAccount).toHaveBeenCalledWith('google'); + expect(onConnectAccount).toHaveBeenCalledWith('apple'); + expect(onDeleteAccount).toHaveBeenCalledOnce(); + }); + + it('renders safely before profile data is available', () => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + }); + + it('forwards field and contact actions', async () => { + const onNameChange = vi.fn(); + const onAddEmail = vi.fn(); + const onManageEmail = vi.fn(); + renderView({ onNameChange, onAddEmail, onManageEmail }); + const user = userEvent.setup(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), ' Jr.'); + await user.click(within(screen.getByRole('region', { name: 'Email' })).getByRole('button', { name: 'Add' })); + await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); + + expect(onNameChange).toHaveBeenCalled(); + expect(onAddEmail).toHaveBeenCalledOnce(); + expect(onManageEmail).toHaveBeenCalledWith('email_2'); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts new file mode 100644 index 00000000000..b2d6534a8bb --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -0,0 +1,100 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + avatarControl: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + position: 'relative', + }, + avatarButton: { + borderWidth: 0, + position: 'relative', + }, + avatarEditSurface: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + borderWidth: '1px', + alignItems: 'center', + backgroundColor: colorVars['--cl-color-card'], + boxSizing: 'border-box', + display: 'flex', + insetInlineStart: '-4.5px', + justifyContent: 'center', + position: 'absolute', + height: '20px', + top: '25px', + width: '20px', + }, + card: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-container'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + rowGap: 0, + }, + contactHeader: { + gap: space['3'], + paddingInline: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + paddingBlockStart: space['4'], + }, + contactList: { + paddingBlock: space['2'], + paddingInline: space['2'], + }, + contactValue: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + minWidth: 0, + }, + dangerContent: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + divider: { + marginInline: space['4'], + backgroundColor: colorVars['--cl-color-border'], + height: '1px', + }, + input: { + flexShrink: 0, + maxWidth: '50%', + width: '10.9375rem', + }, + providerIcon: { + flexShrink: 0, + objectFit: 'contain', + height: space['6'], + width: space['6'], + }, + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + row: { + gap: space['4'], + paddingBlock: space['4'], + paddingInline: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + minHeight: space['13'], + }, + section: { + gap: space['2'], + display: 'flex', + flexDirection: 'column', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx new file mode 100644 index 00000000000..453fabd18b3 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -0,0 +1,497 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ChangeEvent, ReactElement } from 'react'; +import { Fragment, useId } from 'react'; + +import { Avatar } from '../components/avatar'; +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Card } from '../components/card'; +import { Heading } from '../components/heading'; +import { Icon } from '../components/icon'; +import { Input } from '../components/input'; +import { Item } from '../components/item'; +import { Text } from '../components/text'; +import { mergeStyleProps, themeProps } from '../props'; +import { colorVars, space } from '../tokens.stylex'; +import { styles } from './user-profile-profile-panel.styles'; + +export interface UserProfileEmail { + id: string; + value: string; + isDefault?: boolean; +} + +export interface UserProfilePhone { + id: string; + value: string; +} + +export interface UserProfileConnectedAccount { + id: string; + provider: string; + identifier?: string; + iconUrl?: string; + connected?: boolean; +} + +export interface UserProfileProfilePanelViewProps { + imageUrl?: string; + name: string; + username: string; + emails: UserProfileEmail[]; + phones: UserProfilePhone[]; + connectedAccounts?: UserProfileConnectedAccount[]; + onEditProfilePicture?: () => void; + onNameChange?: (value: string) => void; + onUsernameChange?: (value: string) => void; + onAddEmail?: () => void; + onManageEmail?: (id: string) => void; + onAddPhone?: () => void; + onManagePhone?: (id: string) => void; + onConnectAccount?: (id: string) => void; + onManageConnectedAccount?: (id: string) => void; + onDeleteAccount?: () => void; +} + +function Divider() { + return
; +} + +function FieldRow({ label, value, onChange }: { label: string; value: string; onChange?: (value: string) => void }) { + const inputId = useId(); + const handleChange = (event: ChangeEvent) => onChange?.(event.target.value); + + return ( +
+ + +
+ ); +} + +function AccountSection({ + imageUrl, + name, + username, + emails, + phones, + onEditProfilePicture, + onNameChange, + onUsernameChange, + onAddEmail, + onManageEmail, + onAddPhone, + onManagePhone, +}: Pick< + UserProfileProfilePanelViewProps, + | 'imageUrl' + | 'name' + | 'username' + | 'emails' + | 'phones' + | 'onEditProfilePicture' + | 'onNameChange' + | 'onUsernameChange' + | 'onAddEmail' + | 'onManageEmail' + | 'onAddPhone' + | 'onManagePhone' +>) { + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + const avatar = ( + + + {initials} + + ); + + return ( +
+

} + size='xs' + > + Account + + + +
+ + Profile picture + +
+ {onEditProfilePicture ? ( + + ) : ( + avatar + )} +
+
+ + + + + + + +
+
+

+ ); +} + +function ConnectedAccountsSection({ + accounts, + onConnect, + onManage, +}: { + accounts: UserProfileConnectedAccount[]; + onConnect?: (id: string) => void; + onManage?: (id: string) => void; +}) { + return ( +
+

} + size='xs' + > + Connected accounts + + + + + {accounts.map((account, index) => { + const connected = account.connected ?? Boolean(account.identifier); + return ( + + {index > 0 ? ( + + ) : null} + + {account.iconUrl ? ( + + + + ) : null} + + {account.provider} + {account.identifier ? {account.identifier} : null} + + + {connected && onManage ? ( + + ) : null} + {!connected && onConnect ? ( + + ) : null} + + + + ); + })} + + + +

+ ); +} + +function DangerZoneSection({ onDelete }: { onDelete: () => void }) { + return ( +
+

} + size='xs' + > + Danger zone + + + +
+
+ Delete account + + This action is permanent and irreversible. + +
+ +
+
+
+

+ ); +} + +function ContactSection({ + label, + items, + onAdd, + onManage, + divided, +}: { + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; + divided?: boolean; +}) { + return ( +
+
+ + {label} + + {onAdd ? ( + + ) : null} +
+ + {items.map(item => ( + + +
+ } + color='neutral' + > + {item.value} + + {item.isDefault ? ( + + Default + + ) : null} +
+
+ {onManage ? ( + + + + ) : null} +
+ ))} +
+ {divided ? : null} +
+ ); +} + +export function UserProfileProfilePanelView({ + imageUrl, + name = '', + username = '', + emails = [], + phones = [], + connectedAccounts = [], + onEditProfilePicture, + onNameChange, + onUsernameChange, + onAddEmail, + onManageEmail, + onAddPhone, + onManagePhone, + onConnectAccount, + onManageConnectedAccount, + onDeleteAccount, +}: UserProfileProfilePanelViewProps): ReactElement { + return ( +
+

} + size='base' + > + Profile + + + {connectedAccounts.length > 0 ? ( + + ) : null} + {onDeleteAccount ? : null} +

+ ); +} From c76fe5202829ca1c0f866c7323f5a7387017eea1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 3 Aug 2026 19:36:48 -0600 Subject: [PATCH 03/10] docs(swingset): add user profile panel examples --- .../swingset/src/components/DocsViewer.tsx | 3 ++ packages/swingset/src/lib/registry.ts | 11 ++++ .../stories/user-profile-profile-panel.mdx | 40 ++++++++++++++ .../user-profile-profile-panel.stories.tsx | 54 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 packages/swingset/src/stories/user-profile-profile-panel.mdx create mode 100644 packages/swingset/src/stories/user-profile-profile-panel.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 4a700973eef..b1527362df6 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { + user: { + 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), + }, organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 420f857cfe0..959a0e95455 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -116,6 +116,10 @@ import { } from '../stories/text.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { + Default as UserProfileProfilePanelDefault, + meta as userProfileProfilePanelMeta, +} from '../stories/user-profile-profile-panel.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -242,7 +246,14 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const userProfileProfilePanelModule: StoryModule = { + meta: userProfileProfilePanelMeta, + Default: UserProfileProfilePanelDefault, +}; + export const registry: StoryModule[] = [ + // User + userProfileProfilePanelModule, // Organization organizationProfileModule, organizationProfileGeneralPanelModule, diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx new file mode 100644 index 00000000000..58a1891c90b --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -0,0 +1,40 @@ +import * as UserProfileProfilePanelStories from './user-profile-profile-panel.stories'; + +# UserProfileProfilePanel + +The Profile panel from UserProfile, composed without the surrounding navigation shell. The +presentational view renders account details, connected accounts, and the danger zone from props +while its callbacks provide the seam for connected Clerk resources. + +## Example + + + +## Usage + +```tsx +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; + + ({ + id: email.id, + value: email.emailAddress, + isDefault: email.id === user.primaryEmailAddressId, + }))} + phones={user.phoneNumbers.map(phone => ({ id: phone.id, value: phone.phoneNumber }))} + connectedAccounts={user.externalAccounts.map(account => ({ + id: account.id, + provider: account.provider, + identifier: account.emailAddress, + connected: true, + }))} + onNameChange={setName} + onUsernameChange={setUsername} +/> +``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx new file mode 100644 index 00000000000..7e58308c752 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -0,0 +1,54 @@ +/** @jsxImportSource @emotion/react */ +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; +const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; + +export { default as __source } from './user-profile-profile-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileProfilePanel', + source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', +}; + +export function Default(_args: Record) { + const [name, setName] = useState('Preston Booth'); + const [username, setUsername] = useState('prestonxyz'); + + return ( + undefined} + onAddPhone={() => undefined} + onConnectAccount={() => undefined} + onDeleteAccount={() => undefined} + onEditProfilePicture={() => undefined} + onManageEmail={() => undefined} + onManagePhone={() => undefined} + onManageConnectedAccount={() => undefined} + onNameChange={setName} + onUsernameChange={setUsername} + /> + ); +} From 00498e653b43a3b5a1e5f690e6d364d00956868e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 5 Aug 2026 10:50:43 -0600 Subject: [PATCH 04/10] refactor(ui): use Field in user profile panel --- .../user-profile-profile-panel.view.test.tsx | 10 ++++++++-- .../user-profile-profile-panel.styles.ts | 4 ++++ .../user-profile-profile-panel.view.tsx | 20 +++++-------------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 2124b06a8ec..0ac79d2de87 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -34,8 +34,14 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Account' }).nextElementSibling).toHaveClass('cl-card-root'); - expect(screen.getByDisplayValue('Preston Booth')).toBeInTheDocument(); - expect(screen.getByDisplayValue('prestonxyz')).toBeInTheDocument(); + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + const usernameInput = screen.getByRole('textbox', { name: 'Username' }); + expect(nameInput).toHaveValue('Preston Booth'); + expect(usernameInput).toHaveValue('prestonxyz'); + expect(nameInput.closest('.cl-field-root')).not.toBeNull(); + expect(usernameInput.closest('.cl-field-root')).not.toBeNull(); + expect(screen.getByText('Name')).toHaveAttribute('for', nameInput.id); + expect(screen.getByText('Username')).toHaveAttribute('for', usernameInput.id); expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); expect(screen.getByText('Default')).toBeInTheDocument(); expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index b2d6534a8bb..6d0768cd410 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -66,6 +66,10 @@ export const styles = stylex.create({ backgroundColor: colorVars['--cl-color-border'], height: '1px', }, + fieldLabel: { + color: colorVars['--cl-color-neutral-foreground'], + fontWeight: 510, + }, input: { flexShrink: 0, maxWidth: '50%', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 453fabd18b3..2cce7ec0c97 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -1,11 +1,12 @@ import * as stylex from '@stylexjs/stylex'; import type { ChangeEvent, ReactElement } from 'react'; -import { Fragment, useId } from 'react'; +import { Fragment } from 'react'; import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; import { Card } from '../components/card'; +import { Field } from '../components/field'; import { Heading } from '../components/heading'; import { Icon } from '../components/icon'; import { Input } from '../components/input'; @@ -58,30 +59,19 @@ function Divider() { } function FieldRow({ label, value, onChange }: { label: string; value: string; onChange?: (value: string) => void }) { - const inputId = useId(); const handleChange = (event: ChangeEvent) => onChange?.(event.target.value); return ( -
- + + {label} -
+ ); } From ef4c07cc27cbc68e2bea8e1d88304b8a9cfbf274 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 5 Aug 2026 11:01:11 -0600 Subject: [PATCH 05/10] refactor(ui): inherit user profile Field label styles --- .../mosaic/user-profile/user-profile-profile-panel.styles.ts | 4 ---- .../mosaic/user-profile/user-profile-profile-panel.view.tsx | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 6d0768cd410..b2d6534a8bb 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -66,10 +66,6 @@ export const styles = stylex.create({ backgroundColor: colorVars['--cl-color-border'], height: '1px', }, - fieldLabel: { - color: colorVars['--cl-color-neutral-foreground'], - fontWeight: 510, - }, input: { flexShrink: 0, maxWidth: '50%', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 2cce7ec0c97..07f1b77516c 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -63,7 +63,7 @@ function FieldRow({ label, value, onChange }: { label: string; value: string; on return ( - {label} + {label} Date: Wed, 5 Aug 2026 11:11:06 -0600 Subject: [PATCH 06/10] refactor(ui): use Menu for user profile actions --- .../user-profile-profile-panel.view.test.tsx | 4 ++ .../user-profile-profile-panel.view.tsx | 41 +++++++++---------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 0ac79d2de87..db1ce8f8b80 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -83,6 +83,8 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Manage Google' })); + expect(onManageConnectedAccount).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); await user.click(screen.getByRole('button', { name: 'Connect' })); await user.click(screen.getByRole('button', { name: 'Delete account' })); @@ -111,6 +113,8 @@ describe('UserProfileProfilePanelView', () => { await user.type(screen.getByRole('textbox', { name: 'Name' }), ' Jr.'); await user.click(within(screen.getByRole('region', { name: 'Email' })).getByRole('button', { name: 'Add' })); await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); + expect(onManageEmail).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); expect(onNameChange).toHaveBeenCalled(); expect(onAddEmail).toHaveBeenCalledOnce(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 07f1b77516c..f7378615dad 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -11,6 +11,7 @@ import { Heading } from '../components/heading'; import { Icon } from '../components/icon'; import { Input } from '../components/input'; import { Item } from '../components/item'; +import { Menu } from '../components/menu'; import { Text } from '../components/text'; import { mergeStyleProps, themeProps } from '../props'; import { colorVars, space } from '../tokens.stylex'; @@ -266,17 +267,15 @@ function ConnectedAccountsSection({ {connected && onManage ? ( - + + + + onManage(account.id)} + /> + + ) : null} {!connected && onConnect ? ( + + + + onManage(item.id)} + /> + + ) : null} From da78d2b1311b19c681b7a5b9573bae967e911e6e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 5 Aug 2026 11:21:30 -0600 Subject: [PATCH 07/10] feat(ui): add user profile item actions --- .../stories/user-profile-profile-panel.mdx | 15 +- .../user-profile-profile-panel.stories.tsx | 16 +- .../user-profile-profile-panel.view.test.tsx | 90 ++++++++ .../user-profile-profile-panel.view.tsx | 205 ++++++++++++++---- 4 files changed, 272 insertions(+), 54 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 58a1891c90b..3e5764ff71c 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -26,8 +26,14 @@ import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user- id: email.id, value: email.emailAddress, isDefault: email.id === user.primaryEmailAddressId, + isVerified: email.verification.status === 'verified', + }))} + phones={user.phoneNumbers.map(phone => ({ + id: phone.id, + value: phone.phoneNumber, + isDefault: phone.id === user.primaryPhoneNumberId, + isVerified: phone.verification.status === 'verified', }))} - phones={user.phoneNumbers.map(phone => ({ id: phone.id, value: phone.phoneNumber }))} connectedAccounts={user.externalAccounts.map(account => ({ id: account.id, provider: account.provider, @@ -36,5 +42,12 @@ import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user- }))} onNameChange={setName} onUsernameChange={setUsername} + onVerifyEmail={verifyEmail} + onSetPrimaryEmail={setPrimaryEmail} + onRemoveEmail={removeEmail} + onVerifyPhone={verifyPhone} + onSetPrimaryPhone={setPrimaryPhone} + onRemovePhone={removePhone} + onRemoveConnectedAccount={removeConnectedAccount} /> ``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index 7e58308c752..d1813c7714c 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -22,8 +22,8 @@ export function Default(_args: Record) { return ( ) { ]} imageUrl={profileImageUrl} name={name} - phones={[{ id: 'phone_1', value: '+1 801-888-8181' }]} + phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]} username={username} onAddEmail={() => undefined} onAddPhone={() => undefined} onConnectAccount={() => undefined} onDeleteAccount={() => undefined} onEditProfilePicture={() => undefined} - onManageEmail={() => undefined} - onManagePhone={() => undefined} - onManageConnectedAccount={() => undefined} + onRemoveConnectedAccount={() => undefined} + onRemoveEmail={() => undefined} + onRemovePhone={() => undefined} + onSetPrimaryEmail={() => undefined} + onSetPrimaryPhone={() => undefined} + onVerifyEmail={() => undefined} + onVerifyPhone={() => undefined} onNameChange={setName} onUsernameChange={setUsername} /> diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index db1ce8f8b80..8536f19d733 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -120,4 +120,94 @@ describe('UserProfileProfilePanelView', () => { expect(onAddEmail).toHaveBeenCalledOnce(); expect(onManageEmail).toHaveBeenCalledWith('email_2'); }); + + it('matches the existing conditional contact and connected-account actions', async () => { + const onVerifyEmail = vi.fn(); + const onSetPrimaryEmail = vi.fn(); + const onRemoveEmail = vi.fn(); + const onVerifyPhone = vi.fn(); + const onSetPrimaryPhone = vi.fn(); + const onRemovePhone = vi.fn(); + const onRemoveConnectedAccount = vi.fn(); + const user = userEvent.setup(); + + renderView({ + emails: [ + { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: false }, + { id: 'email_secondary', value: 'secondary@clerk.dev', isVerified: true }, + { id: 'email_unverified', value: 'unverified@clerk.dev', isVerified: false }, + ], + phones: [ + { id: 'phone_unverified', value: '+1 801-555-0100', isVerified: false }, + { id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true }, + ], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz' }], + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, + onRemoveConnectedAccount, + }); + + await user.click(screen.getByRole('button', { name: 'Manage primary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Complete verification' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_primary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); + expect(removeEmail).toHaveAttribute('data-color', 'negative'); + await user.click(removeEmail); + expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage unverified@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); + expect(onVerifyPhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0101' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage GitHub' })); + const removeConnectedAccount = screen.getByRole('menuitem', { name: 'Remove' }); + expect(removeConnectedAccount).toHaveAttribute('data-color', 'negative'); + await user.click(removeConnectedAccount); + expect(onRemoveConnectedAccount).toHaveBeenCalledWith('github'); + }); + + it('hides action triggers when immutable items have no available actions', () => { + renderView({ + emails: [ + { + id: 'email_immutable', + value: 'immutable@clerk.dev', + isDefault: true, + isVerified: true, + canRemove: false, + }, + ], + phones: [], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }], + onVerifyEmail: vi.fn(), + onSetPrimaryEmail: vi.fn(), + onRemoveEmail: vi.fn(), + onRemoveConnectedAccount: vi.fn(), + }); + + expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index f7378615dad..4e724a8335e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -21,11 +21,16 @@ export interface UserProfileEmail { id: string; value: string; isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; } export interface UserProfilePhone { id: string; value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; } export interface UserProfileConnectedAccount { @@ -34,6 +39,7 @@ export interface UserProfileConnectedAccount { identifier?: string; iconUrl?: string; connected?: boolean; + canRemove?: boolean; } export interface UserProfileProfilePanelViewProps { @@ -48,13 +54,48 @@ export interface UserProfileProfilePanelViewProps { onUsernameChange?: (value: string) => void; onAddEmail?: () => void; onManageEmail?: (id: string) => void; + onVerifyEmail?: (id: string) => void; + onSetPrimaryEmail?: (id: string) => void; + onRemoveEmail?: (id: string) => void; onAddPhone?: () => void; onManagePhone?: (id: string) => void; + onVerifyPhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void; + onRemovePhone?: (id: string) => void; onConnectAccount?: (id: string) => void; onManageConnectedAccount?: (id: string) => void; + onRemoveConnectedAccount?: (id: string) => void; onDeleteAccount?: () => void; } +interface ProfileMenuAction { + label: string; + color?: 'neutral' | 'negative'; + onClick: () => void; +} + +function ProfileActionMenu({ label, actions }: { label: string; actions: ProfileMenuAction[] }) { + if (actions.length === 0) { + return null; + } + + return ( + + + + {actions.map(action => ( + + ))} + + + ); +} + function Divider() { return
; } @@ -87,8 +128,14 @@ function AccountSection({ onUsernameChange, onAddEmail, onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, onAddPhone, onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, }: Pick< UserProfileProfilePanelViewProps, | 'imageUrl' @@ -101,8 +148,14 @@ function AccountSection({ | 'onUsernameChange' | 'onAddEmail' | 'onManageEmail' + | 'onVerifyEmail' + | 'onSetPrimaryEmail' + | 'onRemoveEmail' | 'onAddPhone' | 'onManagePhone' + | 'onVerifyPhone' + | 'onSetPrimaryPhone' + | 'onRemovePhone' >) { const initials = name .split(/\s+/) @@ -191,15 +244,23 @@ function AccountSection({ @@ -211,10 +272,12 @@ function ConnectedAccountsSection({ accounts, onConnect, onManage, + onRemove, }: { accounts: UserProfileConnectedAccount[]; onConnect?: (id: string) => void; onManage?: (id: string) => void; + onRemove?: (id: string) => void; }) { return (
{accounts.map((account, index) => { const connected = account.connected ?? Boolean(account.identifier); + const actions: ProfileMenuAction[] = []; + if (onRemove && account.canRemove !== false) { + actions.push({ + label: 'Remove', + color: 'negative', + onClick: () => onRemove(account.id), + }); + } else if (!onRemove && onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(account.id) }); + } return ( {index > 0 ? ( @@ -266,16 +339,11 @@ function ConnectedAccountsSection({ {account.identifier ? {account.identifier} : null} - {connected && onManage ? ( - - - - onManage(account.id)} - /> - - + {connected ? ( + ) : null} {!connected && onConnect ? (
- {items.map(item => ( - - -
- } - color='neutral' - > - {item.value} - - {item.isDefault ? ( - { + const actions: ProfileMenuAction[] = []; + const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); + } + + if (onRemove && item.canRemove !== false) { + actions.push({ + label: kind === 'email' ? 'Remove email' : 'Remove phone number', + color: 'negative', + onClick: () => onRemove(item.id), + }); + } + + if (!hasExplicitActions && onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); + } + + return ( + + +
+ } color='neutral' - style={{ backgroundColor: 'rgba(23, 23, 23, 0.06)', color: 'inherit' }} > - Default - - ) : null} -
-
- {onManage ? ( - - - - - onManage(item.id)} - /> - - - - ) : null} -
- ))} + {item.value} + + {item.isDefault ? ( + + Default + + ) : null} +
+
+ {actions.length > 0 ? ( + + + + ) : null} +
+ ); + })}
{divided ? : null} @@ -443,10 +540,17 @@ export function UserProfileProfilePanelView({ onUsernameChange, onAddEmail, onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, onAddPhone, onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, onConnectAccount, onManageConnectedAccount, + onRemoveConnectedAccount, onDeleteAccount, }: UserProfileProfilePanelViewProps): ReactElement { return ( @@ -468,6 +572,12 @@ export function UserProfileProfilePanelView({ onEditProfilePicture={onEditProfilePicture} onManageEmail={onManageEmail} onManagePhone={onManagePhone} + onRemoveEmail={onRemoveEmail} + onRemovePhone={onRemovePhone} + onSetPrimaryEmail={onSetPrimaryEmail} + onSetPrimaryPhone={onSetPrimaryPhone} + onVerifyEmail={onVerifyEmail} + onVerifyPhone={onVerifyPhone} onNameChange={onNameChange} onUsernameChange={onUsernameChange} /> @@ -476,6 +586,7 @@ export function UserProfileProfilePanelView({ accounts={connectedAccounts} onConnect={onConnectAccount} onManage={onManageConnectedAccount} + onRemove={onRemoveConnectedAccount} /> ) : null} {onDeleteAccount ? : null} From 1c2be635cb5c9bca0b0c44e3dcdac702171c438a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 5 Aug 2026 11:57:12 -0600 Subject: [PATCH 08/10] fix(ui): align user profile text color --- .../__tests__/user-profile-profile-panel.view.test.tsx | 6 ++++++ .../user-profile/user-profile-profile-panel.view.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 8536f19d733..96d3b62f95c 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -45,6 +45,10 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); expect(screen.getByText('Default')).toBeInTheDocument(); expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + expect(screen.getByText('Profile picture')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('Email')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('Phone')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('item1@clerk.dev')).toHaveAttribute('data-color', 'primary'); const editProfilePicture = screen.getByRole('button', { name: 'Edit profile picture' }); expect(editProfilePicture).toHaveAttribute('data-size', 'lg'); expect(editProfilePicture.querySelector('.cl-avatar')).not.toBeNull(); @@ -82,6 +86,8 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Delete account', { selector: 'p' })).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('This action is permanent and irreversible.')).toHaveAttribute('data-color', 'neutral'); await user.click(screen.getByRole('button', { name: 'Manage Google' })); expect(onManageConnectedAccount).not.toHaveBeenCalled(); await user.click(screen.getByRole('menuitem', { name: 'Manage' })); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 4e724a8335e..79e5bf6e599 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -195,7 +195,7 @@ function AccountSection({
Profile picture @@ -392,7 +392,7 @@ function DangerZoneSection({ onDelete }: { onDelete: () => void }) {
- Delete account + Delete account {label} @@ -497,7 +497,7 @@ function ContactSection({
} - color='neutral' + color='primary' > {item.value} From d8c3cdb3f9b6ad41f65ef8809bf8e7e699f2a7c2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 5 Aug 2026 12:02:20 -0600 Subject: [PATCH 09/10] fix(ui): align user profile label weight --- .../mosaic/user-profile/user-profile-profile-panel.view.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 79e5bf6e599..ea4fd073551 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -14,7 +14,7 @@ import { Item } from '../components/item'; import { Menu } from '../components/menu'; import { Text } from '../components/text'; import { mergeStyleProps, themeProps } from '../props'; -import { colorVars, space } from '../tokens.stylex'; +import { colorVars, fontWeightVars, space } from '../tokens.stylex'; import { styles } from './user-profile-profile-panel.styles'; export interface UserProfileEmail { @@ -196,7 +196,7 @@ function AccountSection({
Profile picture @@ -447,7 +447,7 @@ function ContactSection({ {label} From 0d6237398ee4864712b94cdd8fbbcdbdfea52688 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 5 Aug 2026 12:16:18 -0600 Subject: [PATCH 10/10] fix(ui): prevent user profile layout shift --- .../__tests__/user-profile-profile-panel.view.test.tsx | 3 +++ .../user-profile/user-profile-profile-panel.styles.ts | 9 --------- .../user-profile/user-profile-profile-panel.view.tsx | 4 ++-- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 96d3b62f95c..c64807318be 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -38,6 +38,8 @@ describe('UserProfileProfilePanelView', () => { const usernameInput = screen.getByRole('textbox', { name: 'Username' }); expect(nameInput).toHaveValue('Preston Booth'); expect(usernameInput).toHaveValue('prestonxyz'); + expect(nameInput).toHaveStyle({ width: '10.9375rem', maxWidth: '50%', flexShrink: 0 }); + expect(usernameInput).toHaveStyle({ width: '10.9375rem', maxWidth: '50%', flexShrink: 0 }); expect(nameInput.closest('.cl-field-root')).not.toBeNull(); expect(usernameInput.closest('.cl-field-root')).not.toBeNull(); expect(screen.getByText('Name')).toHaveAttribute('for', nameInput.id); @@ -51,6 +53,7 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByText('item1@clerk.dev')).toHaveAttribute('data-color', 'primary'); const editProfilePicture = screen.getByRole('button', { name: 'Edit profile picture' }); expect(editProfilePicture).toHaveAttribute('data-size', 'lg'); + expect(editProfilePicture).toHaveStyle({ borderWidth: 0, position: 'relative' }); expect(editProfilePicture.querySelector('.cl-avatar')).not.toBeNull(); expect(screen.queryByRole('tab')).toBeNull(); expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index b2d6534a8bb..d373b3db209 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -9,10 +9,6 @@ export const styles = stylex.create({ flexShrink: 0, position: 'relative', }, - avatarButton: { - borderWidth: 0, - position: 'relative', - }, avatarEditSurface: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-full'], @@ -66,11 +62,6 @@ export const styles = stylex.create({ backgroundColor: colorVars['--cl-color-border'], height: '1px', }, - input: { - flexShrink: 0, - maxWidth: '50%', - width: '10.9375rem', - }, providerIcon: { flexShrink: 0, objectFit: 'contain', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index ea4fd073551..1a5c73a4164 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -109,9 +109,9 @@ function FieldRow({ label, value, onChange }: { label: string; value: string; on ); @@ -207,9 +207,9 @@ function AccountSection({ color='neutral' shape='circle' size='lg' + style={{ borderWidth: 0, position: 'relative' }} variant='ghost' onClick={onEditProfilePicture} - {...stylex.props(styles.avatarButton)} > {avatar}