111 lines
3.0 KiB
TypeScript
111 lines
3.0 KiB
TypeScript
"use client";
|
|
|
|
// Light / dark / system theme state. The actual `.dark` class is set on <html>
|
|
// by an inline script in the root layout (before paint) and kept in sync here.
|
|
// Preference persists in localStorage.
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
export const THEME_STORAGE_KEY = "aram_theme";
|
|
export type Theme = "light" | "dark" | "system";
|
|
|
|
interface ThemeApi {
|
|
theme: Theme;
|
|
resolved: "light" | "dark";
|
|
toggle: () => void;
|
|
setTheme: (t: Theme) => void;
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeApi | null>(null);
|
|
|
|
function isSystemDark(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
}
|
|
|
|
function applyClass(resolved: "light" | "dark") {
|
|
document.documentElement.classList.toggle("dark", resolved === "dark");
|
|
}
|
|
|
|
function readStorage(): Theme {
|
|
if (typeof window === "undefined") return "system";
|
|
try {
|
|
const t = localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (t === "light" || t === "dark" || t === "system") return t;
|
|
} catch { /* private mode */ }
|
|
return "system";
|
|
}
|
|
|
|
function resolve(theme: Theme): "light" | "dark" {
|
|
return theme === "system" ? (isSystemDark() ? "dark" : "light") : theme;
|
|
}
|
|
|
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
const [theme, setThemeState] = useState<Theme>("system");
|
|
const [resolved, setResolved] = useState<"light" | "dark">("light");
|
|
const mqRef = useRef<MediaQueryList | null>(null);
|
|
|
|
// Apply theme to <html> and update resolved state.
|
|
const apply = useCallback((t: Theme) => {
|
|
const r = resolve(t);
|
|
applyClass(r);
|
|
setResolved(r);
|
|
}, []);
|
|
|
|
// On mount: read preference, apply, listen to system changes.
|
|
useEffect(() => {
|
|
const t = readStorage();
|
|
setThemeState(t);
|
|
apply(t);
|
|
|
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
mqRef.current = mq;
|
|
|
|
const handler = () => {
|
|
// Re-read theme from state (via ref won't work for closures, so re-read localStorage).
|
|
const current = readStorage();
|
|
if (current === "system") {
|
|
apply("system");
|
|
}
|
|
};
|
|
mq.addEventListener("change", handler);
|
|
return () => mq.removeEventListener("change", handler);
|
|
}, [apply]);
|
|
|
|
const setTheme = useCallback(
|
|
(t: Theme) => {
|
|
setThemeState(t);
|
|
apply(t);
|
|
try {
|
|
localStorage.setItem(THEME_STORAGE_KEY, t);
|
|
} catch { /* private mode */ }
|
|
},
|
|
[apply],
|
|
);
|
|
|
|
const toggle = useCallback(() => {
|
|
const order: Theme[] = ["light", "dark", "system"];
|
|
const next = order[(order.indexOf(theme) + 1) % order.length];
|
|
setTheme(next);
|
|
}, [theme, setTheme]);
|
|
|
|
return (
|
|
<ThemeContext.Provider value={{ theme, resolved, toggle, setTheme }}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTheme(): ThemeApi {
|
|
const ctx = useContext(ThemeContext);
|
|
if (!ctx) throw new Error("useTheme must be used within <ThemeProvider>");
|
|
return ctx;
|
|
}
|