- button.tsx: Remove gaming/terminal/nature/glass variants, add link variant, add focus-visible glow, remove scale transforms and neon shadows - card.tsx: Remove glass/glow/glowMagenta variants, update radius to rounded-lg, remove hover translate transforms - modal.tsx: Update backdrop to bg-black/60 + blur(4px), bg-popover, SUMI easing curves and durations - badge.tsx: Terminal variant aliased to success, doc updates - avatar.tsx: Already migrated, doc updates - progress.tsx: Fix gradient colors to SUMI semantics - input.tsx: bg-background, border-border, SUMI focus glow - textarea.tsx: Add SUMI focus glow Co-authored-by: Cursor <cursoragent@cursor.com>
305 lines
7.2 KiB
TypeScript
305 lines
7.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* Badge configuration for the Avatar component
|
|
*/
|
|
interface AvatarBadge {
|
|
/**
|
|
* Number to display in the badge (hidden if dot is true)
|
|
*/
|
|
count?: number;
|
|
|
|
/**
|
|
* Color variant of the badge
|
|
* @default 'primary'
|
|
*/
|
|
color?: 'primary' | 'destructive' | 'success';
|
|
|
|
/**
|
|
* Show as a small dot instead of a numbered badge
|
|
* @default false
|
|
*/
|
|
dot?: boolean;
|
|
}
|
|
|
|
/**
|
|
* AvatarProps - Propriétés du composant Avatar
|
|
*
|
|
* @interface AvatarProps
|
|
*/
|
|
interface AvatarProps {
|
|
/**
|
|
* URL de l'image de l'avatar
|
|
*/
|
|
src?: string;
|
|
|
|
/**
|
|
* Texte alternatif de l'avatar
|
|
*
|
|
* @default 'Avatar'
|
|
*/
|
|
alt?: string;
|
|
|
|
/**
|
|
* Texte de fallback utilisé pour générer les initiales si pas d'image
|
|
*/
|
|
fallback?: string;
|
|
|
|
/**
|
|
* Taille de l'avatar
|
|
*
|
|
* - `xs`: 6x6 (24px)
|
|
* - `sm`: 8x8 (32px)
|
|
* - `md`: 10x10 (40px) - par défaut
|
|
* - `lg`: 12x12 (48px)
|
|
* - `xl`: 16x16 (64px)
|
|
* - `2xl`: 24x24 (96px)
|
|
* - `3xl`: 32x32 (128px)
|
|
*
|
|
* @default 'md'
|
|
*/
|
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
|
|
|
|
/**
|
|
* Statut de présence à afficher (indicateur coloré)
|
|
*
|
|
* - `online`: Vert (lime)
|
|
* - `offline`: Gris
|
|
* - `away`: Or
|
|
* - `idle`: Or
|
|
* - `busy`: Rouge
|
|
* - `dnd`: Rouge (do not disturb)
|
|
*/
|
|
status?: 'online' | 'offline' | 'away' | 'busy' | 'idle' | 'dnd';
|
|
|
|
/**
|
|
* Badge overlay configuration
|
|
*/
|
|
badge?: AvatarBadge;
|
|
|
|
/**
|
|
* Classes CSS personnalisées
|
|
*/
|
|
className?: string;
|
|
|
|
/**
|
|
* Fonction appelée lors du clic sur l'avatar
|
|
*/
|
|
onClick?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Avatar - Composant d'avatar avec design system SUMI
|
|
*
|
|
* Composant d'avatar pour afficher des images de profil ou des initiales.
|
|
* Supporte les statuts de présence et plusieurs tailles.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Avatar avec image
|
|
* <Avatar src="/avatar.jpg" alt="John Doe" />
|
|
*
|
|
* // Avatar avec initiales
|
|
* <Avatar fallback="John Doe" size="lg" />
|
|
*
|
|
* // Avatar avec statut
|
|
* <Avatar
|
|
* src="/avatar.jpg"
|
|
* alt="Jane Doe"
|
|
* status="online"
|
|
* />
|
|
*
|
|
* // Avatar avec badge
|
|
* <Avatar
|
|
* src="/avatar.jpg"
|
|
* alt="Jane Doe"
|
|
* badge={{ count: 3, color: 'destructive' }}
|
|
* />
|
|
* ```
|
|
*
|
|
* @component
|
|
* @param {AvatarProps} props - Propriétés du composant
|
|
* @returns {JSX.Element} Avatar circulaire avec image ou initiales
|
|
*/
|
|
|
|
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
|
(
|
|
{
|
|
src,
|
|
alt = 'Avatar',
|
|
fallback,
|
|
size = 'md',
|
|
status,
|
|
badge,
|
|
className = '',
|
|
onClick,
|
|
},
|
|
ref,
|
|
) => {
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const [hasError, setHasError] = useState(false);
|
|
|
|
const sizeClasses = {
|
|
xs: 'w-6 h-6 text-[10px]',
|
|
sm: 'w-8 h-8 text-xs',
|
|
md: 'w-10 h-10 text-sm',
|
|
lg: 'w-12 h-12 text-base',
|
|
xl: 'w-16 h-16 text-lg',
|
|
'2xl': 'w-24 h-24 text-xl',
|
|
'3xl': 'w-32 h-32 text-2xl',
|
|
};
|
|
|
|
const statusColors = {
|
|
online: 'bg-success',
|
|
offline: 'bg-muted',
|
|
away: 'bg-warning',
|
|
idle: 'bg-warning',
|
|
busy: 'bg-destructive',
|
|
dnd: 'bg-destructive',
|
|
};
|
|
|
|
const statusSize = {
|
|
xs: 'w-1.5 h-1.5 border',
|
|
sm: 'w-2 h-2 border',
|
|
md: 'w-2.5 h-2.5 border-2',
|
|
lg: 'w-3 h-3 border-2',
|
|
xl: 'w-4 h-4 border-2',
|
|
'2xl': 'w-5 h-5 border-4',
|
|
'3xl': 'w-6 h-6 border-4',
|
|
};
|
|
|
|
const badgeColors = {
|
|
primary: 'bg-primary text-primary-foreground',
|
|
destructive: 'bg-destructive text-destructive-foreground',
|
|
success: 'bg-success text-success-foreground',
|
|
};
|
|
|
|
const badgeSizeClasses = {
|
|
xs: 'w-2.5 h-2.5 text-[6px]',
|
|
sm: 'w-3 h-3 text-[7px]',
|
|
md: 'w-4 h-4 text-[8px]',
|
|
lg: 'w-4.5 h-4.5 text-[9px]',
|
|
xl: 'w-5 h-5 text-[10px]',
|
|
'2xl': 'w-6 h-6 text-xs',
|
|
'3xl': 'w-8 h-8 text-sm',
|
|
};
|
|
|
|
const badgeDotSizeClasses = {
|
|
xs: 'w-1.5 h-1.5',
|
|
sm: 'w-2 h-2',
|
|
md: 'w-2.5 h-2.5',
|
|
lg: 'w-3 h-3',
|
|
xl: 'w-3.5 h-3.5',
|
|
'2xl': 'w-4 h-4',
|
|
'3xl': 'w-5 h-5',
|
|
};
|
|
|
|
const getInitials = (name?: string): string => {
|
|
if (!name) return '?';
|
|
const parts = name.trim().split(' ');
|
|
if (parts.length >= 2) {
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
return name.substring(0, 2).toUpperCase();
|
|
};
|
|
|
|
const initials = getInitials(fallback || alt);
|
|
|
|
const showFallback = !src || hasError;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
'relative inline-block active:scale-95 transition-transform',
|
|
className,
|
|
onClick ? 'cursor-pointer' : '',
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<div
|
|
className={cn(
|
|
sizeClasses[size],
|
|
'rounded-full overflow-hidden bg-card border border-border flex items-center justify-center relative',
|
|
)}
|
|
>
|
|
{showFallback ? (
|
|
<span className="font-bold text-muted-foreground">{initials}</span>
|
|
) : (
|
|
<>
|
|
{!isLoaded && (
|
|
<div
|
|
className="absolute inset-0 rounded-full bg-muted animate-pulse"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
<img
|
|
src={src}
|
|
alt={alt}
|
|
onLoad={() => setIsLoaded(true)}
|
|
onError={() => {
|
|
setHasError(true);
|
|
setIsLoaded(true);
|
|
}}
|
|
className={cn(
|
|
'w-full h-full object-cover transition-opacity duration-200',
|
|
isLoaded ? 'opacity-100' : 'opacity-0',
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{status && (
|
|
<span
|
|
className={cn(
|
|
'absolute bottom-0 right-0 rounded-full border-background',
|
|
statusColors[status],
|
|
statusSize[size],
|
|
)}
|
|
></span>
|
|
)}
|
|
|
|
{badge && (
|
|
<span
|
|
className={cn(
|
|
'absolute -top-0.5 -right-0.5 rounded-full border-2 border-background flex items-center justify-center font-bold leading-none',
|
|
badgeColors[badge.color ?? 'primary'],
|
|
badge.dot
|
|
? badgeDotSizeClasses[size]
|
|
: badgeSizeClasses[size],
|
|
)}
|
|
>
|
|
{!badge.dot && badge.count != null ? badge.count : null}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
Avatar.displayName = 'Avatar';
|
|
|
|
// Compatibility exports (for existing code using AvatarImage, AvatarFallback)
|
|
export const AvatarImage: React.FC<{
|
|
src?: string;
|
|
alt?: string;
|
|
className?: string;
|
|
}> = ({ src, alt, className }) => {
|
|
if (!src) return null;
|
|
return <img src={src} alt={alt} className={className} />;
|
|
};
|
|
|
|
export const AvatarFallback: React.FC<{
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}> = ({ children, className }) => {
|
|
return <span className={className}>{children}</span>;
|
|
};
|
|
|
|
export const AvatarRoot: React.FC<{
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}> = ({ children, className }) => {
|
|
return <div className={className}>{children}</div>;
|
|
};
|