返回 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 * The site ships the Tideline dark field everywhere; the toggle only renders
7 * on docs routes, where it switches the docs sheet between the dark default
8 * and the opt-in Blue Stage light sheet — the same preset pair the TUI
9 * offers. Showing it off the docs routes would be a control that appears to
10 * do nothing.
11 *
12 * "auto" removes the attribute and follows the site default (dark); "light"
13 * and "dark" force the choice via `data-theme` on <html>. The choice persists
14 * to localStorage and is re-applied before paint by the inline script in the
15 * locale layout, so there is no theme flash on reload.
16 */
17
18 import { useEffect, useState } from "react";
19 import { usePathname } from "next/navigation";
20 import { fill } from "@/lib/i18n/dictionaries";
21 import { isDocsPath } from "@/lib/i18n/path";
22
23 type Mode = "auto" | "light" | "dark";
24 const ORDER: Mode[] = ["auto", "light", "dark"];
25 const KEY = "cw-theme";
26
27 function apply(mode: Mode) {
28 const el = document.documentElement;
29 if (mode === "auto") el.removeAttribute("data-theme");
30 else el.setAttribute("data-theme", mode);
31 }
32
33 export function ThemeToggle({
34 autoLabel,
35 lightLabel,
36 darkLabel,
37 ariaTemplate,
38 titleLabel,
39 }: {
40 autoLabel: string;
41 lightLabel: string;
42 darkLabel: string;
43 /** "Docs theme: {mode} (click to cycle)" — interpolated with fill(). */
44 ariaTemplate: string;
45 titleLabel: string;
46 }) {
47 const pathname = usePathname();
48 const [mode, setMode] = useState<Mode>("auto");
49 const [mounted, setMounted] = useState(false);
50
51 useEffect(() => {
52 setMounted(true);
53 const stored = (typeof localStorage !== "undefined" && localStorage.getItem(KEY)) as Mode | null;
54 if (stored && ORDER.includes(stored)) setMode(stored);
55 }, []);
56
57 if (!isDocsPath(pathname)) return null;
58
59 const cycle = () => {
60 const next = ORDER[(ORDER.indexOf(mode) + 1) % ORDER.length];
61 setMode(next);
62 try {
63 localStorage.setItem(KEY, next);
64 } catch {
65 /* private mode / storage disabled — the choice just won't persist */
66 }
67 apply(next);
68 };
69
70 const labels: Record<Mode, string> = {
71 auto: autoLabel,
72 light: lightLabel,
73 dark: darkLabel,
74 };
75 const glyph: Record<Mode, string> = { auto: "◐", light: "☀", dark: "☾" };
76
77 return (
78 <button
79 type="button"
80 onClick={cycle}
81 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"
82 aria-label={fill(ariaTemplate, { mode: labels[mode] })}
83 title={titleLabel}
84 suppressHydrationWarning
85 >
86 <span aria-hidden>{mounted ? glyph[mode] : glyph.auto}</span>
87 <span className="hidden 2xl:inline" suppressHydrationWarning>
88 {mounted ? labels[mode] : labels.auto}
89 </span>
90 </button>
91 );
92 }
93
93 lines Plain Text