Skip to content
Merged
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
25 changes: 16 additions & 9 deletions frontend/e2e/wails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ import { expect, test } from "@playwright/test";
// Every test addresses a route directly. "/" is a decision, not a page: on a
// home without ~/.oneagent it opens onboarding, so landing there would make
// these tests depend on whichever one ran first and wrote state.
// selectOption is gone from these tests: the pickers are custom listboxes now,
// because a native select's popup is drawn by the OS and cannot be styled. The
// combobox role is unchanged, so they are still found the same way, but choosing
// takes the two steps a user takes.
test("language selection switches to English and persists", async ({ page }) => {
await page.goto("/#/overview");
await page.getByRole("combobox", { name: "语言" }).selectOption("en");
await page.getByRole("combobox", { name: "语言" }).click();
await page.getByRole("option", { name: "English" }).click();
await expect(page.getByRole("heading", { name: "Environment overview" })).toBeVisible();

await page.reload();
await expect(page.getByRole("combobox", { name: "Language" })).toHaveValue("en");
// The trigger shows the current value, which is also the check that the choice
// survived a reload rather than only re-rendering the heading.
await expect(page.getByRole("combobox", { name: "Language" })).toHaveText("English");
});

// The task centre is position: fixed at the viewport's lower-left corner, so it
Expand All @@ -31,14 +38,12 @@ test("every sidebar control at the bottom is actually clickable", async ({ page
const label = `${viewport.width}x${viewport.height}`;

const covered = await page.evaluate(() => {
const selectors = [".theme-picker select", ".language-select-wide", ".language-select-compact", ".task-center-trigger"];
const selectors = [".theme-select .select-field-trigger", ".language-select .select-field-trigger", ".task-center-trigger"];
const blocked: Array<{ selector: string; coveredBy: string }> = [];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (!element) continue;
const box = element.getBoundingClientRect();
// The sidebar swaps the wide and compact selects per breakpoint; the
// hidden one of the pair is not a failure.
if (box.width === 0 || box.height === 0) continue;
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
if (hit !== element && !element.contains(hit)) {
Expand All @@ -49,10 +54,12 @@ test("every sidebar control at the bottom is actually clickable", async ({ page
});
expect(covered, `controls covered at ${label}`).toEqual([]);

// A real pointer click, not selectOption: the regression was that the
// element stayed reachable programmatically while being unreachable by
// pointer, so dispatching events directly would have passed.
await page.locator(".language-select-wide, .language-select-compact").locator("visible=true").click({ timeout: 2000 });
// A real pointer click: the regression was that the element stayed reachable
// programmatically while being unreachable by pointer, so dispatching events
// directly would have passed. Opening also proves the list is not itself
// clipped by the sidebar or covered by the task centre.
await page.getByRole("combobox", { name: /语言|Language/ }).click({ timeout: 2000 });
await expect(page.getByRole("listbox", { name: /语言|Language/ })).toBeVisible();
await page.keyboard.press("Escape");
}
});
Expand Down
27 changes: 17 additions & 10 deletions frontend/src/components/NavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Boxes, FolderCog, Gauge, Languages, Layers3 } from "lucide-react";
import { NavLink } from "react-router-dom";

import { type TranslationKey, useI18n } from "../i18n";
import { SelectField } from "./SelectField";
import { TaskCenter } from "./TaskCenter";
import { ThemePicker } from "./ThemePicker";

Expand Down Expand Up @@ -42,18 +43,24 @@ export function NavigationSidebar() {
and language down together. The task centre is viewport-docked. */}
<ThemePicker />

<label className="language-picker">
{/* One picker, where there used to be two selects differing only in their
option text -- CSS showed one and hid the other per breakpoint. The
short labels now live in the option list, which stays readable at the
72px rail because the list is ours and is not clipped to the trigger. */}
<div className="language-picker">
<Languages size={16} aria-hidden="true" />
<span>{t("语言")}</span>
<select className="language-select-wide" value={locale} onChange={(event) => setLocale(event.target.value as "zh-CN" | "en")} aria-label={t("语言")}>
<option value="zh-CN">中文</option>
<option value="en">English</option>
</select>
<select className="language-select-compact" value={locale} onChange={(event) => setLocale(event.target.value as "zh-CN" | "en")} aria-label={t("语言")}>
<option value="zh-CN">中</option>
<option value="en">EN</option>
</select>
</label>
<SelectField
className="language-select"
label={t("语言")}
value={locale}
onChange={(next) => setLocale(next as "zh-CN" | "en")}
options={[
{ value: "zh-CN", label: "中文" },
{ value: "en", label: "English" },
]}
/>
</div>

{/* The task centre is fixed to the viewport's lower-left corner. */}
<TaskCenter />
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/components/ProviderSegment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Plus } from "lucide-react";
import { useI18n } from "../i18n";
import { byProviderCreatedAt } from "../state/ranking";
import type { ProviderId, StatusResponse } from "../types/api";
import { SelectField } from "./SelectField";

export function ProviderSegment({
value,
Expand All @@ -18,12 +19,17 @@ export function ProviderSegment({
const { t } = useI18n();
return (
<div className="provider-picker">
<label htmlFor="provider-select">{t("模型服务")}</label>
{/* A span, not a label: htmlFor only associates with form elements, and the
trigger is a button. SelectField carries the accessible name itself. */}
<span className="provider-picker-label">{t("模型服务")}</span>
<div className="provider-picker-control">
<select id="provider-select" value={value} onChange={(event) => onChange(event.target.value)}>
{byProviderCreatedAt(providers)
.map(([id, provider]) => <option key={id} value={id}>{provider.name}</option>)}
</select>
<SelectField
id="provider-select"
label={t("模型服务")}
value={value}
onChange={onChange}
options={byProviderCreatedAt(providers).map(([id, provider]) => ({ value: id, label: provider.name }))}
/>
<button className="provider-add-button" type="button" onClick={onAdd} aria-label={t("新增 Provider")} title={t("新增 Provider")}>
<Plus size={17} />
</button>
Expand Down
120 changes: 120 additions & 0 deletions frontend/src/components/SelectField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";

import { SelectField } from "./SelectField";

const OPTIONS = [
{ value: "system", label: "跟随系统" },
{ value: "light", label: "浅色" },
{ value: "dark", label: "深色" },
];

/** Controlled, like every real caller, so a commit has to round-trip through props. */
function Harness({ initial = "system" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<div>
<SelectField label="外观" value={value} options={OPTIONS} onChange={setValue} />
<button type="button">outside</button>
</div>
);
}

const trigger = () => screen.getByRole("combobox", { name: "外观" });

describe("SelectField", () => {
it("keeps the combobox role a native select had", () => {
// The three call sites were <select>, and their tests and the e2e suite find
// them by role. Replacing the element must not change how it is addressed.
render(<Harness />);
expect(trigger()).toBeTruthy();
expect(trigger().getAttribute("aria-expanded")).toBe("false");
expect(screen.queryByRole("listbox")).toBeNull();
});

it("commits a choice by pointer", async () => {
render(<Harness />);
await userEvent.click(trigger());
await userEvent.click(screen.getByRole("option", { name: "深色" }));
expect(trigger()).toHaveTextContent("深色");
// Closing is part of committing; leaving the list open would trap the next click.
expect(screen.queryByRole("listbox")).toBeNull();
});

it("opens, moves and commits by keyboard", async () => {
// The whole reason a native select is worth replacing carefully: none of this
// is free once the OS is no longer providing it.
render(<Harness />);
trigger().focus();
await userEvent.keyboard("{ArrowDown}");
expect(screen.getByRole("listbox")).toBeTruthy();
await userEvent.keyboard("{ArrowDown}{Enter}");
expect(trigger()).toHaveTextContent("浅色");
});

it("does not change the value while arrowing through the list", async () => {
// Committing on every keystroke would apply each option in passing, which for
// the theme picker means the whole app flashing through palettes.
render(<Harness />);
trigger().focus();
await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}");
expect(trigger()).toHaveTextContent("跟随系统");
await userEvent.keyboard("{Escape}");
expect(trigger()).toHaveTextContent("跟随系统");
});

it("names the active option for assistive technology", async () => {
// Focus stays on the trigger, so without aria-activedescendant a screen
// reader would announce nothing as the user arrows down.
render(<Harness />);
trigger().focus();
await userEvent.keyboard("{ArrowDown}{ArrowDown}");
const active = trigger().getAttribute("aria-activedescendant");
expect(active).toBeTruthy();
expect(document.getElementById(active!)).toHaveTextContent("浅色");
expect(trigger().getAttribute("aria-expanded")).toBe("true");
});

it("marks the current value as selected, not merely visible", async () => {
render(<Harness initial="dark" />);
await userEvent.click(trigger());
const selected = screen.getAllByRole("option").filter((option) => option.getAttribute("aria-selected") === "true");
expect(selected).toHaveLength(1);
expect(selected[0]).toHaveTextContent("深色");
});

it("closes without committing on Escape and returns focus", async () => {
render(<Harness />);
trigger().focus();
await userEvent.keyboard("{ArrowDown}{ArrowDown}{Escape}");
expect(screen.queryByRole("listbox")).toBeNull();
expect(trigger()).toHaveTextContent("跟随系统");
// Focus has to come back, or Escape strands keyboard users at the document.
expect(document.activeElement).toBe(trigger());
});

it("closes when a click lands outside", async () => {
render(<Harness />);
await userEvent.click(trigger());
await userEvent.click(screen.getByRole("button", { name: "outside" }));
expect(screen.queryByRole("listbox")).toBeNull();
});

it("jumps to an option by typing its first letters", async () => {
render(<Harness />);
trigger().focus();
await userEvent.keyboard("{ArrowDown}");
await userEvent.keyboard("浅");
const active = trigger().getAttribute("aria-activedescendant");
expect(document.getElementById(active!)).toHaveTextContent("浅色");
});

it("renders no stale value when the current one is not in the list", () => {
// A Provider can be deleted while its id is still the selected value; the
// trigger should read empty rather than invent a label or crash.
render(<SelectField label="模型服务" value="deleted-provider" options={OPTIONS} onChange={() => {}} />);
expect(screen.getByRole("combobox", { name: "模型服务" }).textContent?.trim()).toBe("");
});
});
Loading
Loading