286 lines
6.1 KiB
TypeScript
286 lines
6.1 KiB
TypeScript
import * as React from "react";
|
|
|
|
type Theme = "dark" | "light" | "system";
|
|
type ResolvedTheme = "dark" | "light";
|
|
|
|
type ThemeProviderProps = {
|
|
children: React.ReactNode;
|
|
defaultTheme?: Theme;
|
|
storageKey?: string;
|
|
disableTransitionOnChange?: boolean;
|
|
};
|
|
|
|
type ThemeProviderState = {
|
|
theme: Theme;
|
|
setTheme: (theme: Theme) => void;
|
|
};
|
|
|
|
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
|
|
const THEME_VALUES: Theme[] = ["dark", "light", "system"];
|
|
|
|
const ThemeProviderContext = React.createContext<
|
|
ThemeProviderState | undefined
|
|
>(undefined);
|
|
|
|
/**
|
|
* Executes isTheme.
|
|
* @param value Parameter value.
|
|
* @returns value is Theme.
|
|
*/
|
|
function isTheme(value: string | null): value is Theme {
|
|
if (value === null) {
|
|
return false;
|
|
}
|
|
|
|
return THEME_VALUES.includes(value as Theme);
|
|
}
|
|
|
|
/**
|
|
* Executes getSystemTheme.
|
|
* @param none This function has no parameters.
|
|
* @returns ResolvedTheme.
|
|
*/
|
|
function getSystemTheme(): ResolvedTheme {
|
|
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
|
|
return "dark";
|
|
}
|
|
|
|
return "light";
|
|
}
|
|
|
|
/**
|
|
* Executes disableTransitionsTemporarily.
|
|
* @param none This function has no parameters.
|
|
* @returns unknown.
|
|
*/
|
|
function disableTransitionsTemporarily() {
|
|
const style = document.createElement("style");
|
|
style.appendChild(
|
|
document.createTextNode(
|
|
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}",
|
|
),
|
|
);
|
|
document.head.appendChild(style);
|
|
|
|
return () => {
|
|
window.getComputedStyle(document.body);
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
style.remove();
|
|
});
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Executes isEditableTarget.
|
|
* @param target Parameter target.
|
|
* @returns unknown.
|
|
*/
|
|
function isEditableTarget(target: EventTarget | null) {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
if (target.isContentEditable) {
|
|
return true;
|
|
}
|
|
|
|
const editableParent = target.closest(
|
|
"input, textarea, select, [contenteditable='true']",
|
|
);
|
|
if (editableParent) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Executes ThemeProvider.
|
|
* @param {
|
|
children,
|
|
defaultTheme = "system",
|
|
storageKey = "theme",
|
|
disableTransitionOnChange = true,
|
|
...props
|
|
} Parameter {
|
|
children,
|
|
defaultTheme = "system",
|
|
storageKey = "theme",
|
|
disableTransitionOnChange = true,
|
|
...props
|
|
}.
|
|
* @returns unknown.
|
|
*/
|
|
export function ThemeProvider({
|
|
children,
|
|
defaultTheme = "system",
|
|
storageKey = "theme",
|
|
disableTransitionOnChange = true,
|
|
...props
|
|
}: ThemeProviderProps) {
|
|
const [theme, setThemeState] = React.useState<Theme>(() => {
|
|
const storedTheme = localStorage.getItem(storageKey);
|
|
if (isTheme(storedTheme)) {
|
|
return storedTheme;
|
|
}
|
|
|
|
return defaultTheme;
|
|
});
|
|
|
|
const setTheme = React.useCallback(
|
|
(nextTheme: Theme) => {
|
|
localStorage.setItem(storageKey, nextTheme);
|
|
setThemeState(nextTheme);
|
|
},
|
|
[storageKey],
|
|
);
|
|
|
|
const applyTheme = React.useCallback(
|
|
(nextTheme: Theme) => {
|
|
const root = document.documentElement;
|
|
const resolvedTheme =
|
|
nextTheme === "system" ? getSystemTheme() : nextTheme;
|
|
const restoreTransitions = disableTransitionOnChange
|
|
? disableTransitionsTemporarily()
|
|
: null;
|
|
|
|
root.classList.remove("light", "dark");
|
|
root.classList.add(resolvedTheme);
|
|
|
|
if (restoreTransitions) {
|
|
restoreTransitions();
|
|
}
|
|
},
|
|
[disableTransitionOnChange],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
applyTheme(theme);
|
|
|
|
if (theme !== "system") {
|
|
return undefined;
|
|
}
|
|
|
|
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY);
|
|
/**
|
|
* Executes handleChange.
|
|
* @param none This function has no parameters.
|
|
* @returns unknown.
|
|
*/
|
|
const handleChange = () => {
|
|
applyTheme("system");
|
|
};
|
|
|
|
mediaQuery.addEventListener("change", handleChange);
|
|
|
|
return () => {
|
|
mediaQuery.removeEventListener("change", handleChange);
|
|
};
|
|
}, [theme, applyTheme]);
|
|
|
|
React.useEffect(() => {
|
|
/**
|
|
* Executes handleKeyDown.
|
|
* @param event Parameter event.
|
|
* @returns unknown.
|
|
*/
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.repeat) {
|
|
return;
|
|
}
|
|
|
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
return;
|
|
}
|
|
|
|
if (isEditableTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
if (event.key.toLowerCase() !== "d") {
|
|
return;
|
|
}
|
|
|
|
setThemeState((currentTheme) => {
|
|
const nextTheme =
|
|
currentTheme === "dark"
|
|
? "light"
|
|
: currentTheme === "light"
|
|
? "dark"
|
|
: getSystemTheme() === "dark"
|
|
? "light"
|
|
: "dark";
|
|
|
|
localStorage.setItem(storageKey, nextTheme);
|
|
return nextTheme;
|
|
});
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [storageKey]);
|
|
|
|
React.useEffect(() => {
|
|
/**
|
|
* Executes handleStorageChange.
|
|
* @param event Parameter event.
|
|
* @returns unknown.
|
|
*/
|
|
const handleStorageChange = (event: StorageEvent) => {
|
|
if (event.storageArea !== localStorage) {
|
|
return;
|
|
}
|
|
|
|
if (event.key !== storageKey) {
|
|
return;
|
|
}
|
|
|
|
if (isTheme(event.newValue)) {
|
|
setThemeState(event.newValue);
|
|
return;
|
|
}
|
|
|
|
setThemeState(defaultTheme);
|
|
};
|
|
|
|
window.addEventListener("storage", handleStorageChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("storage", handleStorageChange);
|
|
};
|
|
}, [defaultTheme, storageKey]);
|
|
|
|
const value = React.useMemo(
|
|
() => ({
|
|
theme,
|
|
setTheme,
|
|
}),
|
|
[theme, setTheme],
|
|
);
|
|
|
|
return (
|
|
<ThemeProviderContext.Provider {...props} value={value}>
|
|
{children}
|
|
</ThemeProviderContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Executes useTheme.
|
|
* @param none This function has no parameters.
|
|
* @returns unknown.
|
|
*/
|
|
export const useTheme = () => {
|
|
const context = React.useContext(ThemeProviderContext);
|
|
|
|
if (context === undefined) {
|
|
throw new Error("useTheme must be used within a ThemeProvider");
|
|
}
|
|
|
|
return context;
|
|
};
|