Files
stack/apps/web/src/providers/ThemeProvider.tsx
T
2026-02-23 14:09:10 +00:00

177 lines
4.7 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
type ThemeDefinition,
darkTheme,
getThemeOrDefault,
isValidThemeId,
themeToVariables,
} from "@/themes";
interface ThemeContextValue {
/** User preference: a theme ID (e.g. "dark", "nord") or "system" */
theme: string;
/** The active theme's ID after resolving "system" */
themeId: string;
/** The full active ThemeDefinition object */
themeDefinition: ThemeDefinition;
/** "light" or "dark" classification of the active theme */
resolvedTheme: "light" | "dark";
/** Set theme by ID or "system" */
setTheme: (theme: string) => void;
/** Quick toggle between "dark" and "light" themes */
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
const STORAGE_KEY = "mosaic-theme";
function getSystemThemeId(): "light" | "dark" {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function getStoredPreference(): string {
if (typeof window === "undefined") return "system";
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && (stored === "system" || isValidThemeId(stored))) {
return stored;
}
return "system";
}
function resolveThemeId(preference: string): string {
if (preference === "system") return getSystemThemeId();
return preference;
}
function applyThemeVariables(themeDef: ThemeDefinition): void {
const root = document.documentElement;
const vars = themeToVariables(themeDef);
for (const [prop, value] of Object.entries(vars)) {
root.style.setProperty(prop, value);
}
// Set data-theme attribute for CSS selectors that depend on light/dark
root.setAttribute("data-theme", themeDef.isDark ? "dark" : "light");
}
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: string;
}
export function ThemeProvider({
children,
defaultTheme = "system",
}: ThemeProviderProps): React.JSX.Element {
const [preference, setPreference] = useState<string>(defaultTheme);
const [mounted, setMounted] = useState(false);
const themeId = useMemo(() => resolveThemeId(preference), [preference]);
const themeDefinition = useMemo(() => getThemeOrDefault(themeId), [themeId]);
const resolvedTheme = themeDefinition.isDark ? "dark" : "light";
// Initialize from storage on mount
useEffect(() => {
setMounted(true);
const stored = getStoredPreference();
setPreference(stored);
const id = resolveThemeId(stored);
const def = getThemeOrDefault(id);
applyThemeVariables(def);
}, []);
// Apply theme whenever preference changes (after mount)
useEffect(() => {
if (!mounted) return;
applyThemeVariables(themeDefinition);
}, [themeDefinition, mounted]);
// Listen for system theme changes when preference is "system"
useEffect(() => {
if (!mounted || preference !== "system") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent): void => {
const id = e.matches ? "dark" : "light";
const def = getThemeOrDefault(id);
applyThemeVariables(def);
// Force re-render by updating preference to trigger useMemo recalc
setPreference("system");
};
mediaQuery.addEventListener("change", handleChange);
return (): void => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [preference, mounted]);
const setTheme = useCallback((newPreference: string) => {
setPreference(newPreference);
localStorage.setItem(STORAGE_KEY, newPreference);
}, []);
const toggleTheme = useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
}, [resolvedTheme, setTheme]);
// SSR placeholder — render children but with dark defaults
if (!mounted) {
return (
<ThemeContext.Provider
value={{
theme: defaultTheme,
themeId: "dark",
themeDefinition: darkTheme,
resolvedTheme: "dark",
setTheme: (): void => {
/* no-op during SSR */
},
toggleTheme: (): void => {
/* no-op during SSR */
},
}}
>
{children}
</ThemeContext.Provider>
);
}
return (
<ThemeContext.Provider
value={{
theme: preference,
themeId,
themeDefinition,
resolvedTheme,
setTheme,
toggleTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}