diff --git a/src/__tests__/ThemeProvider.test.tsx b/src/__tests__/ThemeProvider.test.tsx new file mode 100644 index 0000000..f8a1231 --- /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 a319e5f..4714ebf 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 a602c6e..a5a99d5 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 */}