lib/src/client/theme-switcher/theme-switcher.tsx
import type { SetStateAction } from "r18gs";
import type { ColorSchemePreference, ThemeState } from "../../constants";
import { DEFAULT_ID, useRGSMinify } from "../../constants";
import { useEffect } from "react";
const useEffectMinify = useEffect;
export interface ThemeSwitcherProps {
/** id of target element to apply classes to. This is useful when you want to apply theme only to specific container. */
targetId?: string;
/** To stop persisting and syncing theme between tabs. */
dontSync?: boolean;
/** force apply CSS transition property to all the elements during theme switching. E.g., `all .3s` */
themeTransition?: string;
/** provide styles object imported from CSS/SCSS modules, if you are using CSS/SCSS modules. */
styles?: Record<string, string>;
}
/** Add media query listener */
const useMediaQuery = (setThemeState: SetStateAction<ThemeState>) => {
useEffectMinify(() => {
// set event listener for media
const media = matchMedia("(prefers-color-scheme: dark)");
const updateSystemColorScheme = () => {
setThemeState(state => ({ ...state, s: media.matches ? "dark" : "light" }));
};
updateSystemColorScheme();
media.addEventListener("change", updateSystemColorScheme);
return () => {
media.removeEventListener("change", updateSystemColorScheme);
};
}, [setThemeState]);
};
const parseState = (str?: string | null): Partial<ThemeState> => {
const parts = (str ?? ",system").split(",") as [string, ColorSchemePreference];
return { t: parts[0], c: parts[1] };
};
let tInit = 0;
const useLoadSyncedState = (
setThemeState: SetStateAction<ThemeState>,
dontSync?: boolean,
targetId?: string,
) => {
useEffectMinify(() => {
if (dontSync) return;
tInit = Date.now();
const key = targetId ?? DEFAULT_ID;
setThemeState(state => ({ ...state, ...parseState(localStorage.getItem(key)) }));
const storageListener = (e: StorageEvent): void => {
if (e.key === key) setThemeState(state => ({ ...state, ...parseState(e.newValue) }));
};
addEventListener("storage", storageListener);
// skipcq: JS-0045
return () => {
removeEventListener("storage", storageListener);
};
}, [dontSync, setThemeState, targetId]);
};
const modifyTransition = (themeTransition = "none", targetId?: string) => {
const documentMinify = document;
const css = documentMinify.createElement("style");
/** split by ';' to prevent CSS injection */
const transition = `transition: ${themeTransition.split(";")[0]} !important;`;
const targetSelector = targetId
? `#${targetId},#${targetId} *,#${targetId} ~ *,#${targetId} ~ * *`
: "*";
css.appendChild(
documentMinify.createTextNode(
`${targetSelector}{-webkit-${transition}-moz-${transition}-o-${transition}-ms-${transition}${transition}}`,
),
);
documentMinify.head.appendChild(css);
return () => {
// Force restyle
(() => getComputedStyle(documentMinify.body))();
// Wait for next tick before removing
setTimeout(() => {
documentMinify.head.removeChild(css);
}, 1);
};
};
/** Apply classes to the targets */
const applyClasses = (
targets: (HTMLElement | null)[],
theme: string,
resolvedColorScheme: "light" | "dark",
styles?: Record<string, string>,
) => {
let cls = ["dark", "light", `th-${theme}`, resolvedColorScheme];
if (styles) cls = cls.map(c => styles[c] ?? c);
targets.forEach(t => {
t?.classList.remove(cls[0]); // dark
t?.classList.remove(cls[1]); // light
t?.classList.forEach(c => {
if (/(?:^|_)th-/.test(c)) t.classList.remove(c);
});
t?.classList.add(cls[2]); // theme
t?.classList.add(cls[3]); // resolvedColorScheme
});
};
/** Update DOM */
const updateDOM = (
themeState: ThemeState,
targetId?: string,
dontSync?: boolean,
styles?: Record<string, string>,
) => {
const { t: theme, c: csp, s: scs } = themeState;
const resolvedColorScheme = csp === "system" ? scs : csp;
const key = targetId ?? DEFAULT_ID;
// update DOM
let shoulCreateCookie = false;
const documentMinify = document;
const target = documentMinify.getElementById(key);
shoulCreateCookie = !dontSync && target?.getAttribute("data-nth") === "next";
/** do not update documentElement for local targets */
const targets = targetId ? [target] : [target, documentMinify.documentElement];
applyClasses(targets, theme, resolvedColorScheme, styles);
if (shoulCreateCookie)
documentMinify.cookie = `${key}=${theme},${resolvedColorScheme}; max-age=31536000; SameSite=Strict;`;
};
/**
* The core ThemeSwitcher component wich applies classes and transitions.
* Cookies are set only if corresponding ServerTarget is detected.
*/
export const ThemeSwitcher = ({
targetId,
dontSync,
themeTransition,
styles,
}: ThemeSwitcherProps) => {
if (targetId === "") throw new Error("id can not be an empty string");
const [themeState, setThemeState] = useRGSMinify(targetId);
useMediaQuery(setThemeState);
useLoadSyncedState(setThemeState, dontSync, targetId);
/** update DOM and storage */
useEffectMinify(() => {
const restoreTransitions = modifyTransition(themeTransition, targetId);
updateDOM(themeState, targetId, dontSync, styles);
if (!dontSync && tInit < Date.now() - 300) {
// save to localStorage
const { t: theme, c: colorSchemePreference } = themeState;
const stateToSave = [theme, colorSchemePreference].join(",");
const key = targetId ?? DEFAULT_ID;
localStorage.setItem(key, stateToSave);
}
restoreTransitions();
}, [dontSync, styles, targetId, themeState, themeTransition]);
return null;
};