feat(08-01): implement ThemeToggle component and wire into App.tsx

- Add ThemeToggle segmented control (Light/Dark/System) with DOM class toggle
- Use getStored() lazy init and applyTheme() for side-effect-free render
- Stub localStorage and matchMedia in tests for Node v25 compatibility
- Wire ThemeToggle into App.tsx header flex row next to h1
- Change App outer div to bg-surface, h1 to text-on-surface
- All 7 ThemeToggle unit tests passing; 166 total tests green
This commit is contained in:
2026-04-01 04:49:56 +02:00
parent fa2c0d963f
commit 074dc33c86
3 changed files with 119 additions and 15 deletions
+6 -2
View File
@@ -4,6 +4,7 @@
import { useWizard } from './store/context'; import { useWizard } from './store/context';
import { WizardProvider } from './store/context'; import { WizardProvider } from './store/context';
import { ThemeToggle } from './components/ui/ThemeToggle';
import { StepIndicator } from './components/wizard/StepIndicator'; import { StepIndicator } from './components/wizard/StepIndicator';
import { BackendSelectionStep } from './components/wizard/BackendSelectionStep'; import { BackendSelectionStep } from './components/wizard/BackendSelectionStep';
import { RemoteConfigStep } from './components/wizard/RemoteConfigStep'; import { RemoteConfigStep } from './components/wizard/RemoteConfigStep';
@@ -26,9 +27,12 @@ function WizardShell() {
const CurrentStep = steps[stepIndex]; const CurrentStep = steps[stepIndex];
return ( return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center py-12 px-4"> <div className="min-h-screen bg-surface flex flex-col items-center py-12 px-4">
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<h1 className="text-3xl font-bold text-gray-900 mb-8 text-center">Ready2Blob</h1> <div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-on-surface">Ready2Blob</h1>
<ThemeToggle />
</div>
<StepIndicator /> <StepIndicator />
<div className="mt-8"> <div className="mt-8">
{CurrentStep} {CurrentStep}
+43 -13
View File
@@ -1,21 +1,51 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { ThemeToggle } from './ThemeToggle'; import { ThemeToggle } from './ThemeToggle';
// Node v22+ has experimental built-in localStorage that lacks standard Storage methods.
// Replace it with a fully-functional in-memory mock for the duration of these tests.
const makeLocalStorageMock = () => {
let store: Record<string, string> = {};
return {
getItem: (key: string): string | null => store[key] ?? null,
setItem: (key: string, value: string): void => { store[key] = String(value); },
removeItem: (key: string): void => { delete store[key]; },
clear: (): void => { store = {}; },
};
};
const localStorageMock = makeLocalStorageMock();
// jsdom does not implement window.matchMedia — provide a minimal stub
const matchMediaMock = vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
describe('ThemeToggle', () => { describe('ThemeToggle', () => {
beforeEach(() => { beforeEach(() => {
// Reset DOM and localStorage before each test // Stub localStorage with a real in-memory implementation
vi.stubGlobal('localStorage', localStorageMock);
localStorageMock.clear();
// Stub matchMedia
vi.stubGlobal('matchMedia', matchMediaMock);
// Reset DOM dark class
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
localStorage.clear();
}); });
it('renders three buttons: Light, Dark, System', () => { it('renders three buttons: Light, Dark, System', () => {
render(<ThemeToggle />); render(<ThemeToggle />);
expect(screen.getByRole('button', { name: /light/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /light/i })).toBeDefined();
expect(screen.getByRole('button', { name: /dark/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /dark/i })).toBeDefined();
expect(screen.getByRole('button', { name: /system/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /system/i })).toBeDefined();
}); });
it('default state is System (aria-pressed="true" on System button) when localStorage is empty', () => { it('default state is System (aria-pressed="true" on System button) when localStorage is empty', () => {
@@ -23,9 +53,9 @@ describe('ThemeToggle', () => {
const systemButton = screen.getByRole('button', { name: /system/i }); const systemButton = screen.getByRole('button', { name: /system/i });
const lightButton = screen.getByRole('button', { name: /light/i }); const lightButton = screen.getByRole('button', { name: /light/i });
const darkButton = screen.getByRole('button', { name: /dark/i }); const darkButton = screen.getByRole('button', { name: /dark/i });
expect(systemButton).toHaveAttribute('aria-pressed', 'true'); expect(systemButton.getAttribute('aria-pressed')).toBe('true');
expect(lightButton).toHaveAttribute('aria-pressed', 'false'); expect(lightButton.getAttribute('aria-pressed')).toBe('false');
expect(darkButton).toHaveAttribute('aria-pressed', 'false'); expect(darkButton.getAttribute('aria-pressed')).toBe('false');
}); });
it('clicking Dark adds .dark class to document.documentElement', async () => { it('clicking Dark adds .dark class to document.documentElement', async () => {
@@ -59,13 +89,13 @@ describe('ThemeToggle', () => {
render(<ThemeToggle />); render(<ThemeToggle />);
const darkButton = screen.getByRole('button', { name: /dark/i }); const darkButton = screen.getByRole('button', { name: /dark/i });
await user.click(darkButton); await user.click(darkButton);
expect(darkButton).toHaveAttribute('aria-pressed', 'true'); expect(darkButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.getByRole('button', { name: /light/i })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: /light/i }).getAttribute('aria-pressed')).toBe('false');
expect(screen.getByRole('button', { name: /system/i })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: /system/i }).getAttribute('aria-pressed')).toBe('false');
}); });
it('component group has aria-label="Theme"', () => { it('component group has aria-label="Theme"', () => {
render(<ThemeToggle />); render(<ThemeToggle />);
expect(screen.getByRole('group', { name: 'Theme' })).toBeInTheDocument(); expect(screen.getByRole('group', { name: 'Theme' })).toBeDefined();
}); });
}); });
+70
View File
@@ -0,0 +1,70 @@
// src/components/ui/ThemeToggle.tsx
// Segmented theme control — writes to localStorage and toggles .dark on <html>.
// Uses DOM class toggle (no React Context) to avoid subtree re-render cascade.
//
// Note: On Vite HMR, getStored() re-reads localStorage on remount.
// This is correct for production (hard reloads are always consistent).
// In dev, toggling via DevTools and triggering HMR may show a brief state mismatch — expected.
import { useState } from 'react';
type ThemeValue = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'r2b-theme';
function getStored(): ThemeValue {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === 'light' || v === 'dark' || v === 'system') return v;
} catch {
// localStorage unavailable (private mode, etc.)
}
return 'system';
}
function applyTheme(value: ThemeValue): void {
const root = document.documentElement;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = value === 'dark' || (value === 'system' && prefersDark);
root.classList.toggle('dark', isDark);
try {
localStorage.setItem(STORAGE_KEY, value);
} catch {
// localStorage unavailable — silently ignore
}
}
const LABELS: Record<ThemeValue, string> = {
light: '\u2600 Light',
dark: '\uD83C\uDF19 Dark',
system: '\u2299 System',
};
export function ThemeToggle() {
const [theme, setTheme] = useState<ThemeValue>(getStored);
function select(value: ThemeValue): void {
setTheme(value);
applyTheme(value);
}
return (
<div role="group" aria-label="Theme" className="flex rounded-md border border-outline overflow-hidden text-sm">
{(['light', 'dark', 'system'] as const).map((v) => (
<button
key={v}
type="button"
onClick={() => select(v)}
aria-pressed={theme === v}
className={
theme === v
? 'flex-1 px-3 py-1 bg-primary text-on-primary font-medium'
: 'flex-1 px-3 py-1 bg-surface-container text-on-surface-container hover:bg-surface'
}
>
{LABELS[v]}
</button>
))}
</div>
);
}