From 6cb744c0a16a764479b56110776e910ea367e4a3 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Fri, 22 May 2026 11:12:25 -0700 Subject: [PATCH 1/4] srcTransformer --- packages/react/src/Avatar/Avatar.docs.json | 9 ++++ .../src/Avatar/Avatar.features.stories.tsx | 9 ++++ packages/react/src/Avatar/Avatar.test.tsx | 51 +++++++++++++++++++ packages/react/src/Avatar/Avatar.tsx | 8 ++- 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/react/src/Avatar/Avatar.docs.json b/packages/react/src/Avatar/Avatar.docs.json index bfe70bf9206..500b6bd2600 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--src-transformer" } ], "importPath": "@primer/react", @@ -43,6 +46,12 @@ "required": false, "description": "URL of the avatar image.", "defaultValue": "" + }, + { + "name": "srcTransformer", + "type": "(src: string, size: number) => string", + "required": false, + "description": "Optional function to transform the src URL before rendering. Receives the original src and the resolved numeric size in CSS pixels. Useful for appending query parameters for HiDPI/Retina support." } ], "subcomponents": [] diff --git a/packages/react/src/Avatar/Avatar.features.stories.tsx b/packages/react/src/Avatar/Avatar.features.stories.tsx index 6627d744d7b..6fbca67df22 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 SrcTransformer = () => ( + `${src}&s=${size * 2}`} + /> +) diff --git a/packages/react/src/Avatar/Avatar.test.tsx b/packages/react/src/Avatar/Avatar.test.tsx index ba816856822..774fdf7f0f5 100644 --- a/packages/react/src/Avatar/Avatar.test.tsx +++ b/packages/react/src/Avatar/Avatar.test.tsx @@ -57,4 +57,55 @@ describe('Avatar', () => { expect(styleAttr).toContain('--avatarSize-regular: 20px') expect(styleAttr).toContain('background: black') }) + + describe('srcTransformer', () => { + it('transforms the src when srcTransformer is provided', () => { + render( + `${src}?size=${size * 2}`} + data-testid="avatar" + />, + ) + const avatar = screen.getByTestId('avatar') + expect(avatar).toHaveAttribute('src', 'https://avatars.githubusercontent.com/u/1234?size=80') + }) + + it('passes src unchanged when srcTransformer is not provided', () => { + render() + const avatar = screen.getByTestId('avatar') + expect(avatar).toHaveAttribute('src', 'https://avatars.githubusercontent.com/u/1234') + }) + + it('receives the resolved size for responsive values', () => { + const transformer = (src: string, size: number) => `${src}?size=${size * 2}` + render( + , + ) + const avatar = screen.getByTestId('avatar') + // Should use the 'regular' size for the transformer + expect(avatar).toHaveAttribute('src', 'https://avatars.githubusercontent.com/u/1234?size=40') + }) + + it('uses default size when responsive value has no regular key', () => { + const transformer = (src: string, size: number) => `${src}?size=${size * 2}` + render( + , + ) + const avatar = screen.getByTestId('avatar') + // Falls back to DEFAULT_AVATAR_SIZE (20) + expect(avatar).toHaveAttribute('src', 'https://avatars.githubusercontent.com/u/1234?size=40') + }) + }) }) diff --git a/packages/react/src/Avatar/Avatar.tsx b/packages/react/src/Avatar/Avatar.tsx index b9e501a8412..be1d5f7217a 100644 --- a/packages/react/src/Avatar/Avatar.tsx +++ b/packages/react/src/Avatar/Avatar.tsx @@ -13,6 +13,8 @@ export type AvatarProps = { square?: boolean /** URL of the avatar image. */ src: string + /** Transforms the `src` URL before rendering. Receives the original `src` and the resolved numeric `size`. */ + srcTransformer?: (src: string, size: number) => string /** Provide alt text when the Avatar is used without the user's name next to it. */ alt?: string /** Additional class name. */ @@ -20,7 +22,7 @@ export type AvatarProps = { } & React.ComponentPropsWithoutRef<'img'> const Avatar = React.forwardRef(function Avatar( - {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, className, style, ...rest}, + {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, className, style, src, srcTransformer, ...rest}, ref, ) { const isResponsive = isResponsiveValue(size) @@ -34,12 +36,16 @@ const Avatar = React.forwardRef(function Avatar( cssSizeVars['--avatarSize-regular'] = `${size}px` } + const resolvedSize = isResponsive ? ((size as ResponsiveValue).regular ?? DEFAULT_AVATAR_SIZE) : size + const resolvedSrc = srcTransformer ? srcTransformer(src, resolvedSize) : src + return ( {alt} Date: Fri, 22 May 2026 11:38:21 -0700 Subject: [PATCH 2/4] statusIcon --- packages/react/src/Avatar/Avatar.docs.json | 9 ++++++ .../src/Avatar/Avatar.features.stories.tsx | 10 +++++++ packages/react/src/Avatar/Avatar.module.css | 23 +++++++++++++++ packages/react/src/Avatar/Avatar.test.tsx | 29 +++++++++++++++++++ packages/react/src/Avatar/Avatar.tsx | 17 +++++++++-- 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/react/src/Avatar/Avatar.docs.json b/packages/react/src/Avatar/Avatar.docs.json index 500b6bd2600..c36d41f8025 100644 --- a/packages/react/src/Avatar/Avatar.docs.json +++ b/packages/react/src/Avatar/Avatar.docs.json @@ -18,6 +18,9 @@ }, { "id": "components-avatar-features--src-transformer" + }, + { + "id": "components-avatar-features--status-icon" } ], "importPath": "@primer/react", @@ -52,6 +55,12 @@ "type": "(src: string, size: number) => string", "required": false, "description": "Optional function to transform the src URL before rendering. Receives the original src and the resolved numeric size in CSS pixels. Useful for appending query parameters for HiDPI/Retina support." + }, + { + "name": "statusIcon", + "type": "React.ReactNode", + "required": false, + "description": "Renders a status icon overlay positioned at the bottom-right of the avatar." } ], "subcomponents": [] diff --git a/packages/react/src/Avatar/Avatar.features.stories.tsx b/packages/react/src/Avatar/Avatar.features.stories.tsx index 6fbca67df22..7239568893e 100644 --- a/packages/react/src/Avatar/Avatar.features.stories.tsx +++ b/packages/react/src/Avatar/Avatar.features.stories.tsx @@ -1,4 +1,5 @@ import type {Meta} from '@storybook/react-vite' +import {XCircleFillIcon} from '@primer/octicons-react' import Avatar from './Avatar' export default { @@ -88,3 +89,12 @@ export const SrcTransformer = () => ( srcTransformer={(src, size) => `${src}&s=${size * 2}`} /> ) + +export const StatusIcon = () => ( + } + /> +) diff --git a/packages/react/src/Avatar/Avatar.module.css b/packages/react/src/Avatar/Avatar.module.css index fc67531b9a7..133ce3c233e 100644 --- a/packages/react/src/Avatar/Avatar.module.css +++ b/packages/react/src/Avatar/Avatar.module.css @@ -32,3 +32,26 @@ } } } + +.AvatarContainer { + position: relative; + display: inline-flex; + align-items: center; + width: var(--avatarSize-regular); + height: var(--avatarSize-regular); + vertical-align: middle; +} + +.StatusIcon { + position: absolute; + right: calc(-1 * var(--base-size-4)); + bottom: calc(-1 * var(--base-size-4)); + display: flex; + /* stylelint-disable-next-line primer/borders */ + border-radius: 100px; + /* stylelint-disable-next-line primer/box-shadow */ + box-shadow: 0 0 0 2px var(--bgColor-default, var(--color-canvas-default)); + background-color: var(--bgColor-default, var(--color-canvas-default)); + /* stylelint-disable-next-line primer/typography */ + line-height: 1; +} diff --git a/packages/react/src/Avatar/Avatar.test.tsx b/packages/react/src/Avatar/Avatar.test.tsx index 774fdf7f0f5..99e1f5f800d 100644 --- a/packages/react/src/Avatar/Avatar.test.tsx +++ b/packages/react/src/Avatar/Avatar.test.tsx @@ -1,4 +1,5 @@ import {describe, expect, it} from 'vitest' +import React from 'react' import {render, screen} from '@testing-library/react' import Avatar from '../Avatar' import {implementsClassName} from '../utils/testing' @@ -108,4 +109,32 @@ describe('Avatar', () => { expect(avatar).toHaveAttribute('src', 'https://avatars.githubusercontent.com/u/1234?size=40') }) }) + + describe('statusIcon', () => { + it('renders the status icon in a container when provided', () => { + render( + 🟢} data-testid="avatar" />, + ) + const avatar = screen.getByTestId('avatar') + const status = screen.getByTestId('status') + expect(avatar).toBeInTheDocument() + expect(status).toBeInTheDocument() + // Status icon is rendered inside the container alongside the avatar + expect(avatar.parentElement).toBe(status.parentElement?.parentElement) + }) + + it('does not render a container when statusIcon is not provided', () => { + render() + const avatar = screen.getByTestId('avatar') + // The img should not be wrapped in a container div + expect(avatar.parentElement?.classList.toString()).not.toContain('AvatarContainer') + }) + + it('still forwards ref to the img element when statusIcon is provided', () => { + const ref = React.createRef() + render(🟢} data-testid="avatar" />) + expect(ref.current).toBe(screen.getByTestId('avatar')) + expect(ref.current?.tagName).toBe('IMG') + }) + }) }) diff --git a/packages/react/src/Avatar/Avatar.tsx b/packages/react/src/Avatar/Avatar.tsx index be1d5f7217a..0b074325c46 100644 --- a/packages/react/src/Avatar/Avatar.tsx +++ b/packages/react/src/Avatar/Avatar.tsx @@ -15,6 +15,8 @@ export type AvatarProps = { src: string /** Transforms the `src` URL before rendering. Receives the original `src` and the resolved numeric `size`. */ srcTransformer?: (src: string, size: number) => string + /** Renders a status icon overlay positioned at the bottom-right of the avatar. */ + statusIcon?: React.ReactNode /** Provide alt text when the Avatar is used without the user's name next to it. */ alt?: string /** Additional class name. */ @@ -22,7 +24,7 @@ export type AvatarProps = { } & React.ComponentPropsWithoutRef<'img'> const Avatar = React.forwardRef(function Avatar( - {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, className, style, src, srcTransformer, ...rest}, + {alt = '', size = DEFAULT_AVATAR_SIZE, square = false, className, style, src, srcTransformer, statusIcon, ...rest}, ref, ) { const isResponsive = isResponsiveValue(size) @@ -39,7 +41,7 @@ const Avatar = React.forwardRef(function Avatar( const resolvedSize = isResponsive ? ((size as ResponsiveValue).regular ?? DEFAULT_AVATAR_SIZE) : size const resolvedSrc = srcTransformer ? srcTransformer(src, resolvedSize) : src - return ( + const img = ( (function Avatar( {...rest} /> ) + + if (statusIcon) { + return ( +
+ {img} + {statusIcon} +
+ ) + } + + return img }) if (__DEV__) { From fc23ca17e4a954f350c2fde11130ddcac02a5dba Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Fri, 22 May 2026 11:39:18 -0700 Subject: [PATCH 3/4] changeset --- .changeset/clear-groups-cross.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clear-groups-cross.md diff --git a/.changeset/clear-groups-cross.md b/.changeset/clear-groups-cross.md new file mode 100644 index 00000000000..50c03b67014 --- /dev/null +++ b/.changeset/clear-groups-cross.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Avatar: Add `srcTransformer` prop to transform the image URL before rendering, and `statusIcon` slot to render an overlay icon at the bottom-right of the avatar From 06aa69a45af0ae21f0b9f9122cecfaf8b4c09429 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Fri, 22 May 2026 13:15:44 -0700 Subject: [PATCH 4/4] no hard code width/height --- packages/react/src/Avatar/Avatar.module.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/react/src/Avatar/Avatar.module.css b/packages/react/src/Avatar/Avatar.module.css index 133ce3c233e..b048d25bddd 100644 --- a/packages/react/src/Avatar/Avatar.module.css +++ b/packages/react/src/Avatar/Avatar.module.css @@ -36,9 +36,6 @@ .AvatarContainer { position: relative; display: inline-flex; - align-items: center; - width: var(--avatarSize-regular); - height: var(--avatarSize-regular); vertical-align: middle; }