133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
export type ThemePreference = "light" | "dark" | "auto";
|
|
export type ResolvedTheme = "light" | "dark";
|
|
|
|
export const THEME_STORAGE_KEY = "theme";
|
|
export const DEFAULT_THEME_PREFERENCE: ThemePreference = "auto";
|
|
export const SYSTEM_THEME_QUERY = "(prefers-color-scheme: dark)";
|
|
|
|
const VALID_THEME_PREFERENCES = new Set<ThemePreference>(["light", "dark", "auto"]);
|
|
const THEME_EVENT = "eusrr-theme-change";
|
|
|
|
function isThemePreference(value: string | null): value is ThemePreference {
|
|
return Boolean(value) && VALID_THEME_PREFERENCES.has(value as ThemePreference);
|
|
}
|
|
|
|
export function getSystemTheme(): ResolvedTheme {
|
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
return "light";
|
|
}
|
|
|
|
return window.matchMedia(SYSTEM_THEME_QUERY).matches ? "dark" : "light";
|
|
}
|
|
|
|
export function readThemePreference(): ThemePreference {
|
|
if (typeof window === "undefined") {
|
|
return DEFAULT_THEME_PREFERENCE;
|
|
}
|
|
|
|
try {
|
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
return isThemePreference(stored) ? stored : DEFAULT_THEME_PREFERENCE;
|
|
} catch {
|
|
return DEFAULT_THEME_PREFERENCE;
|
|
}
|
|
}
|
|
|
|
export function resolveTheme(preference: ThemePreference): ResolvedTheme {
|
|
return preference === "auto" ? getSystemTheme() : preference;
|
|
}
|
|
|
|
export function applyTheme(preference: ThemePreference): ResolvedTheme {
|
|
if (typeof document === "undefined") {
|
|
return preference === "dark" ? "dark" : "light";
|
|
}
|
|
|
|
const resolved = resolveTheme(preference);
|
|
const root = document.documentElement;
|
|
root.setAttribute("data-bs-theme", resolved);
|
|
root.style.colorScheme = resolved;
|
|
return resolved;
|
|
}
|
|
|
|
export function setThemePreference(preference: ThemePreference): ResolvedTheme {
|
|
if (typeof window !== "undefined") {
|
|
try {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, preference);
|
|
} catch {
|
|
// Ignore storage errors in private mode / blocked storage.
|
|
}
|
|
}
|
|
|
|
const resolved = applyTheme(preference);
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.dispatchEvent(new CustomEvent(THEME_EVENT, { detail: { preference, resolved } }));
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
export function initializeTheme(): { preference: ThemePreference; resolved: ResolvedTheme } {
|
|
const preference = readThemePreference();
|
|
const resolved = applyTheme(preference);
|
|
return { preference, resolved };
|
|
}
|
|
|
|
export function subscribeToThemeChanges(callback: () => void): () => void {
|
|
if (typeof window === "undefined") {
|
|
return () => {};
|
|
}
|
|
|
|
const mediaQuery = window.matchMedia?.(SYSTEM_THEME_QUERY);
|
|
const onThemeEvent = () => callback();
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (!event.key || event.key === THEME_STORAGE_KEY) {
|
|
callback();
|
|
}
|
|
};
|
|
const onMediaChange = () => {
|
|
if (readThemePreference() === "auto") {
|
|
applyTheme("auto");
|
|
callback();
|
|
}
|
|
};
|
|
|
|
window.addEventListener(THEME_EVENT, onThemeEvent);
|
|
window.addEventListener("storage", onStorage);
|
|
mediaQuery?.addEventListener?.("change", onMediaChange);
|
|
|
|
return () => {
|
|
window.removeEventListener(THEME_EVENT, onThemeEvent);
|
|
window.removeEventListener("storage", onStorage);
|
|
mediaQuery?.removeEventListener?.("change", onMediaChange);
|
|
};
|
|
}
|
|
|
|
export function getThemeInitScript(): string {
|
|
return `
|
|
(function () {
|
|
var storageKey = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
var defaultPreference = ${JSON.stringify(DEFAULT_THEME_PREFERENCE)};
|
|
var valid = { light: true, dark: true, auto: true };
|
|
var root = document.documentElement;
|
|
var preference = defaultPreference;
|
|
|
|
try {
|
|
var stored = window.localStorage.getItem(storageKey);
|
|
if (stored && valid[stored]) {
|
|
preference = stored;
|
|
}
|
|
} catch (error) {}
|
|
|
|
var isDark = false;
|
|
try {
|
|
isDark = window.matchMedia && window.matchMedia(${JSON.stringify(SYSTEM_THEME_QUERY)}).matches;
|
|
} catch (error) {}
|
|
|
|
var resolved = preference === "auto" ? (isDark ? "dark" : "light") : preference;
|
|
root.setAttribute("data-bs-theme", resolved);
|
|
root.style.colorScheme = resolved;
|
|
})();
|
|
`;
|
|
}
|