Organize/components/theme/theme-provider.tsx

155 lines
4.9 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import type { ReactNode } from "react";
/**
* App theme provider (replaces next-themes).
*
* next-themes injects its no-FOUC script as a <script> inside the React
* component tree, which React 19 (dev) rejects: "Encountered a script tag
* while rendering React component". Everything it does is small, so this
* provider implements the same API ({ theme, setTheme, resolvedTheme })
* directly, and the equivalent inline script lives in app/layout.tsx where
* it renders as plain server HTML and actually executes.
*
* Semantics (kept compatible for all existing consumers):
* - `theme` the stored preference: default | sunset | dark | ocean | system
* - `resolvedTheme` the theme actually applied (system resolved via
* prefers-color-scheme), always one of the four
* - html classes .theme-default | .theme-sunset | .dark | .ocean (see globals.css)
*/
export const THEMES = ["default", "sunset", "dark", "ocean"] as const;
export type ThemeName = (typeof THEMES)[number];
export type RawTheme = ThemeName | "system";
const STORAGE_KEY = "theme";
const THEME_CLASSES: Record<ThemeName, string> = {
default: "theme-default",
sunset: "theme-sunset",
dark: "dark",
ocean: "ocean",
};
const ALL_CLASSES = Object.values(THEME_CLASSES);
const DARK_SURFACES: ThemeName[] = ["dark", "ocean"];
interface ThemeContextValue {
theme: RawTheme;
resolvedTheme: ThemeName;
setTheme: (theme: RawTheme) => void;
themes: readonly ThemeName[];
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "system",
resolvedTheme: "default",
setTheme: () => {},
themes: THEMES,
});
function systemPrefersDark(): boolean {
return (
typeof window !== "undefined" &&
!!window.matchMedia?.("(prefers-color-scheme: dark)").matches
);
}
function resolve(theme: RawTheme): ThemeName {
return theme === "system" ? (systemPrefersDark() ? "dark" : "default") : theme;
}
/** Exported so consumers (e.g. isDarkTheme) derive the applied theme from
* the same source of truth the provider uses -- and stay reactive when it
* changes. */
export function resolveTheme(theme: RawTheme): ThemeName {
return resolve(theme);
}
function applyToDom(theme: RawTheme) {
if (typeof document === "undefined") return;
const name = resolve(theme);
const root = document.documentElement;
// Also drop "light": a class next-themes applied to <html> in earlier
// versions, harmless but stale.
root.classList.remove(...ALL_CLASSES, "light");
root.classList.add(THEME_CLASSES[name]);
root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
}
export function ThemeProvider({
children,
defaultTheme = "system",
}: {
children: ReactNode;
defaultTheme?: RawTheme;
}) {
// Both the server and the first client render agree on `defaultTheme`
// (hydration-safe); the stored preference is picked up right after mount.
// The inline script in app/layout.tsx already applied the right classes
// before first paint, so there is no visible jump either way.
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) setThemeState(stored as RawTheme);
} catch {
/* storage unavailable -- stay on defaultTheme */
}
}, []);
// Keep the DOM in sync whenever the preference changes -- synchronously
// (layout effect) so the CSS classes land in the same frame as the
// JS-driven colors that derive from this state: no flash of stale colors.
useLayoutEffect(() => {
applyToDom(theme);
}, [theme]);
// Follow the OS while in "system" mode.
useEffect(() => {
if (theme !== "system" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyToDom("system");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, [theme]);
// Keep multiple tabs in sync.
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) setThemeState((e.newValue || defaultTheme) as RawTheme);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [defaultTheme]);
const setTheme = useCallback((t: RawTheme) => {
setThemeState(t);
try {
localStorage.setItem(STORAGE_KEY, t);
} catch {
/* ignore -- preference just won't persist */
}
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
[theme, setTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}