Files
novalon-website/src/components/theme/theme-toggle.tsx
T
zhangxiang 02fec8a67b feat(theme): add ThemeToggle and wire into header + admin top bar
- Add reusable client ThemeToggle at src/components/theme/theme-toggle.tsx
- Reads/writes novalon-theme localStorage key and toggles data-theme
- Placeholder on first render to avoid hydration mismatch
- Wired into public Header desktop cluster and AdminLayout top bar
2026-08-31 22:05:06 +08:00

88 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useState } from 'react';
import { Sun, Moon } from 'lucide-react';
import { cn } from '@/lib/utils';
type Theme = 'light' | 'dark';
const STORAGE_KEY = 'novalon-theme';
/**
* 读取当前应生效的主题:
* 1. 优先用用户显式选择(localStorage,与 layout.tsx FOUC 脚本同一 key
* 2. 回退到 FOUC 脚本已同步写入 <html data-theme> 的值
* 3. 默认浅色
* 这样即便用户刷新,状态也与首帧 FOUC 脚本完全一致,无闪烁。
*/
function resolveTheme(): Theme {
if (typeof window === 'undefined') return 'light';
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') return attr as Theme;
return 'light';
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme);
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
/* 隐私模式等写入失败时忽略:仅本次会话生效 */
}
}
export function ThemeToggle({ className }: { className?: string }) {
// 初始为 null,挂载后再解析真实主题,避免 SSR/CSR 水合不一致
const [theme, setTheme] = useState<Theme | null>(null);
const [mounted, setMounted] = useState(false);
// 必须挂载后读取:FOUC 脚本已在 <head> 同步写入 data-theme
// 此处从 DOM/localStorage 还原真实主题。SSR 阶段 window 不可用,
// 故无法用 useState 惰性初始化(否则首帧与客户端水合不一致)。
useEffect(() => {
// 主题同步必须从挂载后 DOM 读取,此 setState 无法避免
// eslint-disable-next-line react-hooks/set-state-in-effect
setTheme(resolveTheme());
setMounted(true);
}, []);
const isDark = theme === 'dark';
const toggle = () => {
const next: Theme = isDark ? 'light' : 'dark';
setTheme(next);
applyTheme(next);
};
return (
<button
type="button"
onClick={toggle}
aria-label={isDark ? '切换到浅色模式' : '切换到暗黑模式'}
aria-pressed={isDark}
className={cn(
'inline-flex items-center justify-center w-9 h-9 rounded-lg border border-border-primary',
'text-text-tertiary hover:text-text-primary hover:bg-bg-hover',
'transition-colors duration-200 ease-ink',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-border-dark',
className,
)}
>
{/* 挂载前渲染占位,避免与 FOUC 已应用的主题产生水合差异 */}
{mounted ? (
isDark ? (
<Moon className="w-4 h-4" aria-hidden="true" />
) : (
<Sun className="w-4 h-4" aria-hidden="true" />
)
) : (
<span className="block w-4 h-4" aria-hidden="true" />
)}
</button>
);
}
export default ThemeToggle;