diff --git a/app/components/asset/Avatar/Avatar.tsx b/app/components/asset/Avatar/Avatar.tsx index 867b83d..32d12d2 100644 --- a/app/components/asset/Avatar/Avatar.tsx +++ b/app/components/asset/Avatar/Avatar.tsx @@ -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 { - 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 = { + 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 ( + + + + ); } const Avatar = memo( - ({ src, alt, size: sizeProp = "small", className = "", ...props }) => { - const size = sizeProp; - const sizeStyles: Record = { - 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(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]); + + 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 ? ( + + {mark ? mark : } + + ) : null} + {showSkeleton ? ( + + ) : null} + {showImage ? ( + /* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */ + {onClick setLoaded(true)} + onError={() => { + setFailed(true); + setLoaded(false); + }} + /> + ) : null} + + ); + + if (onClick) { + return ( + + ); + } return ( - /* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */ - {alt} + + {visual} + ); }, ); diff --git a/app/components/asset/Avatar/index.tsx b/app/components/asset/Avatar/index.tsx index a2e37c4..211d103 100644 --- a/app/components/asset/Avatar/index.tsx +++ b/app/components/asset/Avatar/index.tsx @@ -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"; diff --git a/docs/figma-component-registry.md b/docs/figma-component-registry.md index 9ccba00..267cbee 100644 --- a/docs/figma-component-registry.md +++ b/docs/figma-component-registry.md @@ -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/.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.* diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts index a1b84b6..a766756 100644 --- a/lib/propNormalization.ts +++ b/lib/propNormalization.ts @@ -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]; diff --git a/stories/asset/Avatar.stories.js b/stories/asset/Avatar.stories.js index e6ec794..2c5a09c 100644 --- a/stories/asset/Avatar.stories.js +++ b/stories/asset/Avatar.stories.js @@ -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", diff --git a/tests/components/Avatar.test.tsx b/tests/components/Avatar.test.tsx new file mode 100644 index 0000000..1a0c6cf --- /dev/null +++ b/tests/components/Avatar.test.tsx @@ -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; + +const baseProps: AvatarProps = { + src: "/assets/marketing/avatar-1.svg", + alt: "User avatar", + size: "medium", +}; + +const config: ComponentTestSuiteConfig = { + component: Avatar, + name: "Avatar", + props: baseProps, + primaryRole: "img", + testCases: { + renders: true, + accessibility: true, + keyboardNavigation: false, + disabledState: false, + errorState: false, + }, +}; + +componentTestSuite(config); + +describe("Avatar (behavioral tests)", () => { + it("renders the image with alt text", () => { + render(); + 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( + , + ); + expect(screen.queryByText("AL")).not.toBeInTheDocument(); + }); + + it("applies the small photo ring after the image loads", () => { + const { container } = render( + , + ); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + expect(container.firstChild).toHaveClass( + "rounded-[var(--measures-radius-200,8px)]", + ); + }); + + it("applies outlined fallback chrome", () => { + render( + , + ); + 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( + , + ); + 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(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); +});