Skip to content

Commit fa2fc97

Browse files
committed
feat: support polymorphic avatar root
1 parent 5cde795 commit fa2fc97

4 files changed

Lines changed: 136 additions & 87 deletions

File tree

src/avatar/avatar.stories.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Avatar, Box, Inline, Stack, Text } from '../index'
55
import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils'
66

77
import type { Meta, StoryObj } from '@storybook/react-vite'
8+
import type { AvatarProps } from './avatar'
89

910
const sizes = AVATAR_SIZES
1011

@@ -159,11 +160,11 @@ function AvatarExample({ label, children }: { label: string; children: React.Rea
159160
)
160161
}
161162

162-
function UserAvatar(props: Omit<React.ComponentProps<typeof Avatar>, 'shape'>) {
163+
function UserAvatar(props: Omit<AvatarProps, 'shape'>) {
163164
return <Avatar shape="circle" {...props} />
164165
}
165166

166-
function WorkspaceAvatarExample(props: Omit<React.ComponentProps<typeof Avatar>, 'shape'>) {
167+
function WorkspaceAvatarExample(props: Omit<AvatarProps, 'shape'>) {
167168
return <Avatar shape="rounded" {...props} />
168169
}
169170

@@ -177,7 +178,7 @@ function AvatarColorExample({ index, name }: { index: number; name: string }) {
177178

178179
type PlaygroundImage = keyof typeof playgroundImages
179180

180-
type PlaygroundArgs = Omit<React.ComponentProps<typeof Avatar>, 'image'> & {
181+
type PlaygroundArgs = Omit<AvatarProps, 'image'> & {
181182
image?: PlaygroundImage
182183
}
183184

src/avatar/avatar.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,36 @@ describe('Avatar', () => {
193193
expect(screen.getByTestId('avatar')).toHaveTextContent('')
194194
})
195195

196+
it('can render the root as a different element', () => {
197+
render(<Avatar as="section" data-testid="avatar" size={36} name="Jane Doe" />)
198+
199+
expect(screen.getByTestId('avatar').tagName).toBe('SECTION')
200+
})
201+
202+
it('derives the root ref type from the element rendered with as', () => {
203+
const anchorRef = React.createRef<HTMLAnchorElement>()
204+
const buttonRef = React.createRef<HTMLButtonElement>()
205+
206+
render(
207+
<Avatar
208+
as="a"
209+
data-testid="avatar"
210+
href="/profile"
211+
ref={anchorRef}
212+
size={36}
213+
name="Jane Doe"
214+
/>,
215+
)
216+
217+
expect(anchorRef.current).toBe(screen.getByTestId('avatar'))
218+
219+
const invalidRefElement = (
220+
// @ts-expect-error refs must match the element selected with as
221+
<Avatar as="a" href="/profile" ref={buttonRef} size={36} name="Jane Doe" />
222+
)
223+
expect(invalidRefElement).toBeTruthy()
224+
})
225+
196226
it('supports rounded shape with size-aware radius', () => {
197227
render(<Avatar data-testid="avatar" size={50} shape="rounded" name="Design" />)
198228

src/avatar/avatar.tsx

Lines changed: 101 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react'
33
import classNames from 'classnames'
44

55
import { Box } from '../box'
6+
import { polymorphicComponent } from '../utils/polymorphism'
67

78
import {
89
getAvailableImageSources,
@@ -16,8 +17,8 @@ import {
1617

1718
import styles from './avatar.module.css'
1819

19-
import type { ComponentProps } from 'react'
2020
import type { ObfuscatedClassName } from '../utils/common-types'
21+
import type { PolymorphicComponentProps } from '../utils/polymorphism'
2122
import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils'
2223

2324
type AvatarStyle = React.CSSProperties & {
@@ -28,7 +29,7 @@ type AvatarStyle = React.CSSProperties & {
2829
/**
2930
* Props for the `Avatar` component.
3031
*/
31-
type AvatarProps = ObfuscatedClassName & {
32+
type AvatarOwnProps = ObfuscatedClassName & {
3233
/**
3334
* The rendered avatar size, in CSS pixels.
3435
*/
@@ -69,99 +70,116 @@ type AvatarProps = ObfuscatedClassName & {
6970
* Test identifier applied to the avatar root element.
7071
*/
7172
'data-testid'?: string
72-
} & Omit<ComponentProps<'div'>, 'className' | 'style'>
73-
74-
const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function AvatarContent(
75-
{
76-
size,
77-
shape = 'circle',
78-
name,
79-
image,
80-
alt,
81-
exceptionallySetClassName,
82-
'data-testid': testId,
83-
'aria-hidden': ariaHidden,
84-
'aria-label': ariaLabel,
85-
...restProps
86-
},
87-
ref,
88-
) {
89-
const imageSources = getSources(image, size)
90-
const [failedImageSources, setFailedImageSources] = React.useState<string[]>([])
91-
const availableImageSources = getAvailableImageSources(imageSources, failedImageSources)
92-
const normalizedName = normalizeAvatarName(name)
93-
const initials = availableImageSources ? '' : getInitials(name)
9473

95-
const hasInitials = initials !== ''
96-
const label = ariaLabel ?? alt ?? normalizedName
97-
const isDecorative = ariaHidden || label === ''
98-
const metaColorIndex = hasInitials ? getAvatarMetaColorIndex(name) : undefined
74+
/**
75+
* Avatar owns its root sizing styles. Use `exceptionallySetClassName` for the styling escape
76+
* hatch.
77+
*/
78+
style?: never
79+
}
9980

100-
return (
101-
<Box
102-
ref={ref}
103-
className={classNames(
104-
styles.avatar,
105-
styles[`shape-${shape}`],
106-
metaColorIndex !== undefined && styles[`meta-color-${metaColorIndex}`],
107-
!availableImageSources && !hasInitials && styles.empty,
108-
exceptionallySetClassName,
109-
)}
110-
style={getAvatarStyle(size)}
111-
data-testid={testId}
112-
aria-hidden={isDecorative || undefined}
113-
display="inlineFlex"
114-
alignItems="center"
115-
justifyContent="center"
116-
flexShrink={0}
117-
overflow="hidden"
118-
textAlign="center"
119-
{...restProps}
120-
>
121-
{availableImageSources ? (
122-
<img
123-
className={styles.image}
124-
src={availableImageSources.src}
125-
srcSet={availableImageSources.srcSet}
126-
sizes={availableImageSources.sizes}
127-
alt={label ?? ''}
128-
onError={(event) => {
129-
const failedSource = getFailedImageSource(
130-
availableImageSources,
131-
event.currentTarget,
132-
)
133-
134-
setFailedImageSources((currentFailedSources) =>
135-
currentFailedSources.includes(failedSource)
136-
? currentFailedSources
137-
: [...currentFailedSources, failedSource],
138-
)
139-
}}
140-
/>
141-
) : hasInitials ? (
142-
<div
143-
className={styles.initials}
144-
role={label ? 'img' : undefined}
145-
aria-label={label}
146-
>
147-
{initials}
148-
</div>
149-
) : null}
150-
</Box>
151-
)
152-
})
81+
type AvatarProps<ComponentType extends React.ElementType = 'div'> = PolymorphicComponentProps<
82+
ComponentType,
83+
AvatarOwnProps,
84+
'omitClassName'
85+
>
86+
87+
const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(
88+
function AvatarContent(
89+
{
90+
as,
91+
size,
92+
shape = 'circle',
93+
name,
94+
image,
95+
alt,
96+
exceptionallySetClassName,
97+
'data-testid': testId,
98+
'aria-hidden': ariaHidden,
99+
'aria-label': ariaLabel,
100+
...restProps
101+
},
102+
ref,
103+
) {
104+
const imageSources = getSources(image, size)
105+
const [failedImageSources, setFailedImageSources] = React.useState<string[]>([])
106+
const availableImageSources = getAvailableImageSources(imageSources, failedImageSources)
107+
const normalizedName = normalizeAvatarName(name)
108+
const initials = availableImageSources ? '' : getInitials(name)
109+
110+
const hasInitials = initials !== ''
111+
const label = ariaLabel ?? alt ?? normalizedName
112+
const isDecorative = ariaHidden || label === ''
113+
const metaColorIndex = hasInitials ? getAvatarMetaColorIndex(name) : undefined
114+
115+
return (
116+
<Box
117+
as={as}
118+
ref={ref}
119+
className={classNames(
120+
styles.avatar,
121+
styles[`shape-${shape}`],
122+
metaColorIndex !== undefined && styles[`meta-color-${metaColorIndex}`],
123+
!availableImageSources && !hasInitials && styles.empty,
124+
exceptionallySetClassName,
125+
)}
126+
style={getAvatarStyle(size)}
127+
data-testid={testId}
128+
aria-hidden={isDecorative || undefined}
129+
display="inlineFlex"
130+
alignItems="center"
131+
justifyContent="center"
132+
flexShrink={0}
133+
overflow="hidden"
134+
textAlign="center"
135+
{...restProps}
136+
>
137+
{availableImageSources ? (
138+
<img
139+
className={styles.image}
140+
src={availableImageSources.src}
141+
srcSet={availableImageSources.srcSet}
142+
sizes={availableImageSources.sizes}
143+
alt={label ?? ''}
144+
onError={(event) => {
145+
const failedSource = getFailedImageSource(
146+
availableImageSources,
147+
event.currentTarget,
148+
)
149+
150+
setFailedImageSources((currentFailedSources) =>
151+
currentFailedSources.includes(failedSource)
152+
? currentFailedSources
153+
: [...currentFailedSources, failedSource],
154+
)
155+
}}
156+
/>
157+
) : hasInitials ? (
158+
<div
159+
className={styles.initials}
160+
role={label ? 'img' : undefined}
161+
aria-label={label}
162+
>
163+
{initials}
164+
</div>
165+
) : null}
166+
</Box>
167+
)
168+
},
169+
)
153170

154171
/**
155172
* Displays an avatar from an image URL, a source map keyed by intrinsic
156173
* image width, or initials derived from the provided name (with a background
157174
* color).
158175
*/
159-
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(function Avatar(
160-
{ image, ...restProps },
176+
const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(function Avatar(
177+
{ as, image, ...restProps },
161178
ref,
162179
) {
163180
return (
164181
<AvatarContent
182+
as={as}
165183
ref={ref}
166184
// Allows `AvatarContent` to remount when the image map changes,
167185
// which resets error states

src/utils/polymorphism.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,5 +197,5 @@ function polymorphicComponent<
197197
>
198198
}
199199

200-
export type { PolymorphicComponent }
200+
export type { PolymorphicComponent, PolymorphicComponentProps }
201201
export { polymorphicComponent }

0 commit comments

Comments
 (0)