diff --git a/src/components/layout/HeaderProfileMenu.test.tsx b/src/components/layout/HeaderProfileMenu.test.tsx index b1becce..6fd91e5 100644 --- a/src/components/layout/HeaderProfileMenu.test.tsx +++ b/src/components/layout/HeaderProfileMenu.test.tsx @@ -63,6 +63,12 @@ describe("HeaderProfileMenu", () => { expect(screen.getByRole("button", { name: "Bobo Example" })).toBeInTheDocument(); }); + it("renders an avatar icon in the trigger", () => { + // Regression: the trigger used to hold an empty placeholder box. + const { container } = renderMenu(); + expect(container.querySelector('[data-slot="avatar-fallback"] svg')).toBeInTheDocument(); + }); + it("navigates to settings from the dropdown", async () => { const user = userEvent.setup(); renderMenu(); diff --git a/src/components/layout/HeaderProfileMenu.tsx b/src/components/layout/HeaderProfileMenu.tsx index 1170caf..e3f6c7f 100644 --- a/src/components/layout/HeaderProfileMenu.tsx +++ b/src/components/layout/HeaderProfileMenu.tsx @@ -4,6 +4,7 @@ import { useAuth } from "../../auth/useAuth"; import { useTheme } from "../../hooks/useTheme"; import { useRouter } from "../../router"; import { Button } from "@/components/ui/button"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { DropdownMenu, DropdownMenuContent, @@ -26,14 +27,13 @@ export function HeaderProfileMenu() { return ( - {/* TODO: User photo/avatar data is not currently available, using fallback for now. */} diff --git a/src/components/ui/avatar.test.tsx b/src/components/ui/avatar.test.tsx new file mode 100644 index 0000000..ab3e05b --- /dev/null +++ b/src/components/ui/avatar.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Avatar, AvatarFallback } from "./avatar"; + +describe("Avatar", () => { + it("renders the root with its data-slot", () => { + const { container } = render(); + expect(container.querySelector('[data-slot="avatar"]')).toBeInTheDocument(); + }); + + it("merges a custom className over the defaults", () => { + const { container } = render(); + const root = container.querySelector('[data-slot="avatar"]')!; + // tailwind-merge must drop the conflicting defaults, not stack them. + expect(root).toHaveClass("size-6", "rounded-md"); + expect(root).not.toHaveClass("size-8", "rounded-full"); + }); + + it("clips overflowing children so images cannot escape the frame", () => { + const { container } = render(); + expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("overflow-hidden"); + }); + + it("renders fallback content when there is no image", () => { + render( + + AB + , + ); + expect(screen.getByText("AB")).toBeInTheDocument(); + }); + + it("gives the fallback theme-aware surface and foreground tokens", () => { + const { container } = render( + + AB + , + ); + const fallback = container.querySelector('[data-slot="avatar-fallback"]')!; + expect(fallback).toHaveClass("bg-muted", "text-muted-foreground"); + }); +}); diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..5614c41 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { Avatar as AvatarPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Avatar({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/user-avatar.test.tsx b/src/components/ui/user-avatar.test.tsx new file mode 100644 index 0000000..f4bfd03 --- /dev/null +++ b/src/components/ui/user-avatar.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { UserAvatar } from "./user-avatar"; + +describe("UserAvatar", () => { + it("falls back to the user icon when no image is available", () => { + const { container } = render(); + const icon = container.querySelector('[data-slot="avatar-fallback"] svg'); + expect(icon).toBeInTheDocument(); + }); + + it("hides the fallback icon from assistive tech", () => { + // The control wrapping the avatar carries the accessible name, so the icon + // must not announce a second one. + const { container } = render(); + const icon = container.querySelector('[data-slot="avatar-fallback"] svg')!; + expect(icon).toHaveAttribute("aria-hidden", "true"); + }); + + it("colors the icon from theme tokens rather than fixed values", () => { + const { container } = render(); + const fallback = container.querySelector('[data-slot="avatar-fallback"]')!; + expect(fallback).toHaveClass("bg-muted", "text-muted-foreground"); + // A hardcoded or dark:-prefixed color would defeat the token indirection. + expect(fallback.className).not.toMatch(/dark:/); + }); + + it("sizes to the 24px header slot by default", () => { + const { container } = render(); + expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("size-6", "rounded-md"); + }); + + it("accepts a className override", () => { + const { container } = render(); + const root = container.querySelector('[data-slot="avatar"]')!; + expect(root).toHaveClass("size-10"); + expect(root).not.toHaveClass("size-6"); + }); + + it("mounts the image slot only when a src is supplied", () => { + // jsdom never resolves the image load, so Radix keeps the fallback visible + // and withholds the . Asserting on the mounted-vs-absent Image child + // is not possible here; the meaningful check is that passing a src neither + // crashes nor removes the fallback, so there is never an empty frame. + const { container } = render(); + expect(container.querySelector('[data-slot="avatar-fallback"] svg')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/user-avatar.tsx b/src/components/ui/user-avatar.tsx new file mode 100644 index 0000000..72045a4 --- /dev/null +++ b/src/components/ui/user-avatar.tsx @@ -0,0 +1,36 @@ +import { UserRound } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; + +interface UserAvatarProps { + /** + * Profile image URL. The API exposes no avatar field today, so this is + * normally undefined and the icon fallback renders instead. + */ + src?: string; + /** + * Alt text for the image. Defaults to empty: the avatar is decorative when + * the control wrapping it already carries the user's name. + */ + alt?: string; + className?: string; +} + +/** + * A user's avatar, falling back to a neutral icon when no image is available. + * + * Colors come from the `muted` / `muted-foreground` tokens, which are redefined + * under `.dark` in index.css, so light and dark are handled without any + * theme-conditional logic here. + */ +export function UserAvatar({ src, alt = "", className }: UserAvatarProps) { + return ( + + {src ? : null} + + + + ); +}