Organize/components/theme/theme-provider.tsx

204 lines
5.8 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: one of THEMES, or "system"
* - `resolvedTheme` the theme actually applied (system resolved via
* prefers-color-scheme), always one of THEMES
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
*/
/* Order is the picker order: all the light looks, then the dark looks. */
export const THEMES = [
"default",
"sunset",
"meadow",
"honey",
"rose",
"lavender",
"slate",
"blueprint",
"vaporwave",
"notebook",
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
] 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",
meadow: "theme-meadow",
honey: "theme-honey",
rose: "theme-rose",
lavender: "theme-lavender",
slate: "theme-slate",
blueprint: "theme-blueprint",
vaporwave: "theme-vaporwave",
notebook: "theme-notebook",
dark: "dark",
ocean: "ocean",
pine: "theme-pine",
plum: "theme-plum",
midnight: "theme-midnight",
ember: "theme-ember",
rosewood: "theme-rosewood",
cyberpunk: "theme-cyberpunk",
starfield: "theme-starfield",
};
const ALL_CLASSES = Object.values(THEME_CLASSES);
/* Themes whose *surfaces* are dark (see app/globals.css). The single source
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
* color-scheme, and the `dark:` custom-variant in globals.css. */
export const DARK_SURFACES: ThemeName[] = [
"dark",
"ocean",
"pine",
"plum",
"midnight",
"ember",
"rosewood",
"cyberpunk",
"starfield",
];
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);
}