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/e2e/components/Avatar.test.ts b/e2e/components/Avatar.test.ts index 00d706cc0e1..b398b91601b 100644 --- a/e2e/components/Avatar.test.ts +++ b/e2e/components/Avatar.test.ts @@ -19,6 +19,10 @@ const stories = [ title: 'Square', id: 'components-avatar-features--square', }, + { + title: 'Text Placeholder', + id: 'components-avatar-features--text-placeholder', + }, ] as const test.describe('Avatar', () => { diff --git a/packages/react/src/Avatar/Avatar.docs.json b/packages/react/src/Avatar/Avatar.docs.json index bfe70bf9206..8de0b951ae1 100644 --- a/packages/react/src/Avatar/Avatar.docs.json +++ b/packages/react/src/Avatar/Avatar.docs.json @@ -15,6 +15,9 @@ }, { "id": "components-avatar-features--size-responsive" + }, + { + "id": "components-avatar-features--text-placeholder" } ], "importPath": "@primer/react", @@ -41,8 +44,14 @@ "name": "src", "type": "string", "required": false, - "description": "URL of the avatar image.", + "description": "URL of the avatar image. When provided, the image takes precedence over the placeholder.", "defaultValue": "" + }, + { + "name": "placeholder", + "type": "string", + "required": false, + "description": "Text to display when no image URL is provided, such as a user's initials." } ], "subcomponents": [] diff --git a/packages/react/src/Avatar/Avatar.features.stories.tsx b/packages/react/src/Avatar/Avatar.features.stories.tsx index 6627d744d7b..fafbf2ba653 100644 --- a/packages/react/src/Avatar/Avatar.features.stories.tsx +++ b/packages/react/src/Avatar/Avatar.features.stories.tsx @@ -79,3 +79,12 @@ export const SizeResponsive = () => ( /> ) + +export const TextPlaceholder = () => ( +
+ + + + +
+) diff --git a/packages/react/src/Avatar/Avatar.module.css b/packages/react/src/Avatar/Avatar.module.css index fc67531b9a7..2f157b70a5f 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: var(--text-body-size-small); + 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 c7d7ff4f0f9..e67354c82af 100644 --- a/packages/react/src/Avatar/Avatar.test.tsx +++ b/packages/react/src/Avatar/Avatar.test.tsx @@ -37,6 +37,31 @@ describe('Avatar', () => { await expect.element(avatar).toHaveAttribute('src', 'primer.png') }) + it('renders a text placeholder when src is omitted', async () => { + render() + const avatar = page.getByTestId('avatar') + await expect.element(avatar).toHaveTextContent('NS') + await expect.element(avatar).toHaveAttribute('role', 'img') + await expect.element(avatar).toHaveAttribute('aria-label', 'NS') + await expect.element(avatar).toHaveAttribute('data-placeholder') + await expect.element(avatar).toHaveAttribute('data-component', 'Avatar') + await expect.element(avatar).not.toHaveAttribute('src') + }) + + it('prefers the image when both src and placeholder are provided', async () => { + render() + const avatar = page.getByTestId('avatar') + expect(avatar.element().tagName).toBe('IMG') + await expect.element(avatar).toHaveAttribute('src', 'primer.png') + await expect.element(avatar).toHaveAttribute('alt', 'Naveed') + await expect.element(avatar).not.toHaveAttribute('data-placeholder') + }) + + it('uses alt text as the accessible name for a placeholder when provided', async () => { + render() + await expect.element(page.getByTestId('avatar')).toHaveAttribute('aria-label', 'Naveed Sajjad') + }) + it('should support the `style` prop without overriding internal styles', async () => { 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} + ) })