From 5f18e75fb1d41cdb56962c26ec825099c1bc1cbc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 20 Oct 2026 08:14:59 +0200 Subject: [PATCH] feat(website): replace the native language select with an EN/ZH menu The header showed an OS-native select whose popup did not match the site. The trigger now shows the language abbreviated (EN, ZH) and opens a menu in the app's dropdown style that names each language in itself, with its name in the page language underneath. Rows are real links to the same page in that language; arrows, Escape, outside presses and taps behave as a menu button, and the menu keeps itself on screen when a narrow header pushes the trigger toward an edge. Also drops the stray space after the full-width comma in the Chinese homepage subtitle. --- packages/website/e2e/i18n.spec.ts | 78 +++++- .../src/components/language-selector.tsx | 252 ++++++++++++++++-- packages/website/src/i18n/index.ts | 1 + packages/website/src/i18n/locale.ts | 6 + packages/website/src/i18n/resources/en.json | 7 +- .../website/src/i18n/resources/zh-CN.json | 9 +- 6 files changed, 313 insertions(+), 40 deletions(-) diff --git a/packages/website/e2e/i18n.spec.ts b/packages/website/e2e/i18n.spec.ts index 1959dc455e..d235c4209e 100644 --- a/packages/website/e2e/i18n.spec.ts +++ b/packages/website/e2e/i18n.spec.ts @@ -1,10 +1,12 @@ import { expect, test, type Page } from "playwright/test"; +function languageButton(page: Page) { + return page.getByRole("button", { name: /^(Language|语言)/ }).first(); +} + async function chooseLanguage(page: Page, language: string) { - await page - .getByRole("combobox", { name: /^(Language|语言)$/ }) - .first() - .selectOption({ label: language }); + await languageButton(page).click(); + await page.getByRole("menuitemradio", { name: language }).click(); } function describeAlternates(links: Element[]): string[] { @@ -62,6 +64,14 @@ test.describe("server-rendered Chinese pages", () => { await expect(mockup).toContainText("给 Agent 发消息"); await expect(mockup).not.toContainText("Ready to review"); }); + + test("keep no space after full-width punctuation in the homepage subtitle", async ({ page }) => { + await page.goto("/zh-CN"); + + await expect(page.getByText(/^同时运行多个编程 Agent/)).toHaveText( + "同时运行多个编程 Agent,在任何机器上。在桌前,也在手机上。", + ); + }); }); test("switches language on the same page, keeping the query and then every link", async ({ @@ -82,13 +92,65 @@ test("switches language on the same page, keeping the query and then every link" await expect(page.locator("html")).toHaveAttribute("lang", "en"); }); -test("offers the language selector on a phone", async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); +test("shows the language as an abbreviation and lists every language by name", async ({ page }) => { + await page.goto("/docs/cli#running-agents"); + const button = languageButton(page); + await expect(button).toHaveText("EN"); + await expect(button).toHaveAccessibleName("Language: English"); + + await button.click(); + await expect(page.getByRole("menuitemradio", { name: "English" })).toHaveAttribute( + "aria-checked", + "true", + ); + const chinese = page.getByRole("menuitemradio", { name: /简体中文/ }); + await expect(chinese).toHaveAttribute("aria-checked", "false"); + await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 }); + await expect(chinese).toHaveAttribute("href", "/zh-CN/docs/cli#running-agents"); + + await page.mouse.click(5, 400); + await expect(page.getByRole("menu")).toHaveCount(0); +}); + +test("switches language from the keyboard", async ({ page }) => { await page.goto("/"); - await chooseLanguage(page, "简体中文"); + await languageButton(page).focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(languageButton(page)).toBeFocused(); + + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitemradio", { name: /简体中文/ })).toBeFocused(); + await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/zh-CN$/); - await page.screenshot({ path: test.info().outputPath("mobile-zh-home.png") }); + await expect(languageButton(page)).toHaveText("ZH"); + await expect(languageButton(page)).toBeFocused(); +}); + +test.describe("on a phone", () => { + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }); + + const cases = [ + { from: "/", choose: "简体中文", to: "/zh-CN", abbreviation: "ZH" }, + { from: "/zh-CN", choose: "English", to: "/", abbreviation: "EN" }, + { from: "/docs/cli", choose: "简体中文", to: "/zh-CN/docs/cli", abbreviation: "ZH" }, + ]; + for (const { from, choose, to, abbreviation } of cases) { + test(`switches from ${from} to ${to} with taps`, async ({ page, baseURL }) => { + await page.goto(from); + await languageButton(page).tap(); + await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 }); + await page.getByRole("menuitemradio", { name: choose }).tap(); + + await expect(page).toHaveURL(new URL(to, baseURL).href); + await expect(languageButton(page)).toHaveText(abbreviation); + }); + } }); test("finds plugins by their Chinese and English listing text", async ({ page }) => { diff --git a/packages/website/src/components/language-selector.tsx b/packages/website/src/components/language-selector.tsx index 694341d0a5..9fb219fad5 100644 --- a/packages/website/src/components/language-selector.tsx +++ b/packages/website/src/components/language-selector.tsx @@ -1,55 +1,249 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; -import { Languages } from "lucide-react"; -import { useCallback } from "react"; +import { AnimatePresence, motion, type Transition } from "framer-motion"; +import { Check, ChevronDown, Languages } from "lucide-react"; +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { - isLocale, + LANGUAGE_ABBREVIATIONS, LANGUAGE_NAMES, LOCALES, + type Locale, localizePath, splitLocalePath, useLocale, } from "~/i18n"; +const MENU_HIDDEN = { opacity: 0, y: -4 }; +const MENU_SHOWN = { opacity: 1, y: 0 }; +const MENU_TRANSITION: Transition = { duration: 0.15, ease: "easeOut" }; + +const ITEM_SELECTOR = '[role="menuitemradio"]'; + +/** The closest the menu comes to the edge of the screen. */ +const VIEWPORT_MARGIN = 8; + +/** Which item takes focus when the menu opens: the current language, or an end the arrow key named. */ +type OpenFocus = "current" | "first" | "last"; + /** * Switches the page to the same page in another language, keeping its query and hash so filters * and anchors survive. The URL carries the choice, so every link on the new page stays in it. + * + * The trigger shows the language abbreviated; the menu names every language in itself. It follows + * the menu button pattern: arrows move between languages, Escape and outside presses close it. */ export function LanguageSelector() { const { t } = useTranslation(); const locale = useLocale(); const location = useLocation(); const navigate = useNavigate(); - const onChange = useCallback( - (event: React.ChangeEvent) => { - const next = event.target.value; - if (!isLocale(next) || next === locale) return; - const { path } = splitLocalePath(location.pathname); - const hash = location.hash ? `#${location.hash}` : ""; - void navigate({ href: `${localizePath(path, next)}${location.searchStr}${hash}` }); + const menuId = useId(); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [openFocus, setOpenFocus] = useState(null); + const open = openFocus !== null; + + const close = useCallback(() => setOpenFocus(null), []); + + // The menu hangs from the trigger's end, which on a narrow header can push it off the screen. + useLayoutEffect(() => { + if (open && menuRef.current) keepInViewport(menuRef.current); + }, [open]); + + useEffect(() => { + if (!openFocus) return; + focusOnOpen(menuItems(menuRef.current), openFocus); + }, [openFocus]); + + useEffect(() => { + if (!open) return; + function closeOnOutsidePress(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) close(); + } + document.addEventListener("pointerdown", closeOnOutsidePress); + return () => document.removeEventListener("pointerdown", closeOnOutsidePress); + }, [open, close]); + + const toggle = useCallback(() => setOpenFocus((current) => (current ? null : "current")), []); + + const onTriggerKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setOpenFocus(event.key === "ArrowDown" ? "first" : "last"); + } + }, []); + + const onMenuKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const items = menuItems(menuRef.current); + const index = items.indexOf(document.activeElement as HTMLElement); + const moveTo = (next: number) => { + event.preventDefault(); + items[(next + items.length) % items.length]?.focus(); + }; + switch (event.key) { + case "ArrowDown": + return moveTo(index + 1); + case "ArrowUp": + return moveTo(index - 1); + case "Home": + return moveTo(0); + case "End": + return moveTo(items.length - 1); + case " ": + event.preventDefault(); + items[index]?.click(); + return; + case "Escape": + event.preventDefault(); + close(); + triggerRef.current?.focus(); + return; + case "Tab": + close(); + return; + } }, - [locale, location.hash, location.pathname, location.searchStr, navigate], + [close], ); + + const { path } = splitLocalePath(location.pathname); + const suffix = `${location.searchStr}${location.hash ? `#${location.hash}` : ""}`; + + const choose = useCallback( + (href: string) => { + close(); + triggerRef.current?.focus(); + void navigate({ href }); + }, + [close, navigate], + ); + return ( - + {LOCALES.map((option) => ( + + ))} + + )} + + ); } + +/** + * One language in the menu. It is a real link to the same page in that language, so it can be + * opened in a new tab; a plain click switches in place. + */ +function LanguageOption({ + language, + href, + checked, + onChoose, +}: { + language: Locale; + href: string; + checked: boolean; + onChoose: (href: string) => void; +}) { + const { t } = useTranslation(); + const localName = t(`languageSelector.names.${language}`); + const onClick = useCallback( + (event: React.MouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + onChoose(href); + }, + [href, onChoose], + ); + const focusOnHover = useCallback( + (event: React.MouseEvent) => event.currentTarget.focus(), + [], + ); + return ( + + + {LANGUAGE_NAMES[language]} + {localName !== LANGUAGE_NAMES[language] && ( + {localName} + )} + + + ); +} + +function menuItems(menu: HTMLElement | null): HTMLElement[] { + return menu ? [...menu.querySelectorAll(ITEM_SELECTOR)] : []; +} + +function keepInViewport(menu: HTMLElement) { + const { left, right } = menu.getBoundingClientRect(); + let shift = 0; + if (left < VIEWPORT_MARGIN) shift = VIEWPORT_MARGIN - left; + else if (right > window.innerWidth - VIEWPORT_MARGIN) { + shift = window.innerWidth - VIEWPORT_MARGIN - right; + } + menu.style.translate = `${shift}px 0`; +} + +function focusOnOpen(items: HTMLElement[], openFocus: OpenFocus) { + if (openFocus === "first") items[0]?.focus(); + else if (openFocus === "last") items.at(-1)?.focus(); + else items.find((item) => item.getAttribute("aria-checked") === "true")?.focus(); +} diff --git a/packages/website/src/i18n/index.ts b/packages/website/src/i18n/index.ts index 8e16f3cb49..caafd03179 100644 --- a/packages/website/src/i18n/index.ts +++ b/packages/website/src/i18n/index.ts @@ -1,5 +1,6 @@ export { DEFAULT_LOCALE, + LANGUAGE_ABBREVIATIONS, LANGUAGE_NAMES, OPEN_GRAPH_LOCALES, LOCALES, diff --git a/packages/website/src/i18n/locale.ts b/packages/website/src/i18n/locale.ts index ecf8ef65a8..138877e803 100644 --- a/packages/website/src/i18n/locale.ts +++ b/packages/website/src/i18n/locale.ts @@ -12,6 +12,12 @@ export const LANGUAGE_NAMES: Record = { "zh-CN": "简体中文", }; +/** Each language abbreviated, for the closed language selector. */ +export const LANGUAGE_ABBREVIATIONS: Record = { + en: "EN", + "zh-CN": "ZH", +}; + /** `og:locale` values, which use an underscore and a region. */ export const OPEN_GRAPH_LOCALES: Record = { en: "en_US", diff --git a/packages/website/src/i18n/resources/en.json b/packages/website/src/i18n/resources/en.json index 70021a54f6..055229ca47 100644 --- a/packages/website/src/i18n/resources/en.json +++ b/packages/website/src/i18n/resources/en.json @@ -13,7 +13,12 @@ "backHome": "Go to the homepage" }, "languageSelector": { - "label": "Language" + "label": "Language", + "current": "Language: {{language}}", + "names": { + "en": "English", + "zh-CN": "Simplified Chinese" + } }, "nav": { "plugins": "Plugins", diff --git a/packages/website/src/i18n/resources/zh-CN.json b/packages/website/src/i18n/resources/zh-CN.json index 2569626e34..048d351e4d 100644 --- a/packages/website/src/i18n/resources/zh-CN.json +++ b/packages/website/src/i18n/resources/zh-CN.json @@ -13,7 +13,12 @@ "backHome": "返回首页" }, "languageSelector": { - "label": "语言" + "label": "语言", + "current": "语言:{{language}}", + "names": { + "en": "英语", + "zh-CN": "简体中文" + } }, "nav": { "plugins": "插件", @@ -322,7 +327,7 @@ "metaTitle": "Paseo – 随时随地运行 Claude Code、Codex、Copilot、OpenCode", "metaDescription": "面向 Claude Code、Codex、OpenCode、Pi 以及另外 30 多个编程 Agent 的开源应用。在你自己的机器上并行运行多个 Agent。编辑器、终端、diff、拉取请求和浏览器集于一个窗口。桌面端一次下载即可使用,iOS 和 Android 上提供完整应用。", "title": "面向 Agent 的
开发环境", - "subtitle": "同时运行多个编程 Agent,在任何机器上。
在桌前,也在手机上。" + "subtitle": "同时运行多个编程 Agent,在任何机器上。
在桌前,也在手机上。" }, "agents": { "metaTitle": "支持的 Agent – Paseo 可运行的所有编程 Agent",