Skip to content

Commit 1d72081

Browse files
committed
theme-switcher component
1 parent 3a2568e commit 1d72081

File tree

1 file changed

+126
-12
lines changed

1 file changed

+126
-12
lines changed
Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,132 @@
1+
import useRGS from "r18gs";
2+
import type { SetStateAction } from "r18gs/use-rgs";
13
import * as React from "react";
4+
import type { ColorSchemePreference, ThemeState } from "../../hooks/use-theme";
5+
import { DEFAULT_ID } from "../../hooks/use-theme";
26

3-
interface ThemeSwitcherProps {
4-
children?: React.ReactNode;
7+
export interface ThemeSwitcherProps {
8+
/** id of target element to apply classes to. This is useful when you want to apply theme only to specific container. */
9+
targetId?: string;
10+
/** To stop persisting and syncing theme between tabs. */
11+
dontSync?: boolean;
12+
/** force apply CSS transition property to all the elements during theme switching. E.g., `all .3s` */
13+
themeTransition?: string;
514
}
615

7-
/**
8-
* # ThemeSwitcher
9-
*
10-
*/
11-
export function ThemeSwitcher({ children }: ThemeSwitcherProps) {
12-
return (
13-
<div>
14-
<h1 data-testid="theme-switcher-h1">theme-switcher</h1>
15-
{children}
16-
</div>
16+
function useMediaQuery(setThemeState: SetStateAction<ThemeState>) {
17+
React.useEffect(() => {
18+
// set event listener for media
19+
const media = matchMedia("(prefers-color-scheme: dark)");
20+
const updateSystemColorScheme = () => {
21+
setThemeState(state => ({ ...state, systemColorScheme: media.matches ? "dark" : "light" }));
22+
};
23+
media.addEventListener("change", updateSystemColorScheme);
24+
return () => {
25+
media.removeEventListener("change", updateSystemColorScheme);
26+
};
27+
}, [setThemeState]);
28+
}
29+
30+
interface LoadSyncedStateProps extends ThemeSwitcherProps {
31+
setThemeState: SetStateAction<ThemeState>;
32+
}
33+
34+
function parseState(str?: string | null) {
35+
const [theme, colorSchemePreference, systemColorScheme] = (str ?? ",system,light").split(",") as [
36+
string,
37+
ColorSchemePreference,
38+
"light" | "dark",
39+
];
40+
return { theme, colorSchemePreference, systemColorScheme };
41+
}
42+
43+
function useLoadSyncedState({ dontSync, targetId, setThemeState }: LoadSyncedStateProps) {
44+
React.useEffect(() => {
45+
if (dontSync) return;
46+
const key = targetId ?? DEFAULT_ID;
47+
setThemeState(parseState(localStorage.getItem(key)));
48+
const storageListener = (e: StorageEvent) => {
49+
if (e.key === key) setThemeState(parseState(e.newValue));
50+
};
51+
window.addEventListener("storage", storageListener);
52+
return () => {
53+
window.removeEventListener("storage", storageListener);
54+
};
55+
}, [dontSync, setThemeState, targetId]);
56+
}
57+
58+
function modifyTransition(themeTransition = "none") {
59+
const css = document.createElement("style");
60+
/** split by ';' to prevent CSS injection */
61+
const transition = `transition: ${themeTransition.split(";")[0]} !important;`;
62+
css.appendChild(
63+
document.createTextNode(
64+
`*{-webkit-${transition}-moz-${transition}-o-${transition}-ms-${transition}${transition}}`,
65+
),
1766
);
67+
document.head.appendChild(css);
68+
69+
return () => {
70+
// Force restyle
71+
(() => window.getComputedStyle(document.body))();
72+
// Wait for next tick before removing
73+
setTimeout(() => {
74+
document.head.removeChild(css);
75+
}, 1);
76+
};
77+
}
78+
79+
interface UpdateDOMProps {
80+
targetId?: string;
81+
themeState: ThemeState;
82+
}
83+
84+
function updateDOM({ targetId, themeState }: UpdateDOMProps) {
85+
const { theme, colorSchemePreference: csp, systemColorScheme: scs } = themeState;
86+
const resolvedColorScheme = csp === "system" ? scs : csp;
87+
// update DOM
88+
let shoulCreateCookie = false;
89+
const target = document.getElementById(targetId ?? DEFAULT_ID);
90+
shoulCreateCookie = target?.getAttribute("data-nth") === "next";
91+
92+
/** do not update documentElement for local targets */
93+
const targets = targetId ? [target] : [target, document.documentElement];
94+
95+
targets.forEach(t => {
96+
t?.classList.remove("dark");
97+
t?.classList.remove("light");
98+
t?.classList.forEach(cls => {
99+
if (cls.startsWith("th-")) t.classList.remove(cls);
100+
});
101+
t?.classList.add(`th-${theme}`);
102+
t?.classList.add(resolvedColorScheme);
103+
});
104+
105+
return shoulCreateCookie;
106+
}
107+
108+
export function ThemeSwitcher({ targetId, dontSync, themeTransition }: ThemeSwitcherProps) {
109+
if (targetId === "") throw new Error("id can not be an empty string");
110+
const [themeState, setThemeState] = useRGS<ThemeState>(targetId ?? DEFAULT_ID);
111+
112+
useMediaQuery(setThemeState);
113+
114+
useLoadSyncedState({ dontSync, targetId, setThemeState });
115+
116+
React.useEffect(() => {
117+
const restoreTransitions = modifyTransition(themeTransition);
118+
const shoulCreateCookie = updateDOM({ targetId, themeState });
119+
if (!dontSync) {
120+
// save to localStorage
121+
const { theme, colorSchemePreference: csp, systemColorScheme: scs } = themeState;
122+
const stateToSave = [theme, csp, scs].join(",");
123+
const key = targetId ?? DEFAULT_ID;
124+
localStorage.setItem(key, stateToSave);
125+
if (shoulCreateCookie) {
126+
document.cookie = `${key}=${stateToSave}; max-age=31536000; SameSite=Strict;`;
127+
}
128+
}
129+
restoreTransitions();
130+
}, [dontSync, targetId, themeState, themeTransition]);
131+
return null;
18132
}

0 commit comments

Comments
 (0)