diff --git a/src/App.tsx b/src/App.tsx index 8aab53f..d207d95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { useWizard } from './store/context'; import { WizardProvider } from './store/context'; +import { ThemeToggle } from './components/ui/ThemeToggle'; import { StepIndicator } from './components/wizard/StepIndicator'; import { BackendSelectionStep } from './components/wizard/BackendSelectionStep'; import { RemoteConfigStep } from './components/wizard/RemoteConfigStep'; @@ -26,9 +27,12 @@ function WizardShell() { const CurrentStep = steps[stepIndex]; return ( -
+
-

Ready2Blob

+
+

Ready2Blob

+ +
{CurrentStep} diff --git a/src/components/ui/ThemeToggle.test.tsx b/src/components/ui/ThemeToggle.test.tsx index f86985f..ed2c51f 100644 --- a/src/components/ui/ThemeToggle.test.tsx +++ b/src/components/ui/ThemeToggle.test.tsx @@ -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 = {}; + 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(); - 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(); 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(); - expect(screen.getByRole('group', { name: 'Theme' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Theme' })).toBeDefined(); }); }); diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx new file mode 100644 index 0000000..ea12a1b --- /dev/null +++ b/src/components/ui/ThemeToggle.tsx @@ -0,0 +1,70 @@ +// src/components/ui/ThemeToggle.tsx +// Segmented theme control — writes to localStorage and toggles .dark on . +// 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 = { + light: '\u2600 Light', + dark: '\uD83C\uDF19 Dark', + system: '\u2299 System', +}; + +export function ThemeToggle() { + const [theme, setTheme] = useState(getStored); + + function select(value: ThemeValue): void { + setTheme(value); + applyTheme(value); + } + + return ( +
+ {(['light', 'dark', 'system'] as const).map((v) => ( + + ))} +
+ ); +}