From 9b6310b24d71611e7d42d3d017f3d725fd7f6929 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/57] 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 ee84719d1e005d5212280a6bb7706f272fd3dcb9 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/57] 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 0aee3f2dd337c7c3bde6c4204bb74032f98dd97c 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/57] 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 0cdebab335871320ba52d853b1fe8e6bdb62d0bd 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/57] 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 e9f0f0e6582ca12f8ae7435c43bd7f6d4ab30bc8 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/57] 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 b815073799ba9dcd41c1a917572ee20ddae22634 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/57] 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 4d52372dacb82300456831469986d01ca2fcfce3 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/57] 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 c69e709b413df4771ecc9e0a3f3d88d558194f75 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/57] 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 2c93a0e5e017e2ba0a81c315454f7f8dcb5b079e 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/57] 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 8af1637966472c050dffc7d4ac2383b836d41d4d 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/57] 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 0b805e7f5eb6d3a2e756d854be4cef298b5a0af0 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/57] 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 e2fcd1cf161d3da6f1d36f06f84f40407ef41af3 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/57] 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/57] 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/57] 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 728fefb9e4b60f5bb509ab4f8091afa82e2d4ef1 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/57] 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 438a8656ccf39600d937469a96f7b48bc2ebe8ab 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/57] 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 65d84fdeb2888ec0a4f329d01941d029a3d8e449 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/57] 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 592e220b69471d4102bef404f023700ef7778a81 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/57] 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 e887da4048a651b63674572683204ee674ed43a7 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/57] 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 97821be430cafcea7987acb4509aab508826ba60 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/57] 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 cb37567bade4ff2e0818c6311250bdc7f0b275a7 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/57] 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 9a3f9c39388588c0d111763612677ffd44be9c0e 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/57] 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 ab691c4e4ce4a3426122182025cec09439cfe795 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/57] 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 9f61a0725ad4c46f3eb0317af03fefe56ef729ab 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/57] 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 0b6b6c8c1651aec0904ae1c7ce2953a3a396bf6a 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/57] 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 8f6ff4971b811fc8eb04e03d7cc6bae64c5aa6a5 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/57] 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 be6aeeaee1938bf4b0a240628882eff770cd98ab 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/57] 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 f18bcf8bb8f8b619c6f66934011b4e0946bf6342 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/57] 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 e96ca5cae339a4bfce19810928c21b7b2131bd4a 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/57] 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 748df8817ddb2a697f3f35c59cff7c7cbd133ad1 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/57] 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 1f07bcc72aaf02f7abe6c8d962d18d38e0e49d57 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/57] 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 05b6b4e291bed2b83e5b1f9b332c1938d1913e0e 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/57] 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 12ccd13c36d150d8302b8424940860133a3dc3f2 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/57] 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 03e56cee386c81fa6d4c671f51168e4da77ccd11 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/57] 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 ee36f49094f667f76f992054e45c6f37f5b750dc 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/57] 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 5cde7958f689d1ff6df2503584e11972ced5c3c2 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/57] 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 fa2fc97c6af44d8459a41579e8c13a911622e7c4 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/57] 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 3e5e5792f1a7fa22019c2af438f0a98a5b4ae0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 13:21:05 -0500 Subject: [PATCH 38/57] feat: add AvatarGroup --- src/avatar/avatar.mdx | 16 ++++ src/avatar/avatar.module.css | 101 ++++++++++++++++++++++++ src/avatar/avatar.stories.tsx | 99 ++++++++++++++++++++++- src/avatar/avatar.test.tsx | 118 +++++++++++++++++++++++++++- src/avatar/avatar.tsx | 143 +++++++++++++++++++++++++++++++++- 5 files changed, 473 insertions(+), 4 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 7700841a..4df23d1c 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -25,6 +25,14 @@ and the deterministic meta color used when initials render. <Canvas of={AvatarStories.Default} /> +## Avatar groups + +Use `AvatarGroup` when a compact surface represents several people. Pass the +same `size` to the group and its direct `Avatar` children. The optional `count` +is rendered as plain numeric text on top of the final avatar. + +<Canvas of={AvatarStories.AvatarGroups} /> + ## Migrating from the legacy API The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or @@ -110,6 +118,7 @@ component appearance. The values shown below are the default values. <ColorItem title="--reactist-avatar-initials-color" colors={['#ffffff']} /> <ColorItem title="--reactist-avatar-border-tint" colors={['#0000001a']} /> <ColorItem title="--reactist-avatar-empty-fill" colors={['#e6e6e6']} /> + <ColorItem title="--reactist-avatar-group-count-overlay" colors={['rgba(0, 0, 0, 0.6)']} /> </ColorPalette> #### Avatar meta colors @@ -168,6 +177,13 @@ the component props instead of overriding them directly. --reactist-avatar-size: 36px; --reactist-avatar-rounded-radius: 5px; } + +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; +} ``` ## What the consumer owns diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index acaec40d..7df9a6ae 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -189,3 +189,104 @@ line-height: 1; user-select: none; } + +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; + --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); + --reactist-avatar-group-previous-center-x: calc( + (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + + var(--reactist-avatar-group-overlap) + ); + + position: relative; +} + +.avatarGroup > * { + flex-shrink: 0; +} + +.avatarGroup > * + * { + margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); +} + +.avatarGroupShape-circle > * + * { + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); +} + +.avatarGroupShape-rounded > * + * { + -webkit-mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); + mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); +} + +.avatarGroup[data-count]::after { + content: attr(data-count); + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--reactist-avatar-group-size); + height: var(--reactist-avatar-group-size); + border-radius: 50%; + background: var(--reactist-avatar-group-count-overlay); + color: var(--reactist-avatar-initials-color); + font-size: calc(var(--reactist-avatar-group-size) / 2); + font-weight: var(--reactist-font-weight-medium); + line-height: 1; + pointer-events: none; + user-select: none; + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); +} + +.avatarGroupShape-rounded[data-count]::after { + border-radius: var(--reactist-avatar-group-rounded-radius); + -webkit-mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); + mask-image: linear-gradient( + to right, + transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), + #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) + ); +} + +.avatarGroup[data-count]:has(> :first-child:last-child)::after { + -webkit-mask-image: none; + mask-image: none; +} diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index 720595d1..2f38b790 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { Avatar, Box, Inline, Stack, Text } from '../index' +import { Avatar, AvatarGroup, Box, Inline, Stack, Text } from '../index' import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils' @@ -168,6 +168,18 @@ function WorkspaceAvatarExample(props: Omit<AvatarProps, 'shape'>) { return <Avatar shape="rounded" {...props} /> } +function AvatarGroupCustomOverlayStyle() { + return ( + <style> + {` + .avatarGroupCustomOverlay { + --reactist-avatar-group-count-overlay: rgba(220, 76, 62, 0.72); + } + `} + </style> + ) +} + function AvatarColorExample({ index, name }: { index: number; name: string }) { return ( <AvatarExample label={`fill-${index}`}> @@ -218,6 +230,91 @@ export const Default = { ), } satisfies Story +export const AvatarGroups = { + render: () => ( + <StoryLayout> + <AvatarGroupCustomOverlayStyle /> + + <StorySection + title="User groups" + description="AvatarGroup overlaps direct Avatar children. Pass count when the final avatar represents additional people." + > + <Inline space="medium" alignY="top"> + <AvatarExample label="With count"> + <AvatarGroup size={36} count={3}> + {contributors.slice(1, 6).map((contributor) => ( + <UserAvatar + key={contributor.name} + size={36} + name={contributor.name} + image={getGithubAvatarUrl(contributor.githubUserId, 72)} + /> + ))} + </AvatarGroup> + </AvatarExample> + <AvatarExample label="No count"> + <AvatarGroup size={36}> + {contributors.slice(2, 5).map((contributor) => ( + <UserAvatar + key={contributor.name} + size={36} + name={contributor.name} + image={getGithubAvatarUrl(contributor.githubUserId, 72)} + /> + ))} + </AvatarGroup> + </AvatarExample> + <AvatarExample label="Custom overlay"> + <AvatarGroup + size={36} + count={9} + exceptionallySetClassName="avatarGroupCustomOverlay" + > + {contributors.slice(3, 7).map((contributor) => ( + <UserAvatar + key={contributor.name} + size={36} + name={contributor.name} + image={getGithubAvatarUrl(contributor.githubUserId, 72)} + /> + ))} + </AvatarGroup> + </AvatarExample> + </Inline> + </StorySection> + + <StorySection + title="Size-dependent spacing" + description="Overlap and transparent mask width are derived from the group size." + > + <Inline space="medium" alignY="top"> + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + <AvatarExample key={size} label={`${size}px`}> + <AvatarGroup size={size} count={3}> + {[0, 1, 2].map((offset) => { + const contributor = getContributor(index + offset)! + + return ( + <UserAvatar + key={contributor.name} + size={size} + name={contributor.name} + image={getGithubSourceMap( + contributor.githubUserId, + size, + )} + /> + ) + })} + </AvatarGroup> + </AvatarExample> + ))} + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + export const InitialsFallback = { render: () => ( <StoryLayout> diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index e78137b3..13886507 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { Avatar } from './avatar' +import { Avatar, AvatarGroup } from './avatar' describe('Avatar', () => { function failCurrentAvatarImage(currentSrc: string) { @@ -303,3 +303,119 @@ describe('Avatar', () => { }) }) }) + +describe('AvatarGroup', () => { + it('renders direct Avatar children without wrappers', () => { + render( + <AvatarGroup data-testid="group" size={36}> + <Avatar data-testid="first" size={36} name="Jane Doe" /> + <Avatar data-testid="second" size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('first')) + expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('second')) + expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('group')) + expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('group')) + }) + + it('sets size-derived spacing variables', () => { + render( + <AvatarGroup data-testid="group" size={36}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '36px', + '--reactist-avatar-group-overlap': '4px', + '--reactist-avatar-group-mask': '2.5px', + }) + }) + + it('sets large size-derived spacing variables', () => { + render( + <AvatarGroup data-testid="group" size={80}> + <Avatar size={80} name="Jane Doe" /> + <Avatar size={80} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '80px', + '--reactist-avatar-group-overlap': '8px', + '--reactist-avatar-group-mask': '3px', + }) + }) + + it('exposes positive count through data-count', () => { + render( + <AvatarGroup data-testid="group" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveAttribute('data-count', '3') + }) + + it('omits data-count when count is not positive', () => { + render( + <AvatarGroup data-testid="group" size={36} count={0}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + }) + + it('omits data-count when count is not provided', () => { + render( + <AvatarGroup data-testid="group" size={36}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + }) + + it('leaves the count overlay custom property available for CSS customization', () => { + render( + <AvatarGroup data-testid="group" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect( + screen + .getByTestId('group') + .style.getPropertyValue('--reactist-avatar-group-count-overlay'), + ).toBe('') + }) + + it('applies the group shape class', () => { + render( + <AvatarGroup data-testid="group" size={36} shape="rounded"> + <Avatar size={36} shape="rounded" name="Workspace" /> + <Avatar size={36} shape="rounded" name="Design System" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveClass('avatarGroupShape-rounded') + }) + + it('applies the escape hatch class name', () => { + render( + <AvatarGroup data-testid="group" size={36} exceptionallySetClassName="custom-group"> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveClass('custom-group') + }) +}) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 9704dbc7..54259d5b 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -26,6 +26,45 @@ type AvatarStyle = React.CSSProperties & { '--reactist-avatar-rounded-radius': string } +type AvatarGroupStyle = React.CSSProperties & { + '--reactist-avatar-group-size': string + '--reactist-avatar-group-overlap': string + '--reactist-avatar-group-mask': string + '--reactist-avatar-group-rounded-radius': string +} + +const AVATAR_GROUP_OVERLAP_BY_SIZE: Record<AvatarSize, string> = { + 80: '8px', + 72: '8px', + 62: '8px', + 50: '4px', + 40: '4px', + 36: '4px', + 30: '2px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '2px', + 16: '2px', + 12: '1px', +} + +const AVATAR_GROUP_MASK_BY_SIZE: Record<AvatarSize, string> = { + 80: '3px', + 72: '3px', + 62: '3px', + 50: '3px', + 40: '3px', + 36: '2.5px', + 30: '2.5px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '1.5px', + 16: '1.25px', + 12: '1px', +} + /** * Props for the `Avatar` component. */ @@ -84,6 +123,54 @@ type AvatarProps<ComponentType extends React.ElementType = 'div'> = PolymorphicC 'omitClassName' > +/** + * Props for the `AvatarGroup` component. + */ +type AvatarGroupOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The grouped avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * The number of additional people represented by the final avatar. + */ + count?: number + + /** + * Grouped Avatar children. + */ + children: React.ReactNode + + /** + * Test identifier applied to the avatar group root element. + */ + 'data-testid'?: string + + /** + * AvatarGroup owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarGroupProps<ComponentType extends React.ElementType = 'div'> = PolymorphicComponentProps< + ComponentType, + AvatarGroupOwnProps, + 'omitClassName' +> + const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>( function AvatarContent( { @@ -190,6 +277,49 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func ) }) +/** + * Displays a row of overlapping Avatar children with an optional count overlay + * on the final avatar. + */ +const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassName'>( + function AvatarGroup( + { + as, + size, + shape = 'circle', + count, + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + const countAttribute = count != null && count > 0 ? String(count) : undefined + + return ( + <Box + as={as} + ref={ref} + className={classNames( + styles.avatarGroup, + styles[`avatarGroupShape-${shape}`], + exceptionallySetClassName, + )} + style={getAvatarGroupStyle(size)} + data-count={countAttribute} + data-testid={testId} + display="inlineFlex" + alignItems="center" + position="relative" + {...restProps} + > + {children} + </Box> + ) + }, +) + function getAvatarStyle(size: AvatarSize): AvatarStyle { return { '--reactist-avatar-size': `${size}px`, @@ -197,6 +327,15 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } } +function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { + return { + '--reactist-avatar-group-size': `${size}px`, + '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], + '--reactist-avatar-group-mask': AVATAR_GROUP_MASK_BY_SIZE[size], + '--reactist-avatar-group-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], + } +} + function getAbsoluteImageSource(src: string, image: HTMLImageElement) { try { return new URL(src, image.ownerDocument.baseURI).href @@ -214,5 +353,5 @@ function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) return matchingSource?.src ?? imageProps.src } -export { Avatar } -export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } +export { Avatar, AvatarGroup } +export type { AvatarGroupProps, AvatarImage, AvatarProps, AvatarShape, AvatarSize } From 1f1b1d54f118ac9c5159c28c670c05917c7de5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 13:59:39 -0500 Subject: [PATCH 39/57] fix: round avatar group overlap masks --- src/avatar/avatar.module.css | 90 +++++++++++++++++++++++++++--------- src/avatar/avatar.test.tsx | 2 + src/avatar/avatar.tsx | 9 +++- 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 7df9a6ae..d8198fe8 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -195,7 +195,14 @@ --reactist-avatar-group-overlap: 4px; --reactist-avatar-group-mask: 2.5px; --reactist-avatar-group-rounded-radius: 5px; + --reactist-avatar-group-rounded-mask-radius: 7.5px; --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); + --reactist-avatar-group-rounded-mask-width: calc( + var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask) + ); + --reactist-avatar-group-rounded-mask-corner-x: calc( + var(--reactist-avatar-group-overlap) - var(--reactist-avatar-group-rounded-radius) + ); --reactist-avatar-group-previous-center-x: calc( (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + var(--reactist-avatar-group-overlap) @@ -227,17 +234,68 @@ ); } -.avatarGroupShape-rounded > * + * { - -webkit-mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); - mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); +.avatarGroupShape-rounded > * + *, +.avatarGroupShape-rounded[data-count]::after { + -webkit-mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + #000 99%, + transparent 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + calc(100% - var(--reactist-avatar-group-rounded-radius)), + #000 99%, + transparent 100% + ), + linear-gradient(#000 0 0); + mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + #000 99%, + transparent 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + calc(100% - var(--reactist-avatar-group-rounded-radius)), + #000 99%, + transparent 100% + ), + linear-gradient(#000 0 0); + -webkit-mask-position: + left center, + 0 0, + 0 0, + 0 0; + mask-position: + left center, + 0 0, + 0 0, + 0 0; + -webkit-mask-size: + var(--reactist-avatar-group-rounded-mask-width) + calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), + 100% 100%, + 100% 100%, + 100% 100%; + mask-size: + var(--reactist-avatar-group-rounded-mask-width) + calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), + 100% 100%, + 100% 100%, + 100% 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-composite: destination-out, destination-out, destination-out; + mask-composite: subtract; } .avatarGroup[data-count]::after { @@ -274,16 +332,6 @@ .avatarGroupShape-rounded[data-count]::after { border-radius: var(--reactist-avatar-group-rounded-radius); - -webkit-mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); - mask-image: linear-gradient( - to right, - transparent 0 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)), - #000 calc(var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask)) - ); } .avatarGroup[data-count]:has(> :first-child:last-child)::after { diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 13886507..05b2f8dc 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -331,6 +331,7 @@ describe('AvatarGroup', () => { '--reactist-avatar-group-size': '36px', '--reactist-avatar-group-overlap': '4px', '--reactist-avatar-group-mask': '2.5px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(5px + 2.5px)', }) }) @@ -346,6 +347,7 @@ describe('AvatarGroup', () => { '--reactist-avatar-group-size': '80px', '--reactist-avatar-group-overlap': '8px', '--reactist-avatar-group-mask': '3px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(10px + 3px)', }) }) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 54259d5b..72e67ad5 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -31,6 +31,7 @@ type AvatarGroupStyle = React.CSSProperties & { '--reactist-avatar-group-overlap': string '--reactist-avatar-group-mask': string '--reactist-avatar-group-rounded-radius': string + '--reactist-avatar-group-rounded-mask-radius': string } const AVATAR_GROUP_OVERLAP_BY_SIZE: Record<AvatarSize, string> = { @@ -328,11 +329,15 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { + const mask = AVATAR_GROUP_MASK_BY_SIZE[size] + const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] + return { '--reactist-avatar-group-size': `${size}px`, '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], - '--reactist-avatar-group-mask': AVATAR_GROUP_MASK_BY_SIZE[size], - '--reactist-avatar-group-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], + '--reactist-avatar-group-mask': mask, + '--reactist-avatar-group-rounded-radius': roundedRadius, + '--reactist-avatar-group-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, } } From ed729e85f573b185db52f646964d77450d3f4cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 14:00:12 -0500 Subject: [PATCH 40/57] docs: add avatar group stories --- src/avatar/avatar-group.stories.tsx | 309 ++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 src/avatar/avatar-group.stories.tsx diff --git a/src/avatar/avatar-group.stories.tsx b/src/avatar/avatar-group.stories.tsx new file mode 100644 index 00000000..edc4d145 --- /dev/null +++ b/src/avatar/avatar-group.stories.tsx @@ -0,0 +1,309 @@ +import * as React from 'react' + +import { Avatar, AvatarGroup, Box, Inline, Stack, Text } from '../index' + +import type { Meta, StoryObj } from '@storybook/react-vite' + +const contributors = [ + { name: 'pawel', githubUserId: '61894375' }, + { name: 'craig', githubUserId: '1305500' }, + { name: 'rui', githubUserId: '3165500' }, + { name: 'ricardo', githubUserId: '96476' }, + { name: 'scott', githubUserId: '25244878' }, + { name: 'francesca', githubUserId: '1509326' }, +] as const + +const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] 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 ( + <Stack as="section" exceptionallySetClassName="story" space="large"> + {children} + </Stack> + ) +} + +function StorySection({ + title, + description, + children, +}: { + title: string + description?: string + children: React.ReactNode +}) { + return ( + <Stack space="small"> + <Stack space="xsmall"> + <Text weight="semibold">{title}</Text> + {description ? ( + <Text size="copy" tone="secondary"> + {description} + </Text> + ) : null} + </Stack> + {children} + </Stack> + ) +} + +function AvatarExample({ label, children }: { label: string; children: React.ReactNode }) { + return ( + <Box width="fitContent"> + <Stack space="xsmall" align="center"> + {children} + <Text size="caption" tone="secondary" align="center"> + {label} + </Text> + </Stack> + </Box> + ) +} + +function UserAvatar({ + contributor, + size, +}: { + contributor: (typeof contributors)[number] + size: React.ComponentProps<typeof Avatar>['size'] +}) { + return ( + <Avatar + size={size} + name={contributor.name} + image={getGithubSourceMap(contributor.githubUserId, size)} + /> + ) +} + +function WorkspaceAvatar({ + name, + size, +}: { + name: string + size: React.ComponentProps<typeof Avatar>['size'] +}) { + return <Avatar size={size} shape="rounded" name={name} /> +} + +function CustomOverlayStyle() { + return ( + <style> + {` + .avatarGroupCustomOverlay { + --reactist-avatar-group-count-overlay: rgba(220, 76, 62, 0.72); + } + `} + </style> + ) +} + +const meta = { + title: 'Components/Avatar/AvatarGroup', + component: AvatarGroup, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta<typeof AvatarGroup> + +export default meta + +type Story = StoryObj<typeof meta> + +export const People = { + render: () => ( + <StoryLayout> + <StorySection + title="People groups" + description="Circular groups use overlapping image avatars with a count overlay when more people are represented." + > + <Inline space="medium" alignY="top"> + <AvatarExample label="Team"> + <AvatarGroup size={36} count={4}> + {contributors.slice(0, 5).map((contributor) => ( + <UserAvatar + key={contributor.name} + contributor={contributor} + size={36} + /> + ))} + </AvatarGroup> + </AvatarExample> + <AvatarExample label="Reviewers"> + <AvatarGroup size={30}> + {contributors.slice(1, 4).map((contributor) => ( + <UserAvatar + key={contributor.name} + contributor={contributor} + size={30} + /> + ))} + </AvatarGroup> + </AvatarExample> + <AvatarExample label="Compact"> + <AvatarGroup size={24} count={8}> + {contributors.slice(2, 5).map((contributor) => ( + <UserAvatar + key={contributor.name} + contributor={contributor} + size={24} + /> + ))} + </AvatarGroup> + </AvatarExample> + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const Workspaces = { + render: () => ( + <StoryLayout> + <StorySection + title="Workspace groups" + description="Rounded groups preserve the workspace avatar corners while clipping the overlap." + > + <Inline space="medium" alignY="top"> + <AvatarExample label="Product suite"> + <AvatarGroup size={36} shape="rounded" count={2}> + {workspaceNames.map((name) => ( + <WorkspaceAvatar key={name} size={36} name={name} /> + ))} + </AvatarGroup> + </AvatarExample> + <AvatarExample label="Two workspaces"> + <AvatarGroup size={40} shape="rounded"> + <WorkspaceAvatar size={40} name="Reactist" /> + <WorkspaceAvatar size={40} name="Todoist" /> + </AvatarGroup> + </AvatarExample> + <AvatarExample label="Small rounded"> + <AvatarGroup size={24} shape="rounded" count={5}> + {workspaceNames.slice(0, 3).map((name) => ( + <WorkspaceAvatar key={name} size={24} name={name} /> + ))} + </AvatarGroup> + </AvatarExample> + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const Sizes = { + render: () => ( + <StoryLayout> + <StorySection + title="Circle sizes" + description="Overlap and mask margin scale with the avatar size." + > + <Inline space="medium" alignY="top"> + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + <AvatarExample key={size} label={`${size}px`}> + <AvatarGroup size={size} count={3}> + {[0, 1, 2].map((offset) => ( + <UserAvatar + key={getContributor(index + offset)!.name} + contributor={getContributor(index + offset)!} + size={size} + /> + ))} + </AvatarGroup> + </AvatarExample> + ))} + </Inline> + </StorySection> + + <StorySection + title="Rounded sizes" + description="Rounded masks use the avatar radius plus the mask margin." + > + <Inline space="medium" alignY="top"> + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + <AvatarExample key={size} label={`${size}px`}> + <AvatarGroup size={size} shape="rounded" count={3}> + {[0, 1, 2].map((offset) => ( + <WorkspaceAvatar + key={`${size}-${offset}`} + size={size} + name={ + workspaceNames[ + (index + offset) % workspaceNames.length + ]! + } + /> + ))} + </AvatarGroup> + </AvatarExample> + ))} + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story + +export const CountOverlay = { + render: () => ( + <StoryLayout> + <CustomOverlayStyle /> + + <StorySection + title="Count overlays" + description="The count overlay inherits the same clipping behavior as the final avatar." + > + <Inline space="medium" alignY="top"> + <AvatarExample label="Default overlay"> + <AvatarGroup size={36} count={9}> + {contributors.slice(0, 4).map((contributor) => ( + <UserAvatar + key={contributor.name} + contributor={contributor} + size={36} + /> + ))} + </AvatarGroup> + </AvatarExample> + <AvatarExample label="Custom overlay"> + <AvatarGroup + size={36} + count={9} + exceptionallySetClassName="avatarGroupCustomOverlay" + > + {contributors.slice(1, 5).map((contributor) => ( + <UserAvatar + key={contributor.name} + contributor={contributor} + size={36} + /> + ))} + </AvatarGroup> + </AvatarExample> + <AvatarExample label="Rounded overlay"> + <AvatarGroup size={36} shape="rounded" count={9}> + {workspaceNames.map((name) => ( + <WorkspaceAvatar key={name} size={36} name={name} /> + ))} + </AvatarGroup> + </AvatarExample> + </Inline> + </StorySection> + </StoryLayout> + ), +} satisfies Story From 551ba1951caa8394cc97535ce855bb044a08ccd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 14:02:50 -0500 Subject: [PATCH 41/57] fix: render rounded avatar group masks directly --- src/avatar/avatar.module.css | 60 +++++++++++++++--------------------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index d8198fe8..066b7e20 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -242,60 +242,48 @@ circle var(--reactist-avatar-group-rounded-mask-radius) at var(--reactist-avatar-group-rounded-mask-corner-x) var(--reactist-avatar-group-rounded-radius), - #000 99%, - transparent 100% + transparent 99%, + #000 100% ), radial-gradient( circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) - calc(100% - var(--reactist-avatar-group-rounded-radius)), - #000 99%, - transparent 100% - ), - linear-gradient(#000 0 0); + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); mask-image: linear-gradient(#000 0 0), radial-gradient( circle var(--reactist-avatar-group-rounded-mask-radius) at var(--reactist-avatar-group-rounded-mask-corner-x) var(--reactist-avatar-group-rounded-radius), - #000 99%, - transparent 100% + transparent 99%, + #000 100% ), radial-gradient( circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) - calc(100% - var(--reactist-avatar-group-rounded-radius)), - #000 99%, - transparent 100% - ), - linear-gradient(#000 0 0); + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); -webkit-mask-position: - left center, - 0 0, - 0 0, - 0 0; + right top, + left top, + left bottom; mask-position: - left center, - 0 0, - 0 0, - 0 0; + right top, + left top, + left bottom; -webkit-mask-size: - var(--reactist-avatar-group-rounded-mask-width) - calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), - 100% 100%, - 100% 100%, - 100% 100%; + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); mask-size: - var(--reactist-avatar-group-rounded-mask-width) - calc(100% - (2 * var(--reactist-avatar-group-rounded-radius))), - 100% 100%, - 100% 100%, - 100% 100%; + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; - -webkit-mask-composite: destination-out, destination-out, destination-out; - mask-composite: subtract; } .avatarGroup[data-count]::after { From f61655f3930356637a9adc00fbf6da1c17516625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 14:29:32 -0500 Subject: [PATCH 42/57] fix: Apply rounded mask corectly --- src/avatar/avatar.module.css | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 066b7e20..0b08d89b 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -219,7 +219,8 @@ margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); } -.avatarGroupShape-circle > * + * { +.avatarGroupShape-circle > * + *, +.avatarGroupShape-circle[data-count]::after { -webkit-mask-image: radial-gradient( circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at var(--reactist-avatar-group-previous-center-x) 50%, @@ -304,18 +305,6 @@ line-height: 1; pointer-events: none; user-select: none; - -webkit-mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, - transparent 99%, - #000 100% - ); - mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, - transparent 99%, - #000 100% - ); } .avatarGroupShape-rounded[data-count]::after { From f0feb0c18f958e17523d0f5ace9903093c480022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 14:59:19 -0500 Subject: [PATCH 43/57] fix: render avatar group count as DOM element Replace ::after pseudo with a real span so the count is in the DOM (better SR support, queryable in tests). Drop data-count attribute; use class-based selectors. Visual now reads "+N" instead of bare N. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- src/avatar/avatar.module.css | 13 +++++-------- src/avatar/avatar.test.tsx | 23 +++++++++++++++++------ src/avatar/avatar.tsx | 6 ++++-- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 0b08d89b..4a4f3a6a 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -219,8 +219,7 @@ margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); } -.avatarGroupShape-circle > * + *, -.avatarGroupShape-circle[data-count]::after { +.avatarGroupShape-circle > * + * { -webkit-mask-image: radial-gradient( circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at var(--reactist-avatar-group-previous-center-x) 50%, @@ -235,8 +234,7 @@ ); } -.avatarGroupShape-rounded > * + *, -.avatarGroupShape-rounded[data-count]::after { +.avatarGroupShape-rounded > * + * { -webkit-mask-image: linear-gradient(#000 0 0), radial-gradient( @@ -287,8 +285,7 @@ mask-repeat: no-repeat; } -.avatarGroup[data-count]::after { - content: attr(data-count); +.avatarGroupCount { position: absolute; top: 0; right: 0; @@ -307,11 +304,11 @@ user-select: none; } -.avatarGroupShape-rounded[data-count]::after { +.avatarGroupShape-rounded > .avatarGroupCount { border-radius: var(--reactist-avatar-group-rounded-radius); } -.avatarGroup[data-count]:has(> :first-child:last-child)::after { +.avatarGroup > .avatarGroupCount:nth-child(2) { -webkit-mask-image: none; mask-image: none; } diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 05b2f8dc..1b5fdf1e 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -351,7 +351,7 @@ describe('AvatarGroup', () => { }) }) - it('exposes positive count through data-count', () => { + it('renders the count overlay when count is positive', () => { render( <AvatarGroup data-testid="group" size={36} count={3}> <Avatar size={36} name="Jane Doe" /> @@ -359,10 +359,10 @@ describe('AvatarGroup', () => { </AvatarGroup>, ) - expect(screen.getByTestId('group')).toHaveAttribute('data-count', '3') + expect(screen.getByText('+3')).toBeInTheDocument() }) - it('omits data-count when count is not positive', () => { + it('omits the count overlay when count is not positive', () => { render( <AvatarGroup data-testid="group" size={36} count={0}> <Avatar size={36} name="Jane Doe" /> @@ -370,10 +370,10 @@ describe('AvatarGroup', () => { </AvatarGroup>, ) - expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() }) - it('omits data-count when count is not provided', () => { + it('omits the count overlay when count is not provided', () => { render( <AvatarGroup data-testid="group" size={36}> <Avatar size={36} name="Jane Doe" /> @@ -381,7 +381,18 @@ describe('AvatarGroup', () => { </AvatarGroup>, ) - expect(screen.getByTestId('group')).not.toHaveAttribute('data-count') + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() + }) + + it('renders the count overlay alongside a single avatar', () => { + render( + <AvatarGroup data-testid="group" size={36} count={4}> + <Avatar size={36} name="Jane Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByText('+4')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument() }) it('leaves the count overlay custom property available for CSS customization', () => { diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 72e67ad5..293d9ef5 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -296,7 +296,7 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN }, ref, ) { - const countAttribute = count != null && count > 0 ? String(count) : undefined + const overflowCount = count != null && count > 0 ? count : null return ( <Box @@ -308,7 +308,6 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN exceptionallySetClassName, )} style={getAvatarGroupStyle(size)} - data-count={countAttribute} data-testid={testId} display="inlineFlex" alignItems="center" @@ -316,6 +315,9 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN {...restProps} > {children} + {overflowCount !== null ? ( + <span className={styles.avatarGroupCount}>{`+${overflowCount}`}</span> + ) : null} </Box> ) }, From 91f09c167bf9c71d4615596d72f34e757f9d0254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 21:36:44 -0500 Subject: [PATCH 44/57] test: cover avatar group with axe --- src/avatar/avatar.test.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 1b5fdf1e..98f5906e 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -431,4 +431,32 @@ describe('AvatarGroup', () => { expect(screen.getByTestId('group')).toHaveClass('custom-group') }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + <AvatarGroup size={36}> + <Avatar size={36} name="Jane Doe" image="avatar.png" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup> + <AvatarGroup size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup> + <AvatarGroup size={36} shape="rounded" count={5}> + <Avatar size={36} shape="rounded" name="Reactist" /> + <Avatar size={36} shape="rounded" name="Todoist" /> + </AvatarGroup> + <AvatarGroup as="button" aria-label="Manage 5 members" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup> + </>, + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) }) From fc112b95bd9ce782ce3281af815c2cfe44b89448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 21:37:13 -0500 Subject: [PATCH 45/57] docs: clarify avatar group count --- src/avatar/avatar.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 4df23d1c..266757d6 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -29,7 +29,9 @@ and the deterministic meta color used when initials render. Use `AvatarGroup` when a compact surface represents several people. Pass the same `size` to the group and its direct `Avatar` children. The optional `count` -is rendered as plain numeric text on top of the final avatar. +is rendered as `+N` text on top of the final avatar. When the group represents +a labeled entity (a button, link, or labeled region), supply an +`aria-label` that conveys the count to assistive tech. <Canvas of={AvatarStories.AvatarGroups} /> From dc206a4bf39a8e8383367a608b4b178f6e3f67db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 15:00:24 -0500 Subject: [PATCH 46/57] refactor: drop non-null assertions in avatar collection stories Tighten getContributor / getWorkspaceName return types and extract the workspace helper so callers don't need ! everywhere. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- src/avatar/avatar-group.stories.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/avatar/avatar-group.stories.tsx b/src/avatar/avatar-group.stories.tsx index edc4d145..0247c474 100644 --- a/src/avatar/avatar-group.stories.tsx +++ b/src/avatar/avatar-group.stories.tsx @@ -15,8 +15,12 @@ const contributors = [ const workspaceNames = ['Reactist', 'Todoist', 'Twist', 'Doist'] as const -function getContributor(index: number) { - return contributors[index % contributors.length] +function getContributor(index: number): (typeof contributors)[number] { + return contributors[index % contributors.length]! +} + +function getWorkspaceName(index: number): (typeof workspaceNames)[number] { + return workspaceNames[index % workspaceNames.length]! } function getGithubAvatarUrl(githubUserId: string, width: number) { @@ -220,8 +224,8 @@ export const Sizes = { <AvatarGroup size={size} count={3}> {[0, 1, 2].map((offset) => ( <UserAvatar - key={getContributor(index + offset)!.name} - contributor={getContributor(index + offset)!} + key={getContributor(index + offset).name} + contributor={getContributor(index + offset)} size={size} /> ))} @@ -243,11 +247,7 @@ export const Sizes = { <WorkspaceAvatar key={`${size}-${offset}`} size={size} - name={ - workspaceNames[ - (index + offset) % workspaceNames.length - ]! - } + name={getWorkspaceName(index + offset)} /> ))} </AvatarGroup> From 9cccd19a237a46c98d09e4c3ca491b36b0941746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 19:04:30 -0500 Subject: [PATCH 47/57] fix: hide avatar group count overlay from assistive tech Count "+N" overlay is purely visual; mark aria-hidden so screen readers don't announce the literal "+3" after the named avatars. Group-level aria-label remains the way to convey count semantically. --- src/avatar/avatar.mdx | 7 ++++--- src/avatar/avatar.test.tsx | 11 +++++++++++ src/avatar/avatar.tsx | 4 +++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 266757d6..0d8f0562 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -29,9 +29,10 @@ and the deterministic meta color used when initials render. Use `AvatarGroup` when a compact surface represents several people. Pass the same `size` to the group and its direct `Avatar` children. The optional `count` -is rendered as `+N` text on top of the final avatar. When the group represents -a labeled entity (a button, link, or labeled region), supply an -`aria-label` that conveys the count to assistive tech. +is rendered as a decorative `+N` visual on top of the final avatar and is +hidden from assistive tech. When the group represents a labeled entity (a +button, link, or labeled region), supply an `aria-label` on the group that +conveys the count to assistive tech. <Canvas of={AvatarStories.AvatarGroups} /> diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 98f5906e..f27c081d 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -362,6 +362,17 @@ describe('AvatarGroup', () => { expect(screen.getByText('+3')).toBeInTheDocument() }) + it('hides the count overlay from assistive tech', () => { + render( + <AvatarGroup data-testid="group" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByText('+3')).toHaveAttribute('aria-hidden', 'true') + }) + it('omits the count overlay when count is not positive', () => { render( <AvatarGroup data-testid="group" size={36} count={0}> diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 293d9ef5..856a1f95 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -316,7 +316,9 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN > {children} {overflowCount !== null ? ( - <span className={styles.avatarGroupCount}>{`+${overflowCount}`}</span> + <span className={styles.avatarGroupCount} aria-hidden="true"> + {`+${overflowCount}`} + </span> ) : null} </Box> ) From 908d4c71c6807a510cd4bac60e521b0cc7d3d61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 19:07:35 -0500 Subject: [PATCH 48/57] refactor: suppress avatar group count overlay mask unconditionally The overlay is a sibling of the avatars and inherits the sibling mask that visually notches each avatar onto the previous one. Suppress it on .avatarGroupCount directly instead of via :nth-child(2), so the rule holds regardless of how many avatars the group contains. --- src/avatar/avatar.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 4a4f3a6a..59af5e79 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -308,7 +308,7 @@ border-radius: var(--reactist-avatar-group-rounded-radius); } -.avatarGroup > .avatarGroupCount:nth-child(2) { +.avatarGroup > .avatarGroupCount { -webkit-mask-image: none; mask-image: none; } From f1b9ab6eeee4bf505800c4001ab55859a02d9514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 19:08:37 -0500 Subject: [PATCH 49/57] refactor: simplify avatar group overflow count expression count is number | undefined; the explicit != null guard was redundant with the > 0 check. --- src/avatar/avatar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 856a1f95..95317fc7 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -296,7 +296,7 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN }, ref, ) { - const overflowCount = count != null && count > 0 ? count : null + const overflowCount = count && count > 0 ? count : null return ( <Box From bed92d0be6239c17d73dbd092235fcd19306e9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 19:08:55 -0500 Subject: [PATCH 50/57] docs: clarify AvatarGroup count is a decorative overlay The count is rendered as a separate +N sibling, not "represented by the final avatar" as the previous JSDoc suggested. Also notes that the overlay is aria-hidden. --- src/avatar/avatar.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 95317fc7..4bc0e167 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -145,7 +145,9 @@ type AvatarGroupOwnProps = ObfuscatedClassName & { shape?: AvatarShape /** - * The number of additional people represented by the final avatar. + * Additional people not shown in the group. When positive, rendered as a + * decorative `+N` overlay on top of the final avatar; hidden from + * assistive tech. */ count?: number From ac61d3ea8c243b07be256dfef7ec6a581b6f9ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 22:15:02 -0500 Subject: [PATCH 51/57] refactor: split AvatarGroup into own files --- src/avatar/avatar-group.test.tsx | 175 +++++++++++++++++++++++++++++++ src/avatar/avatar-group.tsx | 167 +++++++++++++++++++++++++++++ src/avatar/avatar.mdx | 3 +- src/avatar/avatar.stories.tsx | 99 +---------------- src/avatar/avatar.test.tsx | 170 +----------------------------- src/avatar/avatar.tsx | 154 +-------------------------- src/avatar/index.ts | 1 + 7 files changed, 349 insertions(+), 420 deletions(-) create mode 100644 src/avatar/avatar-group.test.tsx create mode 100644 src/avatar/avatar-group.tsx diff --git a/src/avatar/avatar-group.test.tsx b/src/avatar/avatar-group.test.tsx new file mode 100644 index 00000000..5c1fc9da --- /dev/null +++ b/src/avatar/avatar-group.test.tsx @@ -0,0 +1,175 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' + +import { Avatar } from './avatar' +import { AvatarGroup } from './avatar-group' + +describe('AvatarGroup', () => { + it('renders direct Avatar children without wrappers', () => { + render( + <AvatarGroup data-testid="group" size={36}> + <Avatar data-testid="first" size={36} name="Jane Doe" /> + <Avatar data-testid="second" size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('first')) + expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('second')) + expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('group')) + expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('group')) + }) + + it('sets size-derived spacing variables', () => { + render( + <AvatarGroup data-testid="group" size={36}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '36px', + '--reactist-avatar-group-overlap': '4px', + '--reactist-avatar-group-mask': '2.5px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(5px + 2.5px)', + }) + }) + + it('sets large size-derived spacing variables', () => { + render( + <AvatarGroup data-testid="group" size={80}> + <Avatar size={80} name="Jane Doe" /> + <Avatar size={80} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '80px', + '--reactist-avatar-group-overlap': '8px', + '--reactist-avatar-group-mask': '3px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(10px + 3px)', + }) + }) + + it('renders the count overlay when count is positive', () => { + render( + <AvatarGroup data-testid="group" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByText('+3')).toBeInTheDocument() + }) + + it('hides the count overlay from assistive tech', () => { + render( + <AvatarGroup data-testid="group" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByText('+3')).toHaveAttribute('aria-hidden', 'true') + }) + + it('omits the count overlay when count is not positive', () => { + render( + <AvatarGroup data-testid="group" size={36} count={0}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() + }) + + it('omits the count overlay when count is not provided', () => { + render( + <AvatarGroup data-testid="group" size={36}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() + }) + + it('renders the count overlay alongside a single avatar', () => { + render( + <AvatarGroup data-testid="group" size={36} count={4}> + <Avatar size={36} name="Jane Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByText('+4')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument() + }) + + it('leaves the count overlay custom property available for CSS customization', () => { + render( + <AvatarGroup data-testid="group" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect( + screen + .getByTestId('group') + .style.getPropertyValue('--reactist-avatar-group-count-overlay'), + ).toBe('') + }) + + it('applies the group shape class', () => { + render( + <AvatarGroup data-testid="group" size={36} shape="rounded"> + <Avatar size={36} shape="rounded" name="Workspace" /> + <Avatar size={36} shape="rounded" name="Design System" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveClass('avatarGroupShape-rounded') + }) + + it('applies the escape hatch class name', () => { + render( + <AvatarGroup data-testid="group" size={36} exceptionallySetClassName="custom-group"> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup>, + ) + + expect(screen.getByTestId('group')).toHaveClass('custom-group') + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + <AvatarGroup size={36}> + <Avatar size={36} name="Jane Doe" image="avatar.png" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup> + <AvatarGroup size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup> + <AvatarGroup size={36} shape="rounded" count={5}> + <Avatar size={36} shape="rounded" name="Reactist" /> + <Avatar size={36} shape="rounded" name="Todoist" /> + </AvatarGroup> + <AvatarGroup as="button" aria-label="Manage 5 members" size={36} count={3}> + <Avatar size={36} name="Jane Doe" /> + <Avatar size={36} name="John Doe" /> + </AvatarGroup> + </>, + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) +}) diff --git a/src/avatar/avatar-group.tsx b/src/avatar/avatar-group.tsx new file mode 100644 index 00000000..a5e43faa --- /dev/null +++ b/src/avatar/avatar-group.tsx @@ -0,0 +1,167 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { Box } from '../box' +import { polymorphicComponent } from '../utils/polymorphism' + +import { ROUNDED_AVATAR_RADIUS_BY_SIZE } from './utils' + +import styles from './avatar.module.css' + +import type { ObfuscatedClassName } from '../utils/common-types' +import type { PolymorphicComponentProps } from '../utils/polymorphism' +import type { AvatarShape, AvatarSize } from './utils' + +type AvatarGroupStyle = React.CSSProperties & { + '--reactist-avatar-group-size': string + '--reactist-avatar-group-overlap': string + '--reactist-avatar-group-mask': string + '--reactist-avatar-group-rounded-radius': string + '--reactist-avatar-group-rounded-mask-radius': string +} + +const AVATAR_GROUP_OVERLAP_BY_SIZE: Record<AvatarSize, string> = { + 80: '8px', + 72: '8px', + 62: '8px', + 50: '4px', + 40: '4px', + 36: '4px', + 30: '2px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '2px', + 16: '2px', + 12: '1px', +} + +const AVATAR_GROUP_MASK_BY_SIZE: Record<AvatarSize, string> = { + 80: '3px', + 72: '3px', + 62: '3px', + 50: '3px', + 40: '3px', + 36: '2.5px', + 30: '2.5px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '1.5px', + 16: '1.25px', + 12: '1px', +} + +/** + * Props for the `AvatarGroup` component. + */ +type AvatarGroupOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The grouped avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * Additional people not shown in the group. When positive, rendered as a + * decorative `+N` overlay on top of the final avatar; hidden from + * assistive tech. + */ + count?: number + + /** + * Grouped Avatar children. + */ + children: React.ReactNode + + /** + * Test identifier applied to the avatar group root element. + */ + 'data-testid'?: string + + /** + * AvatarGroup owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarGroupProps<ComponentType extends React.ElementType = 'div'> = PolymorphicComponentProps< + ComponentType, + AvatarGroupOwnProps, + 'omitClassName' +> + +/** + * Displays a row of overlapping Avatar children with an optional count overlay + * on the final avatar. + */ +const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassName'>( + function AvatarGroup( + { + as, + size, + shape = 'circle', + count, + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + const overflowCount = count && count > 0 ? count : null + + return ( + <Box + as={as} + ref={ref} + className={classNames( + styles.avatarGroup, + styles[`avatarGroupShape-${shape}`], + exceptionallySetClassName, + )} + style={getAvatarGroupStyle(size)} + data-testid={testId} + display="inlineFlex" + alignItems="center" + position="relative" + {...restProps} + > + {children} + {overflowCount !== null ? ( + <span className={styles.avatarGroupCount} aria-hidden="true"> + {`+${overflowCount}`} + </span> + ) : null} + </Box> + ) + }, +) + +function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { + const mask = AVATAR_GROUP_MASK_BY_SIZE[size] + const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] + + return { + '--reactist-avatar-group-size': `${size}px`, + '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], + '--reactist-avatar-group-mask': mask, + '--reactist-avatar-group-rounded-radius': roundedRadius, + '--reactist-avatar-group-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, + } +} + +export { AvatarGroup } +export type { AvatarGroupProps } diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 0d8f0562..963fa05e 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -10,6 +10,7 @@ import { } from '@storybook/addon-docs/blocks' import * as AvatarStories from './avatar.stories' +import * as AvatarGroupStories from './avatar-group.stories' <Meta of={AvatarStories} /> @@ -34,7 +35,7 @@ hidden from assistive tech. When the group represents a labeled entity (a button, link, or labeled region), supply an `aria-label` on the group that conveys the count to assistive tech. -<Canvas of={AvatarStories.AvatarGroups} /> +<Canvas of={AvatarGroupStories.People} /> ## Migrating from the legacy API diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index 2f38b790..720595d1 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { Avatar, AvatarGroup, Box, Inline, Stack, Text } from '../index' +import { Avatar, Box, Inline, Stack, Text } from '../index' import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils' @@ -168,18 +168,6 @@ function WorkspaceAvatarExample(props: Omit<AvatarProps, 'shape'>) { return <Avatar shape="rounded" {...props} /> } -function AvatarGroupCustomOverlayStyle() { - return ( - <style> - {` - .avatarGroupCustomOverlay { - --reactist-avatar-group-count-overlay: rgba(220, 76, 62, 0.72); - } - `} - </style> - ) -} - function AvatarColorExample({ index, name }: { index: number; name: string }) { return ( <AvatarExample label={`fill-${index}`}> @@ -230,91 +218,6 @@ export const Default = { ), } satisfies Story -export const AvatarGroups = { - render: () => ( - <StoryLayout> - <AvatarGroupCustomOverlayStyle /> - - <StorySection - title="User groups" - description="AvatarGroup overlaps direct Avatar children. Pass count when the final avatar represents additional people." - > - <Inline space="medium" alignY="top"> - <AvatarExample label="With count"> - <AvatarGroup size={36} count={3}> - {contributors.slice(1, 6).map((contributor) => ( - <UserAvatar - key={contributor.name} - size={36} - name={contributor.name} - image={getGithubAvatarUrl(contributor.githubUserId, 72)} - /> - ))} - </AvatarGroup> - </AvatarExample> - <AvatarExample label="No count"> - <AvatarGroup size={36}> - {contributors.slice(2, 5).map((contributor) => ( - <UserAvatar - key={contributor.name} - size={36} - name={contributor.name} - image={getGithubAvatarUrl(contributor.githubUserId, 72)} - /> - ))} - </AvatarGroup> - </AvatarExample> - <AvatarExample label="Custom overlay"> - <AvatarGroup - size={36} - count={9} - exceptionallySetClassName="avatarGroupCustomOverlay" - > - {contributors.slice(3, 7).map((contributor) => ( - <UserAvatar - key={contributor.name} - size={36} - name={contributor.name} - image={getGithubAvatarUrl(contributor.githubUserId, 72)} - /> - ))} - </AvatarGroup> - </AvatarExample> - </Inline> - </StorySection> - - <StorySection - title="Size-dependent spacing" - description="Overlap and transparent mask width are derived from the group size." - > - <Inline space="medium" alignY="top"> - {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( - <AvatarExample key={size} label={`${size}px`}> - <AvatarGroup size={size} count={3}> - {[0, 1, 2].map((offset) => { - const contributor = getContributor(index + offset)! - - return ( - <UserAvatar - key={contributor.name} - size={size} - name={contributor.name} - image={getGithubSourceMap( - contributor.githubUserId, - size, - )} - /> - ) - })} - </AvatarGroup> - </AvatarExample> - ))} - </Inline> - </StorySection> - </StoryLayout> - ), -} satisfies Story - export const InitialsFallback = { render: () => ( <StoryLayout> diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index f27c081d..e78137b3 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { Avatar, AvatarGroup } from './avatar' +import { Avatar } from './avatar' describe('Avatar', () => { function failCurrentAvatarImage(currentSrc: string) { @@ -303,171 +303,3 @@ describe('Avatar', () => { }) }) }) - -describe('AvatarGroup', () => { - it('renders direct Avatar children without wrappers', () => { - render( - <AvatarGroup data-testid="group" size={36}> - <Avatar data-testid="first" size={36} name="Jane Doe" /> - <Avatar data-testid="second" size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('first')) - expect(screen.getByTestId('group')).toContainElement(screen.getByTestId('second')) - expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('group')) - expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('group')) - }) - - it('sets size-derived spacing variables', () => { - render( - <AvatarGroup data-testid="group" size={36}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toHaveStyle({ - '--reactist-avatar-group-size': '36px', - '--reactist-avatar-group-overlap': '4px', - '--reactist-avatar-group-mask': '2.5px', - '--reactist-avatar-group-rounded-mask-radius': 'calc(5px + 2.5px)', - }) - }) - - it('sets large size-derived spacing variables', () => { - render( - <AvatarGroup data-testid="group" size={80}> - <Avatar size={80} name="Jane Doe" /> - <Avatar size={80} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toHaveStyle({ - '--reactist-avatar-group-size': '80px', - '--reactist-avatar-group-overlap': '8px', - '--reactist-avatar-group-mask': '3px', - '--reactist-avatar-group-rounded-mask-radius': 'calc(10px + 3px)', - }) - }) - - it('renders the count overlay when count is positive', () => { - render( - <AvatarGroup data-testid="group" size={36} count={3}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByText('+3')).toBeInTheDocument() - }) - - it('hides the count overlay from assistive tech', () => { - render( - <AvatarGroup data-testid="group" size={36} count={3}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByText('+3')).toHaveAttribute('aria-hidden', 'true') - }) - - it('omits the count overlay when count is not positive', () => { - render( - <AvatarGroup data-testid="group" size={36} count={0}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() - }) - - it('omits the count overlay when count is not provided', () => { - render( - <AvatarGroup data-testid="group" size={36}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() - }) - - it('renders the count overlay alongside a single avatar', () => { - render( - <AvatarGroup data-testid="group" size={36} count={4}> - <Avatar size={36} name="Jane Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByText('+4')).toBeInTheDocument() - expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument() - }) - - it('leaves the count overlay custom property available for CSS customization', () => { - render( - <AvatarGroup data-testid="group" size={36} count={3}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect( - screen - .getByTestId('group') - .style.getPropertyValue('--reactist-avatar-group-count-overlay'), - ).toBe('') - }) - - it('applies the group shape class', () => { - render( - <AvatarGroup data-testid="group" size={36} shape="rounded"> - <Avatar size={36} shape="rounded" name="Workspace" /> - <Avatar size={36} shape="rounded" name="Design System" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toHaveClass('avatarGroupShape-rounded') - }) - - it('applies the escape hatch class name', () => { - render( - <AvatarGroup data-testid="group" size={36} exceptionallySetClassName="custom-group"> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toHaveClass('custom-group') - }) - - describe('a11y', () => { - it('renders with no a11y violations', async () => { - const { container } = render( - <> - <AvatarGroup size={36}> - <Avatar size={36} name="Jane Doe" image="avatar.png" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup> - <AvatarGroup size={36} count={3}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup> - <AvatarGroup size={36} shape="rounded" count={5}> - <Avatar size={36} shape="rounded" name="Reactist" /> - <Avatar size={36} shape="rounded" name="Todoist" /> - </AvatarGroup> - <AvatarGroup as="button" aria-label="Manage 5 members" size={36} count={3}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup> - </>, - ) - const results = await axe(container) - - expect(results).toHaveNoViolations() - }) - }) -}) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 4bc0e167..9704dbc7 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -26,46 +26,6 @@ type AvatarStyle = React.CSSProperties & { '--reactist-avatar-rounded-radius': string } -type AvatarGroupStyle = React.CSSProperties & { - '--reactist-avatar-group-size': string - '--reactist-avatar-group-overlap': string - '--reactist-avatar-group-mask': string - '--reactist-avatar-group-rounded-radius': string - '--reactist-avatar-group-rounded-mask-radius': string -} - -const AVATAR_GROUP_OVERLAP_BY_SIZE: Record<AvatarSize, string> = { - 80: '8px', - 72: '8px', - 62: '8px', - 50: '4px', - 40: '4px', - 36: '4px', - 30: '2px', - 28: '2px', - 24: '2px', - 20: '2px', - 18: '2px', - 16: '2px', - 12: '1px', -} - -const AVATAR_GROUP_MASK_BY_SIZE: Record<AvatarSize, string> = { - 80: '3px', - 72: '3px', - 62: '3px', - 50: '3px', - 40: '3px', - 36: '2.5px', - 30: '2.5px', - 28: '2px', - 24: '2px', - 20: '2px', - 18: '1.5px', - 16: '1.25px', - 12: '1px', -} - /** * Props for the `Avatar` component. */ @@ -124,56 +84,6 @@ type AvatarProps<ComponentType extends React.ElementType = 'div'> = PolymorphicC 'omitClassName' > -/** - * Props for the `AvatarGroup` component. - */ -type AvatarGroupOwnProps = ObfuscatedClassName & { - /** - * The rendered avatar size, in CSS pixels. - * - * Direct child Avatar components should use the same size. - */ - size: AvatarSize - - /** - * The grouped avatar shape. - * - * Direct child Avatar components should use the same shape. - * - * @default 'circle' - */ - shape?: AvatarShape - - /** - * Additional people not shown in the group. When positive, rendered as a - * decorative `+N` overlay on top of the final avatar; hidden from - * assistive tech. - */ - count?: number - - /** - * Grouped Avatar children. - */ - children: React.ReactNode - - /** - * Test identifier applied to the avatar group root element. - */ - 'data-testid'?: string - - /** - * AvatarGroup owns its root sizing styles. Use `exceptionallySetClassName` for the styling - * escape hatch. - */ - style?: never -} - -type AvatarGroupProps<ComponentType extends React.ElementType = 'div'> = PolymorphicComponentProps< - ComponentType, - AvatarGroupOwnProps, - 'omitClassName' -> - const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>( function AvatarContent( { @@ -280,53 +190,6 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func ) }) -/** - * Displays a row of overlapping Avatar children with an optional count overlay - * on the final avatar. - */ -const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassName'>( - function AvatarGroup( - { - as, - size, - shape = 'circle', - count, - children, - exceptionallySetClassName, - 'data-testid': testId, - ...restProps - }, - ref, - ) { - const overflowCount = count && count > 0 ? count : null - - return ( - <Box - as={as} - ref={ref} - className={classNames( - styles.avatarGroup, - styles[`avatarGroupShape-${shape}`], - exceptionallySetClassName, - )} - style={getAvatarGroupStyle(size)} - data-testid={testId} - display="inlineFlex" - alignItems="center" - position="relative" - {...restProps} - > - {children} - {overflowCount !== null ? ( - <span className={styles.avatarGroupCount} aria-hidden="true"> - {`+${overflowCount}`} - </span> - ) : null} - </Box> - ) - }, -) - function getAvatarStyle(size: AvatarSize): AvatarStyle { return { '--reactist-avatar-size': `${size}px`, @@ -334,19 +197,6 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } } -function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { - const mask = AVATAR_GROUP_MASK_BY_SIZE[size] - const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] - - return { - '--reactist-avatar-group-size': `${size}px`, - '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], - '--reactist-avatar-group-mask': mask, - '--reactist-avatar-group-rounded-radius': roundedRadius, - '--reactist-avatar-group-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, - } -} - function getAbsoluteImageSource(src: string, image: HTMLImageElement) { try { return new URL(src, image.ownerDocument.baseURI).href @@ -364,5 +214,5 @@ function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) return matchingSource?.src ?? imageProps.src } -export { Avatar, AvatarGroup } -export type { AvatarGroupProps, AvatarImage, AvatarProps, AvatarShape, AvatarSize } +export { Avatar } +export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } diff --git a/src/avatar/index.ts b/src/avatar/index.ts index 886c6ec3..c57e70d1 100644 --- a/src/avatar/index.ts +++ b/src/avatar/index.ts @@ -1 +1,2 @@ export * from './avatar' +export * from './avatar-group' From 5507aa2ec7464d4b1c24fe6d01b32815c1854608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 22:26:38 -0500 Subject: [PATCH 52/57] fix: remove avatar group count plus sign --- src/avatar/avatar-group.test.tsx | 9 +++++---- src/avatar/avatar-group.tsx | 4 ++-- src/avatar/avatar.mdx | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/avatar/avatar-group.test.tsx b/src/avatar/avatar-group.test.tsx index 5c1fc9da..953f3e21 100644 --- a/src/avatar/avatar-group.test.tsx +++ b/src/avatar/avatar-group.test.tsx @@ -53,7 +53,7 @@ describe('AvatarGroup', () => { }) }) - it('renders the count overlay when count is positive', () => { + it('renders the count overlay without a plus sign when count is positive', () => { render( <AvatarGroup data-testid="group" size={36} count={3}> <Avatar size={36} name="Jane Doe" /> @@ -61,7 +61,8 @@ describe('AvatarGroup', () => { </AvatarGroup>, ) - expect(screen.getByText('+3')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + expect(screen.queryByText('+3')).not.toBeInTheDocument() }) it('hides the count overlay from assistive tech', () => { @@ -72,7 +73,7 @@ describe('AvatarGroup', () => { </AvatarGroup>, ) - expect(screen.getByText('+3')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByText('3')).toHaveAttribute('aria-hidden', 'true') }) it('omits the count overlay when count is not positive', () => { @@ -104,7 +105,7 @@ describe('AvatarGroup', () => { </AvatarGroup>, ) - expect(screen.getByText('+4')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument() }) diff --git a/src/avatar/avatar-group.tsx b/src/avatar/avatar-group.tsx index a5e43faa..4f4e5246 100644 --- a/src/avatar/avatar-group.tsx +++ b/src/avatar/avatar-group.tsx @@ -75,7 +75,7 @@ type AvatarGroupOwnProps = ObfuscatedClassName & { /** * Additional people not shown in the group. When positive, rendered as a - * decorative `+N` overlay on top of the final avatar; hidden from + * decorative `N` overlay on top of the final avatar; hidden from * assistive tech. */ count?: number @@ -142,7 +142,7 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN {children} {overflowCount !== null ? ( <span className={styles.avatarGroupCount} aria-hidden="true"> - {`+${overflowCount}`} + {overflowCount} </span> ) : null} </Box> diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 963fa05e..b36cd8b1 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -30,7 +30,7 @@ and the deterministic meta color used when initials render. Use `AvatarGroup` when a compact surface represents several people. Pass the same `size` to the group and its direct `Avatar` children. The optional `count` -is rendered as a decorative `+N` visual on top of the final avatar and is +is rendered as a decorative `N` visual on top of the final avatar and is hidden from assistive tech. When the group represents a labeled entity (a button, link, or labeled region), supply an `aria-label` on the group that conveys the count to assistive tech. From 61515daafb702fa264150082094956c7bb45e409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 22:30:18 -0500 Subject: [PATCH 53/57] fix: clip avatar group count overlay --- src/avatar/avatar-group.test.tsx | 13 +++++++++++++ src/avatar/avatar.module.css | 5 ----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/avatar/avatar-group.test.tsx b/src/avatar/avatar-group.test.tsx index 953f3e21..e18852dd 100644 --- a/src/avatar/avatar-group.test.tsx +++ b/src/avatar/avatar-group.test.tsx @@ -2,11 +2,24 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' import { axe } from 'jest-axe' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { Avatar } from './avatar' import { AvatarGroup } from './avatar-group' describe('AvatarGroup', () => { + it('lets the count overlay inherit avatar clipping masks', () => { + const avatarStyles = readFileSync(join(__dirname, 'avatar.module.css'), 'utf8') + + expect(avatarStyles).not.toMatch( + /\.avatarGroup\s*>\s*\.avatarGroupCount\s*{[^}]*-webkit-mask-image:\s*none/, + ) + expect(avatarStyles).not.toMatch( + /\.avatarGroup\s*>\s*\.avatarGroupCount\s*{[^}]*mask-image:\s*none/, + ) + }) + it('renders direct Avatar children without wrappers', () => { render( <AvatarGroup data-testid="group" size={36}> diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index 59af5e79..eeff5048 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -307,8 +307,3 @@ .avatarGroupShape-rounded > .avatarGroupCount { border-radius: var(--reactist-avatar-group-rounded-radius); } - -.avatarGroup > .avatarGroupCount { - -webkit-mask-image: none; - mask-image: none; -} From af3c338438107a02dcf81a1eb6052a7b084dbb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 23:26:48 -0500 Subject: [PATCH 54/57] test: remove avatar group visual assertions --- src/avatar/avatar-group.test.tsx | 83 -------------------------------- 1 file changed, 83 deletions(-) diff --git a/src/avatar/avatar-group.test.tsx b/src/avatar/avatar-group.test.tsx index e18852dd..965afc49 100644 --- a/src/avatar/avatar-group.test.tsx +++ b/src/avatar/avatar-group.test.tsx @@ -2,24 +2,11 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { Avatar } from './avatar' import { AvatarGroup } from './avatar-group' describe('AvatarGroup', () => { - it('lets the count overlay inherit avatar clipping masks', () => { - const avatarStyles = readFileSync(join(__dirname, 'avatar.module.css'), 'utf8') - - expect(avatarStyles).not.toMatch( - /\.avatarGroup\s*>\s*\.avatarGroupCount\s*{[^}]*-webkit-mask-image:\s*none/, - ) - expect(avatarStyles).not.toMatch( - /\.avatarGroup\s*>\s*\.avatarGroupCount\s*{[^}]*mask-image:\s*none/, - ) - }) - it('renders direct Avatar children without wrappers', () => { render( <AvatarGroup data-testid="group" size={36}> @@ -34,50 +21,6 @@ describe('AvatarGroup', () => { expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('group')) }) - it('sets size-derived spacing variables', () => { - render( - <AvatarGroup data-testid="group" size={36}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toHaveStyle({ - '--reactist-avatar-group-size': '36px', - '--reactist-avatar-group-overlap': '4px', - '--reactist-avatar-group-mask': '2.5px', - '--reactist-avatar-group-rounded-mask-radius': 'calc(5px + 2.5px)', - }) - }) - - it('sets large size-derived spacing variables', () => { - render( - <AvatarGroup data-testid="group" size={80}> - <Avatar size={80} name="Jane Doe" /> - <Avatar size={80} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toHaveStyle({ - '--reactist-avatar-group-size': '80px', - '--reactist-avatar-group-overlap': '8px', - '--reactist-avatar-group-mask': '3px', - '--reactist-avatar-group-rounded-mask-radius': 'calc(10px + 3px)', - }) - }) - - it('renders the count overlay without a plus sign when count is positive', () => { - render( - <AvatarGroup data-testid="group" size={36} count={3}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect(screen.getByText('3')).toBeInTheDocument() - expect(screen.queryByText('+3')).not.toBeInTheDocument() - }) - it('hides the count overlay from assistive tech', () => { render( <AvatarGroup data-testid="group" size={36} count={3}> @@ -122,32 +65,6 @@ describe('AvatarGroup', () => { expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument() }) - it('leaves the count overlay custom property available for CSS customization', () => { - render( - <AvatarGroup data-testid="group" size={36} count={3}> - <Avatar size={36} name="Jane Doe" /> - <Avatar size={36} name="John Doe" /> - </AvatarGroup>, - ) - - expect( - screen - .getByTestId('group') - .style.getPropertyValue('--reactist-avatar-group-count-overlay'), - ).toBe('') - }) - - it('applies the group shape class', () => { - render( - <AvatarGroup data-testid="group" size={36} shape="rounded"> - <Avatar size={36} shape="rounded" name="Workspace" /> - <Avatar size={36} shape="rounded" name="Design System" /> - </AvatarGroup>, - ) - - expect(screen.getByTestId('group')).toHaveClass('avatarGroupShape-rounded') - }) - it('applies the escape hatch class name', () => { render( <AvatarGroup data-testid="group" size={36} exceptionallySetClassName="custom-group"> From 27bd29fe34b115c1fce561fd8de12fc5ffecfe73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 23:28:43 -0500 Subject: [PATCH 55/57] refactor: move avatar group styles --- src/avatar/avatar-group.module.css | 117 ++++++++++++++++++++++++++++ src/avatar/avatar-group.tsx | 2 +- src/avatar/avatar.module.css | 118 ----------------------------- 3 files changed, 118 insertions(+), 119 deletions(-) create mode 100644 src/avatar/avatar-group.module.css diff --git a/src/avatar/avatar-group.module.css b/src/avatar/avatar-group.module.css new file mode 100644 index 00000000..82124fb1 --- /dev/null +++ b/src/avatar/avatar-group.module.css @@ -0,0 +1,117 @@ +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; + --reactist-avatar-group-rounded-mask-radius: 7.5px; + --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); + --reactist-avatar-group-rounded-mask-width: calc( + var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask) + ); + --reactist-avatar-group-rounded-mask-corner-x: calc( + var(--reactist-avatar-group-overlap) - var(--reactist-avatar-group-rounded-radius) + ); + --reactist-avatar-group-previous-center-x: calc( + (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + + var(--reactist-avatar-group-overlap) + ); + + position: relative; +} + +.avatarGroup > * { + flex-shrink: 0; +} + +.avatarGroup > * + * { + margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); +} + +.avatarGroupShape-circle > * + * { + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); +} + +.avatarGroupShape-rounded > * + * { + -webkit-mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + transparent 99%, + #000 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); + mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + transparent 99%, + #000 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); + -webkit-mask-position: + right top, + left top, + left bottom; + mask-position: + right top, + left top, + left bottom; + -webkit-mask-size: + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); + mask-size: + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} + +.avatarGroupCount { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--reactist-avatar-group-size); + height: var(--reactist-avatar-group-size); + border-radius: 50%; + background: var(--reactist-avatar-group-count-overlay); + color: var(--reactist-avatar-initials-color, var(--reactist-actionable-primary-idle-tint)); + font-size: calc(var(--reactist-avatar-group-size) / 2); + font-weight: var(--reactist-font-weight-medium); + line-height: 1; + pointer-events: none; + user-select: none; +} + +.avatarGroupShape-rounded > .avatarGroupCount { + border-radius: var(--reactist-avatar-group-rounded-radius); +} diff --git a/src/avatar/avatar-group.tsx b/src/avatar/avatar-group.tsx index 4f4e5246..fba9a1ab 100644 --- a/src/avatar/avatar-group.tsx +++ b/src/avatar/avatar-group.tsx @@ -7,7 +7,7 @@ import { polymorphicComponent } from '../utils/polymorphism' import { ROUNDED_AVATAR_RADIUS_BY_SIZE } from './utils' -import styles from './avatar.module.css' +import styles from './avatar-group.module.css' import type { ObfuscatedClassName } from '../utils/common-types' import type { PolymorphicComponentProps } from '../utils/polymorphism' diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index eeff5048..acaec40d 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -189,121 +189,3 @@ line-height: 1; user-select: none; } - -.avatarGroup { - --reactist-avatar-group-size: 36px; - --reactist-avatar-group-overlap: 4px; - --reactist-avatar-group-mask: 2.5px; - --reactist-avatar-group-rounded-radius: 5px; - --reactist-avatar-group-rounded-mask-radius: 7.5px; - --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); - --reactist-avatar-group-rounded-mask-width: calc( - var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask) - ); - --reactist-avatar-group-rounded-mask-corner-x: calc( - var(--reactist-avatar-group-overlap) - var(--reactist-avatar-group-rounded-radius) - ); - --reactist-avatar-group-previous-center-x: calc( - (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + - var(--reactist-avatar-group-overlap) - ); - - position: relative; -} - -.avatarGroup > * { - flex-shrink: 0; -} - -.avatarGroup > * + * { - margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); -} - -.avatarGroupShape-circle > * + * { - -webkit-mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, - transparent 99%, - #000 100% - ); - mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, - transparent 99%, - #000 100% - ); -} - -.avatarGroupShape-rounded > * + * { - -webkit-mask-image: - linear-gradient(#000 0 0), - radial-gradient( - circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) - var(--reactist-avatar-group-rounded-radius), - transparent 99%, - #000 100% - ), - radial-gradient( - circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) 0, - transparent 99%, - #000 100% - ); - mask-image: - linear-gradient(#000 0 0), - radial-gradient( - circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) - var(--reactist-avatar-group-rounded-radius), - transparent 99%, - #000 100% - ), - radial-gradient( - circle var(--reactist-avatar-group-rounded-mask-radius) at - var(--reactist-avatar-group-rounded-mask-corner-x) 0, - transparent 99%, - #000 100% - ); - -webkit-mask-position: - right top, - left top, - left bottom; - mask-position: - right top, - left top, - left bottom; - -webkit-mask-size: - calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, - var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), - var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); - mask-size: - calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, - var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), - var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; -} - -.avatarGroupCount { - position: absolute; - top: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - width: var(--reactist-avatar-group-size); - height: var(--reactist-avatar-group-size); - border-radius: 50%; - background: var(--reactist-avatar-group-count-overlay); - color: var(--reactist-avatar-initials-color); - font-size: calc(var(--reactist-avatar-group-size) / 2); - font-weight: var(--reactist-font-weight-medium); - line-height: 1; - pointer-events: none; - user-select: none; -} - -.avatarGroupShape-rounded > .avatarGroupCount { - border-radius: var(--reactist-avatar-group-rounded-radius); -} From ee23214b85de2a27e0828532e655b8ca0382d46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Wed, 27 May 2026 23:30:49 -0500 Subject: [PATCH 56/57] refactor: move avatar group sizing to css --- src/avatar/avatar-group.module.css | 95 +++++++++++++++++++++++++++++- src/avatar/avatar-group.tsx | 57 +----------------- 2 files changed, 95 insertions(+), 57 deletions(-) diff --git a/src/avatar/avatar-group.module.css b/src/avatar/avatar-group.module.css index 82124fb1..da6598fd 100644 --- a/src/avatar/avatar-group.module.css +++ b/src/avatar/avatar-group.module.css @@ -3,7 +3,9 @@ --reactist-avatar-group-overlap: 4px; --reactist-avatar-group-mask: 2.5px; --reactist-avatar-group-rounded-radius: 5px; - --reactist-avatar-group-rounded-mask-radius: 7.5px; + --reactist-avatar-group-rounded-mask-radius: calc( + var(--reactist-avatar-group-rounded-radius) + var(--reactist-avatar-group-mask) + ); --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); --reactist-avatar-group-rounded-mask-width: calc( var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask) @@ -19,6 +21,97 @@ position: relative; } +.avatarGroupSize-80 { + --reactist-avatar-group-size: 80px; + --reactist-avatar-group-overlap: 8px; + --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-rounded-radius: 10px; +} + +.avatarGroupSize-72 { + --reactist-avatar-group-size: 72px; + --reactist-avatar-group-overlap: 8px; + --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-rounded-radius: 10px; +} + +.avatarGroupSize-62 { + --reactist-avatar-group-size: 62px; + --reactist-avatar-group-overlap: 8px; + --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-rounded-radius: 8.5px; +} + +.avatarGroupSize-50 { + --reactist-avatar-group-size: 50px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-rounded-radius: 7px; +} + +.avatarGroupSize-40 { + --reactist-avatar-group-size: 40px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-rounded-radius: 5.5px; +} + +.avatarGroupSize-36 { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; +} + +.avatarGroupSize-30 { + --reactist-avatar-group-size: 30px; + --reactist-avatar-group-overlap: 2px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; +} + +.avatarGroupSize-28 { + --reactist-avatar-group-size: 28px; + --reactist-avatar-group-overlap: 2px; + --reactist-avatar-group-mask: 2px; + --reactist-avatar-group-rounded-radius: 5px; +} + +.avatarGroupSize-24 { + --reactist-avatar-group-size: 24px; + --reactist-avatar-group-overlap: 2px; + --reactist-avatar-group-mask: 2px; + --reactist-avatar-group-rounded-radius: 3.2px; +} + +.avatarGroupSize-20 { + --reactist-avatar-group-size: 20px; + --reactist-avatar-group-overlap: 2px; + --reactist-avatar-group-mask: 2px; + --reactist-avatar-group-rounded-radius: 3px; +} + +.avatarGroupSize-18 { + --reactist-avatar-group-size: 18px; + --reactist-avatar-group-overlap: 2px; + --reactist-avatar-group-mask: 1.5px; + --reactist-avatar-group-rounded-radius: 3px; +} + +.avatarGroupSize-16 { + --reactist-avatar-group-size: 16px; + --reactist-avatar-group-overlap: 2px; + --reactist-avatar-group-mask: 1.25px; + --reactist-avatar-group-rounded-radius: 2px; +} + +.avatarGroupSize-12 { + --reactist-avatar-group-size: 12px; + --reactist-avatar-group-overlap: 1px; + --reactist-avatar-group-mask: 1px; + --reactist-avatar-group-rounded-radius: 1.6px; +} + .avatarGroup > * { flex-shrink: 0; } diff --git a/src/avatar/avatar-group.tsx b/src/avatar/avatar-group.tsx index fba9a1ab..6aa533c0 100644 --- a/src/avatar/avatar-group.tsx +++ b/src/avatar/avatar-group.tsx @@ -5,54 +5,12 @@ import classNames from 'classnames' import { Box } from '../box' import { polymorphicComponent } from '../utils/polymorphism' -import { ROUNDED_AVATAR_RADIUS_BY_SIZE } from './utils' - import styles from './avatar-group.module.css' import type { ObfuscatedClassName } from '../utils/common-types' import type { PolymorphicComponentProps } from '../utils/polymorphism' import type { AvatarShape, AvatarSize } from './utils' -type AvatarGroupStyle = React.CSSProperties & { - '--reactist-avatar-group-size': string - '--reactist-avatar-group-overlap': string - '--reactist-avatar-group-mask': string - '--reactist-avatar-group-rounded-radius': string - '--reactist-avatar-group-rounded-mask-radius': string -} - -const AVATAR_GROUP_OVERLAP_BY_SIZE: Record<AvatarSize, string> = { - 80: '8px', - 72: '8px', - 62: '8px', - 50: '4px', - 40: '4px', - 36: '4px', - 30: '2px', - 28: '2px', - 24: '2px', - 20: '2px', - 18: '2px', - 16: '2px', - 12: '1px', -} - -const AVATAR_GROUP_MASK_BY_SIZE: Record<AvatarSize, string> = { - 80: '3px', - 72: '3px', - 62: '3px', - 50: '3px', - 40: '3px', - 36: '2.5px', - 30: '2.5px', - 28: '2px', - 24: '2px', - 20: '2px', - 18: '1.5px', - 16: '1.25px', - 12: '1px', -} - /** * Props for the `AvatarGroup` component. */ @@ -129,10 +87,10 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN ref={ref} className={classNames( styles.avatarGroup, + styles[`avatarGroupSize-${size}`], styles[`avatarGroupShape-${shape}`], exceptionallySetClassName, )} - style={getAvatarGroupStyle(size)} data-testid={testId} display="inlineFlex" alignItems="center" @@ -150,18 +108,5 @@ const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassN }, ) -function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { - const mask = AVATAR_GROUP_MASK_BY_SIZE[size] - const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] - - return { - '--reactist-avatar-group-size': `${size}px`, - '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], - '--reactist-avatar-group-mask': mask, - '--reactist-avatar-group-rounded-radius': roundedRadius, - '--reactist-avatar-group-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, - } -} - export { AvatarGroup } export type { AvatarGroupProps } From f9b0edf5c5f6e1678663c62616dbfa80aececc8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Grimm?= <pawel@doist.com> Date: Thu, 28 May 2026 09:42:51 -0500 Subject: [PATCH 57/57] refactor: document avatar group css, rename mask + overlay tokens Add WHY comments for mask geometry; drop redundant default-size block in .avatarGroup (size class always wins); rename --reactist-avatar-group-mask -> -mask-thickness and -count-overlay -> -count-overlay-background for clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- src/avatar/avatar-group.module.css | 112 +++++++++++++++++++++------- src/avatar/avatar-group.stories.tsx | 2 +- src/avatar/avatar.mdx | 7 +- 3 files changed, 93 insertions(+), 28 deletions(-) diff --git a/src/avatar/avatar-group.module.css b/src/avatar/avatar-group.module.css index da6598fd..f0d54074 100644 --- a/src/avatar/avatar-group.module.css +++ b/src/avatar/avatar-group.module.css @@ -1,18 +1,37 @@ +/* + * AvatarGroup overlaps each child avatar over the previous one (negative + * margin-left). Because that partly covers the previous avatar, every + * non-first child uses a mask-image to cut a transparent notch where the + * previous sibling sits — letting it show through cleanly. Per-size custom + * properties (set by .avatarGroupSize-N) tune the overlap, mask thickness, + * and rounded-corner radius. + */ + .avatarGroup { - --reactist-avatar-group-size: 36px; - --reactist-avatar-group-overlap: 4px; - --reactist-avatar-group-mask: 2.5px; - --reactist-avatar-group-rounded-radius: 5px; + --reactist-avatar-group-count-overlay-background: rgba(0, 0, 0, 0.6); + + /* Radius of the corner-cutter gradient (rounded shape): the previous + avatar's own corner radius, padded by the mask gap. */ --reactist-avatar-group-rounded-mask-radius: calc( - var(--reactist-avatar-group-rounded-radius) + var(--reactist-avatar-group-mask) + var(--reactist-avatar-group-rounded-radius) + var(--reactist-avatar-group-mask-thickness) ); - --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); + + /* Horizontal slice on the left that holds the rounded corner cut-outs; + everything to its right is shown unmasked. */ --reactist-avatar-group-rounded-mask-width: calc( - var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask) + var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask-thickness) ); + + /* x-coordinate of the rounded corner-cutter's center. Sits one corner + radius to the left of where the overlap ends, so the curve lines up + with the previous avatar's rounded corner. */ --reactist-avatar-group-rounded-mask-corner-x: calc( var(--reactist-avatar-group-overlap) - var(--reactist-avatar-group-rounded-radius) ); + + /* x-coordinate of the previous avatar's center in this avatar's local + coordinate space. Negative: the previous sibling lies to the left. + Used to place the circle mask's notch. */ --reactist-avatar-group-previous-center-x: calc( (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + var(--reactist-avatar-group-overlap) @@ -21,94 +40,105 @@ position: relative; } +/* + * Per-size knobs. AvatarGroup applies exactly one of these classes based on + * its `size` prop, so one of them always wins. + * + * --reactist-avatar-group-size avatar width/height (must match the children's `size`) + * --reactist-avatar-group-overlap how far each avatar slides over the previous one + * --reactist-avatar-group-mask-thickness padding around the cut-out so the previous avatar's outline doesn't bleed through + * --reactist-avatar-group-rounded-radius corner radius for shape="rounded"; mirrors + * ROUNDED_AVATAR_RADIUS_BY_SIZE in src/avatar/utils.ts + */ + .avatarGroupSize-80 { --reactist-avatar-group-size: 80px; --reactist-avatar-group-overlap: 8px; - --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-mask-thickness: 3px; --reactist-avatar-group-rounded-radius: 10px; } .avatarGroupSize-72 { --reactist-avatar-group-size: 72px; --reactist-avatar-group-overlap: 8px; - --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-mask-thickness: 3px; --reactist-avatar-group-rounded-radius: 10px; } .avatarGroupSize-62 { --reactist-avatar-group-size: 62px; --reactist-avatar-group-overlap: 8px; - --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-mask-thickness: 3px; --reactist-avatar-group-rounded-radius: 8.5px; } .avatarGroupSize-50 { --reactist-avatar-group-size: 50px; --reactist-avatar-group-overlap: 4px; - --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-mask-thickness: 3px; --reactist-avatar-group-rounded-radius: 7px; } .avatarGroupSize-40 { --reactist-avatar-group-size: 40px; --reactist-avatar-group-overlap: 4px; - --reactist-avatar-group-mask: 3px; + --reactist-avatar-group-mask-thickness: 3px; --reactist-avatar-group-rounded-radius: 5.5px; } .avatarGroupSize-36 { --reactist-avatar-group-size: 36px; --reactist-avatar-group-overlap: 4px; - --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-mask-thickness: 2.5px; --reactist-avatar-group-rounded-radius: 5px; } .avatarGroupSize-30 { --reactist-avatar-group-size: 30px; --reactist-avatar-group-overlap: 2px; - --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-mask-thickness: 2.5px; --reactist-avatar-group-rounded-radius: 5px; } .avatarGroupSize-28 { --reactist-avatar-group-size: 28px; --reactist-avatar-group-overlap: 2px; - --reactist-avatar-group-mask: 2px; + --reactist-avatar-group-mask-thickness: 2px; --reactist-avatar-group-rounded-radius: 5px; } .avatarGroupSize-24 { --reactist-avatar-group-size: 24px; --reactist-avatar-group-overlap: 2px; - --reactist-avatar-group-mask: 2px; + --reactist-avatar-group-mask-thickness: 2px; --reactist-avatar-group-rounded-radius: 3.2px; } .avatarGroupSize-20 { --reactist-avatar-group-size: 20px; --reactist-avatar-group-overlap: 2px; - --reactist-avatar-group-mask: 2px; + --reactist-avatar-group-mask-thickness: 2px; --reactist-avatar-group-rounded-radius: 3px; } .avatarGroupSize-18 { --reactist-avatar-group-size: 18px; --reactist-avatar-group-overlap: 2px; - --reactist-avatar-group-mask: 1.5px; + --reactist-avatar-group-mask-thickness: 1.5px; --reactist-avatar-group-rounded-radius: 3px; } .avatarGroupSize-16 { --reactist-avatar-group-size: 16px; --reactist-avatar-group-overlap: 2px; - --reactist-avatar-group-mask: 1.25px; + --reactist-avatar-group-mask-thickness: 1.25px; --reactist-avatar-group-rounded-radius: 2px; } .avatarGroupSize-12 { --reactist-avatar-group-size: 12px; --reactist-avatar-group-overlap: 1px; - --reactist-avatar-group-mask: 1px; + --reactist-avatar-group-mask-thickness: 1px; --reactist-avatar-group-rounded-radius: 1.6px; } @@ -120,21 +150,53 @@ margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); } +/* + * Circle mask: one radial gradient centered on the previous avatar's center + * carves a transparent disc slightly larger than the avatar's outline + * (radius = size/2 + mask-thickness). The current avatar therefore shows + * nothing where the previous circle sits, letting it show through cleanly. + */ .avatarGroupShape-circle > * + * { -webkit-mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, + circle + calc( + (var(--reactist-avatar-group-size) / 2) + + var(--reactist-avatar-group-mask-thickness) + ) + at var(--reactist-avatar-group-previous-center-x) 50%, transparent 99%, #000 100% ); mask-image: radial-gradient( - circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at - var(--reactist-avatar-group-previous-center-x) 50%, + circle + calc( + (var(--reactist-avatar-group-size) / 2) + + var(--reactist-avatar-group-mask-thickness) + ) + at var(--reactist-avatar-group-previous-center-x) 50%, transparent 99%, #000 100% ); } +/* + * Rounded mask: built from three alpha-mask layers, listed top-to-bottom: + * + * 1. linear-gradient(#000), positioned `right top` at + * (100% - mask-width) × 100% — reveals the whole right portion of the + * avatar unmasked. + * + * 2. radial-gradient at the top-left, sized mask-width × rounded-radius — + * carves a concave arc matching the previous avatar's top-right + * rounded corner. + * + * 3. Same radial-gradient pinned to the bottom-left — same trick for the + * previous avatar's bottom-right corner. + * + * The left-middle strip (between the two corner cut-outs) is intentionally + * uncovered by any layer, so it stays transparent — that's where the body of + * the previous avatar shows through. + */ .avatarGroupShape-rounded > * + * { -webkit-mask-image: linear-gradient(#000 0 0), @@ -196,7 +258,7 @@ width: var(--reactist-avatar-group-size); height: var(--reactist-avatar-group-size); border-radius: 50%; - background: var(--reactist-avatar-group-count-overlay); + background: var(--reactist-avatar-group-count-overlay-background); color: var(--reactist-avatar-initials-color, var(--reactist-actionable-primary-idle-tint)); font-size: calc(var(--reactist-avatar-group-size) / 2); font-weight: var(--reactist-font-weight-medium); diff --git a/src/avatar/avatar-group.stories.tsx b/src/avatar/avatar-group.stories.tsx index 0247c474..2cfe9de0 100644 --- a/src/avatar/avatar-group.stories.tsx +++ b/src/avatar/avatar-group.stories.tsx @@ -111,7 +111,7 @@ function CustomOverlayStyle() { <style> {` .avatarGroupCustomOverlay { - --reactist-avatar-group-count-overlay: rgba(220, 76, 62, 0.72); + --reactist-avatar-group-count-overlay-background: rgba(220, 76, 62, 0.72); } `} </style> diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index b36cd8b1..3bf4998f 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -122,7 +122,10 @@ component appearance. The values shown below are the default values. <ColorItem title="--reactist-avatar-initials-color" colors={['#ffffff']} /> <ColorItem title="--reactist-avatar-border-tint" colors={['#0000001a']} /> <ColorItem title="--reactist-avatar-empty-fill" colors={['#e6e6e6']} /> - <ColorItem title="--reactist-avatar-group-count-overlay" colors={['rgba(0, 0, 0, 0.6)']} /> + <ColorItem + title="--reactist-avatar-group-count-overlay-background" + colors={['rgba(0, 0, 0, 0.6)']} + /> </ColorPalette> #### Avatar meta colors @@ -185,7 +188,7 @@ the component props instead of overriding them directly. .avatarGroup { --reactist-avatar-group-size: 36px; --reactist-avatar-group-overlap: 4px; - --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-mask-thickness: 2.5px; --reactist-avatar-group-rounded-radius: 5px; } ```