Files
sharper.ly/apps/web/app/components/ui/Navigation/ThemeToggle.tsx
T
2026-09-23 21:57:24 +02:00

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} />
------------------------------------------------------------------------ */