Unverified Commit 6c3421fe authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 错误提示改为读屏即时播报,图标按钮、筛选下拉与表单字段补齐可读名称,上传区可用键盘选择文件,并加全页面无障碍巡检

parent 1a8cccf2
Loading
Loading
Loading
Loading

panel/e2e/a11y.spec.ts

0 → 100644
+107 −0
Changes for panel/e2e/a11y.spec.ts: 107 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Page } from "@playwright/test";
import { test, expect, t } from "./fixtures";

// Every control a screen reader can land on must say what it does. The sweep
// reads each page's accessibility tree (what assistive tech gets) and fails on
// a button, link, field or toggle whose accessible name is empty, which is
// what an icon-only button without aria-label turns into.
const CONTROL_ROLES = ["button", "link", "combobox", "checkbox", "switch", "textbox", "menuitem", "tab", "slider"];
// A named control reads `- button "Save"`; an unnamed one has nothing quoted
// after the role, only [state] flags and maybe its value (`- combobox: Presets`).
const UNNAMED = new RegExp(`^\\s*- (${CONTROL_ROLES.join("|")})(?: \\[[^\\]]*\\])*(?::.*)?$`);

async function unnamedControls(page: Page): Promise<string[]> {
  const tree = await page.locator("body").ariaSnapshot();
  const lines = tree.split("\n");
  // Each offender is reported with the lines under it, so the failure shows
  // which icon or field it was.
  return lines.flatMap((line, i) => (UNNAMED.test(line) ? [lines.slice(i, i + 3).join("\n")] : []));
}

for (const [account, path] of [
  ["linked", "/"],
  ["linked", "/servers"],
  ["linked", "/servers/lobby"],
  ["linked", "/servers/lobby/players"],
  ["linked", "/servers/lobby/backups"],
  ["linked", "/servers/lobby/files"],
  ["linked", "/servers/lobby/luckperms"],
  ["linked", "/submissions"],
  ["linked", "/account"],
  ["owner", "/"],
  ["owner", "/servers"],
  ["owner", "/admin/images"],
  ["owner", "/admin/builds"],
  ["owner", "/admin/submissions"],
  ["owner", "/admin/updates"],
  ["owner", "/admin/users"],
  ["owner", "/account"],
] as const) {
  test(`every control on ${path} has a name for ${account}`, async ({ page, signIn }) => {
    await signIn(account);
    await page.goto(path);
    await page.waitForLoadState("networkidle");

    expect(await unnamedControls(page)).toEqual([]);
  });
}

// Dialogs only exist once opened, so each is opened and swept on its own.
for (const [account, path, opener] of [
  ["owner", "/servers", "servers:create_server_btn"],
  ["owner", "/servers/lobby", "servers:edit_server_title"],
  ["owner", "/admin/users", "admin:users_create_btn"],
  ["owner", "/admin/images", "admin:add_image_btn"],
  ["owner", "/admin/builds", "admin:trigger_build_title"],
  ["linked", "/submissions", "submissions:submit_card_title"],
] as const) {
  test(`every control in the ${opener} dialog has a name`, async ({ page, signIn }) => {
    await signIn(account);
    await page.goto(path);
    await page.getByRole("button", { name: t(opener) }).first().click();
    await expect(page.getByRole("dialog")).toBeVisible();
    await page.waitForLoadState("networkidle");

    expect(await unnamedControls(page)).toEqual([]);
  });
}

test("every control on a user's detail page has a name", async ({ page, signIn }) => {
  await signIn("owner");
  await page.goto("/admin/users");
  await page.getByRole("link", { name: "linked", exact: true }).click();
  await expect(page.getByRole("heading", { name: "linked", exact: true })).toBeVisible();
  await page.waitForLoadState("networkidle");

  expect(await unnamedControls(page)).toEqual([]);
});

test("every control on a player's LuckPerms groups and nodes has a name", async ({ page, signIn }) => {
  await signIn("owner");
  await page.goto("/servers/survival/luckperms");
  await page.getByRole("textbox", { name: t("servers:luckperms_player_lookup") }).fill("Steve");
  await page.keyboard.press("Enter");
  await expect(page.getByRole("cell", { name: "essentials.fly", exact: true })).toBeVisible();

  expect(await unnamedControls(page)).toEqual([]);
});

test("every control on the sign-in page has a name", async ({ page }) => {
  await page.goto("/login");
  await page.waitForLoadState("networkidle");

  expect(await unnamedControls(page)).toEqual([]);
});

test("the submission's file picker is reachable from the keyboard", async ({ page, signIn }) => {
  await signIn("linked");
  await page.goto("/submissions");
  await page.getByRole("button", { name: t("submissions:submit_card_title") }).first().click();

  const picker = page.getByLabel(t("submissions:file_label"));
  await page.getByLabel(t("submissions:display_name_label")).focus();
  for (let i = 0; i < 10 && !(await picker.evaluate((el) => el === document.activeElement)); i++) {
    await page.keyboard.press("Tab");
  }
  await expect(picker).toBeFocused();
});
+1 −1
Changes for panel/playwright.config.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -22,7 +22,7 @@ export default defineConfig({
    trace: "retain-on-failure",
  },
  projects: [
    { name: "desktop", testMatch: /smoke\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } },
    { name: "desktop", testMatch: /(smoke|a11y)\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } },
    {
      name: "mobile",
      testMatch: /mobile\.spec\.ts/,
+6 −2
Changes for panel/src/components/AppShell.tsx: 6 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -117,11 +117,15 @@ function UserStrip() {
}

function LangToggle() {
  const { i18n } = useTranslation();
  const { t, i18n } = useTranslation("common");

  return (
    <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
      <SelectPrimitive.Trigger className={FOOT_ICON_BTN}>
      <SelectPrimitive.Trigger
        className={FOOT_ICON_BTN}
        aria-label={t("change_language")}
        title={t("change_language")}
      >
        <Globe className="h-4 w-4" />
      </SelectPrimitive.Trigger>
      <SelectContent align="start" className="min-w-[6rem]">
+10 −9
Changes for panel/src/components/CreateServerDialog.tsx: 10 added lines, 9 removed lines.
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
import { InlineError } from "@/components/MessageLine";

// CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every
// dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities)
@@ -154,9 +155,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
          </div>

          <div className="grid gap-2">
            <Label>{t("create_server_image")}</Label>
            <Label htmlFor="create-server-image">{t("create_server_image")}</Label>
            <Select value={form.image} onValueChange={(v) => set("image", v)}>
              <SelectTrigger>
              <SelectTrigger id="create-server-image">
                <SelectValue
                  placeholder={
                    images.loading
@@ -179,9 +180,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {

          <div className="grid grid-cols-2 gap-4">
            <div className="grid gap-2">
              <Label>{t("create_server_memory")}</Label>
              <Label htmlFor="create-server-memory">{t("create_server_memory")}</Label>
              <Select value={form.memory} onValueChange={(v) => set("memory", v)}>
                <SelectTrigger>
                <SelectTrigger id="create-server-memory">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
@@ -194,9 +195,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
              </Select>
            </div>
            <div className="grid gap-2">
              <Label>{t("create_server_storage")}</Label>
              <Label htmlFor="create-server-storage">{t("create_server_storage")}</Label>
              <Select value={form.storage} onValueChange={(v) => set("storage", v)}>
                <SelectTrigger>
                <SelectTrigger id="create-server-storage">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
@@ -211,12 +212,12 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
          </div>

          <div className="grid gap-2">
            <Label>{t("create_server_policy")}</Label>
            <Label htmlFor="create-server-policy">{t("create_server_policy")}</Label>
            <Select
              value={form.autostartPolicy}
              onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
            >
              <SelectTrigger>
              <SelectTrigger id="create-server-policy">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
@@ -229,7 +230,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
            </Select>
          </div>

          {error && <p className="text-sm text-destructive">{error}</p>}
          <InlineError message={error} />
        </div>

        <ConfirmFooter
+6 −4
Changes for panel/src/components/CreateUserDialog.tsx: 6 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -95,10 +95,11 @@ export function CreateUserDialog({ onCreated }: Props) {
        <form onSubmit={handleSubmit} className="space-y-4">
          {/* Username */}
          <div className="space-y-1.5">
            <Label className="text-xs font-semibold text-muted-foreground">
            <Label htmlFor="create-user-username" className="text-xs font-semibold text-muted-foreground">
              {t("users_field_username")} *
            </Label>
            <Input
              id="create-user-username"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              placeholder={t("users_create_username_placeholder")}
@@ -109,10 +110,11 @@ export function CreateUserDialog({ onCreated }: Props) {

          {/* Email */}
          <div className="space-y-1.5">
            <Label className="text-xs font-semibold text-muted-foreground">
            <Label htmlFor="create-user-email" className="text-xs font-semibold text-muted-foreground">
              {t("users_field_email")}
            </Label>
            <Input
              id="create-user-email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
@@ -123,11 +125,11 @@ export function CreateUserDialog({ onCreated }: Props) {

          {/* Role */}
          <div className="space-y-1.5">
            <Label className="text-xs font-semibold text-muted-foreground">
            <Label htmlFor="create-user-role" className="text-xs font-semibold text-muted-foreground">
              {t("users_field_role")}
            </Label>
            <Select value={role} onValueChange={(v: "user" | "admin") => setRole(v)}>
              <SelectTrigger className="h-9 text-sm">
              <SelectTrigger id="create-user-role" className="h-9 text-sm">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
Loading