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
77 changes: 77 additions & 0 deletions src/components/layout/HeaderProfileMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,83 @@ describe("HeaderProfileMenu", () => {
expect(localStorage.getItem("theme-preference")).toBe("system");
});

it("shows the active language on the trigger", async () => {
const user = userEvent.setup();
localStorage.setItem("user-locale", "pt-BR");
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));

expect(screen.getByText("Idioma")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Idioma" })).toHaveTextContent("Português");
});

it("updates the saved locale preference", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByRole("combobox", { name: "Language" }));
await user.click(await screen.findByRole("option", { name: "Español" }));

expect(localStorage.getItem("user-locale")).toBe("es-ES");
expect(document.documentElement.lang).toBe("es-ES");
});

it("keeps the profile menu open while the language list is used", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByRole("combobox", { name: "Language" }));
await user.click(await screen.findByRole("option", { name: "Español" }));

expect(screen.getByText("bobo@cf.com")).toBeInTheDocument();
});

it("switches locale again after the panel re-renders in the new language", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));
await user.click(screen.getByRole("combobox", { name: "Language" }));
await user.click(await screen.findByRole("option", { name: "Español" }));

await user.click(screen.getByRole("combobox", { name: "Idioma" }));
await user.click(await screen.findByRole("option", { name: "Português" }));

expect(localStorage.getItem("user-locale")).toBe("pt-BR");
expect(document.documentElement.lang).toBe("pt-BR");
expect(screen.getByText("bobo@cf.com")).toBeInTheDocument();
});

it("reaches every control by keyboard", async () => {
const user = userEvent.setup();
renderMenu();

await user.click(screen.getByRole("button", { name: "Bobo Example" }));

const focused = () => {
const active = document.activeElement;
return active?.getAttribute("aria-label") ?? active?.textContent ?? "";
};

const reachable = [focused()];
for (let i = 0; i < 5; i++) {
await user.tab();
reachable.push(focused());
}

expect(reachable).toEqual([
"Light mode",
"Dark mode",
"System theme",
"Language",
"Settings",
"Sign Out",
]);
});

it("does not scroll-lock the body while the menu is open", async () => {
// Regression: a modal dropdown wraps its content in react-remove-scroll,
// which locks the body (overflow:hidden + compensating padding) on open and
Expand Down
84 changes: 60 additions & 24 deletions src/components/layout/HeaderProfileMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
import { ChevronDown, LogOut, Monitor, Moon, Settings2, Sun } from "lucide-react";
import { useState } from "react";
import { useIntl } from "react-intl";
import { useAuth } from "../../auth/useAuth";
import { useTheme } from "../../hooks/useTheme";
import { LOCALE_LABELS, SUPPORTED_LOCALES, useI18n } from "../../i18n";
import type { SupportedLocale } from "../../i18n";
import { useRouter } from "../../router";
import { Button } from "@/components/ui/button";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { UserAvatar } from "@/components/ui/user-avatar";

export function HeaderProfileMenu() {
const intl = useIntl();
const { user, logout } = useAuth();
const { navigate } = useRouter();
const { theme, setTheme } = useTheme();
const { locale, setLocale } = useI18n();
const [open, setOpen] = useState(false);

if (!user) return null;

const displayName = user.full_name || user.email || "Profile";

return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
Expand All @@ -36,12 +42,10 @@ export function HeaderProfileMenu() {
<UserAvatar />
<ChevronDown className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 rounded-xl p-2">
<DropdownMenuLabel className="px-3 py-2 text-sm font-normal text-muted-foreground">
{user.email}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</PopoverTrigger>
<PopoverContent align="end" aria-label={displayName} className="rounded-xl p-2">
<p className="px-3 py-2 text-sm text-muted-foreground">{user.email}</p>
<Separator className="-mx-1 my-1 w-auto" />
<div className="flex items-center justify-between gap-3 px-3 py-2">
<span className="text-sm">{intl.formatMessage({ id: "common.theme" })}</span>
<div className="flex items-center gap-1 rounded-full bg-muted p-1">
Expand Down Expand Up @@ -77,18 +81,50 @@ export function HeaderProfileMenu() {
</Button>
</div>
</div>
<DropdownMenuItem
onClick={() => navigate("/app/settings")}
className="gap-2 rounded-lg px-3 py-2"
<div className="flex items-center justify-between gap-3 px-3 py-2">
<span className="text-sm">{intl.formatMessage({ id: "common.language" })}</span>
<Select value={locale} onValueChange={(value) => setLocale(value as SupportedLocale)}>
Comment thread
a-effort marked this conversation as resolved.
<SelectTrigger
size="sm"
aria-label={intl.formatMessage({ id: "common.language" })}
className="h-auto gap-1.5 border-0 bg-transparent px-2 py-1 text-xs font-medium text-secondary-foreground shadow-none"
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="end" sideOffset={4}>
{SUPPORTED_LOCALES.map((supported) => (
<SelectItem key={supported} value={supported}>
{LOCALE_LABELS[supported]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 rounded-lg px-3 py-2 font-normal"
onClick={() => {
setOpen(false);
navigate("/app/settings");
}}
>
<Settings2 className="size-4" aria-hidden="true" />
{intl.formatMessage({ id: "navigation.settings" })}
</DropdownMenuItem>
<DropdownMenuItem onClick={logout} className="gap-2 rounded-lg px-3 py-2">
</Button>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 rounded-lg px-3 py-2 font-normal"
onClick={() => {
setOpen(false);
logout();
}}
>
<LogOut className="size-4" aria-hidden="true" />
{intl.formatMessage({ id: "auth.logout" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Button>
</PopoverContent>
</Popover>
);
}
Loading
Loading