Create a themer

This commit is contained in:
Aditya Thakral 2021-07-27 06:31:17 -04:00
parent 42d4123e72
commit 757b66897e
5 changed files with 281 additions and 38 deletions

178
components/Theme.tsx Normal file
View File

@ -0,0 +1,178 @@
import React, {
createContext,
ReactElement,
useContext,
useEffect,
useState,
} from "react";
type BuiltInThemes = "light" | "dark";
export interface Theme {
name: BuiltInThemes | "custom";
palette: Palette;
}
export type SetThemeInput = BuiltInThemes | Partial<Palette>;
export const PALETTE_NAMES = [
"--primary-background",
"--secondary-background",
"--primary-accent",
"--primary-accent-soft",
"--primary-accent-light",
"--primary-accent-dim",
"--secondary-accent",
"--secondary-accent-light",
"--primary-heading",
"--secondary-heading",
"--text",
"--form-invalid",
"--input-background",
"--input-placeholder-text",
"--input-text",
"--navbar-page-overlay",
] as const;
export const emptyPalette = PALETTE_NAMES.reduce(
(partial, varName) => ({
...partial,
[varName]: "#c4e0f8",
}),
{} as Palette
);
const ThemeContext =
createContext<
| {
theme: Theme;
setTheme(input: SetThemeInput): void;
save(): void;
clearSaved(): void;
}
| undefined
>(undefined);
export interface Props {
children?: ReactElement;
}
export function ThemeProvider(props: Props) {
const update = useForcedUpdate();
const [themeName, setThemeName] =
useState<Theme["name"] | undefined>(undefined);
const setTheme = (input: SetThemeInput) => {
if (typeof input === "string") {
PALETTE_NAMES.forEach((name) =>
document.body.style.setProperty(name, "")
);
if (input === "light") {
document.body.classList.remove("dark");
} else if (input === "dark") {
document.body.classList.add("dark");
}
setThemeName(input);
} else {
const properties = Object.keys(input) as PaletteNames[];
properties.forEach((property) =>
document.body.style.setProperty(
property,
input[property]?.trim() ?? null
)
);
setThemeName("custom");
}
update();
};
useEffect(() => {
const customPalette = getSavedPalette();
if (customPalette == null) {
setThemeName("light");
} else {
setTheme(customPalette);
setThemeName("custom");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<ThemeContext.Provider
value={
themeName == null
? undefined
: {
theme: {
name: themeName,
get palette() {
return getCurrentPalette();
},
},
setTheme,
save: () => savePalette(getCurrentPalette()),
clearSaved: clearSavedPalette,
}
}
>
{props.children}
</ThemeContext.Provider>
);
}
export function useThemeContext() {
return useContext(ThemeContext);
}
export type PaletteNames = typeof PALETTE_NAMES[number];
export type Palette = {
[key in PaletteNames]: string;
};
function getCurrentPalette() {
const styles = getComputedStyle(document.body);
return PALETTE_NAMES.reduce(
(partial, varName) => ({
...partial,
[varName]: styles.getPropertyValue(varName).trim(),
}),
{} as Palette
);
}
function useForcedUpdate() {
const [fakeState, setFakeState] = useState(true);
return () => setFakeState(!fakeState);
}
const STORAGE_KEY = "csc-theme-palette";
function getSavedPalette() {
const raw = localStorage.getItem(STORAGE_KEY);
return raw == null ? undefined : (JSON.parse(raw) as Palette);
}
function savePalette(palette: Palette) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(palette));
}
function clearSavedPalette() {
localStorage.removeItem(STORAGE_KEY);
}

View File

@ -1,36 +0,0 @@
import React, { createContext, ReactElement, useEffect, useState } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{
theme: Theme;
setTheme(newTheme: Theme): void;
}>({
theme: "light",
setTheme: () => {
throw new Error("Use ThemeProvider instead.");
},
});
interface Props {
theme: Theme;
children?: ReactElement;
}
export function ThemeProvider({ children, theme }: Props) {
const [currentTheme, setTheme] = useState(theme);
useEffect(() => {
if (currentTheme === "light") {
document.body.classList.remove("dark");
} else if (currentTheme === "dark") {
document.body.classList.add("dark");
}
}, [currentTheme]);
return (
<ThemeContext.Provider value={{ theme: currentTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}

View File

@ -2,7 +2,7 @@ import React, { ComponentType, ReactNode } from "react";
import { NextComponentType, NextPageContext } from "next";
import { AppProps as DefaultAppProps } from "next/app";
import { MDXProvider } from "@mdx-js/react";
import { ThemeProvider } from "../components/theme";
import { ThemeProvider } from "../components/Theme";
import { Navbar } from "../components/Navbar";
import { Footer } from "../components/Footer";
import { Link } from "../components/Link";
@ -16,7 +16,7 @@ export default function App({ Component, pageProps }: AppProps): JSX.Element {
const Layout = Component.Layout ?? DefaultLayout;
return (
<ThemeProvider theme="light">
<ThemeProvider>
<MDXProvider components={{ a: Link }}>
<div className={styles.appContainer}>
<Navbar />

34
pages/themer.module.css Normal file
View File

@ -0,0 +1,34 @@
.page {
margin-bottom: calc(60rem / 16);
}
.controls {
display: flex;
justify-content: space-between;
}
.palette {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(calc(150rem / 16), 1fr));
row-gap: calc(20rem / 16);
column-gap: calc(40rem / 16);
}
.palette > * {
display: flex;
flex-direction: column;
align-items: center;
}
.colorSelector {
height: calc(75rem / 16);
}
.colorReset {
margin-bottom: calc(10rem / 16);
transition-duration: unset;
}
.colorName {
text-align: center;
}

67
pages/themer.tsx Normal file
View File

@ -0,0 +1,67 @@
import React from "react";
import { useThemeContext, emptyPalette } from "components/Theme";
import { Input } from "components/Input";
import { Button } from "components/Button";
import styles from "./themer.module.css";
export default function Themer() {
const context = useThemeContext();
const palette = context?.theme.palette ?? emptyPalette;
return (
<main className={styles.page}>
<h1>Themer</h1>
<form onSubmit={(event) => event.preventDefault()}>
<div className={styles.controls}>
<Button
type="reset"
onClick={() => {
context?.clearSaved();
context?.setTheme("light");
}}
>
Reset to light mode
</Button>
<Button type="submit" onClick={() => context?.save()}>
Save
</Button>
</div>
<div className={styles.palette}>
{Object.entries(palette).map(([key, value]) => {
return (
<div key={key}>
<Input
id={`color-${key}`}
type="color"
className={styles.colorSelector}
value={value}
onChange={(event) =>
context?.setTheme({ [key]: event.target.value })
}
/>
<Button
type="button"
className={styles.colorReset}
size="small"
onClick={() => context?.setTheme({ [key]: "" })}
>
Reset
</Button>
<code className={styles.colorName}>
<label htmlFor={`color-${key}`}>
{key
.slice(2)
.split("-")
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(" ")}
</label>
</code>
</div>
);
})}
</div>{" "}
</form>
</main>
);
}