From db0878c3ab8b80dbb928107ddf2785ef62658b28 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 4 Sep 2022 15:23:59 +0300 Subject: [PATCH] Add color mode toggle Closes #830 --- app/components/icons/Moon.tsx | 18 +++ app/components/icons/Sun.tsx | 18 +++ app/components/layout/index.tsx | 17 +++ app/modules/theme/README.md | 3 + app/modules/theme/action.server.ts | 25 +++++ app/modules/theme/index.ts | 2 + app/modules/theme/provider.tsx | 164 ++++++++++++++++++++++++++++ app/modules/theme/session.server.ts | 35 ++++++ app/root.tsx | 26 +++-- app/routes/theme.ts | 5 + app/styles/layout.css | 16 +++ app/styles/vars.css | 68 ++++++------ 12 files changed, 358 insertions(+), 39 deletions(-) create mode 100644 app/components/icons/Moon.tsx create mode 100644 app/components/icons/Sun.tsx create mode 100644 app/modules/theme/README.md create mode 100644 app/modules/theme/action.server.ts create mode 100644 app/modules/theme/index.ts create mode 100644 app/modules/theme/provider.tsx create mode 100644 app/modules/theme/session.server.ts create mode 100644 app/routes/theme.ts diff --git a/app/components/icons/Moon.tsx b/app/components/icons/Moon.tsx new file mode 100644 index 000000000..20f491b59 --- /dev/null +++ b/app/components/icons/Moon.tsx @@ -0,0 +1,18 @@ +export function MoonIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/icons/Sun.tsx b/app/components/icons/Sun.tsx new file mode 100644 index 000000000..c6d01eb31 --- /dev/null +++ b/app/components/icons/Sun.tsx @@ -0,0 +1,18 @@ +export function SunIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index efa0b9282..37ce75807 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -8,6 +8,9 @@ import { Image } from "../Image"; import { Footer } from "./Footer"; import type { RootLoaderData } from "~/root"; import { useTranslation } from "react-i18next"; +import { Theme, useTheme } from "~/modules/theme"; +import { MoonIcon } from "../icons/Moon"; +import { SunIcon } from "../icons/Sun"; export const Layout = React.memo(function Layout({ children, @@ -18,6 +21,7 @@ export const Layout = React.memo(function Layout({ patrons?: RootLoaderData["patrons"]; isCatchBoundary?: boolean; }) { + const [, setTheme] = useTheme(); const { t } = useTranslation(); const location = useLocation(); const [menuOpen, setMenuOpen] = React.useState(false); @@ -26,6 +30,12 @@ export const Layout = React.memo(function Layout({ location.pathname.includes(navItem.name) ); + const toggleTheme = () => { + setTheme((prevTheme) => + prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT + ); + }; + return (
@@ -44,6 +54,13 @@ export const Layout = React.memo(function Layout({
)}
+ {!isCatchBoundary ? : null} { + const themeSession = await getThemeSession(request); + const requestText = await request.text(); + const form = new URLSearchParams(requestText); + const theme = form.get("theme"); + + if (!isTheme(theme)) { + return json({ + success: false, + message: `theme value of ${theme ?? "null"} is not a valid theme`, + }); + } + + themeSession.setTheme(theme); + return json( + { success: true }, + { headers: { "Set-Cookie": await themeSession.commit() } } + ); +}; diff --git a/app/modules/theme/index.ts b/app/modules/theme/index.ts new file mode 100644 index 000000000..119743922 --- /dev/null +++ b/app/modules/theme/index.ts @@ -0,0 +1,2 @@ +export { Theme, ThemeHead, ThemeProvider, useTheme } from "./provider"; +export { action } from "./action.server"; diff --git a/app/modules/theme/provider.tsx b/app/modules/theme/provider.tsx new file mode 100644 index 000000000..5e2283c84 --- /dev/null +++ b/app/modules/theme/provider.tsx @@ -0,0 +1,164 @@ +import { useFetcher } from "@remix-run/react"; +import type { Dispatch, ReactNode, SetStateAction } from "react"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; + +enum Theme { + DARK = "dark", + LIGHT = "light", +} +const themes: Array = Object.values(Theme); + +type ThemeContextType = [Theme | null, Dispatch>]; + +const ThemeContext = createContext(undefined); + +const prefersLightMQ = "(prefers-color-scheme: light)"; +const getPreferredTheme = () => + window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK; + +function ThemeProvider({ + children, + specifiedTheme, +}: { + children: ReactNode; + specifiedTheme: Theme | null; +}) { + const [theme, setTheme] = useState(() => { + // On the server, if we don't have a specified theme then we should + // return null and the clientThemeCode will set the theme for us + // before hydration. Then (during hydration), this code will get the same + // value that clientThemeCode got so hydration is happy. + if (specifiedTheme) { + if (themes.includes(specifiedTheme)) { + return specifiedTheme; + } else { + return null; + } + } + + // there's no way for us to know what the theme should be in this context + // the client will have to figure it out before hydration. + if (typeof document === "undefined") { + return null; + } + + return getPreferredTheme(); + }); + + const persistTheme = useFetcher(); + // TODO: remove this when persistTheme is memoized properly + const persistThemeRef = useRef(persistTheme); + useEffect(() => { + persistThemeRef.current = persistTheme; + }, [persistTheme]); + + const mountRun = useRef(false); + + useEffect(() => { + if (!mountRun.current) { + mountRun.current = true; + return; + } + if (!theme) { + return; + } + + persistThemeRef.current.submit( + { theme }, + { action: "theme", method: "post" } + ); + }, [theme]); + + useEffect(() => { + const mediaQuery = window.matchMedia(prefersLightMQ); + const handleChange = () => { + setTheme(mediaQuery.matches ? Theme.DARK : Theme.LIGHT); + }; + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, []); + + return ( + + {children} + + ); +} + +// this is how I make certain we avoid a flash of the wrong theme. If you select +// a theme, then I'll know what you want in the future and you'll not see this +// script anymore. +const clientThemeCode = ` +;(() => { + const theme = window.matchMedia(${JSON.stringify(prefersLightMQ)}).matches + ? 'light' + : 'dark'; + const cl = document.documentElement.classList; + const themeAlreadyApplied = cl.contains('light') || cl.contains('dark'); + if (themeAlreadyApplied) { + console.warn( + "Script is running but theme is already applied", + ); + } else { + cl.add(theme); + } + const meta = document.querySelector('meta[name=color-scheme]'); + if (meta) { + if (theme === 'dark') { + meta.content = 'dark light'; + } else if (theme === 'light') { + meta.content = 'light dark'; + } + } else { + console.warn( + "No meta tag", + ); + } +})(); +`; + +function ThemeHead({ ssrTheme }: { ssrTheme: boolean }) { + const [theme] = useTheme(); + + return ( + <> + {/* + On the server, "theme" might be `null`, so clientThemeCode ensures that + this is correct before hydration. + */} + + {/* + If we know what the theme is from the server then we don't need + to do fancy tricks prior to hydration to make things match. + */} + {ssrTheme ? null : ( + <> +