Files
community-rule/app/components/asset/Avatar/Avatar.tsx
T

183 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
/** 12 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;