From 99b4dc3cf61d63a1e12499fb4da6422c087f36b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:03:16 -0500 Subject: [PATCH 01/41] feat: add avatar utility primitives --- src/avatar/utils.test.ts | 126 +++++++++++++++++++++++++++++--------- src/avatar/utils.ts | 128 +++++++++++++++++++++++++++++++++------ 2 files changed, 206 insertions(+), 48 deletions(-) diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 4cb13c2f..9e27b9e9 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -1,53 +1,123 @@ -import { emailToIndex, getInitials } from './utils' +import { + AVATAR_META_COLOR_COUNT, + getAvatarImageSrcSet, + getAvatarMetaColorIndex, + getInitials, + resolveAvatarImage, + ROUNDED_AVATAR_RADIUS_BY_SIZE, +} from './utils' -describe('Utils', () => { +describe('Avatar utils', () => { describe('getInitials', () => { it('returns uppercased initials for two names', () => { - const initials = getInitials('henning mus') - expect(initials).toBe('HM') + expect(getInitials('jane doe')).toBe('JD') }) - it('returns first and last name initials for more than two names', () => { - const initials = getInitials('henning is awesome mus') - expect(initials).toBe('HM') + it('returns first and last initials for more than two names', () => { + expect(getInitials('jane middle doe')).toBe('JD') }) it('returns first initial for a single name', () => { - const initials = getInitials('henningmus') - expect(initials).toBe('H') + expect(getInitials('jane')).toBe('J') }) - it('returns only first initial if first and second initials are the same', () => { - const initials = getInitials('henning hen') - expect(initials).toBe('H') + it('returns one initial when first and last initials match', () => { + expect(getInitials('jane johnson')).toBe('J') + }) + + it('filters non-letter characters before creating initials', () => { + expect(getInitials('🍕 Francesca 🍕 Ciao 🍕')).toBe('FC') }) it('returns an empty string for an empty name', () => { - const initials = getInitials('') - expect(initials).toBe('') + expect(getInitials('')).toBe('') }) - it('returns an empty string for when called without name', () => { - const initials = getInitials() - expect(initials).toBe('') + it('returns an empty string when called without a name', () => { + expect(getInitials()).toBe('') }) }) - describe('emailToIndex', () => { - it('returns an index for a given mail', () => { - const index = emailToIndex('henning@doist.com', 13) - expect(index).toBe(12) + describe('resolveAvatarImage', () => { + const imageMap = { + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + } + + it('returns a string image directly', () => { + expect(resolveAvatarImage('avatar.png', 36, 2)).toBe('avatar.png') + }) + + it('chooses the smallest source at or above the target pixel size', () => { + expect(resolveAvatarImage(imageMap, 36, 2)).toBe('avatar-72.png') + }) + + it('uses the largest source when every source is smaller than the target', () => { + expect(resolveAvatarImage(imageMap, 80, 2)).toBe('avatar-144.png') + }) + + it('uses the smallest valid source for low pixel densities', () => { + expect(resolveAvatarImage(imageMap, 24, 1)).toBe('avatar-36.png') }) - it('returns the index if the local part of email is the same', () => { - const index1 = emailToIndex('henning@doist.com', 13) - const index2 = emailToIndex('henning@foobar.com', 13) - expect(index1).toBe(index2) + it('returns undefined for an empty source map', () => { + expect(resolveAvatarImage({}, 36, 2)).toBeUndefined() + }) + }) + + describe('getAvatarImageSrcSet', () => { + it('formats source maps as sorted width descriptors', () => { + expect( + getAvatarImageSrcSet({ + 144: 'avatar-144.png', + 36: 'avatar-36.png', + 72: 'avatar-72.png', + }), + ).toBe('avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w') }) - it('returns 0 index if local part of email is empty', () => { - const index1 = emailToIndex('@doist.com', 13) - expect(index1).toBe(0) + it('returns undefined for string images', () => { + expect(getAvatarImageSrcSet('avatar.png')).toBeUndefined() + }) + }) + + describe('getAvatarMetaColorIndex', () => { + it('uses 20 fixed meta color slots', () => { + expect(AVATAR_META_COLOR_COUNT).toBe(20) + }) + + it('returns a deterministic index based on the normalized full name', () => { + expect(getAvatarMetaColorIndex('Jane Doe')).toBe(0) + expect(getAvatarMetaColorIndex('Jane Doe')).toBe(0) + expect(getAvatarMetaColorIndex('John Doe')).toBe(9) + }) + + it('always returns an index in the configured fixed slot range', () => { + const index = getAvatarMetaColorIndex('Francesca Ciao') + + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThan(AVATAR_META_COLOR_COUNT) + }) + }) + + describe('ROUNDED_AVATAR_RADIUS_BY_SIZE', () => { + it('contains the exclusive rounded radius mapping', () => { + expect(ROUNDED_AVATAR_RADIUS_BY_SIZE).toEqual({ + 80: '10px', + 72: '10px', + 62: '8.5px', + 50: '7px', + 40: '5.5px', + 36: '5px', + 30: '5px', + 28: '5px', + 24: '3.2px', + 20: '3px', + 18: '3px', + 16: '2px', + 12: '1.6px', + }) }) }) }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index 633f9efe..7cc191f9 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -1,30 +1,118 @@ +const AVATAR_SIZES = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const + +type AvatarSize = (typeof AVATAR_SIZES)[number] +type AvatarShape = 'circle' | 'rounded' +type AvatarImage = string | Record + +const AVATAR_META_COLOR_COUNT = 20 + +const ROUNDED_AVATAR_RADIUS_BY_SIZE: Record = { + 80: '10px', + 72: '10px', + 62: '8.5px', + 50: '7px', + 40: '5.5px', + 36: '5px', + 30: '5px', + 28: '5px', + 24: '3.2px', + 20: '3px', + 18: '3px', + 16: '2px', + 12: '1.6px', +} + +const FILTER_CHARS_REGEXP = /[^\p{L}\p{M}\p{Zs} ]/gu + +function normalizeAvatarName(name?: string) { + return name?.trim().replace(/\s+/g, ' ') ?? '' +} + function getInitials(name?: string) { - if (!name) { + const words = normalizeAvatarName(name) + .replace(FILTER_CHARS_REGEXP, '') + .split(' ') + .filter(Boolean) + + const firstWord = words[0] + const lastWord = words[words.length - 1] + const firstInitial = firstWord?.[0] ?? '' + const lastInitial = lastWord?.[0] ?? '' + + if (!firstInitial) { return '' } - const seed = name.trim().split(' ') - const firstInitial = seed[0] - const lastInitial = seed[seed.length - 1] + if (lastInitial && firstInitial !== lastInitial) { + return `${firstInitial}${lastInitial}`.toUpperCase() + } + + return firstInitial.toUpperCase() +} + +function getSortedImageSources(image: Record) { + return Object.entries(image) + .map(([sourceSize, src]) => ({ sourceSize: Number(sourceSize), src })) + .filter(({ sourceSize, src }) => Number.isFinite(sourceSize) && sourceSize > 0 && src) + .sort((a, b) => a.sourceSize - b.sourceSize) +} + +function resolveAvatarImage( + image: AvatarImage | undefined, + size: AvatarSize, + pixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1, +) { + if (!image) { + return undefined + } + + if (typeof image === 'string') { + return image + } - let initials = firstInitial?.[0] - if ( - firstInitial != null && - lastInitial != null && - initials != null && - // Better readable this way. - // eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with - firstInitial[0] !== lastInitial[0] - ) { - initials += lastInitial[0] + const sources = getSortedImageSources(image) + if (sources.length === 0) { + return undefined } - return initials?.toUpperCase() + + const targetPixels = size * pixelRatio + return ( + sources.find(({ sourceSize }) => sourceSize >= targetPixels) ?? sources[sources.length - 1] + ).src } -function emailToIndex(email: string, maxIndex: number) { - const seed = email.split('@')[0] - const hash = seed ? seed.charCodeAt(0) + seed.charCodeAt(seed.length - 1) || 0 : 0 - return hash % maxIndex +function getAvatarImageSrcSet(image: AvatarImage | undefined) { + if (!image || typeof image === 'string') { + return undefined + } + + const sources = getSortedImageSources(image) + if (sources.length === 0) { + return undefined + } + + return sources.map(({ sourceSize, src }) => `${src} ${sourceSize}w`).join(', ') } -export { emailToIndex, getInitials } +function getAvatarMetaColorIndex(name?: string) { + const normalizedName = normalizeAvatarName(name) + let hash = 0 + + for (const char of normalizedName) { + hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0 + } + + return hash % AVATAR_META_COLOR_COUNT +} + +export { + AVATAR_META_COLOR_COUNT, + AVATAR_SIZES, + getAvatarImageSrcSet, + getAvatarMetaColorIndex, + getInitials, + normalizeAvatarName, + resolveAvatarImage, + ROUNDED_AVATAR_RADIUS_BY_SIZE, +} +export type { AvatarImage, AvatarShape, AvatarSize } From 5942fd434c8c7334baff96ba706dfbd7e6d588f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:05:05 -0500 Subject: [PATCH 02/41] fix: preserve unicode avatar initials --- src/avatar/utils.test.ts | 4 ++++ src/avatar/utils.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 9e27b9e9..97beeb6c 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -21,6 +21,10 @@ describe('Avatar utils', () => { expect(getInitials('jane')).toBe('J') }) + it('preserves non-BMP Unicode letter initials', () => { + expect(getInitials('\u{10400}eseret doe')).toBe('\u{10400}D') + }) + it('returns one initial when first and last initials match', () => { expect(getInitials('jane johnson')).toBe('J') }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index 7cc191f9..e697b8dd 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -28,6 +28,11 @@ function normalizeAvatarName(name?: string) { return name?.trim().replace(/\s+/g, ' ') ?? '' } +function getFirstCodePoint(value?: string) { + const [firstCodePoint = ''] = Array.from(value ?? '') + return firstCodePoint +} + function getInitials(name?: string) { const words = normalizeAvatarName(name) .replace(FILTER_CHARS_REGEXP, '') @@ -36,8 +41,8 @@ function getInitials(name?: string) { const firstWord = words[0] const lastWord = words[words.length - 1] - const firstInitial = firstWord?.[0] ?? '' - const lastInitial = lastWord?.[0] ?? '' + const firstInitial = getFirstCodePoint(firstWord) + const lastInitial = getFirstCodePoint(lastWord) if (!firstInitial) { return '' From a3527b7741e86def7b0517475524338e9c73d26b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:09:18 -0500 Subject: [PATCH 03/41] fix: preserve avatar utility type checks --- src/avatar/avatar.tsx | 8 +++++++- src/avatar/utils.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++ src/avatar/utils.ts | 4 ++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 92805138..9eddd0fa 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { Box } from '../box' import { getClassNames } from '../utils/responsive-props' -import { emailToIndex, getInitials } from './utils' +import { getInitials } from './utils' import styles from './avatar.module.css' @@ -31,6 +31,12 @@ const AVATAR_COLORS = [ '#5e5e5e', ] +function emailToIndex(email: string, maxIndex: number) { + const seed = email.split('@')[0] + const hash = seed ? seed.charCodeAt(0) + seed.charCodeAt(seed.length - 1) || 0 : 0 + return hash % maxIndex +} + type AvatarSize = 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl' type Props = ObfuscatedClassName & { diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 97beeb6c..fce2d494 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -3,6 +3,7 @@ import { getAvatarImageSrcSet, getAvatarMetaColorIndex, getInitials, + normalizeAvatarName, resolveAvatarImage, ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' @@ -42,6 +43,20 @@ describe('Avatar utils', () => { }) }) + describe('normalizeAvatarName', () => { + it('trims and collapses whitespace', () => { + expect(normalizeAvatarName(' Jane Doe ')).toBe('Jane Doe') + }) + + it('returns an empty string for undefined', () => { + expect(normalizeAvatarName()).toBe('') + }) + + it('returns an empty string for an empty string', () => { + expect(normalizeAvatarName('')).toBe('') + }) + }) + describe('resolveAvatarImage', () => { const imageMap = { 36: 'avatar-36.png', @@ -68,6 +83,21 @@ describe('Avatar utils', () => { it('returns undefined for an empty source map', () => { expect(resolveAvatarImage({}, 36, 2)).toBeUndefined() }) + + it('ignores invalid source entries', () => { + expect( + resolveAvatarImage( + { + '-10': 'avatar-negative.png', + 0: 'avatar-zero.png', + 36: '', + 72: 'avatar-72.png', + } as Record, + 36, + 1, + ), + ).toBe('avatar-72.png') + }) }) describe('getAvatarImageSrcSet', () => { @@ -84,6 +114,17 @@ describe('Avatar utils', () => { it('returns undefined for string images', () => { expect(getAvatarImageSrcSet('avatar.png')).toBeUndefined() }) + + it('ignores invalid source entries', () => { + expect( + getAvatarImageSrcSet({ + '-10': 'avatar-negative.png', + 0: 'avatar-zero.png', + 36: '', + 72: 'avatar-72.png', + } as Record), + ).toBe('avatar-72.png 72w') + }) }) describe('getAvatarMetaColorIndex', () => { diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index e697b8dd..f042e0b7 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -22,7 +22,7 @@ const ROUNDED_AVATAR_RADIUS_BY_SIZE: Record = { 12: '1.6px', } -const FILTER_CHARS_REGEXP = /[^\p{L}\p{M}\p{Zs} ]/gu +const FILTER_CHARS_REGEXP = new RegExp('[^\\p{L}\\p{M}\\p{Zs} ]', 'gu') function normalizeAvatarName(name?: string) { return name?.trim().replace(/\s+/g, ' ') ?? '' @@ -82,7 +82,7 @@ function resolveAvatarImage( const targetPixels = size * pixelRatio return ( - sources.find(({ sourceSize }) => sourceSize >= targetPixels) ?? sources[sources.length - 1] + sources.find(({ sourceSize }) => sourceSize >= targetPixels) ?? sources[sources.length - 1]! ).src } From b4e89bc392e991476f73482549979a85a8cee694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:11:36 -0500 Subject: [PATCH 04/41] fix: normalize avatar initials before comparison --- src/avatar/utils.test.ts | 4 ++++ src/avatar/utils.ts | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index fce2d494..b9be99cb 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -30,6 +30,10 @@ describe('Avatar utils', () => { expect(getInitials('jane johnson')).toBe('J') }) + it('returns one initial when first and last initials match after uppercasing', () => { + expect(getInitials('Jane johnson')).toBe('J') + }) + it('filters non-letter characters before creating initials', () => { expect(getInitials('🍕 Francesca 🍕 Ciao 🍕')).toBe('FC') }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index f042e0b7..bc42cd65 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -41,18 +41,18 @@ function getInitials(name?: string) { const firstWord = words[0] const lastWord = words[words.length - 1] - const firstInitial = getFirstCodePoint(firstWord) - const lastInitial = getFirstCodePoint(lastWord) + const firstInitial = getFirstCodePoint(firstWord).toUpperCase() + const lastInitial = getFirstCodePoint(lastWord).toUpperCase() if (!firstInitial) { return '' } if (lastInitial && firstInitial !== lastInitial) { - return `${firstInitial}${lastInitial}`.toUpperCase() + return `${firstInitial}${lastInitial}` } - return firstInitial.toUpperCase() + return firstInitial } function getSortedImageSources(image: Record) { From cdbe28d04f9722811dc3abae64a1572a4df19d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:17:32 -0500 Subject: [PATCH 05/41] feat: replace avatar primitive API --- src/avatar/__snapshots__/avatar.test.tsx.snap | 11 -- src/avatar/avatar.module.css | 159 ++++++------------ src/avatar/avatar.test.tsx | 148 +++++++++++----- src/avatar/avatar.tsx | 150 ++++++++++------- 4 files changed, 247 insertions(+), 221 deletions(-) delete mode 100644 src/avatar/__snapshots__/avatar.test.tsx.snap diff --git a/src/avatar/__snapshots__/avatar.test.tsx.snap b/src/avatar/__snapshots__/avatar.test.tsx.snap deleted file mode 100644 index 56564145..00000000 --- a/src/avatar/__snapshots__/avatar.test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Avatar renders a background image when avatarUrl is supplied 1`] = ` -
- HM -
-`; diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 4c97af2b..84437e03 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -1,128 +1,69 @@ :root { - --reactist-avatar-size-xxsmall: 16px; - --reactist-avatar-size-xsmall: 20px; - --reactist-avatar-size-small: 30px; - --reactist-avatar-size-medium: 32px; - --reactist-avatar-size-large: 34px; - --reactist-avatar-size-xlarge: 48px; - --reactist-avatar-size-xxlarge: 70px; - --reactist-avatar-size-xxxlarge: 100px; - - --reactist-avatar-size: var(--reactist-avatar-size-large); + --reactist-avatar-initials-color: #ffffff; + --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest); + + --reactist-avatar-meta-fill-0: #fcc652; + --reactist-avatar-meta-fill-1: #e9952c; + --reactist-avatar-meta-fill-2: #e16b2d; + --reactist-avatar-meta-fill-3: #d84b40; + --reactist-avatar-meta-fill-4: #e8435a; + --reactist-avatar-meta-fill-5: #e5198a; + --reactist-avatar-meta-fill-6: #ad3889; + --reactist-avatar-meta-fill-7: #86389c; + --reactist-avatar-meta-fill-8: #a8a8a8; + --reactist-avatar-meta-fill-9: #98be2f; + --reactist-avatar-meta-fill-10: #5d9d50; + --reactist-avatar-meta-fill-11: #5f9f85; + --reactist-avatar-meta-fill-12: #5bbcb6; + --reactist-avatar-meta-fill-13: #32a3bf; + --reactist-avatar-meta-fill-14: #2bafeb; + --reactist-avatar-meta-fill-15: #2d88c3; + --reactist-avatar-meta-fill-16: #3863cc; + --reactist-avatar-meta-fill-17: #5e5e5e; + --reactist-avatar-meta-fill-18: #7a6ff0; + --reactist-avatar-meta-fill-19: #f36d6d; } .avatar { - flex-shrink: 0; - background-position: center; - color: white; - text-align: center; - border-radius: 50%; + --reactist-avatar-size: 36px; + --reactist-avatar-rounded-radius: 5px; + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + overflow: hidden; width: var(--reactist-avatar-size); height: var(--reactist-avatar-size); - line-height: var(--reactist-avatar-size); - background-size: var(--reactist-avatar-size); + color: var(--reactist-avatar-initials-color); font-size: calc(var(--reactist-avatar-size) / 2); + font-weight: var(--reactist-font-weight-medium); + line-height: 1; + text-align: center; + user-select: none; } -.size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); -} - -.size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); -} - -.size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); -} - -.size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); -} - -.size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); -} - -.size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); +.shape-circle { + border-radius: 50%; } -.size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); +.shape-rounded { + border-radius: var(--reactist-avatar-rounded-radius); } -.size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); +.image { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; } -/* avatar size for tablet */ -@media (min-width: 768px /* --reactist-breakpoint-tablet */) { - .tablet-size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); - } - - .tablet-size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); - } - - .tablet-size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); - } - - .tablet-size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); - } - - .tablet-size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); - } - - .tablet-size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); - } - - .tablet-size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); - } - - .tablet-size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); - } +.fallback { + background-color: var(--reactist-avatar-meta-fill); } -/* avatar size for desktop */ -@media (min-width: 992px /* --reactist-breakpoint-desktop */) { - .desktop-size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); - } - - .desktop-size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); - } - - .desktop-size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); - } - - .desktop-size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); - } - - .desktop-size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); - } - - .desktop-size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); - } - - .desktop-size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); - } - - .desktop-size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); - } +.empty { + background-color: var(--reactist-avatar-empty-fill); } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 68409eaf..77fce545 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -1,64 +1,134 @@ import * as React from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { Avatar } from './avatar' describe('Avatar', () => { - it('renders a background image when avatarUrl is supplied', () => { - render(getAvatar({ avatarUrl: 'https://foo.bar/com.png' })) + afterEach(() => { + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value: 1, + }) + }) - const avatar = screen.getByTestId('avatar') + it('renders a string image URL', () => { + render() - expect(avatar).toMatchSnapshot() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') + expect(screen.getByTestId('avatar')).toHaveStyle({ + '--reactist-avatar-size': '36px', + }) }) - it('renders initials of user name when avatarUrl is not supplied', () => { - render(getAvatar()) + it('renders a source-map image URL selected for pixel density', () => { + Object.defineProperty(window, 'devicePixelRatio', { + configurable: true, + value: 2, + }) - const avatar = screen.getByTestId('avatar') + render( + , + ) - expect(avatar).toHaveTextContent('HM') + const image = screen.getByRole('img', { name: 'Jane Doe' }) + expect(image).toHaveAttribute('src', 'avatar-72.png') + expect(image).toHaveAttribute( + 'srcset', + 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w', + ) + expect(image).toHaveAttribute('sizes', '36px') }) - it('renders initials of user email when avatarUrl is not supplied', () => { - render(getAvatar({ user: { email: 'henning@doist.com' } })) + it('falls back to initials when no image is provided', () => { + render() - const avatar = screen.getByTestId('avatar') + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + expect(screen.getByTestId('avatar')).toHaveStyle({ + '--reactist-avatar-meta-fill': 'var(--reactist-avatar-meta-fill-0)', + }) + }) + + it('falls back to initials when the image fails to load', () => { + render() - expect(avatar).toHaveTextContent('H') + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') }) - it('supports responsive values', () => { - render( - getAvatar({ - size: { - mobile: 's', - desktop: 'xl', - tablet: 'xxl', - }, - }), - ) - const avatar = screen.getByTestId('avatar') + it('allows a new image to load after a failed image changes', () => { + const { rerender } = render() + + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + + rerender() + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') + }) + + it('renders a neutral empty avatar when no name or image is provided', () => { + render() - expect(avatar).toHaveClass('size-s') - expect(avatar).toHaveClass('desktop-size-xl') - expect(avatar).toHaveClass('tablet-size-xxl') + expect(screen.getByTestId('avatar')).toHaveClass('empty') + expect(screen.getByTestId('avatar')).toHaveTextContent('') }) - // Helpers ================================================================ - function getAvatar( - props?: Omit, 'user'> & { - user?: { name?: string; email: string } - }, - ) { - return ( + it('supports rounded shape with size-aware radius', () => { + render() + + expect(screen.getByTestId('avatar')).toHaveClass('shape-rounded') + expect(screen.getByTestId('avatar')).toHaveStyle({ + '--reactist-avatar-rounded-radius': '7px', + }) + }) + + it('defaults to circle shape', () => { + render() + + expect(screen.getByTestId('avatar')).toHaveClass('shape-circle') + }) + + it('uses custom alt text as the accessible label', () => { + render() + + expect(screen.getByRole('img', { name: 'Account avatar' })).toBeInTheDocument() + }) + + it('supports decorative image avatars with empty alt text', () => { + render() + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByAltText('')).toHaveAttribute('src', 'avatar.png') + }) + + it('supports decorative initials avatars with empty alt text', () => { + render() + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('avatar')).toHaveTextContent('JD') + }) + + it('applies the escape hatch class name', () => { + render( + size={36} + name="Jane Doe" + exceptionallySetClassName="custom-avatar" + />, ) - } + + expect(screen.getByTestId('avatar')).toHaveClass('custom-avatar') + }) }) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 9eddd0fa..55625e5e 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -1,87 +1,113 @@ import * as React from 'react' +import classNames from 'classnames' + import { Box } from '../box' -import { getClassNames } from '../utils/responsive-props' -import { getInitials } from './utils' +import { + getAvatarImageSrcSet, + getAvatarMetaColorIndex, + getInitials, + resolveAvatarImage, + ROUNDED_AVATAR_RADIUS_BY_SIZE, +} from './utils' import styles from './avatar.module.css' import type { ObfuscatedClassName } from '../utils/common-types' -import type { ResponsiveProp } from '../utils/responsive-props' - -const AVATAR_COLORS = [ - '#fcc652', - '#e9952c', - '#e16b2d', - '#d84b40', - '#e8435a', - '#e5198a', - '#ad3889', - '#86389c', - '#a8a8a8', - '#98be2f', - '#5d9d50', - '#5f9f85', - '#5bbcb6', - '#32a3bf', - '#2bafeb', - '#2d88c3', - '#3863cc', - '#5e5e5e', -] - -function emailToIndex(email: string, maxIndex: number) { - const seed = email.split('@')[0] - const hash = seed ? seed.charCodeAt(0) + seed.charCodeAt(seed.length - 1) || 0 : 0 - return hash % maxIndex +import type { AvatarImage, AvatarShape, AvatarSize } from './utils' + +type AvatarStyle = React.CSSProperties & { + '--reactist-avatar-size': string + '--reactist-avatar-rounded-radius': string + '--reactist-avatar-meta-fill': string +} + +type AvatarProps = ObfuscatedClassName & { + size: AvatarSize + shape?: AvatarShape + name?: string + image?: AvatarImage + alt?: string + 'data-testid'?: string +} + +function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { + const metaColorIndex = getAvatarMetaColorIndex(name) + + return { + '--reactist-avatar-size': `${size}px`, + '--reactist-avatar-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], + '--reactist-avatar-meta-fill': `var(--reactist-avatar-meta-fill-${metaColorIndex})`, + } } -type AvatarSize = 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl' +function getAccessibleProps({ label, isImage }: { label: string | undefined; isImage: boolean }) { + if (isImage) { + return {} + } + + if (label === '') { + return { 'aria-hidden': true } as const + } -type Props = ObfuscatedClassName & { - /** @deprecated Please use `exceptionallySetClassName` */ - className?: string - /** @deprecated */ - colorList?: string[] - size?: ResponsiveProp - avatarUrl?: string - user: { name?: string; email: string } + if (label) { + return { role: 'img', 'aria-label': label } as const + } + + return {} } function Avatar({ - user, - avatarUrl, - size = 'l', - className, - colorList = AVATAR_COLORS, + size, + shape = 'circle', + name, + image, + alt, exceptionallySetClassName, - ...props -}: Props) { - const userInitials = getInitials(user.name) || getInitials(user.email) - const avatarSize = size ? size : 'l' - - const style = avatarUrl - ? { - backgroundImage: `url(${avatarUrl})`, - textIndent: '-9999px', // hide the initials - } - : { - backgroundColor: colorList[emailToIndex(user.email, colorList.length)], - } - - const sizeClassName = getClassNames(styles, 'size', avatarSize) + 'data-testid': testId, +}: AvatarProps) { + const [failedImage, setFailedImage] = React.useState() + + const imageFailed = failedImage === image + const resolvedImage = imageFailed ? undefined : resolveAvatarImage(image, size) + const srcSet = getAvatarImageSrcSet(image) + const initials = getInitials(name) + const label = alt ?? name + const isDecorative = label === '' + const hasFallbackInitials = !resolvedImage && initials + const isEmpty = !resolvedImage && !initials return ( - {userInitials} + {resolvedImage ? ( + {label setFailedImage(image)} + /> + ) : ( + initials + )} ) } Avatar.displayName = 'Avatar' export { Avatar } +export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } From 329ea174afc258163fb0b68a53d771413ef0693d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:20:14 -0500 Subject: [PATCH 06/41] fix: retry avatar image after prop reset --- src/avatar/avatar.test.tsx | 14 ++++++++++++++ src/avatar/avatar.tsx | 13 ++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 77fce545..e67de206 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -76,6 +76,20 @@ describe('Avatar', () => { expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') }) + it('retries a failed image when the same image is provided after being removed', () => { + const { rerender } = render() + + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + + rerender() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + + rerender() + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'missing.png') + }) + it('renders a neutral empty avatar when no name or image is provided', () => { render() diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 55625e5e..b87e50c4 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -67,9 +67,16 @@ function Avatar({ exceptionallySetClassName, 'data-testid': testId, }: AvatarProps) { - const [failedImage, setFailedImage] = React.useState() + const [imageState, setImageState] = React.useState<{ + failedImage?: AvatarImage + previousImage?: AvatarImage + }>({}) - const imageFailed = failedImage === image + if (imageState.previousImage !== image) { + setImageState({ previousImage: image }) + } + + const imageFailed = imageState.previousImage === image && imageState.failedImage === image const resolvedImage = imageFailed ? undefined : resolveAvatarImage(image, size) const srcSet = getAvatarImageSrcSet(image) const initials = getInitials(name) @@ -99,7 +106,7 @@ function Avatar({ sizes={srcSet ? `${size}px` : undefined} alt={label ?? ''} aria-hidden={isDecorative ? true : undefined} - onError={() => setFailedImage(image)} + onError={() => setImageState({ failedImage: image, previousImage: image })} /> ) : ( initials From 9e7ad43cae7438c80d6a8d9fd74a3a38edaf3a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:24:08 -0500 Subject: [PATCH 07/41] docs: update avatar stories --- stories/components/Avatar.stories.tsx | 159 ++++++++++++-------------- 1 file changed, 72 insertions(+), 87 deletions(-) diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx index 51eea3f7..27e43ce0 100644 --- a/stories/components/Avatar.stories.tsx +++ b/stories/components/Avatar.stories.tsx @@ -1,142 +1,127 @@ -import './styles/avatar_story.css' - import * as React from 'react' -import { Avatar, Box, Inline } from '../../src' +import { Avatar, Box, Inline, Stack } from '../../src' export default { title: 'Components/Avatar', component: Avatar, } -const exampleData = [ - { - size: 'xxs', - user: { name: 'Henning Mu', email: 'henning@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, - { - size: 'xs', - user: { name: 'João Va', email: 'joao@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, - { - size: 's', - user: { name: 'Amir Sa', email: 'amir@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, - { - size: 'm', - user: { name: 'Alex Mu', email: 'alex@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, - { - size: 'l', - user: { name: 'Julia', email: 'julia@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, - { - size: 'xl', - user: { name: 'Janusz Gr', email: 'janusz@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, - { - size: 'xxl', - user: { name: 'Jaime Az', email: 'jaime@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, - { - size: 'xxxl', - user: { name: 'Igor Kh', email: 'igor@foo.com' }, - image: 'https://loremflickr.com/320/320', - }, -] as const +const sizes = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const + +const sourceMap = { + 36: 'https://loremflickr.com/36/36', + 72: 'https://loremflickr.com/72/72', + 144: 'https://loremflickr.com/144/144', +} -// Story Definitions ========================================================== +function UserAvatar(props: Omit, 'shape'>) { + return +} + +function WorkspaceAvatar(props: Omit, 'shape'>) { + return +} export const InitialsAvatarStory = () => ( - {exampleData.map((data, index) => ( - + {sizes.map((size) => ( + ))} ) -export const CustomColorAvatarStory = () => ( +export const RoundedAvatarStory = () => ( - {exampleData.map((data, index) => ( - + {sizes.map((size) => ( + ))} ) export const PictureAvatarStory = () => ( - {exampleData.map((data, index) => ( - + {sizes.map((size) => ( + ))} ) +export const SourceMapAvatarStory = () => ( + + + + + +) + +export const ProductWrapperExamplesStory = () => ( + + + + + + + + +) + +export const EmptyAvatarStory = () => ( + + + + +) + export const AvatarPlaygroundStory = (args) => { return ( - + ) } AvatarPlaygroundStory.args = { - size: 'l', - avatarUrl: 'https://loremflickr.com/320/320', - userName: '', - email: '', + size: 36, + shape: 'circle', + name: 'Jane Doe', + image: 'https://loremflickr.com/144/144', + alt: undefined, } AvatarPlaygroundStory.argTypes = { size: { type: 'select', - options: ['xxs', 'xs', 's', 'm', 'l', 'xl', 'xxl', 'xxxl'], + options: sizes, }, - avatarUrl: { - control: { - type: 'text', - }, + shape: { + type: 'select', + options: ['circle', 'rounded'], }, - userName: { + name: { control: { type: 'text', }, }, - email: { + image: { control: { type: 'text', }, }, - className: { - control: { - type: null, - }, - }, - user: { + alt: { control: { - type: null, - }, - }, - colorList: { - control: { - type: null, + type: 'text', }, }, } From 1d3d1045ec96e4c660487784a04a4247f93845b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:25:42 -0500 Subject: [PATCH 08/41] docs: add avatar product wrappers --- stories/components/Avatar.stories.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx index 27e43ce0..d85cfa51 100644 --- a/stories/components/Avatar.stories.tsx +++ b/stories/components/Avatar.stories.tsx @@ -23,6 +23,14 @@ function WorkspaceAvatar(props: Omit, 'shape return } +function PersonAvatar(props: Omit, 'shape'>) { + return +} + +function PeopleAvatar(props: Omit, 'shape'>) { + return +} + export const InitialsAvatarStory = () => ( {sizes.map((size) => ( @@ -65,8 +73,8 @@ export const ProductWrapperExamplesStory = () => ( - - + + ) From f35cade5a0b0debacb6d881972a4d310f2f001cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:36:42 -0500 Subject: [PATCH 09/41] fix: preserve avatar initials and fallback examples --- src/avatar/avatar.test.tsx | 6 ++++++ src/avatar/utils.test.ts | 4 ++++ src/avatar/utils.ts | 1 + stories/components/Avatar.stories.tsx | 7 +++++++ 4 files changed, 18 insertions(+) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index e67de206..d3ae876a 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -57,6 +57,12 @@ describe('Avatar', () => { }) }) + it('falls back to initials when image source map is empty', () => { + render() + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + }) + it('falls back to initials when the image fails to load', () => { render() diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index b9be99cb..d13eec82 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -26,6 +26,10 @@ describe('Avatar utils', () => { expect(getInitials('\u{10400}eseret doe')).toBe('\u{10400}D') }) + it('preserves decomposed accented initials', () => { + expect(getInitials('e\u0301lodie brule\u0301')).toBe('ÉB') + }) + it('returns one initial when first and last initials match', () => { expect(getInitials('jane johnson')).toBe('J') }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index bc42cd65..351d9e78 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -35,6 +35,7 @@ function getFirstCodePoint(value?: string) { function getInitials(name?: string) { const words = normalizeAvatarName(name) + .normalize('NFC') .replace(FILTER_CHARS_REGEXP, '') .split(' ') .filter(Boolean) diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx index d85cfa51..ebc97a30 100644 --- a/stories/components/Avatar.stories.tsx +++ b/stories/components/Avatar.stories.tsx @@ -68,6 +68,13 @@ export const SourceMapAvatarStory = () => ( ) +export const FailedImageFallbackStory = () => ( + + + + +) + export const ProductWrapperExamplesStory = () => ( From 9b66cd76cd493ebd2be6461b84cccd541dcf7b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 12:41:35 -0500 Subject: [PATCH 10/41] fix: cap avatar initials after uppercase expansion --- src/avatar/utils.test.ts | 4 ++++ src/avatar/utils.ts | 14 +++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index d13eec82..24a36974 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -30,6 +30,10 @@ describe('Avatar utils', () => { expect(getInitials('e\u0301lodie brule\u0301')).toBe('ÉB') }) + it('limits uppercase-expanding initials to one character per word', () => { + expect(getInitials('ßmith Müller')).toBe('SM') + }) + it('returns one initial when first and last initials match', () => { expect(getInitials('jane johnson')).toBe('J') }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index 351d9e78..ea4b2b7c 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -33,6 +33,14 @@ function getFirstCodePoint(value?: string) { return firstCodePoint } +function getInitial(value?: string) { + return getFirstCodePoint(getFirstCodePoint(value).toUpperCase()) +} + +function limitInitials(value: string) { + return Array.from(value).slice(0, 2).join('') +} + function getInitials(name?: string) { const words = normalizeAvatarName(name) .normalize('NFC') @@ -42,15 +50,15 @@ function getInitials(name?: string) { const firstWord = words[0] const lastWord = words[words.length - 1] - const firstInitial = getFirstCodePoint(firstWord).toUpperCase() - const lastInitial = getFirstCodePoint(lastWord).toUpperCase() + const firstInitial = getInitial(firstWord) + const lastInitial = getInitial(lastWord) if (!firstInitial) { return '' } if (lastInitial && firstInitial !== lastInitial) { - return `${firstInitial}${lastInitial}` + return limitInitials(`${firstInitial}${lastInitial}`) } return firstInitial From 589a075019f4ae04b663652ebf2a871513acd59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 13:50:01 -0500 Subject: [PATCH 11/41] fix: address avatar review findings --- src/avatar/avatar.test.tsx | 18 ++++++++++++++++ src/avatar/avatar.tsx | 31 ++++++++++++++++----------- src/avatar/utils.test.ts | 6 ++++++ src/avatar/utils.ts | 3 +-- stories/components/Avatar.stories.tsx | 12 ++++++++++- 5 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index d3ae876a..e2c7b7dd 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -82,6 +82,24 @@ describe('Avatar', () => { expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') }) + it('allows a source-map image to load after size changes away from a failed source', () => { + const image = { + 36: 'missing-36.png', + 72: 'avatar-72.png', + } + const { rerender } = render() + + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + + rerender() + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute( + 'src', + 'avatar-72.png', + ) + }) + it('retries a failed image when the same image is provided after being removed', () => { const { rerender } = render() diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index b87e50c4..e3efe86d 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -68,22 +68,24 @@ function Avatar({ 'data-testid': testId, }: AvatarProps) { const [imageState, setImageState] = React.useState<{ - failedImage?: AvatarImage - previousImage?: AvatarImage + failedSrc?: string + previousResolvedImage?: string }>({}) - if (imageState.previousImage !== image) { - setImageState({ previousImage: image }) + const resolvedImage = resolveAvatarImage(image, size) + if (imageState.previousResolvedImage !== resolvedImage) { + setImageState({ previousResolvedImage: resolvedImage }) } - const imageFailed = imageState.previousImage === image && imageState.failedImage === image - const resolvedImage = imageFailed ? undefined : resolveAvatarImage(image, size) + const imageFailed = + imageState.previousResolvedImage === resolvedImage && imageState.failedSrc === resolvedImage + const visibleImage = imageFailed ? undefined : resolvedImage const srcSet = getAvatarImageSrcSet(image) const initials = getInitials(name) const label = alt ?? name const isDecorative = label === '' - const hasFallbackInitials = !resolvedImage && initials - const isEmpty = !resolvedImage && !initials + const hasFallbackInitials = !visibleImage && initials + const isEmpty = !visibleImage && !initials return ( - {resolvedImage ? ( + {visibleImage ? ( {label setImageState({ failedImage: image, previousImage: image })} + onError={() => + setImageState({ + failedSrc: visibleImage, + previousResolvedImage: resolvedImage, + }) + } /> ) : ( initials diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 24a36974..ea4a20e4 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -150,6 +150,12 @@ describe('Avatar utils', () => { expect(getAvatarMetaColorIndex('John Doe')).toBe(9) }) + it('uses the same index for canonically equivalent Unicode names', () => { + expect(getAvatarMetaColorIndex('Élodie Brulé')).toBe( + getAvatarMetaColorIndex('E\u0301lodie Brule\u0301'), + ) + }) + it('always returns an index in the configured fixed slot range', () => { const index = getAvatarMetaColorIndex('Francesca Ciao') diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index ea4b2b7c..d574e155 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -25,7 +25,7 @@ const ROUNDED_AVATAR_RADIUS_BY_SIZE: Record = { const FILTER_CHARS_REGEXP = new RegExp('[^\\p{L}\\p{M}\\p{Zs} ]', 'gu') function normalizeAvatarName(name?: string) { - return name?.trim().replace(/\s+/g, ' ') ?? '' + return name?.normalize('NFC').trim().replace(/\s+/g, ' ') ?? '' } function getFirstCodePoint(value?: string) { @@ -43,7 +43,6 @@ function limitInitials(value: string) { function getInitials(name?: string) { const words = normalizeAvatarName(name) - .normalize('NFC') .replace(FILTER_CHARS_REGEXP, '') .split(' ') .filter(Boolean) diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx index ebc97a30..096ba293 100644 --- a/stories/components/Avatar.stories.tsx +++ b/stories/components/Avatar.stories.tsx @@ -15,6 +15,8 @@ const sourceMap = { 144: 'https://loremflickr.com/144/144', } +const metaColorNames = ['Ada Lovelace', 'Grace Hopper', 'Mary Jackson', 'Katherine Johnson'] + function UserAvatar(props: Omit, 'shape'>) { return } @@ -39,6 +41,14 @@ export const InitialsAvatarStory = () => ( ) +export const MetaColorAvatarStory = () => ( + + {metaColorNames.map((name) => ( + + ))} + +) + export const RoundedAvatarStory = () => ( {sizes.map((size) => ( @@ -77,7 +87,7 @@ export const FailedImageFallbackStory = () => ( export const ProductWrapperExamplesStory = () => ( - + From d7458587e2191d88d5908e5f3fdb89c64076e0a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 14:26:39 -0500 Subject: [PATCH 12/41] fix: type avatar story playground args --- stories/components/Avatar.stories.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx index 096ba293..51822381 100644 --- a/stories/components/Avatar.stories.tsx +++ b/stories/components/Avatar.stories.tsx @@ -33,6 +33,10 @@ function PeopleAvatar(props: Omit, 'shape'>) return } +type AvatarPlaygroundStoryArgs = Omit, 'image'> & { + image?: string +} + export const InitialsAvatarStory = () => ( {sizes.map((size) => ( @@ -103,7 +107,7 @@ export const EmptyAvatarStory = () => ( ) -export const AvatarPlaygroundStory = (args) => { +export const AvatarPlaygroundStory = (args: AvatarPlaygroundStoryArgs) => { return ( Date: Tue, 26 May 2026 14:33:02 -0500 Subject: [PATCH 13/41] refactor: inline avatar accessibility props --- src/avatar/avatar.tsx | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index e3efe86d..1320e48a 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -42,22 +42,6 @@ function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { } } -function getAccessibleProps({ label, isImage }: { label: string | undefined; isImage: boolean }) { - if (isImage) { - return {} - } - - if (label === '') { - return { 'aria-hidden': true } as const - } - - if (label) { - return { role: 'img', 'aria-label': label } as const - } - - return {} -} - function Avatar({ size, shape = 'circle', @@ -98,7 +82,9 @@ function Avatar({ )} style={getAvatarStyle(size, name)} data-testid={testId} - {...getAccessibleProps({ label, isImage: Boolean(visibleImage) })} + role={!visibleImage && label ? 'img' : undefined} + aria-label={!visibleImage && label ? label : undefined} + aria-hidden={!visibleImage && isDecorative ? true : undefined} > {visibleImage ? ( Date: Tue, 26 May 2026 14:42:19 -0500 Subject: [PATCH 14/41] refactor: rely on native avatar srcset selection --- src/avatar/avatar.test.tsx | 16 ++------- src/avatar/avatar.tsx | 74 +++++++++++++++++++++++--------------- src/avatar/utils.test.ts | 61 +++++++++---------------------- src/avatar/utils.ts | 37 ++++++++----------- 4 files changed, 77 insertions(+), 111 deletions(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index e2c7b7dd..ff02279a 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -5,13 +5,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { Avatar } from './avatar' describe('Avatar', () => { - afterEach(() => { - Object.defineProperty(window, 'devicePixelRatio', { - configurable: true, - value: 1, - }) - }) - it('renders a string image URL', () => { render() @@ -21,12 +14,7 @@ describe('Avatar', () => { }) }) - it('renders a source-map image URL selected for pixel density', () => { - Object.defineProperty(window, 'devicePixelRatio', { - configurable: true, - value: 2, - }) - + it('renders a source-map image URL with native responsive image hints', () => { render( { ) const image = screen.getByRole('img', { name: 'Jane Doe' }) - expect(image).toHaveAttribute('src', 'avatar-72.png') + expect(image).toHaveAttribute('src', 'avatar-144.png') expect(image).toHaveAttribute( 'srcset', 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w', diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 1320e48a..0aeb704b 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -5,17 +5,16 @@ import classNames from 'classnames' import { Box } from '../box' import { - getAvatarImageSrcSet, + getAvatarImageProps, getAvatarMetaColorIndex, getInitials, - resolveAvatarImage, ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' import styles from './avatar.module.css' import type { ObfuscatedClassName } from '../utils/common-types' -import type { AvatarImage, AvatarShape, AvatarSize } from './utils' +import type { AvatarImage, AvatarImageProps, AvatarShape, AvatarSize } from './utils' type AvatarStyle = React.CSSProperties & { '--reactist-avatar-size': string @@ -32,6 +31,15 @@ type AvatarProps = ObfuscatedClassName & { 'data-testid'?: string } +type AvatarContentProps = ObfuscatedClassName & { + size: AvatarSize + shape: AvatarShape + name?: string + imageProps?: AvatarImageProps + alt?: string + 'data-testid'?: string +} + function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { const metaColorIndex = getAvatarMetaColorIndex(name) @@ -42,29 +50,26 @@ function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { } } -function Avatar({ +function getAvatarImageKey(imageProps?: AvatarImageProps) { + if (!imageProps) { + return 'fallback' + } + + return [imageProps.src, imageProps.srcSet, imageProps.sizes].filter(Boolean).join('|') +} + +function AvatarContent({ size, - shape = 'circle', + shape, name, - image, + imageProps, alt, exceptionallySetClassName, 'data-testid': testId, -}: AvatarProps) { - const [imageState, setImageState] = React.useState<{ - failedSrc?: string - previousResolvedImage?: string - }>({}) - - const resolvedImage = resolveAvatarImage(image, size) - if (imageState.previousResolvedImage !== resolvedImage) { - setImageState({ previousResolvedImage: resolvedImage }) - } +}: AvatarContentProps) { + const [imageFailed, setImageFailed] = React.useState(false) - const imageFailed = - imageState.previousResolvedImage === resolvedImage && imageState.failedSrc === resolvedImage - const visibleImage = imageFailed ? undefined : resolvedImage - const srcSet = getAvatarImageSrcSet(image) + const visibleImage = imageFailed ? undefined : imageProps const initials = getInitials(name) const label = alt ?? name const isDecorative = label === '' @@ -89,17 +94,12 @@ function Avatar({ {visibleImage ? ( {label - setImageState({ - failedSrc: visibleImage, - previousResolvedImage: resolvedImage, - }) - } + onError={() => setImageFailed(true)} /> ) : ( initials @@ -107,6 +107,22 @@ function Avatar({ ) } + +function Avatar({ size, shape = 'circle', name, image, alt, ...props }: AvatarProps) { + const imageProps = getAvatarImageProps(image, size) + + return ( + + ) +} Avatar.displayName = 'Avatar' export { Avatar } diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index ea4a20e4..2850d3ec 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -1,10 +1,9 @@ import { AVATAR_META_COLOR_COUNT, - getAvatarImageSrcSet, + getAvatarImageProps, getAvatarMetaColorIndex, getInitials, normalizeAvatarName, - resolveAvatarImage, ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' @@ -69,7 +68,7 @@ describe('Avatar utils', () => { }) }) - describe('resolveAvatarImage', () => { + describe('getAvatarImageProps', () => { const imageMap = { 36: 'avatar-36.png', 72: 'avatar-72.png', @@ -77,28 +76,24 @@ describe('Avatar utils', () => { } it('returns a string image directly', () => { - expect(resolveAvatarImage('avatar.png', 36, 2)).toBe('avatar.png') + expect(getAvatarImageProps('avatar.png', 36)).toEqual({ src: 'avatar.png' }) }) - it('chooses the smallest source at or above the target pixel size', () => { - expect(resolveAvatarImage(imageMap, 36, 2)).toBe('avatar-72.png') - }) - - it('uses the largest source when every source is smaller than the target', () => { - expect(resolveAvatarImage(imageMap, 80, 2)).toBe('avatar-144.png') - }) - - it('uses the smallest valid source for low pixel densities', () => { - expect(resolveAvatarImage(imageMap, 24, 1)).toBe('avatar-36.png') + it('uses the largest valid source as the fallback src for source maps', () => { + expect(getAvatarImageProps(imageMap, 36)).toEqual({ + src: 'avatar-144.png', + srcSet: 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w', + sizes: '36px', + }) }) it('returns undefined for an empty source map', () => { - expect(resolveAvatarImage({}, 36, 2)).toBeUndefined() + expect(getAvatarImageProps({}, 36)).toBeUndefined() }) it('ignores invalid source entries', () => { expect( - resolveAvatarImage( + getAvatarImageProps( { '-10': 'avatar-negative.png', 0: 'avatar-zero.png', @@ -106,36 +101,12 @@ describe('Avatar utils', () => { 72: 'avatar-72.png', } as Record, 36, - 1, ), - ).toBe('avatar-72.png') - }) - }) - - describe('getAvatarImageSrcSet', () => { - it('formats source maps as sorted width descriptors', () => { - expect( - getAvatarImageSrcSet({ - 144: 'avatar-144.png', - 36: 'avatar-36.png', - 72: 'avatar-72.png', - }), - ).toBe('avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w') - }) - - it('returns undefined for string images', () => { - expect(getAvatarImageSrcSet('avatar.png')).toBeUndefined() - }) - - it('ignores invalid source entries', () => { - expect( - getAvatarImageSrcSet({ - '-10': 'avatar-negative.png', - 0: 'avatar-zero.png', - 36: '', - 72: 'avatar-72.png', - } as Record), - ).toBe('avatar-72.png 72w') + ).toEqual({ + src: 'avatar-72.png', + srcSet: 'avatar-72.png 72w', + sizes: '36px', + }) }) }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index d574e155..ccb21f0e 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -3,6 +3,11 @@ const AVATAR_SIZES = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as con type AvatarSize = (typeof AVATAR_SIZES)[number] type AvatarShape = 'circle' | 'rounded' type AvatarImage = string | Record +type AvatarImageProps = { + src: string + srcSet?: string + sizes?: string +} const AVATAR_META_COLOR_COUNT = 20 @@ -70,17 +75,16 @@ function getSortedImageSources(image: Record) { .sort((a, b) => a.sourceSize - b.sourceSize) } -function resolveAvatarImage( +function getAvatarImageProps( image: AvatarImage | undefined, size: AvatarSize, - pixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1, -) { +): AvatarImageProps | undefined { if (!image) { return undefined } if (typeof image === 'string') { - return image + return { src: image } } const sources = getSortedImageSources(image) @@ -88,23 +92,11 @@ function resolveAvatarImage( return undefined } - const targetPixels = size * pixelRatio - return ( - sources.find(({ sourceSize }) => sourceSize >= targetPixels) ?? sources[sources.length - 1]! - ).src -} - -function getAvatarImageSrcSet(image: AvatarImage | undefined) { - if (!image || typeof image === 'string') { - return undefined - } - - const sources = getSortedImageSources(image) - if (sources.length === 0) { - return undefined + return { + src: sources[sources.length - 1]!.src, + srcSet: sources.map(({ sourceSize, src }) => `${src} ${sourceSize}w`).join(', '), + sizes: `${size}px`, } - - return sources.map(({ sourceSize, src }) => `${src} ${sourceSize}w`).join(', ') } function getAvatarMetaColorIndex(name?: string) { @@ -121,11 +113,10 @@ function getAvatarMetaColorIndex(name?: string) { export { AVATAR_META_COLOR_COUNT, AVATAR_SIZES, - getAvatarImageSrcSet, + getAvatarImageProps, getAvatarMetaColorIndex, getInitials, normalizeAvatarName, - resolveAvatarImage, ROUNDED_AVATAR_RADIUS_BY_SIZE, } -export type { AvatarImage, AvatarShape, AvatarSize } +export type { AvatarImage, AvatarImageProps, AvatarShape, AvatarSize } From ec3dd2a1657a6baff5de30d7fc37dbcb490aa161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 14:46:52 -0500 Subject: [PATCH 15/41] fix: retry avatar srcset candidates after load failure --- src/avatar/avatar.test.tsx | 83 ++++++++++++++++++++++++++++++++++---- src/avatar/avatar.tsx | 38 +++++++++++++++-- src/avatar/utils.test.ts | 36 +++++++++++++++++ src/avatar/utils.ts | 46 +++++++++++++++++---- 4 files changed, 185 insertions(+), 18 deletions(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index ff02279a..80f7cc9b 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -5,6 +5,15 @@ import { fireEvent, render, screen } from '@testing-library/react' import { Avatar } from './avatar' describe('Avatar', () => { + function failCurrentAvatarImage(currentSrc: string) { + const image = screen.getByRole('img', { name: 'Jane Doe' }) + Object.defineProperty(image, 'currentSrc', { + configurable: true, + value: currentSrc, + }) + fireEvent.error(image) + } + it('renders a string image URL', () => { render() @@ -70,22 +79,82 @@ describe('Avatar', () => { expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') }) - it('allows a source-map image to load after size changes away from a failed source', () => { + it('removes a failed source-map candidate and retries with the remaining candidates', () => { + render( + , + ) + + failCurrentAvatarImage('avatar-144.png') + + const image = screen.getByRole('img', { name: 'Jane Doe' }) + expect(image).toHaveAttribute('src', 'avatar-72.png') + expect(image).toHaveAttribute('srcset', 'avatar-36.png 36w, avatar-72.png 72w') + expect(image).toHaveAttribute('sizes', '36px') + }) + + it('removes the selected source-map candidate when it is not the fallback src', () => { + render( + , + ) + + failCurrentAvatarImage(new URL('avatar-72.png', document.baseURI).href) + + const image = screen.getByRole('img', { name: 'Jane Doe' }) + expect(image).toHaveAttribute('src', 'avatar-144.png') + expect(image).toHaveAttribute('srcset', 'avatar-36.png 36w, avatar-144.png 144w') + expect(image).toHaveAttribute('sizes', '36px') + }) + + it('keeps filtered source-map candidates when only the avatar size changes', () => { const image = { - 36: 'missing-36.png', + 36: 'avatar-36.png', 72: 'avatar-72.png', + 144: 'avatar-144.png', } const { rerender } = render() - fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) - expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + failCurrentAvatarImage('avatar-144.png') rerender() - expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute( - 'src', - 'avatar-72.png', + const retriedImage = screen.getByRole('img', { name: 'Jane Doe' }) + expect(retriedImage).toHaveAttribute('src', 'avatar-72.png') + expect(retriedImage).toHaveAttribute('srcset', 'avatar-36.png 36w, avatar-72.png 72w') + expect(retriedImage).toHaveAttribute('sizes', '72px') + }) + + it('falls back to initials when every source-map candidate fails', () => { + render( + , ) + + failCurrentAvatarImage('avatar-72.png') + failCurrentAvatarImage('avatar-36.png') + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') }) it('retries a failed image when the same image is provided after being removed', () => { diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 0aeb704b..817da3a1 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -5,6 +5,7 @@ import classNames from 'classnames' import { Box } from '../box' import { + getAvailableAvatarImageProps, getAvatarImageProps, getAvatarMetaColorIndex, getInitials, @@ -55,7 +56,28 @@ function getAvatarImageKey(imageProps?: AvatarImageProps) { return 'fallback' } - return [imageProps.src, imageProps.srcSet, imageProps.sizes].filter(Boolean).join('|') + if (imageProps.sources) { + return imageProps.sources.map(({ sourceSize, src }) => `${sourceSize}:${src}`).join('|') + } + + return imageProps.src +} + +function getAbsoluteImageSource(src: string, image: HTMLImageElement) { + try { + return new URL(src, image.ownerDocument.baseURI).href + } catch { + return src + } +} + +function getFailedImageSource(imageProps: AvatarImageProps, image: HTMLImageElement) { + const failedSrc = image.currentSrc || image.src || imageProps.src + const matchingSource = imageProps.sources?.find( + ({ src }) => src === failedSrc || getAbsoluteImageSource(src, image) === failedSrc, + ) + + return matchingSource?.src ?? imageProps.src } function AvatarContent({ @@ -67,9 +89,9 @@ function AvatarContent({ exceptionallySetClassName, 'data-testid': testId, }: AvatarContentProps) { - const [imageFailed, setImageFailed] = React.useState(false) + const [failedImageSources, setFailedImageSources] = React.useState([]) - const visibleImage = imageFailed ? undefined : imageProps + const visibleImage = getAvailableAvatarImageProps(imageProps, failedImageSources) const initials = getInitials(name) const label = alt ?? name const isDecorative = label === '' @@ -99,7 +121,15 @@ function AvatarContent({ sizes={visibleImage.sizes} alt={label ?? ''} aria-hidden={isDecorative ? true : undefined} - onError={() => setImageFailed(true)} + onError={(event) => { + const failedSource = getFailedImageSource(visibleImage, event.currentTarget) + + setFailedImageSources((currentFailedSources) => + currentFailedSources.includes(failedSource) + ? currentFailedSources + : [...currentFailedSources, failedSource], + ) + }} /> ) : ( initials diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 2850d3ec..55210f67 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -1,5 +1,6 @@ import { AVATAR_META_COLOR_COUNT, + getAvailableAvatarImageProps, getAvatarImageProps, getAvatarMetaColorIndex, getInitials, @@ -84,6 +85,11 @@ describe('Avatar utils', () => { src: 'avatar-144.png', srcSet: 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w', sizes: '36px', + sources: [ + { sourceSize: 36, src: 'avatar-36.png' }, + { sourceSize: 72, src: 'avatar-72.png' }, + { sourceSize: 144, src: 'avatar-144.png' }, + ], }) }) @@ -106,10 +112,40 @@ describe('Avatar utils', () => { src: 'avatar-72.png', srcSet: 'avatar-72.png 72w', sizes: '36px', + sources: [{ sourceSize: 72, src: 'avatar-72.png' }], }) }) }) + describe('getAvailableAvatarImageProps', () => { + it('removes failed source-map candidates and recomputes the fallback src', () => { + const imageProps = getAvatarImageProps( + { + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + }, + 36, + ) + + expect(getAvailableAvatarImageProps(imageProps, ['avatar-144.png'])).toEqual({ + src: 'avatar-72.png', + srcSet: 'avatar-36.png 36w, avatar-72.png 72w', + sizes: '36px', + sources: [ + { sourceSize: 36, src: 'avatar-36.png' }, + { sourceSize: 72, src: 'avatar-72.png' }, + ], + }) + }) + + it('returns undefined when a string image has failed', () => { + expect( + getAvailableAvatarImageProps({ src: 'avatar.png' }, ['avatar.png']), + ).toBeUndefined() + }) + }) + describe('getAvatarMetaColorIndex', () => { it('uses 20 fixed meta color slots', () => { expect(AVATAR_META_COLOR_COUNT).toBe(20) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index ccb21f0e..f379591c 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -3,10 +3,15 @@ const AVATAR_SIZES = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as con type AvatarSize = (typeof AVATAR_SIZES)[number] type AvatarShape = 'circle' | 'rounded' type AvatarImage = string | Record +type AvatarImageSource = { + sourceSize: number + src: string +} type AvatarImageProps = { src: string srcSet?: string sizes?: string + sources?: AvatarImageSource[] } const AVATAR_META_COLOR_COUNT = 20 @@ -68,13 +73,29 @@ function getInitials(name?: string) { return firstInitial } -function getSortedImageSources(image: Record) { +function getSortedImageSources(image: Record): AvatarImageSource[] { return Object.entries(image) .map(([sourceSize, src]) => ({ sourceSize: Number(sourceSize), src })) .filter(({ sourceSize, src }) => Number.isFinite(sourceSize) && sourceSize > 0 && src) .sort((a, b) => a.sourceSize - b.sourceSize) } +function getImagePropsFromSources( + sources: AvatarImageSource[], + sizes?: string, +): AvatarImageProps | undefined { + if (sources.length === 0) { + return undefined + } + + return { + src: sources[sources.length - 1]!.src, + srcSet: sources.map(({ sourceSize, src }) => `${src} ${sourceSize}w`).join(', '), + sizes, + sources, + } +} + function getAvatarImageProps( image: AvatarImage | undefined, size: AvatarSize, @@ -88,15 +109,25 @@ function getAvatarImageProps( } const sources = getSortedImageSources(image) - if (sources.length === 0) { + return getImagePropsFromSources(sources, `${size}px`) +} + +function getAvailableAvatarImageProps( + imageProps: AvatarImageProps | undefined, + failedSources: readonly string[], +) { + if (!imageProps) { return undefined } - return { - src: sources[sources.length - 1]!.src, - srcSet: sources.map(({ sourceSize, src }) => `${src} ${sourceSize}w`).join(', '), - sizes: `${size}px`, + if (!imageProps.sources) { + return failedSources.includes(imageProps.src) ? undefined : imageProps } + + return getImagePropsFromSources( + imageProps.sources.filter(({ src }) => !failedSources.includes(src)), + imageProps.sizes, + ) } function getAvatarMetaColorIndex(name?: string) { @@ -113,10 +144,11 @@ function getAvatarMetaColorIndex(name?: string) { export { AVATAR_META_COLOR_COUNT, AVATAR_SIZES, + getAvailableAvatarImageProps, getAvatarImageProps, getAvatarMetaColorIndex, getInitials, normalizeAvatarName, ROUNDED_AVATAR_RADIUS_BY_SIZE, } -export type { AvatarImage, AvatarImageProps, AvatarShape, AvatarSize } +export type { AvatarImage, AvatarImageProps, AvatarImageSource, AvatarShape, AvatarSize } From ad8a98d17241cddb949ee6f1dc543acc5b578810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 15:36:02 -0500 Subject: [PATCH 16/41] refactor: simplify avatar initials generation --- src/avatar/utils.test.ts | 24 +++++++++++++++-------- src/avatar/utils.ts | 41 ++++++++++++++++++---------------------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 55210f67..0b9d947f 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -18,8 +18,8 @@ describe('Avatar utils', () => { expect(getInitials('jane middle doe')).toBe('JD') }) - it('returns first initial for a single name', () => { - expect(getInitials('jane')).toBe('J') + it('returns the first two grapheme clusters for a single name part', () => { + expect(getInitials('jane')).toBe('JA') }) it('preserves non-BMP Unicode letter initials', () => { @@ -30,20 +30,28 @@ describe('Avatar utils', () => { expect(getInitials('e\u0301lodie brule\u0301')).toBe('ÉB') }) + it('preserves grapheme clusters that contain combining marks', () => { + expect(getInitials('q\u0307bert q\u0307uill')).toBe('Q\u0307Q\u0307') + }) + it('limits uppercase-expanding initials to one character per word', () => { expect(getInitials('ßmith Müller')).toBe('SM') }) - it('returns one initial when first and last initials match', () => { - expect(getInitials('jane johnson')).toBe('J') + it('uppercases the whole name part before taking grapheme clusters', () => { + expect(getInitials('ßeta')).toBe('SS') + }) + + it('keeps matching first and last initials for multiple name parts', () => { + expect(getInitials('jane johnson')).toBe('JJ') }) - it('returns one initial when first and last initials match after uppercasing', () => { - expect(getInitials('Jane johnson')).toBe('J') + it('splits name parts by Unicode whitespace', () => { + expect(getInitials('Jane\u2003Doe')).toBe('JD') }) - it('filters non-letter characters before creating initials', () => { - expect(getInitials('🍕 Francesca 🍕 Ciao 🍕')).toBe('FC') + it('does not filter non-letter grapheme clusters from selected name parts', () => { + expect(getInitials('🍕 Francesca 🍕 Ciao 🍕')).toBe('🍕🍕') }) it('returns an empty string for an empty name', () => { diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index f379591c..57f0fb36 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -32,45 +32,40 @@ const ROUNDED_AVATAR_RADIUS_BY_SIZE: Record = { 12: '1.6px', } -const FILTER_CHARS_REGEXP = new RegExp('[^\\p{L}\\p{M}\\p{Zs} ]', 'gu') +const WHITESPACE_REGEXP = new RegExp('\\p{White_Space}+', 'gu') +const GRAPHEME_SEGMENTER = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? new Intl.Segmenter('und', { granularity: 'grapheme' }) + : undefined function normalizeAvatarName(name?: string) { - return name?.normalize('NFC').trim().replace(/\s+/g, ' ') ?? '' + return name?.normalize('NFC').trim().replace(WHITESPACE_REGEXP, ' ') ?? '' } -function getFirstCodePoint(value?: string) { - const [firstCodePoint = ''] = Array.from(value ?? '') - return firstCodePoint -} +function getGraphemeClusters(value: string) { + if (GRAPHEME_SEGMENTER) { + return Array.from(GRAPHEME_SEGMENTER.segment(value), ({ segment }) => segment) + } -function getInitial(value?: string) { - return getFirstCodePoint(getFirstCodePoint(value).toUpperCase()) + return Array.from(value) } -function limitInitials(value: string) { - return Array.from(value).slice(0, 2).join('') +function getInitialGrapheme(value?: string) { + return getGraphemeClusters(value?.toUpperCase() ?? '')[0] ?? '' } function getInitials(name?: string) { - const words = normalizeAvatarName(name) - .replace(FILTER_CHARS_REGEXP, '') - .split(' ') - .filter(Boolean) - - const firstWord = words[0] - const lastWord = words[words.length - 1] - const firstInitial = getInitial(firstWord) - const lastInitial = getInitial(lastWord) + const nameParts = normalizeAvatarName(name).split(WHITESPACE_REGEXP).filter(Boolean) - if (!firstInitial) { + if (nameParts.length === 0) { return '' } - if (lastInitial && firstInitial !== lastInitial) { - return limitInitials(`${firstInitial}${lastInitial}`) + if (nameParts.length === 1) { + return getGraphemeClusters(nameParts[0]!.toUpperCase()).slice(0, 2).join('') } - return firstInitial + return `${getInitialGrapheme(nameParts[0])}${getInitialGrapheme(nameParts[nameParts.length - 1])}` } function getSortedImageSources(image: Record): AvatarImageSource[] { From 5da1d9b497b5ed3e7e4c267e04025030370bf991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 15:40:23 -0500 Subject: [PATCH 17/41] docs: document avatar public api --- src/avatar/avatar.tsx | 42 ++++++++++++++++++++++++++++++++++++++++++ src/avatar/utils.ts | 14 ++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 817da3a1..10b90722 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -23,12 +23,51 @@ type AvatarStyle = React.CSSProperties & { '--reactist-avatar-meta-fill': string } +/** + * Props for the `Avatar` component. + */ type AvatarProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + */ size: AvatarSize + + /** + * The avatar shape. + * + * Use `circle` for user avatars and `rounded` for workspace or object avatars. + * + * @default 'circle' + */ shape?: AvatarShape + + /** + * The display name represented by the avatar. + * + * Used as the default accessible label, to generate fallback initials, and to assign the + * deterministic fallback meta color. + */ name?: string + + /** + * The avatar image. + * + * Pass a string for a single image URL, or a source map keyed by intrinsic image width. Source + * maps render as native `srcSet`/`sizes` hints, with the largest valid source used as the + * fallback `src`. + */ image?: AvatarImage + + /** + * Accessible text for the avatar image. + * + * Defaults to `name`. Pass an empty string when the avatar is decorative. + */ alt?: string + + /** + * Test identifier applied to the avatar root element. + */ 'data-testid'?: string } @@ -138,6 +177,9 @@ function AvatarContent({ ) } +/** + * Displays an avatar from an image URL or deterministic initials fallback. + */ function Avatar({ size, shape = 'circle', name, image, alt, ...props }: AvatarProps) { const imageProps = getAvatarImageProps(image, size) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index 57f0fb36..b39509bc 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -1,7 +1,21 @@ const AVATAR_SIZES = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const +/** + * Supported avatar sizes, in CSS pixels. + */ type AvatarSize = (typeof AVATAR_SIZES)[number] + +/** + * Supported avatar clipping shapes. + */ type AvatarShape = 'circle' | 'rounded' + +/** + * Avatar image source. + * + * Use a string for a single image URL, or a source map keyed by intrinsic image width. Source maps + * are converted to native `srcSet` width descriptors. + */ type AvatarImage = string | Record type AvatarImageSource = { sourceSize: number From 8a86a0380cda83f4f9a01ed8fe597e551e79aeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 15:46:47 -0500 Subject: [PATCH 18/41] refactor: clarify avatar image identity key --- src/avatar/avatar.tsx | 53 ++++++++++++++++------------------------ src/avatar/utils.test.ts | 28 +++++++++++++++++++++ src/avatar/utils.ts | 18 ++++++++++++++ 3 files changed, 67 insertions(+), 32 deletions(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 10b90722..3d4d9196 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -6,6 +6,7 @@ import { Box } from '../box' import { getAvailableAvatarImageProps, + getAvatarImageIdentityKey, getAvatarImageProps, getAvatarMetaColorIndex, getInitials, @@ -71,6 +72,26 @@ type AvatarProps = ObfuscatedClassName & { 'data-testid'?: string } +/** + * Displays an avatar from an image URL or deterministic initials fallback. + */ +function Avatar({ size, shape = 'circle', name, image, alt, ...props }: AvatarProps) { + const imageProps = getAvatarImageProps(image, size) + + return ( + + ) +} +Avatar.displayName = 'Avatar' + type AvatarContentProps = ObfuscatedClassName & { size: AvatarSize shape: AvatarShape @@ -90,18 +111,6 @@ function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { } } -function getAvatarImageKey(imageProps?: AvatarImageProps) { - if (!imageProps) { - return 'fallback' - } - - if (imageProps.sources) { - return imageProps.sources.map(({ sourceSize, src }) => `${sourceSize}:${src}`).join('|') - } - - return imageProps.src -} - function getAbsoluteImageSource(src: string, image: HTMLImageElement) { try { return new URL(src, image.ownerDocument.baseURI).href @@ -177,25 +186,5 @@ function AvatarContent({ ) } -/** - * Displays an avatar from an image URL or deterministic initials fallback. - */ -function Avatar({ size, shape = 'circle', name, image, alt, ...props }: AvatarProps) { - const imageProps = getAvatarImageProps(image, size) - - return ( - - ) -} -Avatar.displayName = 'Avatar' - export { Avatar } export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 0b9d947f..0061512c 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -1,6 +1,7 @@ import { AVATAR_META_COLOR_COUNT, getAvailableAvatarImageProps, + getAvatarImageIdentityKey, getAvatarImageProps, getAvatarMetaColorIndex, getInitials, @@ -125,6 +126,33 @@ describe('Avatar utils', () => { }) }) + describe('getAvatarImageIdentityKey', () => { + it('returns the string image as its identity', () => { + expect(getAvatarImageIdentityKey('avatar.png')).toBe('avatar.png') + }) + + it('returns a stable identity for source maps independent of entry order', () => { + expect( + getAvatarImageIdentityKey({ + 144: 'avatar-144.png', + 36: 'avatar-36.png', + 72: 'avatar-72.png', + }), + ).toBe('36:avatar-36.png|72:avatar-72.png|144:avatar-144.png') + }) + + it('uses fallback identity when no valid image source exists', () => { + expect(getAvatarImageIdentityKey()).toBe('fallback') + expect(getAvatarImageIdentityKey({})).toBe('fallback') + expect( + getAvatarImageIdentityKey({ + 0: 'avatar-zero.png', + 36: '', + }), + ).toBe('fallback') + }) + }) + describe('getAvailableAvatarImageProps', () => { it('removes failed source-map candidates and recomputes the fallback src', () => { const imageProps = getAvatarImageProps( diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index b39509bc..7afca384 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -121,6 +121,23 @@ function getAvatarImageProps( return getImagePropsFromSources(sources, `${size}px`) } +function getAvatarImageIdentityKey(image?: AvatarImage) { + if (!image) { + return 'fallback' + } + + if (typeof image === 'string') { + return image + } + + const sources = getSortedImageSources(image) + if (sources.length === 0) { + return 'fallback' + } + + return sources.map(({ sourceSize, src }) => `${sourceSize}:${src}`).join('|') +} + function getAvailableAvatarImageProps( imageProps: AvatarImageProps | undefined, failedSources: readonly string[], @@ -154,6 +171,7 @@ export { AVATAR_META_COLOR_COUNT, AVATAR_SIZES, getAvailableAvatarImageProps, + getAvatarImageIdentityKey, getAvatarImageProps, getAvatarMetaColorIndex, getInitials, From fa0b235eea95058c63d69b09ad2f34c0eb10765e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 16:13:38 -0500 Subject: [PATCH 19/41] refactor: Clean up and clarify --- src/avatar/avatar.module.css | 67 ++++++++++------- src/avatar/avatar.tsx | 138 ++++++++++++++++------------------- src/avatar/utils.test.ts | 20 +++-- src/avatar/utils.ts | 19 ++--- 4 files changed, 123 insertions(+), 121 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 84437e03..9cce296a 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -2,26 +2,46 @@ --reactist-avatar-initials-color: #ffffff; --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest); - --reactist-avatar-meta-fill-0: #fcc652; - --reactist-avatar-meta-fill-1: #e9952c; - --reactist-avatar-meta-fill-2: #e16b2d; - --reactist-avatar-meta-fill-3: #d84b40; - --reactist-avatar-meta-fill-4: #e8435a; - --reactist-avatar-meta-fill-5: #e5198a; - --reactist-avatar-meta-fill-6: #ad3889; - --reactist-avatar-meta-fill-7: #86389c; - --reactist-avatar-meta-fill-8: #a8a8a8; - --reactist-avatar-meta-fill-9: #98be2f; - --reactist-avatar-meta-fill-10: #5d9d50; - --reactist-avatar-meta-fill-11: #5f9f85; - --reactist-avatar-meta-fill-12: #5bbcb6; - --reactist-avatar-meta-fill-13: #32a3bf; - --reactist-avatar-meta-fill-14: #2bafeb; - --reactist-avatar-meta-fill-15: #2d88c3; - --reactist-avatar-meta-fill-16: #3863cc; - --reactist-avatar-meta-fill-17: #5e5e5e; - --reactist-avatar-meta-fill-18: #7a6ff0; - --reactist-avatar-meta-fill-19: #f36d6d; + --reactist-avatar-meta-fill-0: #b8255f; + --reactist-avatar-meta-tint-0: #d43876; + --reactist-avatar-meta-fill-1: #dc4c3e; + --reactist-avatar-meta-tint-1: #ea584a; + --reactist-avatar-meta-fill-2: #f48318; + --reactist-avatar-meta-tint-2: #c77100; + --reactist-avatar-meta-fill-3: #fecf05; + --reactist-avatar-meta-tint-3: #b29104; + --reactist-avatar-meta-fill-4: #aeb83a; + --reactist-avatar-meta-tint-4: #949c31; + --reactist-avatar-meta-fill-5: #7ecc48; + --reactist-avatar-meta-tint-5: #65a33a; + --reactist-avatar-meta-fill-6: #369307; + --reactist-avatar-meta-tint-6: #369307; + --reactist-avatar-meta-fill-7: #52ccb8; + --reactist-avatar-meta-tint-7: #42a393; + --reactist-avatar-meta-fill-8: #148fad; + --reactist-avatar-meta-tint-8: #148fad; + --reactist-avatar-meta-fill-9: #3ab9e2; + --reactist-avatar-meta-tint-9: #319dc0; + --reactist-avatar-meta-fill-10: #96c3eb; + --reactist-avatar-meta-tint-10: #6988a4; + --reactist-avatar-meta-fill-11: #2a67e2; + --reactist-avatar-meta-tint-11: #4180ff; + --reactist-avatar-meta-fill-12: #692ec2; + --reactist-avatar-meta-tint-12: #692ec2; + --reactist-avatar-meta-fill-13: #ac30cc; + --reactist-avatar-meta-tint-13: #ca3fee; + --reactist-avatar-meta-fill-14: #eb96c8; + --reactist-avatar-meta-tint-14: #a4698c; + --reactist-avatar-meta-fill-15: #e05095; + --reactist-avatar-meta-tint-15: #e05095; + --reactist-avatar-meta-fill-16: #c9766f; + --reactist-avatar-meta-tint-16: #ff8e84; + --reactist-avatar-meta-fill-17: #808080; + --reactist-avatar-meta-tint-17: #808080; + --reactist-avatar-meta-fill-18: #999999; + --reactist-avatar-meta-tint-18: #999999; + --reactist-avatar-meta-fill-19: #ccae96; + --reactist-avatar-meta-tint-19: #8f7a69; } .avatar { @@ -29,6 +49,7 @@ --reactist-avatar-rounded-radius: 5px; --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); + background-color: var(--reactist-avatar-empty-fill); display: inline-flex; align-items: center; justify-content: center; @@ -60,10 +81,6 @@ object-fit: cover; } -.fallback { +.initials { background-color: var(--reactist-avatar-meta-fill); } - -.empty { - background-color: var(--reactist-avatar-empty-fill); -} diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 3d4d9196..6fef0ba0 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -5,18 +5,18 @@ import classNames from 'classnames' import { Box } from '../box' import { - getAvailableAvatarImageProps, + getAvailableImageSources, getAvatarImageIdentityKey, - getAvatarImageProps, getAvatarMetaColorIndex, getInitials, + getSources, ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' import styles from './avatar.module.css' import type { ObfuscatedClassName } from '../utils/common-types' -import type { AvatarImage, AvatarImageProps, AvatarShape, AvatarSize } from './utils' +import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils' type AvatarStyle = React.CSSProperties & { '--reactist-avatar-size': string @@ -36,8 +36,6 @@ type AvatarProps = ObfuscatedClassName & { /** * The avatar shape. * - * Use `circle` for user avatars and `rounded` for workspace or object avatars. - * * @default 'circle' */ shape?: AvatarShape @@ -45,17 +43,17 @@ type AvatarProps = ObfuscatedClassName & { /** * The display name represented by the avatar. * - * Used as the default accessible label, to generate fallback initials, and to assign the - * deterministic fallback meta color. + * Used as the default accessible label, to generate fallback initials, and + * to assign the deterministic background color when rendering initials. */ name?: string /** * The avatar image. * - * Pass a string for a single image URL, or a source map keyed by intrinsic image width. Source - * maps render as native `srcSet`/`sizes` hints, with the largest valid source used as the - * fallback `src`. + * Pass a string for a single image URL, or a source map keyed by intrinsic + * image width. Source maps render as native `srcSet`/`sizes` hints, with + * the largest valid source used as the fallback `src`. */ image?: AvatarImage @@ -73,104 +71,62 @@ type AvatarProps = ObfuscatedClassName & { } /** - * Displays an avatar from an image URL or deterministic initials fallback. + * Displays an avatar from an image URL, a source map keyed by intrinsic + * image width, or initials derived from the provided name (with a background + * color). */ -function Avatar({ size, shape = 'circle', name, image, alt, ...props }: AvatarProps) { - const imageProps = getAvatarImageProps(image, size) - +function Avatar({ image, ...props }: AvatarProps) { return ( ) } -Avatar.displayName = 'Avatar' - -type AvatarContentProps = ObfuscatedClassName & { - size: AvatarSize - shape: AvatarShape - name?: string - imageProps?: AvatarImageProps - alt?: string - 'data-testid'?: string -} - -function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { - const metaColorIndex = getAvatarMetaColorIndex(name) - - return { - '--reactist-avatar-size': `${size}px`, - '--reactist-avatar-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], - '--reactist-avatar-meta-fill': `var(--reactist-avatar-meta-fill-${metaColorIndex})`, - } -} - -function getAbsoluteImageSource(src: string, image: HTMLImageElement) { - try { - return new URL(src, image.ownerDocument.baseURI).href - } catch { - return src - } -} - -function getFailedImageSource(imageProps: AvatarImageProps, image: HTMLImageElement) { - const failedSrc = image.currentSrc || image.src || imageProps.src - const matchingSource = imageProps.sources?.find( - ({ src }) => src === failedSrc || getAbsoluteImageSource(src, image) === failedSrc, - ) - - return matchingSource?.src ?? imageProps.src -} function AvatarContent({ size, - shape, + shape = 'circle', name, - imageProps, + image, alt, exceptionallySetClassName, 'data-testid': testId, -}: AvatarContentProps) { +}: AvatarProps) { + const imageSources = getSources(image, size) const [failedImageSources, setFailedImageSources] = React.useState([]) + const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) - const visibleImage = getAvailableAvatarImageProps(imageProps, failedImageSources) const initials = getInitials(name) const label = alt ?? name const isDecorative = label === '' - const hasFallbackInitials = !visibleImage && initials - const isEmpty = !visibleImage && !initials return ( - {visibleImage ? ( + {availableImageSources ? ( {label { - const failedSource = getFailedImageSource(visibleImage, event.currentTarget) + const failedSource = getFailedImageSource( + availableImageSources, + event.currentTarget, + ) setFailedImageSources((currentFailedSources) => currentFailedSources.includes(failedSource) @@ -180,11 +136,45 @@ function AvatarContent({ }} /> ) : ( - initials +
+ {initials} +
)}
) } +function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { + const metaColorIndex = getAvatarMetaColorIndex(name) + + return { + '--reactist-avatar-size': `${size}px`, + '--reactist-avatar-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], + '--reactist-avatar-meta-fill': `var(--reactist-avatar-meta-fill-${metaColorIndex})`, + } +} + +function getAbsoluteImageSource(src: string, image: HTMLImageElement) { + try { + return new URL(src, image.ownerDocument.baseURI).href + } catch { + return src + } +} + +function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) { + const failedSrc = image.currentSrc || image.src || imageProps.src + const matchingSource = imageProps.sources?.find( + ({ src }) => src === failedSrc || getAbsoluteImageSource(src, image) === failedSrc, + ) + + return matchingSource?.src ?? imageProps.src +} + export { Avatar } export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 0061512c..1d3af2fc 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -1,10 +1,10 @@ import { AVATAR_META_COLOR_COUNT, - getAvailableAvatarImageProps, + getAvailableImageSources, getAvatarImageIdentityKey, - getAvatarImageProps, getAvatarMetaColorIndex, getInitials, + getSources, normalizeAvatarName, ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' @@ -86,11 +86,11 @@ describe('Avatar utils', () => { } it('returns a string image directly', () => { - expect(getAvatarImageProps('avatar.png', 36)).toEqual({ src: 'avatar.png' }) + expect(getSources('avatar.png', 36)).toEqual({ src: 'avatar.png' }) }) it('uses the largest valid source as the fallback src for source maps', () => { - expect(getAvatarImageProps(imageMap, 36)).toEqual({ + expect(getSources(imageMap, 36)).toEqual({ src: 'avatar-144.png', srcSet: 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w', sizes: '36px', @@ -103,12 +103,12 @@ describe('Avatar utils', () => { }) it('returns undefined for an empty source map', () => { - expect(getAvatarImageProps({}, 36)).toBeUndefined() + expect(getSources({}, 36)).toBeUndefined() }) it('ignores invalid source entries', () => { expect( - getAvatarImageProps( + getSources( { '-10': 'avatar-negative.png', 0: 'avatar-zero.png', @@ -155,7 +155,7 @@ describe('Avatar utils', () => { describe('getAvailableAvatarImageProps', () => { it('removes failed source-map candidates and recomputes the fallback src', () => { - const imageProps = getAvatarImageProps( + const imageProps = getSources( { 36: 'avatar-36.png', 72: 'avatar-72.png', @@ -164,7 +164,7 @@ describe('Avatar utils', () => { 36, ) - expect(getAvailableAvatarImageProps(imageProps, ['avatar-144.png'])).toEqual({ + expect(getAvailableImageSources(imageProps, ['avatar-144.png'])).toEqual({ src: 'avatar-72.png', srcSet: 'avatar-36.png 36w, avatar-72.png 72w', sizes: '36px', @@ -176,9 +176,7 @@ describe('Avatar utils', () => { }) it('returns undefined when a string image has failed', () => { - expect( - getAvailableAvatarImageProps({ src: 'avatar.png' }, ['avatar.png']), - ).toBeUndefined() + expect(getAvailableImageSources({ src: 'avatar.png' }, ['avatar.png'])).toBeUndefined() }) }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index 7afca384..53db12cf 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -21,7 +21,7 @@ type AvatarImageSource = { sourceSize: number src: string } -type AvatarImageProps = { +type ImageSources = { src: string srcSet?: string sizes?: string @@ -92,7 +92,7 @@ function getSortedImageSources(image: Record): AvatarImageSource function getImagePropsFromSources( sources: AvatarImageSource[], sizes?: string, -): AvatarImageProps | undefined { +): ImageSources | undefined { if (sources.length === 0) { return undefined } @@ -105,10 +105,7 @@ function getImagePropsFromSources( } } -function getAvatarImageProps( - image: AvatarImage | undefined, - size: AvatarSize, -): AvatarImageProps | undefined { +function getSources(image: AvatarImage | undefined, size: AvatarSize): ImageSources | undefined { if (!image) { return undefined } @@ -138,8 +135,8 @@ function getAvatarImageIdentityKey(image?: AvatarImage) { return sources.map(({ sourceSize, src }) => `${sourceSize}:${src}`).join('|') } -function getAvailableAvatarImageProps( - imageProps: AvatarImageProps | undefined, +function getAvailableImageSources( + imageProps: ImageSources | undefined, failedSources: readonly string[], ) { if (!imageProps) { @@ -170,12 +167,12 @@ function getAvatarMetaColorIndex(name?: string) { export { AVATAR_META_COLOR_COUNT, AVATAR_SIZES, - getAvailableAvatarImageProps, + getAvailableImageSources, getAvatarImageIdentityKey, - getAvatarImageProps, getAvatarMetaColorIndex, getInitials, + getSources, normalizeAvatarName, ROUNDED_AVATAR_RADIUS_BY_SIZE, } -export type { AvatarImage, AvatarImageProps, AvatarImageSource, AvatarShape, AvatarSize } +export type { AvatarImage, AvatarImageSource, AvatarShape, AvatarSize, ImageSources } From 49d18c08a7904b24c0eb2f3a550c65ce8e94daab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 16:18:39 -0500 Subject: [PATCH 20/41] refactor: Use Box props where possible, restructure CSS --- src/avatar/avatar.module.css | 21 +++++++++------------ src/avatar/avatar.tsx | 6 ++++++ src/avatar/utils.ts | 2 ++ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 9cce296a..e53158b0 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -50,19 +50,12 @@ --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); background-color: var(--reactist-avatar-empty-fill); - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - overflow: hidden; width: var(--reactist-avatar-size); height: var(--reactist-avatar-size); - color: var(--reactist-avatar-initials-color); - font-size: calc(var(--reactist-avatar-size) / 2); - font-weight: var(--reactist-font-weight-medium); - line-height: 1; - text-align: center; - user-select: none; +} + +.avatar:has(.initials) { + background-color: var(--reactist-avatar-meta-fill); } .shape-circle { @@ -82,5 +75,9 @@ } .initials { - background-color: var(--reactist-avatar-meta-fill); + color: var(--reactist-avatar-initials-color); + font-size: calc(var(--reactist-avatar-size) / 2); + font-weight: var(--reactist-font-weight-medium); + line-height: 1; + user-select: none; } diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 6fef0ba0..f9b4490f 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -113,6 +113,12 @@ function AvatarContent({ )} style={getAvatarStyle(size, name)} data-testid={testId} + display="inlineFlex" + alignItems="center" + justifyContent="center" + flexShrink={0} + overflow="hidden" + textAlign="center" > {availableImageSources ? ( + type AvatarImageSource = { sourceSize: number src: string } + type ImageSources = { src: string srcSet?: string From 4392eb3cbebae576d9fc3a1b6fea06123bdc93c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 17:01:06 -0500 Subject: [PATCH 21/41] feat: Implement new stories --- src/avatar/avatar.stories.tsx | 463 ++++++++++++++++++++++++++ stories/components/Avatar.stories.tsx | 156 --------- 2 files changed, 463 insertions(+), 156 deletions(-) create mode 100644 src/avatar/avatar.stories.tsx delete mode 100644 stories/components/Avatar.stories.tsx diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx new file mode 100644 index 00000000..e3e42956 --- /dev/null +++ b/src/avatar/avatar.stories.tsx @@ -0,0 +1,463 @@ +import * as React from 'react' + +import { Avatar, Box, Inline, Stack, Text } from '../index' + +export default { + title: 'Components/Avatar', + component: Avatar, +} + +const sizes = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const + +const contributors = [ + { + name: 'doistbot', + githubUserId: '37183429', + }, + { + name: 'pawel', + githubUserId: '61894375', + }, + { + name: 'craig', + githubUserId: '1305500', + }, + { + name: 'rui', + githubUserId: '3165500', + }, + { + name: 'ricardo', + githubUserId: '96476', + }, + { + name: 'scott', + githubUserId: '25244878', + }, + { + name: 'francesca', + githubUserId: '1509326', + }, + { + name: 'henning', + githubUserId: '6048870', + }, +] as const + +const initialsExamples = [ + { + label: 'Single part', + name: 'doistbot', + }, + { + label: 'First + last', + name: 'Pawel Grimm', + }, + { + label: 'Whitespace', + name: ' craig reactist ', + }, + { + label: 'Unicode', + name: 'Åsa Núñez', + }, +] as const + +const playgroundImages = { + None: '', + 'doistbot, 60px': getGithubAvatarUrl('37183429', 60), + 'pawel, 72px': getGithubAvatarUrl('61894375', 72), + 'craig, 96px': getGithubAvatarUrl('1305500', 96), + 'rui, 120px': getGithubAvatarUrl('3165500', 120), + 'ricardo, 144px': getGithubAvatarUrl('96476', 144), + 'scott, 180px': getGithubAvatarUrl('25244878', 180), + 'francesca, 216px': getGithubAvatarUrl('1509326', 216), + 'henning, 240px': getGithubAvatarUrl('6048870', 240), + 'Missing image': '/missing-avatar-playground.png', +} as const + +function getContributor(index: number) { + return contributors[index % contributors.length] +} + +function getGithubAvatarUrl(githubUserId: string, width: number) { + return `https://avatars.githubusercontent.com/u/${githubUserId}?s=${width}` +} + +function getGithubSourceMap(githubUserId: string, width: number) { + return { + [width]: getGithubAvatarUrl(githubUserId, width), + [width * 2]: getGithubAvatarUrl(githubUserId, width * 2), + [width * 3]: getGithubAvatarUrl(githubUserId, width * 3), + } +} + +function StoryLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function StorySection({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) { + return ( + + + {title} + {description ? ( + + {description} + + ) : null} + + {children} + + ) +} + +function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {children} + + {label} + + + + ) +} + +function UserAvatar(props: Omit, 'shape'>) { + return +} + +function WorkspaceAvatar(props: Omit, 'shape'>) { + return +} + +type PlaygroundImage = keyof typeof playgroundImages + +type AvatarPlaygroundStoryArgs = Omit, 'image'> & { + image?: PlaygroundImage +} + +export const OverviewStory = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const UserAvatarsStory = () => ( + + + + {contributors.slice(1).map((contributor) => ( + + + + ))} + + + + + + + + + + + + + + +) + +export const WorkspaceAvatarsStory = () => ( + + + + + + + + + + + + + + + + + + +) + +export const ImageSourcesStory = () => ( + + + + + + + + + + + + + + + +) + +export const NamesAndInitialsStory = () => ( + + + + {initialsExamples.map(({ label, name }) => ( + + + + ))} + + + + + + + + + + + + {contributors.slice(2, 6).map((contributor) => ( + + + + ))} + + + +) + +export const SizesStory = () => ( + + + + {sizes.map((size, index) => { + const contributor = getContributor(index) + + return ( + + + + ) + })} + + + + + + {sizes.map((size, index) => { + const contributor = getContributor(index) + + return ( + + + + ) + })} + + + +) + +export const AccessibilityStory = () => ( + + + + + + + + + + + + + + + + + + +) + +export const AvatarPlaygroundStory = (args: AvatarPlaygroundStoryArgs) => { + return ( + + + + ) +} + +AvatarPlaygroundStory.args = { + size: 36, + shape: 'circle', + name: contributors[1].name, + image: 'pawel, 72px', + alt: undefined, +} + +AvatarPlaygroundStory.argTypes = { + size: { + type: 'select', + options: sizes, + }, + shape: { + type: 'select', + options: ['circle', 'rounded'], + }, + name: { + control: { + type: 'text', + }, + }, + image: { + options: Object.keys(playgroundImages), + control: { + type: 'select', + }, + }, + alt: { + control: { + type: 'text', + }, + }, +} diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx deleted file mode 100644 index 51822381..00000000 --- a/stories/components/Avatar.stories.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import * as React from 'react' - -import { Avatar, Box, Inline, Stack } from '../../src' - -export default { - title: 'Components/Avatar', - component: Avatar, -} - -const sizes = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const - -const sourceMap = { - 36: 'https://loremflickr.com/36/36', - 72: 'https://loremflickr.com/72/72', - 144: 'https://loremflickr.com/144/144', -} - -const metaColorNames = ['Ada Lovelace', 'Grace Hopper', 'Mary Jackson', 'Katherine Johnson'] - -function UserAvatar(props: Omit, 'shape'>) { - return -} - -function WorkspaceAvatar(props: Omit, 'shape'>) { - return -} - -function PersonAvatar(props: Omit, 'shape'>) { - return -} - -function PeopleAvatar(props: Omit, 'shape'>) { - return -} - -type AvatarPlaygroundStoryArgs = Omit, 'image'> & { - image?: string -} - -export const InitialsAvatarStory = () => ( - - {sizes.map((size) => ( - - ))} - -) - -export const MetaColorAvatarStory = () => ( - - {metaColorNames.map((name) => ( - - ))} - -) - -export const RoundedAvatarStory = () => ( - - {sizes.map((size) => ( - - ))} - -) - -export const PictureAvatarStory = () => ( - - {sizes.map((size) => ( - - ))} - -) - -export const SourceMapAvatarStory = () => ( - - - - - -) - -export const FailedImageFallbackStory = () => ( - - - - -) - -export const ProductWrapperExamplesStory = () => ( - - - - - - - - -) - -export const EmptyAvatarStory = () => ( - - - - -) - -export const AvatarPlaygroundStory = (args: AvatarPlaygroundStoryArgs) => { - return ( - - - - ) -} - -AvatarPlaygroundStory.args = { - size: 36, - shape: 'circle', - name: 'Jane Doe', - image: 'https://loremflickr.com/144/144', - alt: undefined, -} - -AvatarPlaygroundStory.argTypes = { - size: { - type: 'select', - options: sizes, - }, - shape: { - type: 'select', - options: ['circle', 'rounded'], - }, - name: { - control: { - type: 'text', - }, - }, - image: { - control: { - type: 'text', - }, - }, - alt: { - control: { - type: 'text', - }, - }, -} From 8b1be8b250fd27301790eeea6df326a626992e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 17:04:28 -0500 Subject: [PATCH 22/41] fix: Add inset border to avatars --- src/avatar/avatar.module.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index e53158b0..d8bcef4d 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -1,5 +1,6 @@ :root { --reactist-avatar-initials-color: #ffffff; + --reactist-avatar-border-tint: #0000001a; --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest); --reactist-avatar-meta-fill-0: #b8255f; @@ -52,6 +53,9 @@ background-color: var(--reactist-avatar-empty-fill); width: var(--reactist-avatar-size); height: var(--reactist-avatar-size); + + outline: 2px solid var(--reactist-avatar-border-tint); + outline-offset: -2px; } .avatar:has(.initials) { From b9d130f3fc7e990eda05282a899c9eee3cd7c66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Tue, 26 May 2026 17:18:33 -0500 Subject: [PATCH 23/41] fix: handle empty avatar state --- src/avatar/avatar.module.css | 4 ++++ src/avatar/avatar.tsx | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index d8bcef4d..0e3557f4 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -62,6 +62,10 @@ background-color: var(--reactist-avatar-meta-fill); } +.empty { + background-color: var(--reactist-avatar-empty-fill); +} + .shape-circle { border-radius: 50%; } diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index f9b4490f..4a0aab60 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -101,6 +101,7 @@ function AvatarContent({ const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) const initials = getInitials(name) + const hasInitials = initials !== '' const label = alt ?? name const isDecorative = label === '' @@ -109,10 +110,12 @@ function AvatarContent({ className={classNames( styles.avatar, styles[`shape-${shape}`], + !availableImageSources && !hasInitials && styles.empty, exceptionallySetClassName, )} style={getAvatarStyle(size, name)} data-testid={testId} + aria-hidden={isDecorative || undefined} display="inlineFlex" alignItems="center" justifyContent="center" @@ -141,7 +144,7 @@ function AvatarContent({ ) }} /> - ) : ( + ) : hasInitials ? (
{initials}
- )} + ) : null}
) } From e75165789dc97f72e628195b1e9b69488ef82dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= Date: Wed, 27 May 2026 10:39:24 -0500 Subject: [PATCH 24/41] refactor: Create improved avatar docs --- src/avatar/avatar.mdx | 148 +++++++++ src/avatar/avatar.stories.tsx | 599 +++++++++++++++++----------------- tsconfig.json | 2 + 3 files changed, 448 insertions(+), 301 deletions(-) create mode 100644 src/avatar/avatar.mdx diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx new file mode 100644 index 00000000..9da27168 --- /dev/null +++ b/src/avatar/avatar.mdx @@ -0,0 +1,148 @@ +import { + Canvas, + ColorItem, + ColorPalette, + Controls, + Meta, + Subtitle, + Title, +} from '@storybook/addon-docs/blocks' + +import * as AvatarStories from './avatar.stories' + + + + + +<Subtitle>Image, initials, and empty-state avatar primitive.</Subtitle> + +## Basic usage + +Use `Avatar` for people by default. Pass `size`, `name`, and an optional +`image`; `name` supplies the default accessible label, the initials fallback, +and the deterministic meta color used when initials render. + +<Canvas of={AvatarStories.Default} /> + +## Initials fallback + +When `image` is not supplied, cannot be resolved, or every responsive image +candidate fails, Avatar falls back to initials derived from `name`. Names are +normalized before initials are generated. + +<Canvas of={AvatarStories.InitialsFallback} /> + +## Workspace avatars + +Use `shape="rounded"` for workspace-like entities. Product code can wrap +Avatar with a small convention component when a surface always represents the +same kind of entity. + +<Canvas of={AvatarStories.WorkspaceAvatar} /> + +## Image sources + +Pass a string for a single image URL, or a source map keyed by intrinsic image +width. Source maps render native `srcSet` width descriptors and a `sizes` hint +based on the selected avatar size. + +<Canvas of={AvatarStories.ImageSources} /> + +## Sizes + +Avatar supports a fixed set of CSS pixel sizes. Use one of the supported +numeric values instead of styling the avatar dimensions from the outside. + +<Canvas of={AvatarStories.Sizes} /> + +## Accessibility + +Images default to `name` for alt text. Pass `alt` when the visual needs a more +specific label, and pass `alt=""` when the avatar is decorative. + +<Canvas of={AvatarStories.Accessibility} /> + +## Playground + +Use the controls to inspect the component API and common image/name +combinations. + +<Canvas of={AvatarStories.Playground} /> + +### API + +<Controls of={AvatarStories.Playground} /> + +## Custom properties + +The following CSS custom properties are available to customize the avatar +component appearance. The values shown below are the default values. + +<Canvas of={AvatarStories.MetaColors} /> + +### Customizable properties + +#### Avatar colors + +<ColorPalette> + <ColorItem title="--reactist-avatar-initials-color" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-border-tint" colors={['#0000001a']} /> + <ColorItem title="--reactist-avatar-empty-fill" colors={['var(--reactist-framework-fill-crest)']} /> +</ColorPalette> + +#### Avatar meta fills + +<ColorPalette> + <ColorItem title="--reactist-avatar-meta-fill-0" colors={['#b8255f']} /> + <ColorItem title="--reactist-avatar-meta-fill-1" colors={['#dc4c3e']} /> + <ColorItem title="--reactist-avatar-meta-fill-2" colors={['#f48318']} /> + <ColorItem title="--reactist-avatar-meta-fill-3" colors={['#fecf05']} /> + <ColorItem title="--reactist-avatar-meta-fill-4" colors={['#aeb83a']} /> + <ColorItem title="--reactist-avatar-meta-fill-5" colors={['#7ecc48']} /> + <ColorItem title="--reactist-avatar-meta-fill-6" colors={['#369307']} /> + <ColorItem title="--reactist-avatar-meta-fill-7" colors={['#52ccb8']} /> + <ColorItem title="--reactist-avatar-meta-fill-8" colors={['#148fad']} /> + <ColorItem title="--reactist-avatar-meta-fill-9" colors={['#3ab9e2']} /> + <ColorItem title="--reactist-avatar-meta-fill-10" colors={['#96c3eb']} /> + <ColorItem title="--reactist-avatar-meta-fill-11" colors={['#2a67e2']} /> + <ColorItem title="--reactist-avatar-meta-fill-12" colors={['#692ec2']} /> + <ColorItem title="--reactist-avatar-meta-fill-13" colors={['#ac30cc']} /> + <ColorItem title="--reactist-avatar-meta-fill-14" colors={['#eb96c8']} /> + <ColorItem title="--reactist-avatar-meta-fill-15" colors={['#e05095']} /> + <ColorItem title="--reactist-avatar-meta-fill-16" colors={['#c9766f']} /> + <ColorItem title="--reactist-avatar-meta-fill-17" colors={['#808080']} /> + <ColorItem title="--reactist-avatar-meta-fill-18" colors={['#999999']} /> + <ColorItem title="--reactist-avatar-meta-fill-19" colors={['#ccae96']} /> +</ColorPalette> + +### Component-owned variables + +Avatar sets these variables at render time from the `size`, `shape`, and +`name` props. They are listed for completeness, but consumers should prefer +the component props instead of overriding them directly. + +```css +.avatar { + --reactist-avatar-size: 36px; + --reactist-avatar-rounded-radius: 5px; + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); +} +``` + +## What the consumer owns + +- **Identity data** — choose the `name`, `image`, and any custom `alt` text. +- **Source selection** — provide either one URL or a width-keyed source map. +- **Entity convention** — choose `shape="circle"` for people and + `shape="rounded"` for workspace-like entities. +- **Decorative usage** — pass `alt=""` when surrounding UI already names the + represented entity. +- **Persistence and fetching** — Avatar does not load, cache, or persist remote + user/workspace data. + +## Accessibility + +- `name` becomes the default image `alt` text and initials `aria-label`. +- `alt` overrides the accessible label for both image and initials rendering. +- `alt=""` marks image and initials avatars as decorative. +- An avatar with no `name` and no `image` renders as an empty decorative visual. diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index e3e42956..0ed5e764 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -2,10 +2,9 @@ import * as React from 'react' import { Avatar, Box, Inline, Stack, Text } from '../index' -export default { - title: 'Components/Avatar', - component: Avatar, -} +import { getAvatarMetaColorIndex } from './utils' + +import type { Meta, StoryObj } from '@storybook/react-vite' const sizes = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const @@ -63,6 +62,29 @@ const initialsExamples = [ }, ] as const +const metaColorExamples = [ + 'Ada 28', + 'Ben 15', + 'Cam 38', + 'Dee 3', + 'Eli 2', + 'Flo 17', + 'Gia 3', + 'Hao 27', + 'Ivy 26', + 'Jon 4', + 'Kai 3', + 'Lia 3', + 'Max 8', + 'Nia 3', + 'Oli 2', + 'Pia 3', + 'Quin 3', + 'Rae 7', + 'Sol 6', + 'Tia 3', +].map((name) => ({ name, index: getAvatarMetaColorIndex(name) })) + const playgroundImages = { None: '', 'doistbot, 60px': getGithubAvatarUrl('37183429', 60), @@ -141,280 +163,290 @@ function UserAvatar(props: Omit<React.ComponentProps<typeof Avatar>, 'shape'>) { return <Avatar shape="circle" {...props} /> } -function WorkspaceAvatar(props: Omit<React.ComponentProps<typeof Avatar>, 'shape'>) { +function WorkspaceAvatarExample(props: Omit<React.ComponentProps<typeof Avatar>, 'shape'>) { return <Avatar shape="rounded" {...props} /> } +function AvatarColorExample({ index, name }: { index: number; name: string }) { + return ( + <AvatarExample label={`fill-${index}`}> + <UserAvatar size={36} name={name} /> + </AvatarExample> + ) +} + type PlaygroundImage = keyof typeof playgroundImages -type AvatarPlaygroundStoryArgs = Omit<React.ComponentProps<typeof Avatar>, 'image'> & { +type PlaygroundArgs = Omit<React.ComponentProps<typeof Avatar>, 'image'> & { image?: PlaygroundImage } -export const OverviewStory = () => ( - <StoryLayout> - <StorySection - title="Common outcomes" - description="Avatar handles image URLs, responsive image maps, initials, rounded shapes, failed images, and decorative empty states." - > - <Inline space="medium" alignY="top"> - <AvatarExample label="User image"> - <UserAvatar - size={36} - name={contributors[1].name} - image={getGithubAvatarUrl(contributors[1].githubUserId, 72)} - /> - </AvatarExample> - <AvatarExample label="Initials"> - <UserAvatar size={36} name="Pawel Grimm" /> - </AvatarExample> - <AvatarExample label="Workspace"> - <WorkspaceAvatar size={36} name="Reactist" /> - </AvatarExample> - <AvatarExample label="Source map"> - <UserAvatar - size={36} - name={contributors[2].name} - image={getGithubSourceMap(contributors[2].githubUserId, 36)} - /> - </AvatarExample> - <AvatarExample label="Failed image"> - <UserAvatar size={36} name={contributors[3].name} image="/missing-avatar.png" /> - </AvatarExample> - <AvatarExample label="Decorative"> - <UserAvatar - size={36} - name={contributors[4].name} - image={getGithubAvatarUrl(contributors[4].githubUserId, 72)} - alt="" - /> - </AvatarExample> - <AvatarExample label="Empty"> - <Avatar size={36} alt="" /> - </AvatarExample> - </Inline> - </StorySection> - </StoryLayout> -) - -export const UserAvatarsStory = () => ( - <StoryLayout> - <StorySection - title="User avatars" - description="Use the default circle shape for people. Pass a name for labeling and initials fallback." - > - <Inline space="medium" alignY="top"> - {contributors.slice(1).map((contributor) => ( - <AvatarExample key={contributor.name} label={contributor.name}> +const meta = { + title: 'Components/Avatar', + component: Avatar, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta<typeof Avatar> + +export default meta + +type Story = StoryObj<typeof meta> +type PlaygroundStory = StoryObj<PlaygroundArgs> + +export const Default = { + render: () => ( + <StoryLayout> + <StorySection + title="User avatar" + description="Use the default circle shape for people. Pass a name for labeling and initials fallback." + > + <Inline space="medium" alignY="top"> + {contributors.slice(1, 6).map((contributor) => ( + <AvatarExample key={contributor.name} label={contributor.name}> + <UserAvatar + size={36} + name={contributor.name} + image={getGithubAvatarUrl(contributor.githubUserId, 72)} + /> + </AvatarExample> + ))} + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const InitialsFallback = { + render: () => ( + <StoryLayout> + <StorySection + title="Initials fallback" + description="When no image is available, Avatar derives initials from the normalized name and assigns a deterministic meta color." + > + <Inline space="medium" alignY="top"> + {initialsExamples.map(({ label, name }) => ( + <AvatarExample key={label} label={label}> + <UserAvatar size={36} name={name} /> + </AvatarExample> + ))} + <AvatarExample label="Failed image"> + <UserAvatar size={36} name="Craig Reactist" image="/missing-avatar.png" /> + </AvatarExample> + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const WorkspaceAvatar = { + render: () => ( + <StoryLayout> + <StorySection + title="Workspace avatars" + description='Use shape="rounded" for workspace-like entities, either directly or through a small product wrapper.' + > + <Inline space="medium" alignY="top"> + <AvatarExample label="Workspace image"> + <WorkspaceAvatarExample + size={36} + name="Reactist" + image={getGithubAvatarUrl(contributors[0].githubUserId, 72)} + /> + </AvatarExample> + <AvatarExample label="Workspace initials"> + <WorkspaceAvatarExample size={36} name="Design System" /> + </AvatarExample> + <AvatarExample label="Failed image"> + <WorkspaceAvatarExample + size={36} + name="Todoist Web" + image="/missing-workspace-avatar.png" + /> + </AvatarExample> + <AvatarExample label="Empty"> + <Avatar size={36} shape="rounded" alt="" /> + </AvatarExample> + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const ImageSources = { + render: () => ( + <StoryLayout> + <StorySection + title="Image sources" + description="Pass a string for a single image, or a source map keyed by intrinsic image width. Source maps render native srcSet and sizes hints." + > + <Inline space="medium" alignY="top"> + <AvatarExample label="String URL"> <UserAvatar size={36} - name={contributor.name} - image={getGithubAvatarUrl(contributor.githubUserId, 72)} + name={contributors[1].name} + image={getGithubAvatarUrl(contributors[1].githubUserId, 72)} /> </AvatarExample> - ))} - </Inline> - </StorySection> - - <StorySection - title="Fallback initials" - description="When an image is missing or fails to load, Avatar derives two characters from the provided name." - > - <Inline space="medium" alignY="top"> - <AvatarExample label="No image"> - <UserAvatar size={36} name="Pawel Grimm" /> - </AvatarExample> - <AvatarExample label="Failed image"> - <UserAvatar size={36} name="Craig Reactist" image="/missing-user-avatar.png" /> - </AvatarExample> - </Inline> - </StorySection> - </StoryLayout> -) - -export const WorkspaceAvatarsStory = () => ( - <StoryLayout> - <StorySection - title="Workspace avatars" - description='Workspace-like surfaces can encode their convention with a small wrapper that sets shape="rounded".' - > - <Inline space="medium" alignY="top"> - <AvatarExample label="Workspace image"> - <WorkspaceAvatar - size={36} - name="Reactist" - image={getGithubAvatarUrl(contributors[0].githubUserId, 72)} - /> - </AvatarExample> - <AvatarExample label="Workspace initials"> - <WorkspaceAvatar size={36} name="Design System" /> - </AvatarExample> - <AvatarExample label="Failed image"> - <WorkspaceAvatar - size={36} - name="Todoist Web" - image="/missing-workspace-avatar.png" - /> - </AvatarExample> - <AvatarExample label="Empty"> - <Avatar size={36} shape="rounded" alt="" /> - </AvatarExample> - </Inline> - </StorySection> - </StoryLayout> -) - -export const ImageSourcesStory = () => ( - <StoryLayout> - <StorySection - title="Image sources" - description="Pass a string for a single image, or a source map keyed by image width. These examples include 1x, 2x, and 3x source links." - > - <Inline space="medium" alignY="top"> - <AvatarExample label="String URL"> - <UserAvatar - size={36} - name={contributors[1].name} - image={getGithubAvatarUrl(contributors[1].githubUserId, 72)} - /> - </AvatarExample> - <AvatarExample label="Source map"> - <UserAvatar - size={36} - name={contributors[2].name} - image={getGithubSourceMap(contributors[2].githubUserId, 36)} - /> - </AvatarExample> - <AvatarExample label="Large avatar"> - <UserAvatar - size={72} - name={contributors[3].name} - image={getGithubSourceMap(contributors[3].githubUserId, 72)} - /> - </AvatarExample> - </Inline> - </StorySection> - </StoryLayout> -) - -export const NamesAndInitialsStory = () => ( - <StoryLayout> - <StorySection - title="Names and initials" - description="Initials are derived from normalized names and meta colors are assigned deterministically from the full name." - > - <Inline space="medium" alignY="top"> - {initialsExamples.map(({ label, name }) => ( - <AvatarExample key={label} label={label}> - <UserAvatar size={36} name={name} /> + <AvatarExample label="Source map"> + <UserAvatar + size={36} + name={contributors[2].name} + image={getGithubSourceMap(contributors[2].githubUserId, 36)} + /> </AvatarExample> - ))} - </Inline> - </StorySection> - - <StorySection - title="Deterministic meta colors" - description="The same name receives the same meta color across sizes; different names spread across the configured palette." - > - <Inline space="medium" alignY="top"> - <AvatarExample label="Same name, 36"> - <UserAvatar size={36} name="Pawel Grimm" /> - </AvatarExample> - <AvatarExample label="Same name, 50"> - <UserAvatar size={50} name="Pawel Grimm" /> - </AvatarExample> - {contributors.slice(2, 6).map((contributor) => ( - <AvatarExample key={contributor.name} label={contributor.name}> - <UserAvatar size={36} name={contributor.name} /> + <AvatarExample label="Large source map"> + <UserAvatar + size={72} + name={contributors[3].name} + image={getGithubSourceMap(contributors[3].githubUserId, 72)} + /> </AvatarExample> - ))} - </Inline> - </StorySection> - </StoryLayout> -) - -export const SizesStory = () => ( - <StoryLayout> - <StorySection - title="Supported sizes" - description="Avatar supports this exact set of CSS pixel sizes. Each image example includes 1x, 2x, and 3x source links." - > - <Inline space="medium" alignY="top"> - {sizes.map((size, index) => { - const contributor = getContributor(index) - - return ( - <AvatarExample key={size} label={`${size}px`}> - <UserAvatar - size={size} - name={contributor!.name} - image={getGithubSourceMap(contributor!.githubUserId, size)} - /> - </AvatarExample> - ) - })} - </Inline> - </StorySection> - - <StorySection - title="Initials at every size" - description="Initials scale with the avatar size and keep the same two-character derivation." - > - <Inline space="medium" alignY="top"> - {sizes.map((size, index) => { - const contributor = getContributor(index) - - return ( - <AvatarExample key={size} label={`${size}px`}> - <UserAvatar size={size} name={contributor!.name} /> - </AvatarExample> - ) - })} - </Inline> - </StorySection> - </StoryLayout> -) - -export const AccessibilityStory = () => ( - <StoryLayout> - <StorySection - title="Accessible names" - description='Images default to name for alt text. Pass alt for a custom label, or alt="" for decorative avatars.' - > - <Inline space="medium" alignY="top"> - <AvatarExample label="Default from name"> - <UserAvatar - size={36} - name={contributors[1].name} - image={getGithubAvatarUrl(contributors[1].githubUserId, 72)} - /> - </AvatarExample> - <AvatarExample label="Custom alt"> - <UserAvatar - size={36} - name={contributors[0].name} - image={getGithubAvatarUrl(contributors[0].githubUserId, 72)} - alt="Reactist automation account" - /> - </AvatarExample> - <AvatarExample label="Decorative image"> - <UserAvatar - size={36} - name={contributors[3].name} - image={getGithubAvatarUrl(contributors[3].githubUserId, 72)} - alt="" - /> - </AvatarExample> - <AvatarExample label="Decorative empty"> - <Avatar size={36} alt="" /> - </AvatarExample> - </Inline> - </StorySection> - </StoryLayout> -) - -export const AvatarPlaygroundStory = (args: AvatarPlaygroundStoryArgs) => { - return ( + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const Sizes = { + render: () => ( + <StoryLayout> + <StorySection + title="Supported sizes" + description="Avatar supports this exact set of CSS pixel sizes. The same size value is also used in image source-map sizes hints." + > + <Inline space="medium" alignY="top"> + {sizes.map((size, index) => { + const contributor = getContributor(index) + + return ( + <AvatarExample key={size} label={`${size}px`}> + <UserAvatar + size={size} + name={contributor!.name} + image={getGithubSourceMap(contributor!.githubUserId, size)} + /> + </AvatarExample> + ) + })} + </Inline> + </StorySection> + + <StorySection + title="Initials at every size" + description="Initials scale with the avatar size and keep the same two-character derivation." + > + <Inline space="medium" alignY="top"> + {sizes.map((size, index) => { + const contributor = getContributor(index) + + return ( + <AvatarExample key={size} label={`${size}px`}> + <UserAvatar size={size} name={contributor!.name} /> + </AvatarExample> + ) + })} + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const Accessibility = { + render: () => ( + <StoryLayout> + <StorySection + title="Accessible names" + description='Images default to name for alt text. Pass alt for a custom label, or alt="" for decorative avatars.' + > + <Inline space="medium" alignY="top"> + <AvatarExample label="Default from name"> + <UserAvatar + size={36} + name={contributors[1].name} + image={getGithubAvatarUrl(contributors[1].githubUserId, 72)} + /> + </AvatarExample> + <AvatarExample label="Custom alt"> + <UserAvatar + size={36} + name={contributors[0].name} + image={getGithubAvatarUrl(contributors[0].githubUserId, 72)} + alt="Reactist automation account" + /> + </AvatarExample> + <AvatarExample label="Decorative image"> + <UserAvatar + size={36} + name={contributors[3].name} + image={getGithubAvatarUrl(contributors[3].githubUserId, 72)} + alt="" + /> + </AvatarExample> + <AvatarExample label="Decorative initials"> + <UserAvatar size={36} name="Jane Doe" alt="" /> + </AvatarExample> + <AvatarExample label="Decorative empty"> + <Avatar size={36} alt="" /> + </AvatarExample> + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const MetaColors = { + render: () => ( + <StoryLayout> + <StorySection + title="Meta colors" + description="Avatar assigns one of 20 meta fill colors deterministically from the provided name." + > + <Inline space="medium" alignY="top"> + {metaColorExamples.map(({ index, name }) => ( + <AvatarColorExample key={index} index={index} name={name} /> + ))} + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const Playground = { + args: { + size: 36, + shape: 'circle', + name: contributors[1].name, + image: 'pawel, 72px', + alt: undefined, + }, + argTypes: { + size: { + control: { type: 'select' }, + options: sizes, + }, + shape: { + control: { type: 'select' }, + options: ['circle', 'rounded'], + }, + name: { + control: { + type: 'text', + }, + }, + image: { + options: Object.keys(playgroundImages), + control: { + type: 'select', + }, + }, + alt: { + control: { + type: 'text', + }, + }, + }, + render: (args: PlaygroundArgs) => ( <Box> <Avatar size={args.size} @@ -424,40 +456,5 @@ export const AvatarPlaygroundStory = (args: AvatarPlaygroundStoryArgs) => { alt={args.alt} /> </Box> - ) -} - -AvatarPlaygroundStory.args = { - size: 36, - shape: 'circle', - name: contributors[1].name, - image: 'pawel, 72px', - alt: undefined, -} - -AvatarPlaygroundStory.argTypes = { - size: { - type: 'select', - options: sizes, - }, - shape: { - type: 'select', - options: ['circle', 'rounded'], - }, - name: { - control: { - type: 'text', - }, - }, - image: { - options: Object.keys(playgroundImages), - control: { - type: 'select', - }, - }, - alt: { - control: { - type: 'text', - }, - }, -} + ), +} satisfies PlaygroundStory diff --git a/tsconfig.json b/tsconfig.json index e035be3b..e60c62b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ "baseUrl": "./", "paths": { "@": ["./"], + "@storybook/react": ["node_modules/@storybook/react/dist/index.d.ts"], + "@storybook/react-vite": ["node_modules/@storybook/react-vite/dist/index.d.ts"], "storybook/actions": ["node_modules/storybook/dist/actions/index.d.ts"], "storybook/test": ["node_modules/storybook/dist/test/index.d.ts"], "*": ["src/*", "node_modules/*"] From 211dd34d080efe13e383617344aba6939626f0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 11:24:09 -0500 Subject: [PATCH 25/41] Update avatar meta color tokens --- src/avatar/avatar.mdx | 87 ++++++++++++++++++++- src/avatar/avatar.module.css | 142 +++++++++++++++++++++++++++++------ src/avatar/avatar.test.tsx | 10 ++- src/avatar/avatar.tsx | 10 +-- 4 files changed, 215 insertions(+), 34 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 9da27168..ad7f6e8c 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -85,34 +85,114 @@ component appearance. The values shown below are the default values. #### Avatar colors <ColorPalette> - <ColorItem title="--reactist-avatar-initials-color" colors={['#ffffff']} /> + <ColorItem + title="--reactist-avatar-initials-color" + colors={['var(--reactist-actionable-primary-idle-tint)']} + /> <ColorItem title="--reactist-avatar-border-tint" colors={['#0000001a']} /> - <ColorItem title="--reactist-avatar-empty-fill" colors={['var(--reactist-framework-fill-crest)']} /> + <ColorItem + title="--reactist-avatar-empty-fill" + colors={['var(--reactist-framework-fill-crest)']} + /> </ColorPalette> -#### Avatar meta fills +#### Avatar meta colors <ColorPalette> <ColorItem title="--reactist-avatar-meta-fill-0" colors={['#b8255f']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-0" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-1" colors={['#dc4c3e']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-1" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-2" colors={['#f48318']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-2" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-3" colors={['#fecf05']} /> + <ColorItem title="--reactist-avatar-meta-on-idle-tint-3" colors={['#202020']} /> <ColorItem title="--reactist-avatar-meta-fill-4" colors={['#aeb83a']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-4" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-5" colors={['#7ecc48']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-5" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-6" colors={['#369307']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-6" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-7" colors={['#52ccb8']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-7" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-8" colors={['#148fad']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-8" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-9" colors={['#3ab9e2']} /> + <ColorItem title="--reactist-avatar-meta-on-idle-tint-9" colors={['#202020']} /> <ColorItem title="--reactist-avatar-meta-fill-10" colors={['#96c3eb']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-10" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-11" colors={['#2a67e2']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-11" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-12" colors={['#692ec2']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-12" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-13" colors={['#ac30cc']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-13" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-14" colors={['#eb96c8']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-14" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-15" colors={['#e05095']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-15" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-16" colors={['#c9766f']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-16" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-17" colors={['#808080']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-17" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-18" colors={['#999999']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-18" + colors={['var(--reactist-avatar-initials-color)']} + /> <ColorItem title="--reactist-avatar-meta-fill-19" colors={['#ccae96']} /> + <ColorItem + title="--reactist-avatar-meta-on-idle-tint-19" + colors={['var(--reactist-avatar-initials-color)']} + /> </ColorPalette> ### Component-owned variables @@ -125,7 +205,6 @@ the component props instead of overriding them directly. .avatar { --reactist-avatar-size: 36px; --reactist-avatar-rounded-radius: 5px; - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); } ``` diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 0e3557f4..5be2bc92 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -1,48 +1,48 @@ :root { - --reactist-avatar-initials-color: #ffffff; + --reactist-avatar-initials-color: var(--reactist-actionable-primary-idle-tint); --reactist-avatar-border-tint: #0000001a; --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest); --reactist-avatar-meta-fill-0: #b8255f; - --reactist-avatar-meta-tint-0: #d43876; + --reactist-avatar-meta-on-idle-tint-0: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-1: #dc4c3e; - --reactist-avatar-meta-tint-1: #ea584a; + --reactist-avatar-meta-on-idle-tint-1: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-2: #f48318; - --reactist-avatar-meta-tint-2: #c77100; + --reactist-avatar-meta-on-idle-tint-2: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-3: #fecf05; - --reactist-avatar-meta-tint-3: #b29104; + --reactist-avatar-meta-on-idle-tint-3: #202020; --reactist-avatar-meta-fill-4: #aeb83a; - --reactist-avatar-meta-tint-4: #949c31; + --reactist-avatar-meta-on-idle-tint-4: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-5: #7ecc48; - --reactist-avatar-meta-tint-5: #65a33a; + --reactist-avatar-meta-on-idle-tint-5: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-6: #369307; - --reactist-avatar-meta-tint-6: #369307; + --reactist-avatar-meta-on-idle-tint-6: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-7: #52ccb8; - --reactist-avatar-meta-tint-7: #42a393; + --reactist-avatar-meta-on-idle-tint-7: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-8: #148fad; - --reactist-avatar-meta-tint-8: #148fad; + --reactist-avatar-meta-on-idle-tint-8: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-9: #3ab9e2; - --reactist-avatar-meta-tint-9: #319dc0; + --reactist-avatar-meta-on-idle-tint-9: #202020; --reactist-avatar-meta-fill-10: #96c3eb; - --reactist-avatar-meta-tint-10: #6988a4; + --reactist-avatar-meta-on-idle-tint-10: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-11: #2a67e2; - --reactist-avatar-meta-tint-11: #4180ff; + --reactist-avatar-meta-on-idle-tint-11: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-12: #692ec2; - --reactist-avatar-meta-tint-12: #692ec2; + --reactist-avatar-meta-on-idle-tint-12: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-13: #ac30cc; - --reactist-avatar-meta-tint-13: #ca3fee; + --reactist-avatar-meta-on-idle-tint-13: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-14: #eb96c8; - --reactist-avatar-meta-tint-14: #a4698c; + --reactist-avatar-meta-on-idle-tint-14: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-15: #e05095; - --reactist-avatar-meta-tint-15: #e05095; + --reactist-avatar-meta-on-idle-tint-15: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-16: #c9766f; - --reactist-avatar-meta-tint-16: #ff8e84; + --reactist-avatar-meta-on-idle-tint-16: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-17: #808080; - --reactist-avatar-meta-tint-17: #808080; + --reactist-avatar-meta-on-idle-tint-17: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-18: #999999; - --reactist-avatar-meta-tint-18: #999999; + --reactist-avatar-meta-on-idle-tint-18: var(--reactist-avatar-initials-color); --reactist-avatar-meta-fill-19: #ccae96; - --reactist-avatar-meta-tint-19: #8f7a69; + --reactist-avatar-meta-on-idle-tint-19: var(--reactist-avatar-initials-color); } .avatar { @@ -62,6 +62,106 @@ background-color: var(--reactist-avatar-meta-fill); } +.metaColor-0 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-0); +} + +.metaColor-1 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-1); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-1); +} + +.metaColor-2 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-2); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-2); +} + +.metaColor-3 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-3); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-3); +} + +.metaColor-4 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-4); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-4); +} + +.metaColor-5 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-5); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-5); +} + +.metaColor-6 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-6); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-6); +} + +.metaColor-7 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-7); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-7); +} + +.metaColor-8 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-8); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-8); +} + +.metaColor-9 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-9); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-9); +} + +.metaColor-10 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-10); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-10); +} + +.metaColor-11 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-11); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-11); +} + +.metaColor-12 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-12); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-12); +} + +.metaColor-13 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-13); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-13); +} + +.metaColor-14 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-14); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-14); +} + +.metaColor-15 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-15); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-15); +} + +.metaColor-16 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-16); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-16); +} + +.metaColor-17 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-17); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-17); +} + +.metaColor-18 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-18); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-18); +} + +.metaColor-19 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-19); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-19); +} + .empty { background-color: var(--reactist-avatar-empty-fill); } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 80f7cc9b..5e4ebb95 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -49,9 +49,13 @@ describe('Avatar', () => { render(<Avatar data-testid="avatar" size={36} name="Jane Doe" />) expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') - expect(screen.getByTestId('avatar')).toHaveStyle({ - '--reactist-avatar-meta-fill': 'var(--reactist-avatar-meta-fill-0)', - }) + expect(screen.getByTestId('avatar')).toHaveClass('metaColor-0') + }) + + it('applies the deterministic meta color class for the avatar name', () => { + render(<Avatar data-testid="avatar" size={36} name="John Doe" />) + + expect(screen.getByTestId('avatar')).toHaveClass('metaColor-9') }) it('falls back to initials when image source map is empty', () => { diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 4a0aab60..a5abed40 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -21,7 +21,6 @@ import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils type AvatarStyle = React.CSSProperties & { '--reactist-avatar-size': string '--reactist-avatar-rounded-radius': string - '--reactist-avatar-meta-fill': string } /** @@ -104,16 +103,18 @@ function AvatarContent({ const hasInitials = initials !== '' const label = alt ?? name const isDecorative = label === '' + const metaColorIndex = getAvatarMetaColorIndex(name) return ( <Box className={classNames( styles.avatar, styles[`shape-${shape}`], + styles[`metaColor-${metaColorIndex}`], !availableImageSources && !hasInitials && styles.empty, exceptionallySetClassName, )} - style={getAvatarStyle(size, name)} + style={getAvatarStyle(size)} data-testid={testId} aria-hidden={isDecorative || undefined} display="inlineFlex" @@ -158,13 +159,10 @@ function AvatarContent({ ) } -function getAvatarStyle(size: AvatarSize, name?: string): AvatarStyle { - const metaColorIndex = getAvatarMetaColorIndex(name) - +function getAvatarStyle(size: AvatarSize): AvatarStyle { return { '--reactist-avatar-size': `${size}px`, '--reactist-avatar-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], - '--reactist-avatar-meta-fill': `var(--reactist-avatar-meta-fill-${metaColorIndex})`, } } From bdd27c58b253190fa9162005872259248b63b570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 11:30:07 -0500 Subject: [PATCH 26/41] refactor: Rework meta color CSS custom props --- src/avatar/avatar.mdx | 144 +++++++++---------------------- src/avatar/avatar.module.css | 162 +++++++++++++++++------------------ 2 files changed, 123 insertions(+), 183 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index ad7f6e8c..4036b61a 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -85,114 +85,54 @@ component appearance. The values shown below are the default values. #### Avatar colors <ColorPalette> - <ColorItem - title="--reactist-avatar-initials-color" - colors={['var(--reactist-actionable-primary-idle-tint)']} - /> + <ColorItem title="--reactist-avatar-initials-color" colors={['#ffffff']} /> <ColorItem title="--reactist-avatar-border-tint" colors={['#0000001a']} /> - <ColorItem - title="--reactist-avatar-empty-fill" - colors={['var(--reactist-framework-fill-crest)']} - /> + <ColorItem title="--reactist-avatar-empty-fill" colors={['#e6e6e6']} /> </ColorPalette> #### Avatar meta colors <ColorPalette> - <ColorItem title="--reactist-avatar-meta-fill-0" colors={['#b8255f']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-0" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-1" colors={['#dc4c3e']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-1" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-2" colors={['#f48318']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-2" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-3" colors={['#fecf05']} /> - <ColorItem title="--reactist-avatar-meta-on-idle-tint-3" colors={['#202020']} /> - <ColorItem title="--reactist-avatar-meta-fill-4" colors={['#aeb83a']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-4" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-5" colors={['#7ecc48']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-5" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-6" colors={['#369307']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-6" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-7" colors={['#52ccb8']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-7" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-8" colors={['#148fad']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-8" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-9" colors={['#3ab9e2']} /> - <ColorItem title="--reactist-avatar-meta-on-idle-tint-9" colors={['#202020']} /> - <ColorItem title="--reactist-avatar-meta-fill-10" colors={['#96c3eb']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-10" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-11" colors={['#2a67e2']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-11" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-12" colors={['#692ec2']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-12" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-13" colors={['#ac30cc']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-13" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-14" colors={['#eb96c8']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-14" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-15" colors={['#e05095']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-15" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-16" colors={['#c9766f']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-16" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-17" colors={['#808080']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-17" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-18" colors={['#999999']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-18" - colors={['var(--reactist-avatar-initials-color)']} - /> - <ColorItem title="--reactist-avatar-meta-fill-19" colors={['#ccae96']} /> - <ColorItem - title="--reactist-avatar-meta-on-idle-tint-19" - colors={['var(--reactist-avatar-initials-color)']} - /> + <ColorItem title="--reactist-avatar-meta-0-fill" colors={['#b8255f']} /> + <ColorItem title="--reactist-avatar-meta-0-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-1-fill" colors={['#dc4c3e']} /> + <ColorItem title="--reactist-avatar-meta-1-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-2-fill" colors={['#f48318']} /> + <ColorItem title="--reactist-avatar-meta-2-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-3-fill" colors={['#fecf05']} /> + <ColorItem title="--reactist-avatar-meta-3-on-idle-tint" colors={['#202020']} /> + <ColorItem title="--reactist-avatar-meta-4-fill" colors={['#aeb83a']} /> + <ColorItem title="--reactist-avatar-meta-4-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-5-fill" colors={['#7ecc48']} /> + <ColorItem title="--reactist-avatar-meta-5-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-6-fill" colors={['#369307']} /> + <ColorItem title="--reactist-avatar-meta-6-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-7-fill" colors={['#52ccb8']} /> + <ColorItem title="--reactist-avatar-meta-7-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-8-fill" colors={['#148fad']} /> + <ColorItem title="--reactist-avatar-meta-8-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-9-fill" colors={['#3ab9e2']} /> + <ColorItem title="--reactist-avatar-meta-9-on-idle-tint" colors={['#202020']} /> + <ColorItem title="--reactist-avatar-meta-10-fill" colors={['#96c3eb']} /> + <ColorItem title="--reactist-avatar-meta-10-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-11-fill" colors={['#2a67e2']} /> + <ColorItem title="--reactist-avatar-meta-11-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-12-fill" colors={['#692ec2']} /> + <ColorItem title="--reactist-avatar-meta-12-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-13-fill" colors={['#ac30cc']} /> + <ColorItem title="--reactist-avatar-meta-13-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-14-fill" colors={['#eb96c8']} /> + <ColorItem title="--reactist-avatar-meta-14-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-15-fill" colors={['#e05095']} /> + <ColorItem title="--reactist-avatar-meta-15-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-16-fill" colors={['#c9766f']} /> + <ColorItem title="--reactist-avatar-meta-16-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-17-fill" colors={['#808080']} /> + <ColorItem title="--reactist-avatar-meta-17-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-18-fill" colors={['#999999']} /> + <ColorItem title="--reactist-avatar-meta-18-on-idle-tint" colors={['#ffffff']} /> + <ColorItem title="--reactist-avatar-meta-19-fill" colors={['#ccae96']} /> + <ColorItem title="--reactist-avatar-meta-19-on-idle-tint" colors={['#ffffff']} /> </ColorPalette> ### Component-owned variables diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 5be2bc92..f7a6e362 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -3,52 +3,52 @@ --reactist-avatar-border-tint: #0000001a; --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest); - --reactist-avatar-meta-fill-0: #b8255f; - --reactist-avatar-meta-on-idle-tint-0: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-1: #dc4c3e; - --reactist-avatar-meta-on-idle-tint-1: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-2: #f48318; - --reactist-avatar-meta-on-idle-tint-2: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-3: #fecf05; - --reactist-avatar-meta-on-idle-tint-3: #202020; - --reactist-avatar-meta-fill-4: #aeb83a; - --reactist-avatar-meta-on-idle-tint-4: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-5: #7ecc48; - --reactist-avatar-meta-on-idle-tint-5: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-6: #369307; - --reactist-avatar-meta-on-idle-tint-6: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-7: #52ccb8; - --reactist-avatar-meta-on-idle-tint-7: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-8: #148fad; - --reactist-avatar-meta-on-idle-tint-8: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-9: #3ab9e2; - --reactist-avatar-meta-on-idle-tint-9: #202020; - --reactist-avatar-meta-fill-10: #96c3eb; - --reactist-avatar-meta-on-idle-tint-10: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-11: #2a67e2; - --reactist-avatar-meta-on-idle-tint-11: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-12: #692ec2; - --reactist-avatar-meta-on-idle-tint-12: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-13: #ac30cc; - --reactist-avatar-meta-on-idle-tint-13: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-14: #eb96c8; - --reactist-avatar-meta-on-idle-tint-14: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-15: #e05095; - --reactist-avatar-meta-on-idle-tint-15: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-16: #c9766f; - --reactist-avatar-meta-on-idle-tint-16: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-17: #808080; - --reactist-avatar-meta-on-idle-tint-17: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-18: #999999; - --reactist-avatar-meta-on-idle-tint-18: var(--reactist-avatar-initials-color); - --reactist-avatar-meta-fill-19: #ccae96; - --reactist-avatar-meta-on-idle-tint-19: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-0-fill: #b8255f; + --reactist-avatar-meta-0-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-1-fill: #dc4c3e; + --reactist-avatar-meta-1-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-2-fill: #f48318; + --reactist-avatar-meta-2-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-3-fill: #fecf05; + --reactist-avatar-meta-3-on-idle-tint: #202020; + --reactist-avatar-meta-4-fill: #aeb83a; + --reactist-avatar-meta-4-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-5-fill: #7ecc48; + --reactist-avatar-meta-5-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-6-fill: #369307; + --reactist-avatar-meta-6-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-7-fill: #52ccb8; + --reactist-avatar-meta-7-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-8-fill: #148fad; + --reactist-avatar-meta-8-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-9-fill: #3ab9e2; + --reactist-avatar-meta-9-on-idle-tint: #202020; + --reactist-avatar-meta-10-fill: #96c3eb; + --reactist-avatar-meta-10-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-11-fill: #2a67e2; + --reactist-avatar-meta-11-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-12-fill: #692ec2; + --reactist-avatar-meta-12-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-13-fill: #ac30cc; + --reactist-avatar-meta-13-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-14-fill: #eb96c8; + --reactist-avatar-meta-14-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-15-fill: #e05095; + --reactist-avatar-meta-15-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-16-fill: #c9766f; + --reactist-avatar-meta-16-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-17-fill: #808080; + --reactist-avatar-meta-17-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-18-fill: #999999; + --reactist-avatar-meta-18-on-idle-tint: var(--reactist-avatar-initials-color); + --reactist-avatar-meta-19-fill: #ccae96; + --reactist-avatar-meta-19-on-idle-tint: var(--reactist-avatar-initials-color); } .avatar { --reactist-avatar-size: 36px; --reactist-avatar-rounded-radius: 5px; - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-0-fill); background-color: var(--reactist-avatar-empty-fill); width: var(--reactist-avatar-size); @@ -63,103 +63,103 @@ } .metaColor-0 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-0); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-0); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-0-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-0-on-idle-tint); } .metaColor-1 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-1); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-1); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-1-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-1-on-idle-tint); } .metaColor-2 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-2); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-2); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-2-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-2-on-idle-tint); } .metaColor-3 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-3); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-3); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-3-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-3-on-idle-tint); } .metaColor-4 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-4); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-4); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-4-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-4-on-idle-tint); } .metaColor-5 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-5); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-5); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-5-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-5-on-idle-tint); } .metaColor-6 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-6); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-6); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-6-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-6-on-idle-tint); } .metaColor-7 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-7); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-7); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-7-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-7-on-idle-tint); } .metaColor-8 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-8); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-8); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-8-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-8-on-idle-tint); } .metaColor-9 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-9); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-9); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-9-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-9-on-idle-tint); } .metaColor-10 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-10); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-10); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-10-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-10-on-idle-tint); } .metaColor-11 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-11); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-11); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-11-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-11-on-idle-tint); } .metaColor-12 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-12); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-12); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-12-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-12-on-idle-tint); } .metaColor-13 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-13); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-13); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-13-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-13-on-idle-tint); } .metaColor-14 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-14); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-14); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-14-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-14-on-idle-tint); } .metaColor-15 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-15); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-15); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-15-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-15-on-idle-tint); } .metaColor-16 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-16); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-16); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-16-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-16-on-idle-tint); } .metaColor-17 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-17); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-17); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-17-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-17-on-idle-tint); } .metaColor-18 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-18); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-18); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-18-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-18-on-idle-tint); } .metaColor-19 { - --reactist-avatar-meta-fill: var(--reactist-avatar-meta-fill-19); - --reactist-avatar-initials-color: var(--reactist-avatar-meta-on-idle-tint-19); + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-19-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-19-on-idle-tint); } .empty { From f0d2a2ce34f3c3f8b8cbd944eeb574ebfcfd6372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 11:45:32 -0500 Subject: [PATCH 27/41] docs: add avatar migration guidance --- src/avatar/avatar.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 4036b61a..23f97bb3 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -24,6 +24,25 @@ and the deterministic meta color used when initials render. <Canvas of={AvatarStories.Default} /> +## Migrating from the legacy API + +The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or +responsive `size` values, and a deprecated `className`. The current API uses +direct identity props instead: + +| Legacy prop | Current API | +| ------------------------------ | ------------------------------------------------------------------------ | +| `user.name` | `name` | +| `avatarUrl` | `image` | +| `user.email` | No replacement. Email is no longer used for initials or color selection. | +| `colorList` | Customize the CSS custom properties listed below. | +| `size="l"` or responsive sizes | Pass one supported numeric CSS-pixel `size`. | +| `className` | `exceptionallySetClassName` | + +```tsx +<Avatar size={36} name={user.name} image={avatarUrl} exceptionallySetClassName={className} /> +``` + ## Initials fallback When `image` is not supplied, cannot be resolved, or every responsive image From 5cc19fb6d59d09751d2745b02cf481f6b86dbf2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:07:28 -0500 Subject: [PATCH 28/41] fix: Add ref + passthrough props support --- src/avatar/avatar.tsx | 77 +++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index a5abed40..18bf5474 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -15,6 +15,7 @@ import { import styles from './avatar.module.css' +import type { ComponentProps } from 'react' import type { ObfuscatedClassName } from '../utils/common-types' import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils' @@ -67,46 +68,36 @@ type AvatarProps = ObfuscatedClassName & { * Test identifier applied to the avatar root element. */ 'data-testid'?: string -} - -/** - * Displays an avatar from an image URL, a source map keyed by intrinsic - * image width, or initials derived from the provided name (with a background - * color). - */ -function Avatar({ image, ...props }: AvatarProps) { - return ( - <AvatarContent - // Allows `AvatarContent` to remount when the image map changes, - // which resets error states - key={getAvatarImageIdentityKey(image)} - image={image} - {...props} - /> - ) -} - -function AvatarContent({ - size, - shape = 'circle', - name, - image, - alt, - exceptionallySetClassName, - 'data-testid': testId, -}: AvatarProps) { +} & Omit<ComponentProps<'div'>, 'className' | 'style'> + +const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function AvatarContent( + { + size, + shape = 'circle', + name, + image, + alt, + exceptionallySetClassName, + 'data-testid': testId, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + ...restProps + }, + ref, +) { const imageSources = getSources(image, size) const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) - const initials = getInitials(name) + const hasInitials = initials !== '' - const label = alt ?? name - const isDecorative = label === '' + const label = ariaLabel ?? alt ?? name + const isDecorative = ariaHidden || label === '' const metaColorIndex = getAvatarMetaColorIndex(name) return ( <Box + ref={ref} className={classNames( styles.avatar, styles[`shape-${shape}`], @@ -123,6 +114,7 @@ function AvatarContent({ flexShrink={0} overflow="hidden" textAlign="center" + {...restProps} > {availableImageSources ? ( <img @@ -157,7 +149,28 @@ function AvatarContent({ ) : null} </Box> ) -} +}) + +/** + * Displays an avatar from an image URL, a source map keyed by intrinsic + * image width, or initials derived from the provided name (with a background + * color). + */ +const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(function Avatar( + { image, ...restProps }, + ref, +) { + return ( + <AvatarContent + ref={ref} + // Allows `AvatarContent` to remount when the image map changes, + // which resets error states + key={getAvatarImageIdentityKey(image)} + image={image} + {...restProps} + /> + ) +}) function getAvatarStyle(size: AvatarSize): AvatarStyle { return { From 2bdf3d3168c69899157aa5ecf71619f8d3135382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:08:04 -0500 Subject: [PATCH 29/41] fix: Only set aria-hidden on container --- src/avatar/avatar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 18bf5474..8af4259a 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -123,7 +123,6 @@ const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function Ava srcSet={availableImageSources.srcSet} sizes={availableImageSources.sizes} alt={label ?? ''} - aria-hidden={isDecorative} onError={(event) => { const failedSource = getFailedImageSource( availableImageSources, @@ -142,7 +141,6 @@ const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function Ava className={styles.initials} role={label ? 'img' : undefined} aria-label={label} - aria-hidden={isDecorative} > {initials} </div> From 29d09e254fbb64902520062097dfa26546aa579e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:15:30 -0500 Subject: [PATCH 30/41] test: add avatar axe coverage --- src/avatar/avatar.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 5e4ebb95..b50cecd2 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { fireEvent, render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' import { Avatar } from './avatar' @@ -230,4 +231,21 @@ describe('Avatar', () => { expect(screen.getByTestId('avatar')).toHaveClass('custom-avatar') }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + <Avatar size={36} name="Jane Doe" image="avatar.png" /> + <Avatar size={36} name="John Doe" /> + <Avatar size={36} name="Decorative Image" image="decorative.png" alt="" /> + <Avatar size={36} name="Decorative Initials" alt="" /> + <Avatar size={36} /> + </>, + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) }) From b12ee971f5d494a85a4a980b2dd7068c3416fa06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:39:01 -0500 Subject: [PATCH 31/41] fix: normalize avatar fallback labels --- src/avatar/avatar.test.tsx | 14 ++++++++++++++ src/avatar/avatar.tsx | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index b50cecd2..f4ae9eb1 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -204,6 +204,20 @@ describe('Avatar', () => { expect(screen.getByRole('img', { name: 'Account avatar' })).toBeInTheDocument() }) + it('uses custom alt text as the accessible label for initials avatars', () => { + render(<Avatar size={36} name="Jane Doe" alt="Account avatar" />) + + expect(screen.getByRole('img', { name: 'Account avatar' })).toHaveTextContent('JD') + }) + + it('normalizes the default accessible label before deciding whether it is decorative', () => { + render(<Avatar data-testid="avatar" size={36} name=" " image="avatar.png" />) + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByAltText('')).toHaveAttribute('src', 'avatar.png') + expect(screen.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true') + }) + it('supports decorative image avatars with empty alt text', () => { render(<Avatar size={36} name="Jane Doe" image="avatar.png" alt="" />) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 8af4259a..7435f347 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -10,6 +10,7 @@ import { getAvatarMetaColorIndex, getInitials, getSources, + normalizeAvatarName, ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' @@ -88,10 +89,11 @@ const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function Ava const imageSources = getSources(image, size) const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) + const normalizedName = normalizeAvatarName(name) const initials = getInitials(name) const hasInitials = initials !== '' - const label = ariaLabel ?? alt ?? name + const label = ariaLabel ?? alt ?? normalizedName const isDecorative = ariaHidden || label === '' const metaColorIndex = getAvatarMetaColorIndex(name) From 76abc6297ce2dd098dd22b5ac997c83862c2b7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:39:12 -0500 Subject: [PATCH 32/41] refactor: apply avatar meta colors to initials --- src/avatar/avatar.module.css | 40 ++++++++++++++++++------------------ src/avatar/avatar.test.tsx | 14 +++++++++++-- src/avatar/avatar.tsx | 6 +++--- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index f7a6e362..acaec40d 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -62,102 +62,102 @@ background-color: var(--reactist-avatar-meta-fill); } -.metaColor-0 { +.meta-color-0 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-0-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-0-on-idle-tint); } -.metaColor-1 { +.meta-color-1 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-1-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-1-on-idle-tint); } -.metaColor-2 { +.meta-color-2 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-2-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-2-on-idle-tint); } -.metaColor-3 { +.meta-color-3 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-3-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-3-on-idle-tint); } -.metaColor-4 { +.meta-color-4 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-4-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-4-on-idle-tint); } -.metaColor-5 { +.meta-color-5 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-5-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-5-on-idle-tint); } -.metaColor-6 { +.meta-color-6 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-6-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-6-on-idle-tint); } -.metaColor-7 { +.meta-color-7 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-7-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-7-on-idle-tint); } -.metaColor-8 { +.meta-color-8 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-8-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-8-on-idle-tint); } -.metaColor-9 { +.meta-color-9 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-9-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-9-on-idle-tint); } -.metaColor-10 { +.meta-color-10 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-10-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-10-on-idle-tint); } -.metaColor-11 { +.meta-color-11 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-11-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-11-on-idle-tint); } -.metaColor-12 { +.meta-color-12 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-12-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-12-on-idle-tint); } -.metaColor-13 { +.meta-color-13 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-13-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-13-on-idle-tint); } -.metaColor-14 { +.meta-color-14 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-14-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-14-on-idle-tint); } -.metaColor-15 { +.meta-color-15 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-15-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-15-on-idle-tint); } -.metaColor-16 { +.meta-color-16 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-16-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-16-on-idle-tint); } -.metaColor-17 { +.meta-color-17 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-17-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-17-on-idle-tint); } -.metaColor-18 { +.meta-color-18 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-18-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-18-on-idle-tint); } -.metaColor-19 { +.meta-color-19 { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-19-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-19-on-idle-tint); } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index f4ae9eb1..939c2790 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -24,6 +24,16 @@ describe('Avatar', () => { }) }) + it('does not apply meta color classes while rendering an image', () => { + render(<Avatar data-testid="avatar" size={36} name="Jane Doe" image="avatar.png" />) + + expect( + Array.from(screen.getByTestId('avatar').classList).some((className) => + className.startsWith('meta-color-'), + ), + ).toBe(false) + }) + it('renders a source-map image URL with native responsive image hints', () => { render( <Avatar @@ -50,13 +60,13 @@ describe('Avatar', () => { render(<Avatar data-testid="avatar" size={36} name="Jane Doe" />) expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') - expect(screen.getByTestId('avatar')).toHaveClass('metaColor-0') + expect(screen.getByTestId('avatar')).toHaveClass('meta-color-0') }) it('applies the deterministic meta color class for the avatar name', () => { render(<Avatar data-testid="avatar" size={36} name="John Doe" />) - expect(screen.getByTestId('avatar')).toHaveClass('metaColor-9') + expect(screen.getByTestId('avatar')).toHaveClass('meta-color-9') }) it('falls back to initials when image source map is empty', () => { diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 7435f347..f7b28de5 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -90,12 +90,12 @@ const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function Ava const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) const normalizedName = normalizeAvatarName(name) - const initials = getInitials(name) + const initials = availableImageSources ? '' : getInitials(name) const hasInitials = initials !== '' const label = ariaLabel ?? alt ?? normalizedName const isDecorative = ariaHidden || label === '' - const metaColorIndex = getAvatarMetaColorIndex(name) + const metaColorIndex = hasInitials ? getAvatarMetaColorIndex(name) : undefined return ( <Box @@ -103,7 +103,7 @@ const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function Ava className={classNames( styles.avatar, styles[`shape-${shape}`], - styles[`metaColor-${metaColorIndex}`], + metaColorIndex !== undefined && styles[`meta-color-${metaColorIndex}`], !availableImageSources && !hasInitials && styles.empty, exceptionallySetClassName, )} From 3587011f2a76f92f90cb75e0bcf2370f0a6ba4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:39:24 -0500 Subject: [PATCH 33/41] refactor: simplify avatar initials splitting --- src/avatar/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index 19588f29..e40a625f 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -71,7 +71,7 @@ function getInitialGrapheme(value?: string) { } function getInitials(name?: string) { - const nameParts = normalizeAvatarName(name).split(WHITESPACE_REGEXP).filter(Boolean) + const nameParts = normalizeAvatarName(name).split(' ').filter(Boolean) if (nameParts.length === 0) { return '' From a98b772eb120b74e8712f8e3aeaa9a85161b7ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:39:30 -0500 Subject: [PATCH 34/41] perf: skip avatar source filtering when unchanged --- src/avatar/utils.test.ts | 12 ++++++++++++ src/avatar/utils.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 1d3af2fc..779ac856 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -178,6 +178,18 @@ describe('Avatar utils', () => { it('returns undefined when a string image has failed', () => { expect(getAvailableImageSources({ src: 'avatar.png' }, ['avatar.png'])).toBeUndefined() }) + + it('returns the original image sources when no candidates have failed', () => { + const imageProps = getSources( + { + 36: 'avatar-36.png', + 72: 'avatar-72.png', + }, + 36, + ) + + expect(getAvailableImageSources(imageProps, [])).toBe(imageProps) + }) }) describe('getAvatarMetaColorIndex', () => { diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index e40a625f..f8d09409 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -145,6 +145,10 @@ function getAvailableImageSources( return undefined } + if (failedSources.length === 0) { + return imageProps + } + if (!imageProps.sources) { return failedSources.includes(imageProps.src) ? undefined : imageProps } From 8d673d2a202f5f461b878d5e53a4725e17a1d06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:39:37 -0500 Subject: [PATCH 35/41] refactor: reuse avatar size constants in stories --- src/avatar/avatar.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index 0ed5e764..79f5e154 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -2,11 +2,11 @@ import * as React from 'react' import { Avatar, Box, Inline, Stack, Text } from '../index' -import { getAvatarMetaColorIndex } from './utils' +import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils' import type { Meta, StoryObj } from '@storybook/react-vite' -const sizes = [80, 72, 62, 50, 40, 36, 30, 28, 24, 20, 18, 16, 12] as const +const sizes = AVATAR_SIZES const contributors = [ { From e66680f470f0e772bbbf089c46e8a72bdd7ede64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 12:39:46 -0500 Subject: [PATCH 36/41] docs: render avatar migration table as markdown --- src/avatar/avatar.mdx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 23f97bb3..7700841a 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -3,6 +3,7 @@ import { ColorItem, ColorPalette, Controls, + Markdown, Meta, Subtitle, Title, @@ -30,14 +31,16 @@ The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or responsive `size` values, and a deprecated `className`. The current API uses direct identity props instead: +<Markdown>{` | Legacy prop | Current API | | ------------------------------ | ------------------------------------------------------------------------ | -| `user.name` | `name` | -| `avatarUrl` | `image` | -| `user.email` | No replacement. Email is no longer used for initials or color selection. | -| `colorList` | Customize the CSS custom properties listed below. | -| `size="l"` or responsive sizes | Pass one supported numeric CSS-pixel `size`. | -| `className` | `exceptionallySetClassName` | +| \`user.name\` | \`name\` | +| \`avatarUrl\` | \`image\` | +| \`user.email\` | No replacement. Email is no longer used for initials or color selection. | +| \`colorList\` | Customize the CSS custom properties listed below. | +| \`size="l"\` or responsive sizes | Pass one supported numeric CSS-pixel \`size\`. | +| \`className\` | \`exceptionallySetClassName\` | +`}</Markdown> ```tsx <Avatar size={36} name={user.name} image={avatarUrl} exceptionallySetClassName={className} /> From ab3eef6cf81cd972717382adfff94c02a65ac9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 13:48:55 -0500 Subject: [PATCH 37/41] feat: support polymorphic avatar root --- src/avatar/avatar.stories.tsx | 7 +- src/avatar/avatar.test.tsx | 30 ++++++ src/avatar/avatar.tsx | 184 +++++++++++++++++++--------------- src/utils/polymorphism.ts | 2 +- 4 files changed, 136 insertions(+), 87 deletions(-) diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index 79f5e154..720595d1 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -5,6 +5,7 @@ import { Avatar, Box, Inline, Stack, Text } from '../index' import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils' import type { Meta, StoryObj } from '@storybook/react-vite' +import type { AvatarProps } from './avatar' const sizes = AVATAR_SIZES @@ -159,11 +160,11 @@ function AvatarExample({ label, children }: { label: string; children: React.Rea ) } -function UserAvatar(props: Omit<React.ComponentProps<typeof Avatar>, 'shape'>) { +function UserAvatar(props: Omit<AvatarProps, 'shape'>) { return <Avatar shape="circle" {...props} /> } -function WorkspaceAvatarExample(props: Omit<React.ComponentProps<typeof Avatar>, 'shape'>) { +function WorkspaceAvatarExample(props: Omit<AvatarProps, 'shape'>) { return <Avatar shape="rounded" {...props} /> } @@ -177,7 +178,7 @@ function AvatarColorExample({ index, name }: { index: number; name: string }) { type PlaygroundImage = keyof typeof playgroundImages -type PlaygroundArgs = Omit<React.ComponentProps<typeof Avatar>, 'image'> & { +type PlaygroundArgs = Omit<AvatarProps, 'image'> & { image?: PlaygroundImage } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 939c2790..e78137b3 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -193,6 +193,36 @@ describe('Avatar', () => { expect(screen.getByTestId('avatar')).toHaveTextContent('') }) + it('can render the root as a different element', () => { + render(<Avatar as="section" data-testid="avatar" size={36} name="Jane Doe" />) + + expect(screen.getByTestId('avatar').tagName).toBe('SECTION') + }) + + it('derives the root ref type from the element rendered with as', () => { + const anchorRef = React.createRef<HTMLAnchorElement>() + const buttonRef = React.createRef<HTMLButtonElement>() + + render( + <Avatar + as="a" + data-testid="avatar" + href="/profile" + ref={anchorRef} + size={36} + name="Jane Doe" + />, + ) + + expect(anchorRef.current).toBe(screen.getByTestId('avatar')) + + const invalidRefElement = ( + // @ts-expect-error refs must match the element selected with as + <Avatar as="a" href="/profile" ref={buttonRef} size={36} name="Jane Doe" /> + ) + expect(invalidRefElement).toBeTruthy() + }) + it('supports rounded shape with size-aware radius', () => { render(<Avatar data-testid="avatar" size={50} shape="rounded" name="Design" />) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index f7b28de5..9704dbc7 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import classNames from 'classnames' import { Box } from '../box' +import { polymorphicComponent } from '../utils/polymorphism' import { getAvailableImageSources, @@ -16,8 +17,8 @@ import { import styles from './avatar.module.css' -import type { ComponentProps } from 'react' import type { ObfuscatedClassName } from '../utils/common-types' +import type { PolymorphicComponentProps } from '../utils/polymorphism' import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils' type AvatarStyle = React.CSSProperties & { @@ -28,7 +29,7 @@ type AvatarStyle = React.CSSProperties & { /** * Props for the `Avatar` component. */ -type AvatarProps = ObfuscatedClassName & { +type AvatarOwnProps = ObfuscatedClassName & { /** * The rendered avatar size, in CSS pixels. */ @@ -69,99 +70,116 @@ type AvatarProps = ObfuscatedClassName & { * Test identifier applied to the avatar root element. */ 'data-testid'?: string -} & Omit<ComponentProps<'div'>, 'className' | 'style'> - -const AvatarContent = React.forwardRef<HTMLDivElement, AvatarProps>(function AvatarContent( - { - size, - shape = 'circle', - name, - image, - alt, - exceptionallySetClassName, - 'data-testid': testId, - 'aria-hidden': ariaHidden, - 'aria-label': ariaLabel, - ...restProps - }, - ref, -) { - const imageSources = getSources(image, size) - const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) - const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) - const normalizedName = normalizeAvatarName(name) - const initials = availableImageSources ? '' : getInitials(name) - const hasInitials = initials !== '' - const label = ariaLabel ?? alt ?? normalizedName - const isDecorative = ariaHidden || label === '' - const metaColorIndex = hasInitials ? getAvatarMetaColorIndex(name) : undefined + /** + * Avatar owns its root sizing styles. Use `exceptionallySetClassName` for the styling escape + * hatch. + */ + style?: never +} - return ( - <Box - ref={ref} - className={classNames( - styles.avatar, - styles[`shape-${shape}`], - metaColorIndex !== undefined && styles[`meta-color-${metaColorIndex}`], - !availableImageSources && !hasInitials && styles.empty, - exceptionallySetClassName, - )} - style={getAvatarStyle(size)} - data-testid={testId} - aria-hidden={isDecorative || undefined} - display="inlineFlex" - alignItems="center" - justifyContent="center" - flexShrink={0} - overflow="hidden" - textAlign="center" - {...restProps} - > - {availableImageSources ? ( - <img - className={styles.image} - src={availableImageSources.src} - srcSet={availableImageSources.srcSet} - sizes={availableImageSources.sizes} - alt={label ?? ''} - onError={(event) => { - const failedSource = getFailedImageSource( - availableImageSources, - event.currentTarget, - ) - - setFailedImageSources((currentFailedSources) => - currentFailedSources.includes(failedSource) - ? currentFailedSources - : [...currentFailedSources, failedSource], - ) - }} - /> - ) : hasInitials ? ( - <div - className={styles.initials} - role={label ? 'img' : undefined} - aria-label={label} - > - {initials} - </div> - ) : null} - </Box> - ) -}) +type AvatarProps<ComponentType extends React.ElementType = 'div'> = PolymorphicComponentProps< + ComponentType, + AvatarOwnProps, + 'omitClassName' +> + +const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>( + function AvatarContent( + { + as, + size, + shape = 'circle', + name, + image, + alt, + exceptionallySetClassName, + 'data-testid': testId, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + ...restProps + }, + ref, + ) { + const imageSources = getSources(image, size) + const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) + const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) + const normalizedName = normalizeAvatarName(name) + const initials = availableImageSources ? '' : getInitials(name) + + const hasInitials = initials !== '' + const label = ariaLabel ?? alt ?? normalizedName + const isDecorative = ariaHidden || label === '' + const metaColorIndex = hasInitials ? getAvatarMetaColorIndex(name) : undefined + + return ( + <Box + as={as} + ref={ref} + className={classNames( + styles.avatar, + styles[`shape-${shape}`], + metaColorIndex !== undefined && styles[`meta-color-${metaColorIndex}`], + !availableImageSources && !hasInitials && styles.empty, + exceptionallySetClassName, + )} + style={getAvatarStyle(size)} + data-testid={testId} + aria-hidden={isDecorative || undefined} + display="inlineFlex" + alignItems="center" + justifyContent="center" + flexShrink={0} + overflow="hidden" + textAlign="center" + {...restProps} + > + {availableImageSources ? ( + <img + className={styles.image} + src={availableImageSources.src} + srcSet={availableImageSources.srcSet} + sizes={availableImageSources.sizes} + alt={label ?? ''} + onError={(event) => { + const failedSource = getFailedImageSource( + availableImageSources, + event.currentTarget, + ) + + setFailedImageSources((currentFailedSources) => + currentFailedSources.includes(failedSource) + ? currentFailedSources + : [...currentFailedSources, failedSource], + ) + }} + /> + ) : hasInitials ? ( + <div + className={styles.initials} + role={label ? 'img' : undefined} + aria-label={label} + > + {initials} + </div> + ) : null} + </Box> + ) + }, +) /** * Displays an avatar from an image URL, a source map keyed by intrinsic * image width, or initials derived from the provided name (with a background * color). */ -const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(function Avatar( - { image, ...restProps }, +const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(function Avatar( + { as, image, ...restProps }, ref, ) { return ( <AvatarContent + as={as} ref={ref} // Allows `AvatarContent` to remount when the image map changes, // which resets error states diff --git a/src/utils/polymorphism.ts b/src/utils/polymorphism.ts index afb26bbd..b7e56297 100644 --- a/src/utils/polymorphism.ts +++ b/src/utils/polymorphism.ts @@ -197,5 +197,5 @@ function polymorphicComponent< > } -export type { PolymorphicComponent } +export type { PolymorphicComponent, PolymorphicComponentProps } export { polymorphicComponent } From 10b13ec591b812a47cf80e80cbbe74dbefe82f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 23:38:04 -0500 Subject: [PATCH 38/41] refactor: move avatar size styles to css --- src/avatar/avatar.mdx | 6 ++-- src/avatar/avatar.module.css | 65 ++++++++++++++++++++++++++++++++++++ src/avatar/avatar.test.tsx | 10 ++---- src/avatar/avatar.tsx | 15 +-------- src/avatar/utils.test.ts | 21 ------------ src/avatar/utils.ts | 17 ---------- 6 files changed, 72 insertions(+), 62 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 7700841a..9da9dd48 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -159,9 +159,9 @@ component appearance. The values shown below are the default values. ### Component-owned variables -Avatar sets these variables at render time from the `size`, `shape`, and -`name` props. They are listed for completeness, but consumers should prefer -the component props instead of overriding them directly. +Avatar's size classes set these variables from the `size` prop. They are +listed for completeness, but consumers should prefer the component props +instead of overriding them directly. ```css .avatar { diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index acaec40d..b74a57d6 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -58,6 +58,71 @@ outline-offset: -2px; } +.size-80 { + --reactist-avatar-size: 80px; + --reactist-avatar-rounded-radius: 10px; +} + +.size-72 { + --reactist-avatar-size: 72px; + --reactist-avatar-rounded-radius: 10px; +} + +.size-62 { + --reactist-avatar-size: 62px; + --reactist-avatar-rounded-radius: 8.5px; +} + +.size-50 { + --reactist-avatar-size: 50px; + --reactist-avatar-rounded-radius: 7px; +} + +.size-40 { + --reactist-avatar-size: 40px; + --reactist-avatar-rounded-radius: 5.5px; +} + +.size-36 { + --reactist-avatar-size: 36px; + --reactist-avatar-rounded-radius: 5px; +} + +.size-30 { + --reactist-avatar-size: 30px; + --reactist-avatar-rounded-radius: 5px; +} + +.size-28 { + --reactist-avatar-size: 28px; + --reactist-avatar-rounded-radius: 5px; +} + +.size-24 { + --reactist-avatar-size: 24px; + --reactist-avatar-rounded-radius: 3.2px; +} + +.size-20 { + --reactist-avatar-size: 20px; + --reactist-avatar-rounded-radius: 3px; +} + +.size-18 { + --reactist-avatar-size: 18px; + --reactist-avatar-rounded-radius: 3px; +} + +.size-16 { + --reactist-avatar-size: 16px; + --reactist-avatar-rounded-radius: 2px; +} + +.size-12 { + --reactist-avatar-size: 12px; + --reactist-avatar-rounded-radius: 1.6px; +} + .avatar:has(.initials) { background-color: var(--reactist-avatar-meta-fill); } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index e78137b3..9182be72 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -19,9 +19,7 @@ describe('Avatar', () => { render(<Avatar data-testid="avatar" size={36} name="Jane Doe" image="avatar.png" />) expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') - expect(screen.getByTestId('avatar')).toHaveStyle({ - '--reactist-avatar-size': '36px', - }) + expect(screen.getByTestId('avatar')).toHaveClass('size-36') }) it('does not apply meta color classes while rendering an image', () => { @@ -223,13 +221,11 @@ describe('Avatar', () => { expect(invalidRefElement).toBeTruthy() }) - it('supports rounded shape with size-aware radius', () => { + it('supports rounded shape with size-driven CSS classes', () => { render(<Avatar data-testid="avatar" size={50} shape="rounded" name="Design" />) expect(screen.getByTestId('avatar')).toHaveClass('shape-rounded') - expect(screen.getByTestId('avatar')).toHaveStyle({ - '--reactist-avatar-rounded-radius': '7px', - }) + expect(screen.getByTestId('avatar')).toHaveClass('size-50') }) it('defaults to circle shape', () => { diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 9704dbc7..0cc16d07 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -12,7 +12,6 @@ import { getInitials, getSources, normalizeAvatarName, - ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' import styles from './avatar.module.css' @@ -21,11 +20,6 @@ import type { ObfuscatedClassName } from '../utils/common-types' import type { PolymorphicComponentProps } from '../utils/polymorphism' import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils' -type AvatarStyle = React.CSSProperties & { - '--reactist-avatar-size': string - '--reactist-avatar-rounded-radius': string -} - /** * Props for the `Avatar` component. */ @@ -118,12 +112,12 @@ const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName ref={ref} className={classNames( styles.avatar, + styles[`size-${size}`], styles[`shape-${shape}`], metaColorIndex !== undefined && styles[`meta-color-${metaColorIndex}`], !availableImageSources && !hasInitials && styles.empty, exceptionallySetClassName, )} - style={getAvatarStyle(size)} data-testid={testId} aria-hidden={isDecorative || undefined} display="inlineFlex" @@ -190,13 +184,6 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func ) }) -function getAvatarStyle(size: AvatarSize): AvatarStyle { - return { - '--reactist-avatar-size': `${size}px`, - '--reactist-avatar-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], - } -} - function getAbsoluteImageSource(src: string, image: HTMLImageElement) { try { return new URL(src, image.ownerDocument.baseURI).href diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 779ac856..d8ed07bd 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -6,7 +6,6 @@ import { getInitials, getSources, normalizeAvatarName, - ROUNDED_AVATAR_RADIUS_BY_SIZE, } from './utils' describe('Avatar utils', () => { @@ -216,24 +215,4 @@ describe('Avatar utils', () => { expect(index).toBeLessThan(AVATAR_META_COLOR_COUNT) }) }) - - describe('ROUNDED_AVATAR_RADIUS_BY_SIZE', () => { - it('contains the exclusive rounded radius mapping', () => { - expect(ROUNDED_AVATAR_RADIUS_BY_SIZE).toEqual({ - 80: '10px', - 72: '10px', - 62: '8.5px', - 50: '7px', - 40: '5.5px', - 36: '5px', - 30: '5px', - 28: '5px', - 24: '3.2px', - 20: '3px', - 18: '3px', - 16: '2px', - 12: '1.6px', - }) - }) - }) }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index f8d09409..a4b9eaa8 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -32,22 +32,6 @@ type ImageSources = { const AVATAR_META_COLOR_COUNT = 20 -const ROUNDED_AVATAR_RADIUS_BY_SIZE: Record<AvatarSize, string> = { - 80: '10px', - 72: '10px', - 62: '8.5px', - 50: '7px', - 40: '5.5px', - 36: '5px', - 30: '5px', - 28: '5px', - 24: '3.2px', - 20: '3px', - 18: '3px', - 16: '2px', - 12: '1.6px', -} - const WHITESPACE_REGEXP = new RegExp('\\p{White_Space}+', 'gu') const GRAPHEME_SEGMENTER = typeof Intl !== 'undefined' && 'Segmenter' in Intl @@ -179,6 +163,5 @@ export { getInitials, getSources, normalizeAvatarName, - ROUNDED_AVATAR_RADIUS_BY_SIZE, } export type { AvatarImage, AvatarImageSource, AvatarShape, AvatarSize, ImageSources } From 7f9421ea01b9c1effaf122057b9fa57f714719d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Thu, 28 May 2026 14:57:00 -0500 Subject: [PATCH 39/41] refactor: reset only AvatarImage when key changes --- src/avatar/avatar.module.css | 64 ++++++----- src/avatar/avatar.test.tsx | 24 ++++- src/avatar/avatar.tsx | 203 +++++++++++++++++++---------------- 3 files changed, 169 insertions(+), 122 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index b74a57d6..6e0ff984 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -127,110 +127,126 @@ background-color: var(--reactist-avatar-meta-fill); } -.meta-color-0 { +.meta-color-0, +.avatar:has(.meta-color-0) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-0-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-0-on-idle-tint); } -.meta-color-1 { +.meta-color-1, +.avatar:has(.meta-color-1) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-1-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-1-on-idle-tint); } -.meta-color-2 { +.meta-color-2, +.avatar:has(.meta-color-2) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-2-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-2-on-idle-tint); } -.meta-color-3 { +.meta-color-3, +.avatar:has(.meta-color-3) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-3-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-3-on-idle-tint); } -.meta-color-4 { +.meta-color-4, +.avatar:has(.meta-color-4) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-4-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-4-on-idle-tint); } -.meta-color-5 { +.meta-color-5, +.avatar:has(.meta-color-5) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-5-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-5-on-idle-tint); } -.meta-color-6 { +.meta-color-6, +.avatar:has(.meta-color-6) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-6-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-6-on-idle-tint); } -.meta-color-7 { +.meta-color-7, +.avatar:has(.meta-color-7) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-7-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-7-on-idle-tint); } -.meta-color-8 { +.meta-color-8, +.avatar:has(.meta-color-8) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-8-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-8-on-idle-tint); } -.meta-color-9 { +.meta-color-9, +.avatar:has(.meta-color-9) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-9-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-9-on-idle-tint); } -.meta-color-10 { +.meta-color-10, +.avatar:has(.meta-color-10) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-10-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-10-on-idle-tint); } -.meta-color-11 { +.meta-color-11, +.avatar:has(.meta-color-11) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-11-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-11-on-idle-tint); } -.meta-color-12 { +.meta-color-12, +.avatar:has(.meta-color-12) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-12-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-12-on-idle-tint); } -.meta-color-13 { +.meta-color-13, +.avatar:has(.meta-color-13) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-13-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-13-on-idle-tint); } -.meta-color-14 { +.meta-color-14, +.avatar:has(.meta-color-14) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-14-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-14-on-idle-tint); } -.meta-color-15 { +.meta-color-15, +.avatar:has(.meta-color-15) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-15-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-15-on-idle-tint); } -.meta-color-16 { +.meta-color-16, +.avatar:has(.meta-color-16) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-16-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-16-on-idle-tint); } -.meta-color-17 { +.meta-color-17, +.avatar:has(.meta-color-17) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-17-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-17-on-idle-tint); } -.meta-color-18 { +.meta-color-18, +.avatar:has(.meta-color-18) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-18-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-18-on-idle-tint); } -.meta-color-19 { +.meta-color-19, +.avatar:has(.meta-color-19) { --reactist-avatar-meta-fill: var(--reactist-avatar-meta-19-fill); --reactist-avatar-initials-color: var(--reactist-avatar-meta-19-on-idle-tint); } -.empty { - background-color: var(--reactist-avatar-empty-fill); -} - .shape-circle { border-radius: 50%; } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 9182be72..e5c1b656 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -57,14 +57,15 @@ describe('Avatar', () => { it('falls back to initials when no image is provided', () => { render(<Avatar data-testid="avatar" size={36} name="Jane Doe" />) - expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') - expect(screen.getByTestId('avatar')).toHaveClass('meta-color-0') + const initials = screen.getByRole('img', { name: 'Jane Doe' }) + expect(initials).toHaveTextContent('JD') + expect(initials).toHaveClass('meta-color-0') }) it('applies the deterministic meta color class for the avatar name', () => { render(<Avatar data-testid="avatar" size={36} name="John Doe" />) - expect(screen.getByTestId('avatar')).toHaveClass('meta-color-9') + expect(screen.getByRole('img', { name: 'John Doe' })).toHaveClass('meta-color-9') }) it('falls back to initials when image source map is empty', () => { @@ -92,6 +93,20 @@ describe('Avatar', () => { expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') }) + it('keeps the root element mounted when resetting failed image state', () => { + const { rerender } = render( + <Avatar data-testid="avatar" size={36} name="Jane Doe" image="missing.png" />, + ) + const avatarRoot = screen.getByTestId('avatar') + + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + + rerender(<Avatar data-testid="avatar" size={36} name="Jane Doe" image="avatar.png" />) + + expect(screen.getByTestId('avatar')).toBe(avatarRoot) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') + }) + it('removes a failed source-map candidate and retries with the remaining candidates', () => { render( <Avatar @@ -187,8 +202,9 @@ describe('Avatar', () => { it('renders a neutral empty avatar when no name or image is provided', () => { render(<Avatar data-testid="avatar" size={36} />) - expect(screen.getByTestId('avatar')).toHaveClass('empty') + expect(screen.getByTestId('avatar')).not.toHaveClass('meta-color-0') expect(screen.getByTestId('avatar')).toHaveTextContent('') + expect(screen.queryByRole('img')).not.toBeInTheDocument() }) it('can render the root as a different element', () => { diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 0cc16d07..4d1dfbe3 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -18,7 +18,7 @@ import styles from './avatar.module.css' import type { ObfuscatedClassName } from '../utils/common-types' import type { PolymorphicComponentProps } from '../utils/polymorphism' -import type { AvatarImage, AvatarShape, AvatarSize, ImageSources } from './utils' +import type { AvatarImage as AvatarImageProp, AvatarShape, AvatarSize, ImageSources } from './utils' /** * Props for the `Avatar` component. @@ -51,7 +51,7 @@ type AvatarOwnProps = ObfuscatedClassName & { * image width. Source maps render as native `srcSet`/`sizes` hints, with * the largest valid source used as the fallback `src`. */ - image?: AvatarImage + image?: AvatarImageProp /** * Accessible text for the avatar image. @@ -78,112 +78,126 @@ type AvatarProps<ComponentType extends React.ElementType = 'div'> = PolymorphicC 'omitClassName' > -const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>( - function AvatarContent( - { - as, - size, - shape = 'circle', - name, - image, - alt, - exceptionallySetClassName, - 'data-testid': testId, - 'aria-hidden': ariaHidden, - 'aria-label': ariaLabel, - ...restProps - }, - ref, - ) { - const imageSources = getSources(image, size) - const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) - const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) - const normalizedName = normalizeAvatarName(name) - const initials = availableImageSources ? '' : getInitials(name) - - const hasInitials = initials !== '' - const label = ariaLabel ?? alt ?? normalizedName - const isDecorative = ariaHidden || label === '' - const metaColorIndex = hasInitials ? getAvatarMetaColorIndex(name) : undefined - - return ( - <Box - as={as} - ref={ref} - className={classNames( - styles.avatar, - styles[`size-${size}`], - styles[`shape-${shape}`], - metaColorIndex !== undefined && styles[`meta-color-${metaColorIndex}`], - !availableImageSources && !hasInitials && styles.empty, - exceptionallySetClassName, - )} - data-testid={testId} - aria-hidden={isDecorative || undefined} - display="inlineFlex" - alignItems="center" - justifyContent="center" - flexShrink={0} - overflow="hidden" - textAlign="center" - {...restProps} - > - {availableImageSources ? ( - <img - className={styles.image} - src={availableImageSources.src} - srcSet={availableImageSources.srcSet} - sizes={availableImageSources.sizes} - alt={label ?? ''} - onError={(event) => { - const failedSource = getFailedImageSource( - availableImageSources, - event.currentTarget, - ) - - setFailedImageSources((currentFailedSources) => - currentFailedSources.includes(failedSource) - ? currentFailedSources - : [...currentFailedSources, failedSource], - ) - }} - /> - ) : hasInitials ? ( - <div - className={styles.initials} - role={label ? 'img' : undefined} - aria-label={label} - > - {initials} - </div> - ) : null} - </Box> - ) - }, -) - /** * Displays an avatar from an image URL, a source map keyed by intrinsic * image width, or initials derived from the provided name (with a background * color). */ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(function Avatar( - { as, image, ...restProps }, + { + as, + size, + shape = 'circle', + name, + image, + alt, + exceptionallySetClassName, + 'data-testid': testId, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + ...restProps + }, ref, ) { + const label = getAvatarLabel({ alt, name, 'aria-label': ariaLabel }) + const isDecorative = ariaHidden || label === '' + return ( - <AvatarContent + <Box as={as} ref={ref} - // Allows `AvatarContent` to remount when the image map changes, - // which resets error states - key={getAvatarImageIdentityKey(image)} - image={image} + className={classNames( + styles.avatar, + styles[`size-${size}`], + styles[`shape-${shape}`], + exceptionallySetClassName, + )} + data-testid={testId} + aria-hidden={isDecorative || undefined} + display="inlineFlex" + alignItems="center" + justifyContent="center" + flexShrink={0} + overflow="hidden" + textAlign="center" {...restProps} - /> + > + <AvatarImage + // Allows `AvatarImage` to remount when the image map changes, + // which resets error states without replacing the avatar root. + key={getAvatarImageIdentityKey(image)} + size={size} + name={name} + image={image} + label={label} + /> + </Box> ) }) +function getAvatarLabel({ + alt, + name, + 'aria-label': ariaLabel, +}: Pick<AvatarProps, 'alt' | 'name' | 'aria-label'>) { + return ariaLabel ?? alt ?? normalizeAvatarName(name) +} + +type AvatarImageProps = { + size: AvatarSize + name?: string + image?: AvatarImageProp + label?: string +} + +function AvatarImage({ size, name, image, label }: AvatarImageProps) { + const imageSources = getSources(image, size) + const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) + const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) + const initials = availableImageSources ? '' : getInitials(name) + const hasInitials = initials !== '' + + if (availableImageSources) { + return ( + <img + className={styles.image} + src={availableImageSources.src} + srcSet={availableImageSources.srcSet} + sizes={availableImageSources.sizes} + alt={label} + onError={(event) => { + const failedSource = getFailedImageSource( + availableImageSources, + event.currentTarget, + ) + + setFailedImageSources((currentFailedSources) => + currentFailedSources.includes(failedSource) + ? currentFailedSources + : [...currentFailedSources, failedSource], + ) + }} + /> + ) + } + if (hasInitials) { + return ( + <div + className={classNames( + styles.initials, + styles[`meta-color-${getAvatarMetaColorIndex(name)}`], + )} + role={label ? 'img' : undefined} + aria-label={label} + > + {initials} + </div> + ) + } + + return null +} + function getAbsoluteImageSource(src: string, image: HTMLImageElement) { try { return new URL(src, image.ownerDocument.baseURI).href @@ -202,4 +216,5 @@ function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) } export { Avatar } -export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } +export type { AvatarProps } +export type { AvatarImage, AvatarShape, AvatarSize } from './utils' From 30fca0180e4fb9ce4687116a787042b81b7a3456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Thu, 28 May 2026 15:02:05 -0500 Subject: [PATCH 40/41] refactor: More aria-hidden to AvatarImage --- src/avatar/avatar.test.tsx | 7 +++++-- src/avatar/avatar.tsx | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index e5c1b656..7caf8963 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -267,7 +267,8 @@ describe('Avatar', () => { expect(screen.queryByRole('img')).not.toBeInTheDocument() expect(screen.getByAltText('')).toHaveAttribute('src', 'avatar.png') - expect(screen.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByAltText('')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('avatar')).not.toHaveAttribute('aria-hidden') }) it('supports decorative image avatars with empty alt text', () => { @@ -275,13 +276,15 @@ describe('Avatar', () => { expect(screen.queryByRole('img')).not.toBeInTheDocument() expect(screen.getByAltText('')).toHaveAttribute('src', 'avatar.png') + expect(screen.getByAltText('')).toHaveAttribute('aria-hidden', 'true') }) it('supports decorative initials avatars with empty alt text', () => { render(<Avatar data-testid="avatar" size={36} name="Jane Doe" alt="" />) expect(screen.queryByRole('img')).not.toBeInTheDocument() - expect(screen.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByText('JD')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('avatar')).not.toHaveAttribute('aria-hidden') expect(screen.getByTestId('avatar')).toHaveTextContent('JD') }) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 4d1dfbe3..5615fb17 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -100,7 +100,7 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func ref, ) { const label = getAvatarLabel({ alt, name, 'aria-label': ariaLabel }) - const isDecorative = ariaHidden || label === '' + const isDecorative = Boolean(ariaHidden ?? label === '') return ( <Box @@ -113,7 +113,6 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func exceptionallySetClassName, )} data-testid={testId} - aria-hidden={isDecorative || undefined} display="inlineFlex" alignItems="center" justifyContent="center" @@ -130,6 +129,7 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func name={name} image={image} label={label} + aria-hidden={isDecorative} /> </Box> ) @@ -148,9 +148,10 @@ type AvatarImageProps = { name?: string image?: AvatarImageProp label?: string + 'aria-hidden'?: boolean } -function AvatarImage({ size, name, image, label }: AvatarImageProps) { +function AvatarImage({ size, name, image, label, 'aria-hidden': ariaHidden }: AvatarImageProps) { const imageSources = getSources(image, size) const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) @@ -165,6 +166,7 @@ function AvatarImage({ size, name, image, label }: AvatarImageProps) { srcSet={availableImageSources.srcSet} sizes={availableImageSources.sizes} alt={label} + aria-hidden={ariaHidden} onError={(event) => { const failedSource = getFailedImageSource( availableImageSources, @@ -189,6 +191,7 @@ function AvatarImage({ size, name, image, label }: AvatarImageProps) { )} role={label ? 'img' : undefined} aria-label={label} + aria-hidden={ariaHidden} > {initials} </div> From 79082f0e49bf71a918083f77d81d89a5283fd90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Thu, 28 May 2026 15:14:22 -0500 Subject: [PATCH 41/41] test: extract avatar image failure helper --- src/avatar/avatar.test.tsx | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 7caf8963..66c111b7 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -6,12 +6,16 @@ import { axe } from 'jest-axe' import { Avatar } from './avatar' describe('Avatar', () => { - function failCurrentAvatarImage(currentSrc: string) { + function failAvatarImage(currentSrc?: string) { const image = screen.getByRole('img', { name: 'Jane Doe' }) - Object.defineProperty(image, 'currentSrc', { - configurable: true, - value: currentSrc, - }) + + if (currentSrc) { + Object.defineProperty(image, 'currentSrc', { + configurable: true, + value: currentSrc, + }) + } + fireEvent.error(image) } @@ -77,7 +81,7 @@ describe('Avatar', () => { it('falls back to initials when the image fails to load', () => { render(<Avatar size={36} name="Jane Doe" image="missing.png" />) - fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + failAvatarImage() expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') }) @@ -85,7 +89,7 @@ describe('Avatar', () => { it('allows a new image to load after a failed image changes', () => { const { rerender } = render(<Avatar size={36} name="Jane Doe" image="missing.png" />) - fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + failAvatarImage() expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') rerender(<Avatar size={36} name="Jane Doe" image="avatar.png" />) @@ -99,7 +103,7 @@ describe('Avatar', () => { ) const avatarRoot = screen.getByTestId('avatar') - fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + failAvatarImage() rerender(<Avatar data-testid="avatar" size={36} name="Jane Doe" image="avatar.png" />) @@ -120,7 +124,7 @@ describe('Avatar', () => { />, ) - failCurrentAvatarImage('avatar-144.png') + failAvatarImage('avatar-144.png') const image = screen.getByRole('img', { name: 'Jane Doe' }) expect(image).toHaveAttribute('src', 'avatar-72.png') @@ -141,7 +145,7 @@ describe('Avatar', () => { />, ) - failCurrentAvatarImage(new URL('avatar-72.png', document.baseURI).href) + failAvatarImage(new URL('avatar-72.png', document.baseURI).href) const image = screen.getByRole('img', { name: 'Jane Doe' }) expect(image).toHaveAttribute('src', 'avatar-144.png') @@ -157,7 +161,7 @@ describe('Avatar', () => { } const { rerender } = render(<Avatar size={36} name="Jane Doe" image={image} />) - failCurrentAvatarImage('avatar-144.png') + failAvatarImage('avatar-144.png') rerender(<Avatar size={72} name="Jane Doe" image={image} />) @@ -179,8 +183,8 @@ describe('Avatar', () => { />, ) - failCurrentAvatarImage('avatar-72.png') - failCurrentAvatarImage('avatar-36.png') + failAvatarImage('avatar-72.png') + failAvatarImage('avatar-36.png') expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') }) @@ -188,7 +192,7 @@ describe('Avatar', () => { it('retries a failed image when the same image is provided after being removed', () => { const { rerender } = render(<Avatar size={36} name="Jane Doe" image="missing.png" />) - fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + failAvatarImage() expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') rerender(<Avatar size={36} name="Jane Doe" />)