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
120 changes: 120 additions & 0 deletions src/__tests__/ThemeProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<span data-testid="current-theme">{theme}</span>
<span data-testid="resolved-theme">{resolvedTheme}</span>
<button onClick={() => setTheme("dark")}>Set Dark</button>
<button onClick={() => setTheme("light")}>Set Light</button>
<button onClick={() => setTheme("system")}>Set System</button>
<button onClick={cycleTheme}>Cycle</button>
</div>
);
}

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(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>
);

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(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>
);

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(
<ThemeProvider>
<TestConsumer />
</ThemeProvider>
);

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(
<ThemeProvider>
<ThemeToggle />
<TestConsumer />
</ThemeProvider>
);

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");
});
});
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export default function Navbar() {
<SimulationModeToggle />
<NotificationCenter />
<HeaderShortcutsButton />
<ThemeToggle />
</div>
<ThemeToggle />

{/* New Invoice CTA — hidden below lg */}
<Link
Expand Down
1 change: 1 addition & 0 deletions src/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function resolveTheme(mode: ThemeMode): ResolvedTheme {

function applyTheme(resolved: ResolvedTheme) {
const root = document.documentElement;
root.setAttribute("data-theme", resolved);
if (resolved === "dark") {
root.classList.add("dark");
} else {
Expand Down