Skip to content

Commit 63ef2f9

Browse files
committed
feat(NEWS-17): Add dark mode toggle to profile page
- Create ThemeToggle component with sun/moon icons - Set up ThemeProvider in main.tsx with system preference support - Integrate theme toggle into ProfileView in settings section - Add comprehensive unit tests for ThemeToggle component - Update ProfileView tests to include theme settings section - Update documentation with theme support details - Theme preference persists across browser sessions using localStorage - Smooth transitions between light and dark themes - Full accessibility support with ARIA labels and keyboard navigation Resolves NEWS-17
1 parent aa6edd7 commit 63ef2f9

File tree

13 files changed

+788
-4
lines changed

13 files changed

+788
-4
lines changed

.claude/commands/develop-us.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Follow these steps:
55
1. Use Jira MCP to get the ticket details, whether it is the ticket id/number, keywords referring to the ticket or indicating status, like "the one in progress"
66
2. Understand the problem described in the ticket
77
3. Search the codebase for relevant files
8-
4. Implement the necessary changes to solve the ticket
8+
4. Start a new branch using the ID of the ticket (for example NEWS-1)
9+
4. Implement the necessary changes to solve the ticket
910
5. Write and run tests to verify the solution
1011
6. Ensure code passes linting and type checking
1112
7. Create a descriptive commit message

.cursor/commands/plan-backend-ticket.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Obtain a step-by-step plan for a Jira ticket that is ready to start implementing
1616
2. Propose a step-by-step plan for the backend part, taking into account everything mentioned in the ticket and applying the project’s best practices and rules.
1717
3. Apply the best practices of your role to ensure the developer can be fully autonomous and implement the ticket end-to-end using only your plan.
1818
4. Do not write code yet; provide only the plan in the output format defined below.
19+
5. If you are asked to start implementing at some point, make sure the first thing you do is to move to a branch named after the ticket id (if you are not yet there)
1920

2021
# Output format
2122

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ frontend/
8989
- **State Management**: React Context + React Query (TanStack Query)
9090
- **Routing**: React Router v7
9191
- **Form Validation**: Zod schemas
92+
- **Theme**: next-themes for dark/light mode with system preference support
9293
- **Testing**: Vitest + React Testing Library
9394

9495
## 📦 Installation

backend/.env.backup

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
MONGODB_URL=mongodb://admin:admin123@localhost:27017/react_fastapi_db?authSource=admin
2+
DATABASE_NAME=react_fastapi_db
3+
SECRET_KEY=dev-secret-key-change-in-production-12345678901234567890
4+
ALGORITHM=HS256
5+
ACCESS_TOKEN_EXPIRE_MINUTES=30

frontend/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ A modern React TypeScript frontend for the News Management system, built with fe
2626

2727
### User Interface
2828
- **Dark/Light Mode**: Theme switching with system preference detection
29+
- Toggle available in the profile page
30+
- Persists preference across browser sessions
31+
- Automatically respects system theme by default
32+
- Smooth transitions between themes
2933
- **Responsive Design**: Mobile-first approach with desktop enhancements
3034
- **Accessibility**: ARIA labels, keyboard navigation, screen reader support
3135
- **Loading States**: Skeleton loaders and loading indicators
@@ -179,6 +183,33 @@ npm run test:coverage
179183
npm run test:ui
180184
```
181185

186+
## Theme Support
187+
188+
The application includes a complete dark mode implementation using `next-themes`.
189+
190+
### Features
191+
- **Theme Toggle**: Available in the user profile page
192+
- **System Preference**: Automatically detects and respects OS theme settings
193+
- **Persistence**: Theme preference is saved to localStorage
194+
- **Smooth Transitions**: CSS transitions for a polished experience
195+
- **Accessibility**: Proper ARIA labels and keyboard navigation
196+
197+
### Usage
198+
199+
The theme toggle is integrated into the profile page. Users can:
200+
1. Navigate to `/profile`
201+
2. Use the theme toggle switch in the settings section
202+
3. Choose between light and dark modes
203+
204+
The theme preference is stored using the localStorage key `theme` and persists across sessions.
205+
206+
### Implementation Details
207+
208+
- **ThemeProvider**: Wraps the entire app in `main.tsx`
209+
- **ThemeToggle Component**: Reusable component at `src/core/components/ThemeToggle.tsx`
210+
- **CSS Variables**: Theme colors defined in `src/index.css`
211+
- **Tailwind Configuration**: Dark mode enabled with `class` strategy
212+
182213
## Configuration
183214

184215
### Environment Variables

frontend/src/components/__tests__/DashboardHeader.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,23 @@ vi.mock('@/features/auth/hooks/useAuthContext', () => ({
1212
useAuthContext: () => mockUseAuthContext()
1313
}))
1414

15+
// Mock react-router-dom
16+
vi.mock('react-router-dom', () => ({
17+
Link: ({ to, children, ...props }: any) => (
18+
<a href={to} {...props}>{children}</a>
19+
)
20+
}))
21+
1522
// Mock Lucide React icons
1623
vi.mock('lucide-react', () => ({
1724
LogOut: ({ className }: { className?: string }) => (
1825
<span data-testid="logout-icon" className={className}>LogOut</span>
1926
),
2027
User: ({ className }: { className?: string }) => (
2128
<span data-testid="user-icon" className={className}>User</span>
29+
),
30+
Settings: ({ className }: { className?: string }) => (
31+
<span data-testid="settings-icon" className={className}>Settings</span>
2232
)
2333
}))
2434

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { useTheme } from 'next-themes';
3+
import { Moon, Sun } from 'lucide-react';
4+
import { Switch } from '../../components/ui/switch';
5+
6+
/**
7+
* ThemeToggle Component
8+
*
9+
* A reusable component that provides a toggle switch for switching between
10+
* light and dark themes. Uses next-themes for theme management and persists
11+
* the user's preference in localStorage.
12+
*
13+
* Features:
14+
* - Visual feedback with Sun/Moon icons
15+
* - Smooth transitions between themes
16+
* - Accessibility support (keyboard navigation, ARIA labels)
17+
* - Respects system theme preference by default
18+
*
19+
* @returns {JSX.Element} The theme toggle switch component
20+
*/
21+
export function ThemeToggle(): JSX.Element {
22+
const [mounted, setMounted] = useState(false);
23+
const { setTheme, resolvedTheme } = useTheme();
24+
25+
// useEffect only runs on the client, so we can safely show the UI
26+
useEffect(() => {
27+
setMounted(true);
28+
}, []);
29+
30+
// Prevent hydration mismatch by not rendering until mounted
31+
if (!mounted) {
32+
return (
33+
<div className="flex items-center gap-2">
34+
<Sun className="h-4 w-4 text-gray-400" />
35+
<Switch disabled aria-label="Loading theme toggle" />
36+
<Moon className="h-4 w-4 text-gray-400" />
37+
</div>
38+
);
39+
}
40+
41+
const isDark = resolvedTheme === 'dark';
42+
43+
const handleToggle = (checked: boolean) => {
44+
setTheme(checked ? 'dark' : 'light');
45+
};
46+
47+
return (
48+
<div className="flex items-center gap-2">
49+
<Sun
50+
className={`h-4 w-4 transition-colors ${
51+
isDark ? 'text-gray-400' : 'text-yellow-500'
52+
}`}
53+
aria-hidden="true"
54+
/>
55+
<Switch
56+
checked={isDark}
57+
onCheckedChange={handleToggle}
58+
aria-label={`Switch to ${isDark ? 'light' : 'dark'} mode`}
59+
className="data-[state=checked]:bg-primary"
60+
/>
61+
<Moon
62+
className={`h-4 w-4 transition-colors ${
63+
isDark ? 'text-blue-400' : 'text-gray-400'
64+
}`}
65+
aria-hidden="true"
66+
/>
67+
</div>
68+
);
69+
}
70+
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import { ThemeToggle } from '../ThemeToggle';
4+
5+
// Mock next-themes
6+
const mockSetTheme = vi.fn();
7+
const mockUseTheme = vi.fn();
8+
9+
vi.mock('next-themes', () => ({
10+
useTheme: () => mockUseTheme(),
11+
}));
12+
13+
// Mock lucide-react icons
14+
vi.mock('lucide-react', () => ({
15+
Moon: ({ className, 'aria-hidden': ariaHidden }: any) => (
16+
<div data-testid="moon-icon" className={className} aria-hidden={ariaHidden}>🌙</div>
17+
),
18+
Sun: ({ className, 'aria-hidden': ariaHidden }: any) => (
19+
<div data-testid="sun-icon" className={className} aria-hidden={ariaHidden}>☀️</div>
20+
),
21+
}));
22+
23+
// Mock Switch component
24+
vi.mock('../../../components/ui/switch', () => ({
25+
Switch: ({ checked, onCheckedChange, disabled, 'aria-label': ariaLabel, className }: any) => (
26+
<button
27+
data-testid="theme-switch"
28+
onClick={() => !disabled && onCheckedChange && onCheckedChange(!checked)}
29+
disabled={disabled}
30+
aria-label={ariaLabel}
31+
className={className}
32+
data-checked={checked}
33+
>
34+
{checked ? 'ON' : 'OFF'}
35+
</button>
36+
),
37+
}));
38+
39+
describe('ThemeToggle', () => {
40+
beforeEach(() => {
41+
vi.clearAllMocks();
42+
});
43+
44+
it('should render with icons', () => {
45+
mockUseTheme.mockReturnValue({
46+
theme: 'light',
47+
setTheme: mockSetTheme,
48+
resolvedTheme: 'light',
49+
});
50+
51+
render(<ThemeToggle />);
52+
53+
// Check icons are rendered
54+
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
55+
expect(screen.getByTestId('moon-icon')).toBeInTheDocument();
56+
});
57+
58+
it('should render correctly with light theme', async () => {
59+
mockUseTheme.mockReturnValue({
60+
theme: 'light',
61+
setTheme: mockSetTheme,
62+
resolvedTheme: 'light',
63+
});
64+
65+
render(<ThemeToggle />);
66+
67+
// Wait for component to be mounted
68+
await waitFor(() => {
69+
const switchButton = screen.getByTestId('theme-switch');
70+
expect(switchButton).not.toBeDisabled();
71+
});
72+
73+
const switchButton = screen.getByTestId('theme-switch');
74+
expect(switchButton).toHaveAttribute('data-checked', 'false');
75+
expect(switchButton).toHaveAttribute('aria-label', 'Switch to dark mode');
76+
77+
// Check icons are rendered
78+
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
79+
expect(screen.getByTestId('moon-icon')).toBeInTheDocument();
80+
81+
// Check sun icon has active styling
82+
const sunIcon = screen.getByTestId('sun-icon');
83+
expect(sunIcon.className).toContain('text-yellow-500');
84+
85+
// Check moon icon has inactive styling
86+
const moonIcon = screen.getByTestId('moon-icon');
87+
expect(moonIcon.className).toContain('text-gray-400');
88+
});
89+
90+
it('should render correctly with dark theme', async () => {
91+
mockUseTheme.mockReturnValue({
92+
theme: 'dark',
93+
setTheme: mockSetTheme,
94+
resolvedTheme: 'dark',
95+
});
96+
97+
render(<ThemeToggle />);
98+
99+
// Wait for component to be mounted
100+
await waitFor(() => {
101+
const switchButton = screen.getByTestId('theme-switch');
102+
expect(switchButton).not.toBeDisabled();
103+
});
104+
105+
const switchButton = screen.getByTestId('theme-switch');
106+
expect(switchButton).toHaveAttribute('data-checked', 'true');
107+
expect(switchButton).toHaveAttribute('aria-label', 'Switch to light mode');
108+
109+
// Check sun icon has inactive styling
110+
const sunIcon = screen.getByTestId('sun-icon');
111+
expect(sunIcon.className).toContain('text-gray-400');
112+
113+
// Check moon icon has active styling
114+
const moonIcon = screen.getByTestId('moon-icon');
115+
expect(moonIcon.className).toContain('text-blue-400');
116+
});
117+
118+
it('should call setTheme with "dark" when switching from light to dark', async () => {
119+
mockUseTheme.mockReturnValue({
120+
theme: 'light',
121+
setTheme: mockSetTheme,
122+
resolvedTheme: 'light',
123+
});
124+
125+
render(<ThemeToggle />);
126+
127+
// Wait for component to be mounted
128+
await waitFor(() => {
129+
const switchButton = screen.getByTestId('theme-switch');
130+
expect(switchButton).not.toBeDisabled();
131+
});
132+
133+
const switchButton = screen.getByTestId('theme-switch');
134+
fireEvent.click(switchButton);
135+
136+
expect(mockSetTheme).toHaveBeenCalledWith('dark');
137+
expect(mockSetTheme).toHaveBeenCalledTimes(1);
138+
});
139+
140+
it('should call setTheme with "light" when switching from dark to light', async () => {
141+
mockUseTheme.mockReturnValue({
142+
theme: 'dark',
143+
setTheme: mockSetTheme,
144+
resolvedTheme: 'dark',
145+
});
146+
147+
render(<ThemeToggle />);
148+
149+
// Wait for component to be mounted
150+
await waitFor(() => {
151+
const switchButton = screen.getByTestId('theme-switch');
152+
expect(switchButton).not.toBeDisabled();
153+
});
154+
155+
const switchButton = screen.getByTestId('theme-switch');
156+
fireEvent.click(switchButton);
157+
158+
expect(mockSetTheme).toHaveBeenCalledWith('light');
159+
expect(mockSetTheme).toHaveBeenCalledTimes(1);
160+
});
161+
162+
it('should have proper accessibility attributes', async () => {
163+
mockUseTheme.mockReturnValue({
164+
theme: 'light',
165+
setTheme: mockSetTheme,
166+
resolvedTheme: 'light',
167+
});
168+
169+
render(<ThemeToggle />);
170+
171+
// Wait for component to be mounted
172+
await waitFor(() => {
173+
const switchButton = screen.getByTestId('theme-switch');
174+
expect(switchButton).not.toBeDisabled();
175+
});
176+
177+
const switchButton = screen.getByTestId('theme-switch');
178+
expect(switchButton).toHaveAttribute('aria-label', 'Switch to dark mode');
179+
180+
// Icons should have aria-hidden
181+
const sunIcon = screen.getByTestId('sun-icon');
182+
const moonIcon = screen.getByTestId('moon-icon');
183+
expect(sunIcon).toHaveAttribute('aria-hidden', 'true');
184+
expect(moonIcon).toHaveAttribute('aria-hidden', 'true');
185+
});
186+
187+
it('should handle system theme correctly', async () => {
188+
mockUseTheme.mockReturnValue({
189+
theme: 'system',
190+
setTheme: mockSetTheme,
191+
resolvedTheme: 'light', // System preference is light
192+
});
193+
194+
render(<ThemeToggle />);
195+
196+
// Wait for component to be mounted
197+
await waitFor(() => {
198+
const switchButton = screen.getByTestId('theme-switch');
199+
expect(switchButton).not.toBeDisabled();
200+
});
201+
202+
// Should use resolvedTheme, not theme
203+
const switchButton = screen.getByTestId('theme-switch');
204+
expect(switchButton).toHaveAttribute('data-checked', 'false');
205+
});
206+
207+
it('should handle dark system theme correctly', async () => {
208+
mockUseTheme.mockReturnValue({
209+
theme: 'system',
210+
setTheme: mockSetTheme,
211+
resolvedTheme: 'dark', // System preference is dark
212+
});
213+
214+
render(<ThemeToggle />);
215+
216+
// Wait for component to be mounted
217+
await waitFor(() => {
218+
const switchButton = screen.getByTestId('theme-switch');
219+
expect(switchButton).not.toBeDisabled();
220+
});
221+
222+
// Should use resolvedTheme, not theme
223+
const switchButton = screen.getByTestId('theme-switch');
224+
expect(switchButton).toHaveAttribute('data-checked', 'true');
225+
});
226+
});
227+

0 commit comments

Comments
 (0)