diff --git a/src/avatar/__snapshots__/avatar.test.tsx.snap b/src/avatar/__snapshots__/avatar.test.tsx.snap deleted file mode 100644 index 565641450..000000000 --- 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-group.module.css b/src/avatar/avatar-group.module.css new file mode 100644 index 000000000..f0d540749 --- /dev/null +++ b/src/avatar/avatar-group.module.css @@ -0,0 +1,272 @@ +/* + * 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-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-thickness) + ); + + /* 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-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) + ); + + 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-thickness: 3px; + --reactist-avatar-group-rounded-radius: 10px; +} + +.avatarGroupSize-72 { + --reactist-avatar-group-size: 72px; + --reactist-avatar-group-overlap: 8px; + --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-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-thickness: 3px; + --reactist-avatar-group-rounded-radius: 7px; +} + +.avatarGroupSize-40 { + --reactist-avatar-group-size: 40px; + --reactist-avatar-group-overlap: 4px; + --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-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-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-thickness: 2px; + --reactist-avatar-group-rounded-radius: 5px; +} + +.avatarGroupSize-24 { + --reactist-avatar-group-size: 24px; + --reactist-avatar-group-overlap: 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-thickness: 2px; + --reactist-avatar-group-rounded-radius: 3px; +} + +.avatarGroupSize-18 { + --reactist-avatar-group-size: 18px; + --reactist-avatar-group-overlap: 2px; + --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-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-thickness: 1px; + --reactist-avatar-group-rounded-radius: 1.6px; +} + +.avatarGroup > * { + flex-shrink: 0; +} + +.avatarGroup > * + * { + 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-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-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), + 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-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); + 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.stories.tsx b/src/avatar/avatar-group.stories.tsx new file mode 100644 index 000000000..2cfe9de0b --- /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): (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) { + 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({ + contributor, + size, +}: { + contributor: (typeof contributors)[number] + size: React.ComponentProps['size'] +}) { + return ( + + ) +} + +function WorkspaceAvatar({ + name, + size, +}: { + name: string + size: React.ComponentProps['size'] +}) { + return +} + +function CustomOverlayStyle() { + return ( + + ) +} + +const meta = { + title: 'Components/Avatar/AvatarGroup', + component: AvatarGroup, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const People = { + render: () => ( + + + + + + {contributors.slice(0, 5).map((contributor) => ( + + ))} + + + + + {contributors.slice(1, 4).map((contributor) => ( + + ))} + + + + + {contributors.slice(2, 5).map((contributor) => ( + + ))} + + + + + + ), +} satisfies Story + +export const Workspaces = { + render: () => ( + + + + + + {workspaceNames.map((name) => ( + + ))} + + + + + + + + + + + {workspaceNames.slice(0, 3).map((name) => ( + + ))} + + + + + + ), +} satisfies Story + +export const Sizes = { + render: () => ( + + + + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + + + {[0, 1, 2].map((offset) => ( + + ))} + + + ))} + + + + + + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + + + {[0, 1, 2].map((offset) => ( + + ))} + + + ))} + + + + ), +} satisfies Story + +export const CountOverlay = { + render: () => ( + + + + + + + + {contributors.slice(0, 4).map((contributor) => ( + + ))} + + + + + {contributors.slice(1, 5).map((contributor) => ( + + ))} + + + + + {workspaceNames.map((name) => ( + + ))} + + + + + + ), +} satisfies Story diff --git a/src/avatar/avatar-group.test.tsx b/src/avatar/avatar-group.test.tsx new file mode 100644 index 000000000..965afc49d --- /dev/null +++ b/src/avatar/avatar-group.test.tsx @@ -0,0 +1,106 @@ +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( + + + + , + ) + + 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('hides the count overlay from assistive tech', () => { + render( + + + + , + ) + + expect(screen.getByText('3')).toHaveAttribute('aria-hidden', 'true') + }) + + it('omits the count overlay when count is not positive', () => { + render( + + + + , + ) + + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() + }) + + it('omits the count overlay when count is not provided', () => { + render( + + + + , + ) + + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument() + }) + + it('renders the count overlay alongside a single avatar', () => { + render( + + + , + ) + + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Jane Doe' })).toBeInTheDocument() + }) + + it('applies the escape hatch class name', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveClass('custom-group') + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + + + + + + + + + + + + + + , + ) + 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 000000000..6aa533c0c --- /dev/null +++ b/src/avatar/avatar-group.tsx @@ -0,0 +1,112 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { Box } from '../box' +import { polymorphicComponent } from '../utils/polymorphism' + +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' + +/** + * 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 = 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 ( + + {children} + {overflowCount !== null ? ( + + ) : null} + + ) + }, +) + +export { AvatarGroup } +export type { AvatarGroupProps } diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx new file mode 100644 index 000000000..3bf4998f2 --- /dev/null +++ b/src/avatar/avatar.mdx @@ -0,0 +1,212 @@ +import { + Canvas, + ColorItem, + ColorPalette, + Controls, + Markdown, + Meta, + Subtitle, + Title, +} from '@storybook/addon-docs/blocks' + +import * as AvatarStories from './avatar.stories' +import * as AvatarGroupStories from './avatar-group.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} /> + +## 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 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={AvatarGroupStories.People} /> + +## 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']} /> + <ColorItem + title="--reactist-avatar-group-count-overlay-background" + colors={['rgba(0, 0, 0, 0.6)']} + /> +</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 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; +} + +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask-thickness: 2.5px; + --reactist-avatar-group-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 4c97af2bb..acaec40d6 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -1,128 +1,191 @@ :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: var(--reactist-actionable-primary-idle-tint); + --reactist-avatar-border-tint: #0000001a; + --reactist-avatar-empty-fill: var(--reactist-framework-fill-crest); + + --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; +} + +.avatar:has(.initials) { + background-color: var(--reactist-avatar-meta-fill); } -.size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); +.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); } -.size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); +.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); } -.size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); +.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); } -.size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); +.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); } -.size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); +.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); } -.size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); +.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); } -.size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); +.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); } -.size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); +.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); } -/* avatar size for tablet */ -@media (min-width: 768px /* --reactist-breakpoint-tablet */) { - .tablet-size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); - } +.meta-color-8 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-8-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-8-on-idle-tint); +} + +.meta-color-9 { + --reactist-avatar-meta-fill: var(--reactist-avatar-meta-9-fill); + --reactist-avatar-initials-color: var(--reactist-avatar-meta-9-on-idle-tint); +} - .tablet-size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); - } +.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); +} - .tablet-size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); - } +.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); +} - .tablet-size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); - } +.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); +} - .tablet-size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); - } +.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); +} - .tablet-size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); - } +.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); +} - .tablet-size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); - } +.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); +} - .tablet-size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); - } +.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); } -/* avatar size for desktop */ -@media (min-width: 992px /* --reactist-breakpoint-desktop */) { - .desktop-size-xxs { - --reactist-avatar-size: var(--reactist-avatar-size-xxsmall); - } +.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); +} - .desktop-size-xs { - --reactist-avatar-size: var(--reactist-avatar-size-xsmall); - } +.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); +} - .desktop-size-s { - --reactist-avatar-size: var(--reactist-avatar-size-small); - } +.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); +} - .desktop-size-m { - --reactist-avatar-size: var(--reactist-avatar-size-medium); - } +.empty { + background-color: var(--reactist-avatar-empty-fill); +} - .desktop-size-l { - --reactist-avatar-size: var(--reactist-avatar-size-large); - } +.shape-circle { + border-radius: 50%; +} - .desktop-size-xl { - --reactist-avatar-size: var(--reactist-avatar-size-xlarge); - } +.shape-rounded { + border-radius: var(--reactist-avatar-rounded-radius); +} - .desktop-size-xxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxlarge); - } +.image { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; +} - .desktop-size-xxxl { - --reactist-avatar-size: var(--reactist-avatar-size-xxxlarge); - } +.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 000000000..720595d14 --- /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 68409eafb..e78137b30 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -1,64 +1,305 @@ 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 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(<Avatar data-testid="avatar" size={36} name="Jane Doe" image="avatar.png" />) + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveAttribute('src', 'avatar.png') + expect(screen.getByTestId('avatar')).toHaveStyle({ + '--reactist-avatar-size': '36px', + }) + }) + + 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" />) + + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') + 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('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" />) + + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + + 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" />) + + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + 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('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', + }} + />, + ) + + 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( + <Avatar + size={36} + name="Jane Doe" + image={{ + 36: 'avatar-36.png', + 72: 'avatar-72.png', + 144: 'avatar-144.png', + }} + />, + ) + + 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') + }) - const avatar = screen.getByTestId('avatar') + 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} />) - expect(avatar).toMatchSnapshot() + failCurrentAvatarImage('avatar-144.png') + + rerender(<Avatar size={72} name="Jane Doe" image={image} />) + + 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 name when avatarUrl is not supplied', () => { - render(getAvatar()) + 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') + failCurrentAvatarImage('avatar-72.png') + failCurrentAvatarImage('avatar-36.png') - expect(avatar).toHaveTextContent('HM') + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') }) - it('renders initials of user email when avatarUrl is not supplied', () => { - render(getAvatar({ user: { email: 'henning@doist.com' } })) + it('retries a failed image when the same image is provided after being removed', () => { + const { rerender } = render(<Avatar size={36} name="Jane Doe" image="missing.png" />) + + fireEvent.error(screen.getByRole('img', { name: 'Jane Doe' })) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') - const avatar = screen.getByTestId('avatar') + rerender(<Avatar size={36} name="Jane Doe" />) + expect(screen.getByRole('img', { name: 'Jane Doe' })).toHaveTextContent('JD') - expect(avatar).toHaveTextContent('H') + 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')).toHaveClass('empty') + expect(screen.getByTestId('avatar')).toHaveTextContent('') }) - it('supports responsive values', () => { + 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" + />, ) - const avatar = screen.getByTestId('avatar') - expect(avatar).toHaveClass('size-s') - expect(avatar).toHaveClass('desktop-size-xl') - expect(avatar).toHaveClass('tablet-size-xxl') + 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" />) + + expect(screen.getByTestId('avatar')).toHaveClass('shape-rounded') + expect(screen.getByTestId('avatar')).toHaveStyle({ + '--reactist-avatar-rounded-radius': '7px', + }) + }) + + 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.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true') }) - // Helpers ================================================================ - function getAvatar( - props?: Omit<React.ComponentProps<typeof Avatar>, 'user'> & { - user?: { name?: string; email: string } - }, - ) { - return ( + 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') + }) + + it('supports decorative initials avatars with empty alt text', () => { + render(<Avatar data-testid="avatar" size={36} name="Jane Doe" alt="" />) + + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.getByTestId('avatar')).toHaveAttribute('aria-hidden', 'true') + expect(screen.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 928051384..9704dbc76 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -1,81 +1,218 @@ 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, + 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', -] - -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, AvatarShape, AvatarSize, ImageSources } from './utils' + +type AvatarStyle = React.CSSProperties & { + '--reactist-avatar-size': string + '--reactist-avatar-rounded-radius': string } -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) +/** + * 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?: 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 + + /** + * Avatar owns its root sizing styles. Use `exceptionallySetClassName` for the styling escape + * hatch. + */ + style?: never +} + +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 = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(function Avatar( + { as, image, ...restProps }, + ref, +) { return ( - <Box - className={[className, styles.avatar, sizeClassName, exceptionallySetClassName]} - style={style} - {...props} - > - {userInitials} - </Box> + <AvatarContent + as={as} + 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 { + '--reactist-avatar-size': `${size}px`, + '--reactist-avatar-rounded-radius': ROUNDED_AVATAR_RADIUS_BY_SIZE[size], + } +} + +function getAbsoluteImageSource(src: string, image: HTMLImageElement) { + try { + return new URL(src, image.ownerDocument.baseURI).href + } 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 { AvatarImage, AvatarProps, AvatarShape, AvatarSize } diff --git a/src/avatar/index.ts b/src/avatar/index.ts index 886c6ec3a..c57e70d17 100644 --- a/src/avatar/index.ts +++ b/src/avatar/index.ts @@ -1 +1,2 @@ export * from './avatar' +export * from './avatar-group' diff --git a/src/avatar/utils.test.ts b/src/avatar/utils.test.ts index 4cb13c2f0..779ac8561 100644 --- a/src/avatar/utils.test.ts +++ b/src/avatar/utils.test.ts @@ -1,53 +1,239 @@ -import { emailToIndex, getInitials } from './utils' +import { + AVATAR_META_COLOR_COUNT, + getAvailableImageSources, + getAvatarImageIdentityKey, + getAvatarMetaColorIndex, + getInitials, + getSources, + normalizeAvatarName, + 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') + 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 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('normalizeAvatarName', () => { + it('trims and collapses whitespace', () => { + expect(normalizeAvatarName(' Jane Doe ')).toBe('Jane Doe') }) - 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 an empty string for undefined', () => { + expect(normalizeAvatarName()).toBe('') }) - it('returns 0 index if local part of email is empty', () => { - const index1 = emailToIndex('@doist.com', 13) - expect(index1).toBe(0) + 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 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('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 = 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) + }) + }) + + 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 633f9efe1..f8d094093 100644 --- a/src/avatar/utils.ts +++ b/src/avatar/utils.ts @@ -1,30 +1,184 @@ +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 ROUNDED_AVATAR_RADIUS_BY_SIZE: Record<AvatarSize, string> = { + 80: '10px', + 72: '10px', + 62: '8.5px', + 50: '7px', + 40: '5.5px', + 36: '5px', + 30: '5px', + 28: '5px', + 24: '3.2px', + 20: '3px', + 18: '3px', + 16: '2px', + 12: '1.6px', +} + +const WHITESPACE_REGEXP = new RegExp('\\p{White_Space}+', 'gu') +const GRAPHEME_SEGMENTER = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? 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, + } +} - 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 getSources(image: AvatarImage | undefined, size: AvatarSize): ImageSources | undefined { + if (!image) { + return undefined } - return initials?.toUpperCase() + + if (typeof image === 'string') { + return { src: image } + } + + const sources = getSortedImageSources(image) + return getImagePropsFromSources(sources, `${size}px`) } -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 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('|') } -export { emailToIndex, getInitials } +function getAvailableImageSources( + imageProps: ImageSources | undefined, + failedSources: readonly string[], +) { + if (!imageProps) { + return undefined + } + + 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 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, + getAvailableImageSources, + getAvatarImageIdentityKey, + getAvatarMetaColorIndex, + getInitials, + getSources, + normalizeAvatarName, + ROUNDED_AVATAR_RADIUS_BY_SIZE, +} +export type { AvatarImage, AvatarImageSource, AvatarShape, AvatarSize, ImageSources } diff --git a/src/utils/polymorphism.ts b/src/utils/polymorphism.ts index afb26bbdf..b7e56297f 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 51eea3f7c..000000000 --- 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 e035be3b1..e60c62b6f 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/*"]