- 首帧 mode 用默认值(与服务端一致)→ 消除 SSR/客户端 aria-label/图标不一致 - 图标改由 CSS 按 <html data-theme> 显隐(两个图标始终在 DOM)→ 无首帧闪烁、无结构差异 - 挂载后 useEffect 以 localStorage 校正 mode → label 恢复正确文案 - 去掉会锁死服务端陈旧 label 的 suppressHydrationWarning
75 lines
2.2 KiB
TypeScript
75 lines
2.2 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Moon, Sun } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/Button";
|
||
import {
|
||
DEFAULT_THEME_MODE,
|
||
THEME_STORAGE_KEY,
|
||
nextThemeMode,
|
||
normalizeThemeMode,
|
||
themeModeLabel,
|
||
themeToggleLabel,
|
||
type ThemeMode,
|
||
} from "@/lib/ui/theme";
|
||
|
||
function applyThemeMode(mode: ThemeMode) {
|
||
document.documentElement.dataset.theme = mode;
|
||
}
|
||
|
||
function readStoredThemeMode(): ThemeMode {
|
||
try {
|
||
return normalizeThemeMode(window.localStorage.getItem(THEME_STORAGE_KEY));
|
||
} catch {
|
||
return DEFAULT_THEME_MODE;
|
||
}
|
||
}
|
||
|
||
function writeStoredThemeMode(mode: ThemeMode) {
|
||
try {
|
||
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||
} catch {
|
||
// 存储不可用时仍允许本次页面会话切换主题。
|
||
}
|
||
}
|
||
|
||
export function ThemeToggle() {
|
||
// 首帧用默认模式(与服务端一致)避免水合不一致;挂载后以 localStorage 校正。
|
||
// 可见的图标由 CSS 按 <html data-theme> 决定(ThemeScript 已在水合前写入),故无首帧闪烁。
|
||
const [mode, setMode] = useState<ThemeMode>(DEFAULT_THEME_MODE);
|
||
|
||
useEffect(() => {
|
||
// 水合后以 localStorage 为权威源校正(dataset 已由 ThemeScript 同样依据写入,通常一致)。
|
||
const initial = readStoredThemeMode();
|
||
setMode(initial);
|
||
applyThemeMode(initial);
|
||
}, []);
|
||
|
||
const toggle = () => {
|
||
setMode((current) => {
|
||
const next = nextThemeMode(current);
|
||
writeStoredThemeMode(next);
|
||
applyThemeMode(next);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
// 图标显隐交给 CSS(按 data-theme),两个图标始终渲染 → 首帧结构与服务端一致、无闪烁。
|
||
// 首帧 label 用默认模式(与服务端一致),挂载后随 mode 校正为正确文案。
|
||
return (
|
||
<Button
|
||
onClick={toggle}
|
||
variant="ghost"
|
||
size="icon"
|
||
aria-label={themeToggleLabel(mode)}
|
||
title={themeToggleLabel(mode)}
|
||
className="border-transparent"
|
||
>
|
||
<Moon className="theme-icon-moon h-4 w-4" aria-hidden="true" />
|
||
<Sun className="theme-icon-sun h-4 w-4" aria-hidden="true" />
|
||
<span className="sr-only">{themeModeLabel(mode)}</span>
|
||
</Button>
|
||
);
|
||
}
|