feat: add AvatarGroup composite
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
32
src/design-system/composites/AvatarGroup/AvatarGroup.tsx
Normal file
32
src/design-system/composites/AvatarGroup/AvatarGroup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user