Skip to content

Commit 19e9dd0

Browse files
a-effortgcgoncalves
authored andcommitted
feat(ui): show a user icon in the profile menu trigger (#46)
The header profile trigger rendered an empty rounded square, since no avatar data exists to put in it. Fill it with a lucide UserRound behind an Avatar primitive, so the frame that already reserved the space reads as a person rather than a gap. Colors come from the muted / muted-foreground tokens, which index.css redefines under .dark, so light and dark need no conditional logic here and no dark: variants. A test asserts the fallback carries no dark: prefix, to keep a future hardcoded override from creeping back in. UserAvatar owns the fallback ladder rather than HeaderProfileMenu inlining an icon, and takes an optional src. The API exposes no avatar field today, so that leg is unused — but Radix Avatar is what handles the image load/error swap, which is the part that gets ugly to retrofit once profile pictures land. The primitive comes from the radix-ui umbrella package, already a dependency, so this adds none. Sizing stays size-6 rounded-md with overflow-hidden: an image later fills the same box and the header geometry does not move. The trigger button already carries aria-label={displayName}, so the icon is decorative and adds no second accessible name and no new i18n strings. Signed-off-by: Anna Effort <anna.effort@ibm.com>
1 parent 2786fbc commit 19e9dd0

6 files changed

Lines changed: 176 additions & 2 deletions

File tree

src/components/layout/HeaderProfileMenu.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ describe("HeaderProfileMenu", () => {
6363
expect(screen.getByRole("button", { name: "Bobo Example" })).toBeInTheDocument();
6464
});
6565

66+
it("renders an avatar icon in the trigger", () => {
67+
// Regression: the trigger used to hold an empty placeholder box.
68+
const { container } = renderMenu();
69+
expect(container.querySelector('[data-slot="avatar-fallback"] svg')).toBeInTheDocument();
70+
});
71+
6672
it("navigates to settings from the dropdown", async () => {
6773
const user = userEvent.setup();
6874
renderMenu();

src/components/layout/HeaderProfileMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useAuth } from "../../auth/useAuth";
44
import { useTheme } from "../../hooks/useTheme";
55
import { useRouter } from "../../router";
66
import { Button } from "@/components/ui/button";
7+
import { UserAvatar } from "@/components/ui/user-avatar";
78
import {
89
DropdownMenu,
910
DropdownMenuContent,
@@ -26,14 +27,13 @@ export function HeaderProfileMenu() {
2627
return (
2728
<DropdownMenu modal={false}>
2829
<DropdownMenuTrigger asChild>
29-
{/* TODO: User photo/avatar data is not currently available, using fallback for now. */}
3030
<Button
3131
variant="ghost"
3232
size="sm"
3333
className="h-8 gap-1.5 rounded-lg px-1.5 hover:bg-muted"
3434
aria-label={displayName}
3535
>
36-
<span className="block size-6 overflow-hidden rounded-md bg-muted" aria-hidden="true" />
36+
<UserAvatar />
3737
<ChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
3838
</Button>
3939
</DropdownMenuTrigger>

src/components/ui/avatar.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import { Avatar, AvatarFallback } from "./avatar";
4+
5+
describe("Avatar", () => {
6+
it("renders the root with its data-slot", () => {
7+
const { container } = render(<Avatar />);
8+
expect(container.querySelector('[data-slot="avatar"]')).toBeInTheDocument();
9+
});
10+
11+
it("merges a custom className over the defaults", () => {
12+
const { container } = render(<Avatar className="size-6 rounded-md" />);
13+
const root = container.querySelector('[data-slot="avatar"]')!;
14+
// tailwind-merge must drop the conflicting defaults, not stack them.
15+
expect(root).toHaveClass("size-6", "rounded-md");
16+
expect(root).not.toHaveClass("size-8", "rounded-full");
17+
});
18+
19+
it("clips overflowing children so images cannot escape the frame", () => {
20+
const { container } = render(<Avatar />);
21+
expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("overflow-hidden");
22+
});
23+
24+
it("renders fallback content when there is no image", () => {
25+
render(
26+
<Avatar>
27+
<AvatarFallback>AB</AvatarFallback>
28+
</Avatar>,
29+
);
30+
expect(screen.getByText("AB")).toBeInTheDocument();
31+
});
32+
33+
it("gives the fallback theme-aware surface and foreground tokens", () => {
34+
const { container } = render(
35+
<Avatar>
36+
<AvatarFallback>AB</AvatarFallback>
37+
</Avatar>,
38+
);
39+
const fallback = container.querySelector('[data-slot="avatar-fallback"]')!;
40+
expect(fallback).toHaveClass("bg-muted", "text-muted-foreground");
41+
});
42+
});

src/components/ui/avatar.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as React from "react";
2+
import { Avatar as AvatarPrimitive } from "radix-ui";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
7+
return (
8+
<AvatarPrimitive.Root
9+
data-slot="avatar"
10+
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
11+
{...props}
12+
/>
13+
);
14+
}
15+
16+
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
17+
return (
18+
<AvatarPrimitive.Image
19+
data-slot="avatar-image"
20+
className={cn("aspect-square size-full object-cover", className)}
21+
{...props}
22+
/>
23+
);
24+
}
25+
26+
function AvatarFallback({
27+
className,
28+
...props
29+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
30+
return (
31+
<AvatarPrimitive.Fallback
32+
data-slot="avatar-fallback"
33+
className={cn(
34+
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground",
35+
className,
36+
)}
37+
{...props}
38+
/>
39+
);
40+
}
41+
42+
export { Avatar, AvatarImage, AvatarFallback };
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, it, expect } from "vitest";
2+
import { render } from "@testing-library/react";
3+
import { UserAvatar } from "./user-avatar";
4+
5+
describe("UserAvatar", () => {
6+
it("falls back to the user icon when no image is available", () => {
7+
const { container } = render(<UserAvatar />);
8+
const icon = container.querySelector('[data-slot="avatar-fallback"] svg');
9+
expect(icon).toBeInTheDocument();
10+
});
11+
12+
it("hides the fallback icon from assistive tech", () => {
13+
// The control wrapping the avatar carries the accessible name, so the icon
14+
// must not announce a second one.
15+
const { container } = render(<UserAvatar />);
16+
const icon = container.querySelector('[data-slot="avatar-fallback"] svg')!;
17+
expect(icon).toHaveAttribute("aria-hidden", "true");
18+
});
19+
20+
it("colors the icon from theme tokens rather than fixed values", () => {
21+
const { container } = render(<UserAvatar />);
22+
const fallback = container.querySelector('[data-slot="avatar-fallback"]')!;
23+
expect(fallback).toHaveClass("bg-muted", "text-muted-foreground");
24+
// A hardcoded or dark:-prefixed color would defeat the token indirection.
25+
expect(fallback.className).not.toMatch(/dark:/);
26+
});
27+
28+
it("sizes to the 24px header slot by default", () => {
29+
const { container } = render(<UserAvatar />);
30+
expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("size-6", "rounded-md");
31+
});
32+
33+
it("accepts a className override", () => {
34+
const { container } = render(<UserAvatar className="size-10" />);
35+
const root = container.querySelector('[data-slot="avatar"]')!;
36+
expect(root).toHaveClass("size-10");
37+
expect(root).not.toHaveClass("size-6");
38+
});
39+
40+
it("mounts the image slot only when a src is supplied", () => {
41+
// jsdom never resolves the image load, so Radix keeps the fallback visible
42+
// and withholds the <img>. Asserting on the mounted-vs-absent Image child
43+
// is not possible here; the meaningful check is that passing a src neither
44+
// crashes nor removes the fallback, so there is never an empty frame.
45+
const { container } = render(<UserAvatar src="https://example.com/avatar.png" alt="Ada" />);
46+
expect(container.querySelector('[data-slot="avatar-fallback"] svg')).toBeInTheDocument();
47+
});
48+
});

src/components/ui/user-avatar.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { UserRound } from "lucide-react";
2+
3+
import { cn } from "@/lib/utils";
4+
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
5+
6+
interface UserAvatarProps {
7+
/**
8+
* Profile image URL. The API exposes no avatar field today, so this is
9+
* normally undefined and the icon fallback renders instead.
10+
*/
11+
src?: string;
12+
/**
13+
* Alt text for the image. Defaults to empty: the avatar is decorative when
14+
* the control wrapping it already carries the user's name.
15+
*/
16+
alt?: string;
17+
className?: string;
18+
}
19+
20+
/**
21+
* A user's avatar, falling back to a neutral icon when no image is available.
22+
*
23+
* Colors come from the `muted` / `muted-foreground` tokens, which are redefined
24+
* under `.dark` in index.css, so light and dark are handled without any
25+
* theme-conditional logic here.
26+
*/
27+
export function UserAvatar({ src, alt = "", className }: UserAvatarProps) {
28+
return (
29+
<Avatar className={cn("size-6 rounded-md", className)}>
30+
{src ? <AvatarImage src={src} alt={alt} /> : null}
31+
<AvatarFallback className="rounded-md">
32+
<UserRound className="size-3.5" aria-hidden="true" />
33+
</AvatarFallback>
34+
</Avatar>
35+
);
36+
}

0 commit comments

Comments
 (0)