feat: ThemeProvider with light/dark toggle and localStorage persistence

This commit is contained in:
hsiegeln
2026-03-18 09:09:52 +01:00
parent 91e137a013
commit c24c2829b2
3 changed files with 133 additions and 1 deletions

View File

@@ -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 (
<div>
<span data-testid="theme">{theme}</span>
<button onClick={toggleTheme}>Toggle</button>
</div>
)
}
describe('ThemeProvider', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
})
it('defaults to light theme', () => {
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
expect(screen.getByTestId('theme').textContent).toBe('light')
})
it('sets data-theme attribute on document', () => {
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
expect(document.documentElement.dataset.theme).toBe('light')
})
it('toggles theme on button click', async () => {
const user = userEvent.setup()
render(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
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(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
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(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>,
)
expect(screen.getByTestId('theme').textContent).toBe('dark')
})
})

View File

@@ -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<ThemeContextValue | null>(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<Theme>(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 (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -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(
<StrictMode>
<BrowserRouter>
<ThemeProvider>
<App />
</ThemeProvider>
</BrowserRouter>
</StrictMode>,
)