@@ -0,0 +1,108 @@
|
||||
"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 }} />;
|
||||
}
|
||||
Reference in New Issue
Block a user