mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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.
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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<HTMLSelectElement>) => {
|
||||
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<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [openFocus, setOpenFocus] = useState<OpenFocus | null>(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<HTMLButtonElement>) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpenFocus(event.key === "ArrowDown" ? "first" : "last");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onMenuKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
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 (
|
||||
<label className="relative inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<span className="sr-only">{t("languageSelector.label")}</span>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={onChange}
|
||||
className="appearance-none bg-transparent pr-1 cursor-pointer focus:outline-none focus-visible:underline"
|
||||
<div ref={rootRef} className="relative inline-flex">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={t("languageSelector.current", { language: LANGUAGE_NAMES[locale] })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
onClick={toggle}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
className={`-mx-1.5 inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${open ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{LOCALES.map((option) => (
|
||||
<option
|
||||
key={option}
|
||||
value={option}
|
||||
lang={option}
|
||||
className="bg-background text-foreground"
|
||||
<Languages size={16} aria-hidden="true" />
|
||||
<span className="font-medium tracking-wide">{LANGUAGE_ABBREVIATIONS[locale]}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
id={menuId}
|
||||
role="menu"
|
||||
aria-label={t("languageSelector.label")}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
initial={MENU_HIDDEN}
|
||||
animate={MENU_SHOWN}
|
||||
exit={MENU_HIDDEN}
|
||||
transition={MENU_TRANSITION}
|
||||
className="absolute right-0 top-full z-50 mt-2 flex min-w-44 flex-col gap-0.5 rounded-lg border border-border bg-card p-1 shadow-xl shadow-black/40"
|
||||
>
|
||||
{LANGUAGE_NAMES[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{LOCALES.map((option) => (
|
||||
<LanguageOption
|
||||
key={option}
|
||||
language={option}
|
||||
href={`${localizePath(path, option)}${suffix}`}
|
||||
checked={option === locale}
|
||||
onChoose={choose}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<HTMLAnchorElement>) => {
|
||||
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<HTMLAnchorElement>) => event.currentTarget.focus(),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
hrefLang={language}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
tabIndex={-1}
|
||||
onClick={onClick}
|
||||
onMouseEnter={focusOnHover}
|
||||
className={`flex items-center gap-3 whitespace-nowrap rounded-md px-2.5 py-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-foreground ${checked ? "text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<span className="flex flex-col">
|
||||
<span lang={language}>{LANGUAGE_NAMES[language]}</span>
|
||||
{localName !== LANGUAGE_NAMES[language] && (
|
||||
<span className="text-xs text-extra-muted-foreground">{localName}</span>
|
||||
)}
|
||||
</span>
|
||||
<Check
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className={`ml-auto shrink-0 ${checked ? "" : "invisible"}`}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function menuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return menu ? [...menu.querySelectorAll<HTMLElement>(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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
LANGUAGE_ABBREVIATIONS,
|
||||
LANGUAGE_NAMES,
|
||||
OPEN_GRAPH_LOCALES,
|
||||
LOCALES,
|
||||
|
||||
@@ -12,6 +12,12 @@ export const LANGUAGE_NAMES: Record<Locale, string> = {
|
||||
"zh-CN": "简体中文",
|
||||
};
|
||||
|
||||
/** Each language abbreviated, for the closed language selector. */
|
||||
export const LANGUAGE_ABBREVIATIONS: Record<Locale, string> = {
|
||||
en: "EN",
|
||||
"zh-CN": "ZH",
|
||||
};
|
||||
|
||||
/** `og:locale` values, which use an underscore and a region. */
|
||||
export const OPEN_GRAPH_LOCALES: Record<Locale, string> = {
|
||||
en: "en_US",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 的<br/>开发环境",
|
||||
"subtitle": "同时运行多个编程 Agent,<mobileBreak/><wideSpace/>在任何机器上。<br/>在桌前,也在手机上。"
|
||||
"subtitle": "同时运行多个编程 Agent,<mobileBreak/>在任何机器上。<br/>在桌前,也在手机上。"
|
||||
},
|
||||
"agents": {
|
||||
"metaTitle": "支持的 Agent – Paseo 可运行的所有编程 Agent",
|
||||
|
||||
Reference in New Issue
Block a user