Files
ui-kit/src/lib/theme.tsx
T
Jason Stedwell 822d8e41e6
ci / build-and-design (push) Failing after 13s
visual upgrade
2026-07-23 04:23:19 -05:00

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 }} />;
}