Wires tailwind.config.ts to every custom-property token in styles.css (spacing, OKLCH colours, radii, z-index, sizing, durations) so Tailwind utilities resolve to the same variables the vanilla CSS uses. Adds ThemeContext/useTheme as a React port of theme.ts, keeping the same localStorage keys and data-theme/data-palette attributes so it can run alongside the vanilla shell.
164 lines
5.0 KiB
TypeScript
164 lines
5.0 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { PALETTES, ThemeProvider, useTheme } from "./ThemeContext";
|
|
|
|
// happy-dom's vitest environment ships neither localStorage nor matchMedia;
|
|
// back both with plain in-memory fakes so persistence and system-theme
|
|
// reactivity are observable (mirrors ../theme.test.ts's localStorage fake).
|
|
let store = new Map<string, string>();
|
|
let systemPrefersLight = false;
|
|
let mediaListeners: Array<(e: MediaQueryListEvent) => void> = [];
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: {
|
|
getItem: (k: string) => store.get(k) ?? null,
|
|
setItem: (k: string, v: string) => store.set(k, v),
|
|
removeItem: (k: string) => store.delete(k),
|
|
},
|
|
});
|
|
Object.defineProperty(window, "matchMedia", {
|
|
value: (query: string) => ({
|
|
matches: query.includes("light") ? systemPrefersLight : false,
|
|
media: query,
|
|
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
mediaListeners.push(cb);
|
|
},
|
|
removeEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
mediaListeners = mediaListeners.filter((l) => l !== cb);
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
function fireSystemThemeChange(matches: boolean): void {
|
|
systemPrefersLight = matches;
|
|
for (const cb of mediaListeners) {
|
|
cb({ matches } as MediaQueryListEvent);
|
|
}
|
|
}
|
|
|
|
function Probe() {
|
|
const { theme, palette, setTheme, toggleTheme, setPalette } = useTheme();
|
|
return (
|
|
<div>
|
|
<span data-testid="theme">{theme}</span>
|
|
<span data-testid="palette">{palette}</span>
|
|
<button onClick={() => setTheme("light")}>pin-light</button>
|
|
<button onClick={toggleTheme}>toggle</button>
|
|
<button onClick={() => setPalette("ember")}>set-ember</button>
|
|
<button onClick={() => setPalette("graphite")}>set-graphite</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("ThemeProvider / useTheme", () => {
|
|
beforeEach(() => {
|
|
store = new Map();
|
|
mediaListeners = [];
|
|
systemPrefersLight = false;
|
|
delete document.documentElement.dataset.theme;
|
|
delete document.documentElement.dataset.palette;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it("defaults to graphite and the system theme, no data-palette attribute", () => {
|
|
render(
|
|
<ThemeProvider>
|
|
<Probe />
|
|
</ThemeProvider>,
|
|
);
|
|
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
|
expect(screen.getByTestId("palette").textContent).toBe("graphite");
|
|
expect(document.documentElement.dataset.palette).toBeUndefined();
|
|
});
|
|
|
|
it("follows a system theme change while unpinned", () => {
|
|
render(
|
|
<ThemeProvider>
|
|
<Probe />
|
|
</ThemeProvider>,
|
|
);
|
|
act(() => fireSystemThemeChange(true));
|
|
expect(screen.getByTestId("theme").textContent).toBe("light");
|
|
});
|
|
|
|
it("setTheme pins, persists, and stops following the system", () => {
|
|
render(
|
|
<ThemeProvider>
|
|
<Probe />
|
|
</ThemeProvider>,
|
|
);
|
|
act(() => screen.getByText("pin-light").click());
|
|
expect(screen.getByTestId("theme").textContent).toBe("light");
|
|
expect(document.documentElement.dataset.theme).toBe("light");
|
|
expect(localStorage.getItem("maestro-theme")).toBe("light");
|
|
|
|
act(() => fireSystemThemeChange(true));
|
|
expect(screen.getByTestId("theme").textContent).toBe("light");
|
|
});
|
|
|
|
it("toggleTheme flips the resolved theme", () => {
|
|
render(
|
|
<ThemeProvider>
|
|
<Probe />
|
|
</ThemeProvider>,
|
|
);
|
|
act(() => screen.getByText("toggle").click());
|
|
expect(screen.getByTestId("theme").textContent).toBe("light");
|
|
act(() => screen.getByText("toggle").click());
|
|
expect(screen.getByTestId("theme").textContent).toBe("dark");
|
|
});
|
|
|
|
it("setPalette stamps data-palette and persists; graphite clears it", () => {
|
|
render(
|
|
<ThemeProvider>
|
|
<Probe />
|
|
</ThemeProvider>,
|
|
);
|
|
act(() => screen.getByText("set-ember").click());
|
|
expect(document.documentElement.dataset.palette).toBe("ember");
|
|
expect(localStorage.getItem("maestro-palette")).toBe("ember");
|
|
|
|
act(() => screen.getByText("set-graphite").click());
|
|
expect(document.documentElement.dataset.palette).toBeUndefined();
|
|
});
|
|
|
|
it("a fresh mount re-applies a stored palette", () => {
|
|
localStorage.setItem("maestro-palette", "periwinkle");
|
|
render(
|
|
<ThemeProvider>
|
|
<Probe />
|
|
</ThemeProvider>,
|
|
);
|
|
expect(screen.getByTestId("palette").textContent).toBe("periwinkle");
|
|
expect(document.documentElement.dataset.palette).toBe("periwinkle");
|
|
});
|
|
|
|
it("exposes every palette in picker order", () => {
|
|
expect(PALETTES).toEqual([
|
|
"graphite",
|
|
"ice-cyan",
|
|
"periwinkle",
|
|
"amber-phosphor",
|
|
"ember",
|
|
"orchid",
|
|
]);
|
|
});
|
|
|
|
it("throws when used outside a provider", () => {
|
|
const Bare = () => {
|
|
useTheme();
|
|
return null;
|
|
};
|
|
expect(() => render(<Bare />)).toThrow(
|
|
"useTheme must be used within a ThemeProvider",
|
|
);
|
|
});
|
|
});
|