diff --git a/.changeset/small-avatars-smile.md b/.changeset/small-avatars-smile.md new file mode 100644 index 00000000000..24e0458bdec --- /dev/null +++ b/.changeset/small-avatars-smile.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add text placeholder support to Avatar when no image source is provided. diff --git a/packages/react/src/Avatar/Avatar.module.css b/packages/react/src/Avatar/Avatar.module.css index fc67531b9a7..6641d530df4 100644 --- a/packages/react/src/Avatar/Avatar.module.css +++ b/packages/react/src/Avatar/Avatar.module.css @@ -10,6 +10,17 @@ /* stylelint-disable-next-line primer/box-shadow */ box-shadow: 0 0 0 1px var(--avatar-borderColor); + &:where([data-placeholder]) { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--bgColor-muted); + color: var(--fgColor-muted); + font-size: calc(var(--avatarSize-regular) * 0.4); + font-weight: var(--base-text-weight-semibold); + white-space: nowrap; + } + &:where([data-square]) { /* stylelint-disable-next-line primer/borders */ border-radius: clamp(4px, calc(var(--avatarSize-regular) - 24px), var(--borderRadius-medium)); diff --git a/packages/react/src/Avatar/Avatar.test.tsx b/packages/react/src/Avatar/Avatar.test.tsx index ba816856822..314934a2190 100644 --- a/packages/react/src/Avatar/Avatar.test.tsx +++ b/packages/react/src/Avatar/Avatar.test.tsx @@ -36,6 +36,31 @@ describe('Avatar', () => { expect(avatar).toHaveAttribute('src', 'primer.png') }) + it('renders a text placeholder when src is omitted', () => { + render() + const avatar = screen.getByTestId('avatar') + expect(avatar).toHaveTextContent('NS') + expect(avatar).toHaveAttribute('role', 'img') + expect(avatar).toHaveAttribute('aria-label', 'NS') + expect(avatar).toHaveAttribute('data-placeholder') + expect(avatar).toHaveAttribute('data-component', 'Avatar') + expect(avatar).not.toHaveAttribute('src') + }) + + it('prefers the image when both src and placeholder are provided', () => { + render() + const avatar = screen.getByTestId('avatar') + expect(avatar.tagName).toBe('IMG') + expect(avatar).toHaveAttribute('src', 'primer.png') + expect(avatar).toHaveAttribute('alt', 'Naveed') + expect(avatar).not.toHaveAttribute('data-placeholder') + }) + + it('uses alt text as the accessible name for a placeholder when provided', () => { + render() + expect(screen.getByTestId('avatar')).toHaveAttribute('aria-label', 'Naveed Sajjad') + }) + it('should support the `style` prop without overriding internal styles', () => { render( /** Sets the shape of the avatar to a square if true. If false, the avatar will be circular. */ square?: boolean - /** URL of the avatar image. */ - src: string + /** URL of the avatar image. When provided, the image takes precedence over the placeholder. */ + src?: string + /** Text to display when no image URL is provided, such as a user's initials. */ + placeholder?: string /** Provide alt text when the Avatar is used without the user's name next to it. */ alt?: string /** Additional class name. */ className?: string -} & React.ComponentPropsWithoutRef<'img'> +} & Omit, 'src'> -const Avatar = React.forwardRef(function Avatar( - {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, className, style, ...rest}, +const Avatar = React.forwardRef(function Avatar( + {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, placeholder, src, className, style, ...rest}, ref, ) { const isResponsive = isResponsiveValue(size) @@ -34,26 +36,43 @@ const Avatar = React.forwardRef(function Avatar( cssSizeVars['--avatarSize-regular'] = `${size}px` } + const sharedProps = { + 'data-component': 'Avatar', + className: clsx(className, classes.Avatar), + 'data-responsive': isResponsive ? '' : undefined, + 'data-square': square ? '' : undefined, + style: style + ? { + ...cssSizeVars, + ...style, + } + : (cssSizeVars as React.CSSProperties), + ...rest, + } + + if (src) { + return ( + } + alt={alt} + width={isResponsive ? undefined : size} + height={isResponsive ? undefined : size} + src={src} + /> + ) + } + return ( - {alt} + } + role="img" + aria-label={alt || placeholder || undefined} + data-placeholder="" + > + {placeholder} + ) })