Add auto theme option for detecting the theme from system/browser preferences (#1083)

* Add "auto" option to theme switcher

* Add labels to language and user menu buttons

* Update translation-progress.md
This commit is contained in:
Remmy Cat Stock
2022-11-03 01:44:56 +01:00
committed by GitHub
parent 0d8b88ea5a
commit 19fbd85f8f
17 changed files with 332 additions and 111 deletions

View File

@@ -14,10 +14,12 @@ export function Avatar({
user,
size = "sm",
className,
alt = "",
...rest
}: {
user: Pick<User, "discordId" | "discordAvatar">;
className?: string;
alt?: string;
size: keyof typeof dimensions;
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
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

View File

@@ -1,4 +1,10 @@
export function GlobeIcon({ className }: { className?: string }) {
export function GlobeIcon({
className,
alt,
}: {
className?: string;
alt: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -7,7 +13,11 @@ export function GlobeIcon({ className }: { className?: string }) {
strokeWidth={1.5}
stroke="currentColor"
className={className}
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
>
{alt !== "" && <title>{alt}</title>}
<path
strokeLinecap="round"
strokeLinejoin="round"

View File

@@ -1,4 +1,10 @@
export function MoonIcon({ className }: { className?: string }) {
export function MoonIcon({
className,
alt,
}: {
className?: string;
alt: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -7,7 +13,11 @@ export function MoonIcon({ className }: { className?: string }) {
strokeWidth={1.5}
stroke="currentColor"
className={className}
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
>
{alt !== "" && <title>{alt}</title>}
<path
strokeLinecap="round"
strokeLinejoin="round"

View File

@@ -1,4 +1,11 @@
export function SunIcon({ className }: { className?: string }) {
export function SunIcon({
className,
alt,
}: {
className?: string;
alt: string;
title?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -7,7 +14,11 @@ export function SunIcon({ className }: { className?: string }) {
strokeWidth={1.5}
stroke="currentColor"
className={className}
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
>
{alt !== "" && <title>{alt}</title>}
<path
strokeLinecap="round"
strokeLinejoin="round"

View File

@@ -0,0 +1,28 @@
export function SunAndMoonIcon({
className,
alt,
}: {
className?: string;
alt?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
>
{alt !== "" && <title>{alt}</title>}
<path
d="m19.921 16.252-1.4411-1.4841m-8.6467-8.9048-1.4411-1.4841m5.8557-0.26889 0.03042-2.0685m4.3307 3.9505 1.4841-1.4411m0.2689 5.8557 2.0685 0.03042m-10.747 2.2802c-3.2025-3.2981 1.7446-8.1018 4.9471-4.8037 3.2012 3.298-1.7459 8.1018-4.9471 4.8037zm3.233 5.4901a7.0662 7.0662 0 0 1-2.7676 0.28142c-3.8978-0.37316-6.7547-3.8351-6.3816-7.7328 0.092161-0.96268 0.3725-1.8613 0.80141-2.6639a7.0917 7.0917 0 0 0-4.9653 6.1002c-0.37316 3.8978 2.4838 7.3597 6.3816 7.7328a7.0917 7.0917 0 0 0 6.9314-3.7177z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -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 (
<button className="layout__header__button" onClick={toggleTheme}>
<SunIcon className="light-mode-only layout__header__button__icon" />
<MoonIcon className="dark-mode-only layout__header__button__icon" />
</button>
);
}

View File

@@ -5,11 +5,16 @@ import { GlobeIcon } from "../icons/Globe";
import { Popover } from "../Popover";
export function LanguageChanger() {
const { i18n } = useTranslation();
const { t, i18n } = useTranslation();
return (
<Popover
buttonChildren={<GlobeIcon className="layout__header__button__icon" />}
buttonChildren={
<GlobeIcon
alt={t("header.language")}
className="layout__header__button__icon"
/>
}
triggerClassName="layout__header__button"
>
<div className="layout__user-popover">

View File

@@ -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 (
<Popover
buttonChildren={
<SelectedIcon
alt={t("header.theme")}
className="layout__header__button__icon"
/>
}
triggerClassName="layout__header__button"
>
<div className="layout__user-popover">
{(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
const Icon = ThemeIcons[theme];
const selected = userTheme === theme;
return (
<Button
variant="minimal"
key={theme}
tiny
icon={<Icon alt="" />}
// TODO: Remove this and find better semantic representation than
// just multiple buttons. Maybe radio group?
aria-current={selected}
className={selected ? undefined : "text-main-forced"}
onClick={() => setUserTheme(theme)}
>
{t(`theme.${theme}`)}
</Button>
);
})}
</div>
</Popover>
);
}

View File

@@ -19,7 +19,14 @@ export function UserItem() {
return (
<Popover
buttonChildren={
<Avatar user={user} className="layout__avatar" size="sm" />
<Avatar
user={user}
alt={t("header.loggedInAs", {
userName: `${user.discordName}`,
})}
className="layout__avatar"
size="sm"
/>
}
>
<div className="layout__user-popover">

View File

@@ -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({
<div className="layout__header__right-container">
{!isCatchBoundary ? <UserItem /> : null}
<LanguageChanger />
<ColorModeToggle />
<ThemeChanger />
<HamburgerButton
expanded={menuOpen}
onClick={() => setMenuOpen(!menuOpen)}

View File

@@ -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,

View File

@@ -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<Theme> = Object.values(Theme);
type ThemeContextType = [Theme | null, Dispatch<SetStateAction<Theme | null>>];
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<ThemeContextType | undefined>(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<Theme | null>(() => {
// 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 (
<ThemeContext.Provider value={[theme, setTheme]}>
<ThemeContext.Provider
value={{
// Gets corrected by clientThemeCode if set to "" during SSR
htmlThemeClass: theme ?? "",
// Gets corrected by clientThemeCode if set to wrong value during SSR
metaColorScheme: theme === "light" ? "light dark" : "dark light",
userTheme:
themeSource === "static" ? null : isAutoDetected ? "auto" : theme!,
setUserTheme,
}}
>
{children}
</ThemeContext.Provider>
);
@@ -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.
*/}
<meta
name="color-scheme"
content={theme === "light" ? "light dark" : "dark light"}
/>
<meta name="color-scheme" content={metaColorScheme} />
{/*
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 : (
<>
<script
// NOTE: we cannot use type="module" because that automatically makes
// the script "defer". That doesn't work for us because we need
// this script to run synchronously before the rest of the document
// is finished loading.
dangerouslySetInnerHTML={{ __html: clientThemeCode }}
/>
</>
{initialUserTheme === "auto" && (
<script
// NOTE: we cannot use type="module" because that automatically makes
// the script "defer". That doesn't work for us because we need
// this script to run synchronously before the rest of the document
// is finished loading.
dangerouslySetInnerHTML={{ __html: clientThemeCode }}
/>
)}
</>
);

View File

@@ -32,6 +32,7 @@ async function getThemeSession(request: Request) {
},
setTheme: (theme: Theme) => session.set("theme", theme),
commit: () => themeStorage.commitSession(session),
destroy: () => themeStorage.destroySession(session, { maxAge: 0 }),
};
}

View File

@@ -35,6 +35,7 @@ import { COMMON_PREVIEW_IMAGE } from "./utils/urls";
import { ConditionalScrollRestoration } from "./components/ConditionalScrollRestoration";
import { type SendouRouteHandle } from "~/utils/remix";
import generalI18next from "i18next";
import { isTheme } from "./modules/theme/provider";
export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => {
// reload on language change so the selected language gets set into the cookie
@@ -63,11 +64,16 @@ export const meta: MetaFunction = () => ({
export interface RootLoaderData {
locale: string;
theme: Theme | null;
theme: string | null;
patrons: FindAllPatrons;
user?: Pick<
UserWithPlusTier,
"id" | "discordId" | "discordAvatar" | "plusTier" | "customUrl"
| "id"
| "discordId"
| "discordAvatar"
| "plusTier"
| "customUrl"
| "discordName"
>;
}
@@ -83,6 +89,7 @@ export const loader: LoaderFunction = async ({ request }) => {
patrons: db.users.findAllPatrons(),
user: user
? {
discordName: user.discordName,
discordAvatar: user.discordAvatar,
discordId: user.discordId,
id: user.id,
@@ -108,7 +115,7 @@ function Document({
children: React.ReactNode;
data?: RootLoaderData;
}) {
const [theme] = useTheme();
const { htmlThemeClass } = useTheme();
const { i18n } = useTranslation();
const locale = data?.locale ?? DEFAULT_LANGUAGE;
@@ -116,11 +123,11 @@ function Document({
usePreloadTranslation();
return (
<html lang={locale} dir={i18n.dir()} className={theme ?? ""}>
<html lang={locale} dir={i18n.dir()} className={htmlThemeClass}>
<head>
<Meta />
<Links />
<ThemeHead ssrTheme={Boolean(data?.theme)} />
<ThemeHead />
</head>
<body>
<React.StrictMode>
@@ -169,7 +176,10 @@ export default function App() {
const data = useLoaderData<RootLoaderData>();
return (
<ThemeProvider specifiedTheme={data?.theme ?? null}>
<ThemeProvider
specifiedTheme={isTheme(data.theme) ? data.theme : null}
themeSource="user-preference"
>
<Document data={data}>
<Outlet />
</Document>
@@ -179,7 +189,7 @@ export default function App() {
export function CatchBoundary() {
return (
<ThemeProvider specifiedTheme={Theme.DARK}>
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
<Document>
<Catcher />
</Document>
@@ -191,7 +201,7 @@ export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => {
console.error(error);
return (
<ThemeProvider specifiedTheme={Theme.DARK}>
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
<Document>
<Catcher />
</Document>

View File

@@ -14,6 +14,9 @@
"header.profile": "Profil",
"header.logout": "Ausloggen",
"header.login": "Einloggen",
"header.language": "Sprache",
"header.loggedInAs": "Eingeloggt als {{userName}}",
"header.theme": "Theme",
"auth.errors.aborted": "Einloggen abgebrochen",
"auth.errors.failed": "Einloggen fehlgeschlagen",
@@ -83,5 +86,9 @@
"weapon.category.DUALIES": "Doppler",
"weapon.category.BRELLAS": "Pluviatoren",
"weapon.category.STRINGERS": "Stringer",
"weapon.category.SPLATANAS": "Splatanas"
"weapon.category.SPLATANAS": "Splatanas",
"theme.light": "Hell",
"theme.dark": "Dunkel",
"theme.auto": "Auto"
}

View File

@@ -15,6 +15,9 @@
"header.profile": "Profile",
"header.logout": "Log out",
"header.login": "Log in",
"header.language": "Language",
"header.loggedInAs": "Logged in as {{userName}}",
"header.theme": "Theme",
"auth.errors.aborted": "Login Aborted",
"auth.errors.failed": "Login Failed",
@@ -84,5 +87,9 @@
"weapon.category.DUALIES": "Dualies",
"weapon.category.BRELLAS": "Brellas",
"weapon.category.STRINGERS": "Stringers",
"weapon.category.SPLATANAS": "Splatanas"
"weapon.category.SPLATANAS": "Splatanas",
"theme.light": "Light",
"theme.dark": "Dark",
"theme.auto": "Auto"
}

View File

@@ -20,12 +20,18 @@
### 🟡 common.json
**75/76**
**75/82**
<details>
<summary>Missing</summary>
- pages.articles
- header.language
- header.loggedInAs
- header.theme
- theme.light
- theme.dark
- theme.auto
</details>
@@ -78,7 +84,7 @@
### 🟡 common.json
**75/76**
**81/82**
<details>
<summary>Missing</summary>
@@ -170,7 +176,7 @@
### 🟡 common.json
**48/76**
**48/82**
<details>
<summary>Missing</summary>
@@ -179,6 +185,9 @@
- pages.s2
- pages.maps
- pages.object-damage-calculator
- header.language
- header.loggedInAs
- header.theme
- auth.errors.aborted
- auth.errors.failed
- auth.errors.discordPermissions
@@ -203,6 +212,9 @@
- maps.template.preset.onlyMode
- forms.errors.noSearchMatches
- errors.genericReload
- theme.light
- theme.dark
- theme.auto
</details>
@@ -331,7 +343,7 @@
### 🟡 common.json
**47/76**
**47/82**
<details>
<summary>Missing</summary>
@@ -340,6 +352,9 @@
- pages.analyzer
- pages.maps
- pages.object-damage-calculator
- header.language
- header.loggedInAs
- header.theme
- auth.errors.aborted
- auth.errors.failed
- auth.errors.discordPermissions
@@ -365,6 +380,9 @@
- maps.template.preset.onlyMode
- forms.errors.noSearchMatches
- errors.genericReload
- theme.light
- theme.dark
- theme.auto
</details>
@@ -468,7 +486,7 @@
### 🔴 common.json
**0/76**
**0/82**
### 🔴 contributions.json
@@ -528,12 +546,18 @@
### 🟡 common.json
**75/76**
**75/82**
<details>
<summary>Missing</summary>
- pages.articles
- header.language
- header.loggedInAs
- header.theme
- theme.light
- theme.dark
- theme.auto
</details>
@@ -594,7 +618,7 @@
### 🟡 common.json
**35/76**
**35/82**
<details>
<summary>Missing</summary>
@@ -604,6 +628,9 @@
- pages.analyzer
- pages.maps
- pages.object-damage-calculator
- header.language
- header.loggedInAs
- header.theme
- auth.errors.aborted
- auth.errors.failed
- auth.errors.discordPermissions
@@ -640,6 +667,9 @@
- weapon.category.BRELLAS
- weapon.category.STRINGERS
- weapon.category.SPLATANAS
- theme.light
- theme.dark
- theme.auto
</details>
@@ -766,13 +796,16 @@
### 🟡 common.json
**55/76**
**55/82**
<details>
<summary>Missing</summary>
- pages.articles
- pages.object-damage-calculator
- header.language
- header.loggedInAs
- header.theme
- auth.errors.aborted
- auth.errors.failed
- auth.errors.discordPermissions
@@ -792,6 +825,9 @@
- maps.template.preset.onlyMode
- forms.errors.noSearchMatches
- errors.genericReload
- theme.light
- theme.dark
- theme.auto
</details>
@@ -883,12 +919,15 @@
### 🟡 common.json
**61/76**
**61/82**
<details>
<summary>Missing</summary>
- pages.articles
- header.language
- header.loggedInAs
- header.theme
- actions.loading
- actions.clear
- actions.selectAll
@@ -903,6 +942,9 @@
- maps.template.preset.onlyMode
- forms.errors.noSearchMatches
- errors.genericReload
- theme.light
- theme.dark
- theme.auto
</details>
@@ -998,7 +1040,7 @@
### 🟡 common.json
**35/76**
**35/82**
<details>
<summary>Missing</summary>
@@ -1008,6 +1050,9 @@
- pages.analyzer
- pages.maps
- pages.object-damage-calculator
- header.language
- header.loggedInAs
- header.theme
- auth.errors.aborted
- auth.errors.failed
- auth.errors.discordPermissions
@@ -1044,6 +1089,9 @@
- weapon.category.BRELLAS
- weapon.category.STRINGERS
- weapon.category.SPLATANAS
- theme.light
- theme.dark
- theme.auto
</details>