Skip to content

Commit 16ab711

Browse files
authored
feat(mosaic): add UserAvatar (#10500)
1 parent 8af8e3e commit 16ab711

11 files changed

Lines changed: 417 additions & 0 deletions

File tree

‎.changeset/mosaic-user-avatar.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
Add `<UserAvatar />`, which shows the signed-in user's profile image. It takes `shape`, `size` and `fallback`, renders nothing when nobody is signed in, and can be styled through `.cl-user-avatar`.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
5+
import { fapiClient, fapiEmailAddress, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
6+
import { renderWithClerk } from '../../../__tests__/feature/render';
7+
import type { UserAvatarProps } from '../user-avatar';
8+
import { UserAvatar } from '../user-avatar';
9+
10+
const PIXEL =
11+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
12+
13+
const email = fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' });
14+
15+
const alice = fapiUser({ id: 'user_1', first_name: 'Alice', last_name: 'Smith', email_addresses: [email] });
16+
17+
function signedInAs(user = alice) {
18+
return { client: fapiClient([fapiSession({ id: 'sess_1', user })]) };
19+
}
20+
21+
function tree(props: UserAvatarProps = {}) {
22+
return (
23+
<div data-testid='host'>
24+
<UserAvatar {...props} />
25+
</div>
26+
);
27+
}
28+
29+
const host = () => screen.getByTestId('host');
30+
31+
describe('UserAvatar', () => {
32+
it('renders its fallback while Clerk is still loading', async () => {
33+
serveFapi(signedInAs());
34+
const client = holdRequests('get', '/v1/client');
35+
const rendering = renderWithClerk(tree({ fallback: <output data-testid='fallback'>Loading</output> }));
36+
37+
await waitFor(() => expect(client.requests).toHaveLength(1));
38+
expect(screen.getByTestId('fallback')).toBeInTheDocument();
39+
40+
client.release();
41+
await rendering;
42+
expect(screen.queryByTestId('fallback')).toBeNull();
43+
expect(screen.getByRole('img', { name: 'Alice Smith' })).toBeInTheDocument();
44+
});
45+
46+
it('renders nothing, not even its fallback, when nobody is signed in', async () => {
47+
serveFapi({ client: fapiClient() });
48+
await renderWithClerk(tree({ fallback: <output>Loading</output> }));
49+
50+
expect(host()).toBeEmptyDOMElement();
51+
});
52+
53+
it('holds the initials in the placeholder for a user with no profile image', async () => {
54+
serveFapi(signedInAs());
55+
await renderWithClerk(tree());
56+
57+
const avatar = screen.getByRole('img', { name: 'Alice Smith' });
58+
expect(avatar).toHaveTextContent('AS');
59+
expect(avatar.querySelector('img')).toBeNull();
60+
});
61+
62+
it('shows the profile image once it loads', async () => {
63+
serveFapi(signedInAs(fapiUser({ ...alice, image_url: PIXEL, has_image: true })));
64+
await renderWithClerk(tree());
65+
66+
const avatar = screen.getByRole('img', { name: 'Alice Smith' });
67+
await waitFor(() => expect(avatar.querySelector('img')).toHaveAttribute('src', PIXEL));
68+
expect(avatar).not.toHaveTextContent('AS');
69+
});
70+
71+
it('is named by the identifier, and holds its first letter, for a user with no name', async () => {
72+
serveFapi(signedInAs(fapiUser({ id: 'user_2', email_addresses: [email] })));
73+
await renderWithClerk(tree());
74+
75+
expect(screen.getByRole('img', { name: 'alice@example.com' })).toHaveTextContent('A');
76+
});
77+
78+
it('takes the shape and size it is given', async () => {
79+
serveFapi(signedInAs());
80+
await renderWithClerk(tree({ shape: 'square', size: 'lg' }));
81+
82+
const avatar = screen.getByRole('img', { name: 'Alice Smith' });
83+
expect(avatar).toHaveClass('cl-user-avatar', 'cl-avatar');
84+
expect(avatar).toHaveAttribute('data-shape', 'square');
85+
expect(avatar).toHaveAttribute('data-size', 'lg');
86+
});
87+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getFullName, getIdentifier, getInitials } from '@clerk/shared/internal/clerk-js/user';
2+
import { useUser } from '@clerk/shared/react';
3+
4+
import type { UserAvatarData } from './user-avatar.types';
5+
6+
export type UserAvatarModel = { status: 'loading' } | { status: 'hidden' } | (UserAvatarData & { status: 'ready' });
7+
8+
export function useUserAvatarModel(): UserAvatarModel {
9+
const { isLoaded, user } = useUser();
10+
11+
if (!isLoaded) {
12+
return { status: 'loading' };
13+
}
14+
15+
if (!user) {
16+
return { status: 'hidden' };
17+
}
18+
19+
const name = getFullName(user) || getIdentifier(user);
20+
21+
return {
22+
status: 'ready',
23+
name,
24+
initials: (getInitials(user) || name.charAt(0)).toUpperCase(),
25+
imageUrl: user.imageUrl || undefined,
26+
};
27+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use client';
2+
3+
import type { ReactElement, ReactNode } from 'react';
4+
5+
import { useUserAvatarModel } from './user-avatar.model';
6+
import type { UserAvatarAppearanceProps } from './user-avatar.view';
7+
import { UserAvatarView } from './user-avatar.view';
8+
9+
/** Everything `<UserAvatar />` takes: its shape and size, and what stands in while Clerk loads. */
10+
export interface UserAvatarProps extends UserAvatarAppearanceProps {
11+
/**
12+
* Fallback while loading.
13+
*
14+
* Note that the component renders nothing when the user is signed out, so using this on
15+
* pages that are reachable while both signed-out and signed-in can result in Fallback->Nothing.
16+
*/
17+
fallback?: ReactNode;
18+
}
19+
20+
/**
21+
* The signed-in user's profile image. It reads the user from Clerk, so it takes no data. It renders
22+
* `fallback` until Clerk answers, and nothing at all when nobody is signed in. Assistive technology
23+
* reads it as an image named for the user.
24+
*
25+
* @example
26+
* ```tsx
27+
* import { UserAvatar } from '@clerk/mosaic';
28+
*
29+
* <UserAvatar />
30+
* ```
31+
*
32+
* @example
33+
* `shape` and `size` take the same values as `Avatar`.
34+
* ```tsx
35+
* <UserAvatar shape='square' size='lg' />
36+
* ```
37+
*
38+
* @example
39+
* `fallback` holds the space while Clerk is still answering. Nothing stands in once the answer is
40+
* that nobody is signed in.
41+
* ```tsx
42+
* <UserAvatar fallback={<AvatarSkeleton />} />
43+
* ```
44+
*/
45+
export function UserAvatar({ fallback, ...appearance }: UserAvatarProps = {}): ReactElement | null {
46+
const model = useUserAvatarModel();
47+
48+
if (model.status === 'loading') {
49+
return <>{fallback}</>;
50+
}
51+
52+
if (model.status === 'hidden') {
53+
return null;
54+
}
55+
56+
const { status: _status, ...data } = model;
57+
58+
return (
59+
<UserAvatarView
60+
{...data}
61+
{...appearance}
62+
/>
63+
);
64+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { UserResource } from '@clerk/shared/types';
2+
3+
export interface UserAvatarData extends Partial<Pick<UserResource, 'imageUrl'>> {
4+
/** The user's full name, or their identifier when they have none. */
5+
name: string;
6+
initials: string;
7+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { ReactElement } from 'react';
2+
3+
import type { AvatarProps } from '../../components/avatar';
4+
import { Avatar } from '../../components/avatar';
5+
import { themeProps } from '../../props';
6+
import type { UserAvatarData } from './user-avatar.types';
7+
8+
export interface UserAvatarAppearanceProps {
9+
/**
10+
* The outline of the avatar.
11+
*
12+
* @default 'circle'
13+
*/
14+
shape?: AvatarProps['shape'];
15+
/**
16+
* The size of the avatar. `fit` fills the element it sits in.
17+
*
18+
* @default 'sm'
19+
*/
20+
size?: AvatarProps['size'];
21+
}
22+
23+
export interface UserAvatarViewProps extends UserAvatarData, UserAvatarAppearanceProps {}
24+
25+
export function UserAvatarView({
26+
name,
27+
initials,
28+
imageUrl,
29+
shape = 'circle',
30+
size = 'sm',
31+
}: UserAvatarViewProps): ReactElement {
32+
return (
33+
<Avatar.Root
34+
{...themeProps('user-avatar')}
35+
role='img'
36+
aria-label={name}
37+
shape={shape}
38+
size={size}
39+
>
40+
{imageUrl ? (
41+
<Avatar.Image
42+
src={imageUrl}
43+
alt=''
44+
/>
45+
) : null}
46+
<Avatar.Fallback>{initials}</Avatar.Fallback>
47+
</Avatar.Root>
48+
);
49+
}

‎packages/mosaic/src/index.ts‎

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

33
export type { UserButtonProps } from './features/user-button/user-button';
44
export { UserButton } from './features/user-button/user-button';
5+
export type { UserAvatarProps } from './features/user-avatar/user-avatar';
6+
export { UserAvatar } from './features/user-avatar/user-avatar';
57
export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher';
68
export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher';
79
export type { OrganizationProfileProps } from './features/organization-profile/organization-profile';

‎packages/swingset/src/components/DocsViewer.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
5353
},
5454
misc: {
5555
'organization-switcher': dynamic(() => import('../stories/organization-switcher.mdx')),
56+
'user-avatar': dynamic(() => import('../stories/user-avatar.mdx')),
5657
'api-keys-table': dynamic(() => import('../stories/api-keys-table.mdx')),
5758
reverification: dynamic(() => import('../stories/reverification.mdx')),
5859
},

‎packages/swingset/src/lib/registry.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,13 @@ import {
347347
} from '../stories/tooltip.component.stories';
348348
import { meta as tooltipMeta } from '../stories/tooltip.stories';
349349
import { Default as UseFormDefault, meta as useFormMeta } from '../stories/use-form.stories';
350+
import {
351+
Default as UserAvatarDefault,
352+
meta as userAvatarMeta,
353+
NoImage as UserAvatarNoImage,
354+
Shapes as UserAvatarShapes,
355+
Sizes as UserAvatarSizes,
356+
} from '../stories/user-avatar.stories';
350357
import {
351358
Combined as UserButtonCombined,
352359
meta as userButtonMeta,
@@ -687,6 +694,14 @@ const userButtonModule: StoryModule = {
687694
User: UserButtonUser,
688695
};
689696

697+
const userAvatarModule: StoryModule = {
698+
meta: userAvatarMeta,
699+
Default: UserAvatarDefault,
700+
NoImage: UserAvatarNoImage,
701+
Shapes: UserAvatarShapes,
702+
Sizes: UserAvatarSizes,
703+
};
704+
690705
const organizationSwitcherModule: StoryModule = {
691706
meta: organizationSwitcherMeta,
692707
Default: OrganizationSwitcherDefault,
@@ -1063,6 +1078,7 @@ export const registry: StoryModule[] = [
10631078
organizationProfileDangerSectionModule,
10641079
// Misc
10651080
organizationSwitcherModule,
1081+
userAvatarModule,
10661082
apiKeysTableModule,
10671083
reverificationModule,
10681084
// Blocks — flows assembled from components, wired by the caller's machine.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import * as UserAvatarStories from './user-avatar.stories';
2+
3+
# UserAvatar
4+
5+
The signed-in user's profile image on its own. It reads the user from Clerk and takes no data.
6+
Assistive technology reads it as an image named for the user, by their full name or, without one,
7+
their username, email, or phone number.
8+
9+
It renders `fallback` while Clerk loads, and nothing when nobody is signed in.
10+
11+
## Example
12+
13+
<Story
14+
name='Default'
15+
storyModule={UserAvatarStories}
16+
composition={[{ name: 'Avatar', href: '/components/avatar', layer: 'Components' }]}
17+
/>
18+
19+
## Usage
20+
21+
```tsx
22+
import { UserAvatar } from '@clerk/mosaic';
23+
24+
<UserAvatar />;
25+
```
26+
27+
## Props
28+
29+
| Prop | Type | Default | Description |
30+
| ---------- | --------------------------------------- | ---------- | ----------------------------------------------- |
31+
| `shape` | `'circle' \| 'square'` | `'circle'` | The outline of the avatar. |
32+
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'fit'` | `'sm'` | The size of the avatar. `fit` fills its parent. |
33+
| `fallback` | `ReactNode` | — | Rendered while Clerk loads. |
34+
35+
## Without an image
36+
37+
Until the image loads, or when it fails, a blank placeholder holds its place. The user's initials sit
38+
inside it, hidden; see [Styling](#styling) to paint them.
39+
40+
<Story
41+
name='NoImage'
42+
storyModule={UserAvatarStories}
43+
/>
44+
45+
## Shapes
46+
47+
<Story
48+
name='Shapes'
49+
storyModule={UserAvatarStories}
50+
/>
51+
52+
## Sizes
53+
54+
<Story
55+
name='Sizes'
56+
storyModule={UserAvatarStories}
57+
/>
58+
59+
## Styling
60+
61+
| Slot | Attributes |
62+
| ----------------- | ------------------------------------------ |
63+
| `.cl-user-avatar` | `data-shape`, `data-size`, `data-bordered` |
64+
65+
The avatar also carries the `.cl-avatar` slot and its parts, so anything that styles `Avatar` applies
66+
here too. `.cl-user-avatar` targets this one alone.
67+
68+
```css
69+
.cl-user-avatar .cl-avatar-fallback-content {
70+
visibility: visible;
71+
}
72+
```

0 commit comments

Comments
 (0)