120 lines
3.7 KiB
JavaScript
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>
|
|
);
|
|
}
|