Co-authored-by: Jason Woltje <[email protected]> Co-committed-by: Jason Woltje <[email protected]>
177 lines
4.7 KiB
TypeScript
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;
|
|
}
|