From 2526dcab6542a5cac8c35c0e67c6607478fb1d3c Mon Sep 17 00:00:00 2001 From: kingbitnation Date: Sun, 28 Jun 2026 14:14:11 +0100 Subject: [PATCH 1/2] fix(frontend): expose active settings section to assistive tech Add tablist/tab/tabpanel semantics with aria-selected and aria-controls so screen readers can identify the active panel. Keyboard focus stays on the active tab when switching sections, with arrow-key navigation between tabs. Assert active state via accessible attributes in unit tests. Closes #1231 --- .../src/app/[locale]/settings/page.test.tsx | 82 +++++++++++++++++ frontend/src/app/[locale]/settings/page.tsx | 91 +++++++++++++++---- 2 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 frontend/src/app/[locale]/settings/page.test.tsx diff --git a/frontend/src/app/[locale]/settings/page.test.tsx b/frontend/src/app/[locale]/settings/page.test.tsx new file mode 100644 index 00000000..7cc6e680 --- /dev/null +++ b/frontend/src/app/[locale]/settings/page.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import SettingsPage from "./page"; + +jest.mock("../../lib/session", () => ({ + logoutUser: jest.fn(), +})); + +jest.mock("../../hooks/useLogout", () => ({ + useLogout: () => ({ logout: jest.fn() }), +})); + +jest.mock("../../stores/useUserStore", () => ({ + useUserStore: jest.fn((selector) => + selector({ + user: { id: "user1", email: "test@example.com" }, + }), + ), + selectUser: (state: { user: { id: string; email: string } }) => state.user, +})); + +jest.mock("../../stores/useWalletStore", () => ({ + useWalletStore: jest.fn((selector) => + selector({ + address: null, + network: "testnet", + disconnect: jest.fn(), + }), + ), + selectWalletAddress: (state: { address: string | null }) => state.address, + selectWalletNetwork: (state: { network: string }) => state.network, +})); + +jest.mock("../../stores/useThemeStore", () => ({ + useThemeStore: jest.fn(() => ({ + theme: "system", + setTheme: jest.fn(), + })), +})); + +jest.mock("../../hooks/useApi", () => ({ + useNotificationPreferences: () => ({ data: undefined, isLoading: false, error: null }), + useUpdateNotificationPreferences: () => ({ mutate: jest.fn(), isPending: false }), +})); + +jest.mock("../../components/gamification/GamificationSettings", () => ({ + GamificationSettings: () =>
Gamification Settings
, +})); + +describe("SettingsPage section navigation", () => { + it("exposes the default active section via aria-selected", () => { + render(); + + expect(screen.getByRole("tab", { name: "Profile" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Wallet" })).toHaveAttribute("aria-selected", "false"); + }); + + it("updates accessible state and focus when switching sections", async () => { + const user = userEvent.setup(); + render(); + + const walletTab = screen.getByRole("tab", { name: "Wallet" }); + await user.click(walletTab); + + expect(walletTab).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Profile" })).toHaveAttribute("aria-selected", "false"); + expect(document.activeElement).toBe(walletTab); + }); + + it("links each tab to its panel with aria-controls and tabpanel semantics", () => { + render(); + + const profileTab = screen.getByRole("tab", { name: "Profile" }); + const panelId = profileTab.getAttribute("aria-controls"); + + expect(panelId).toBe("settings-panel-profile"); + + const panel = screen.getByRole("tabpanel"); + expect(panel).toHaveAttribute("id", panelId); + expect(panel).toHaveAttribute("aria-labelledby", "settings-tab-profile"); + }); +}); diff --git a/frontend/src/app/[locale]/settings/page.tsx b/frontend/src/app/[locale]/settings/page.tsx index b4926b38..c0dcce73 100644 --- a/frontend/src/app/[locale]/settings/page.tsx +++ b/frontend/src/app/[locale]/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type KeyboardEvent } from "react"; import { User, Wallet, @@ -55,6 +55,14 @@ const SECTIONS = [ type SectionId = (typeof SECTIONS)[number]["id"]; +function settingsTabId(id: SectionId) { + return `settings-tab-${id}`; +} + +function settingsPanelId(id: SectionId) { + return `settings-panel-${id}`; +} + // ─── Copy-to-clipboard helper ───────────────────────────────────────────────── function CopyButton({ value }: { value: string }) { @@ -634,6 +642,32 @@ export default function SettingsPage() { const [activeSection, setActiveSection] = useState("profile"); const handleLogout = () => logoutUser("manual"); + const activateSection = (id: SectionId) => { + setActiveSection(id); + requestAnimationFrame(() => { + document.getElementById(settingsTabId(id))?.focus(); + }); + }; + + const handleTabKeyDown = (event: KeyboardEvent, index: number) => { + let nextIndex: number | null = null; + + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + nextIndex = (index + 1) % SECTIONS.length; + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + nextIndex = (index - 1 + SECTIONS.length) % SECTIONS.length; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = SECTIONS.length - 1; + } + + if (nextIndex === null) return; + + event.preventDefault(); + activateSection(SECTIONS[nextIndex].id); + }; + const renderSection = () => { switch (activeSection) { case "profile": @@ -674,26 +708,49 @@ export default function SettingsPage() {
{/* Side nav */} {/* Content */} -
{renderSection()}
+
+ {renderSection()} +
); From 03057ff81da54c1823ae5140ee1b95b83467abe6 Mon Sep 17 00:00:00 2001 From: kingbitnation Date: Sun, 28 Jun 2026 14:29:26 +0100 Subject: [PATCH 2/2] style(frontend): format settings page for prettier --- frontend/src/app/[locale]/settings/page.tsx | 50 +++++++++++---------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/[locale]/settings/page.tsx b/frontend/src/app/[locale]/settings/page.tsx index c0dcce73..57d6c47b 100644 --- a/frontend/src/app/[locale]/settings/page.tsx +++ b/frontend/src/app/[locale]/settings/page.tsx @@ -110,15 +110,17 @@ function Toggle({ @@ -228,10 +230,11 @@ function WalletSection() {

{network?.isSupported ? "Supported" : "Unsupported"} @@ -409,9 +412,7 @@ function NotificationsSection() { } /> {phoneError && ( -

- {phoneError} -

+

{phoneError}

)} @@ -507,10 +508,11 @@ function SecuritySection() {
KYC Status {user?.kycVerified ? "Verified" : "Not Verified"} @@ -600,10 +602,11 @@ function DisplaySection() { @@ -727,10 +730,11 @@ export default function SettingsPage() { tabIndex={isActive ? 0 : -1} onClick={() => activateSection(id)} onKeyDown={(event) => handleTabKeyDown(event, index)} - className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium w-full transition-colors whitespace-nowrap ${isActive - ? "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400" - : "text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-50" - }`} + className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium w-full transition-colors whitespace-nowrap ${ + isActive + ? "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400" + : "text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-50" + }`} >