45 lines
1.9 KiB
TypeScript
45 lines
1.9 KiB
TypeScript
"use client";
|
|
|
|
import { useLayoutEffect } from "react";
|
|
import { Sun, Moon } from "lucide-react";
|
|
|
|
function getEffectiveTheme(): "light" | "dark" {
|
|
const stored = document.documentElement.dataset.theme;
|
|
if (stored === "light" || stored === "dark") return stored;
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
}
|
|
|
|
// Both icons always render; CSS (mirroring the same data-theme cascade the
|
|
// palette itself uses) decides which one shows — avoids a useState/useEffect
|
|
// pair just to reflect the DOM attribute the no-flash script already set.
|
|
export function ThemeToggle() {
|
|
// The <head> no-flash script sets data-theme before hydration, but
|
|
// something in this app's streaming SSR/hydration commits again shortly
|
|
// after first paint and strips it back off (confirmed: present at ~0ms,
|
|
// gone by ~50ms, and it doesn't come back on its own) — suppressHydration
|
|
// Warning on <html> only silences the mismatch warning for the *first*
|
|
// hydration diff, it doesn't stop a later commit from resetting an
|
|
// attribute React doesn't know about. Re-applying here, once this client
|
|
// component has mounted (i.e. after that reset has already happened),
|
|
// wins the race and makes it stick.
|
|
useLayoutEffect(() => {
|
|
const stored = localStorage.getItem("theme");
|
|
if (stored === "light" || stored === "dark") {
|
|
document.documentElement.dataset.theme = stored;
|
|
}
|
|
}, []);
|
|
|
|
function toggle() {
|
|
const next = getEffectiveTheme() === "dark" ? "light" : "dark";
|
|
document.documentElement.dataset.theme = next;
|
|
localStorage.setItem("theme", next);
|
|
}
|
|
|
|
return (
|
|
<button onClick={toggle} className="btn btn-ghost theme-toggle-btn" title="Переключить тему" aria-label="Переключить тему">
|
|
<Sun size={15} className="theme-icon-sun" />
|
|
<Moon size={15} className="theme-icon-moon" />
|
|
</button>
|
|
);
|
|
}
|