返回 CodeWhale
theme-toggle.tsx
根目录 / web / components / theme-toggle.tsx
1 "use client";
2
3 /**
4 * <ThemeToggle> — a compact Auto / Light / Dark control for the date strip.
5 *
6 * Dark mode is scoped to the /docs routes (see globals.css `.docs-theme`),
7 * so the toggle only renders while on a docs route — showing it site-wide
8 * would be a control that appears to do nothing on the marketing pages.
9 *
10 * "auto" removes the attribute and follows prefers-color-scheme; "light" and
11 * "dark" force the choice via `data-theme` on <html>. The choice persists to
12 * localStorage and is re-applied before paint by the inline script in the
13 * locale layout, so there is no theme flash on reload.
14 */
15
16 import { useEffect, useState } from "react";
17 import { usePathname } from "next/navigation";
18 import { fill } from "@/lib/i18n/dictionaries";
19
20 type Mode = "auto" | "light" | "dark";
21 const ORDER: Mode[] = ["auto", "light", "dark"];
22 const KEY = "cw-theme";
23
24 function apply(mode: Mode) {
25 const el = document.documentElement;
26 if (mode === "auto") el.removeAttribute("data-theme");
27 else el.setAttribute("data-theme", mode);
28 }
29
30 export function ThemeToggle({
31 autoLabel,
32 lightLabel,
33 darkLabel,
34 ariaTemplate,
35 titleLabel,
36 }: {
37 autoLabel: string;
38 lightLabel: string;
39 darkLabel: string;
40 /** "Docs theme: {mode} (click to cycle)" — interpolated with fill(). */
41 ariaTemplate: string;
42 titleLabel: string;
43 }) {
44 const pathname = usePathname();
45 const [mode, setMode] = useState<Mode>("auto");
46 const [mounted, setMounted] = useState(false);
47
48 useEffect(() => {
49 setMounted(true);
50 const stored = (typeof localStorage !== "undefined" && localStorage.getItem(KEY)) as Mode | null;
51 if (stored && ORDER.includes(stored)) setMode(stored);
52 }, []);
53
54 const onDocs = /^\/[a-z]{2}\/docs(\/|$)/.test(pathname) || pathname.includes("/docs");
55 if (!onDocs) return null;
56
57 const cycle = () => {
58 const next = ORDER[(ORDER.indexOf(mode) + 1) % ORDER.length];
59 setMode(next);
60 try {
61 localStorage.setItem(KEY, next);
62 } catch {
63 /* private mode / storage disabled — the choice just won't persist */
64 }
65 apply(next);
66 };
67
68 const labels: Record<Mode, string> = {
69 auto: autoLabel,
70 light: lightLabel,
71 dark: darkLabel,
72 };
73 const glyph: Record<Mode, string> = { auto: "◐", light: "☀", dark: "☾" };
74
75 return (
76 <button
77 type="button"
78 onClick={cycle}
79 className="inline-flex items-center gap-1.5 px-1.5 py-0.5 hairline-l hairline-r hairline-t hairline-b hover:text-indigo transition-colors"
80 aria-label={fill(ariaTemplate, { mode: labels[mode] })}
81 title={titleLabel}
82 suppressHydrationWarning
83 >
84 <span aria-hidden>{mounted ? glyph[mode] : glyph.auto}</span>
85 <span className="hidden sm:inline" suppressHydrationWarning>
86 {mounted ? labels[mode] : labels.auto}
87 </span>
88 </button>
89 );
90 }
91
91 lines Plain Text