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 ? (
+
+ {`+${overflowCount}`}
+
+ ) : 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 }