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
102 changes: 102 additions & 0 deletions src/components/SiteHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,108 @@ describe("SiteHeader active route marking", () => {
});
});

// ---------------------------------------------------------------------------
// Reset to system theme button
// ---------------------------------------------------------------------------

describe("SiteHeader reset to system theme", () => {
it("does not show the reset button when isOverridden is false (no stored override)", async () => {
mockMediaQuery(false);
renderHeader();

await waitFor(() => {
expect(
screen.getByRole("button", { name: /switch to dark mode/i }),
).toBeInTheDocument();
});

expect(
screen.queryByRole("button", { name: /use system theme/i }),
).not.toBeInTheDocument();
});

it("shows the reset button when isOverridden is true (stored override exists)", async () => {
localStorage.setItem(STORAGE_KEY, "dark");
mockMediaQuery(false);
renderHeader();

await waitFor(() => {
expect(
screen.getByRole("button", { name: /use system theme/i }),
).toBeInTheDocument();
});
});

it("calls resetToSystem when clicked and reverts toggle label to follow system", async () => {
localStorage.setItem(STORAGE_KEY, "dark"); // override → isOverridden = true
mockMediaQuery(false); // system is light
renderHeader();

// Wait for the override to be read and reset button to appear
const resetBtn = await screen.findByRole("button", {
name: /use system theme/i,
});

// After reset, the toggle should reflect the system preference (light)
fireEvent.click(resetBtn);

await waitFor(() => {
expect(
screen.getByRole("button", { name: /switch to dark mode/i }),
).toBeInTheDocument();
});

// localStorage should be cleared
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();

// Reset button should disappear
expect(
screen.queryByRole("button", { name: /use system theme/i }),
).not.toBeInTheDocument();
});

it("hides the reset button after clicking it (isOverridden becomes false)", async () => {
localStorage.setItem(STORAGE_KEY, "light");
mockMediaQuery(true); // system is dark
renderHeader();

const resetBtn = await screen.findByRole("button", {
name: /use system theme/i,
});

fireEvent.click(resetBtn);

await waitFor(() => {
expect(
screen.queryByRole("button", { name: /use system theme/i }),
).not.toBeInTheDocument();
});
});

it("does not break the existing toggle when reset button is present", async () => {
localStorage.setItem(STORAGE_KEY, "dark");
mockMediaQuery(false);
renderHeader();

const resetBtn = await screen.findByRole("button", {
name: /use system theme/i,
});
expect(resetBtn).toBeInTheDocument();

// Existing toggle should still work
const toggleBtn = screen.getByRole("button", {
name: /switch to light mode/i,
});
fireEvent.click(toggleBtn);

await waitFor(() => {
expect(
screen.getByRole("button", { name: /switch to dark mode/i }),
).toBeInTheDocument();
});
});
});

// ---------------------------------------------------------------------------
// Theme toggle button rendering
// ---------------------------------------------------------------------------
Expand Down
41 changes: 41 additions & 0 deletions src/components/SiteHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,46 @@ function ThemeToggle() {
);
}

/** Sync icon used for the "Use system theme" reset button. */
function SyncIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
);
}

/** Button that reverts to the OS theme preference, only shown when overridden. */
function ResetToSystemButton() {
const { isOverridden, resetToSystem } = useTheme();

if (!isOverridden) return null;

return (
<button
onClick={resetToSystem}
aria-label="Use system theme"
title="Use system theme"
className="rounded-lg border border-zinc-700 p-1.5 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
>
<SyncIcon />
</button>
);
}

const NAV_LINKS = [
{ href: "/", label: "Home" },
{ href: "/dashboard", label: "Dashboard" },
Expand Down Expand Up @@ -114,6 +154,7 @@ export function SiteHeader() {
);
})}
<ThemeToggle />
<ResetToSystemButton />
<ConnectButton />
</div>
</nav>
Expand Down
23 changes: 22 additions & 1 deletion vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";

// Node.js v26 removed the implicit localStorage in jsdom; provide a mock so
// tests that rely on window.localStorage (theme persistence, wallet session,
// etc.) continue to work.
if (typeof globalThis.localStorage === "undefined") {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => store.set(key, value)),
removeItem: vi.fn((key: string) => store.delete(key)),
clear: vi.fn(() => store.clear()),
get length() {
return store.size;
},
key: vi.fn((index: number) => [...store.keys()][index] ?? null),
},
writable: true,
configurable: true,
});
}

// Unmount rendered components between tests so component tests don't leak
// into one another (auto-cleanup relies on Jest/vitest globals, which this
// project intentionally doesn't enable).
Expand Down