client/packages/ui/src/theme.tsx

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;
};