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
109 changes: 109 additions & 0 deletions src/components/layout/language-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import { usePathname } from "next/navigation";
import type { ChangeEvent } from "react";
import { useId } from "react";
import type { UiMessages } from "@/lib/content/ui-messages.types";
import {
buildLocalizedRoute,
defaultLocale,
localeOptions,
resolveLocalizedRouteSwitch,
type SiteLocale,
} from "@/lib/i18n/locale-routing";

type LanguageSelectorProps = {
messages: UiMessages;
locale?: SiteLocale;
};

export function LanguageSelector({
messages,
locale = defaultLocale,
}: LanguageSelectorProps) {
const pathname = usePathname();
const selectId = useId();
const unavailableHintId = useId();
const routePath =
pathname || buildLocalizedRoute({ surface: "home" }, locale);
const options = localeOptions.map((option) => {
if (option.code === locale) {
return { ...option, available: true };
}

return {
...option,
...resolveLocalizedRouteSwitch(routePath, option.code),
};
});
const unavailableOptions = options.filter((option) => !option.available);
const unavailableSummary =
unavailableOptions.length > 0
? `${messages.nav.unavailableOnPage}: ${unavailableOptions
.map((option) => option.label)
.join(", ")}`
: null;

function onChange(event: ChangeEvent<HTMLSelectElement>) {
const nextLocale = event.currentTarget.value as SiteLocale;

if (nextLocale === locale || typeof window === "undefined") {
return;
}

const activePath = `${routePath}${window.location.search}${window.location.hash}`;
const fallbackPath = routePath;
const resolved = resolveLocalizedRouteSwitch(
activePath || fallbackPath,
nextLocale,
);

if (!resolved.available) {
return;
}

window.location.assign(resolved.href);
}

return (
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
<label htmlFor={selectId} className="flex items-center gap-2">
<span className="hidden sm:inline">{messages.nav.language}</span>
<span className="sr-only md:hidden">{messages.nav.language}</span>
</label>
<select
id={selectId}
aria-label={messages.nav.language}
aria-describedby={unavailableSummary ? unavailableHintId : undefined}
className="h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onChange={onChange}
value={locale}
>
{options.map((option) => (
<option
key={option.code}
disabled={!option.available}
lang={option.code}
value={option.code}
>
{option.label}
{option.code === locale
? ` (${messages.nav.currentLanguage})`
: option.available
? ""
: ` (${messages.nav.unavailableLanguage})`}
</option>
))}
</select>
{unavailableSummary ? (
<span
id={unavailableHintId}
className="text-xs text-muted-foreground"
role="status"
>
{unavailableSummary}
</span>
) : null}
</div>
);
}
166 changes: 166 additions & 0 deletions src/components/layout/model-atlas-docs-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,43 @@ import {
PRIMARY_NAV_MOBILE_PANEL_CLASS,
} from "@/components/layout/primary-nav";
import { loadUiMessages } from "@/lib/content/ui-messages";
import { localeOptions } from "@/lib/i18n/locale-routing";
import { assertPrimaryNavNoDuplicateSearchLink } from "@/lib/verify/customer-ask-home-header-convergence";
import {
resetMockNavigation,
setMockPathname,
} from "@/tests/a11y/mock-navigation";
import { renderWithAppProviders } from "@/tests/a11y/render";

describe("ModelAtlasDocsHeader", () => {
afterEach(() => {
cleanup();
resetMockNavigation();
});

function withWindowLocation(
location: Pick<Location, "pathname" | "search" | "hash"> &
Partial<Pick<Location, "assign">>,
run: () => Promise<void>,
): Promise<void> {
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: {
...originalLocation,
...location,
assign: location.assign ?? originalLocation.assign,
},
});

return run().finally(() => {
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
});
}

test("renders header search trigger without duplicate /search primary nav link", async () => {
const messages = await loadUiMessages();
const SearchDialog: ComponentType<SharedProps> = () => null;
Expand Down Expand Up @@ -52,6 +81,131 @@ describe("ModelAtlasDocsHeader", () => {
expect(html).toContain('data-search=""');
expect(html).toContain(`aria-label="${messages.search.open}"`);
expect(html).toContain(messages.search.shortcut);
expect(html).toContain(`aria-label="${messages.nav.language}"`);
for (const option of localeOptions) {
expect(html).toContain(`value="${option.code}"`);
expect(html).toContain(option.label);
}
});

test("renders the shared locale selector with the active locale selected", async () => {
const messages = await loadUiMessages("vi");
const SearchDialog: ComponentType<SharedProps> = () => null;
await renderWithAppProviders(
<ModelAtlasDocsHeader messages={messages} locale="vi" />,
{
SearchDialog,
},
);

const selector = screen.getByRole("combobox", {
name: messages.nav.language,
}) as HTMLSelectElement;
const selectorLabel = selector.closest("div")?.querySelector("label");
expect(selector.value).toBe("vi");
expect(selector.getAttribute("id")).toBeTruthy();
expect(selectorLabel?.tagName).toBe("LABEL");
expect(selectorLabel?.getAttribute("for")).toBe(selector.id);

const options = within(selector).getAllByRole("option");
expect(options).toHaveLength(localeOptions.length);
expect(options.map((option) => option.textContent)).toEqual([
"English",
"Tiếng Việt (Hiện tại)",
"日本語",
]);
expect(options.map((option) => option.getAttribute("lang"))).toEqual([
"en",
"vi",
"ja",
]);
});

test("marks unshipped locale destinations as unavailable and does not navigate to them", async () => {
const messages = await loadUiMessages();
const SearchDialog: ComponentType<SharedProps> = () => null;
const assignedUrls: string[] = [];

await withWindowLocation(
{
assign: (url: string | URL) => {
assignedUrls.push(String(url));
},
pathname: "/docs/modules/grouped-query-attention",
search: "",
hash: "",
},
async () => {
setMockPathname("/docs/modules/grouped-query-attention");
await renderWithAppProviders(
<ModelAtlasDocsHeader messages={messages} locale="en" />,
{
SearchDialog,
},
);

const selector = screen.getByRole("combobox", {
name: messages.nav.language,
}) as HTMLSelectElement;
const options = within(selector).getAllByRole(
"option",
) as HTMLOptionElement[];
const japaneseOption = options.find((option) => option.value === "ja");

expect(japaneseOption?.textContent).toBe("日本語 (Unavailable)");
expect(japaneseOption?.hasAttribute("disabled")).toBe(true);
const unavailableStatus = screen.getByText(
"Unavailable on this page: 日本語",
);
expect(unavailableStatus.getAttribute("role")).toBe("status");
expect(selector.getAttribute("aria-describedby")).toBe(
unavailableStatus.id,
);

fireEvent.change(selector, {
target: { value: "ja" },
});

expect(assignedUrls).toEqual([]);
},
);
});

test("switches to the localized equivalent route while preserving query and hash state", async () => {
const messages = await loadUiMessages();
const SearchDialog: ComponentType<SharedProps> = () => null;
const assignedUrls: string[] = [];

await withWindowLocation(
{
assign: (url: string | URL) => {
assignedUrls.push(String(url));
},
pathname: "/docs/modules/grouped-query-attention",
search: "?view=graph",
hash: "#kv-cache",
},
async () => {
setMockPathname("/docs/modules/grouped-query-attention");
await renderWithAppProviders(
<ModelAtlasDocsHeader messages={messages} locale="en" />,
{
SearchDialog,
},
);

fireEvent.change(
screen.getByRole("combobox", { name: messages.nav.language }),
{
target: { value: "vi" },
},
);

expect(assignedUrls).toEqual([
"/vi/docs/modules/grouped-query-attention?view=graph#kv-cache",
]);
},
);
});

test("mobile width markup hides desktop inline nav links and exposes the menu control", async () => {
Expand Down Expand Up @@ -135,6 +289,12 @@ describe("ModelAtlasDocsHeader", () => {
expect(panel).toBeTruthy();
expect(panel?.className).toContain(PRIMARY_NAV_MOBILE_PANEL_CLASS);

const selector = screen.getByRole("combobox", {
name: messages.nav.language,
});
selector.focus();
expect(document.activeElement).toBe(selector);

const expectedItems = getPrimaryNavItems(messages);
for (const item of expectedItems) {
const link = within(panel as HTMLElement).getByRole("link", {
Expand Down Expand Up @@ -189,6 +349,9 @@ describe("ModelAtlasDocsHeader", () => {
});
const user = userEvent.setup();
const menuButton = screen.getByRole("button", { name: messages.nav.menu });
const languageSelector = screen.getByRole("combobox", {
name: messages.nav.language,
});
const searchTrigger = screen.getByRole("button", {
name: messages.search.open,
});
Expand All @@ -213,6 +376,9 @@ describe("ModelAtlasDocsHeader", () => {
expect(document.activeElement).toBe(link);
}

await user.tab();
expect(document.activeElement).toBe(languageSelector);

await user.tab();
expect(document.activeElement).toBe(searchTrigger);
});
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/model-atlas-docs-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Menu } from "lucide-react";
import Link from "next/link";
import { type ReactNode, useId, useState } from "react";
import { LanguageSelector } from "@/components/layout/language-selector";
import {
getPrimaryNavItems,
PRIMARY_NAV_DESKTOP_CLASS,
Expand Down Expand Up @@ -80,6 +81,7 @@ export function ModelAtlasDocsHeader({
: null}
</nav>
<div className="ms-auto flex items-center gap-2">
<LanguageSelector messages={messages} locale={locale} />
<SearchTrigger messages={messages} />
{trailing}
</div>
Expand Down
6 changes: 5 additions & 1 deletion src/content/messages/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
"menu": "Open menu",
"architecture": "Architecture",
"glossary": "Glossary",
"tags": "Tags"
"tags": "Tags",
"language": "Language",
"currentLanguage": "Current",
"unavailableLanguage": "Unavailable",
"unavailableOnPage": "Unavailable on this page"
},
"searchEntry": {
"title": "Search",
Expand Down
6 changes: 5 additions & 1 deletion src/content/messages/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
"menu": "メニューを開く",
"architecture": "アーキテクチャ",
"glossary": "用語集",
"tags": "タグ"
"tags": "タグ",
"language": "言語",
"currentLanguage": "現在",
"unavailableLanguage": "未提供",
"unavailableOnPage": "このページでは未提供"
},
"searchEntry": {
"title": "検索",
Expand Down
6 changes: 5 additions & 1 deletion src/content/messages/vi/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@
"menu": "Mở menu",
"architecture": "Kiến trúc",
"glossary": "Thuật ngữ",
"tags": "Thẻ"
"tags": "Thẻ",
"language": "Ngôn ngữ",
"currentLanguage": "Hiện tại",
"unavailableLanguage": "Chưa khả dụng",
"unavailableOnPage": "Chưa khả dụng trên trang này"
},
"searchEntry": {
"title": "Tìm kiếm",
Expand Down
Loading