Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clear-groups-cross.md
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions packages/react/src/Avatar/Avatar.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": []
Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/Avatar/Avatar.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {Meta} from '@storybook/react-vite'
import {XCircleFillIcon} from '@primer/octicons-react'
import Avatar from './Avatar'

export default {
Expand Down Expand Up @@ -79,3 +80,21 @@ export const SizeResponsive = () => (
/>
</div>
)

export const SrcTransformer = () => (
<Avatar
size={32}
alt="mona"
src="https://avatars.githubusercontent.com/u/7143434?v=4"
srcTransformer={(src, size) => `${src}&s=${size * 2}`}
/>
)

export const StatusIcon = () => (
<Avatar
size={20}
alt="status avatar"
src="https://avatars.githubusercontent.com/u/7143434?v=4"
statusIcon={<XCircleFillIcon size={12} fill="var(--fgColor-success)" />}
/>
)
20 changes: 20 additions & 0 deletions packages/react/src/Avatar/Avatar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
80 changes: 80 additions & 0 deletions packages/react/src/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(
<Avatar
src="https://avatars.githubusercontent.com/u/1234"
size={40}
srcTransformer={(src, size) => `${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(<Avatar src="https://avatars.githubusercontent.com/u/1234" data-testid="avatar" />)
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(
<Avatar
src="https://avatars.githubusercontent.com/u/1234"
size={{narrow: 16, regular: 20, wide: 24}}
srcTransformer={transformer}
data-testid="avatar"
/>,
)
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(
<Avatar
src="https://avatars.githubusercontent.com/u/1234"
size={{narrow: 16, wide: 24}}
srcTransformer={transformer}
data-testid="avatar"
/>,
)
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(
<Avatar src="primer.png" size={20} statusIcon={<span data-testid="status">🟢</span>} 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(<Avatar src="primer.png" data-testid="avatar" />)
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<HTMLImageElement>()
render(<Avatar ref={ref} src="primer.png" statusIcon={<span>🟢</span>} data-testid="avatar" />)
expect(ref.current).toBe(screen.getByTestId('avatar'))
expect(ref.current?.tagName).toBe('IMG')
})
})
})
23 changes: 21 additions & 2 deletions packages/react/src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ 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. */
className?: string
} & React.ComponentPropsWithoutRef<'img'>

const Avatar = React.forwardRef<HTMLImageElement, AvatarProps>(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)
Expand All @@ -34,12 +38,16 @@ const Avatar = React.forwardRef<HTMLImageElement, AvatarProps>(function Avatar(
cssSizeVars['--avatarSize-regular'] = `${size}px`
}

return (
const resolvedSize = isResponsive ? ((size as ResponsiveValue<number>).regular ?? DEFAULT_AVATAR_SIZE) : size
const resolvedSrc = srcTransformer ? srcTransformer(src, resolvedSize) : src

const img = (
<img
data-component="Avatar"
className={clsx(className, classes.Avatar)}
ref={ref}
alt={alt}
src={resolvedSrc}
data-responsive={isResponsive ? '' : undefined}
data-square={square ? '' : undefined}
width={isResponsive ? undefined : size}
Expand All @@ -55,6 +63,17 @@ const Avatar = React.forwardRef<HTMLImageElement, AvatarProps>(function Avatar(
{...rest}
/>
)

if (statusIcon) {
return (
<div className={classes.AvatarContainer} style={cssSizeVars as React.CSSProperties}>
{img}
<span className={classes.StatusIcon}>{statusIcon}</span>
</div>
Comment on lines +67 to +72
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The style prop applies to the <img> not the container

)
}

return img
})

if (__DEV__) {
Expand Down
Loading