Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
import { createRequire } from "node:module";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
baseDirectory: __dirname,
});

const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];

export default eslintConfig;
resolvePluginsRelativeTo: dirname(createRequire(import.meta.url).resolve("eslint-config-next/package.json")),
});

const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];

export default eslintConfig;
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { themeScript } from "@/lib/theme";
import { Toaster } from "sonner";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
Expand All @@ -23,6 +24,7 @@ export default function RootLayout({
}) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<head><script dangerouslySetInnerHTML={{ __html: themeScript }} /></head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
Expand Down
73 changes: 29 additions & 44 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,47 @@
import { useEffect, useState } from "react";

type ThemeType = "light" | "dark" | "system";
const valid = (value: string | null): ThemeType =>
value === "light" || value === "dark" ? value : "system";

export default function ThemeToggle() {
const [theme, setTheme] = useState<ThemeType>("system");

useEffect(() => {
const saved = localStorage.getItem("theme") as ThemeType | null;
const initial: ThemeType = saved || "system";
setTheme(initial);

const getSystem = () =>
window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";

const actual = initial === "system" ? getSystem() : initial;
document.documentElement.classList.toggle("dark", actual === "dark");
}, []);

useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
if (theme === "system") {
document.documentElement.classList.toggle("dark", e.matches);
}
const media = window.matchMedia("(prefers-color-scheme: dark)");
const sync = () => {
let saved: string | null = null;
try { saved = localStorage.getItem("theme"); } catch { /* Storage is optional. */ }
const preference = valid(saved);
setTheme(preference);
const dark = preference === "dark" || (preference === "system" && media.matches);
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
};
mq.addEventListener("change", handler);

document.documentElement.classList.toggle(
"dark",
theme === "dark" || (theme === "system" && mq.matches),
);

const storage = (event: StorageEvent) => {
if (event.key === "theme" || event.key === null) sync();
};
sync();
media.addEventListener("change", sync);
window.addEventListener("storage", storage);
return () => {
mq.removeEventListener("change", handler);
media.removeEventListener("change", sync);
window.removeEventListener("storage", storage);
};
}, [theme]);

const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newTheme = e.target.value as ThemeType;
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
}, []);

const isDark =
newTheme === "dark" ||
(newTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", isDark);
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const preference = valid(event.target.value);
setTheme(preference);
try { localStorage.setItem("theme", preference); } catch { /* Storage is optional. */ }
const dark = preference === "dark" || (preference === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
};

return (
<select
aria-label="Tema da interface"
value={theme}
onChange={handleChange}
className="theme-select"
>
<select aria-label="Tema da interface" value={theme} onChange={handleChange} className="theme-select">
<option value="light">☀️ Claro</option>
<option value="dark">🌙 Escuro</option>
<option value="system">🖥️ Sistema</option>
Expand Down
1 change: 1 addition & 0 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const themeScript = "(() => {\n const root = document.documentElement;\n let saved;\n try { saved = localStorage.getItem('theme'); } catch {}\n const dark = saved === 'dark' || (saved !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);\n root.classList.toggle('dark', dark);\n root.style.colorScheme = dark ? 'dark' : 'light';\n})();";
27 changes: 27 additions & 0 deletions tests/theme.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { themeScript } from '../src/lib/theme.ts';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';

const source = themeScript;
function setup(saved, systemDark = false, blocked = false) {
const listeners = {};
const root = { dataset: {}, style: {}, classList: { toggle(name, on) { this[name] = on; } } };
const media = { matches: systemDark, addEventListener(_, fn) { listeners.media = fn; } };
const window = { matchMedia: () => media, dispatchEvent() {}, addEventListener(name, fn) { listeners[name] = fn; } };
const storage = { getItem() { if (blocked) throw Error('blocked'); return saved; } };
runInNewContext(source, { document: { documentElement: root }, window, localStorage: storage, Event: class {} });
return { root, media, listeners };
}
test('saved light and dark preferences override the operating system', () => {
assert.equal(setup('light', true).root.style.colorScheme, 'light');
assert.equal(setup('dark', false).root.style.colorScheme, 'dark');
});
test('missing or invalid preferences follow the system', () => {
assert.equal(setup(null, true).root.style.colorScheme, 'dark');
assert.equal(setup('invalid', false).root.style.colorScheme, 'light');
});
test('storage restrictions do not prevent the theme from loading', () => {
assert.equal(setup(null, true, true).root.style.colorScheme, 'dark');
});

Loading