diff --git a/src/components/layout/HeaderProfileMenu.test.tsx b/src/components/layout/HeaderProfileMenu.test.tsx index 6fd91e57..837ab508 100644 --- a/src/components/layout/HeaderProfileMenu.test.tsx +++ b/src/components/layout/HeaderProfileMenu.test.tsx @@ -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 diff --git a/src/components/layout/HeaderProfileMenu.tsx b/src/components/layout/HeaderProfileMenu.tsx index e3f6c7f5..b266fb43 100644 --- a/src/components/layout/HeaderProfileMenu.tsx +++ b/src/components/layout/HeaderProfileMenu.tsx @@ -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 ( - - + + - - - - {user.email} - - + + +

{user.email}

+
{intl.formatMessage({ id: "common.theme" })}
@@ -77,18 +81,50 @@ export function HeaderProfileMenu() {
- navigate("/app/settings")} - className="gap-2 rounded-lg px-3 py-2" +
+ {intl.formatMessage({ id: "common.language" })} + +
+ + +
+ ); } diff --git a/src/components/ui/LanguageSwitcher.test.tsx b/src/components/ui/LanguageSwitcher.test.tsx deleted file mode 100644 index 2c1dbce9..00000000 --- a/src/components/ui/LanguageSwitcher.test.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { I18nProvider } from "../../i18n"; -import { LanguageSwitcher } from "./LanguageSwitcher"; - -describe("LanguageSwitcher", () => { - beforeEach(() => { - // Clear localStorage before each test to ensure clean state - localStorage.clear(); - // Reset document lang attribute - document.documentElement.lang = "en-US"; - // Clear any mocks - vi.clearAllMocks(); - }); - - describe("Rendering", () => { - it("should render the select element", () => { - render( - - - , - ); - - const select = screen.getByRole("combobox"); - expect(select).toBeInTheDocument(); - }); - - it("should have correct accessibility label", () => { - render( - - - , - ); - - const select = screen.getByRole("combobox"); - expect(select).toHaveAttribute("aria-label", "Select language"); - }); - - it("should apply correct CSS classes for styling", () => { - render( - - - , - ); - - const select = screen.getByRole("combobox"); - expect(select).toHaveClass("rounded-md", "border", "px-3", "py-1.5", "text-sm"); - }); - }); - - describe("Locale Options", () => { - it("should render all supported locales as options", () => { - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - const options = Array.from(select.options); - - expect(options).toHaveLength(3); - }); - - it("should render English (US) option with flag", () => { - render( - - - , - ); - - const option = screen.getByRole("option", { name: /🇺🇸 English \(US\)/ }); - expect(option).toBeInTheDocument(); - expect(option).toHaveValue("en-US"); - }); - - it("should render Portuguese (BR) option with flag", () => { - render( - - - , - ); - - const option = screen.getByRole("option", { name: /🇧🇷 Português \(BR\)/ }); - expect(option).toBeInTheDocument(); - expect(option).toHaveValue("pt-BR"); - }); - - it("should render Spanish (ES) option with flag", () => { - render( - - - , - ); - - const option = screen.getByRole("option", { name: /🇪🇸 Español \(ES\)/ }); - expect(option).toBeInTheDocument(); - expect(option).toHaveValue("es-ES"); - }); - }); - - describe("Initial Value", () => { - it("should default to en-US when no preference is stored", () => { - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - expect(select.value).toBe("en-US"); - }); - - it("should use stored locale preference from localStorage", () => { - localStorage.setItem("user-locale", "pt-BR"); - - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - expect(select.value).toBe("pt-BR"); - }); - - it("should use stored Spanish preference from localStorage", () => { - localStorage.setItem("user-locale", "es-ES"); - - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - expect(select.value).toBe("es-ES"); - }); - }); - - describe("Locale Switching", () => { - it("should update locale when English option is selected", async () => { - const user = userEvent.setup(); - - localStorage.setItem("user-locale", "pt-BR"); - - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - expect(select.value).toBe("pt-BR"); - - await user.selectOptions(select, "en-US"); - - expect(select.value).toBe("en-US"); - expect(localStorage.getItem("user-locale")).toBe("en-US"); - }); - - it("should update locale when Portuguese option is selected", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - await user.selectOptions(select, "pt-BR"); - - expect(select.value).toBe("pt-BR"); - expect(localStorage.getItem("user-locale")).toBe("pt-BR"); - }); - - it("should update locale when Spanish option is selected", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - await user.selectOptions(select, "es-ES"); - - expect(select.value).toBe("es-ES"); - expect(localStorage.getItem("user-locale")).toBe("es-ES"); - }); - - it("should persist locale selection to localStorage", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox"); - await user.selectOptions(select, "pt-BR"); - - const stored = localStorage.getItem("user-locale"); - expect(stored).toBe("pt-BR"); - }); - }); - - describe("Multiple Switches", () => { - it("should handle sequential locale changes", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - - // Change to Portuguese - await user.selectOptions(select, "pt-BR"); - expect(select.value).toBe("pt-BR"); - expect(localStorage.getItem("user-locale")).toBe("pt-BR"); - - // Change to Spanish - await user.selectOptions(select, "es-ES"); - expect(select.value).toBe("es-ES"); - expect(localStorage.getItem("user-locale")).toBe("es-ES"); - - // Change back to English - await user.selectOptions(select, "en-US"); - expect(select.value).toBe("en-US"); - expect(localStorage.getItem("user-locale")).toBe("en-US"); - }); - }); - - describe("HTML Lang Attribute", () => { - it("should update document.documentElement.lang when locale changes", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox"); - await user.selectOptions(select, "pt-BR"); - - expect(document.documentElement.lang).toBe("pt-BR"); - }); - }); - - describe("Option Accessibility", () => { - it("should have proper option values", () => { - render( - - - , - ); - - const select = screen.getByRole("combobox") as HTMLSelectElement; - const optionValues = Array.from(select.options).map((opt) => opt.value); - - expect(optionValues).toContain("en-US"); - expect(optionValues).toContain("pt-BR"); - expect(optionValues).toContain("es-ES"); - }); - - it("should have unique option keys (no warnings in React)", () => { - const { container } = render( - - - , - ); - - const options = container.querySelectorAll("option"); - const values = Array.from(options).map((opt) => opt.value); - - // All values should be unique (keys used in map should be unique) - expect(new Set(values).size).toBe(values.length); - }); - }); - - describe("Interaction Validation", () => { - it("should be keyboard accessible", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox"); - - // Tab to the select - await user.tab(); - expect(select).toHaveFocus(); - - // Use keyboard to change selection - await user.selectOptions(select, "pt-BR"); - - expect(select).toHaveValue("pt-BR"); - }); - - it("should be clickable", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox"); - - await user.click(select); - await user.selectOptions(select, "es-ES"); - - expect(select).toHaveValue("es-ES"); - }); - }); - - describe("Edge Cases", () => { - it("should handle selecting the same locale twice", async () => { - const user = userEvent.setup(); - - render( - - - , - ); - - const select = screen.getByRole("combobox"); - - await user.selectOptions(select, "pt-BR"); - expect(select).toHaveValue("pt-BR"); - - // Select the same option again - await user.selectOptions(select, "pt-BR"); - expect(select).toHaveValue("pt-BR"); - expect(localStorage.getItem("user-locale")).toBe("pt-BR"); - }); - - it("should maintain selected value after re-render", async () => { - const user = userEvent.setup(); - - const { rerender } = render( - - - , - ); - - const select = screen.getByRole("combobox"); - - await user.selectOptions(select, "pt-BR"); - expect(select).toHaveValue("pt-BR"); - - // Force a re-render - rerender( - - - , - ); - - const updatedSelect = screen.getByRole("combobox") as HTMLSelectElement; - expect(updatedSelect.value).toBe("pt-BR"); - }); - }); -}); diff --git a/src/components/ui/LanguageSwitcher.tsx b/src/components/ui/LanguageSwitcher.tsx deleted file mode 100644 index cdd04985..00000000 --- a/src/components/ui/LanguageSwitcher.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useI18n } from "../../i18n"; -import { SUPPORTED_LOCALES, SupportedLocale } from "../../i18n/types"; - -const LOCALE_NAMES: Record = { - "en-US": "English (US)", - "pt-BR": "Português (BR)", - "es-ES": "Español (ES)", -}; - -const LOCALE_FLAGS: Record = { - "en-US": "🇺🇸", - "pt-BR": "🇧🇷", - "es-ES": "🇪🇸", -}; - -export function LanguageSwitcher() { - const { locale, setLocale } = useI18n(); - - return ( - - ); -} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index ed1f1256..0dce0a61 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,3 +1,3 @@ export { I18nProvider, useI18n } from "./IntlProvider"; -export { SUPPORTED_LOCALES } from "./types"; +export { SUPPORTED_LOCALES, LOCALE_LABELS } from "./types"; export type { SupportedLocale, LocaleMessages } from "./types"; diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json index e8199626..1c2450d7 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -50,6 +50,7 @@ "common.tagInput.create": "Create \"{value}\"", "common.tagInput.maxReached": "Maximum {max} tags reached.", "common.tagInput.remove": "Remove {tag}", + "common.language": "Language", "common.theme": "Theme", "common.theme.light": "Light mode", "common.theme.dark": "Dark mode", diff --git a/src/i18n/locales/es-ES/common.json b/src/i18n/locales/es-ES/common.json index 181da399..8ea694ee 100644 --- a/src/i18n/locales/es-ES/common.json +++ b/src/i18n/locales/es-ES/common.json @@ -50,6 +50,7 @@ "common.tagInput.create": "Crear \"{value}\"", "common.tagInput.maxReached": "Se alcanzó el máximo de {max} etiquetas.", "common.tagInput.remove": "Eliminar {tag}", + "common.language": "Idioma", "common.theme": "Tema", "common.theme.light": "Modo claro", "common.theme.dark": "Modo oscuro", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index f4733ff9..bf1809a2 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -50,6 +50,7 @@ "common.tagInput.create": "Criar \"{value}\"", "common.tagInput.maxReached": "Máximo de {max} tags atingido.", "common.tagInput.remove": "Remover {tag}", + "common.language": "Idioma", "common.theme": "Tema", "common.theme.light": "Modo claro", "common.theme.dark": "Modo escuro", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index d8d5eb01..9e99166d 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,6 +1,13 @@ export const SUPPORTED_LOCALES = ["en-US", "pt-BR", "es-ES"] as const; export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; +/** Each locale is named in its own language, so the labels are never translated. */ +export const LOCALE_LABELS: Record = { + "en-US": "English", + "pt-BR": "Português", + "es-ES": "Español", +}; + export interface LocaleMessages { [key: string]: string; }