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 { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { ThemeProvider } from './design-system/providers/ThemeProvider'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ThemeProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user