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
105 changes: 96 additions & 9 deletions packages/react/src/client/locale-switcher.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import React from "react";
import { LocaleSwitcher } from "./locale-switcher";
import {
LocaleSwitcher,
normalizeLocales,
LocaleConfig,
LocalesProp,
} from "./locale-switcher";

vi.mock("./utils", async (orig) => {
const actual = await orig();
Expand All @@ -14,6 +19,61 @@ vi.mock("./utils", async (orig) => {

import { getLocaleFromCookies, setLocaleInCookies } from "./utils";

describe("normalizeLocales", () => {
it("should handle string array input", () => {
const locales: LocalesProp = ["en", "es"];
const expected: LocaleConfig[] = [
{ code: "en", displayName: "en" },
{ code: "es", displayName: "es" },
];
expect(normalizeLocales(locales)).toEqual(expected);
});

it("should handle object input", () => {
const locales: LocalesProp = { en: "English", es: "Español" };
const expected: LocaleConfig[] = [
{ code: "en", displayName: "English" },
{ code: "es", displayName: "Español" },
];
expect(normalizeLocales(locales)).toEqual(expected);
});

it("should handle LocaleConfig array input", () => {
const locales: LocalesProp = [
{ code: "en", displayName: "English", flag: "🇬🇧" },
{ code: "es", displayName: "Español", nativeName: "Español" },
];
expect(normalizeLocales(locales)).toEqual(locales);
});

it("should throw an error for invalid LocaleConfig array", () => {
const invalidLocales = [{ name: "English" }] as any;
expect(() => normalizeLocales(invalidLocales)).toThrow(
"Invalid LocaleConfig array provided. Each object must have 'code' and 'displayName' properties.",
);
});

it("should throw an error for mixed array input", () => {
const mixedLocales = ["en", { code: "es", displayName: "Español" }] as any;
expect(() => normalizeLocales(mixedLocales)).toThrow(
"Invalid 'locales' prop provided. It must be an array of strings, an array of LocaleConfig objects, or a Record<string, string>.",
);
});

it("should throw an error for other invalid inputs", () => {
expect(() => normalizeLocales(null as any)).toThrow(
"Invalid 'locales' prop provided.",
);
expect(() => normalizeLocales(123 as any)).toThrow(
"Invalid 'locales' prop provided.",
);
});

it("should handle empty array", () => {
expect(normalizeLocales([])).toEqual([]);
});
});

describe("LocaleSwitcher", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -28,18 +88,17 @@ describe("LocaleSwitcher", () => {
});

it("uses cookie locale if valid; otherwise defaults to first provided locale", async () => {
(getLocaleFromCookies as any).mockReturnValueOnce("es");
render(<LocaleSwitcher locales={["en", "es"]} />);
(getLocaleFromCookies as any).mockReturnValue("es");
const { unmount } = render(<LocaleSwitcher locales={["en", "es"]} />);
const select = (await screen.findByRole("combobox")) as HTMLSelectElement;
expect(select.value).toBe("es");
unmount(); // Unmount to ensure a clean state for the next render

// invalid cookie -> defaults to first
(getLocaleFromCookies as any).mockReturnValueOnce("fr");
(getLocaleFromCookies as any).mockReturnValue("fr");
render(<LocaleSwitcher locales={["en", "es"]} />);
const selects = (await screen.findAllByRole(
"combobox",
)) as HTMLSelectElement[];
expect(selects[1].value).toBe("en");
const select2 = (await screen.findByRole("combobox")) as HTMLSelectElement;
expect(select2.value).toBe("en");
});

it("on change sets cookie and triggers full reload", async () => {
Expand All @@ -49,10 +108,38 @@ describe("LocaleSwitcher", () => {
writable: true,
});
render(<LocaleSwitcher locales={["en", "es"]} />);
const select = await screen.findByRole("combobox");
const select = (await screen.findByRole("combobox")) as HTMLSelectElement;
fireEvent.change(select, { target: { value: "en" } });

expect(setLocaleInCookies).toHaveBeenCalledWith("en");
expect(reloadSpy).toHaveBeenCalled();
});

it("renders correctly with string array input", async () => {
render(<LocaleSwitcher locales={["en", "es"]} />);
const options = await screen.findAllByRole("option");
expect(options).toHaveLength(2);
expect(options[0].textContent).toBe("en");
expect(options[1].textContent).toBe("es");
});

it("renders correctly with object input", async () => {
render(<LocaleSwitcher locales={{ en: "English", es: "Español" }} />);
const options = await screen.findAllByRole("option");
expect(options).toHaveLength(2);
expect(options[0].textContent).toBe("English");
expect(options[1].textContent).toBe("Español");
});

it("renders correctly with LocaleConfig array input", async () => {
const locales: LocaleConfig[] = [
{ code: "en-US", displayName: "English (US)" },
{ code: "fr-CA", displayName: "Français (Canada)" },
];
render(<LocaleSwitcher locales={locales} />);
const options = await screen.findAllByRole("option");
expect(options).toHaveLength(2);
expect(options[0].textContent).toBe("English (US)");
expect(options[1].textContent).toBe("Français (Canada)");
});
});
93 changes: 81 additions & 12 deletions packages/react/src/client/locale-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,88 @@
import { useState, useEffect } from "react";
import { getLocaleFromCookies, setLocaleInCookies } from "./utils";

/**
* Represents a detailed locale configuration.
*/
export type LocaleConfig = {
/** The locale code (e.g., "en", "es-MX"). */
code: string;
/** The name to display for the locale (e.g., "English", "Español"). */
displayName: string;
/** An optional flag icon or component string. */
flag?: string;
/** The native name of the language (e.g., "English", "Español"). */
nativeName?: string;
};

/**
* The type for the `locales` prop, allowing for simple strings,
* a key-value object, or a full configuration array.
*/
export type LocalesProp = string[] | Record<string, string> | LocaleConfig[];

/**
* The props for the `LocaleSwitcher` component.
*/
export type LocaleSwitcherProps = {
/**
* An array of locale codes to display in the dropdown.
*
* This should contain both the source and target locales.
* The locales to display in the dropdown. Can be an array of strings,
* an object mapping locale codes to display names, or an array of
* `LocaleConfig` objects.
*/
locales: string[];
locales: LocalesProp;
/**
* A custom class name for the dropddown's `select` element.
* A custom class name for the dropdown's `select` element.
*/
className?: string;
};

/**
* Normalizes the `locales` prop into a consistent `LocaleConfig[]` format.
* @param locales - The `locales` prop to normalize.
* @returns An array of `LocaleConfig` objects.
*/
export function normalizeLocales(locales: LocalesProp): LocaleConfig[] {
if (Array.isArray(locales)) {
if (locales.length === 0) return [];

const isStringArray = locales.every((item) => typeof item === "string");
const isObjectArray = locales.every(
(item) => typeof item === "object" && item !== null,
);

if (isStringArray) {
return (locales as string[]).map((code) => ({
code,
displayName: code,
}));
}

if (isObjectArray) {
const isLocaleConfigArray = locales.every(
(item: any) => "code" in item && "displayName" in item,
);

if (!isLocaleConfigArray) {
throw new Error(
"Invalid LocaleConfig array provided. Each object must have 'code' and 'displayName' properties.",
);
}
return locales as LocaleConfig[];
}
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for handling mixed arrays (e.g., ['en', { code: 'es', displayName: 'Español' }]) falls through to the generic error at line 83-85 without clear indication that mixed types are not allowed. When both isStringArray and isObjectArray are false (lines 56 and 63), the code continues past line 74 without handling this case explicitly, making the control flow unclear. Consider adding an explicit check and error message for mixed arrays between lines 62-75.

Suggested change
}
}
// Explicit check for mixed-type arrays
const hasString = locales.some((item) => typeof item === "string");
const hasObject = locales.some((item) => typeof item === "object" && item !== null);
if (hasString && hasObject) {
throw new Error(
"Invalid 'locales' array: mixed types detected. Do not mix strings and objects in the locales array."
);
}

Copilot uses AI. Check for mistakes.
} else if (typeof locales === "object" && locales !== null) {
// Handle Record<string, string>
return Object.entries(locales).map(([code, displayName]) => ({
code,
displayName,
}));
}

throw new Error(
"Invalid 'locales' prop provided. It must be an array of strings, an array of LocaleConfig objects, or a Record<string, string>.",
);
}

/**
* An unstyled dropdown for switching between locales.
*
Expand All @@ -37,21 +103,24 @@ export type LocaleSwitcherProps = {
* <header>
* <nav>
* <LocaleSwitcher locales={["en", "es"]} />
* <LocaleSwitcher locales={{ en: "English", es: "Español" }} />
* <LocaleSwitcher locales={[{ code: "en", displayName: "English" }]} />
* </nav>
* </header>
* );
* }
* ```
*/
export function LocaleSwitcher(props: LocaleSwitcherProps) {
const { locales } = props;
const normalizedLocales = normalizeLocales(props.locales);
const localeCodes = normalizedLocales.map((l) => l.code);
const [locale, setLocale] = useState<string | undefined>(undefined);

useEffect(() => {
const currentLocale = getLocaleFromCookies();
const isValidLocale = currentLocale && locales.includes(currentLocale);
setLocale(isValidLocale ? currentLocale : locales[0]);
}, [locales]);
const isValidLocale = currentLocale && localeCodes.includes(currentLocale);
setLocale(isValidLocale ? currentLocale : localeCodes[0]);
}, [localeCodes]);
Comment on lines +115 to +123
Copy link

Copilot AI Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The localeCodes array is recalculated on every render (line 116) and used as a dependency in useEffect (line 123). Since arrays are compared by reference, this will cause the effect to run on every render even when props.locales hasn't changed. Consider using useMemo to memoize normalizedLocales and localeCodes, or use props.locales directly in the dependency array.

Copilot uses AI. Check for mistakes.

if (locale === undefined) {
return null;
Expand All @@ -65,9 +134,9 @@ export function LocaleSwitcher(props: LocaleSwitcherProps) {
handleLocaleChange(e.target.value);
}}
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{locale}
{normalizedLocales.map((localeConfig) => (
<option key={localeConfig.code} value={localeConfig.code}>
{localeConfig.displayName}
</option>
))}
</select>
Expand Down
Loading