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.mdx b/src/avatar/avatar.mdx new file mode 100644 index 00000000..9da9dd48 --- /dev/null +++ b/src/avatar/avatar.mdx @@ -0,0 +1,189 @@ +import { + Canvas, + ColorItem, + ColorPalette, + Controls, + Markdown, + 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} /> + +## 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: + +<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\` | +`}</Markdown> + +```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 +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={['#e6e6e6']} /> +</ColorPalette> + +#### Avatar meta colors + +<ColorPalette> + <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 + +Avatar's size classes set these variables from the `size` prop. They are +listed for completeness, but consumers should prefer the component props +instead of overriding them directly. + +```css +.avatar { + --reactist-avatar-size: 36px; + --reactist-avatar-rounded-radius: 5px; +} +``` + +## 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.module.css b/src/avatar/avatar.module.css index 4c97af2b..6e0ff984 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -1,128 +1,272 @@ :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-initials-color: var(--reactist-actionable-primary-idle-tint); + --reactist-avatar-border-tint: #0000001a; + --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest); - --reactist-avatar-size: var(--reactist-avatar-size-large); + --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 { - 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-0-fill); + background-color: var(--reactist-avatar-empty-fill); width: var(--reactist-avatar-size); height: var(--reactist-avatar-size); - line-height: var(--reactist-avatar-size); - background-size: var(--reactist-avatar-size); - font-size: calc(var(--reactist-avatar-size) / 2); + + outline: 2px solid var(--reactist-avatar-border-tint); + outline-offset: -2px; +} + +.size-80 { + --reactist-avatar-size: 80px; + --reactist-avatar-rounded-radius: 10px; +} + +.size-72 { + --reactist-avatar-size: 72px; + --reactist-avatar-rounded-radius: 10px; +} + +.size-62 { + --reactist-avatar-size: 62px; + --reactist-avatar-rounded-radius: 8.5px; } -.size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); +.size-50 { + --reactist-avatar-size: 50px; + --reactist-avatar-rounded-radius: 7px; } -.size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); +.size-40 { + --reactist-avatar-size: 40px; + --reactist-avatar-rounded-radius: 5.5px; } -.size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); +.size-36 { + --reactist-avatar-size: 36px; + --reactist-avatar-rounded-radius: 5px; } -.size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); +.size-30 { + --reactist-avatar-size: 30px; + --reactist-avatar-rounded-radius: 5px; } -.size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); +.size-28 { + --reactist-avatar-size: 28px; + --reactist-avatar-rounded-radius: 5px; } -.size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); +.size-24 { + --reactist-avatar-size: 24px; + --reactist-avatar-rounded-radius: 3.2px; } -.size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); +.size-20 { + --reactist-avatar-size: 20px; + --reactist-avatar-rounded-radius: 3px; } -.size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); +.size-18 { + --reactist-avatar-size: 18px; + --reactist-avatar-rounded-radius: 3px; } -/* avatar size for tablet */ -@media (min-width: 768px /* --reactist-breakpoint-tablet */) { - .tablet-size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); - } +.size-16 { + --reactist-avatar-size: 16px; + --reactist-avatar-rounded-radius: 2px; +} - .tablet-size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); - } +.size-12 { + --reactist-avatar-size: 12px; + --reactist-avatar-rounded-radius: 1.6px; +} - .tablet-size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); - } +.avatar:has(.initials) { + background-color: var(--reactist-avatar-meta-fill); +} - .tablet-size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); - } +.meta-color-0, +.avatar:has(.meta-color-0) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-0-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-0-on-idle-tint); +} - .tablet-size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); - } +.meta-color-1, +.avatar:has(.meta-color-1) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-1-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-1-on-idle-tint); +} - .tablet-size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); - } +.meta-color-2, +.avatar:has(.meta-color-2) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-2-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-2-on-idle-tint); +} - .tablet-size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); - } +.meta-color-3, +.avatar:has(.meta-color-3) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-3-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-3-on-idle-tint); +} - .tablet-size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); - } +.meta-color-4, +.avatar:has(.meta-color-4) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-4-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-4-on-idle-tint); } -/* avatar size for desktop */ -@media (min-width: 992px /* --reactist-breakpoint-desktop */) { - .desktop-size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); - } +.meta-color-5, +.avatar:has(.meta-color-5) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-5-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-5-on-idle-tint); +} - .desktop-size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); - } +.meta-color-6, +.avatar:has(.meta-color-6) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-6-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-6-on-idle-tint); +} - .desktop-size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); - } +.meta-color-7, +.avatar:has(.meta-color-7) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-7-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-7-on-idle-tint); +} - .desktop-size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); - } +.meta-color-8, +.avatar:has(.meta-color-8) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-8-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-8-on-idle-tint); +} - .desktop-size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); - } +.meta-color-9, +.avatar:has(.meta-color-9) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-9-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-9-on-idle-tint); +} - .desktop-size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); - } +.meta-color-10, +.avatar:has(.meta-color-10) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-10-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-10-on-idle-tint); +} - .desktop-size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); - } +.meta-color-11, +.avatar:has(.meta-color-11) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-11-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-11-on-idle-tint); +} - .desktop-size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); - } +.meta-color-12, +.avatar:has(.meta-color-12) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-12-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-12-on-idle-tint); +} + +.meta-color-13, +.avatar:has(.meta-color-13) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-13-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-13-on-idle-tint); +} + +.meta-color-14, +.avatar:has(.meta-color-14) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-14-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-14-on-idle-tint); +} + +.meta-color-15, +.avatar:has(.meta-color-15) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-15-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-15-on-idle-tint); +} + +.meta-color-16, +.avatar:has(.meta-color-16) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-16-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-16-on-idle-tint); +} + +.meta-color-17, +.avatar:has(.meta-color-17) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-17-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-17-on-idle-tint); +} + +.meta-color-18, +.avatar:has(.meta-color-18) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-18-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-18-on-idle-tint); +} + +.meta-color-19, +.avatar:has(.meta-color-19) { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-19-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-19-on-idle-tint); +} + +.shape-circle { + border-radius: 50%; +} + +.shape-rounded { + border-radius: var(--reactist-avatar-rounded-radius); +} + +.image { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; +} + +.initials { + 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.stories.tsx b/src/avatar/avatar.stories.tsx new file mode 100644 index 00000000..720595d1 --- /dev/null +++ b/src/avatar/avatar.stories.tsx @@ -0,0 +1,461 @@ +import * as React from 'react' + +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 + +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 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), + '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 ( + <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(props: Omit<AvatarProps, 'shape'>) { + return <Avatar shape="circle" {...props} /> +} + +function WorkspaceAvatarExample(props: Omit<AvatarProps, '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 PlaygroundArgs = Omit<AvatarProps, 'image'> & { + image?: PlaygroundImage +} + +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={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 source map"> + <UserAvatar + size={72} + name={contributors[3].name} + image={getGithubSourceMap(contributors[3].githubUserId, 72)} + /> + </AvatarExample> + </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} + shape={args.shape} + name={args.name} + image={args.image ? playgroundImages[args.image] || undefined : undefined} + alt={args.alt} + /> + </Box> + ), +} satisfies PlaygroundStory diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index 68409eaf..66c111b7 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -1,64 +1,324 @@ import * as React from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' import { Avatar } from './avatar' describe('Avatar', () => { - it('renders a background image when avatarUrl is supplied', () => { - render(getAvatar({ avatarUrl: 'https://foo.bar/com.png' })) + function failAvatarImage(currentSrc?: string) { + const image = screen.getByRole('img', { name: 'Jane Doe' }) - const avatar = screen.getByTestId('avatar') + if (currentSrc) { + Object.defineProperty(image, 'currentSrc', { + configurable: true, + value: currentSrc, + }) + } - expect(avatar).toMatchSnapshot() + fireEvent.error(image) + } + + it('renders a string image URL', () => { + render(<Avatar data-testid="avatar" size={36} name="Jane Doe" image="avatar.png" />) + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') + expect(screen.getByTestId('avatar')).toHaveClass('size-36') + }) + + 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 + size={36} + name="Jane Doe" + image={{ + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + }} + />, + ) + + const image = screen.getByRole('img', { name: 'Jane Doe' }) + expect(image).toHaveAttribute('src', 'avatar-144.png') + expect(image).toHaveAttribute( + 'srcset', + 'avatar-36.png 36w, avatar-72.png 72w, avatar-144.png 144w', + ) + expect(image).toHaveAttribute('sizes', '36px') + }) + + it('falls back to initials when no image is provided', () => { + render(<Avatar data-testid="avatar" size={36} name="Jane Doe" />) + + const initials = screen.getByRole('img', { name: 'Jane Doe' }) + expect(initials).toHaveTextContent('JD') + expect(initials).toHaveClass('meta-color-0') + }) + + it('applies the deterministic meta color class for the avatar name', () => { + render(<Avatar data-testid="avatar" size={36} name="John Doe" />) + + expect(screen.getByRole('img', { name: 'John Doe' })).toHaveClass('meta-color-9') + }) + + it('falls back to initials when image source map is empty', () => { + render(<Avatar size={36} name="Jane Doe" image={{}} />) + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + }) + + it('falls back to initials when the image fails to load', () => { + render(<Avatar size={36} name="Jane Doe" image="missing.png" />) + + failAvatarImage() + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + }) + + it('allows a new image to load after a failed image changes', () => { + const { rerender } = render(<Avatar size={36} name="Jane Doe" image="missing.png" />) + + failAvatarImage() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + + rerender(<Avatar size={36} name="Jane Doe" image="avatar.png" />) + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') + }) + + it('keeps the root element mounted when resetting failed image state', () => { + const { rerender } = render( + <Avatar data-testid="avatar" size={36} name="Jane Doe" image="missing.png" />, + ) + const avatarRoot = screen.getByTestId('avatar') + + failAvatarImage() + + rerender(<Avatar data-testid="avatar" size={36} name="Jane Doe" image="avatar.png" />) + + expect(screen.getByTestId('avatar')).toBe(avatarRoot) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') + }) + + it('removes a failed source-map candidate and retries with the remaining candidates', () => { + render( + <Avatar + size={36} + name="Jane Doe" + image={{ + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + }} + />, + ) + + failAvatarImage('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( + <Avatar + size={36} + name="Jane Doe" + image={{ + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + }} + />, + ) + + failAvatarImage(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('renders initials of user name when avatarUrl is not supplied', () => { - render(getAvatar()) + it('keeps filtered source-map candidates when only the avatar size changes', () => { + const image = { + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + } + const { rerender } = render(<Avatar size={36} name="Jane Doe" image={image} />) + + failAvatarImage('avatar-144.png') - const avatar = screen.getByTestId('avatar') + rerender(<Avatar size={72} name="Jane Doe" image={image} />) - expect(avatar).toHaveTextContent('HM') + 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('renders initials of user email when avatarUrl is not supplied', () => { - render(getAvatar({ user: { email: 'henning@doist.com' } })) + it('falls back to initials when every source-map candidate fails', () => { + render( + <Avatar + size={36} + name="Jane Doe" + image={{ + 36: 'avatar-36.png', + 72: 'avatar-72.png', + }} + />, + ) - const avatar = screen.getByTestId('avatar') + failAvatarImage('avatar-72.png') + failAvatarImage('avatar-36.png') - expect(avatar).toHaveTextContent('H') + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') }) - it('supports responsive values', () => { + it('retries a failed image when the same image is provided after being removed', () => { + const { rerender } = render(<Avatar size={36} name="Jane Doe" image="missing.png" />) + + failAvatarImage() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + + rerender(<Avatar size={36} name="Jane Doe" />) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + + rerender(<Avatar size={36} name="Jane Doe" image="missing.png" />) + + 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(<Avatar data-testid="avatar" size={36} />) + + expect(screen.getByTestId('avatar')).not.toHaveClass('meta-color-0') + expect(screen.getByTestId('avatar')).toHaveTextContent('') + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) + + it('can render the root as a different element', () => { + 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( - getAvatar({ - size: { - mobile: 's', - desktop: 'xl', - tablet: 'xxl', - }, - }), + <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" /> ) - const avatar = screen.getByTestId('avatar') + expect(invalidRefElement).toBeTruthy() + }) + + it('supports rounded shape with size-driven CSS classes', () => { + render(<Avatar data-testid="avatar" size={50} shape="rounded" name="Design" />) - expect(avatar).toHaveClass('size-s') - expect(avatar).toHaveClass('desktop-size-xl') - expect(avatar).toHaveClass('tablet-size-xxl') + expect(screen.getByTestId('avatar')).toHaveClass('shape-rounded') + expect(screen.getByTestId('avatar')).toHaveClass('size-50') }) - // Helpers ================================================================ - function getAvatar( - props?: Omit<React.ComponentProps<typeof Avatar>, 'user'> & { - user?: { name?: string; email: string } - }, - ) { - return ( + it('defaults to circle shape', () => { + render(<Avatar data-testid="avatar" size={36} name="Jane Doe" />) + + expect(screen.getByTestId('avatar')).toHaveClass('shape-circle') + }) + + it('uses custom alt text as the accessible label', () => { + render(<Avatar size={36} name="Jane Doe" image="avatar.png" alt="Account 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.getByAltText('')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('avatar')).not.toHaveAttribute('aria-hidden') + }) + + it('supports decorative image avatars with empty alt text', () => { + render(<Avatar size={36} name="Jane Doe" image="avatar.png" alt="" />) + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByAltText('')).toHaveAttribute('src', 'avatar.png') + expect(screen.getByAltText('')).toHaveAttribute('aria-hidden', 'true') + }) + + it('supports decorative initials avatars with empty alt text', () => { + render(<Avatar data-testid="avatar" size={36} name="Jane Doe" alt="" />) + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByText('JD')).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('avatar')).not.toHaveAttribute('aria-hidden') + expect(screen.getByTestId('avatar')).toHaveTextContent('JD') + }) + + it('applies the escape hatch class name', () => { + render( <Avatar data-testid="avatar" - user={{ name: 'Henning Mus', email: 'henning@doist.com' }} - size="xl" - {...props} - /> + size={36} + name="Jane Doe" + exceptionallySetClassName="custom-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() + }) + }) }) diff --git a/src/avatar/avatar.tsx b/src/avatar/avatar.tsx index 92805138..5615fb17 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -1,81 +1,223 @@ import * as React from 'react' +import classNames from 'classnames' + import { Box } from '../box' -import { getClassNames } from '../utils/responsive-props' +import { polymorphicComponent } from '../utils/polymorphism' -import { emailToIndex, getInitials } from './utils' +import { + getAvailableImageSources, + getAvatarImageIdentityKey, + getAvatarMetaColorIndex, + getInitials, + getSources, + normalizeAvatarName, +} 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', -] - -type AvatarSize = 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl' | 'xxxl' - -type Props = ObfuscatedClassName & { - /** @deprecated Please use `exceptionallySetClassName` */ - className?: string - /** @deprecated */ - colorList?: string[] - size?: ResponsiveProp<AvatarSize> - avatarUrl?: string - user: { name?: string; email: string } +import type { PolymorphicComponentProps } from '../utils/polymorphism' +import type { AvatarImage as AvatarImageProp, AvatarShape, AvatarSize, ImageSources } from './utils' + +/** + * Props for the `Avatar` component. + */ +type AvatarOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + */ + size: AvatarSize + + /** + * The avatar shape. + * + * @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 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`. + */ + image?: AvatarImageProp + + /** + * 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 + + /** + * Avatar owns its root sizing styles. Use `exceptionallySetClassName` for the styling escape + * hatch. + */ + style?: never } -function Avatar({ - user, - avatarUrl, - size = 'l', - className, - colorList = AVATAR_COLORS, - 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) +type AvatarProps<ComponentType extends React.ElementType = 'div'> = PolymorphicComponentProps< + ComponentType, + AvatarOwnProps, + 'omitClassName' +> + +/** + * Displays an avatar from an image URL, a source map keyed by intrinsic + * image width, or initials derived from the provided name (with a background + * color). + */ +const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(function Avatar( + { + as, + size, + shape = 'circle', + name, + image, + alt, + exceptionallySetClassName, + 'data-testid': testId, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + ...restProps + }, + ref, +) { + const label = getAvatarLabel({ alt, name, 'aria-label': ariaLabel }) + const isDecorative = Boolean(ariaHidden ?? label === '') return ( <Box - className={[className, styles.avatar, sizeClassName, exceptionallySetClassName]} - style={style} - {...props} + as={as} + ref={ref} + className={classNames( + styles.avatar, + styles[`size-${size}`], + styles[`shape-${shape}`], + exceptionallySetClassName, + )} + data-testid={testId} + display="inlineFlex" + alignItems="center" + justifyContent="center" + flexShrink={0} + overflow="hidden" + textAlign="center" + {...restProps} > - {userInitials} + <AvatarImage + // Allows `AvatarImage` to remount when the image map changes, + // which resets error states without replacing the avatar root. + key={getAvatarImageIdentityKey(image)} + size={size} + name={name} + image={image} + label={label} + aria-hidden={isDecorative} + /> </Box> ) +}) + +function getAvatarLabel({ + alt, + name, + 'aria-label': ariaLabel, +}: Pick<AvatarProps, 'alt' | 'name' | 'aria-label'>) { + return ariaLabel ?? alt ?? normalizeAvatarName(name) +} + +type AvatarImageProps = { + size: AvatarSize + name?: string + image?: AvatarImageProp + label?: string + 'aria-hidden'?: boolean +} + +function AvatarImage({ size, name, image, label, 'aria-hidden': ariaHidden }: AvatarImageProps) { + const imageSources = getSources(image, size) + const [failedImageSources, setFailedImageSources] = React.useState<string[]>([]) + const availableImageSources = getAvailableImageSources(imageSources, failedImageSources) + const initials = availableImageSources ? '' : getInitials(name) + const hasInitials = initials !== '' + + if (availableImageSources) { + return ( + <img + className={styles.image} + src={availableImageSources.src} + srcSet={availableImageSources.srcSet} + sizes={availableImageSources.sizes} + alt={label} + aria-hidden={ariaHidden} + onError={(event) => { + const failedSource = getFailedImageSource( + availableImageSources, + event.currentTarget, + ) + + setFailedImageSources((currentFailedSources) => + currentFailedSources.includes(failedSource) + ? currentFailedSources + : [...currentFailedSources, failedSource], + ) + }} + /> + ) + } + if (hasInitials) { + return ( + <div + className={classNames( + styles.initials, + styles[`meta-color-${getAvatarMetaColorIndex(name)}`], + )} + role={label ? 'img' : undefined} + aria-label={label} + aria-hidden={ariaHidden} + > + {initials} + </div> + ) + } + + return null +} + +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 } -Avatar.displayName = 'Avatar' export { Avatar } +export type { AvatarProps } +export type { AvatarImage, AvatarShape, AvatarSize } from './utils' diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 4cb13c2f..d8ed07bd 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -1,53 +1,218 @@ -import { emailToIndex, getInitials } from './utils' +import { + AVATAR_META_COLOR_COUNT, + getAvailableImageSources, + getAvatarImageIdentityKey, + getAvatarMetaColorIndex, + getInitials, + getSources, + normalizeAvatarName, +} 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') + it('returns the first two grapheme clusters for a single name part', () => { + expect(getInitials('jane')).toBe('JA') }) - it('returns only first initial if first and second initials are the same', () => { - const initials = getInitials('henning hen') - expect(initials).toBe('H') + it('preserves non-BMP Unicode letter initials', () => { + expect(getInitials('\u{10400}eseret doe')).toBe('\u{10400}D') + }) + + it('preserves decomposed accented initials', () => { + 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('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('splits name parts by Unicode whitespace', () => { + expect(getInitials('Jane\u2003Doe')).toBe('JD') + }) + + 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', () => { - const initials = getInitials('') - expect(initials).toBe('') + expect(getInitials('')).toBe('') + }) + + it('returns an empty string when called without a name', () => { + expect(getInitials()).toBe('') + }) + }) + + 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('getAvatarImageProps', () => { + const imageMap = { + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + } + + it('returns a string image directly', () => { + expect(getSources('avatar.png', 36)).toEqual({ src: 'avatar.png' }) + }) + + it('uses the largest valid source as the fallback src for source maps', () => { + expect(getSources(imageMap, 36)).toEqual({ + 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' }, + ], + }) }) - it('returns an empty string for when called without name', () => { - const initials = getInitials() - expect(initials).toBe('') + it('returns undefined for an empty source map', () => { + expect(getSources({}, 36)).toBeUndefined() + }) + + it('ignores invalid source entries', () => { + expect( + getSources( + { + '-10': 'avatar-negative.png', + 0: 'avatar-zero.png', + 36: '', + 72: 'avatar-72.png', + } as Record<number, string>, + 36, + ), + ).toEqual({ + src: 'avatar-72.png', + srcSet: 'avatar-72.png 72w', + sizes: '36px', + sources: [{ sourceSize: 72, src: 'avatar-72.png' }], + }) }) }) - describe('emailToIndex', () => { - it('returns an index for a given mail', () => { - const index = emailToIndex('henning@doist.com', 13) - expect(index).toBe(12) + describe('getAvatarImageIdentityKey', () => { + it('returns the string image as its identity', () => { + expect(getAvatarImageIdentityKey('avatar.png')).toBe('avatar.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 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('returns 0 index if local part of email is empty', () => { - const index1 = emailToIndex('@doist.com', 13) - expect(index1).toBe(0) + 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 = getSources( + { + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + }, + 36, + ) + + expect(getAvailableImageSources(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(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', () => { + 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('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') + + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThan(AVATAR_META_COLOR_COUNT) }) }) }) diff --git a/src/avatar/utils.ts b/src/avatar/utils.ts index 633f9efe..a4b9eaa8 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -1,30 +1,167 @@ +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<number, string> + +type AvatarImageSource = { + sourceSize: number + src: string +} + +type ImageSources = { + src: string + srcSet?: string + sizes?: string + sources?: AvatarImageSource[] +} + +const AVATAR_META_COLOR_COUNT = 20 + +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(WHITESPACE_REGEXP, ' ') ?? '' +} + +function getGraphemeClusters(value: string) { + if (GRAPHEME_SEGMENTER) { + return Array.from(GRAPHEME_SEGMENTER.segment(value), ({ segment }) => segment) + } + + return Array.from(value) +} + +function getInitialGrapheme(value?: string) { + return getGraphemeClusters(value?.toUpperCase() ?? '')[0] ?? '' +} + function getInitials(name?: string) { - if (!name) { + const nameParts = normalizeAvatarName(name).split(' ').filter(Boolean) + + if (nameParts.length === 0) { return '' } - const seed = name.trim().split(' ') - const firstInitial = seed[0] - const lastInitial = seed[seed.length - 1] + if (nameParts.length === 1) { + return getGraphemeClusters(nameParts[0]!.toUpperCase()).slice(0, 2).join('') + } + + return `${getInitialGrapheme(nameParts[0])}${getInitialGrapheme(nameParts[nameParts.length - 1])}` +} + +function getSortedImageSources(image: Record<number, string>): 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, +): ImageSources | 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 getSources(image: AvatarImage | undefined, size: AvatarSize): ImageSources | undefined { + if (!image) { + return undefined + } + + if (typeof image === 'string') { + return { src: image } + } + + const sources = getSortedImageSources(image) + 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('|') +} - 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] +function getAvailableImageSources( + imageProps: ImageSources | undefined, + failedSources: readonly string[], +) { + if (!imageProps) { + return undefined } - return initials?.toUpperCase() + + if (failedSources.length === 0) { + return imageProps + } + + if (!imageProps.sources) { + return failedSources.includes(imageProps.src) ? undefined : imageProps + } + + return getImagePropsFromSources( + imageProps.sources.filter(({ src }) => !failedSources.includes(src)), + imageProps.sizes, + ) } -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 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 { emailToIndex, getInitials } +export { + AVATAR_META_COLOR_COUNT, + AVATAR_SIZES, + getAvailableImageSources, + getAvatarImageIdentityKey, + getAvatarMetaColorIndex, + getInitials, + getSources, + normalizeAvatarName, +} +export type { AvatarImage, AvatarImageSource, AvatarShape, AvatarSize, ImageSources } 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 } diff --git a/stories/components/Avatar.stories.tsx b/stories/components/Avatar.stories.tsx deleted file mode 100644 index 51eea3f7..00000000 --- a/stories/components/Avatar.stories.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import './styles/avatar_story.css' - -import * as React from 'react' - -import { Avatar, Box, Inline } 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 - -// Story Definitions ========================================================== - -export const InitialsAvatarStory = () => ( - <Inline space="small"> - {exampleData.map((data, index) => ( - <Avatar key={index} size={data.size} user={data.user} /> - ))} - </Inline> -) - -export const CustomColorAvatarStory = () => ( - <Inline space="small"> - {exampleData.map((data, index) => ( - <Avatar - colorList={['palevioletred', 'palegoldenrod', 'palegreen', 'paleturquoise']} - key={index} - size={data.size} - user={data.user} - /> - ))} - </Inline> -) - -export const PictureAvatarStory = () => ( - <Inline space="small"> - {exampleData.map((data, index) => ( - <Avatar key={index} size={data.size} user={data.user} avatarUrl={data.image} /> - ))} - </Inline> -) - -export const AvatarPlaygroundStory = (args) => { - return ( - <Box className="story Avatar"> - <Avatar - {...args} - user={{ - name: args.userName, - email: args.email, - }} - /> - </Box> - ) -} - -AvatarPlaygroundStory.args = { - size: 'l', - avatarUrl: 'https://loremflickr.com/320/320', - userName: '', - email: '', -} - -AvatarPlaygroundStory.argTypes = { - size: { - type: 'select', - options: ['xxs', 'xs', 's', 'm', 'l', 'xl', 'xxl', 'xxxl'], - }, - avatarUrl: { - control: { - type: 'text', - }, - }, - userName: { - control: { - type: 'text', - }, - }, - email: { - control: { - type: 'text', - }, - }, - className: { - control: { - type: null, - }, - }, - user: { - control: { - type: null, - }, - }, - colorList: { - control: { - type: null, - }, - }, -} 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/*"]