From 3da8470ab31c6a19bb4aff6a255cf98c72bd9287 Mon Sep 17 00:00:00 2001 From: rain91300 Date: Thu, 24 Sep 2026 19:00:40 +0100 Subject: [PATCH] feat(theme): add dark/light/system theme system with data-theme, no-FOUC script, and navbar toggle --- src/__tests__/ThemeProvider.test.tsx | 120 +++++++++++++++++++++++++++ src/app/layout.tsx | 2 + src/components/Navbar.tsx | 2 +- src/contexts/ThemeContext.tsx | 1 + 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/ThemeProvider.test.tsx diff --git a/src/__tests__/ThemeProvider.test.tsx b/src/__tests__/ThemeProvider.test.tsx new file mode 100644 index 00000000..f8a1231c --- /dev/null +++ b/src/__tests__/ThemeProvider.test.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { ThemeProvider, useTheme } from "@/contexts/ThemeContext"; +import ThemeToggle from "@/components/ThemeToggle"; + +function TestConsumer() { + const { theme, resolvedTheme, setTheme, cycleTheme } = useTheme(); + return ( +
+ {theme} + {resolvedTheme} + + + + +
+ ); +} + +describe("ThemeProvider & ThemeToggle", () => { + beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.classList.remove("dark"); + + // Mock matchMedia + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: query.includes("dark"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("initializes with default system mode and applies data-theme attribute", () => { + render( + + + + ); + + expect(screen.getByTestId("current-theme").textContent).toBe("system"); + expect(screen.getByTestId("resolved-theme").textContent).toBe("dark"); + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + }); + + it("persists theme change to localStorage and sets data-theme attribute", () => { + render( + + + + ); + + const lightButton = screen.getByText("Set Light"); + act(() => { + fireEvent.click(lightButton); + }); + + expect(screen.getByTestId("current-theme").textContent).toBe("light"); + expect(screen.getByTestId("resolved-theme").textContent).toBe("light"); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + expect(localStorage.getItem("split-theme")).toBe("light"); + }); + + it("restores theme from localStorage on initial render", () => { + localStorage.setItem("split-theme", "light"); + + render( + + + + ); + + expect(screen.getByTestId("current-theme").textContent).toBe("light"); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + }); + + it("ThemeToggle cycles through all 3 modes: light -> dark -> system", () => { + localStorage.setItem("split-theme", "light"); + + render( + + + + + ); + + expect(screen.getByTestId("current-theme").textContent).toBe("light"); + + const toggle = screen.getByRole("button", { name: /switch to dark mode/i }); + act(() => { + fireEvent.click(toggle); + }); + + expect(screen.getByTestId("current-theme").textContent).toBe("dark"); + + act(() => { + fireEvent.click(toggle); + }); + + expect(screen.getByTestId("current-theme").textContent).toBe("system"); + + act(() => { + fireEvent.click(toggle); + }); + + expect(screen.getByTestId("current-theme").textContent).toBe("light"); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a319e5ff..4714ebf9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -31,6 +31,8 @@ const themeBootstrap = ` var stored = window.localStorage.getItem("split-theme"); var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; var isDark = stored === "dark" || (!stored && prefersDark) || (stored === "system" && prefersDark); + var resolved = isDark ? "dark" : "light"; + document.documentElement.setAttribute("data-theme", resolved); if (isDark) { document.documentElement.classList.add("dark"); } else { diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index a602c6e1..a5a99d50 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -110,8 +110,8 @@ export default function Navbar() { - + {/* New Invoice CTA — hidden below lg */}