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