Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 469
feat(ui): add Avatar button composition#9378
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
Changes from all commits
b4315125f83080ced7d972336d1cfd082bb99436f5File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
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. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Use the required compound-component documentation archetype.
As per coding guidelines, “Compound Components pages must use the exact section order: 🤖 Prompt for AI AgentsSource: 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. | ||
| @@ -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. | | ||
| --- | ||
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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', | ||
| @@ -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', | ||
| }, | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| // 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: { | ||
| @@ -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 | ||
| 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'; | ||
| @@ -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), | ||
alexcarpenter marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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'>; | ||
| @@ -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, | ||
| }; | ||
| 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'; |
Uh oh!
There was an error while loading. Please reload this page.
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.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Add a release entry for
@clerk/ui.This PR adds public
Avatar.Root.render,Avatar.Icon, andAvatarIconProps. An empty Changeset will not create a package version or changelog entry for consumers. Add an@clerk/uiminor 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
Sources: Coding guidelines, Learnings