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:
@@ -1,21 +1,51 @@
|
||||
// @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 userEvent from '@testing-library/user-event';
|
||||
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', () => {
|
||||
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');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders three buttons: Light, Dark, System', () => {
|
||||
render(<ThemeToggle />);
|
||||
expect(screen.getByRole('button', { name: /light/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /dark/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /system/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /light/i })).toBeDefined();
|
||||
expect(screen.getByRole('button', { name: /dark/i })).toBeDefined();
|
||||
expect(screen.getByRole('button', { name: /system/i })).toBeDefined();
|
||||
});
|
||||
|
||||
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 lightButton = screen.getByRole('button', { name: /light/i });
|
||||
const darkButton = screen.getByRole('button', { name: /dark/i });
|
||||
expect(systemButton).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(lightButton).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(darkButton).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(systemButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(lightButton.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(darkButton.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('clicking Dark adds .dark class to document.documentElement', async () => {
|
||||
@@ -59,13 +89,13 @@ describe('ThemeToggle', () => {
|
||||
render(<ThemeToggle />);
|
||||
const darkButton = screen.getByRole('button', { name: /dark/i });
|
||||
await user.click(darkButton);
|
||||
expect(darkButton).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: /light/i })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: /system/i })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(darkButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByRole('button', { name: /light/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByRole('button', { name: /system/i }).getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('component group has aria-label="Theme"', () => {
|
||||
render(<ThemeToggle />);
|
||||
expect(screen.getByRole('group', { name: 'Theme' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('group', { name: 'Theme' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user