feat: add AvatarGroup composite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 15:00:11 +01:00
parent 73bfab757f
commit 222c24cc9a
3 changed files with 150 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
.group {
display: inline-flex;
align-items: center;
}
/* Each avatar (except the first) overlaps the previous */
.avatar {
margin-left: -8px;
outline: 2px solid var(--bg-surface);
border-radius: 50%;
}
.first {
margin-left: 0;
}
/* Size-specific overlap amounts */
.sm .avatar {
margin-left: -6px;
}
.lg .avatar {
margin-left: -10px;
}
/* Overflow circle */
.overflow {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: var(--bg-inset);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
flex-shrink: 0;
margin-left: -8px;
outline: 2px solid var(--bg-surface);
}
.overflow_sm {
width: 24px;
height: 24px;
margin-left: -6px;
}
.overflow_md {
width: 28px;
height: 28px;
margin-left: -8px;
}
.overflow_lg {
width: 40px;
height: 40px;
margin-left: -10px;
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ThemeProvider } from '../../providers/ThemeProvider'
import { AvatarGroup } from './AvatarGroup'
const names = ['Alice Johnson', 'Bob Smith', 'Carol White', 'Dave Brown', 'Eve Davis']
function renderGroup(props: React.ComponentProps<typeof AvatarGroup>) {
return render(
<ThemeProvider>
<AvatarGroup {...props} />
</ThemeProvider>
)
}
describe('AvatarGroup', () => {
it('renders max avatars (default 3) when count exceeds max', () => {
renderGroup({ names })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.getByLabelText('Carol White')).toBeInTheDocument()
expect(screen.queryByLabelText('Dave Brown')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Eve Davis')).not.toBeInTheDocument()
})
it('shows +N overflow indicator for remaining avatars', () => {
renderGroup({ names })
// 5 names, max 3 => +2 overflow
expect(screen.getByText('+2')).toBeInTheDocument()
})
it('renders all avatars and no overflow when count <= max', () => {
renderGroup({ names: ['Alice Johnson', 'Bob Smith'], max: 3 })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument()
})
it('respects a custom max prop', () => {
renderGroup({ names, max: 2 })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.queryByLabelText('Carol White')).not.toBeInTheDocument()
// 5 names, max 2 => +3 overflow
expect(screen.getByText('+3')).toBeInTheDocument()
})
it('accepts a size prop without errors', () => {
const { container } = renderGroup({ names, size: 'lg' })
expect(container.firstChild).toBeInTheDocument()
})
it('renders all avatars when count exactly equals max', () => {
renderGroup({ names: ['Alice Johnson', 'Bob Smith', 'Carol White'], max: 3 })
expect(screen.getByLabelText('Alice Johnson')).toBeInTheDocument()
expect(screen.getByLabelText('Bob Smith')).toBeInTheDocument()
expect(screen.getByLabelText('Carol White')).toBeInTheDocument()
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,32 @@
import styles from './AvatarGroup.module.css'
import { Avatar } from '../../primitives/Avatar/Avatar'
interface AvatarGroupProps {
names: string[]
max?: number
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function AvatarGroup({ names, max = 3, size = 'md', className }: AvatarGroupProps) {
const visible = names.slice(0, max)
const overflow = names.length - visible.length
return (
<span className={`${styles.group} ${styles[size]} ${className ?? ''}`}>
{visible.map((name, index) => (
<Avatar
key={name}
name={name}
size={size}
className={`${styles.avatar} ${index === 0 ? styles.first : ''}`}
/>
))}
{overflow > 0 && (
<span className={`${styles.overflow} ${styles[`overflow_${size}`]}`}>
+{overflow}
</span>
)}
</span>
)
}