Files
next-tjwater-frontend/tests/browser/form-focus.e2e.ts
jiang 617ba4d87c feat(ui): refine gem-blue acrylic palette and focus borders
Derive the gem-blue palette with OKLCH tonal and complementary-color
rules while preserving acrylic opacity, blur and surface hierarchy.

Darker focus tokens exposed stacked field rings, fallback outlines and
persistent basemap selection rings. Keep fallback focus styles in the
base layer and use one themed field boundary or inset card stroke.
Preserve keyboard focus, invalid states and native input semantics.

Add browser regression coverage for pointer and keyboard focus, all
basemap presentations, narrow layouts and rendered contrast.

Validation in an isolated checkout: pnpm lint, pnpm typecheck,
pnpm test (393 tests), focused browser suites and pnpm build.
2026-10-09 10:57:06 +08:00

141 lines
8.0 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { expectThemeColor } from "./support/theme-colors";
test.beforeEach(async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await mockWorkbenchContext(page);
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
});
test("Agent composer keeps one thin focus boundary without an outer blue ring", async ({ page }) => {
await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click();
const form = page.locator("form.agent-panel-control");
const textarea = page.getByPlaceholder(/输入调度问题/);
const restingShadow = await form.evaluate((element) => getComputedStyle(element).boxShadow);
expect(restingShadow).not.toBe("none");
const restingBox = await form.boundingBox();
await textarea.click();
await expectThemeColor(form, "border-color", "--action-blue-hover");
await expect(form).toHaveCSS("border-width", "1px");
await expect(form).toHaveCSS("box-shadow", restingShadow);
await expect(textarea).toHaveCSS("outline-style", "none");
expect(await form.boundingBox()).toEqual(restingBox);
await page.keyboard.press("Tab");
await textarea.focus();
await expectThemeColor(form, "border-color", "--action-blue-hover");
const focusedBox = await form.boundingBox();
await page.screenshot({
path: "test-results/form-focus/agent-focused.png",
clip: { x: focusedBox!.x - 4, y: focusedBox!.y - 4, width: focusedBox!.width + 8, height: focusedBox!.height + 8 }
});
});
test("shared fields and grouped inputs do not stack native outlines and focus rings", async ({ page }) => {
await renderFields(page);
for (const label of ["共享输入", "共享多行输入", "组合输入", "选择字段", "日期字段"]) {
const control = page.getByLabel(label, { exact: true });
await page.keyboard.press("Tab");
await control.focus();
await expect(control).toHaveCSS("outline-style", "none");
const owner = label === "组合输入" ? page.getByRole("group", { name: "组合输入区" }) : control;
await expectThemeColor(owner, "border-color", "--action-blue-hover");
await expect(owner).toHaveCSS("border-width", "1px");
const shadow = await owner.evaluate((element) => getComputedStyle(element).boxShadow);
expect(shadow).not.toMatch(/0px 0px 0px [12]px/);
}
const input = page.getByLabel("共享输入", { exact: true });
await input.evaluate((element) => element.setAttribute("aria-invalid", "true"));
await input.focus();
await expectThemeColor(input, "border-color", "--action-danger");
const groupedInput = page.getByLabel("组合输入", { exact: true });
await groupedInput.evaluate((element) => element.setAttribute("aria-invalid", "true"));
await groupedInput.focus();
await expectThemeColor(page.getByRole("group", { name: "组合输入区" }), "border-color", "--action-danger");
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/shared-fields.png" });
});
test("borderless selects and choice controls keep one theme keyboard focus indicator", async ({ page }) => {
await renderFields(page);
const borderless = page.getByRole("combobox", { name: "无边框选择" });
await page.keyboard.press("Tab");
await borderless.focus();
await expect(borderless).toHaveCSS("border-width", "0px");
await expect(borderless).toHaveCSS("outline-style", "solid");
await expect(borderless).toHaveCSS("outline-width", "1px");
await expect(borderless).toHaveCSS("outline-offset", "-1px");
await expectThemeColor(borderless, "outline-color", "--action-blue-hover");
await page.keyboard.press("Enter");
await expect(page.getByRole("option", { name: "第二个选项" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(borderless).toBeFocused();
const firstRadio = page.getByRole("radio", { name: "单选一" });
await firstRadio.focus();
await expect(firstRadio).toHaveCSS("appearance", "none");
await page.keyboard.press("ArrowRight");
await expect(page.getByRole("radio", { name: "单选二" })).toBeChecked();
const checkbox = page.getByRole("checkbox", { name: "多选" });
await checkbox.focus();
await expect(checkbox).toHaveCSS("appearance", "none");
await page.keyboard.press("Space");
await expect(checkbox).toBeChecked();
await expect(checkbox).toHaveCSS("outline-style", "none");
const shadow = await checkbox.evaluate((element) => getComputedStyle(element).boxShadow);
expect(shadow).toContain("0px 0px 0px 4px");
await page.locator("#focus-fields").screenshot({ path: "test-results/form-focus/choice-fields.png" });
});
for (const width of [320, 375]) {
test(`mobile composer owns a single inset focus stroke at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await page.getByRole("button", { name: "打开 Agent 面板" }).click();
const textarea = page.getByPlaceholder(/输入调度问题/);
await textarea.click();
const material = page.locator(".agent-panel-composer");
await expect(material).toHaveCSS("outline-width", "1px");
await expect(material).toHaveCSS("outline-offset", "-1px");
await expectThemeColor(material, "outline-color", "--action-blue-hover");
await expect(textarea).toHaveCSS("outline-style", "none");
await page.screenshot({ path: `test-results/form-focus/mobile-${width}.png` });
});
}
async function renderFields(page: Page) {
await page.evaluate(async () => {
// Load the production components through Vite, so the test exercises their real CSS.
const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js",
"/src/shared/ui/input.tsx", "/src/shared/ui/textarea.tsx", "/src/shared/ui/input-group.tsx",
"/src/shared/ui/field-select.tsx", "/src/shared/ui/date-time-field.tsx", "/src/shared/ui/select.tsx",
"/src/shared/ui/checkbox.tsx", "/src/shared/ui/radio.tsx", "/src/shared/ai-elements/prompt-input.tsx"];
const [react, dom, inputs, textareas, groups, selects, dates, primitives, checkboxes, radios, prompts] = await Promise.all(paths.map((path) => import(path)));
const h = react.default.createElement;
const container = document.createElement("div");
container.id = "focus-fields";
container.style.cssText = "position:fixed;z-index:40;top:80px;left:80px;width:360px;padding:24px;background:var(--surface-reading);display:grid;gap:16px";
document.body.append(container);
dom.default.createRoot(container).render(h(react.default.Fragment, null,
h(inputs.Input, { "aria-label": "共享输入", placeholder: "共享输入" }),
h(textareas.Textarea, { "aria-label": "共享多行输入", placeholder: "共享多行输入" }),
h(groups.InputGroup, { "aria-label": "组合输入区" }, h(groups.InputGroupInput, { "aria-label": "组合输入", placeholder: "组合输入" })),
h(selects.FieldSelect, { label: "选择字段", value: "first", onValueChange: () => {}, options: [{ value: "first", label: "第一个选项" }, { value: "second", label: "第二个选项" }] }),
h(dates.DateTimeField, { label: "日期字段", value: "2026-10-09T09:00", onValueChange: () => {} }),
h(primitives.Select, { defaultValue: "first" },
h(prompts.PromptInputSelectTrigger, { "aria-label": "无边框选择" }, h(primitives.SelectValue)),
h(primitives.SelectContent, null,
h(primitives.SelectItem, { value: "first" }, "第一个选项"),
h(primitives.SelectItem, { value: "second" }, "第二个选项")
)
),
h("div", { style: { display: "flex", gap: "16px" } },
h("label", null, h(radios.Radio, { name: "focus-radio", defaultChecked: true }), "单选一"),
h("label", null, h(radios.Radio, { name: "focus-radio" }), "单选二"),
h("label", null, h(checkboxes.Checkbox), "多选")
)
));
});
await expect(page.getByLabel("共享输入", { exact: true })).toBeVisible();
}