Skip to content

Commit 7421656

Browse files
authored
Merge pull request #112 from godekina/feat/light-dark-theme-toggle
feat: complete light/dark theme toggle with persistence and anti-flicker
2 parents 1c42fd8 + b83246a commit 7421656

5 files changed

Lines changed: 171 additions & 81 deletions

File tree

src/app/layout.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import { WalletProvider } from '@/context/WalletContext';
66
import { RoleProvider } from '@/context/RoleContext';
77
import { AlertProvider } from '@/context/AlertContext';
88
import { ToastProvider } from '@/components/Toast';
9-
import { ErrorProvider } from '@/components/ErrorModal';
10-
import { ThemeProvider } from '@/context/ThemeContext';
9+
import { ThemeProvider, themeScript } from '@/context/ThemeContext';
1110
import { I18nProvider } from '@/i18n';
1211
import { NetworkProvider } from '@/context/NetworkContext';
1312
import { SocketIOProvider } from '@/context/SocketIOContext';
@@ -26,7 +25,14 @@ interface RootLayoutProps {
2625

2726
export default function RootLayout({ children }: RootLayoutProps): ReactElement {
2827
return (
29-
<html lang="en" className={inter.className}>
28+
<html lang="en" className={inter.className} suppressHydrationWarning>
29+
<head>
30+
{/*
31+
Anti-flicker script: runs synchronously before first paint to apply
32+
the correct dark/light class from localStorage, preventing theme flash.
33+
*/}
34+
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
35+
</head>
3036
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200">
3137
<I18nProvider>
3238
<AlertProvider>

src/components/Layout/index.tsx

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
'use client';
22

33
import { useState } from 'react';
4-
import { Menu, X, Home, GitPullRequest, ShieldCheck, Settings } from 'lucide-react';
4+
import { Menu, X, Home, GitPullRequest, ShieldCheck, Settings, Shield } from 'lucide-react';
55
import { useTranslation } from 'react-i18next';
66
import ConnectButton from '@/components/ConnectButton';
7-
import { Shield } from 'lucide-react';
87

98
export default function Layout({ children }: { children: React.ReactNode }) {
109
const { t } = useTranslation();
@@ -18,28 +17,30 @@ export default function Layout({ children }: { children: React.ReactNode }) {
1817
];
1918

2019
return (
21-
<div className="min-h-screen bg-slate-950 flex text-slate-100">
20+
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 flex transition-colors duration-200">
2221
{/* Sidebar for Desktop */}
23-
<aside className="hidden md:flex flex-col w-64 border-r border-slate-800 bg-slate-900/50">
22+
<aside className="hidden md:flex flex-col w-64 border-r border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/50">
2423
<div className="p-6 flex items-center gap-3">
2524
<div className="w-10 h-10 bg-gradient-to-br from-indigo-600 to-violet-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-900/30">
26-
<Shield className="w-6 h-6 text-white" />
25+
<Shield className="w-6 h-6 text-white" aria-hidden="true" />
2726
</div>
2827
<div>
29-
<h1 className="text-xl font-bold text-white">Vero</h1>
30-
<p className="text-xs text-slate-400">Guardian</p>
28+
<h1 className="text-xl font-bold text-slate-900 dark:text-white">Vero</h1>
29+
<p className="text-xs text-slate-500 dark:text-slate-400">Guardian</p>
3130
</div>
3231
</div>
33-
<nav className="flex-1 px-4 space-y-2 mt-4">
32+
<nav className="flex-1 px-4 space-y-2 mt-4" aria-label={t('navigation.dashboard')}>
3433
{navigation.map((item) => (
3534
<a
3635
key={item.name}
3736
href="#"
3837
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
39-
item.current ? 'bg-indigo-600/10 text-indigo-400' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'
38+
item.current
39+
? 'bg-indigo-50 dark:bg-indigo-600/10 text-indigo-600 dark:text-indigo-400'
40+
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
4041
}`}
4142
>
42-
<item.icon className="w-5 h-5" />
43+
<item.icon className="w-5 h-5" aria-hidden="true" />
4344
{item.name}
4445
</a>
4546
))}
@@ -49,20 +50,23 @@ export default function Layout({ children }: { children: React.ReactNode }) {
4950
{/* Main Content */}
5051
<div className="flex-1 flex flex-col min-w-0">
5152
{/* Header */}
52-
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur-sm sticky top-0 z-40">
53+
<header className="border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/50 backdrop-blur-sm sticky top-0 z-40">
5354
<div className="px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between md:justify-end">
5455
<div className="flex items-center gap-3 md:hidden">
5556
<div className="w-8 h-8 bg-gradient-to-br from-indigo-600 to-violet-600 rounded-lg flex items-center justify-center shadow-lg shadow-indigo-900/30">
56-
<Shield className="w-5 h-5 text-white" />
57+
<Shield className="w-5 h-5 text-white" aria-hidden="true" />
5758
</div>
58-
<span className="font-bold text-white">Vero Guardian</span>
59+
<span className="font-bold text-slate-900 dark:text-white">Vero Guardian</span>
5960
</div>
6061

6162
<div className="flex items-center gap-4">
6263
<ConnectButton />
6364
<button
64-
className="md:hidden p-2 text-slate-400 hover:text-white rounded-lg min-h-[44px] min-w-[44px] flex items-center justify-center"
65+
type="button"
66+
className="md:hidden p-2 text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white rounded-lg min-h-[44px] min-w-[44px] flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-indigo-500"
6567
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
68+
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
69+
aria-expanded={isMobileMenuOpen}
6670
>
6771
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
6872
</button>
@@ -72,17 +76,19 @@ export default function Layout({ children }: { children: React.ReactNode }) {
7276

7377
{/* Mobile Menu */}
7478
{isMobileMenuOpen && (
75-
<div className="md:hidden border-b border-slate-800 bg-slate-900">
76-
<nav className="px-4 py-4 space-y-2">
79+
<div className="md:hidden border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
80+
<nav className="px-4 py-4 space-y-2" aria-label="Mobile navigation">
7781
{navigation.map((item) => (
7882
<a
7983
key={item.name}
8084
href="#"
8185
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
82-
item.current ? 'bg-indigo-600/10 text-indigo-400' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'
86+
item.current
87+
? 'bg-indigo-50 dark:bg-indigo-600/10 text-indigo-600 dark:text-indigo-400'
88+
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
8389
}`}
8490
>
85-
<item.icon className="w-5 h-5" />
91+
<item.icon className="w-5 h-5" aria-hidden="true" />
8692
{item.name}
8793
</a>
8894
))}
@@ -91,9 +97,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
9197
)}
9298

9399
{/* Page Content */}
94-
<div className="flex-1">
95-
{children}
96-
</div>
100+
<div className="flex-1">{children}</div>
97101
</div>
98102
</div>
99103
);

src/components/ThemeToggle.tsx

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,47 @@
11
'use client';
22

3-
import { useTheme } from '@/context/ThemeContext';
4-
import { Sun, Moon, Laptop } from 'lucide-react';
3+
import type { ReactElement } from 'react';
4+
import { Sun, Moon, Monitor } from 'lucide-react';
55
import { useTranslation } from 'react-i18next';
6+
import { useTheme, type Theme } from '@/context/ThemeContext';
67

7-
export default function ThemeToggle() {
8+
/** Cycle order: light → dark → system → light */
9+
const CYCLE: Theme[] = ['light', 'dark', 'system'];
10+
11+
function next(current: Theme): Theme {
12+
const idx = CYCLE.indexOf(current);
13+
return CYCLE[(idx + 1) % CYCLE.length];
14+
}
15+
16+
const ICONS: Record<Theme, ReactElement> = {
17+
light: <Sun className="w-5 h-5 text-amber-500 dark:text-amber-400" aria-hidden="true" />,
18+
dark: <Moon className="w-5 h-5 text-violet-500 dark:text-violet-400" aria-hidden="true" />,
19+
system: <Monitor className="w-5 h-5 text-indigo-500 dark:text-indigo-400" aria-hidden="true" />,
20+
};
21+
22+
export default function ThemeToggle(): ReactElement {
823
const { t } = useTranslation();
9-
const { theme, setTheme, resolvedTheme, mounted } = useTheme();
24+
const { theme, setTheme, mounted } = useTheme();
1025

26+
// Render a same-size invisible placeholder before mount to avoid layout shift.
1127
if (!mounted) {
1228
return (
13-
<div className="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 opacity-0" aria-hidden="true" />
29+
<div
30+
className="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 opacity-0"
31+
aria-hidden="true"
32+
/>
1433
);
1534
}
1635

17-
const cycleTheme = () => {
18-
if (theme === 'system') {
19-
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
20-
} else {
21-
setTheme(theme === 'light' ? 'dark' : 'system');
22-
}
23-
};
24-
25-
const getIcon = () => {
26-
if (theme === 'system') {
27-
return <Laptop className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />;
28-
}
29-
return theme === 'dark' ? (
30-
<Moon className="w-5 h-5 text-violet-600 dark:text-violet-400" />
31-
) : (
32-
<Sun className="w-5 h-5 text-amber-600 dark:text-amber-400" />
33-
);
34-
};
35-
36-
const getAriaLabel = () => {
37-
return t('theme.ariaLabel', { theme });
38-
};
39-
4036
return (
4137
<button
42-
onClick={cycleTheme}
43-
aria-label={getAriaLabel()}
44-
className="w-10 h-10 flex items-center justify-center bg-slate-100 hover:bg-slate-200 dark:bg-slate-800/80 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-xl border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all duration-200"
38+
type="button"
39+
onClick={() => setTheme(next(theme))}
40+
aria-label={t('theme.ariaLabel', { theme })}
41+
title={t('theme.ariaLabel', { theme })}
42+
className="w-10 h-10 flex items-center justify-center rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-100 hover:bg-slate-200 dark:bg-slate-800/80 dark:hover:bg-slate-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
4543
>
46-
{getIcon()}
44+
{ICONS[theme]}
4745
</button>
4846
);
4947
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import ThemeToggle from '../ThemeToggle';
3+
import { ThemeProvider } from '@/context/ThemeContext';
4+
import { I18nextProvider } from 'react-i18next';
5+
import i18n from '@/i18n/config';
6+
7+
function renderWithProviders(ui: React.ReactElement) {
8+
return render(
9+
<I18nextProvider i18n={i18n}>
10+
<ThemeProvider>{ui}</ThemeProvider>
11+
</I18nextProvider>,
12+
);
13+
}
14+
15+
describe('ThemeToggle', () => {
16+
beforeEach(() => {
17+
localStorage.clear();
18+
// Ensure matchMedia is available in jsdom
19+
Object.defineProperty(window, 'matchMedia', {
20+
configurable: true,
21+
writable: true,
22+
value: jest.fn().mockImplementation((query: string) => ({
23+
matches: false,
24+
media: query,
25+
onchange: null,
26+
addListener: jest.fn(),
27+
removeListener: jest.fn(),
28+
addEventListener: jest.fn(),
29+
removeEventListener: jest.fn(),
30+
dispatchEvent: jest.fn(),
31+
})),
32+
});
33+
});
34+
35+
test('renders a button', () => {
36+
renderWithProviders(<ThemeToggle />);
37+
expect(screen.getByRole('button')).toBeInTheDocument();
38+
});
39+
40+
test('persists theme to localStorage when clicked', () => {
41+
renderWithProviders(<ThemeToggle />);
42+
const button = screen.getByRole('button');
43+
fireEvent.click(button);
44+
const saved = localStorage.getItem('vero.guardian.theme');
45+
expect(['light', 'dark', 'system']).toContain(saved);
46+
});
47+
48+
test('cycles through light → dark → system on successive clicks', () => {
49+
renderWithProviders(<ThemeToggle />);
50+
const button = screen.getByRole('button');
51+
52+
// Default state is 'system', so first click → 'light'
53+
fireEvent.click(button);
54+
expect(localStorage.getItem('vero.guardian.theme')).toBe('light');
55+
56+
fireEvent.click(button);
57+
expect(localStorage.getItem('vero.guardian.theme')).toBe('dark');
58+
59+
fireEvent.click(button);
60+
expect(localStorage.getItem('vero.guardian.theme')).toBe('system');
61+
});
62+
63+
test('restores theme from localStorage on mount', () => {
64+
localStorage.setItem('vero.guardian.theme', 'dark');
65+
renderWithProviders(<ThemeToggle />);
66+
// After mount the dark class should be applied to <html>
67+
expect(document.documentElement.classList.contains('dark')).toBe(true);
68+
});
69+
});

0 commit comments

Comments
 (0)