diff --git a/src/design-system/composites/AvatarGroup/AvatarGroup.module.css b/src/design-system/composites/AvatarGroup/AvatarGroup.module.css new file mode 100644 index 0000000..fb7fc81 --- /dev/null +++ b/src/design-system/composites/AvatarGroup/AvatarGroup.module.css @@ -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; +} diff --git a/src/design-system/composites/AvatarGroup/AvatarGroup.test.tsx b/src/design-system/composites/AvatarGroup/AvatarGroup.test.tsx new file mode 100644 index 0000000..416d305 --- /dev/null +++ b/src/design-system/composites/AvatarGroup/AvatarGroup.test.tsx @@ -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) { + return render( + + + + ) +} + +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() + }) +}) diff --git a/src/design-system/composites/AvatarGroup/AvatarGroup.tsx b/src/design-system/composites/AvatarGroup/AvatarGroup.tsx new file mode 100644 index 0000000..52cef01 --- /dev/null +++ b/src/design-system/composites/AvatarGroup/AvatarGroup.tsx @@ -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 ( + + {visible.map((name, index) => ( + + ))} + {overflow > 0 && ( + + +{overflow} + + )} + + ) +}