From dd84094109df7a58a7012dd933c8d91a8c6469d1 Mon Sep 17 00:00:00 2001 From: aabdi Date: Thu, 18 Jun 2026 10:23:52 +0700 Subject: [PATCH 1/4] feat: [phase-4-header-language-selector-001] - [Show shared locale choices in the docs header] --- src/components/layout/language-selector.tsx | 55 +++++++++++++++++++ .../layout/model-atlas-docs-header.test.tsx | 36 ++++++++++++ .../layout/model-atlas-docs-header.tsx | 2 + src/content/messages/en/common.json | 4 +- src/content/messages/ja/common.json | 4 +- src/content/messages/vi/common.json | 4 +- .../glossary-opening-convergence.test.tsx | 22 ++++---- src/lib/content/ui-messages.types.ts | 2 + src/lib/i18n/locale-routing.test.ts | 6 ++ src/lib/i18n/locale-routing.ts | 11 ++++ .../a11y/primary-navigation.a11y.test.tsx | 30 ++++++++++ src/tests/content/ui-messages.test.ts | 4 ++ 12 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 src/components/layout/language-selector.tsx diff --git a/src/components/layout/language-selector.tsx b/src/components/layout/language-selector.tsx new file mode 100644 index 00000000..b9c3a23a --- /dev/null +++ b/src/components/layout/language-selector.tsx @@ -0,0 +1,55 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import type { UiMessages } from "@/lib/content/ui-messages.types"; +import { + buildLocalizedRoute, + defaultLocale, + localeOptions, + type SiteLocale, + switchRouteLocale, +} from "@/lib/i18n/locale-routing"; + +type LanguageSelectorProps = { + messages: UiMessages; + locale?: SiteLocale; +}; + +export function LanguageSelector({ + messages, + locale = defaultLocale, +}: LanguageSelectorProps) { + function onChange(event: ChangeEvent) { + const nextLocale = event.currentTarget.value as SiteLocale; + + if (nextLocale === locale || typeof window === "undefined") { + return; + } + + const activePath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + const fallbackPath = buildLocalizedRoute({ surface: "home" }, locale); + window.location.assign( + switchRouteLocale(activePath || fallbackPath, nextLocale), + ); + } + + return ( + + ); +} diff --git a/src/components/layout/model-atlas-docs-header.test.tsx b/src/components/layout/model-atlas-docs-header.test.tsx index 23fb228d..2a69cefd 100644 --- a/src/components/layout/model-atlas-docs-header.test.tsx +++ b/src/components/layout/model-atlas-docs-header.test.tsx @@ -14,6 +14,7 @@ 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 { renderWithAppProviders } from "@/tests/a11y/render"; @@ -52,6 +53,35 @@ 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 = () => null; + await renderWithAppProviders( + , + { + SearchDialog, + }, + ); + + const selector = screen.getByRole("combobox", { + name: messages.nav.language, + }) as HTMLSelectElement; + expect(selector.value).toBe("vi"); + + 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)", + "日本語", + ]); }); test("mobile width markup hides desktop inline nav links and exposes the menu control", async () => { @@ -189,6 +219,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, }); @@ -213,6 +246,9 @@ describe("ModelAtlasDocsHeader", () => { expect(document.activeElement).toBe(link); } + await user.tab(); + expect(document.activeElement).toBe(languageSelector); + await user.tab(); expect(document.activeElement).toBe(searchTrigger); }); diff --git a/src/components/layout/model-atlas-docs-header.tsx b/src/components/layout/model-atlas-docs-header.tsx index a92597ab..cef6e190 100644 --- a/src/components/layout/model-atlas-docs-header.tsx +++ b/src/components/layout/model-atlas-docs-header.tsx @@ -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, @@ -80,6 +81,7 @@ export function ModelAtlasDocsHeader({ : null}
+ {trailing}
diff --git a/src/content/messages/en/common.json b/src/content/messages/en/common.json index 935af9e3..76dad3a4 100644 --- a/src/content/messages/en/common.json +++ b/src/content/messages/en/common.json @@ -17,7 +17,9 @@ "menu": "Open menu", "architecture": "Architecture", "glossary": "Glossary", - "tags": "Tags" + "tags": "Tags", + "language": "Language", + "currentLanguage": "Current" }, "searchEntry": { "title": "Search", diff --git a/src/content/messages/ja/common.json b/src/content/messages/ja/common.json index 55e6e454..f3b61b33 100644 --- a/src/content/messages/ja/common.json +++ b/src/content/messages/ja/common.json @@ -17,7 +17,9 @@ "menu": "メニューを開く", "architecture": "アーキテクチャ", "glossary": "用語集", - "tags": "タグ" + "tags": "タグ", + "language": "言語", + "currentLanguage": "現在" }, "searchEntry": { "title": "検索", diff --git a/src/content/messages/vi/common.json b/src/content/messages/vi/common.json index c0ec7ae9..d204d6c4 100644 --- a/src/content/messages/vi/common.json +++ b/src/content/messages/vi/common.json @@ -17,7 +17,9 @@ "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" }, "searchEntry": { "title": "Tìm kiếm", diff --git a/src/lib/content/glossary-opening-convergence.test.tsx b/src/lib/content/glossary-opening-convergence.test.tsx index e3dda633..8043320a 100644 --- a/src/lib/content/glossary-opening-convergence.test.tsx +++ b/src/lib/content/glossary-opening-convergence.test.tsx @@ -39,17 +39,19 @@ describe("glossary opening convergence", () => { async () => { const pages = await listPublishedGlossaryPages(); - for (const page of pages) { - const loadedPage = await loadLocalDocsPage({ - section: "glossary", - slug: page.slug, - }); - expectGlossaryOpeningSummaryMessage(loadedPage.messages); + await Promise.all( + pages.map(async (page) => { + const loadedPage = await loadLocalDocsPage({ + section: "glossary", + slug: page.slug, + }); + expectGlossaryOpeningSummaryMessage(loadedPage.messages); - const html = renderGlossaryDocsShell(loadedPage); - expectGlossaryOmitsOpeningSummary(html); - } + const html = renderGlossaryDocsShell(loadedPage); + expectGlossaryOmitsOpeningSummary(html); + }), + ); }, - { timeout: 10_000 }, + { timeout: 20_000 }, ); }); diff --git a/src/lib/content/ui-messages.types.ts b/src/lib/content/ui-messages.types.ts index 5e8b44fa..1a9e9069 100644 --- a/src/lib/content/ui-messages.types.ts +++ b/src/lib/content/ui-messages.types.ts @@ -18,6 +18,8 @@ export type UiMessages = { architecture: string; glossary: string; tags: string; + language: string; + currentLanguage: string; }; searchEntry: { title: string; diff --git a/src/lib/i18n/locale-routing.test.ts b/src/lib/i18n/locale-routing.test.ts index f0822acc..41bf8523 100644 --- a/src/lib/i18n/locale-routing.test.ts +++ b/src/lib/i18n/locale-routing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { buildLocalizedRoute, defaultLocale, + localeOptions, localizePath, matchLocalizedRoute, resolveLocale, @@ -14,6 +15,11 @@ describe("locale-routing", () => { test("exports the shared locale contract", () => { expect(supportedLocales).toEqual(["en", "vi", "ja"]); expect(defaultLocale).toBe("en"); + expect(localeOptions).toEqual([ + { code: "en", label: "English" }, + { code: "vi", label: "Tiếng Việt" }, + { code: "ja", label: "日本語" }, + ]); }); test("resolveLocale defaults to english and rejects unsupported locales", () => { diff --git a/src/lib/i18n/locale-routing.ts b/src/lib/i18n/locale-routing.ts index e0b5bd18..0638db72 100644 --- a/src/lib/i18n/locale-routing.ts +++ b/src/lib/i18n/locale-routing.ts @@ -6,6 +6,17 @@ export type SiteLocale = (typeof supportedLocales)[number]; export const defaultLocale: SiteLocale = "en"; +export type LocaleOption = { + code: SiteLocale; + label: string; +}; + +export const localeOptions: readonly LocaleOption[] = [ + { code: "en", label: "English" }, + { code: "vi", label: "Tiếng Việt" }, + { code: "ja", label: "日本語" }, +] as const; + export type LocalizedRouteDestination = | { surface: "home" } | { surface: "search" } diff --git a/src/tests/a11y/primary-navigation.a11y.test.tsx b/src/tests/a11y/primary-navigation.a11y.test.tsx index a99aed72..dcc36f93 100644 --- a/src/tests/a11y/primary-navigation.a11y.test.tsx +++ b/src/tests/a11y/primary-navigation.a11y.test.tsx @@ -148,4 +148,34 @@ describe("primary navigation accessibility smoke", () => { await expectNoSeriousAxeViolations(header ?? document.body); }); + + test("header exposes a keyboard-focusable language selector with the current locale selected", async () => { + await installDocsSearchFetchMock(); + const context = await loadAppTestContext(); + await act(async () => { + await renderWithAppProviders( + , + { context }, + ); + }); + + const selector = screen.getByRole("combobox", { + name: context.messages.nav.language, + }) as HTMLSelectElement; + + expect(selector.value).toBe("ja"); + + const options = within(selector).getAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "English", + "Tiếng Việt", + "日本語 (Current)", + ]); + + selector.focus(); + expect(document.activeElement).toBe(selector); + + const header = document.querySelector("header"); + await expectNoSeriousAxeViolations(header ?? document.body); + }); }); diff --git a/src/tests/content/ui-messages.test.ts b/src/tests/content/ui-messages.test.ts index 58c4446f..51d9aa11 100644 --- a/src/tests/content/ui-messages.test.ts +++ b/src/tests/content/ui-messages.test.ts @@ -25,6 +25,8 @@ describe("loadUiMessages shell keys", () => { expect(messages.nav.search).toBe("Search"); expect(messages.nav.menu).toBe("Open menu"); expect(messages.nav.architecture).toBe("Architecture"); + expect(messages.nav.language).toBe("Language"); + expect(messages.nav.currentLanguage).toBe("Current"); expect(messages.searchEntry.title).toBe("Search"); expect(messages.architectureIndex.title).toBe("Architecture"); expect(messages.glossaryIndex.title).toBe("Glossary"); @@ -34,6 +36,7 @@ describe("loadUiMessages shell keys", () => { it("loads shipped vietnamese shell copy when vi shared messages are available", async () => { const messages = await loadUiMessages("vi"); expect(messages.nav.home).toBe("Trang chủ"); + expect(messages.nav.language).toBe("Ngôn ngữ"); expect(messages.searchEntry.title).toBe("Tìm kiếm"); expect(messages.tagsIndex.title).toBe("Thẻ"); }); @@ -41,6 +44,7 @@ describe("loadUiMessages shell keys", () => { it("loads shipped japanese shell copy when ja shared messages are available", async () => { const messages = await loadUiMessages("ja"); expect(messages.nav.home).toBe("ホーム"); + expect(messages.nav.language).toBe("言語"); expect(messages.searchEntry.title).toBe("検索"); expect(messages.tagsIndex.title).toBe("タグ"); expect(messages.shell.sidebarTitle).toBe("リファレンス"); From 301cb61ff484e23778cc9c7b817e299e7d8a82d1 Mon Sep 17 00:00:00 2001 From: aabdi Date: Thu, 18 Jun 2026 10:32:01 +0700 Subject: [PATCH 2/4] feat: [phase-4-header-language-selector-002] - [Preserve the current canonical destination when switching to a shipped locale] --- .../layout/model-atlas-docs-header.test.tsx | 58 +++++++++++++++++++ src/lib/i18n/locale-routing.test.ts | 17 +++++- src/lib/i18n/locale-routing.ts | 3 +- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/components/layout/model-atlas-docs-header.test.tsx b/src/components/layout/model-atlas-docs-header.test.tsx index 2a69cefd..8c5ca8e3 100644 --- a/src/components/layout/model-atlas-docs-header.test.tsx +++ b/src/components/layout/model-atlas-docs-header.test.tsx @@ -23,6 +23,28 @@ describe("ModelAtlasDocsHeader", () => { cleanup(); }); + function withWindowLocation( + location: Pick & + Partial>, + run: () => Promise, + ): Promise { + const originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...originalLocation, + ...location, + }, + }); + + 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 = () => null; @@ -84,6 +106,42 @@ describe("ModelAtlasDocsHeader", () => { ]); }); + test("switches to the localized equivalent route while preserving query and hash state", async () => { + const messages = await loadUiMessages(); + const SearchDialog: ComponentType = () => 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 () => { + await renderWithAppProviders( + , + { + 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 () => { const messages = await loadUiMessages(); const SearchDialog: ComponentType = () => null; diff --git a/src/lib/i18n/locale-routing.test.ts b/src/lib/i18n/locale-routing.test.ts index 41bf8523..5e1d91ba 100644 --- a/src/lib/i18n/locale-routing.test.ts +++ b/src/lib/i18n/locale-routing.test.ts @@ -141,13 +141,26 @@ describe("locale-routing", () => { expect(switchRouteLocale("/docs/glossary/token", "ja")).toBe( "/ja/docs/glossary/token", ); + expect( + switchRouteLocale("/docs/glossary/token?view=compact#usage-notes", "vi"), + ).toBe("/vi/docs/glossary/token?view=compact#usage-notes"); + expect( + switchRouteLocale( + "/vi/docs/modules/grouped-query-attention?tab=graph#kv-cache", + "en", + ), + ).toBe("/docs/modules/grouped-query-attention?tab=graph#kv-cache"); expect(switchRouteLocale("/vi/tags/attention", "en")).toBe( "/tags/attention", ); expect(switchRouteLocale("/vi/tags/attention", "ja")).toBe( "/ja/tags/attention", ); - expect(switchRouteLocale("/search?tag=attention", "vi")).toBe("/vi/search"); - expect(switchRouteLocale("/search?tag=attention", "ja")).toBe("/ja/search"); + expect(switchRouteLocale("/search?tag=attention", "vi")).toBe( + "/vi/search?tag=attention", + ); + expect(switchRouteLocale("/search?tag=attention", "ja")).toBe( + "/ja/search?tag=attention", + ); }); }); diff --git a/src/lib/i18n/locale-routing.ts b/src/lib/i18n/locale-routing.ts index 0638db72..0c150916 100644 --- a/src/lib/i18n/locale-routing.ts +++ b/src/lib/i18n/locale-routing.ts @@ -225,6 +225,7 @@ export function switchRouteLocale( pathname: string, locale: SiteLocale, ): string { + const { suffix } = splitPathname(pathname); const match = matchLocalizedRoute(pathname); if (match.kind !== "matched") { @@ -234,5 +235,5 @@ export function switchRouteLocale( return localizePath(pathname, locale); } - return buildLocalizedRoute(match.destination, locale); + return `${buildLocalizedRoute(match.destination, locale)}${suffix}`; } From caf78d9feabbe7b1213038e14d80d968efb87ad9 Mon Sep 17 00:00:00 2001 From: aabdi Date: Thu, 18 Jun 2026 10:47:00 +0700 Subject: [PATCH 3/4] feat: [phase-4-header-language-selector-003] - [Make unshipped localized destinations explicit instead of misrouting] --- src/components/layout/language-selector.tsx | 73 ++++++++++++++++--- .../layout/model-atlas-docs-header.test.tsx | 53 ++++++++++++++ src/content/messages/en/common.json | 4 +- src/content/messages/ja/common.json | 4 +- src/content/messages/vi/common.json | 4 +- src/lib/content/localized-docs-href.ts | 17 +---- src/lib/content/shipped-localized-docs.ts | 2 +- src/lib/content/ui-messages.types.ts | 2 + src/lib/i18n/locale-routing.test.ts | 51 +++++++++++++ src/lib/i18n/locale-routing.ts | 69 ++++++++++++++++++ .../a11y/primary-navigation.a11y.test.tsx | 30 +++++++- src/tests/a11y/render.tsx | 7 +- src/tests/content/ui-messages.test.ts | 4 + 13 files changed, 290 insertions(+), 30 deletions(-) diff --git a/src/components/layout/language-selector.tsx b/src/components/layout/language-selector.tsx index b9c3a23a..174d4dd6 100644 --- a/src/components/layout/language-selector.tsx +++ b/src/components/layout/language-selector.tsx @@ -1,13 +1,15 @@ "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, - switchRouteLocale, } from "@/lib/i18n/locale-routing"; type LanguageSelectorProps = { @@ -19,6 +21,28 @@ export function LanguageSelector({ messages, locale = defaultLocale, }: LanguageSelectorProps) { + const pathname = usePathname(); + 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) { const nextLocale = event.currentTarget.value as SiteLocale; @@ -26,30 +50,57 @@ export function LanguageSelector({ return; } - const activePath = `${window.location.pathname}${window.location.search}${window.location.hash}`; - const fallbackPath = buildLocalizedRoute({ surface: "home" }, locale); - window.location.assign( - switchRouteLocale(activePath || fallbackPath, nextLocale), + 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 ( -