diff --git a/src/avatar/avatar-group.stories.tsx b/src/avatar/avatar-group.stories.tsx new file mode 100644 index 00000000..0247c474 --- /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-pair.stories.tsx b/src/avatar/avatar-pair.stories.tsx new file mode 100644 index 00000000..b636471c --- /dev/null +++ b/src/avatar/avatar-pair.stories.tsx @@ -0,0 +1,226 @@ +import * as React from 'react' + +import { Avatar, AvatarPair, 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 +} + +const meta = { + title: 'Components/Avatar/AvatarPair', + component: AvatarPair, + parameters: { + badges: ['accessible'], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const People = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Workspaces = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + ), +} satisfies Story + +export const Sizes = { + render: () => ( + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => ( + + + + + + + ))} + + + + ), +} satisfies Story diff --git a/src/avatar/avatar.mdx b/src/avatar/avatar.mdx index 7700841a..1903c052 100644 --- a/src/avatar/avatar.mdx +++ b/src/avatar/avatar.mdx @@ -25,6 +25,26 @@ and the deterministic meta color used when initials render. +## 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. + + + +## Avatar pairs + +Use `AvatarPair` when a surface represents two related people or entities. Pass +the same `size` to the pair and its exactly two direct `Avatar` children. The +first child is the foreground avatar (positioned bottom-right); the second is +positioned diagonally above-left of the first child. + + + ## Migrating from the legacy API The previous Avatar API accepted `user`, `avatarUrl`, `colorList`, string or @@ -110,6 +130,7 @@ component appearance. The values shown below are the default values. + #### Avatar meta colors @@ -168,6 +189,20 @@ the component props instead of overriding them directly. --reactist-avatar-size: 36px; --reactist-avatar-rounded-radius: 5px; } + +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; +} + +.avatarPair { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 5px; +} ``` ## What the consumer owns diff --git a/src/avatar/avatar.module.css b/src/avatar/avatar.module.css index acaec40d..d38b23d4 100644 --- a/src/avatar/avatar.module.css +++ b/src/avatar/avatar.module.css @@ -189,3 +189,215 @@ line-height: 1; user-select: none; } + +.avatarGroup { + --reactist-avatar-group-size: 36px; + --reactist-avatar-group-overlap: 4px; + --reactist-avatar-group-mask: 2.5px; + --reactist-avatar-group-rounded-radius: 5px; + --reactist-avatar-group-rounded-mask-radius: 7.5px; + --reactist-avatar-group-count-overlay: rgba(0, 0, 0, 0.6); + --reactist-avatar-group-rounded-mask-width: calc( + var(--reactist-avatar-group-overlap) + var(--reactist-avatar-group-mask) + ); + --reactist-avatar-group-rounded-mask-corner-x: calc( + var(--reactist-avatar-group-overlap) - var(--reactist-avatar-group-rounded-radius) + ); + --reactist-avatar-group-previous-center-x: calc( + (var(--reactist-avatar-group-size) / 2) - var(--reactist-avatar-group-size) + + var(--reactist-avatar-group-overlap) + ); + + position: relative; +} + +.avatarGroup > * { + flex-shrink: 0; +} + +.avatarGroup > * + * { + margin-left: calc(-1 * var(--reactist-avatar-group-overlap)); +} + +.avatarGroupShape-circle > * + * { + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-group-size) / 2) + var(--reactist-avatar-group-mask)) at + var(--reactist-avatar-group-previous-center-x) 50%, + transparent 99%, + #000 100% + ); +} + +.avatarGroupShape-rounded > * + * { + -webkit-mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + transparent 99%, + #000 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); + mask-image: + linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) + var(--reactist-avatar-group-rounded-radius), + transparent 99%, + #000 100% + ), + radial-gradient( + circle var(--reactist-avatar-group-rounded-mask-radius) at + var(--reactist-avatar-group-rounded-mask-corner-x) 0, + transparent 99%, + #000 100% + ); + -webkit-mask-position: + right top, + left top, + left bottom; + mask-position: + right top, + left top, + left bottom; + -webkit-mask-size: + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); + mask-size: + calc(100% - var(--reactist-avatar-group-rounded-mask-width)) 100%, + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius), + var(--reactist-avatar-group-rounded-mask-width) var(--reactist-avatar-group-rounded-radius); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} + +.avatarGroupCount { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: var(--reactist-avatar-group-size); + height: var(--reactist-avatar-group-size); + border-radius: 50%; + background: var(--reactist-avatar-group-count-overlay); + color: var(--reactist-avatar-initials-color); + font-size: calc(var(--reactist-avatar-group-size) / 2); + font-weight: var(--reactist-font-weight-medium); + line-height: 1; + pointer-events: none; + user-select: none; +} + +.avatarGroupShape-rounded > .avatarGroupCount { + border-radius: var(--reactist-avatar-group-rounded-radius); +} + +.avatarGroup > .avatarGroupCount { + -webkit-mask-image: none; + mask-image: none; +} + +.avatarPair { + --reactist-avatar-pair-size: 28px; + --reactist-avatar-pair-spacing: 12px; + --reactist-avatar-pair-mask: 2px; + --reactist-avatar-pair-rounded-radius: 5px; + --reactist-avatar-pair-rounded-mask-radius: 7px; + --reactist-avatar-pair-rounded-mask-start: calc( + var(--reactist-avatar-pair-spacing) - var(--reactist-avatar-pair-mask) + ); + --reactist-avatar-pair-first-center-x: calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + ); + --reactist-avatar-pair-first-center-y: calc( + (var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-spacing) + ); + + position: relative; + width: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); + height: calc(var(--reactist-avatar-pair-size) + var(--reactist-avatar-pair-spacing)); +} + +.avatarPair > * { + position: absolute; +} + +.avatarPair > :first-child { + right: 0; + bottom: 0; +} + +.avatarPair > :last-child { + top: 0; + left: 0; +} + +.avatarPairShape-circle > :last-child { + -webkit-mask-image: radial-gradient( + circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at + var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + transparent 99%, + #000 100% + ); + mask-image: radial-gradient( + circle calc((var(--reactist-avatar-pair-size) / 2) + var(--reactist-avatar-pair-mask)) at + var(--reactist-avatar-pair-first-center-x) var(--reactist-avatar-pair-first-center-y), + transparent 99%, + #000 100% + ); +} + +.avatarPairShape-rounded > :last-child { + -webkit-mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + mask-image: + linear-gradient(#000 0 0), linear-gradient(#000 0 0), + radial-gradient( + circle var(--reactist-avatar-pair-rounded-mask-radius) at 100% 100%, + transparent 99%, + #000 100% + ); + -webkit-mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + mask-position: + 0 0, + 0 0, + var(--reactist-avatar-pair-rounded-mask-start) + var(--reactist-avatar-pair-rounded-mask-start); + -webkit-mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + mask-size: + 100% var(--reactist-avatar-pair-rounded-mask-start), + var(--reactist-avatar-pair-rounded-mask-start) 100%, + var(--reactist-avatar-pair-rounded-mask-radius) + var(--reactist-avatar-pair-rounded-mask-radius); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} diff --git a/src/avatar/avatar.stories.tsx b/src/avatar/avatar.stories.tsx index 720595d1..2000adf6 100644 --- a/src/avatar/avatar.stories.tsx +++ b/src/avatar/avatar.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { Avatar, Box, Inline, Stack, Text } from '../index' +import { Avatar, AvatarGroup, AvatarPair, Box, Inline, Stack, Text } from '../index' import { AVATAR_SIZES, getAvatarMetaColorIndex } from './utils' @@ -168,6 +168,18 @@ function WorkspaceAvatarExample(props: Omit) { return } +function AvatarGroupCustomOverlayStyle() { + return ( + + ) +} + function AvatarColorExample({ index, name }: { index: number; name: string }) { return ( @@ -218,6 +230,166 @@ export const Default = { ), } satisfies Story +export const AvatarGroups = { + render: () => ( + + + + + + + + {contributors.slice(1, 6).map((contributor) => ( + + ))} + + + + + {contributors.slice(2, 5).map((contributor) => ( + + ))} + + + + + {contributors.slice(3, 7).map((contributor) => ( + + ))} + + + + + + + + {([80, 62, 50, 36, 24, 18, 12] as const).map((size, index) => ( + + + {[0, 1, 2].map((offset) => { + const contributor = getContributor(index + offset)! + + return ( + + ) + })} + + + ))} + + + + ), +} satisfies Story + +export const AvatarPairs = { + render: () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + {([80, 62, 50, 36, 28, 20, 16, 12] as const).map((size, index) => { + const firstContributor = getContributor(index)! + const secondContributor = getContributor(index + 1)! + + return ( + + + + + + + ) + })} + + + + ), +} satisfies Story + export const InitialsFallback = { render: () => ( diff --git a/src/avatar/avatar.test.tsx b/src/avatar/avatar.test.tsx index e78137b3..9673ac51 100644 --- a/src/avatar/avatar.test.tsx +++ b/src/avatar/avatar.test.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { axe } from 'jest-axe' -import { Avatar } from './avatar' +import { Avatar, AvatarGroup, AvatarPair } from './avatar' describe('Avatar', () => { function failCurrentAvatarImage(currentSrc: string) { @@ -303,3 +303,349 @@ describe('Avatar', () => { }) }) }) + +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('sets size-derived spacing variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '36px', + '--reactist-avatar-group-overlap': '4px', + '--reactist-avatar-group-mask': '2.5px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(5px + 2.5px)', + }) + }) + + it('sets large size-derived spacing variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveStyle({ + '--reactist-avatar-group-size': '80px', + '--reactist-avatar-group-overlap': '8px', + '--reactist-avatar-group-mask': '3px', + '--reactist-avatar-group-rounded-mask-radius': 'calc(10px + 3px)', + }) + }) + + it('renders the count overlay when count is positive', () => { + render( + + + + , + ) + + expect(screen.getByText('+3')).toBeInTheDocument() + }) + + 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('leaves the count overlay custom property available for CSS customization', () => { + render( + + + + , + ) + + expect( + screen + .getByTestId('group') + .style.getPropertyValue('--reactist-avatar-group-count-overlay'), + ).toBe('') + }) + + it('applies the group shape class', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveClass('avatarGroupShape-rounded') + }) + + it('applies the escape hatch class name', () => { + render( + + + + , + ) + + expect(screen.getByTestId('group')).toHaveClass('custom-group') + }) + + it('can render as a button', () => { + render( + + + + , + ) + + expect(screen.getByRole('button', { name: 'Manage members' })).toBeVisible() + }) + + it('derives the root ref type from the element rendered with as', () => { + const anchorRef = React.createRef() + const buttonRef = React.createRef() + + render( + + + + , + ) + + expect(anchorRef.current).toBe(screen.getByTestId('group')) + + const invalidRefElement = ( + // @ts-expect-error refs must match the element selected with as + + + + + ) + expect(invalidRefElement).toBeTruthy() + }) + + describe('a11y', () => { + it('renders with no a11y violations', async () => { + const { container } = render( + <> + + + + + + + + + + + + + + + + + , + ) + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + }) +}) + +describe('AvatarPair', () => { + it('renders direct Avatar children without wrappers', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('first')) + expect(screen.getByTestId('pair')).toContainElement(screen.getByTestId('second')) + expect(screen.getByTestId('first').parentElement).toBe(screen.getByTestId('pair')) + expect(screen.getByTestId('second').parentElement).toBe(screen.getByTestId('pair')) + }) + + it('sets size-derived pair variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveStyle({ + '--reactist-avatar-pair-size': '28px', + '--reactist-avatar-pair-spacing': '12px', + '--reactist-avatar-pair-mask': '2px', + '--reactist-avatar-pair-rounded-mask-radius': 'calc(5px + 2px)', + }) + }) + + it('sets large size-derived pair variables', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveStyle({ + '--reactist-avatar-pair-size': '80px', + '--reactist-avatar-pair-spacing': '36px', + '--reactist-avatar-pair-mask': '3px', + '--reactist-avatar-pair-rounded-mask-radius': 'calc(10px + 3px)', + }) + }) + + it('applies the pair shape class', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveClass('avatarPairShape-rounded') + }) + + it('requires exactly two children at the type level', () => { + const invalidPair = ( + // @ts-expect-error AvatarPair children must be a tuple of two elements + + + + ) + expect(invalidPair).toBeTruthy() + }) + + it('applies the escape hatch class name', () => { + render( + + + + , + ) + + expect(screen.getByTestId('pair')).toHaveClass('custom-pair') + }) + + it('can render as a button', () => { + render( + + + + , + ) + + expect(screen.getByRole('button', { name: 'Open workspace pair' })).toBeVisible() + }) + + it('derives the root ref type from the element rendered with as', () => { + const anchorRef = React.createRef() + const buttonRef = React.createRef() + + render( + + + + , + ) + + expect(anchorRef.current).toBe(screen.getByTestId('pair')) + + const invalidRefElement = ( + // @ts-expect-error refs must match the element selected with as + + + + + ) + expect(invalidRefElement).toBeTruthy() + }) + + 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.tsx b/src/avatar/avatar.tsx index 9704dbc7..945f8160 100644 --- a/src/avatar/avatar.tsx +++ b/src/avatar/avatar.tsx @@ -26,6 +26,70 @@ type AvatarStyle = React.CSSProperties & { '--reactist-avatar-rounded-radius': string } +type AvatarGroupStyle = React.CSSProperties & { + '--reactist-avatar-group-size': string + '--reactist-avatar-group-overlap': string + '--reactist-avatar-group-mask': string + '--reactist-avatar-group-rounded-radius': string + '--reactist-avatar-group-rounded-mask-radius': string +} + +type AvatarPairStyle = React.CSSProperties & { + '--reactist-avatar-pair-size': string + '--reactist-avatar-pair-spacing': string + '--reactist-avatar-pair-mask': string + '--reactist-avatar-pair-rounded-radius': string + '--reactist-avatar-pair-rounded-mask-radius': string +} + +const AVATAR_GROUP_OVERLAP_BY_SIZE: Record = { + 80: '8px', + 72: '8px', + 62: '8px', + 50: '4px', + 40: '4px', + 36: '4px', + 30: '2px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '2px', + 16: '2px', + 12: '1px', +} + +const AVATAR_MASK_BY_SIZE: Record = { + 80: '3px', + 72: '3px', + 62: '3px', + 50: '3px', + 40: '3px', + 36: '2.5px', + 30: '2.5px', + 28: '2px', + 24: '2px', + 20: '2px', + 18: '1.5px', + 16: '1.25px', + 12: '1px', +} + +const AVATAR_PAIR_SPACING_BY_SIZE: Record = { + 80: '36px', + 72: '32px', + 62: '28px', + 50: '22px', + 40: '18px', + 36: '16px', + 30: '14px', + 28: '12px', + 24: '12px', + 20: '10px', + 18: '10px', + 16: '8px', + 12: '6px', +} + /** * Props for the `Avatar` component. */ @@ -84,6 +148,101 @@ type AvatarProps = PolymorphicC 'omitClassName' > +/** + * Props for the `AvatarGroup` component. + */ +type AvatarGroupOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The grouped avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * Additional people not shown in the group. When positive, rendered as a + * decorative `+N` overlay on top of the final avatar; hidden from + * assistive tech. + */ + count?: number + + /** + * Grouped Avatar children. + */ + children: React.ReactNode + + /** + * Test identifier applied to the avatar group root element. + */ + 'data-testid'?: string + + /** + * AvatarGroup owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarGroupProps = PolymorphicComponentProps< + ComponentType, + AvatarGroupOwnProps, + 'omitClassName' +> + +/** + * Props for the `AvatarPair` component. + */ +type AvatarPairOwnProps = ObfuscatedClassName & { + /** + * The rendered avatar size, in CSS pixels. + * + * Direct child Avatar components should use the same size. + */ + size: AvatarSize + + /** + * The paired avatar shape. + * + * Direct child Avatar components should use the same shape. + * + * @default 'circle' + */ + shape?: AvatarShape + + /** + * Exactly two paired Avatar children. The first child is the foreground + * avatar (positioned bottom-right); the second is the diagonal overlay + * (positioned top-left, masked where it overlaps the first). + */ + children: readonly [React.ReactElement, React.ReactElement] + + /** + * Test identifier applied to the avatar pair root element. + */ + 'data-testid'?: string + + /** + * AvatarPair owns its root sizing styles. Use `exceptionallySetClassName` for the styling + * escape hatch. + */ + style?: never +} + +type AvatarPairProps = PolymorphicComponentProps< + ComponentType, + AvatarPairOwnProps, + 'omitClassName' +> + const AvatarContent = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>( function AvatarContent( { @@ -190,6 +349,91 @@ const Avatar = polymorphicComponent<'div', AvatarOwnProps, 'omitClassName'>(func ) }) +/** + * Displays a row of overlapping Avatar children with an optional count overlay + * on the final avatar. + */ +const AvatarGroup = polymorphicComponent<'div', AvatarGroupOwnProps, 'omitClassName'>( + function AvatarGroup( + { + as, + size, + shape = 'circle', + count, + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + const overflowCount = count && count > 0 ? count : null + + return ( + + {children} + {overflowCount !== null ? ( + + ) : null} + + ) + }, +) + +/** + * Displays two Avatar children with the second avatar positioned diagonally + * above-left of the first avatar. + */ +const AvatarPair = polymorphicComponent<'div', AvatarPairOwnProps, 'omitClassName'>( + function AvatarPair( + { + as, + size, + shape = 'circle', + children, + exceptionallySetClassName, + 'data-testid': testId, + ...restProps + }, + ref, + ) { + return ( + + {children} + + ) + }, +) + function getAvatarStyle(size: AvatarSize): AvatarStyle { return { '--reactist-avatar-size': `${size}px`, @@ -197,6 +441,32 @@ function getAvatarStyle(size: AvatarSize): AvatarStyle { } } +function getAvatarPairStyle(size: AvatarSize): AvatarPairStyle { + const mask = AVATAR_MASK_BY_SIZE[size] + const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] + + return { + '--reactist-avatar-pair-size': `${size}px`, + '--reactist-avatar-pair-spacing': AVATAR_PAIR_SPACING_BY_SIZE[size], + '--reactist-avatar-pair-mask': mask, + '--reactist-avatar-pair-rounded-radius': roundedRadius, + '--reactist-avatar-pair-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, + } +} + +function getAvatarGroupStyle(size: AvatarSize): AvatarGroupStyle { + const mask = AVATAR_MASK_BY_SIZE[size] + const roundedRadius = ROUNDED_AVATAR_RADIUS_BY_SIZE[size] + + return { + '--reactist-avatar-group-size': `${size}px`, + '--reactist-avatar-group-overlap': AVATAR_GROUP_OVERLAP_BY_SIZE[size], + '--reactist-avatar-group-mask': mask, + '--reactist-avatar-group-rounded-radius': roundedRadius, + '--reactist-avatar-group-rounded-mask-radius': `calc(${roundedRadius} + ${mask})`, + } +} + function getAbsoluteImageSource(src: string, image: HTMLImageElement) { try { return new URL(src, image.ownerDocument.baseURI).href @@ -214,5 +484,5 @@ function getFailedImageSource(imageProps: ImageSources, image: HTMLImageElement) return matchingSource?.src ?? imageProps.src } -export { Avatar } -export type { AvatarImage, AvatarProps, AvatarShape, AvatarSize } +export { Avatar, AvatarGroup, AvatarPair } +export type { AvatarGroupProps, AvatarImage, AvatarPairProps, AvatarProps, AvatarShape, AvatarSize }