Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .changeset/avatar-button-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-avatar-interactive-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/user-profile-account-section.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ViewSource } from './ViewSource';
const docModules: Record<string, Record<string, React.ComponentType>> = {
user: {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')),
},
organization: {
'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')),
Expand Down
12 changes: 12 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { meta as accordionMeta } from '../stories/accordion.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import {
Fallback as AvatarFallbackStory,
Interactive as AvatarInteractive,
meta as avatarMeta,
Primary as AvatarPrimary,
Shapes as AvatarShapes,
Expand Down Expand Up @@ -129,6 +130,10 @@ import {
Organizations as UserButtonOrganizations,
User as UserButtonUser,
} from '../stories/user-button.stories';
import {
Default as UserProfileAccountSectionDefault,
meta as userProfileAccountSectionMeta,
} from '../stories/user-profile-account-section.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';

Expand Down Expand Up @@ -175,6 +180,7 @@ const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: Car
const avatarModule: StoryModule = {
meta: avatarMeta,
Primary: AvatarPrimary,
Interactive: AvatarInteractive,
Fallback: AvatarFallbackStory,
Sizes: AvatarSizes,
Shapes: AvatarShapes,
Expand Down Expand Up @@ -269,9 +275,15 @@ const scrollAreaModule: StoryModule = {

const useDataTableModule: StoryModule = { meta: useDataTableMeta };

const userProfileAccountSectionModule: StoryModule = {
meta: userProfileAccountSectionMeta,
Default: UserProfileAccountSectionDefault,
};

export const registry: StoryModule[] = [
// User
userButtonModule,
userProfileAccountSectionModule,
// Organization
organizationProfileModule,
organizationProfileGeneralPanelModule,
Expand Down
14 changes: 12 additions & 2 deletions packages/swingset/src/stories/avatar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as AvatarStories from './avatar.stories';

# Avatar

Avatar represents a user or entity as an image, falling back to a blank placeholder when the image is missing or fails to load. It is a compound component: `Avatar.Root` clips and sizes the box, `Avatar.Image` renders the picture once it loads, and `Avatar.Fallback` holds the space until then.
Avatar represents a user or entity as an image, falling back to a blank placeholder when the image is missing or fails to load. It is a compound component: `Avatar.Root` positions and sizes the box, `Avatar.Image` renders the picture once it loads, `Avatar.Fallback` holds the space until then, and `Avatar.Icon` adds an optional corner affordance.

The fallback still takes children — initials, an icon — but never paints them, so an entity without a picture reads as an absent image rather than as two letters. They stay in the DOM, held in an `avatar-fallback-content` slot that is `visibility: hidden`: out of the page, the accessibility tree, and the tab order together, and overridable by a consumer who wants them back.

Expand Down Expand Up @@ -37,10 +37,11 @@ import { Avatar } from '@clerk/ui/mosaic/components/avatar';

| Part | Slot (`.cl-*`) | Description |
| ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `Avatar.Root` | `cl-avatar` | Owns `shape` / `size`, clips its children to the shape. |
| `Avatar.Root` | `cl-avatar` | Owns `shape` / `size`, positioning, and polymorphic rendering. |
| `Avatar.Image` | `cl-avatar-image` | Renders an `<img>` once the source loads; renders nothing until then. |
| `Avatar.Fallback` | `cl-avatar-fallback` | Rendered while the image is pending or has failed. Carries `data-pending` while an image is still resolving. Optional `delayMs`. |
| — | `cl-avatar-fallback-content` | Wraps whatever `Avatar.Fallback` was handed, hidden. Override its `visibility` to paint initials or an icon. |
| `Avatar.Icon` | `cl-avatar-icon` | Renders an optional icon surface over the avatar corner. |

---

Expand All @@ -55,6 +56,15 @@ While an image is still resolving, the fallback carries `data-pending` and pulse
storyModule={AvatarStories}
/>

### Interactive

Use `render` to compose the avatar onto a button or another trigger without introducing a separate wrapper component. The `lg` avatar and button are both 48×48.

<Story
name='Interactive'
storyModule={AvatarStories}
/>

### Fallback

Once the image fails, the pulse stops and the fallback settles into a plain fill. Nothing is on its way for an entity that has no picture, so its mark holds still rather than claiming to be loading one.
Expand Down
26 changes: 26 additions & 0 deletions packages/swingset/src/stories/avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @jsxImportSource @emotion/react */
import type { AvatarProps } from '@clerk/ui/mosaic/components/avatar';
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
import { Icon } from '@clerk/ui/mosaic/components/icon';

import type { StoryMeta } from '@/lib/types';

Expand Down Expand Up @@ -33,6 +34,7 @@ function knobsAsProps(props: Record<string, unknown>) {
}

const IMAGE_SRC = 'https://github.com/clerk.png';
const COLIN_IMAGE_SRC = 'https://avatars.githubusercontent.com/u/51144033?v=4';

export function Primary(props: Record<string, unknown>) {
return (
Expand Down Expand Up @@ -70,6 +72,30 @@ export function Fallback(props: Record<string, unknown>) {
);
}

export function Interactive(props: Record<string, unknown>) {
return (
<Avatar.Root
{...knobsAsProps(props)}
size='lg'
render={
<button
type='button'
aria-label='Edit profile picture'
/>
}
>
<Avatar.Image
src={COLIN_IMAGE_SRC}
alt='Colin'
/>
<Avatar.Fallback>CL</Avatar.Fallback>
<Avatar.Icon>
<Icon name='pen' />
</Avatar.Icon>
</Avatar.Root>
);
}

export function Sizes(props: Record<string, unknown>) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
Expand Down
14 changes: 14 additions & 0 deletions packages/swingset/src/stories/user-profile-account-section.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Stories from './user-profile-account-section.stories';

# UserProfileAccountSection

Account details, profile image, email addresses, and phone numbers composed with `Settings`.

<Story
name='Default'
storyModule={Stories}
composition={[
{ name: 'Settings', href: '/components/settings', layer: 'Components' },
{ name: 'Avatar', href: '/components/avatar', layer: 'Components' },
]}
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** @jsxImportSource @emotion/react */
import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view';

import type { StoryMeta } from '@/lib/types';

export { default as __source } from './user-profile-account-section.stories?raw';

export const meta: StoryMeta = {
group: 'User',
title: 'UserProfileAccountSection',
source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx',
styleEngine: 'stylex',
};

export function Default() {
return (
<UserProfileAccountSectionView
emails={[
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
{ id: 'email_2', value: 'item2@clerk.dev', isVerified: true },
]}
imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4'
name='Preston Booth'
phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]}
username='prestonxyz'
onAddEmail={() => undefined}
onAddPhone={() => undefined}
onEditProfilePicture={() => undefined}
onManageEmail={() => undefined}
onManagePhone={() => undefined}
onNameChange={() => undefined}
onUsernameChange={() => undefined}
/>
);
}
37 changes: 35 additions & 2 deletions packages/ui/src/mosaic/components/avatar/avatar.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ const pulse = stylex.keyframes({
});

export const styles = stylex.create({
// root — clips its parts to the shape/size; fill comes from the image or fallback
// root — sizes and positions its parts; fill comes from the image or fallback
base: {
overflow: 'hidden',
alignItems: 'center',
aspectRatio: '1 / 1',
display: 'inline-flex',
Expand All @@ -24,6 +23,22 @@ export const styles = stylex.create({
verticalAlign: 'middle',
},

interactive: {
background: 'transparent',
// An avatar used as a native button has no border shrinking the avatar inside it.
borderWidth: 0,
outline: {
default: 'none',
':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`,
},
appearance: 'none',
cursor: {
default: 'pointer',
':is(:disabled, [aria-disabled="true"])': 'not-allowed',
},
outlineOffset: '2px',
},

// Carries the root's radius rather than leaning on the clip alone, so a part that paints its own
// fill rounds off cleanly instead of showing a corner.
image: {
Expand Down Expand Up @@ -63,6 +78,24 @@ export const styles = stylex.create({
},
animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)',
},

icon: {
borderColor: colorVars['--cl-color-border'],
borderRadius: radiusVars['--cl-radius-full'],
borderStyle: 'solid',
borderWidth: '1px',
overflow: 'hidden',
alignItems: 'center',
backgroundColor: colorVars['--cl-color-card'],
boxSizing: 'border-box',
display: 'flex',
insetBlockEnd: `calc(${space['2']} * -1)`,
insetInlineStart: `calc(${space['1']} * -1)`,
justifyContent: 'center',
position: 'absolute',
height: space['6'],
width: space['6'],
},
});

// shape — square shares its radius with Button; circle rounds fully
Expand Down
29 changes: 29 additions & 0 deletions packages/ui/src/mosaic/components/avatar/avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Icon } from '../icon';
import { Avatar } from './avatar';

// React reads this off the global object and ships no typing for it.
Expand Down Expand Up @@ -280,9 +281,37 @@ describe('Mosaic Avatar', () => {
expect(avatar).toHaveStyle({ marginTop: '8px' });
});

it('composes its root onto another element and renders an icon affordance', () => {
const ref = React.createRef<HTMLSpanElement>();
render(
<Avatar.Root
ref={ref}
size='lg'
render={
<button
type='button'
aria-label='Edit profile picture'
/>
}
>
<Avatar.Fallback>CN</Avatar.Fallback>
<Avatar.Icon>
<Icon name='pen' />
</Avatar.Icon>
</Avatar.Root>,
);

const button = screen.getByRole('button', { name: 'Edit profile picture' });
expect(button).toHaveClass('cl-avatar');
expect(button).toHaveAttribute('data-size', 'lg');
expect(ref.current).toBe(button);
expect(button.querySelector('.cl-avatar-icon')).toHaveAttribute('aria-hidden', 'true');
});

it('throws when a part is rendered outside <Avatar.Root>', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Avatar.Fallback>CN</Avatar.Fallback>)).toThrow(/must be rendered inside <Avatar.Root>/);
expect(() => render(<Avatar.Icon />)).toThrow(/must be rendered inside <Avatar.Root>/);
spy.mockRestore();
});
});
62 changes: 42 additions & 20 deletions packages/ui/src/mosaic/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useRender } from '@clerk/headless/utils';
import { useSafeLayoutEffect } from '@clerk/shared/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import type { MosaicComponentProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../reset.styles';
import { shapes, sizes, styles } from './avatar.styles';
Expand All @@ -23,34 +25,34 @@ function useAvatarContext(part: string): AvatarContextValue {
return context;
}

export interface AvatarProps extends React.ComponentPropsWithRef<'span'> {
export interface AvatarProps extends MosaicComponentProps<'span'> {
shape?: 'circle' | 'square';
size?: 'fit' | 'lg' | 'md' | 'sm' | 'xs';
}

const AvatarRoot = React.forwardRef<HTMLSpanElement, AvatarProps>(function MosaicAvatarRoot(
{ shape = 'circle', size = 'md', className, style, children, ...rest },
{ shape = 'circle', size = 'md', render, className, style, ...rest },
ref,
) {
const [status, setStatus] = React.useState<ImageLoadingStatus>('idle');
const value = React.useMemo<AvatarContextValue>(() => ({ status, onStatusChange: setStatus }), [status]);
const interactive = Boolean(render);
const element = useRender({
defaultTagName: 'span',
render,
ref,
props: {
...mergeStyleProps(
themeProps('avatar', { shape, size }),
stylex.props(reset.base, styles.base, shapes[shape], sizes[size], interactive && styles.interactive),
className,
style,
),
...rest,
},
});

return (
<AvatarContext.Provider value={value}>
<span
ref={ref}
{...mergeStyleProps(
themeProps('avatar', { shape, size }),
stylex.props(reset.base, styles.base, shapes[shape], sizes[size]),
className,
style,
)}
{...rest}
>
{children}
</span>
</AvatarContext.Provider>
);
return <AvatarContext.Provider value={value}>{element}</AvatarContext.Provider>;
});

export type AvatarImageProps = React.ComponentPropsWithRef<'img'>;
Expand Down Expand Up @@ -152,13 +154,33 @@ const AvatarFallback = React.forwardRef<HTMLSpanElement, AvatarFallbackProps>(fu
);
});

export type AvatarIconProps = React.ComponentPropsWithRef<'span'>;

const AvatarIcon = React.forwardRef<HTMLSpanElement, AvatarIconProps>(function MosaicAvatarIcon(
{ className, style, ...rest },
ref,
) {
useAvatarContext('Avatar.Icon');

return (
<span
ref={ref}
aria-hidden
{...mergeStyleProps(themeProps('avatar-icon'), stylex.props(reset.base, styles.icon), className, style)}
{...rest}
/>
);
});

/**
* Compound avatar. `Avatar.Root` clips and sizes the box; `Avatar.Image` renders
* Compound avatar. `Avatar.Root` positions and sizes the box; `Avatar.Image` renders
* once its source loads; `Avatar.Fallback` holds the space until then, as a blank
* placeholder that pulses only while an image is actually on its way.
* placeholder that pulses only while an image is actually on its way; `Avatar.Icon`
* adds an optional corner affordance.
*/
export const Avatar = {
Root: AvatarRoot,
Image: AvatarImage,
Fallback: AvatarFallback,
Icon: AvatarIcon,
};
Loading
Loading