Files
krow_talent_app/src/components/ds/Avatar.jsx
2026-08-07 23:53:15 +05:30

120 lines
3.7 KiB
JavaScript

import * as React from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const SIZES = {
xs: 'w-6 h-6 text-[9px]',
sm: 'w-7 h-7 text-[10px]',
default: 'w-9 h-9 text-[12px]',
lg: 'w-11 h-11 text-body-sm',
xl: 'w-16 h-16 text-title',
'2xl': 'w-20 h-20 text-title-lg',
};
const avatarVariants = cva(
'relative inline-grid place-items-center shrink-0 overflow-hidden font-semibold select-none',
{
variants: {
shape: { circle: 'rounded-full', rounded: 'rounded-xl' },
tone: {
brand: 'bg-krow-blue text-white',
navy: 'bg-krow-navy text-white',
soft: 'bg-krow-blue-tint text-krow-blue',
neutral: 'bg-surface-sunken text-ink-3',
},
},
defaultVariants: { shape: 'circle', tone: 'brand' },
}
);
/** First letters of the first two words — "Chef Antoine Dubois" → "CA". */
export function initialsFrom(name = '') {
const words = String(name).trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
return words.slice(0, 2).map((w) => w[0]).join('').toUpperCase();
}
/**
* Avatar — a photo when one exists, initials otherwise.
*
* The app shows people constantly and most have no photo, so initials are a
* first-class state rather than a fallback afterthought. A failed image load
* degrades to initials instead of a broken-image icon.
*/
export const Avatar = React.forwardRef(
({ name, src, size = 'default', shape, tone, status, className, ...props }, ref) => {
const [failed, setFailed] = React.useState(false);
const showImage = src && !failed;
// Reset when the subject changes, so a new src gets its own chance to load.
React.useEffect(() => setFailed(false), [src]);
return (
<span ref={ref} className={cn('relative inline-block shrink-0', className)} {...props}>
<span
className={cn(avatarVariants({ shape, tone }), SIZES[size] || SIZES.default)}
aria-hidden={showImage ? 'true' : undefined}
>
{showImage ? (
<img
src={src}
alt={name ? `${name}` : ''}
onError={() => setFailed(true)}
className="w-full h-full object-cover"
/>
) : (
<span aria-label={name || undefined}>{initialsFrom(name)}</span>
)}
</span>
{status && (
<span
className={cn(
'absolute bottom-0 right-0 block rounded-full ring-2 ring-white',
size === 'xs' || size === 'sm' ? 'w-2 h-2' : 'w-2.5 h-2.5',
status === 'online' && 'bg-success',
status === 'busy' && 'bg-warning',
status === 'offline' && 'bg-ink-4'
)}
aria-label={status}
/>
)}
</span>
);
}
);
Avatar.displayName = 'Avatar';
/**
* AvatarGroup — overlapping avatars with a "+N" remainder, for showing several
* people in a tight row (applicants on a position, endorsers on a skill).
*/
export function AvatarGroup({ people = [], max = 4, size = 'sm', className }) {
const visible = people.slice(0, max);
const overflow = people.length - visible.length;
return (
<div className={cn('flex items-center', className)}>
{visible.map((person, i) => (
<Avatar
key={person.id ?? person.name ?? i}
name={person.name}
src={person.src}
size={size}
className={cn('ring-2 ring-white', i > 0 && '-ml-2')}
/>
))}
{overflow > 0 && (
<span
className={cn(
'grid place-items-center rounded-full bg-surface-sunken text-ink-3 font-semibold ring-2 ring-white -ml-2',
SIZES[size] || SIZES.default
)}
>
+{overflow}
</span>
)}
</div>
);
}