Initial commit
This commit is contained in:
119
src/components/ds/Avatar.jsx
Normal file
119
src/components/ds/Avatar.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user