feat(web): theme core — resolve/read/apply tri-state theme (#59)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { applyTheme, readTheme, resolveTheme, THEME_KEY } from "./theme";
|
||||||
|
|
||||||
|
function mockMatchMedia(matches: boolean) {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveTheme returns explicit values verbatim", () => {
|
||||||
|
expect(resolveTheme("light")).toBe("light");
|
||||||
|
expect(resolveTheme("dark")).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveTheme maps system via prefers-color-scheme", () => {
|
||||||
|
mockMatchMedia(true);
|
||||||
|
expect(resolveTheme("system")).toBe("dark");
|
||||||
|
mockMatchMedia(false);
|
||||||
|
expect(resolveTheme("system")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readTheme defaults to system when unset or invalid", () => {
|
||||||
|
expect(readTheme()).toBe("system");
|
||||||
|
localStorage.setItem(THEME_KEY, "bogus");
|
||||||
|
expect(readTheme()).toBe("system");
|
||||||
|
localStorage.setItem(THEME_KEY, "dark");
|
||||||
|
expect(readTheme()).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyTheme toggles the dark class on documentElement", () => {
|
||||||
|
mockMatchMedia(false);
|
||||||
|
applyTheme("dark");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
applyTheme("light");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||||
|
mockMatchMedia(true);
|
||||||
|
applyTheme("system");
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const THEME_KEY = "theme";
|
||||||
|
|
||||||
|
export type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const THEMES: readonly Theme[] = ["light", "dark", "system"];
|
||||||
|
|
||||||
|
function prefersDark(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(theme: Theme): "light" | "dark" {
|
||||||
|
if (theme === "light" || theme === "dark") return theme;
|
||||||
|
|
||||||
|
return prefersDark() ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTheme(): Theme {
|
||||||
|
if (typeof localStorage === "undefined") return "system";
|
||||||
|
|
||||||
|
const stored = localStorage.getItem(THEME_KEY);
|
||||||
|
|
||||||
|
return THEMES.includes(stored as Theme) ? (stored as Theme) : "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
document.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user