From 26e43b2eadf4a15b1067346df7bd41292f7640c1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 8 Dec 2022 21:29:46 +0200 Subject: [PATCH] Restore light mode --- app/components/FormWithConfirm.tsx | 4 +- app/components/icons/Moon.tsx | 32 ++++ app/components/icons/Sun.tsx | 33 ++++ app/components/icons/SunAndMoon.tsx | 32 ++++ app/components/layout/SelectedThemeIcon.tsx | 28 +++ app/components/layout/ThemeChanger.tsx | 58 ++++++ app/components/layout/index.tsx | 2 + app/modules/theme/README.md | 3 + app/modules/theme/action.server.ts | 32 ++++ app/modules/theme/index.ts | 2 + app/modules/theme/provider.tsx | 191 ++++++++++++++++++++ app/modules/theme/session.server.ts | 39 ++++ app/root.tsx | 37 +++- app/routes/index.tsx | 13 +- app/routes/theme.ts | 5 + app/styles/common.css | 2 +- app/styles/layout.css | 4 +- app/styles/vars.css | 132 +++++++++----- public/locales/da/common.json | 4 + public/locales/de/common.json | 4 + public/locales/en/common.json | 4 + public/locales/ja/common.json | 4 + public/locales/ru/common.json | 4 + public/locales/zh/common.json | 4 + 24 files changed, 610 insertions(+), 63 deletions(-) create mode 100644 app/components/icons/Moon.tsx create mode 100644 app/components/icons/Sun.tsx create mode 100644 app/components/icons/SunAndMoon.tsx create mode 100644 app/components/layout/SelectedThemeIcon.tsx create mode 100644 app/components/layout/ThemeChanger.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/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index edcebcca8..8631e566d 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -36,9 +36,7 @@ export function FormWithConfirm({ - + diff --git a/app/components/icons/Moon.tsx b/app/components/icons/Moon.tsx new file mode 100644 index 000000000..6490af5c9 --- /dev/null +++ b/app/components/icons/Moon.tsx @@ -0,0 +1,32 @@ +export function MoonIcon({ + className, + alt, + size, +}: { + className?: string; + alt: string; + size?: number; +}) { + return ( + + {alt !== "" && {alt}} + + + ); +} diff --git a/app/components/icons/Sun.tsx b/app/components/icons/Sun.tsx new file mode 100644 index 000000000..82cf5e728 --- /dev/null +++ b/app/components/icons/Sun.tsx @@ -0,0 +1,33 @@ +export function SunIcon({ + className, + alt, + size, +}: { + className?: string; + alt: string; + title?: string; + size?: number; +}) { + return ( + + {alt !== "" && {alt}} + + + ); +} diff --git a/app/components/icons/SunAndMoon.tsx b/app/components/icons/SunAndMoon.tsx new file mode 100644 index 000000000..1f9a45e5b --- /dev/null +++ b/app/components/icons/SunAndMoon.tsx @@ -0,0 +1,32 @@ +export function SunAndMoonIcon({ + className, + alt, + size, +}: { + className?: string; + alt?: string; + size?: number; +}) { + return ( + + {alt !== "" && {alt}} + + + ); +} diff --git a/app/components/layout/SelectedThemeIcon.tsx b/app/components/layout/SelectedThemeIcon.tsx new file mode 100644 index 000000000..878bfa639 --- /dev/null +++ b/app/components/layout/SelectedThemeIcon.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "~/hooks/useTranslation"; +import { Theme, useTheme } from "~/modules/theme"; +import { MoonIcon } from "../icons/Moon"; +import { SunIcon } from "../icons/Sun"; +import { SunAndMoonIcon } from "../icons/SunAndMoon"; + +const ThemeIcons = { + [Theme.LIGHT]: SunIcon, + [Theme.DARK]: MoonIcon, + auto: SunAndMoonIcon, +}; + +export function SelectedThemeIcon({ size }: { size?: number }) { + const { t } = useTranslation(); + const { userTheme } = useTheme(); + + if (!userTheme) return null; + + const SelectedIcon = ThemeIcons[userTheme]; + + return ( + + ); +} diff --git a/app/components/layout/ThemeChanger.tsx b/app/components/layout/ThemeChanger.tsx new file mode 100644 index 000000000..2daf1a394 --- /dev/null +++ b/app/components/layout/ThemeChanger.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "~/hooks/useTranslation"; +import { Theme, useTheme } from "~/modules/theme"; +import { Button } from "../Button"; +import { MoonIcon } from "../icons/Moon"; +import { SunIcon } from "../icons/Sun"; +import { SunAndMoonIcon } from "../icons/SunAndMoon"; +import { Popover } from "../Popover"; +import { SelectedThemeIcon } from "./SelectedThemeIcon"; + +const ThemeIcons = { + [Theme.LIGHT]: SunIcon, + [Theme.DARK]: MoonIcon, + auto: SunAndMoonIcon, +}; + +export function ThemeChanger({ + children, + plain, +}: { + children?: React.ReactNode; + plain?: boolean; +}) { + const { userTheme, setUserTheme } = useTheme(); + const { t } = useTranslation(); + + if (!userTheme) { + return null; + } + + return ( + } + triggerClassName={plain ? undefined : "layout__header__button"} + > +
+ {(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => { + const Icon = ThemeIcons[theme]; + const selected = userTheme === theme; + return ( + + ); + })} +
+
+ ); +} diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index a10487002..6365e3add 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -8,6 +8,7 @@ import { Image } from "../Image"; import { SideNav } from "./SideNav"; import { UserItem } from "./UserItem"; import { LanguageChanger } from "./LanguageChanger"; +import { ThemeChanger } from "./ThemeChanger"; function useBreadcrumbs() { const { t } = useTranslation(); @@ -73,6 +74,7 @@ export const Layout = React.memo(function Layout({
+ {!isCatchBoundary ? : null}
diff --git a/app/modules/theme/README.md b/app/modules/theme/README.md new file mode 100644 index 000000000..7aff6875e --- /dev/null +++ b/app/modules/theme/README.md @@ -0,0 +1,3 @@ +Implements dark mode for a Remix app. + +Based on https://github.com/remix-run/remix/blob/main/examples/dark-mode diff --git a/app/modules/theme/action.server.ts b/app/modules/theme/action.server.ts new file mode 100644 index 000000000..140ccc58e --- /dev/null +++ b/app/modules/theme/action.server.ts @@ -0,0 +1,32 @@ +import type { ActionFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; + +import { getThemeSession } from "./session.server"; +import { isTheme } from "./provider"; + +export const action: ActionFunction = async ({ request }) => { + const themeSession = await getThemeSession(request); + const requestText = await request.text(); + const form = new URLSearchParams(requestText); + const theme = form.get("theme"); + + if (theme === "auto") { + return json( + { success: true }, + { headers: { "Set-Cookie": await themeSession.destroy() } } + ); + } + + 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..8f6fa1d00 --- /dev/null +++ b/app/modules/theme/provider.tsx @@ -0,0 +1,191 @@ +import { useFetcher } from "@remix-run/react"; +import { type ReactNode, useCallback } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; + +enum Theme { + DARK = "dark", + LIGHT = "light", +} +const themes: Array = Object.values(Theme); + +type ThemeContextType = { + /** The CSS class to attach to the `html` tag */ + htmlThemeClass: Theme | ""; + /** The color scheme to be defined in the meta tag */ + metaColorScheme: "light dark" | "dark light"; + /** + * The Theme setting of the user, as displayed in the theme switcher. + * `null` means there is no theme switcher (static theme on error pages). + */ + userTheme: Theme | "auto" | null; + /** Persists a new `userTheme` setting */ + setUserTheme: (newTheme: Theme | "auto") => void; +}; + +const ThemeContext = createContext(undefined); + +const prefersLightMQ = "(prefers-color-scheme: light)"; +const getPreferredTheme = () => + window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK; + +type ThemeProviderProps = { + children: ReactNode; + specifiedTheme: Theme | null; + themeSource: "user-preference" | "static"; +}; + +function ThemeProvider({ + children, + specifiedTheme, + themeSource, +}: ThemeProviderProps) { + const [[theme, isAutoDetected], setThemeState] = useState< + [Theme, false] | [Theme | null, true] + >(() => { + if (themeSource === "static") { + return [specifiedTheme ?? Theme.DARK, false]; + } + + if (specifiedTheme) { + return [specifiedTheme, false]; + } + + /* + If we don't know a preferred user theme, we have to auto-detect it. + Since the server has no way of doing auto-detection, it returns null, + leading to the `html` class and `color-scheme` values being set to a + default. + + Then, on the client, the `clientThemeCode` will run, correcting those + defaults with the determined correct value. + Which means, when we later render this component again, hydration will + succeed. Because the output of `getPreferredTheme()` is (very likely) the + same that the `clientThemeCode` determined and added to the html element + shortly before. + */ + + return [typeof document === "undefined" ? null : getPreferredTheme(), true]; + }); + + const persistThemeFetcher = useFetcher(); + const persistTheme = persistThemeFetcher.submit; + + const setUserTheme = useCallback( + (newTheme: Theme | "auto") => { + setThemeState( + newTheme === "auto" ? [getPreferredTheme(), true] : [newTheme, false] + ); + persistTheme( + { theme: newTheme }, + { + action: "theme", + method: "post", + } + ); + }, + [setThemeState, persistTheme] + ); + + useEffect(() => { + if (!isAutoDetected) { + return; + } + + const mediaQuery = window.matchMedia(prefersLightMQ); + const handleChange = () => { + setThemeState([mediaQuery.matches ? Theme.LIGHT : Theme.DARK, true]); + }; + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [isAutoDetected]); + + 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() { + const { userTheme, metaColorScheme } = useTheme(); + const [initialUserTheme] = useState(userTheme); + + 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 user preference, then we don't need + to do fancy tricks prior to hydration to make things match. + */} + {initialUserTheme === "auto" && ( +