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",