Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions frontend/src/app/[locale]/settings/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>Gamification Settings</div>,
}));

describe("SettingsPage section navigation", () => {
it("exposes the default active section via aria-selected", () => {
render(<SettingsPage />);

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(<SettingsPage />);

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(<SettingsPage />);

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");
});
});
131 changes: 96 additions & 35 deletions frontend/src/app/[locale]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useState, type KeyboardEvent } from "react";
import {
User,
Wallet,
Expand Down Expand Up @@ -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 }) {
Expand Down Expand Up @@ -102,15 +110,17 @@ function Toggle({
</div>
<button
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${checked ? "bg-indigo-600" : "bg-zinc-300 dark:bg-zinc-700"
}`}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? "bg-indigo-600" : "bg-zinc-300 dark:bg-zinc-700"
}`}
role="switch"
aria-checked={checked}
aria-label={label}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${checked ? "translate-x-6" : "translate-x-1"
}`}
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
checked ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
Expand Down Expand Up @@ -220,10 +230,11 @@ function WalletSection() {
</p>
</div>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${network?.isSupported
? "bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400"
: "bg-yellow-50 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400"
}`}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
network?.isSupported
? "bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400"
: "bg-yellow-50 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400"
}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" />
{network?.isSupported ? "Supported" : "Unsupported"}
Expand Down Expand Up @@ -401,9 +412,7 @@ function NotificationsSection() {
}
/>
{phoneError && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{phoneError}
</p>
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{phoneError}</p>
)}
</div>

Expand Down Expand Up @@ -499,10 +508,11 @@ function SecuritySection() {
<div className="flex justify-between text-sm">
<span className="text-zinc-500 dark:text-zinc-400">KYC Status</span>
<span
className={`font-medium ${user?.kycVerified
? "text-green-600 dark:text-green-400"
: "text-yellow-600 dark:text-yellow-400"
}`}
className={`font-medium ${
user?.kycVerified
? "text-green-600 dark:text-green-400"
: "text-yellow-600 dark:text-yellow-400"
}`}
>
{user?.kycVerified ? "Verified" : "Not Verified"}
</span>
Expand Down Expand Up @@ -592,10 +602,11 @@ function DisplaySection() {
<button
key={opt}
onClick={() => setTheme(opt)}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${active
? "bg-indigo-600 text-white"
: "bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
}`}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
active
? "bg-indigo-600 text-white"
: "bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
}`}
>
{opt[0].toUpperCase() + opt.slice(1)}
</button>
Expand Down Expand Up @@ -634,6 +645,32 @@ export default function SettingsPage() {
const [activeSection, setActiveSection] = useState<SectionId>("profile");
const handleLogout = () => logoutUser("manual");

const activateSection = (id: SectionId) => {
setActiveSection(id);
requestAnimationFrame(() => {
document.getElementById(settingsTabId(id))?.focus();
});
};

const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, 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":
Expand Down Expand Up @@ -674,26 +711,50 @@ export default function SettingsPage() {
<div className="flex flex-col lg:flex-row gap-8">
{/* Side nav */}
<nav aria-label="Settings sections" className="lg:w-52 flex-shrink-0">
<ul className="flex flex-row lg:flex-col gap-1 overflow-x-auto lg:overflow-x-visible pb-2 lg:pb-0">
{SECTIONS.map(({ id, label, icon: Icon }) => (
<li key={id}>
<button
onClick={() => setActiveSection(id)}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium w-full transition-colors whitespace-nowrap ${activeSection === id
? "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"
<ul
role="tablist"
aria-orientation="vertical"
className="flex flex-row lg:flex-col gap-1 overflow-x-auto lg:overflow-x-visible pb-2 lg:pb-0"
>
{SECTIONS.map(({ id, label, icon: Icon }, index) => {
const isActive = activeSection === id;

return (
<li key={id} role="presentation">
<button
type="button"
role="tab"
id={settingsTabId(id)}
aria-selected={isActive}
aria-controls={settingsPanelId(id)}
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"
}`}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{label}
</button>
</li>
))}
>
<Icon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
{label}
</button>
</li>
);
})}
</ul>
</nav>

{/* Content */}
<div className="flex-1 min-w-0">{renderSection()}</div>
<div
role="tabpanel"
id={settingsPanelId(activeSection)}
aria-labelledby={settingsTabId(activeSection)}
tabIndex={0}
className="flex-1 min-w-0"
>
{renderSection()}
</div>
</div>
</main>
);
Expand Down
Loading