111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
import { useMemo } from "react";
|
|||
|
|
import { useFetcher, useRouteLoaderData } from "react-router";
|
||
|
|
|
||
|
|
import { createTranslator } from "~/i18n/t";
|
||
|
|
import type { AppTheme } from "~/lib/theme";
|
||
|
|
|
||
|
|
import type { Locale } from "../LanguageSwitcher";
|
||
|
|
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
|
||
|
|
|
||
|
|
/** The document surface the root loader currently paints on <html>. */
|
||
|
|
interface RootLoaderData {
|
||
|
|
theme?: AppTheme;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A minimal sun, for "switch to light". Decorative - the label calls it. */
|
||
|
|
function SunIcon() {
|
||
|
|
return (
|
||
|
|
<svg
|
||
|
|
width="18"
|
||
|
|
height="18"
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth="2"
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<circle cx="12" cy="12" r="4" />
|
||
|
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A minimal crescent moon, for "switch to dark". Decorative. */
|
||
|
|
function MoonIcon() {
|
||
|
|
return (
|
||
|
|
<svg
|
||
|
|
width="18"
|
||
|
|
height="18"
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth="2"
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ThemeToggleProps {
|
||
|
|
/** Reading locale, for the accessible label. */
|
||
|
|
locale: Locale;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The site's one global theme control: sun in dark mode, moon in light.
|
||
|
|
*
|
||
|
|
* It renders the target of the next click - the label and the icon describe
|
||
|
|
* what a press DOES ("Switch to light theme"), never the current state - and
|
||
|
|
* posts that target to the root action at `/`. The action persists the
|
||
|
|
* `sharper-theme` cookie, the loader re-runs and the shell repaints
|
||
|
|
* `data-surface` on <html>, so the whole tier-2 palette re-resolves. Because
|
||
|
|
* the current theme is read off the root loader (which the server fed from
|
||
|
|
* the cookie), the SSR markup and the first client render always agree.
|
||
|
|
*/
|
||
|
|
export function ThemeToggle({ locale, className }: ThemeToggleProps) {
|
||
|
|
const fetcher = useFetcher();
|
||
|
|
const rootData = useRouteLoaderData<RootLoaderData>("root");
|
||
|
|
const t = useMemo(() => createTranslator(locale), [locale]);
|
||
|
|
|
||
|
|
const theme: AppTheme = rootData?.theme ?? "dark";
|
||
|
|
const targetTheme: AppTheme = theme === "light" ? "dark" : "light";
|
||
|
|
const label =
|
||
|
|
targetTheme === "light" ? t("nav.themeToLight") : t("nav.themeToDark");
|
||
|
|
|
||
|
|
return (
|
||
|
|
<fetcher.Form action="/" method="post">
|
||
|
|
<input type="hidden" name="theme" value={targetTheme} />
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
aria-label={label}
|
||
|
|
title={label}
|
||
|
|
className={cx(
|
||
|
|
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center",
|
||
|
|
"rounded-control text-[var(--text-secondary)]",
|
||
|
|
"hover:text-[var(--text-primary)]",
|
||
|
|
HOVER_TRANSITION,
|
||
|
|
FOCUS_RING,
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{targetTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||
|
|
</button>
|
||
|
|
</fetcher.Form>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default ThemeToggle;
|
||
|
|
|
||
|
|
/* ---------------------------------------------------------------------------
|
||
|
|
USAGE
|
||
|
|
---------------------------------------------------------------------------
|
||
|
|
// In the nav actions cluster, between the language control and the
|
||
|
|
// hamburger. Reads the theme from the root loader on its own.
|
||
|
|
<ThemeToggle locale={locale} />
|
||
|
|
------------------------------------------------------------------------ */
|