Give Avatar initials, person-mark, and load-error fallbacks so missing photos still look like the design-system atom.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,39 +1,178 @@
|
||||
import { memo } from "react";
|
||||
"use client";
|
||||
|
||||
export type AvatarSizeValue = "small" | "medium" | "large" | "xlarge";
|
||||
/**
|
||||
* Figma: "Asset / Avatar" (18628:32840). Circular (or rounded-square) image
|
||||
* with initials or a person mark when `src` is missing or fails to load.
|
||||
*/
|
||||
|
||||
interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
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: sizeProp = "small", className = "", ...props }) => {
|
||||
const size = sizeProp;
|
||||
const sizeStyles: Record<string, string> = {
|
||||
small:
|
||||
// White 30% border: no DS token (opacity scale is black-alpha; inverse border tokens don't cover this).
|
||||
"w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)] border-[1.5px] border-[#FFFFFF4D] border-solid",
|
||||
medium: "w-[var(--spacing-scale-018)] h-[var(--spacing-scale-018)]",
|
||||
large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]",
|
||||
xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]",
|
||||
};
|
||||
({
|
||||
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;
|
||||
|
||||
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover box-border ${sizeStyles[size]} ${className}`;
|
||||
useLayoutEffect(() => {
|
||||
setFailed(false);
|
||||
const img = imgRef.current;
|
||||
setLoaded(Boolean(img?.complete && img.naturalWidth > 0));
|
||||
}, [trimmedSrc]);
|
||||
|
||||
return (
|
||||
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
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={baseStyles}
|
||||
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"
|
||||
{...props}
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export { default } from "./Avatar";
|
||||
export type { AvatarSizeValue } from "./Avatar";
|
||||
export type { AvatarProps } from "./Avatar";
|
||||
export type {
|
||||
AvatarShapeValue,
|
||||
AvatarSizeValue,
|
||||
AvatarVariantValue,
|
||||
} from "./Avatar";
|
||||
|
||||
@@ -202,7 +202,7 @@ Full folder map and PNG audit: **[guides/static-assets.md](./guides/static-asset
|
||||
- **`public/assets/`** — lowercase kebab-case folders: **`icons/`**, **`logos/`** (incl. **`logos/partners/`**), **`marketing/`**, **`case-study/`**, **`shapes/`**, **`vector/`**, **`template-mark/`**, **`share/`**. Use helpers in **`lib/assetUtils.ts`** (`ASSETS`, `partnerLogoPath`, `vectorMarkPath`, …).
|
||||
- **`public/assets/vector/<slug>.svg`** — Figma Asset / Vector marks (same kebab **`slug`** convention as **`public/assets/template-mark/`**). Use **`vectorMarkPath(slug)`**.
|
||||
- **`asset/Logo`** — Community Rule **`Logo`** component (folder PascalCase, like **`Avatar/`**); brand SVG at **`public/assets/logos/community-rule.svg`**.
|
||||
- **`asset/Avatar`** + **`asset/AvatarContainer`** — paired circular image stacks (e.g. top nav). Fuller DS Avatar behavior (**initials**, upload routing, …) tracked as **[CR-58](https://linear.app/community-rule/issue/CR-58)**.
|
||||
- **`asset/Avatar`** + **`asset/AvatarContainer`** — circular (or rounded-square) image with initials / person-mark fallback; `AvatarContainer` stacks them (e.g. top nav).
|
||||
- **`asset/Shapes/`** — decorative blobs for **`cards/Stat`** and About header inline art (Figma **Shapes**); static files under **`public/assets/shapes/`**.
|
||||
|
||||
*Update this when you add a new top-level `app/components/*` package or a new Figma canvas.*
|
||||
|
||||
@@ -245,3 +245,17 @@ export const PROPORTION_BAR_VARIANT_OPTIONS = [
|
||||
] as const;
|
||||
export type ProportionBarVariantValue =
|
||||
(typeof PROPORTION_BAR_VARIANT_OPTIONS)[number];
|
||||
|
||||
export const AVATAR_SIZE_OPTIONS = [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"xlarge",
|
||||
] as const;
|
||||
export type AvatarSizeValue = (typeof AVATAR_SIZE_OPTIONS)[number];
|
||||
|
||||
export const AVATAR_SHAPE_OPTIONS = ["circle", "rounded-square"] as const;
|
||||
export type AvatarShapeValue = (typeof AVATAR_SHAPE_OPTIONS)[number];
|
||||
|
||||
export const AVATAR_VARIANT_OPTIONS = ["filled", "outlined"] as const;
|
||||
export type AvatarVariantValue = (typeof AVATAR_VARIANT_OPTIONS)[number];
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import Avatar from "../../app/components/asset/Avatar";
|
||||
import {
|
||||
AVATAR_SHAPE_OPTIONS,
|
||||
AVATAR_SIZE_OPTIONS,
|
||||
AVATAR_VARIANT_OPTIONS,
|
||||
} from "../../lib/propNormalization";
|
||||
|
||||
export default {
|
||||
title: "Components/Asset/Avatar",
|
||||
@@ -8,7 +13,7 @@ export default {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Rounded profile image primitive; stacks with **`AvatarContainer`**. Fuller DS-driven Avatar behavior is **[CR-58](https://linear.app/community-rule/issue/CR-58)**.",
|
||||
"Rounded profile image with initials or a person mark when the image is missing or fails to load. Stack with **AvatarContainer**.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -21,11 +26,25 @@ export default {
|
||||
control: { type: "text" },
|
||||
description: "Alt text for accessibility",
|
||||
},
|
||||
initials: {
|
||||
control: { type: "text" },
|
||||
description: "Fallback initials when the image is missing or errors",
|
||||
},
|
||||
size: {
|
||||
control: { type: "select" },
|
||||
options: ["small", "medium", "large", "xlarge"],
|
||||
options: [...AVATAR_SIZE_OPTIONS],
|
||||
description: "The size of the avatar",
|
||||
},
|
||||
shape: {
|
||||
control: { type: "select" },
|
||||
options: [...AVATAR_SHAPE_OPTIONS],
|
||||
description: "Circle (default) or rounded square",
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: [...AVATAR_VARIANT_OPTIONS],
|
||||
description: "Fallback chrome: filled cream or outlined yellow",
|
||||
},
|
||||
className: {
|
||||
control: { type: "text" },
|
||||
description: "Additional CSS classes",
|
||||
@@ -66,6 +85,60 @@ export const Sizes = {
|
||||
},
|
||||
};
|
||||
|
||||
export const InitialsFallback = {
|
||||
args: {
|
||||
alt: "Ada Lovelace",
|
||||
initials: "Ada Lovelace",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const PersonMarkFallback = {
|
||||
args: {
|
||||
alt: "Community member",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const OutlinedFallback = {
|
||||
args: {
|
||||
alt: "Ada Lovelace",
|
||||
initials: "AL",
|
||||
size: "large",
|
||||
variant: "outlined",
|
||||
},
|
||||
};
|
||||
|
||||
export const RoundedSquare = {
|
||||
args: {
|
||||
alt: "Organization",
|
||||
initials: "CR",
|
||||
size: "large",
|
||||
shape: "rounded-square",
|
||||
},
|
||||
};
|
||||
|
||||
export const BrokenImage = {
|
||||
args: {
|
||||
src: "assets/marketing/missing-avatar.png",
|
||||
alt: "Ada Lovelace",
|
||||
initials: "Ada Lovelace",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const Clickable = {
|
||||
args: {
|
||||
alt: "Ada Lovelace",
|
||||
initials: "AL",
|
||||
size: "large",
|
||||
onClick: () => {},
|
||||
},
|
||||
argTypes: {
|
||||
onClick: { action: "clicked" },
|
||||
},
|
||||
};
|
||||
|
||||
export const DifferentAvatars = {
|
||||
args: {
|
||||
size: "large",
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
fireEvent,
|
||||
} from "../utils/test-utils";
|
||||
import Avatar from "../../app/components/asset/Avatar";
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
} from "../utils/componentTestSuite";
|
||||
|
||||
type AvatarProps = React.ComponentProps<typeof Avatar>;
|
||||
|
||||
const baseProps: AvatarProps = {
|
||||
src: "/assets/marketing/avatar-1.svg",
|
||||
alt: "User avatar",
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
const config: ComponentTestSuiteConfig<AvatarProps> = {
|
||||
component: Avatar,
|
||||
name: "Avatar",
|
||||
props: baseProps,
|
||||
primaryRole: "img",
|
||||
testCases: {
|
||||
renders: true,
|
||||
accessibility: true,
|
||||
keyboardNavigation: false,
|
||||
disabledState: false,
|
||||
errorState: false,
|
||||
},
|
||||
};
|
||||
|
||||
componentTestSuite<AvatarProps>(config);
|
||||
|
||||
describe("Avatar (behavioral tests)", () => {
|
||||
it("renders the image with alt text", () => {
|
||||
render(<Avatar {...baseProps} />);
|
||||
const img = screen.getByRole("img", { name: "User avatar" });
|
||||
expect(img).toHaveAttribute("src", baseProps.src);
|
||||
});
|
||||
|
||||
it("does not show initials while the image is loading", () => {
|
||||
render(
|
||||
<Avatar
|
||||
src="/assets/marketing/avatar-1.svg"
|
||||
alt="Ada Lovelace"
|
||||
initials="AL"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("AL")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies the small photo ring after the image loads", () => {
|
||||
const { container } = render(
|
||||
<Avatar
|
||||
src="/assets/marketing/avatar-1.svg"
|
||||
alt="User avatar"
|
||||
size="small"
|
||||
/>,
|
||||
);
|
||||
fireEvent.load(screen.getByRole("img", { name: "User avatar" }));
|
||||
expect(container.firstChild).toHaveClass("border-[#FFFFFF4D]");
|
||||
expect(container.firstChild).toHaveClass("box-border");
|
||||
});
|
||||
|
||||
it("shows initials when src is omitted", () => {
|
||||
render(<Avatar alt="Ada Lovelace" initials="Ada Lovelace" />);
|
||||
expect(screen.getByRole("img", { name: "Ada Lovelace" })).toHaveTextContent(
|
||||
"AL",
|
||||
);
|
||||
expect(document.querySelector("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a person mark when src and initials are omitted", () => {
|
||||
const { container } = render(<Avatar alt="Member" />);
|
||||
expect(screen.getByRole("img", { name: "Member" })).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(document.querySelector("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to initials when the image errors", () => {
|
||||
render(
|
||||
<Avatar
|
||||
src="/missing-avatar.png"
|
||||
alt="Ada Lovelace"
|
||||
initials="AL"
|
||||
size="large"
|
||||
/>,
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img", { name: "Ada Lovelace" }));
|
||||
expect(document.querySelector("img")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "Ada Lovelace" })).toHaveTextContent(
|
||||
"AL",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies rounded-square radius", () => {
|
||||
const { container } = render(
|
||||
<Avatar alt="Org" initials="CR" shape="rounded-square" />,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass(
|
||||
"rounded-[var(--measures-radius-200,8px)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies outlined fallback chrome", () => {
|
||||
render(
|
||||
<Avatar alt="Ada Lovelace" initials="AL" variant="outlined" />,
|
||||
);
|
||||
const mark = screen.getByText("AL");
|
||||
expect(mark).toHaveClass(
|
||||
"border-[var(--color-content-default-brand-primary)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a button with hover treatment when onClick is set", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Avatar alt="Ada Lovelace" initials="AL" onClick={onClick} />,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Ada Lovelace" });
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(button).toHaveClass("hover:opacity-90");
|
||||
});
|
||||
|
||||
it("hides decorative fallbacks from the accessibility tree", () => {
|
||||
render(<Avatar alt="" initials="AB" />);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user