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