Skip to content

Commit d8d5735

Browse files
feat(ui): add user profile account section
1 parent c9700db commit d8d5735

8 files changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ViewSource } from './ViewSource';
1212
const docModules: Record<string, Record<string, React.ComponentType>> = {
1313
user: {
1414
'user-button': dynamic(() => import('../stories/user-button.mdx')),
15+
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
1516
},
1617
components: {
1718
avatar: dynamic(() => import('../stories/avatar.mdx')),

packages/swingset/src/lib/registry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ import {
9696
Organizations as UserButtonOrganizations,
9797
User as UserButtonUser,
9898
} from '../stories/user-button.stories';
99+
import {
100+
Default as UserProfileAccountSectionDefault,
101+
meta as userProfileAccountSectionMeta,
102+
} from '../stories/user-profile-account-section.stories';
99103
import { toSlug } from './slug';
100104
import type { StoryModule } from './types';
101105

@@ -204,9 +208,15 @@ const scrollAreaModule: StoryModule = {
204208

205209
const useDataTableModule: StoryModule = { meta: useDataTableMeta };
206210

211+
const userProfileAccountSectionModule: StoryModule = {
212+
meta: userProfileAccountSectionMeta,
213+
Default: UserProfileAccountSectionDefault,
214+
};
215+
207216
export const registry: StoryModule[] = [
208217
// User
209218
userButtonModule,
219+
userProfileAccountSectionModule,
210220
// Components
211221
avatarModule,
212222
badgeModule,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as Stories from './user-profile-account-section.stories';
2+
3+
# UserProfileAccountSection
4+
5+
Account details, profile image, email addresses, and phone numbers composed with `Section`.
6+
7+
<Story
8+
name='Default'
9+
storyModule={Stories}
10+
composition={[
11+
{ name: 'Section', href: '/components/section', layer: 'Components' },
12+
{ name: 'Avatar', href: '/components/avatar', layer: 'Components' },
13+
]}
14+
/>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view';
3+
4+
import type { StoryMeta } from '@/lib/types';
5+
6+
export { default as __source } from './user-profile-account-section.stories?raw';
7+
8+
export const meta: StoryMeta = {
9+
group: 'User',
10+
title: 'UserProfileAccountSection',
11+
source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx',
12+
styleEngine: 'stylex',
13+
};
14+
15+
export function Default() {
16+
return (
17+
<UserProfileAccountSectionView
18+
emails={[
19+
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
20+
{ id: 'email_2', value: 'item2@clerk.dev', isVerified: true },
21+
]}
22+
imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4'
23+
name='Preston Booth'
24+
phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]}
25+
username='prestonxyz'
26+
onAddEmail={() => undefined}
27+
onAddPhone={() => undefined}
28+
onEditProfilePicture={() => undefined}
29+
onManageEmail={() => undefined}
30+
onManagePhone={() => undefined}
31+
onNameChange={() => undefined}
32+
onUsernameChange={() => undefined}
33+
/>
34+
);
35+
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
import { Avatar } from '../components/avatar';
4+
import { Badge } from '../components/badge';
5+
import { Button } from '../components/button';
6+
import { Icon } from '../components/icon';
7+
import { Section } from '../components/section';
8+
import type { UserProfileMenuAction } from './user-profile-action-menu';
9+
import { UserProfileActionMenu } from './user-profile-action-menu';
10+
import { styles } from './user-profile-profile-panel.styles';
11+
12+
export interface UserProfileEmail {
13+
id: string;
14+
value: string;
15+
isDefault?: boolean;
16+
isVerified?: boolean;
17+
canRemove?: boolean;
18+
}
19+
20+
export interface UserProfilePhone {
21+
id: string;
22+
value: string;
23+
isDefault?: boolean;
24+
isVerified?: boolean;
25+
canRemove?: boolean;
26+
}
27+
28+
export interface UserProfileAccountSectionViewProps {
29+
imageUrl?: string;
30+
name: string;
31+
username: string;
32+
emails: UserProfileEmail[];
33+
phones: UserProfilePhone[];
34+
onEditProfilePicture?: () => void;
35+
onNameChange?: (value: string) => void;
36+
onUsernameChange?: (value: string) => void;
37+
onAddEmail?: () => void;
38+
onManageEmail?: (id: string) => void;
39+
onVerifyEmail?: (id: string) => void;
40+
onSetPrimaryEmail?: (id: string) => void;
41+
onRemoveEmail?: (id: string) => void;
42+
onAddPhone?: () => void;
43+
onManagePhone?: (id: string) => void;
44+
onVerifyPhone?: (id: string) => void;
45+
onSetPrimaryPhone?: (id: string) => void;
46+
onRemovePhone?: (id: string) => void;
47+
}
48+
49+
export function UserProfileAccountSectionView({
50+
imageUrl,
51+
name,
52+
username,
53+
emails,
54+
phones,
55+
onEditProfilePicture,
56+
onNameChange,
57+
onUsernameChange,
58+
onAddEmail,
59+
onManageEmail,
60+
onVerifyEmail,
61+
onSetPrimaryEmail,
62+
onRemoveEmail,
63+
onAddPhone,
64+
onManagePhone,
65+
onVerifyPhone,
66+
onSetPrimaryPhone,
67+
onRemovePhone,
68+
}: UserProfileAccountSectionViewProps) {
69+
const initials = name
70+
.split(/\s+/)
71+
.map(part => part[0])
72+
.join('')
73+
.slice(0, 2)
74+
.toUpperCase();
75+
const updateName = onNameChange ? () => onNameChange(name) : undefined;
76+
const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined;
77+
78+
return (
79+
<Section.Root aria-label='Account'>
80+
<Section.Group>
81+
<Section.Row>
82+
<Section.Item>
83+
<Section.Media size='lg'>
84+
<Avatar.Root
85+
size='fit'
86+
render={
87+
onEditProfilePicture ? (
88+
<button
89+
type='button'
90+
aria-label='Edit profile picture'
91+
onClick={onEditProfilePicture}
92+
/>
93+
) : undefined
94+
}
95+
>
96+
<Avatar.Image
97+
alt={name}
98+
src={imageUrl}
99+
/>
100+
<Avatar.Fallback>{initials}</Avatar.Fallback>
101+
{onEditProfilePicture ? (
102+
<Avatar.Icon>
103+
<Icon name='pen' />
104+
</Avatar.Icon>
105+
) : null}
106+
</Avatar.Root>
107+
</Section.Media>
108+
<Section.Content>
109+
<Section.Label>Profile picture</Section.Label>
110+
<Section.Description>Recommend size 1:1, up to 10MB.</Section.Description>
111+
</Section.Content>
112+
</Section.Item>
113+
</Section.Row>
114+
<Section.Row>
115+
<Section.Item>
116+
<Section.Content>
117+
<Section.Label>Name</Section.Label>
118+
<Section.Description>{name}</Section.Description>
119+
</Section.Content>
120+
{updateName ? (
121+
<Section.Actions>
122+
<Button
123+
color='neutral'
124+
size='sm'
125+
variant='outline'
126+
onClick={updateName}
127+
>
128+
Edit name
129+
</Button>
130+
</Section.Actions>
131+
) : null}
132+
</Section.Item>
133+
</Section.Row>
134+
<Section.Row>
135+
<Section.Item>
136+
<Section.Content>
137+
<Section.Label>Username</Section.Label>
138+
<Section.Description>{username}</Section.Description>
139+
</Section.Content>
140+
{updateUsername ? (
141+
<Section.Actions>
142+
<Button
143+
color='neutral'
144+
size='sm'
145+
variant='outline'
146+
onClick={updateUsername}
147+
>
148+
Edit username
149+
</Button>
150+
</Section.Actions>
151+
) : null}
152+
</Section.Item>
153+
</Section.Row>
154+
<ContactSection
155+
items={emails}
156+
kind='email'
157+
label='Email'
158+
onAdd={onAddEmail}
159+
onManage={onManageEmail}
160+
onRemove={onRemoveEmail}
161+
onSetPrimary={onSetPrimaryEmail}
162+
onVerify={onVerifyEmail}
163+
/>
164+
<ContactSection
165+
items={phones}
166+
kind='phone'
167+
label='Phone'
168+
onAdd={onAddPhone}
169+
onManage={onManagePhone}
170+
onRemove={onRemovePhone}
171+
onSetPrimary={onSetPrimaryPhone}
172+
onVerify={onVerifyPhone}
173+
/>
174+
</Section.Group>
175+
</Section.Root>
176+
);
177+
}
178+
179+
function ContactSection({
180+
kind,
181+
label,
182+
items,
183+
onAdd,
184+
onManage,
185+
onVerify,
186+
onSetPrimary,
187+
onRemove,
188+
}: {
189+
kind: 'email' | 'phone';
190+
label: string;
191+
items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>;
192+
onAdd?: () => void;
193+
onManage?: (id: string) => void;
194+
onVerify?: (id: string) => void;
195+
onSetPrimary?: (id: string) => void;
196+
onRemove?: (id: string) => void;
197+
}) {
198+
const labelId = `user-profile-profile-panel-${label.toLowerCase()}`;
199+
200+
return (
201+
<Section.Row
202+
render={props => (
203+
<section
204+
{...props}
205+
aria-labelledby={labelId}
206+
/>
207+
)}
208+
>
209+
<Section.Item>
210+
<Section.Content>
211+
<Section.Label id={labelId}>{label}</Section.Label>
212+
</Section.Content>
213+
{onAdd ? (
214+
<Section.Actions>
215+
<Button
216+
color='neutral'
217+
size='sm'
218+
variant='outline'
219+
onClick={onAdd}
220+
>
221+
{kind === 'email' ? 'Add email' : 'Add phone number'}
222+
</Button>
223+
</Section.Actions>
224+
) : null}
225+
</Section.Item>
226+
<Section.Items>
227+
{items.map(item => {
228+
const actions: UserProfileMenuAction[] = [];
229+
const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove);
230+
231+
if (item.isVerified === false && onVerify) {
232+
actions.push({
233+
label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number',
234+
onClick: () => onVerify(item.id),
235+
});
236+
} else if (!item.isDefault && item.isVerified === true && onSetPrimary) {
237+
actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) });
238+
}
239+
240+
if (onRemove && item.canRemove !== false) {
241+
actions.push({
242+
label: kind === 'email' ? 'Remove email' : 'Remove phone number',
243+
color: 'negative',
244+
onClick: () => onRemove(item.id),
245+
});
246+
}
247+
248+
if (!hasExplicitActions && onManage) {
249+
actions.push({ label: 'Manage', onClick: () => onManage(item.id) });
250+
}
251+
252+
return (
253+
<Section.Item key={item.id}>
254+
<Section.Content>
255+
<Section.Description {...stylex.props(styles.contactValue)}>
256+
<span>{item.value}</span>
257+
{item.isDefault ? <Badge color='neutral'>Primary</Badge> : null}
258+
</Section.Description>
259+
</Section.Content>
260+
{actions.length > 0 ? (
261+
<Section.Actions>
262+
<UserProfileActionMenu
263+
actions={actions}
264+
label={`Manage ${item.value}`}
265+
/>
266+
</Section.Actions>
267+
) : null}
268+
</Section.Item>
269+
);
270+
})}
271+
</Section.Items>
272+
</Section.Row>
273+
);
274+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Menu } from '../components/menu';
2+
3+
export interface UserProfileMenuAction {
4+
label: string;
5+
color?: 'neutral' | 'negative';
6+
onClick: () => void;
7+
}
8+
9+
export function UserProfileActionMenu({ label, actions }: { label: string; actions: UserProfileMenuAction[] }) {
10+
if (actions.length === 0) {
11+
return null;
12+
}
13+
14+
return (
15+
<Menu.Root placement='bottom-end'>
16+
<Menu.Trigger aria-label={label} />
17+
<Menu.Content>
18+
{actions.map(action => (
19+
<Menu.Item
20+
key={action.label}
21+
color={action.color}
22+
label={action.label}
23+
onClick={action.onClick}
24+
/>
25+
))}
26+
</Menu.Content>
27+
</Menu.Root>
28+
);
29+
}

0 commit comments

Comments
 (0)