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 diff --git a/packages/react/src/Avatar/Avatar.docs.json b/packages/react/src/Avatar/Avatar.docs.json index bfe70bf9206..c36d41f8025 100644 --- a/packages/react/src/Avatar/Avatar.docs.json +++ b/packages/react/src/Avatar/Avatar.docs.json @@ -15,6 +15,12 @@ }, { "id": "components-avatar-features--size-responsive" + }, + { + "id": "components-avatar-features--src-transformer" + }, + { + "id": "components-avatar-features--status-icon" } ], "importPath": "@primer/react", @@ -43,6 +49,18 @@ "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." + }, + { + "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 6627d744d7b..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 { @@ -79,3 +80,21 @@ export const SizeResponsive = () => ( /> ) + +export const SrcTransformer = () => ( + `${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..b048d25bddd 100644 --- a/packages/react/src/Avatar/Avatar.module.css +++ b/packages/react/src/Avatar/Avatar.module.css @@ -32,3 +32,23 @@ } } } + +.AvatarContainer { + position: relative; + display: inline-flex; + 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 ba816856822..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' @@ -57,4 +58,83 @@ 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') + }) + }) + + 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 b9e501a8412..0b074325c46 100644 --- a/packages/react/src/Avatar/Avatar.tsx +++ b/packages/react/src/Avatar/Avatar.tsx @@ -13,6 +13,10 @@ 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 + /** 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. */ @@ -20,7 +24,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, statusIcon, ...rest}, ref, ) { const isResponsive = isResponsiveValue(size) @@ -34,12 +38,16 @@ const Avatar = React.forwardRef(function Avatar( cssSizeVars['--avatarSize-regular'] = `${size}px` } - return ( + const resolvedSize = isResponsive ? ((size as ResponsiveValue).regular ?? DEFAULT_AVATAR_SIZE) : size + const resolvedSrc = srcTransformer ? srcTransformer(src, resolvedSize) : src + + const img = ( {alt}(function Avatar( {...rest} /> ) + + if (statusIcon) { + return ( +
+ {img} + {statusIcon} +
+ ) + } + + return img }) if (__DEV__) {