Repository navigation
feat(mosaic): add UserAvatar #10500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(mosaic): add UserAvatar #10500
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@clerk/mosaic': patch | ||
| --- | ||
|
|
||
| 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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { screen, waitFor } from '@testing-library/react'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; | ||
| import { fapiClient, fapiEmailAddress, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; | ||
| import { renderWithClerk } from '../../../__tests__/feature/render'; | ||
| import type { UserAvatarProps } from '../user-avatar'; | ||
| import { UserAvatar } from '../user-avatar'; | ||
|
|
||
| const PIXEL = | ||
| 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; | ||
|
|
||
| const email = fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' }); | ||
|
|
||
| const alice = fapiUser({ id: 'user_1', first_name: 'Alice', last_name: 'Smith', email_addresses: [email] }); | ||
|
|
||
| function signedInAs(user = alice) { | ||
| return { client: fapiClient([fapiSession({ id: 'sess_1', user })]) }; | ||
| } | ||
|
|
||
| function tree(props: UserAvatarProps = {}) { | ||
| return ( | ||
| <div data-testid='host'> | ||
| <UserAvatar {...props} /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| const host = () => screen.getByTestId('host'); | ||
|
|
||
| describe('UserAvatar', () => { | ||
| it('renders its fallback while Clerk is still loading', async () => { | ||
| serveFapi(signedInAs()); | ||
| const client = holdRequests('get', '/v1/client'); | ||
| const rendering = renderWithClerk(tree({ fallback: <output data-testid='fallback'>Loading</output> })); | ||
|
|
||
| await waitFor(() => expect(client.requests).toHaveLength(1)); | ||
| expect(screen.getByTestId('fallback')).toBeInTheDocument(); | ||
|
|
||
| client.release(); | ||
| await rendering; | ||
| expect(screen.queryByTestId('fallback')).toBeNull(); | ||
| expect(screen.getByRole('img', { name: 'Alice Smith' })).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders nothing, not even its fallback, when nobody is signed in', async () => { | ||
| serveFapi({ client: fapiClient() }); | ||
| await renderWithClerk(tree({ fallback: <output>Loading</output> })); | ||
|
|
||
| expect(host()).toBeEmptyDOMElement(); | ||
| }); | ||
|
|
||
| it('holds the initials in the placeholder for a user with no profile image', async () => { | ||
| serveFapi(signedInAs()); | ||
| await renderWithClerk(tree()); | ||
|
|
||
| const avatar = screen.getByRole('img', { name: 'Alice Smith' }); | ||
| expect(avatar).toHaveTextContent('AS'); | ||
| expect(avatar.querySelector('img')).toBeNull(); | ||
| }); | ||
|
|
||
| it('shows the profile image once it loads', async () => { | ||
| serveFapi(signedInAs(fapiUser({ ...alice, image_url: PIXEL, has_image: true }))); | ||
| await renderWithClerk(tree()); | ||
|
|
||
| const avatar = screen.getByRole('img', { name: 'Alice Smith' }); | ||
| await waitFor(() => expect(avatar.querySelector('img')).toHaveAttribute('src', PIXEL)); | ||
| expect(avatar).not.toHaveTextContent('AS'); | ||
| }); | ||
|
|
||
| it('is named by the identifier, and holds its first letter, for a user with no name', async () => { | ||
| serveFapi(signedInAs(fapiUser({ id: 'user_2', email_addresses: [email] }))); | ||
| await renderWithClerk(tree()); | ||
|
|
||
| expect(screen.getByRole('img', { name: 'alice@example.com' })).toHaveTextContent('A'); | ||
| }); | ||
|
|
||
| it('takes the shape and size it is given', async () => { | ||
| serveFapi(signedInAs()); | ||
| await renderWithClerk(tree({ shape: 'square', size: 'lg' })); | ||
|
|
||
| const avatar = screen.getByRole('img', { name: 'Alice Smith' }); | ||
| expect(avatar).toHaveClass('cl-user-avatar', 'cl-avatar'); | ||
| expect(avatar).toHaveAttribute('data-shape', 'square'); | ||
| expect(avatar).toHaveAttribute('data-size', 'lg'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { getFullName, getIdentifier, getInitials } from '@clerk/shared/internal/clerk-js/user'; | ||
| import { useUser } from '@clerk/shared/react'; | ||
|
|
||
| import type { UserAvatarData } from './user-avatar.types'; | ||
|
|
||
| export type UserAvatarModel = { status: 'loading' } | { status: 'hidden' } | (UserAvatarData & { status: 'ready' }); | ||
|
|
||
| export function useUserAvatarModel(): UserAvatarModel { | ||
| const { isLoaded, user } = useUser(); | ||
|
|
||
| if (!isLoaded) { | ||
| return { status: 'loading' }; | ||
| } | ||
|
|
||
| if (!user) { | ||
| return { status: 'hidden' }; | ||
| } | ||
|
|
||
| const name = getFullName(user) || getIdentifier(user); | ||
|
|
||
| return { | ||
| status: 'ready', | ||
| name, | ||
| initials: (getInitials(user) || name.charAt(0)).toUpperCase(), | ||
| imageUrl: user.imageUrl || undefined, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| 'use client'; | ||
|
|
||
| import type { ReactElement, ReactNode } from 'react'; | ||
|
|
||
| import { useUserAvatarModel } from './user-avatar.model'; | ||
| import type { UserAvatarAppearanceProps } from './user-avatar.view'; | ||
| import { UserAvatarView } from './user-avatar.view'; | ||
|
|
||
| /** Everything `<UserAvatar />` takes: its shape and size, and what stands in while Clerk loads. */ | ||
| export interface UserAvatarProps extends UserAvatarAppearanceProps { | ||
| /** | ||
| * Fallback while loading. | ||
| * | ||
| * Note that the component renders nothing when the user is signed out, so using this on | ||
| * pages that are reachable while both signed-out and signed-in can result in Fallback->Nothing. | ||
| */ | ||
| fallback?: ReactNode; | ||
| } | ||
|
|
||
| /** | ||
| * The signed-in user's profile image. It reads the user from Clerk, so it takes no data. It renders | ||
| * `fallback` until Clerk answers, and nothing at all when nobody is signed in. Assistive technology | ||
| * reads it as an image named for the user. | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * import { UserAvatar } from '@clerk/mosaic'; | ||
| * | ||
| * <UserAvatar /> | ||
| * ``` | ||
| * | ||
| * @example | ||
| * `shape` and `size` take the same values as `Avatar`. | ||
| * ```tsx | ||
| * <UserAvatar shape='square' size='lg' /> | ||
| * ``` | ||
| * | ||
| * @example | ||
| * `fallback` holds the space while Clerk is still answering. Nothing stands in once the answer is | ||
| * that nobody is signed in. | ||
| * ```tsx | ||
| * <UserAvatar fallback={<AvatarSkeleton />} /> | ||
| * ``` | ||
| */ | ||
| export function UserAvatar({ fallback, ...appearance }: UserAvatarProps = {}): ReactElement | null { | ||
| const model = useUserAvatarModel(); | ||
|
|
||
| if (model.status === 'loading') { | ||
| return <>{fallback}</>; | ||
| } | ||
|
|
||
| if (model.status === 'hidden') { | ||
| return null; | ||
| } | ||
|
|
||
| const { status: _status, ...data } = model; | ||
|
|
||
| return ( | ||
| <UserAvatarView | ||
| {...data} | ||
| {...appearance} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import type { UserResource } from '@clerk/shared/types'; | ||
|
|
||
| export interface UserAvatarData extends Partial<Pick<UserResource, 'imageUrl'>> { | ||
| /** The user's full name, or their identifier when they have none. */ | ||
| name: string; | ||
| initials: string; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import type { ReactElement } from 'react'; | ||
|
|
||
| import type { AvatarProps } from '../../components/avatar'; | ||
| import { Avatar } from '../../components/avatar'; | ||
| import { themeProps } from '../../props'; | ||
| import type { UserAvatarData } from './user-avatar.types'; | ||
|
|
||
| export interface UserAvatarAppearanceProps { | ||
| /** | ||
| * The outline of the avatar. | ||
| * | ||
| * @default 'circle' | ||
| */ | ||
| shape?: AvatarProps['shape']; | ||
| /** | ||
| * The size of the avatar. `fit` fills the element it sits in. | ||
| * | ||
| * @default 'sm' | ||
| */ | ||
| size?: AvatarProps['size']; | ||
| } | ||
|
|
||
| export interface UserAvatarViewProps extends UserAvatarData, UserAvatarAppearanceProps {} | ||
|
|
||
| export function UserAvatarView({ | ||
| name, | ||
| initials, | ||
| imageUrl, | ||
| shape = 'circle', | ||
| size = 'sm', | ||
| }: UserAvatarViewProps): ReactElement { | ||
| return ( | ||
| <Avatar.Root | ||
| {...themeProps('user-avatar')} | ||
| role='img' | ||
| aria-label={name} | ||
| shape={shape} | ||
| size={size} | ||
| > | ||
| {imageUrl ? ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reset avatar status when the image disappears. If a mounted Proposed change
|
||
| <Avatar.Image | ||
| src={imageUrl} | ||
| alt='' | ||
| /> | ||
| ) : null} | ||
| <Avatar.Fallback>{initials}</Avatar.Fallback> | ||
| </Avatar.Root> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import * as UserAvatarStories from './user-avatar.stories'; | ||
|
|
||
| # UserAvatar | ||
|
|
||
| The signed-in user's profile image on its own. It reads the user from Clerk and takes no data. | ||
| Assistive technology reads it as an image named for the user, by their full name or, without one, | ||
| their username, email, or phone number. | ||
|
|
||
| It renders `fallback` while Clerk loads, and nothing when nobody is signed in. | ||
|
|
||
| ## Example | ||
|
|
||
| <Story | ||
| name='Default' | ||
| storyModule={UserAvatarStories} | ||
| composition={[{ name: 'Avatar', href: '/components/avatar', layer: 'Components' }]} | ||
| /> | ||
|
|
||
| ## Usage | ||
|
|
||
| ```tsx | ||
| import { UserAvatar } from '@clerk/mosaic'; | ||
|
|
||
| <UserAvatar />; | ||
| ``` | ||
|
|
||
| ## Props | ||
|
|
||
| | Prop | Type | Default | Description | | ||
| | ---------- | --------------------------------------- | ---------- | ----------------------------------------------- | | ||
| | `shape` | `'circle' \| 'square'` | `'circle'` | The outline of the avatar. | | ||
| | `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'fit'` | `'sm'` | The size of the avatar. `fit` fills its parent. | | ||
| | `fallback` | `ReactNode` | — | Rendered while Clerk loads. | | ||
|
|
||
| ## Without an image | ||
|
|
||
| Until the image loads, or when it fails, a blank placeholder holds its place. The user's initials sit | ||
| inside it, hidden; see [Styling](#styling) to paint them. | ||
|
|
||
| <Story | ||
| name='NoImage' | ||
| storyModule={UserAvatarStories} | ||
| /> | ||
|
|
||
| ## Shapes | ||
|
|
||
| <Story | ||
| name='Shapes' | ||
| storyModule={UserAvatarStories} | ||
| /> | ||
|
|
||
| ## Sizes | ||
|
|
||
| <Story | ||
| name='Sizes' | ||
| storyModule={UserAvatarStories} | ||
| /> | ||
|
|
||
| ## Styling | ||
|
|
||
| | Slot | Attributes | | ||
| | ----------------- | ------------------------------------------ | | ||
| | `.cl-user-avatar` | `data-shape`, `data-size`, `data-bordered` | | ||
|
|
||
| The avatar also carries the `.cl-avatar` slot and its parts, so anything that styles `Avatar` applies | ||
| here too. `.cl-user-avatar` targets this one alone. | ||
|
|
||
| ```css | ||
| .cl-user-avatar .cl-avatar-fallback-content { | ||
| visibility: visible; | ||
| } | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: clerk/javascript
Length of output: 1585
Gate the avatar image on
hasImage.user.imageUrlcan hold Clerk's default avatar when the user has no profile image. Forwarding it toAvatar.Imageshows that default instead of the blank placeholder the feature is meant to render.user.hasImageidentifies whether a profile image exists, so it can gate the URL.Proposed change
Committable suggestion