Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/layout/HeaderProfileMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/HeaderProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,14 +27,13 @@ export function HeaderProfileMenu() {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
{/* TODO: User photo/avatar data is not currently available, using fallback for now. */}
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 rounded-lg px-1.5 hover:bg-muted"
aria-label={displayName}
>
<span className="block size-6 overflow-hidden rounded-md bg-muted" aria-hidden="true" />
<UserAvatar />
<ChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
Expand Down
42 changes: 42 additions & 0 deletions src/components/ui/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Avatar />);
expect(container.querySelector('[data-slot="avatar"]')).toBeInTheDocument();
});

it("merges a custom className over the defaults", () => {
const { container } = render(<Avatar className="size-6 rounded-md" />);
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(<Avatar />);
expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("overflow-hidden");
});

it("renders fallback content when there is no image", () => {
render(
<Avatar>
<AvatarFallback>AB</AvatarFallback>
</Avatar>,
);
expect(screen.getByText("AB")).toBeInTheDocument();
});

it("gives the fallback theme-aware surface and foreground tokens", () => {
const { container } = render(
<Avatar>
<AvatarFallback>AB</AvatarFallback>
</Avatar>,
);
const fallback = container.querySelector('[data-slot="avatar-fallback"]')!;
expect(fallback).toHaveClass("bg-muted", "text-muted-foreground");
});
});
42 changes: 42 additions & 0 deletions src/components/ui/avatar.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
);
}

function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full object-cover", className)}
{...props}
/>
);
}

function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground",
className,
)}
{...props}
/>
);
}

export { Avatar, AvatarImage, AvatarFallback };
48 changes: 48 additions & 0 deletions src/components/ui/user-avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UserAvatar />);
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(<UserAvatar />);
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(<UserAvatar />);
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(<UserAvatar />);
expect(container.querySelector('[data-slot="avatar"]')).toHaveClass("size-6", "rounded-md");
});

it("accepts a className override", () => {
const { container } = render(<UserAvatar className="size-10" />);
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 <img>. 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(<UserAvatar src="https://example.com/avatar.png" alt="Ada" />);
expect(container.querySelector('[data-slot="avatar-fallback"] svg')).toBeInTheDocument();
});
});
36 changes: 36 additions & 0 deletions src/components/ui/user-avatar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Avatar className={cn("size-6 rounded-md", className)}>
{src ? <AvatarImage src={src} alt={alt} /> : null}
<AvatarFallback className="rounded-md">
<UserRound className="size-3.5" aria-hidden="true" />
</AvatarFallback>
</Avatar>
);
}
20 changes: 7 additions & 13 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,13 @@ export default defineConfig({
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return;
if (
id.includes("/react/") ||
id.includes("/react-dom/") ||
id.includes("/scheduler/") ||
id.includes("/react-is/") ||
id.includes("/react-remove-scroll") ||
id.includes("/react-style-singleton") ||
id.includes("/use-callback-ref") ||
id.includes("/use-sidecar") ||
id.includes("react-intl") ||
id.includes("@formatjs") ||
id.includes("/sonner/")
)
// Anchored to the package root: a bare `/react-dom/` substring also
// matches @floating-ui/react-dom, which drags @floating-ui/dom in
// from `vendor` and makes vendor <-> vendor-react circular. Keep this
// chunk a leaf — react-adjacent packages (react-intl, @formatjs,
// sonner, react-remove-scroll) belong in `vendor`, since they import
// helpers that live there.
if (/node_modules\/(react|react-dom|scheduler|react-is)\//.test(id))
return "vendor-react";
if (id.includes("@radix-ui") || id.includes("radix-ui")) return "vendor-radix";
if (id.includes("lucide-react")) return "vendor-lucide";
Expand Down
Loading