Skip to content
Open
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 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Add a release entry for @clerk/ui.

This PR adds public Avatar.Root.render, Avatar.Icon, and AvatarIconProps. An empty Changeset will not create a package version or changelog entry for consumers. Add an @clerk/ui minor release entry with a concise summary.

Based on learnings, empty Changesets are acceptable only when no published package requires release metadata. As per coding guidelines, “Use Changesets for version management and changelogs.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/avatar-button-block.md around lines 1 - 2, Add a non-empty
Changeset entry for `@clerk/ui` declaring a minor release and summarize the newly
public Avatar.Root.render, Avatar.Icon, and AvatarIconProps APIs.

Sources: Coding guidelines, Learnings

2 changes: 2 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 @@ -161,6 +162,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
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Use the required compound-component documentation archetype.

Avatar is a compound component, but this page uses Playground, Props, and Examples. It also uses <Preview> and <PropTable>. Replace the page structure with Example, Usage, Parts, then Styling. Remove <Preview> and <PropTable>.

As per coding guidelines, “Compound Components pages must use the exact section order: Example, Usage, Parts, then Styling, with no <Preview> or <PropTable>.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/avatar.mdx` at line 5, Rework the Avatar
documentation page to follow the compound-component structure: use the sections
Example, Usage, Parts, and Styling in that exact order. Replace the existing
Playground, Props, and Examples structure, and remove all Preview and PropTable
usage while preserving the Avatar component details.

Source: Coding guidelines


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
29 changes: 29 additions & 0 deletions packages/swingset/src/stories/avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** @jsxImportSource @emotion/react */
import type { AvatarProps } from '@clerk/ui/mosaic/components/avatar';
import { Avatar } from '@clerk/ui/mosaic/components/avatar';
import { Button } from '@clerk/ui/mosaic/components/button';
import { Icon } from '@clerk/ui/mosaic/components/icon';

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

Expand Down Expand Up @@ -33,6 +35,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 +73,32 @@ export function Fallback(props: Record<string, unknown>) {
);
}

export function Interactive(props: Record<string, unknown>) {
return (
<Avatar.Root
{...knobsAsProps(props)}
size='lg'
render={
<Button
aria-label='Edit profile picture'
color='neutral'
shape='circle'
variant='ghost'
/>
}
>
<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
27 changes: 25 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,10 @@ 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',
// An avatar used as a native button has no border shrinking the avatar inside it.
borderWidth: { default: null, ':is(button)': 0 },
alignItems: 'center',
aspectRatio: '1 / 1',
display: 'inline-flex',
Expand All @@ -22,6 +23,9 @@ export const styles = stylex.create({
position: 'relative',
userSelect: 'none',
verticalAlign: 'middle',
// Button's own largest size is smaller; non-button roots continue to follow the size axis.
minHeight: { default: null, ':is(button)': space['12'] },
minWidth: { default: null, ':is(button)': space['12'] },
},

// Carries the root's radius rather than leaning on the clip alone, so a part that paints its own
Expand Down Expand Up @@ -63,6 +67,25 @@ export const styles = stylex.create({
},
animationTimingFunction: 'cubic-bezier(0.4, 0, 0.6, 1)',
},

icon: {
'--_cl-icon-color': colorVars['--cl-color-avatar-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(0px - ${space['2']})`,
insetInlineStart: '-4.5px',
justifyContent: 'center',
position: 'absolute',
height: space['6'],
width: space['6'],
},
});

// shape — square shares its radius with Button; circle rounds fully
Expand Down
32 changes: 32 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,8 @@ import { flushSync } from 'react-dom';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Button } from '../button';
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 +282,39 @@ 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
aria-label='Edit profile picture'
color='neutral'
shape='circle'
variant='ghost'
/>
}
>
<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', 'cl-button');
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();
});
});
61 changes: 41 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,33 @@ 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 element = useRender({
defaultTagName: 'span',
render,
ref,
props: {
...mergeStyleProps(
themeProps('avatar', { shape, size }),
stylex.props(reset.base, styles.base, shapes[shape], sizes[size]),
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 +153,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,
};
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/components/avatar/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { Avatar } from './avatar';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from './avatar';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from './avatar';
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/components/icon/icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ describe('Mosaic Icon', () => {
expect(svg).toHaveStyle({ marginTop: '8px' });
});

it('renders the pen at the default 16px size with its native geometry', () => {
const { container } = wrap(<Icon name='pen' />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('data-size', 'md');
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(<Icon name='chevron-right' />);
expect(container.querySelector('svg')).not.toHaveAttribute('data-icon');
Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/mosaic/icons/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ type IconComponent = React.ForwardRefExoticComponent<
* Builds a glyph from its inner `<path>` 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<SVGSVGElement, React.ComponentPropsWithoutRef<'svg'>>((props, ref) => (
<svg
ref={ref}
viewBox='0 0 16 16'
viewBox={viewBox}
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
Expand Down Expand Up @@ -94,6 +94,16 @@ const Plus = glyph(
/>,
);

const Pen = glyph(
<path
clipRule='evenodd'
d='M9.02209 2.97867C8.55345 2.50711 7.79511 2.50711 7.32647 2.97867L7.31857 2.98647L3.5273 6.65369C3.39533 6.78134 3.30375 6.94493 3.26392 7.12416L2.80602 9.1847L4.71264 8.73608C4.87966 8.69678 5.03269 8.6124 5.15506 8.49214L9.06427 4.65015C9.494 4.1715 9.47623 3.43565 9.02209 2.97867ZM6.53268 2.18146C7.44155 1.27145 8.91291 1.27285 9.82005 2.18566C10.7012 3.07231 10.7294 4.49927 9.88442 5.42042C9.87785 5.42757 9.87111 5.43455 9.86419 5.44135L5.94362 9.2945C5.67441 9.55909 5.33775 9.74472 4.97031 9.83117L2.19135 10.485C2.00293 10.5294 1.80496 10.4737 1.66724 10.3377C1.52953 10.2017 1.47142 10.0044 1.51341 9.81548L2.16571 6.88012C2.25333 6.48581 2.45481 6.12591 2.74514 5.84508L6.53268 2.18146Z'
fill='currentColor'
fillRule='evenodd'
/>,
'0 0 12 12',
);

const LogOut = glyph(
<path
d='M6.25 13.25H3.75V2.75H6.25M10.25 10.75L13 8L10.25 5.25M13 8H6.25'
Expand Down Expand Up @@ -141,6 +151,7 @@ export const iconRegistry = {
check: Check,
close: Close,
ellipsis: Ellipsis,
pen: Pen,
plus: Plus,
'log-out': LogOut,
cog: Cog,
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/mosaic/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
export type { MosaicComponentProps, MosaicElementProps } from '../props';

export { Avatar } from '../components/avatar';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from '../components/avatar';
export { Badge } from '../components/badge';
export type { BadgeProps } from '../components/badge';
export { Button, SubmitButton } from '../components/button';
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/mosaic/tokens.stylex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const colorDefaults = {

'--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))',
'--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))',
'--cl-color-avatar-icon': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))',

'--cl-color-input': 'light-dark(oklch(1 0 0), oklch(0.2046 0 0))',
'--cl-color-input-placeholder': '#a3a3a3',
Expand Down
Loading