feat: theme toggle
This commit is contained in:
+36
-5
@@ -4,7 +4,9 @@
|
||||
`Layout` renders the whole document, which is why <html lang> and <html dir>
|
||||
are correct in the FIRST SSR byte: the locale is read from the URL, not from
|
||||
a cookie, a context provider or an effect, so the server and the first
|
||||
client render cannot disagree.
|
||||
client render cannot disagree. The theme surface is the one deliberate
|
||||
exception to "no cookies" - it is a cookie read in the loader, but because
|
||||
the loader is what the server renders, the first byte still matches.
|
||||
|
||||
The header and the footer live here rather than in the ":lang" layout so the
|
||||
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
} from "~/lib/route-handle";
|
||||
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
|
||||
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
|
||||
import { DEFAULT_THEME, readTheme, themeCookie, type AppTheme } from "~/lib/theme";
|
||||
|
||||
/**
|
||||
* Last-resort origin for the canonical and hreflang links. The root loader
|
||||
@@ -80,9 +83,31 @@ export function loader({ request }: Route.LoaderArgs) {
|
||||
dir: dirFor(locale),
|
||||
pathname,
|
||||
siteUrl: getSiteUrl(request),
|
||||
/* Which surface the document stands on. Read from the theme cookie so the
|
||||
server paints the same surface the reader asked for - the browser never
|
||||
guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
|
||||
`:root` is dark, so the absence of a cookie is a dark first visit. */
|
||||
theme: readTheme(request.headers.get("Cookie")),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The root action is the theme toggle's destination (the nav posts here at
|
||||
* `/`). It validates the requested theme and persists it with Set-Cookie; the
|
||||
* loader re-runs and the shell repaints the `data-surface` on <html>.
|
||||
*/
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const requested = formData.get("theme");
|
||||
const theme: AppTheme =
|
||||
requested === "light" ? "light" : requested === "dark" ? "dark" : DEFAULT_THEME;
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: { "Set-Cookie": themeCookie(theme) },
|
||||
});
|
||||
}
|
||||
|
||||
function pathnameLocale(pathname: string): Locale {
|
||||
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
|
||||
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
|
||||
@@ -126,13 +151,19 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
const navItems = useMemo(() => buildNavItems(locale), [locale]);
|
||||
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
|
||||
|
||||
const navSurface = useRouteHandleValue(readNavSurface) ?? "dark";
|
||||
const footerSurface = useRouteHandleValue(readFooterSurface) ?? "dark";
|
||||
/* The theme cookie decides which surface the DOCUMENT stands on. The header
|
||||
and footer follow it, unless the route's handle forces one (`navSurface`
|
||||
/ `footerSurface`) - so a page can still draw a light nav on a dark
|
||||
surface in either theme. */
|
||||
const rootData = useRouteLoaderData<typeof loader>("root");
|
||||
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
|
||||
|
||||
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
|
||||
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
|
||||
|
||||
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
|
||||
`meta` replaces its parents' wholesale, so a page that writes its own
|
||||
would silently drop them. `handle.canonicalPath` is the escape hatch. */
|
||||
const rootData = useRouteLoaderData<typeof loader>("root");
|
||||
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
|
||||
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
|
||||
/* A detail route emits its own pair from the counterpart's real Strapi slug
|
||||
@@ -142,7 +173,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={dir} data-surface="dark">
|
||||
<html lang={locale} dir={dir} data-surface={theme}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
Reference in New Issue
Block a user