feat: add persistent responsive color themes

This commit is contained in:
donghyeon-ka
2026-07-26 00:06:49 +09:00
parent bee5c158c6
commit 4cfbe5a29e
9 changed files with 372 additions and 51 deletions
@@ -0,0 +1,86 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import {
normalizeColorSchemePreference,
resolveColorScheme,
} from "../../application/policies/color-scheme.js";
/**
* @typedef {{
* preference: "system" | "light" | "dark",
* resolvedTheme: "light" | "dark",
* setPreference: (preference: "system" | "light" | "dark") => void
* }} ThemeContextValue
*/
const ThemeContext = createContext(/** @type {ThemeContextValue | null} */ (null));
function systemPrefersDark() {
return (
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
/**
* @param {{
* storage?: import("../../application/ports/storage-port.js").StoragePort,
* children: React.ReactNode
* }} props
*/
export function ThemeProvider({ storage, children }) {
const [preference, updatePreference] = useState(() => {
const result = storage?.read("COLOR_SCHEME");
return normalizeColorSchemePreference(
result?.ok ? result.value : undefined,
);
});
const [darkSystemTheme, setDarkSystemTheme] = useState(systemPrefersDark);
const resolvedTheme = resolveColorScheme(preference, darkSystemTheme);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(prefers-color-scheme: dark)");
/** @param {MediaQueryListEvent} event */
const update = (event) => setDarkSystemTheme(event.matches);
setDarkSystemTheme(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
useLayoutEffect(() => {
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.dataset.themePreference = preference;
document.documentElement.style.colorScheme = resolvedTheme;
}, [preference, resolvedTheme]);
const value = useMemo(
() =>
Object.freeze({
preference,
resolvedTheme,
/** @param {"system" | "light" | "dark"} next */
setPreference(next) {
const normalized = normalizeColorSchemePreference(next);
updatePreference(normalized);
storage?.write("COLOR_SCHEME", normalized);
},
}),
[preference, resolvedTheme, storage],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const theme = useContext(ThemeContext);
if (!theme) throw new Error("ThemeProvider is required");
return theme;
}