From de3b097ca38e219a32c66046d38b62ba084ecdc3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 10 Aug 2026 11:20:12 -0600 Subject: [PATCH 1/4] feat(ui): add AvatarButton block --- .changeset/avatar-button-block.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 11 +++ .../swingset/src/stories/avatar-button.mdx | 11 +++ .../src/stories/avatar-button.stories.tsx | 32 +++++++ .../swingset/src/stories/icon.stories.tsx | 7 +- .../src/mosaic/block/avatar-button.test.tsx | 40 ++++++++ .../ui/src/mosaic/block/avatar-button.tsx | 94 +++++++++++++++++++ .../src/mosaic/components/icon/icon.styles.ts | 1 + .../src/mosaic/components/icon/icon.test.tsx | 13 +++ .../ui/src/mosaic/components/icon/icon.tsx | 2 +- packages/ui/src/mosaic/icons/registry.tsx | 15 ++- packages/ui/src/mosaic/styles/index.ts | 2 + 13 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 .changeset/avatar-button-block.md create mode 100644 packages/swingset/src/stories/avatar-button.mdx create mode 100644 packages/swingset/src/stories/avatar-button.stories.tsx create mode 100644 packages/ui/src/mosaic/block/avatar-button.test.tsx create mode 100644 packages/ui/src/mosaic/block/avatar-button.tsx diff --git a/.changeset/avatar-button-block.md b/.changeset/avatar-button-block.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/avatar-button-block.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index fb680d13c3c..9cbbdbb1a37 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -28,6 +28,7 @@ const docModules: Record> = { 'organization-profile-delete-section': dynamic(() => import('../stories/organization-profile-delete-section.mdx')), }, blocks: { + 'avatar-button': dynamic(() => import('../stories/avatar-button.mdx')), destructive: dynamic(() => import('../stories/destructive.mdx')), }, components: { diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 60a834b5d98..cbad5a25ed3 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -8,6 +8,11 @@ import { Shapes as AvatarShapes, Sizes as AvatarSizes, } from '../stories/avatar.stories'; +import { + Default as AvatarButtonDefault, + Fallback as AvatarButtonFallback, + meta as avatarButtonMeta, +} from '../stories/avatar-button.stories'; import { Colors as BadgeColors, meta as badgeMeta, @@ -126,6 +131,11 @@ import { toSlug } from './slug'; import type { StoryModule } from './types'; const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault }; +const avatarButtonModule: StoryModule = { + meta: avatarButtonMeta, + Default: AvatarButtonDefault, + Fallback: AvatarButtonFallback, +}; const organizationProfileLeaveSectionModule: StoryModule = { meta: organizationProfileLeaveSectionMeta, Default: OrganizationProfileLeaveSectionDefault, @@ -268,6 +278,7 @@ export const registry: StoryModule[] = [ organizationProfileLeaveSectionModule, organizationProfileDeleteSectionModule, // Blocks + avatarButtonModule, destructiveModule, // Components avatarModule, diff --git a/packages/swingset/src/stories/avatar-button.mdx b/packages/swingset/src/stories/avatar-button.mdx new file mode 100644 index 00000000000..f09c73d7fee --- /dev/null +++ b/packages/swingset/src/stories/avatar-button.mdx @@ -0,0 +1,11 @@ +import * as AvatarButtonStories from './avatar-button.stories'; + +# AvatarButton + +An interactive profile avatar with a stable edit affordance, accessible button semantics, and initials fallback. + + + +## Fallback + + diff --git a/packages/swingset/src/stories/avatar-button.stories.tsx b/packages/swingset/src/stories/avatar-button.stories.tsx new file mode 100644 index 00000000000..1be1ab2d50d --- /dev/null +++ b/packages/swingset/src/stories/avatar-button.stories.tsx @@ -0,0 +1,32 @@ +/** @jsxImportSource @emotion/react */ +import { AvatarButton } from '@clerk/ui/mosaic/block/avatar-button'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './avatar-button.stories?raw'; + +export const meta: StoryMeta = { + group: 'Blocks', + title: 'AvatarButton', + source: 'packages/ui/src/mosaic/block/avatar-button.tsx', + styleEngine: 'stylex', +}; + +export function Default() { + return ( + undefined} + /> + ); +} + +export function Fallback() { + return ( + undefined} + /> + ); +} diff --git a/packages/swingset/src/stories/icon.stories.tsx b/packages/swingset/src/stories/icon.stories.tsx index d3bc7eed11c..0bc34de25ff 100644 --- a/packages/swingset/src/stories/icon.stories.tsx +++ b/packages/swingset/src/stories/icon.stories.tsx @@ -17,7 +17,7 @@ export const meta: StoryMeta = { styleEngine: 'stylex', styles: { _variants: { - size: { sm: {}, md: {}, lg: {} }, + size: { xs: {}, sm: {}, md: {}, lg: {} }, }, _defaultVariants: { size: 'md', @@ -44,6 +44,11 @@ export function Default(props: Record) { export function Sizes(props: Record) { return (
+ { + it('renders an accessible avatar action with a stable edit treatment', () => { + render( + , + ); + + const button = screen.getByRole('button', { name: 'Edit profile picture' }); + expect(button).toHaveClass('cl-avatar-button'); + expect(button).toHaveAttribute('data-shape', 'circle'); + expect(button).toHaveAttribute('data-size', 'lg'); + expect(button.querySelector('.cl-avatar')).toHaveAttribute('aria-hidden', 'true'); + expect(button.querySelector('.cl-avatar-button-edit-surface')).not.toBeNull(); + }); + + it('derives fallback initials and forwards button behavior', () => { + const onClick = vi.fn(); + const ref = React.createRef(); + render( + , + ); + + expect(screen.getByText('AL')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Edit profile picture' })); + expect(onClick).toHaveBeenCalledOnce(); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); +}); diff --git a/packages/ui/src/mosaic/block/avatar-button.tsx b/packages/ui/src/mosaic/block/avatar-button.tsx new file mode 100644 index 00000000000..27af5deb557 --- /dev/null +++ b/packages/ui/src/mosaic/block/avatar-button.tsx @@ -0,0 +1,94 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { Avatar } from '../components/avatar'; +import type { ButtonProps } from '../components/button'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { mergeStyleProps, themeProps } from '../props'; +import { colorVars, radiusVars } from '../tokens.stylex'; + +export interface AvatarButtonProps extends Omit { + imageUrl?: string; + name: string; + fallback?: React.ReactNode; +} + +const styles = stylex.create({ + root: { + borderWidth: 0, + position: 'relative', + }, + editSurface: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-full'], + borderStyle: 'solid', + borderWidth: '1px', + alignItems: 'center', + backgroundColor: colorVars['--cl-color-card'], + boxSizing: 'border-box', + display: 'flex', + insetInlineStart: '-4.5px', + justifyContent: 'center', + position: 'absolute', + height: '20px', + top: '25px', + width: '20px', + }, +}); + +export const AvatarButton = React.forwardRef(function AvatarButton( + { + imageUrl, + name, + fallback, + color = 'neutral', + variant = 'ghost', + className, + style, + 'aria-label': ariaLabel = 'Edit profile picture', + ...rest + }, + ref, +) { + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + return ( + + ); +}); diff --git a/packages/ui/src/mosaic/components/icon/icon.styles.ts b/packages/ui/src/mosaic/components/icon/icon.styles.ts index 1ed40b76011..2200f853e83 100644 --- a/packages/ui/src/mosaic/components/icon/icon.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon.styles.ts @@ -20,6 +20,7 @@ export const styles = stylex.create({ }); export const sizes = stylex.create({ + xs: { height: space['3'], width: space['3'] }, sm: { height: space['3.5'], width: space['3.5'] }, md: { height: space['4'], width: space['4'] }, lg: { height: space['5'], width: space['5'] }, diff --git a/packages/ui/src/mosaic/components/icon/icon.test.tsx b/packages/ui/src/mosaic/components/icon/icon.test.tsx index 8da9ee9dfd5..7a6622dfcda 100644 --- a/packages/ui/src/mosaic/components/icon/icon.test.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.test.tsx @@ -41,6 +41,19 @@ describe('Mosaic Icon', () => { expect(svg).toHaveStyle({ marginTop: '8px' }); }); + it('renders the pen with its native 12px geometry', () => { + const { container } = wrap( + , + ); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('data-size', 'xs'); + 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(); expect(container.querySelector('svg')).not.toHaveAttribute('data-icon'); diff --git a/packages/ui/src/mosaic/components/icon/icon.tsx b/packages/ui/src/mosaic/components/icon/icon.tsx index b5f756068b4..f8716d1d757 100644 --- a/packages/ui/src/mosaic/components/icon/icon.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.tsx @@ -11,7 +11,7 @@ import { sizes, styles } from './icon.styles'; export interface IconProps extends React.ComponentPropsWithRef<'svg'> { name: IconName; - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; placement?: 'inline-start' | 'inline-end'; } diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 5dcc15f012c..763d4562237 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -9,11 +9,11 @@ type IconComponent = React.ForwardRefExoticComponent< * Builds a glyph from its inner `` 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>((props, ref) => ( , ); +const Pen = glyph( + , + '0 0 12 12', +); + const LogOut = glyph( Date: Tue, 11 Aug 2026 19:24:38 -0600 Subject: [PATCH 2/4] refactor(ui): compose interactive avatars --- .changeset/avatar-button-block.md | 3 + .../swingset/src/components/DocsViewer.tsx | 1 - packages/swingset/src/lib/registry.ts | 13 +-- .../swingset/src/stories/avatar-button.mdx | 11 --- .../src/stories/avatar-button.stories.tsx | 32 ------- packages/swingset/src/stories/avatar.mdx | 14 ++- .../swingset/src/stories/avatar.stories.tsx | 28 ++++++ .../swingset/src/stories/icon.stories.tsx | 7 +- .../src/mosaic/block/avatar-button.test.tsx | 40 -------- .../ui/src/mosaic/block/avatar-button.tsx | 94 ------------------- .../mosaic/components/avatar/avatar.styles.ts | 27 +++++- .../mosaic/components/avatar/avatar.test.tsx | 32 +++++++ .../src/mosaic/components/avatar/avatar.tsx | 61 ++++++++---- .../ui/src/mosaic/components/avatar/index.ts | 2 +- .../src/mosaic/components/icon/icon.styles.ts | 1 - .../src/mosaic/components/icon/icon.test.tsx | 11 +-- .../ui/src/mosaic/components/icon/icon.tsx | 2 +- packages/ui/src/mosaic/styles/index.ts | 4 +- packages/ui/src/mosaic/tokens.stylex.ts | 1 + 19 files changed, 151 insertions(+), 233 deletions(-) delete mode 100644 packages/swingset/src/stories/avatar-button.mdx delete mode 100644 packages/swingset/src/stories/avatar-button.stories.tsx delete mode 100644 packages/ui/src/mosaic/block/avatar-button.test.tsx delete mode 100644 packages/ui/src/mosaic/block/avatar-button.tsx diff --git a/.changeset/avatar-button-block.md b/.changeset/avatar-button-block.md index a845151cc84..6a532da434a 100644 --- a/.changeset/avatar-button-block.md +++ b/.changeset/avatar-button-block.md @@ -1,2 +1,5 @@ --- +'@clerk/ui': minor --- + +Add polymorphic Avatar roots and an optional icon affordance for interactive avatars. diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 9cbbdbb1a37..fb680d13c3c 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -28,7 +28,6 @@ const docModules: Record> = { 'organization-profile-delete-section': dynamic(() => import('../stories/organization-profile-delete-section.mdx')), }, blocks: { - 'avatar-button': dynamic(() => import('../stories/avatar-button.mdx')), destructive: dynamic(() => import('../stories/destructive.mdx')), }, components: { diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index cbad5a25ed3..8a04710c7af 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -3,16 +3,12 @@ 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, Sizes as AvatarSizes, } from '../stories/avatar.stories'; -import { - Default as AvatarButtonDefault, - Fallback as AvatarButtonFallback, - meta as avatarButtonMeta, -} from '../stories/avatar-button.stories'; import { Colors as BadgeColors, meta as badgeMeta, @@ -131,11 +127,6 @@ import { toSlug } from './slug'; import type { StoryModule } from './types'; const destructiveModule: StoryModule = { meta: destructiveMeta, Default: DestructiveDefault }; -const avatarButtonModule: StoryModule = { - meta: avatarButtonMeta, - Default: AvatarButtonDefault, - Fallback: AvatarButtonFallback, -}; const organizationProfileLeaveSectionModule: StoryModule = { meta: organizationProfileLeaveSectionMeta, Default: OrganizationProfileLeaveSectionDefault, @@ -171,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, @@ -278,7 +270,6 @@ export const registry: StoryModule[] = [ organizationProfileLeaveSectionModule, organizationProfileDeleteSectionModule, // Blocks - avatarButtonModule, destructiveModule, // Components avatarModule, diff --git a/packages/swingset/src/stories/avatar-button.mdx b/packages/swingset/src/stories/avatar-button.mdx deleted file mode 100644 index f09c73d7fee..00000000000 --- a/packages/swingset/src/stories/avatar-button.mdx +++ /dev/null @@ -1,11 +0,0 @@ -import * as AvatarButtonStories from './avatar-button.stories'; - -# AvatarButton - -An interactive profile avatar with a stable edit affordance, accessible button semantics, and initials fallback. - - - -## Fallback - - diff --git a/packages/swingset/src/stories/avatar-button.stories.tsx b/packages/swingset/src/stories/avatar-button.stories.tsx deleted file mode 100644 index 1be1ab2d50d..00000000000 --- a/packages/swingset/src/stories/avatar-button.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import { AvatarButton } from '@clerk/ui/mosaic/block/avatar-button'; - -import type { StoryMeta } from '@/lib/types'; - -export { default as __source } from './avatar-button.stories?raw'; - -export const meta: StoryMeta = { - group: 'Blocks', - title: 'AvatarButton', - source: 'packages/ui/src/mosaic/block/avatar-button.tsx', - styleEngine: 'stylex', -}; - -export function Default() { - return ( - undefined} - /> - ); -} - -export function Fallback() { - return ( - undefined} - /> - ); -} diff --git a/packages/swingset/src/stories/avatar.mdx b/packages/swingset/src/stories/avatar.mdx index dce77c7f735..9deae63120c 100644 --- a/packages/swingset/src/stories/avatar.mdx +++ b/packages/swingset/src/stories/avatar.mdx @@ -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. @@ -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 `` 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. | --- @@ -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. + + + ### 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. diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index b14a0cea45e..5b61c0a0cfb 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -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'; @@ -70,6 +72,32 @@ export function Fallback(props: Record) { ); } +export function Interactive(props: Record) { + return ( + + } + > + + CL + + + + + ); +} + export function Sizes(props: Record) { return (
diff --git a/packages/swingset/src/stories/icon.stories.tsx b/packages/swingset/src/stories/icon.stories.tsx index 0bc34de25ff..d3bc7eed11c 100644 --- a/packages/swingset/src/stories/icon.stories.tsx +++ b/packages/swingset/src/stories/icon.stories.tsx @@ -17,7 +17,7 @@ export const meta: StoryMeta = { styleEngine: 'stylex', styles: { _variants: { - size: { xs: {}, sm: {}, md: {}, lg: {} }, + size: { sm: {}, md: {}, lg: {} }, }, _defaultVariants: { size: 'md', @@ -44,11 +44,6 @@ export function Default(props: Record) { export function Sizes(props: Record) { return (
- { - it('renders an accessible avatar action with a stable edit treatment', () => { - render( - , - ); - - const button = screen.getByRole('button', { name: 'Edit profile picture' }); - expect(button).toHaveClass('cl-avatar-button'); - expect(button).toHaveAttribute('data-shape', 'circle'); - expect(button).toHaveAttribute('data-size', 'lg'); - expect(button.querySelector('.cl-avatar')).toHaveAttribute('aria-hidden', 'true'); - expect(button.querySelector('.cl-avatar-button-edit-surface')).not.toBeNull(); - }); - - it('derives fallback initials and forwards button behavior', () => { - const onClick = vi.fn(); - const ref = React.createRef(); - render( - , - ); - - expect(screen.getByText('AL')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Edit profile picture' })); - expect(onClick).toHaveBeenCalledOnce(); - expect(ref.current).toBeInstanceOf(HTMLButtonElement); - }); -}); diff --git a/packages/ui/src/mosaic/block/avatar-button.tsx b/packages/ui/src/mosaic/block/avatar-button.tsx deleted file mode 100644 index 27af5deb557..00000000000 --- a/packages/ui/src/mosaic/block/avatar-button.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; -import React from 'react'; - -import { Avatar } from '../components/avatar'; -import type { ButtonProps } from '../components/button'; -import { Button } from '../components/button'; -import { Icon } from '../components/icon'; -import { mergeStyleProps, themeProps } from '../props'; -import { colorVars, radiusVars } from '../tokens.stylex'; - -export interface AvatarButtonProps extends Omit { - imageUrl?: string; - name: string; - fallback?: React.ReactNode; -} - -const styles = stylex.create({ - root: { - borderWidth: 0, - position: 'relative', - }, - editSurface: { - borderColor: colorVars['--cl-color-border'], - borderRadius: radiusVars['--cl-radius-full'], - borderStyle: 'solid', - borderWidth: '1px', - alignItems: 'center', - backgroundColor: colorVars['--cl-color-card'], - boxSizing: 'border-box', - display: 'flex', - insetInlineStart: '-4.5px', - justifyContent: 'center', - position: 'absolute', - height: '20px', - top: '25px', - width: '20px', - }, -}); - -export const AvatarButton = React.forwardRef(function AvatarButton( - { - imageUrl, - name, - fallback, - color = 'neutral', - variant = 'ghost', - className, - style, - 'aria-label': ariaLabel = 'Edit profile picture', - ...rest - }, - ref, -) { - const initials = name - .split(/\s+/) - .map(part => part[0]) - .join('') - .slice(0, 2) - .toUpperCase(); - - return ( - - ); -}); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts index c4961a0f156..4b3285961ed 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -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', @@ -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 @@ -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 diff --git a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx index 37d55b255d6..2875e37f87a 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.test.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.test.tsx @@ -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. @@ -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(); + render( + + } + > + CN + + + + , + ); + + 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 ', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(() => render(CN)).toThrow(/must be rendered inside /); + expect(() => render()).toThrow(/must be rendered inside /); spy.mockRestore(); }); }); diff --git a/packages/ui/src/mosaic/components/avatar/avatar.tsx b/packages/ui/src/mosaic/components/avatar/avatar.tsx index a679244d3d2..31b50d518ce 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.tsx +++ b/packages/ui/src/mosaic/components/avatar/avatar.tsx @@ -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'; @@ -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(function MosaicAvatarRoot( - { shape = 'circle', size = 'md', className, style, children, ...rest }, + { shape = 'circle', size = 'md', render, className, style, ...rest }, ref, ) { const [status, setStatus] = React.useState('idle'); const value = React.useMemo(() => ({ 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 ( - - - {children} - - - ); + return {element}; }); export type AvatarImageProps = React.ComponentPropsWithRef<'img'>; @@ -152,13 +153,33 @@ const AvatarFallback = React.forwardRef(fu ); }); +export type AvatarIconProps = React.ComponentPropsWithRef<'span'>; + +const AvatarIcon = React.forwardRef(function MosaicAvatarIcon( + { className, style, ...rest }, + ref, +) { + useAvatarContext('Avatar.Icon'); + + return ( + + ); +}); + /** - * 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, }; diff --git a/packages/ui/src/mosaic/components/avatar/index.ts b/packages/ui/src/mosaic/components/avatar/index.ts index 236ba032f57..363ec08e971 100644 --- a/packages/ui/src/mosaic/components/avatar/index.ts +++ b/packages/ui/src/mosaic/components/avatar/index.ts @@ -1,2 +1,2 @@ export { Avatar } from './avatar'; -export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from './avatar'; +export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from './avatar'; diff --git a/packages/ui/src/mosaic/components/icon/icon.styles.ts b/packages/ui/src/mosaic/components/icon/icon.styles.ts index 2200f853e83..1ed40b76011 100644 --- a/packages/ui/src/mosaic/components/icon/icon.styles.ts +++ b/packages/ui/src/mosaic/components/icon/icon.styles.ts @@ -20,7 +20,6 @@ export const styles = stylex.create({ }); export const sizes = stylex.create({ - xs: { height: space['3'], width: space['3'] }, sm: { height: space['3.5'], width: space['3.5'] }, md: { height: space['4'], width: space['4'] }, lg: { height: space['5'], width: space['5'] }, diff --git a/packages/ui/src/mosaic/components/icon/icon.test.tsx b/packages/ui/src/mosaic/components/icon/icon.test.tsx index 7a6622dfcda..e3d6c1acf46 100644 --- a/packages/ui/src/mosaic/components/icon/icon.test.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.test.tsx @@ -41,15 +41,10 @@ describe('Mosaic Icon', () => { expect(svg).toHaveStyle({ marginTop: '8px' }); }); - it('renders the pen with its native 12px geometry', () => { - const { container } = wrap( - , - ); + it('renders the pen at the default 16px size with its native geometry', () => { + const { container } = wrap(); const svg = container.querySelector('svg'); - expect(svg).toHaveAttribute('data-size', 'xs'); + expect(svg).toHaveAttribute('data-size', 'md'); expect(svg).toHaveAttribute('viewBox', '0 0 12 12'); expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor'); }); diff --git a/packages/ui/src/mosaic/components/icon/icon.tsx b/packages/ui/src/mosaic/components/icon/icon.tsx index f8716d1d757..b5f756068b4 100644 --- a/packages/ui/src/mosaic/components/icon/icon.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.tsx @@ -11,7 +11,7 @@ import { sizes, styles } from './icon.styles'; export interface IconProps extends React.ComponentPropsWithRef<'svg'> { name: IconName; - size?: 'xs' | 'sm' | 'md' | 'lg'; + size?: 'sm' | 'md' | 'lg'; placement?: 'inline-start' | 'inline-end'; } diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 8e22f556278..2af7f3cb1d6 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -6,10 +6,8 @@ export type { MosaicComponentProps, MosaicElementProps } from '../props'; -export { AvatarButton } from '../block/avatar-button'; -export type { AvatarButtonProps } from '../block/avatar-button'; 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'; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 5b9ea1d20bc..299f914060f 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -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', From 2f721aa24c59e5ac0a6d69fd26fb1df16743cc62 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 11 Aug 2026 19:31:16 -0600 Subject: [PATCH 3/4] chore(repo): use empty changeset --- .changeset/avatar-button-block.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/.changeset/avatar-button-block.md b/.changeset/avatar-button-block.md index 6a532da434a..a845151cc84 100644 --- a/.changeset/avatar-button-block.md +++ b/.changeset/avatar-button-block.md @@ -1,5 +1,2 @@ --- -'@clerk/ui': minor --- - -Add polymorphic Avatar roots and an optional icon affordance for interactive avatars. From 3c92f741ab86149b649f8c6f8b474c4d760e1313 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 11 Aug 2026 19:36:21 -0600 Subject: [PATCH 4/4] docs(swingset): use Colin avatar in interactive example --- packages/swingset/src/stories/avatar.stories.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/avatar.stories.tsx b/packages/swingset/src/stories/avatar.stories.tsx index 5b61c0a0cfb..b08b51d42e1 100644 --- a/packages/swingset/src/stories/avatar.stories.tsx +++ b/packages/swingset/src/stories/avatar.stories.tsx @@ -35,6 +35,7 @@ function knobsAsProps(props: Record) { } 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) { return ( @@ -87,8 +88,8 @@ export function Interactive(props: Record) { } > CL