import { useSafeLayoutEffect } from '@clerk/shared/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps, MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { focusOutline } from '../../styles/focus-outline.styles'; import { reset } from '../../styles/reset.styles'; import { skeletonStyles } from '../../styles/skeleton.styles'; import { shapes, sizes, styles } from './avatar.styles'; type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; interface AvatarContextValue { bordered: boolean; status: ImageLoadingStatus; onStatusChange: (status: ImageLoadingStatus) => void; } const AvatarContext = React.createContext(null); function useAvatarContext(part: string): AvatarContextValue { const context = React.useContext(AvatarContext); if (!context) { throw new Error(`<${part}> must be rendered inside `); } return context; } export interface AvatarProps extends MosaicComponentProps<'span'> { bordered?: boolean; shape?: 'circle' | 'square'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'fit'; } const AvatarRoot = React.forwardRef(function MosaicAvatarRoot( { bordered = true, shape = 'circle', size = 'md', render, xstyle, ...rest }, ref, ) { const [status, setStatus] = React.useState('idle'); const value = React.useMemo( () => ({ bordered, status, onStatusChange: setStatus }), [bordered, status], ); const interactive = Boolean(render); const element = useRender({ defaultTagName: 'span', render, ref, props: { ...mergeStyleProps( themeProps('avatar', { bordered, shape, size }), stylex.props( reset.base, styles.base, shapes[shape], sizes[size], interactive && styles.interactive, interactive && focusOutline.visible, xstyle, ), rest, ), }, }); return {element}; }); export type AvatarImageProps = MosaicElementProps<'img'>; const AvatarImage = React.forwardRef(function MosaicAvatarImage( { src, alt = '', xstyle, ...rest }, ref, ) { const { bordered, status, onStatusChange } = useAvatarContext('Avatar.Image'); // Preload `src` and report status to the root, so the fallback shows until the image resolves. // A layout effect, because it also has to catch the case below before anything is painted. useSafeLayoutEffect(() => { if (!src) { onStatusChange('error'); return; } const image = new window.Image(); image.src = src; // An image the browser already holds is complete the moment it is asked for. Resolving it here // rather than off an event keeps a remount (a row changing shape) or a swap between two avatars // already on screen from dropping to the initials and back for a frame. if (image.complete) { onStatusChange('loaded'); return; } let active = true; onStatusChange('loading'); image.onload = () => active && onStatusChange('loaded'); image.onerror = () => active && onStatusChange('error'); return () => { active = false; }; }, [src, onStatusChange]); if (status !== 'loaded') { return null; } return ( {alt} ); }); export interface AvatarFallbackProps extends MosaicElementProps<'span'> { /** Wait this many ms before showing the fallback, to avoid a flash on fast connections. */ delayMs?: number; } const AvatarFallback = React.forwardRef(function MosaicAvatarFallback( { delayMs, xstyle, children, ...rest }, ref, ) { const { bordered, status } = useAvatarContext('Avatar.Fallback'); const [canRender, setCanRender] = React.useState(delayMs === undefined); React.useEffect(() => { if (delayMs === undefined) { return; } const timer = setTimeout(() => setCanRender(true), delayMs); return () => clearTimeout(timer); }, [delayMs]); const pending = canRender && status === 'loading'; if (!canRender || status === 'loaded') { return null; } return ( {children} ); }); export type AvatarIconProps = MosaicElementProps<'span'>; const AvatarIcon = React.forwardRef(function MosaicAvatarIcon( { xstyle, ...rest }, ref, ) { useAvatarContext('Avatar.Icon'); return ( ); }); /** * 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 shimmers 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, };