BillTracker/client/contexts/ThemeContext.tsx

83 lines
2.0 KiB
TypeScript

import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
/**
* Themes: 'light' | 'dark'
* Persisted to localStorage under key 'bt-theme'.
*
* Class mapping on document.documentElement:
* light → (no classes)
* dark → 'dark'
*/
type Theme = 'light' | 'dark';
const STORAGE_KEY = 'bt-theme';
const VALID_THEMES: Theme[] = ['light', 'dark'];
const DEFAULT_THEME: Theme = 'dark';
function applyTheme(theme: Theme) {
const root = document.documentElement;
root.classList.remove('dark');
if (theme === 'dark') {
root.classList.add('dark');
}
// 'light' → no classes
}
function loadStoredTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark-purple') return 'dark';
if (stored && (VALID_THEMES as string[]).includes(stored)) return stored as Theme;
} catch {
// localStorage unavailable
}
return DEFAULT_THEME;
}
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: string) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = loadStoredTheme();
// Apply immediately (before first paint) to avoid flash
applyTheme(stored);
return stored;
});
const setTheme = (newTheme: string) => {
if (!(VALID_THEMES as string[]).includes(newTheme)) return;
applyTheme(newTheme as Theme);
setThemeState(newTheme as Theme);
try {
localStorage.setItem(STORAGE_KEY, newTheme);
} catch {
// localStorage unavailable
}
};
// Keep DOM in sync if theme state ever changes externally
useEffect(() => {
applyTheme(theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return ctx;
}