109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
|
|
export type Theme = "dark" | "light" | "system";
|
|
export type ResolvedTheme = Exclude<Theme, "system">;
|
|
|
|
const DEFAULT_STORAGE_KEY = "jason-ui-theme";
|
|
const mediaQuery = "(prefers-color-scheme: light)";
|
|
|
|
function systemTheme(): ResolvedTheme {
|
|
return typeof window !== "undefined" && window.matchMedia(mediaQuery).matches ? "light" : "dark";
|
|
}
|
|
|
|
function resolveTheme(theme: Theme): ResolvedTheme {
|
|
return theme === "system" ? systemTheme() : theme;
|
|
}
|
|
|
|
/** Apply a theme immediately. For persisted React state, use ThemeProvider. */
|
|
export function setTheme(theme: Theme): ResolvedTheme {
|
|
const resolved = resolveTheme(theme);
|
|
if (typeof document === "undefined") return resolved;
|
|
const root = document.documentElement;
|
|
root.classList.toggle("light", resolved === "light");
|
|
root.classList.toggle("dark", resolved === "dark");
|
|
root.style.colorScheme = resolved;
|
|
return resolved;
|
|
}
|
|
|
|
/** Toggle the currently rendered mode. */
|
|
export function toggleTheme(): ResolvedTheme {
|
|
const current = typeof document !== "undefined" && document.documentElement.classList.contains("light");
|
|
return setTheme(current ? "dark" : "light");
|
|
}
|
|
|
|
export interface ThemeContextValue {
|
|
theme: Theme;
|
|
resolvedTheme: ResolvedTheme;
|
|
setTheme: (theme: Theme) => void;
|
|
toggleTheme: () => void;
|
|
}
|
|
|
|
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
|
|
|
|
export function useOptionalTheme() {
|
|
return React.useContext(ThemeContext);
|
|
}
|
|
|
|
export function ThemeProvider({
|
|
children,
|
|
defaultTheme = "system",
|
|
storageKey = DEFAULT_STORAGE_KEY,
|
|
}: {
|
|
children: React.ReactNode;
|
|
defaultTheme?: Theme;
|
|
storageKey?: string;
|
|
}) {
|
|
const [theme, updateTheme] = React.useState<Theme>(defaultTheme);
|
|
const [resolvedTheme, updateResolvedTheme] = React.useState<ResolvedTheme>(() => resolveTheme(defaultTheme));
|
|
|
|
React.useEffect(() => {
|
|
const stored = window.localStorage.getItem(storageKey);
|
|
if (stored === "dark" || stored === "light" || stored === "system") updateTheme(stored);
|
|
}, [storageKey]);
|
|
|
|
React.useEffect(() => {
|
|
const query = window.matchMedia(mediaQuery);
|
|
const apply = () => updateResolvedTheme(setTheme(theme));
|
|
apply();
|
|
if (theme !== "system") return;
|
|
query.addEventListener("change", apply);
|
|
return () => query.removeEventListener("change", apply);
|
|
}, [theme]);
|
|
|
|
const changeTheme = React.useCallback((next: Theme) => {
|
|
window.localStorage.setItem(storageKey, next);
|
|
updateTheme(next);
|
|
}, [storageKey]);
|
|
|
|
const toggle = React.useCallback(() => {
|
|
changeTheme(resolvedTheme === "light" ? "dark" : "light");
|
|
}, [changeTheme, resolvedTheme]);
|
|
|
|
const value = React.useMemo(
|
|
() => ({ theme, resolvedTheme, setTheme: changeTheme, toggleTheme: toggle }),
|
|
[changeTheme, resolvedTheme, theme, toggle]
|
|
);
|
|
|
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
}
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
const context = useOptionalTheme();
|
|
if (!context) throw new Error("useTheme must be used inside ThemeProvider");
|
|
return context;
|
|
}
|
|
|
|
/** Inline this before app CSS to avoid a light/dark flash during hydration. */
|
|
export function ThemeScript({
|
|
defaultTheme = "system",
|
|
storageKey = DEFAULT_STORAGE_KEY,
|
|
}: {
|
|
defaultTheme?: Theme;
|
|
storageKey?: string;
|
|
}) {
|
|
const script = `(()=>{try{const k=${JSON.stringify(storageKey)},d=${JSON.stringify(defaultTheme)},s=localStorage.getItem(k)||d,m=s==="system"?(matchMedia(${JSON.stringify(mediaQuery)}).matches?"light":"dark"):s,e=document.documentElement;e.classList.remove("light","dark");e.classList.add(m);e.style.colorScheme=m}catch{}})();`;
|
|
return <script dangerouslySetInnerHTML={{ __html: script }} />;
|
|
}
|