Change CSS variable names + create a themer #109

Merged
a3thakra merged 5 commits from adi-create-themer into main 2021-08-03 19:37:03 -04:00
5 changed files with 281 additions and 38 deletions
Showing only changes of commit 757b66897e - Show all commits

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);
a3thakra marked this conversation as resolved
Review

Nit: might be better to have this function return the default palette when the it's not found in local storage, so callers don't have to worry about checking for undefined.

Nit: might be better to have this function return the default palette when the it's not found in local storage, so callers don't have to worry about checking for undefined.
Review

It's only used in 1 place rn (line 102) and I need to check if a saved version exists, so I can assign the theme name correctly. This might change later on when we actually have a dark theme.

It's only used in 1 place rn (line 102) and I need to check if a saved version exists, so I can assign the theme name correctly. This might change later on when we actually have a dark theme.
}
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>
);
}