feat: ThemeProvider with light/dark toggle and localStorage persistence
This commit is contained in:
72
src/design-system/providers/ThemeProvider.test.tsx
Normal file
72
src/design-system/providers/ThemeProvider.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
57
src/design-system/providers/ThemeProvider.tsx
Normal file
57
src/design-system/providers/ThemeProvider.tsx
Normal 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
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user