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 (
-
+
);
@@ -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");
+ });
+});