mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
fix(profile): expose accessible status and progress (#11838)
Exposes Profile loading/terminal-error states and exact clamped XP progressbar semantics to assistive technologies, plus a responsive page heading that doesn't duplicate the desktop Dashboard heading. 6/6 focused a11y tests passing. Thanks!
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** Expose Profile loading, errors, page structure, and XP progress to assistive technologies ([#11838](https://github.com/diegosouzapw/OmniRoute/pull/11838)) — thanks @pacocartones
|
||||
@@ -140,7 +140,12 @@ export default function ProfilePage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
className="flex items-center justify-center min-h-[400px]"
|
||||
>
|
||||
<div className="text-text-muted">{t("profileLoading")}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -172,7 +177,12 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{error && <div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">{error}</div>}
|
||||
<h1 className="sr-only lg:hidden">{t("profile")}</h1>
|
||||
{error && (
|
||||
<div role="alert" className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level & XP Card */}
|
||||
<Card>
|
||||
@@ -209,7 +219,14 @@ export default function ProfilePage() {
|
||||
{xpInCurrentLevel.toLocaleString()} / {xpForNext.toLocaleString()} XP
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-3 rounded-full bg-border overflow-hidden">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={tg("levelProgress", { current: level, next: level + 1 })}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={xpForNext}
|
||||
aria-valuenow={Math.min(Math.max(xpInCurrentLevel, 0), xpForNext)}
|
||||
className="w-full h-3 rounded-full bg-border overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 transition-all duration-500"
|
||||
style={{ width: `${Math.min(xpProgress, 100)}%` }}
|
||||
|
||||
@@ -770,6 +770,7 @@
|
||||
"batchListDeleteAllCompletedTitle": "Delete all completed batches",
|
||||
"batchListBatchesTable": "Batches",
|
||||
"changelogViewerLoading": "Loading changelog from GitHub...",
|
||||
"profile": "Profile",
|
||||
"profileLoading": "Loading profile...",
|
||||
"profileHowToEarn": "How to earn",
|
||||
"bootstrapBannerDismiss": "Dismiss",
|
||||
|
||||
@@ -770,6 +770,7 @@
|
||||
"batchListDeleteAllCompletedTitle": "Excluir todos os lotes concluídos",
|
||||
"batchListBatchesTable": "Lotes",
|
||||
"changelogViewerLoading": "Carregando changelog do GitHub...",
|
||||
"profile": "Perfil",
|
||||
"profileLoading": "Carregando perfil...",
|
||||
"profileHowToEarn": "Como ganhar",
|
||||
"bootstrapBannerDismiss": "Dispensar",
|
||||
|
||||
@@ -770,6 +770,7 @@
|
||||
"batchListDeleteAllCompletedTitle": "Xóa tất cả các lô đã hoàn thành",
|
||||
"batchListBatchesTable": "Lô",
|
||||
"changelogViewerLoading": "Đang tải nhật ký thay đổi từ GitHub...",
|
||||
"profile": "Hồ sơ",
|
||||
"profileLoading": "Đang tải hồ sơ...",
|
||||
"profileHowToEarn": "Cách kiếm điểm",
|
||||
"bootstrapBannerDismiss": "Bỏ qua",
|
||||
|
||||
121
tests/unit/ui/profile-accessibility-semantics.test.tsx
Normal file
121
tests/unit/ui/profile-accessibility-semantics.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const translate = (key: string) => key;
|
||||
vi.mock("next-intl", () => ({
|
||||
useLocale: () => "en",
|
||||
useTranslations: () => Object.assign(translate, { has: () => false }),
|
||||
}));
|
||||
|
||||
const { default: ProfilePage } = await import("@/app/(dashboard)/dashboard/profile/page");
|
||||
|
||||
const roots: Array<{ root: ReturnType<typeof createRoot>; container: HTMLDivElement }> = [];
|
||||
|
||||
function mountProfile() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push({ root, container });
|
||||
act(() => root.render(<ProfilePage />));
|
||||
return container;
|
||||
}
|
||||
|
||||
async function waitForLoad(container: HTMLDivElement) {
|
||||
for (let i = 0; i < 40 && container.querySelector('[role="status"]'); i++) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, container } of roots.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Profile accessibility semantics", () => {
|
||||
it("announces the loading state as a busy polite status", () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => new Promise(() => undefined))
|
||||
);
|
||||
|
||||
const container = mountProfile();
|
||||
const status = container.querySelector('[role="status"]');
|
||||
|
||||
expect(status?.textContent).toContain("profileLoading");
|
||||
expect(status?.getAttribute("aria-live")).toBe("polite");
|
||||
expect(status?.getAttribute("aria-busy")).toBe("true");
|
||||
});
|
||||
|
||||
it("exposes the page heading and XP progress value", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/level")) {
|
||||
return { ok: true, json: async () => ({ level: { totalXp: 150, currentLevel: 2 } }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({ badges: [] }) };
|
||||
})
|
||||
);
|
||||
|
||||
const container = mountProfile();
|
||||
await waitForLoad(container);
|
||||
|
||||
const heading = container.querySelector("h1");
|
||||
expect(heading?.textContent).toBe("profile");
|
||||
expect(heading?.classList.contains("lg:hidden")).toBe(true);
|
||||
const progress = container.querySelector('[role="progressbar"]');
|
||||
expect(progress?.getAttribute("aria-label")).toBe("levelProgress");
|
||||
expect(progress?.getAttribute("aria-valuemin")).toBe("0");
|
||||
expect(progress?.getAttribute("aria-valuemax")).toBe("519");
|
||||
expect(progress?.getAttribute("aria-valuenow")).toBe("0");
|
||||
});
|
||||
|
||||
it("clamps XP progress above the current level range", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/level")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ level: { totalXp: 10_000, currentLevel: 2 } }),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({ badges: [] }) };
|
||||
})
|
||||
);
|
||||
|
||||
const container = mountProfile();
|
||||
await waitForLoad(container);
|
||||
|
||||
const progress = container.querySelector('[role="progressbar"]');
|
||||
expect(progress?.getAttribute("aria-valuemax")).toBe("519");
|
||||
expect(progress?.getAttribute("aria-valuenow")).toBe("519");
|
||||
});
|
||||
|
||||
it("announces a complete load failure as an alert", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({ ok: false }))
|
||||
);
|
||||
|
||||
const container = mountProfile();
|
||||
await waitForLoad(container);
|
||||
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("profileLoadFailed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user