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"
+ }`}
>
{label}