返回 CodeWhale
theme-toggle.tsx
根目录 / web / components / theme-toggle.tsx
1 "use client";
2
3 /**
4 * <ThemeToggle> — an icon-only Light / Dark control in the site nav, shown
5 * on every page. The current mode is in its accessible name.
6 *
7 * Paper is the site's appearance (paper above, sea below the horizon), so
8 * the site no longer follows the OS. Dark pins the navy ground through
9 * `data-theme="dark"`; light removes the pin.
10 *
11 * One storage contract, shared with the web app: the `cw-theme` key holds
12 * `light | dark`. A stored `system` or `auto` (earlier modes) reads as
13 * light. The inline boot script in the locale layout applies a stored pin
14 * before paint, so there is no theme flash on reload.
15 */
16
17 import { useEffect, useState } from "react";
18 import { fill } from "@/lib/i18n/dictionaries";
19 import { Icon, type IconName } from "./icon";
20
21 type Mode = "light" | "dark";
22 const ORDER: Mode[] = ["light", "dark"];
23 const KEY = "cw-theme";
24
25 function load(): Mode {
26 try {
27 const stored = localStorage.getItem(KEY);
28 return stored === "dark" ? "dark" : "light";
29 } catch {
30 return "light";
31 }
32 }
33
34 function apply(mode: Mode) {
35 const el = document.documentElement;
36 if (mode === "light") el.removeAttribute("data-theme");
37 else el.setAttribute("data-theme", mode);
38 try {
39 localStorage.setItem(KEY, mode);
40 } catch {
41 /* private mode / storage disabled — the choice applies until reload */
42 }
43 }
44
45 export function ThemeToggle({
46 lightLabel,
47 darkLabel,
48 ariaTemplate,
49 titleLabel,
50 }: {
51 /** Former "system" mode label; unused since paper became the default. */
52 autoLabel?: string;
53 lightLabel: string;
54 darkLabel: string;
55 /** "Theme: {mode} (click to cycle)" — interpolated with fill(). */
56 ariaTemplate: string;
57 titleLabel: string;
58 }) {
59 const [mode, setMode] = useState<Mode>("light");
60 const [mounted, setMounted] = useState(false);
61
62 useEffect(() => {
63 setMounted(true);
64 setMode(load());
65 }, []);
66
67 const cycle = () => {
68 const next = ORDER[(ORDER.indexOf(mode) + 1) % ORDER.length];
69 setMode(next);
70 apply(next);
71 };
72
73 const labels: Record<Mode, string> = {
74 light: lightLabel,
75 dark: darkLabel,
76 };
77 const glyph: Record<Mode, IconName> = { light: "sun", dark: "moon" };
78 const shown = mounted ? mode : "light";
79
80 return (
81 <button
82 type="button"
83 onClick={cycle}
84 className="nav-icon-button"
85 aria-label={fill(ariaTemplate, { mode: labels[shown] })}
86 title={titleLabel}
87 suppressHydrationWarning
88 >
89 <Icon name={glyph[shown]} className="nav-icon" />
90 </button>
91 );
92 }
93
93 lines Plain Text