183 lines
5.6 KiB
TypeScript
183 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Figma: "Asset / Avatar" (18628:32840). Circular (or rounded-square) image
|
||
* with initials or a person mark when `src` is missing or fails to load.
|
||
*/
|
||
|
||
import { memo, useLayoutEffect, useRef, useState } from "react";
|
||
import type {
|
||
AvatarShapeValue,
|
||
AvatarSizeValue,
|
||
AvatarVariantValue,
|
||
} from "../../../../lib/propNormalization";
|
||
|
||
export type { AvatarShapeValue, AvatarSizeValue, AvatarVariantValue };
|
||
|
||
export interface AvatarProps {
|
||
src?: string;
|
||
alt: string;
|
||
size?: AvatarSizeValue;
|
||
/** 1–2 characters, or a name (first letters of the first two words). */
|
||
initials?: string;
|
||
shape?: AvatarShapeValue;
|
||
/** Fallback chrome: cream fill (default) or yellow outline. */
|
||
variant?: AvatarVariantValue;
|
||
className?: string;
|
||
onClick?: () => void;
|
||
}
|
||
|
||
const SIZE_CLASS: Record<AvatarSizeValue, string> = {
|
||
small:
|
||
"h-[var(--spacing-scale-016)] w-[var(--spacing-scale-016)] text-xx-small-label",
|
||
medium:
|
||
"h-[var(--spacing-scale-018)] w-[var(--spacing-scale-018)] text-xx-small-label",
|
||
large:
|
||
"h-[var(--spacing-scale-024)] w-[var(--spacing-scale-024)] text-xx-small-label",
|
||
xlarge:
|
||
"h-[var(--spacing-scale-032)] w-[var(--spacing-scale-032)] text-x-small-label",
|
||
};
|
||
|
||
function formatAvatarInitials(value: string): string {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return "";
|
||
const words = trimmed.split(/\s+/);
|
||
if (words.length >= 2) {
|
||
return `${words[0]?.[0] ?? ""}${words[1]?.[0] ?? ""}`.toUpperCase();
|
||
}
|
||
return trimmed.slice(0, 2).toUpperCase();
|
||
}
|
||
|
||
function AvatarPersonIcon() {
|
||
return (
|
||
<svg
|
||
viewBox="0 0 24 24"
|
||
className="h-1/2 w-1/2"
|
||
aria-hidden
|
||
focusable="false"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8V21h19.2v-1.8c0-3.2-6.4-4.8-9.6-4.8z"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
const Avatar = memo<AvatarProps>(
|
||
({
|
||
src,
|
||
alt,
|
||
size = "small",
|
||
initials,
|
||
shape = "circle",
|
||
variant = "filled",
|
||
className = "",
|
||
onClick,
|
||
}) => {
|
||
const imgRef = useRef<HTMLImageElement>(null);
|
||
const [failed, setFailed] = useState(false);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const trimmedSrc = src?.trim() ?? "";
|
||
const hasSrc = trimmedSrc.length > 0;
|
||
const showImage = hasSrc && !failed;
|
||
const mark = initials ? formatAvatarInitials(initials) : "";
|
||
const decorative = alt.trim().length === 0;
|
||
|
||
useLayoutEffect(() => {
|
||
setFailed(false);
|
||
const img = imgRef.current;
|
||
setLoaded(Boolean(img?.complete && img.naturalWidth > 0));
|
||
}, [trimmedSrc]);
|
||
|
||
const radiusClass =
|
||
shape === "rounded-square"
|
||
? "rounded-[var(--measures-radius-200,8px)]"
|
||
: "rounded-[var(--radius-measures-radius-full)]";
|
||
const fallbackChrome =
|
||
variant === "outlined"
|
||
? "border-[1.5px] border-solid border-[var(--color-content-default-brand-primary)] bg-transparent text-[var(--color-content-default-brand-primary)]"
|
||
: "bg-[var(--color-surface-invert-brand-primary)] text-[var(--color-content-default-primary)]";
|
||
// White 30% ring on small photos: no DS token (opacity scale is black-alpha).
|
||
const smallImageBorder =
|
||
size === "small" && showImage && loaded
|
||
? "border-[1.5px] border-solid border-[#FFFFFF4D]"
|
||
: "";
|
||
const interactiveClass = onClick
|
||
? `cursor-pointer appearance-none bg-transparent p-0 ${smallImageBorder ? "" : "border-0"} transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]`
|
||
: "";
|
||
const shellClass =
|
||
`relative inline-flex box-border shrink-0 items-center justify-center overflow-hidden ${SIZE_CLASS[size]} ${radiusClass} ${smallImageBorder} ${interactiveClass} ${className}`.trim();
|
||
|
||
const showFallback = !showImage;
|
||
const showSkeleton = showImage && !loaded;
|
||
|
||
const visual = (
|
||
<>
|
||
{showFallback ? (
|
||
<span
|
||
className={`absolute inset-0 flex items-center justify-center font-medium ${fallbackChrome} ${radiusClass}`}
|
||
>
|
||
{mark ? mark : <AvatarPersonIcon />}
|
||
</span>
|
||
) : null}
|
||
{showSkeleton ? (
|
||
<span
|
||
className={`absolute inset-0 animate-pulse bg-[var(--color-surface-default-tertiary)] ${radiusClass}`}
|
||
aria-hidden
|
||
/>
|
||
) : null}
|
||
{showImage ? (
|
||
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
|
||
<img
|
||
ref={imgRef}
|
||
src={trimmedSrc}
|
||
alt={onClick ? "" : alt}
|
||
className={`relative z-[1] h-full w-full object-cover ${radiusClass} ${loaded ? "opacity-100" : "opacity-0"}`}
|
||
loading="eager"
|
||
decoding="async"
|
||
fetchPriority="high"
|
||
onLoad={() => setLoaded(true)}
|
||
onError={() => {
|
||
setFailed(true);
|
||
setLoaded(false);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
|
||
if (onClick) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
data-figma-node="18628-32840"
|
||
className={shellClass}
|
||
onClick={onClick}
|
||
aria-label={decorative ? undefined : alt}
|
||
>
|
||
{visual}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<span
|
||
data-figma-node="18628-32840"
|
||
className={shellClass}
|
||
{...(showFallback
|
||
? decorative
|
||
? { "aria-hidden": true as const }
|
||
: { role: "img" as const, "aria-label": alt }
|
||
: {})}
|
||
>
|
||
{visual}
|
||
</span>
|
||
);
|
||
},
|
||
);
|
||
|
||
Avatar.displayName = "Avatar";
|
||
|
||
export default Avatar;
|