From bd4fd893e6e0adae5af70dfc5d21d1e4b016d1d1 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:14:12 +0200 Subject: [PATCH] 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! --- .../11838-profile-accessibility-semantics.md | 1 + .../(dashboard)/dashboard/profile/page.tsx | 23 +++- src/i18n/messages/en.json | 1 + src/i18n/messages/pt-BR.json | 1 + src/i18n/messages/vi.json | 1 + .../profile-accessibility-semantics.test.tsx | 121 ++++++++++++++++++ 6 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/11838-profile-accessibility-semantics.md create mode 100644 tests/unit/ui/profile-accessibility-semantics.test.tsx diff --git a/changelog.d/fixes/11838-profile-accessibility-semantics.md b/changelog.d/fixes/11838-profile-accessibility-semantics.md new file mode 100644 index 0000000000..b34eae1033 --- /dev/null +++ b/changelog.d/fixes/11838-profile-accessibility-semantics.md @@ -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 diff --git a/src/app/(dashboard)/dashboard/profile/page.tsx b/src/app/(dashboard)/dashboard/profile/page.tsx index 082ea0bee2..4864fc8c78 100644 --- a/src/app/(dashboard)/dashboard/profile/page.tsx +++ b/src/app/(dashboard)/dashboard/profile/page.tsx @@ -140,7 +140,12 @@ export default function ProfilePage() { if (loading) { return ( -
+
{t("profileLoading")}
); @@ -172,7 +177,12 @@ export default function ProfilePage() { return (
- {error &&
{error}
} +

{t("profile")}

+ {error && ( +
+ {error} +
+ )} {/* Level & XP Card */} @@ -209,7 +219,14 @@ export default function ProfilePage() { {xpInCurrentLevel.toLocaleString()} / {xpForNext.toLocaleString()} XP
-
+
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; container: HTMLDivElement }> = []; + +function mountProfile() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push({ root, container }); + act(() => root.render()); + 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"); + }); +});