From c24c2829b284536215f43f9db3c3cebe9af7de92 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:09:52 +0100 Subject: [PATCH] feat: ThemeProvider with light/dark toggle and localStorage persistence --- .../providers/ThemeProvider.test.tsx | 72 +++++++++++++++++++ src/design-system/providers/ThemeProvider.tsx | 57 +++++++++++++++ src/main.tsx | 5 +- 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/design-system/providers/ThemeProvider.test.tsx create mode 100644 src/design-system/providers/ThemeProvider.tsx diff --git a/src/design-system/providers/ThemeProvider.test.tsx b/src/design-system/providers/ThemeProvider.test.tsx new file mode 100644 index 0000000..4b6726c --- /dev/null +++ b/src/design-system/providers/ThemeProvider.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ThemeProvider, useTheme } from './ThemeProvider' + +function TestConsumer() { + const { theme, toggleTheme } = useTheme() + return ( +
+ {theme} + +
+ ) +} + +describe('ThemeProvider', () => { + beforeEach(() => { + localStorage.clear() + document.documentElement.removeAttribute('data-theme') + }) + + it('defaults to light theme', () => { + render( + + + , + ) + expect(screen.getByTestId('theme').textContent).toBe('light') + }) + + it('sets data-theme attribute on document', () => { + render( + + + , + ) + expect(document.documentElement.dataset.theme).toBe('light') + }) + + it('toggles theme on button click', async () => { + const user = userEvent.setup() + render( + + + , + ) + await user.click(screen.getByText('Toggle')) + expect(screen.getByTestId('theme').textContent).toBe('dark') + expect(document.documentElement.dataset.theme).toBe('dark') + }) + + it('persists theme to localStorage', async () => { + const user = userEvent.setup() + render( + + + , + ) + await user.click(screen.getByText('Toggle')) + expect(localStorage.getItem('cameleer-theme')).toBe('dark') + }) + + it('reads initial theme from localStorage', () => { + localStorage.setItem('cameleer-theme', 'dark') + render( + + + , + ) + expect(screen.getByTestId('theme').textContent).toBe('dark') + }) +}) diff --git a/src/design-system/providers/ThemeProvider.tsx b/src/design-system/providers/ThemeProvider.tsx new file mode 100644 index 0000000..3abe824 --- /dev/null +++ b/src/design-system/providers/ThemeProvider.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' + +type Theme = 'light' | 'dark' + +interface ThemeContextValue { + theme: Theme + toggleTheme: () => void + setTheme: (theme: Theme) => void +} + +const ThemeContext = createContext(null) + +const STORAGE_KEY = 'cameleer-theme' + +function getInitialTheme(): Theme { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === 'dark' || stored === 'light') return stored + } catch { + // localStorage unavailable + } + return 'light' +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(getInitialTheme) + + const setTheme = useCallback((t: Theme) => { + setThemeState(t) + document.documentElement.dataset.theme = t + try { + localStorage.setItem(STORAGE_KEY, t) + } catch { + // localStorage unavailable + } + }, []) + + const toggleTheme = useCallback(() => { + setTheme(theme === 'light' ? 'dark' : 'light') + }, [theme, setTheme]) + + useEffect(() => { + document.documentElement.dataset.theme = theme + }, [theme]) + + return ( + + {children} + + ) +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) throw new Error('useTheme must be used within ThemeProvider') + return ctx +} diff --git a/src/main.tsx b/src/main.tsx index 8e0deb1..83eded7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,13 +1,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' +import { ThemeProvider } from './design-system/providers/ThemeProvider' import App from './App' import './index.css' createRoot(document.getElementById('root')!).render( - + + + , )