diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 355745b42..5c792f7d3 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -14,10 +14,12 @@ export function Avatar({ user, size = "sm", className, + alt = "", ...rest }: { user: Pick; className?: string; + alt?: string; size: keyof typeof dimensions; } & React.ButtonHTMLAttributes) { const [isErrored, setIsErrored] = React.useState(false); @@ -38,7 +40,8 @@ export function Avatar({ }.webp${size === "lg" ? "?size=240" : "?size=80"}` : "/img/blank.gif" // avoid broken image placeholder } - alt="" + alt={alt} + title={alt ? alt : undefined} width={dimensions[size]} height={dimensions[size]} // https://github.com/jsx-eslint/eslint-plugin-react/issues/3388 diff --git a/app/components/icons/Globe.tsx b/app/components/icons/Globe.tsx index 516d2abb5..7a1ad3e7a 100644 --- a/app/components/icons/Globe.tsx +++ b/app/components/icons/Globe.tsx @@ -1,4 +1,10 @@ -export function GlobeIcon({ className }: { className?: string }) { +export function GlobeIcon({ + className, + alt, +}: { + className?: string; + alt: string; +}) { return ( + {alt !== "" && {alt}} + {alt !== "" && {alt}} + {alt !== "" && {alt}} + {alt !== "" && {alt}} + + + ); +} diff --git a/app/components/layout/ColorModeToggle.tsx b/app/components/layout/ColorModeToggle.tsx deleted file mode 100644 index 2175d0021..000000000 --- a/app/components/layout/ColorModeToggle.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Theme, useTheme } from "~/modules/theme"; -import { MoonIcon } from "../icons/Moon"; -import { SunIcon } from "../icons/Sun"; - -export function ColorModeToggle() { - const [, setTheme] = useTheme(); - - const toggleTheme = () => { - setTheme((prevTheme) => - prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT - ); - }; - - return ( - - ); -} diff --git a/app/components/layout/LanguageChanger.tsx b/app/components/layout/LanguageChanger.tsx index afc42c4c7..7c6275122 100644 --- a/app/components/layout/LanguageChanger.tsx +++ b/app/components/layout/LanguageChanger.tsx @@ -5,11 +5,16 @@ import { GlobeIcon } from "../icons/Globe"; import { Popover } from "../Popover"; export function LanguageChanger() { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation(); return ( } + buttonChildren={ + + } triggerClassName="layout__header__button" >
diff --git a/app/components/layout/ThemeChanger.tsx b/app/components/layout/ThemeChanger.tsx new file mode 100644 index 000000000..3bc2f216a --- /dev/null +++ b/app/components/layout/ThemeChanger.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +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"; + +const ThemeIcons = { + [Theme.LIGHT]: SunIcon, + [Theme.DARK]: MoonIcon, + auto: SunAndMoonIcon, +}; + +export function ThemeChanger() { + const { userTheme, setUserTheme } = useTheme(); + const { t } = useTranslation(); + + if (!userTheme) { + return null; + } + + const SelectedIcon = ThemeIcons[userTheme]; + + return ( + + } + triggerClassName="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/UserItem.tsx b/app/components/layout/UserItem.tsx index 6c410a025..42b6a9db5 100644 --- a/app/components/layout/UserItem.tsx +++ b/app/components/layout/UserItem.tsx @@ -19,7 +19,14 @@ export function UserItem() { return ( + } >
diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index b939b001e..0fa778b7a 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -5,7 +5,7 @@ import type { RootLoaderData } from "~/root"; import { type SendouRouteHandle } from "~/utils/remix"; import { LOGO_PATH, navIconUrl } from "~/utils/urls"; import { Image } from "../Image"; -import { ColorModeToggle } from "./ColorModeToggle"; +import { ThemeChanger } from "./ThemeChanger"; import { Footer } from "./Footer"; import { HamburgerButton } from "./HamburgerButton"; import { LanguageChanger } from "./LanguageChanger"; @@ -60,7 +60,7 @@ export const Layout = React.memo(function Layout({
{!isCatchBoundary ? : null} - + setMenuOpen(!menuOpen)} diff --git a/app/modules/theme/action.server.ts b/app/modules/theme/action.server.ts index 720214ace..140ccc58e 100644 --- a/app/modules/theme/action.server.ts +++ b/app/modules/theme/action.server.ts @@ -10,6 +10,13 @@ export const action: ActionFunction = async ({ request }) => { 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, diff --git a/app/modules/theme/provider.tsx b/app/modules/theme/provider.tsx index 5e2283c84..33bf2175b 100644 --- a/app/modules/theme/provider.tsx +++ b/app/modules/theme/provider.tsx @@ -1,6 +1,6 @@ import { useFetcher } from "@remix-run/react"; -import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { type ReactNode, useCallback } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; enum Theme { DARK = "dark", @@ -8,7 +8,19 @@ enum Theme { } const themes: Array = Object.values(Theme); -type ThemeContextType = [Theme | null, Dispatch>]; +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); @@ -16,70 +28,91 @@ 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, -}: { - 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. + themeSource, +}: ThemeProviderProps) { + const [[theme, isAutoDetected], setThemeState] = useState< + [Theme, false] | [Theme | null, true] + >(() => { + if (themeSource === "static") { + return [specifiedTheme ?? Theme.DARK, false]; + } + if (specifiedTheme) { - if (themes.includes(specifiedTheme)) { - return specifiedTheme; - } else { - return null; - } + return [specifiedTheme, false]; } - // 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; - } + /* + If we don't know a preferred user theme, we have to auto-detect it. - return getPreferredTheme(); + 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 persistTheme = useFetcher(); - // TODO: remove this when persistTheme is memoized properly - const persistThemeRef = useRef(persistTheme); - useEffect(() => { - persistThemeRef.current = persistTheme; - }, [persistTheme]); + const persistThemeFetcher = useFetcher(); + const persistTheme = persistThemeFetcher.submit; - const mountRun = useRef(false); + const setUserTheme = useCallback( + (newTheme: Theme | "auto") => { + setThemeState( + newTheme === "auto" ? [getPreferredTheme(), true] : [newTheme, false] + ); + persistTheme( + { theme: newTheme }, + { + action: "theme", + method: "post", + } + ); + }, + [setThemeState, persistTheme] + ); useEffect(() => { - if (!mountRun.current) { - mountRun.current = true; - return; - } - if (!theme) { + if (!isAutoDetected) { 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); + setThemeState([mediaQuery.matches ? Theme.LIGHT : Theme.DARK, true]); }; mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); - }, []); + }, [isAutoDetected]); return ( - + {children} ); @@ -117,8 +150,9 @@ const clientThemeCode = ` })(); `; -function ThemeHead({ ssrTheme }: { ssrTheme: boolean }) { - const [theme] = useTheme(); +function ThemeHead() { + const { userTheme, metaColorScheme } = useTheme(); + const [initialUserTheme] = useState(userTheme); return ( <> @@ -126,24 +160,19 @@ function ThemeHead({ ssrTheme }: { ssrTheme: boolean }) { 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 + 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. */} - {ssrTheme ? null : ( - <> -