// components/ui/avatar.tsx
import * as React from 'react';
import { View, Text, Image, ImageSourcePropType } from 'react-native';
import { cn } from '../../lib/utils';
interface AvatarProps {
source?: ImageSourcePropType;
alt?: string;
fallback?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
status?: 'online' | 'offline' | 'away' | 'busy';
className?: string;
}
interface AvatarGroupProps {
children: React.ReactNode;
max?: number;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
interface AvatarImageProps {
source: ImageSourcePropType;
alt?: string;
}
interface AvatarFallbackProps {
children: React.ReactNode;
className?: string;
}
const AvatarContext = React.createContext<{
size: 'sm' | 'md' | 'lg' | 'xl';
hasImage: boolean;
setHasImage: (value: boolean) => void;
} | null>(null);
function useAvatar() {
const context = React.useContext(AvatarContext);
if (!context) {
throw new Error('Avatar components must be used within Avatar');
}
return context;
}
// Size mappings
const sizeClasses = {
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
};
const textSizeClasses = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
xl: 'text-xl',
};
const statusSizeClasses = {
sm: 'h-2 w-2',
md: 'h-2.5 w-2.5',
lg: 'h-3 w-3',
xl: 'h-4 w-4',
};
const statusColors = {
online: 'bg-green-500',
offline: 'bg-slate-400',
away: 'bg-yellow-500',
busy: 'bg-red-500',
};
export function Avatar({
source,
alt,
fallback,
size = 'md',
status,
className
}: AvatarProps) {
const [hasImage, setHasImage] = React.useState(!!source);
const [imageError, setImageError] = React.useState(false);
React.useEffect(() => {
setHasImage(!!source && !imageError);
}, [source, imageError]);
// Get initials from fallback text
const getInitials = (text?: string) => {
if (!text) return '?';
const words = text.trim().split(' ');
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return text.substring(0, 2).toUpperCase();
};
return (
{source && !imageError ? (
setImageError(true)}
resizeMode="cover"
/>
) : (
{getInitials(fallback || alt)}
)}
{/* Status Indicator */}
{status && (
)}
);
}
// Composable Avatar components
export function AvatarRoot({
size = 'md',
className,
children
}: {
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
children: React.ReactNode;
}) {
const [hasImage, setHasImage] = React.useState(false);
return (
{children}
);
}
export function AvatarImage({ source, alt }: AvatarImageProps) {
const { setHasImage } = useAvatar();
return (
setHasImage(true)}
onError={() => setHasImage(false)}
resizeMode="cover"
/>
);
}
export function AvatarFallback({ children, className }: AvatarFallbackProps) {
const { size, hasImage } = useAvatar();
if (hasImage) return null;
return (
{children}
);
}
// Avatar Group
export function AvatarGroup({ children, max = 3, size = 'md', className }: AvatarGroupProps) {
const avatars = React.Children.toArray(children);
const visibleAvatars = avatars.slice(0, max);
const remainingCount = avatars.length - max;
// Overlap offset based on size
const overlapOffset = {
sm: -8,
md: -10,
lg: -12,
xl: -16,
};
return (
{visibleAvatars.map((avatar, index) => (
0 ? overlapOffset[size] : 0,
zIndex: visibleAvatars.length - index,
}}
className="border-2 border-white rounded-full"
>
{React.isValidElement(avatar) && avatar.type === Avatar
? React.cloneElement(avatar as React.ReactElement, { size })
: avatar}
))}
{/* Remaining count badge */}
{remainingCount > 0 && (
+{remainingCount}
)}
);
}