diff --git a/panel/e2e/a11y.spec.ts b/panel/e2e/a11y.spec.ts new file mode 100644 index 0000000..2b748e3 --- /dev/null +++ b/panel/e2e/a11y.spec.ts @@ -0,0 +1,107 @@ +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 { + 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(); +}); diff --git a/panel/playwright.config.ts b/panel/playwright.config.ts index bd7a639..e30b6a0 100644 --- a/panel/playwright.config.ts +++ b/panel/playwright.config.ts @@ -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/, diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index bd1e015..41a0215 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -117,11 +117,15 @@ function UserStrip() { } function LangToggle() { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation("common"); return ( set("image", v)}> - +
- +
- + set("autostartPolicy", v as AutostartPolicy)} > - + @@ -229,7 +230,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
- {error &&

{error}

} + {/* Username */}
-
{ + it("MessageLine interrupts for an error and waits for a success", () => { + const { rerender } = render(); + expect(screen.getByRole("alert").textContent).toBe("save failed"); + expect(screen.queryByRole("status")).toBeNull(); + + rerender(); + expect(screen.getByRole("status").textContent).toBe("saved"); + expect(screen.queryByRole("alert")).toBeNull(); + + rerender(); + expect(screen.getByRole("alert").textContent).toBe("short"); + }); + + it("InlineError renders nothing until there is an error", () => { + const { rerender, container } = render(); + expect(container.innerHTML).toBe(""); + + rerender(); + expect(container.innerHTML).toBe(""); + + rerender(); + expect(screen.getByRole("alert").textContent).toBe("wrong code"); + }); + + it("FeedbackLine interrupts only for a failed player action", () => { + const { rerender, container } = render(); + expect(screen.getByRole("alert").textContent).toBe("no such player"); + + rerender(); + expect(screen.getByRole("status").textContent).toBe("whitelisted"); + expect(screen.queryByRole("alert")).toBeNull(); + + rerender(); + expect(container.innerHTML).toBe(""); + }); + + it("ErrorState reads out the reason, and only the reason", () => { + render( {}} />); + const alert = screen.getByRole("alert"); + expect(alert.textContent).toBe("backend refused"); + expect(alert.querySelector("button")).toBeNull(); + }); +}); diff --git a/panel/src/components/MessageLine.tsx b/panel/src/components/MessageLine.tsx index e723a91..c484f11 100644 --- a/panel/src/components/MessageLine.tsx +++ b/panel/src/components/MessageLine.tsx @@ -18,10 +18,15 @@ const STYLE = { const COMPACT_STYLE = "bg-destructive/10 text-destructive"; +// A failure is read out at once (alert); a success waits for the screen reader +// to finish what it is saying (status). +const ROLE = { error: "alert", success: "status" } as const; + export function MessageLine({ kind, message, compact, className }: Props) { if (compact) { return (

); } + +/** InlineError is the one-line failure a form shows under its fields, or + * nothing when there is none. */ +export function InlineError({ message, className }: { message?: string | null; className?: string }) { + if (!message) return null; + return ( +

+ {message} +

+ ); +} diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx index 30b96cc..b850d71 100644 --- a/panel/src/components/States.tsx +++ b/panel/src/components/States.tsx @@ -19,7 +19,7 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => return (
-

{humanizeError(error)}

+

{humanizeError(error)}

{onRetry && (
) : error ? ( -

{t("access_ban_load_error")}

+

{t("access_ban_load_error")}

) : players.length === 0 ? (

{t("access_ban_empty")}

diff --git a/panel/src/components/players/OnlineSection.tsx b/panel/src/components/players/OnlineSection.tsx index 12e7e55..9d46cc3 100644 --- a/panel/src/components/players/OnlineSection.tsx +++ b/panel/src/components/players/OnlineSection.tsx @@ -112,7 +112,7 @@ export function OnlineSection({ name, defaultOpen = true }: { name: string; defa {t("log_connecting")}
) : error ? ( -

{t("access_online_load_error")}

+

{t("access_online_load_error")}

) : players.length === 0 ? (
{namesUnavailable ? ( diff --git a/panel/src/components/players/WhitelistSection.tsx b/panel/src/components/players/WhitelistSection.tsx index a4c3f6f..2bf27a7 100644 --- a/panel/src/components/players/WhitelistSection.tsx +++ b/panel/src/components/players/WhitelistSection.tsx @@ -145,7 +145,7 @@ export function WhitelistSection({ name, defaultOpen = false }: { name: string; {t("log_connecting")}
) : error ? ( -

{t("access_whitelist_load_error")}

+

{t("access_whitelist_load_error")}

) : players.length === 0 ? (

{t("access_whitelist_empty")}

diff --git a/panel/src/components/players/shared.tsx b/panel/src/components/players/shared.tsx index d03f111..6a832a1 100644 --- a/panel/src/components/players/shared.tsx +++ b/panel/src/components/players/shared.tsx @@ -37,7 +37,7 @@ export function FeedbackLine({ fb }: { fb: Feedback }) { if (!fb) return null; return (

{fb.msg} diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index f3e9338..128a1d6 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -142,6 +142,8 @@ "users_search_btn": "Search", "users_filter_role_all": "All Roles", "users_filter_status_all": "All Status", + "users_filter_role": "Filter by role", + "users_filter_status": "Filter by status", "users_status_active": "Active", "users_status_disabled": "Disabled", "users_role_admin": "Admin", diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json index 2b648f4..d1e292c 100644 --- a/panel/src/i18n/resources/en-US/common.json +++ b/panel/src/i18n/resources/en-US/common.json @@ -17,6 +17,7 @@ "brand_tagline": "K8s-native Minecraft orchestration", "page_title": "Felis · Console", "toggle_theme": "Toggle theme", + "change_language": "Language", "pagination_prev": "Previous", "pagination_next": "Next", "pagination_of": "Page {{current}} of {{total}}", diff --git a/panel/src/i18n/resources/en-US/ops.json b/panel/src/i18n/resources/en-US/ops.json index e9588b9..df9224b 100644 --- a/panel/src/i18n/resources/en-US/ops.json +++ b/panel/src/i18n/resources/en-US/ops.json @@ -12,6 +12,7 @@ "fleet_distribution": "Phase distribution", "fleet_search_placeholder": "Search name, subdomain or owner…", "fleet_filter_all": "All phases", + "fleet_filter_phase": "Filter by phase", "fleet_count": "{{count}} servers", "fleet_count_filtered": "{{shown}} / {{total}} servers", "fleet_col_status": "Status", diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 102d46f..dbaa668 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -156,9 +156,12 @@ "luckperms_actions_column": "Actions", "luckperms_custom_group_placeholder": "Enter custom group name...", "luckperms_batch_players": "Player Usernames List", + "luckperms_player_lookup": "Look up a player by name", "luckperms_recent_actions": "Recent Actions History", "luckperms_no_recent_actions": "No recent actions.", "luckperms_revert": "Revert", + "luckperms_remove_group": "Remove group {{group}}", + "luckperms_remove_perm": "Remove permission {{node}}", "luckperms_reverting": "Reverting...", "luckperms_revert_success": "Action reverted successfully!", "luckperms_presets": "Presets", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 77dbeff..c0bf707 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -142,6 +142,8 @@ "users_search_btn": "搜索", "users_filter_role_all": "全部角色", "users_filter_status_all": "全部状态", + "users_filter_role": "按角色筛选", + "users_filter_status": "按状态筛选", "users_status_active": "正常", "users_status_disabled": "已禁用", "users_role_admin": "管理员", diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json index 4f240b4..2078224 100644 --- a/panel/src/i18n/resources/zh-CN/common.json +++ b/panel/src/i18n/resources/zh-CN/common.json @@ -17,6 +17,7 @@ "brand_tagline": "Kubernetes 原生的 Minecraft 管理平台", "page_title": "Felis · 控制台", "toggle_theme": "切换主题", + "change_language": "切换语言", "pagination_prev": "上一页", "pagination_next": "下一页", "pagination_of": "第 {{current}} / {{total}} 页", diff --git a/panel/src/i18n/resources/zh-CN/ops.json b/panel/src/i18n/resources/zh-CN/ops.json index c98d93c..16cdb0b 100644 --- a/panel/src/i18n/resources/zh-CN/ops.json +++ b/panel/src/i18n/resources/zh-CN/ops.json @@ -12,6 +12,7 @@ "fleet_distribution": "状态分布", "fleet_search_placeholder": "搜索名称、域名或所有者…", "fleet_filter_all": "全部状态", + "fleet_filter_phase": "按状态筛选", "fleet_count": "共 {{count}} 台", "fleet_count_filtered": "{{shown}} / {{total}} 台", "fleet_col_status": "状态", diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 9d48c9c..3b97d70 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -156,9 +156,12 @@ "luckperms_actions_column": "操作", "luckperms_custom_group_placeholder": "输入自定义组名...", "luckperms_batch_players": "玩家用户名列表", + "luckperms_player_lookup": "按名称查询玩家", "luckperms_recent_actions": "最近操作历史", "luckperms_no_recent_actions": "暂无最近操作历史。", "luckperms_revert": "撤销", + "luckperms_remove_group": "移除权限组 {{group}}", + "luckperms_remove_perm": "移除权限 {{node}}", "luckperms_reverting": "正在撤销...", "luckperms_revert_success": "已成功撤销该操作!", "luckperms_presets": "预设", diff --git a/panel/src/pages/Account.test.tsx b/panel/src/pages/Account.test.tsx index d0247e4..0303a07 100644 --- a/panel/src/pages/Account.test.tsx +++ b/panel/src/pages/Account.test.tsx @@ -119,7 +119,8 @@ describe("Account passkey delete", () => { await userEvent.click(await screen.findByRole("button", deleteButton("Laptop"))); await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") })); - expect(await within(screen.getByRole("dialog")).findByText(t("errors:last_passkey"))).toBeTruthy(); + const alert = await within(screen.getByRole("dialog")).findByRole("alert"); + expect(alert.textContent).toBe(t("errors:last_passkey")); expect(mocks.passkeyList).toHaveBeenCalledTimes(2); }); diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index 62ad8cc..299a33f 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loading, ErrorState } from "@/components/States"; import { ConfirmFooter } from "@/components/ConfirmFooter"; -import { MessageLine } from "@/components/MessageLine"; +import { MessageLine, InlineError } from "@/components/MessageLine"; import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { formatAbsolute } from "@/lib/format"; @@ -361,7 +361,7 @@ export function Account() {

)} - {emailError &&

{emailError}

} + )} @@ -405,7 +405,7 @@ export function Account() { required /> - {passkeyError &&

{passkeyError}

} +