Change CSS variable names + create a themer #109
|
@ -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
|
||||
}
|
||||
|
||||
function savePalette(palette: Palette) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(palette));
|
||||
}
|
||||
|
||||
function clearSavedPalette() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 />
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
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.
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.