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
5 changes: 5 additions & 0 deletions .changeset/calm-themes-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Fixes the admin appearance toggle so every click changes the visible color scheme. The admin follows the system preference whenever the selected appearance matches it.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion packages/admin/src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ export function ThemeProvider({ children, defaultTheme = "system" }: ThemeProvid

const setTheme = React.useCallback((newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
if (newTheme === "system") {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, newTheme);
}
}, []);

const value = React.useMemo(
Expand Down
39 changes: 14 additions & 25 deletions packages/admin/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,34 @@
import { Button } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import { Sun, Moon, Monitor } from "@phosphor-icons/react";
import * as React from "react";
import { Sun, Moon } from "@phosphor-icons/react";

import { useTheme } from "./ThemeProvider";

/**
* Theme toggle button that cycles through: system -> light -> dark
*/
export function ThemeToggle() {
const { t } = useLingui();
const { theme, setTheme, resolvedTheme } = useTheme();
const { setTheme, resolvedTheme } = useTheme();

const cycleTheme = () => {
const order: ["system", "light", "dark"] = ["system", "light", "dark"];
const currentIndex = order.indexOf(theme);
const nextIndex = (currentIndex + 1) % order.length;
setTheme(order[nextIndex]!);
const toggleTheme = () => {
const nextTheme = resolvedTheme === "light" ? "dark" : "light";
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
setTheme(nextTheme === systemTheme ? "system" : nextTheme);
};

const resolvedLabel = resolvedTheme === "light" ? t`light` : t`dark`;
const label =
theme === "system" ? t`System (${resolvedLabel})` : theme === "light" ? t`Light` : t`Dark`;
const label = resolvedTheme === "light" ? t`Switch to dark` : t`Switch to light`;

return (
<Button
variant="ghost"
shape="square"
size="sm"
aria-label={t`Toggle theme (current: ${label})`}
onClick={cycleTheme}
title={t`Theme: ${label}`}
aria-label={label}
onClick={toggleTheme}
title={label}
>
{theme === "system" ? (
<Monitor className="h-4 w-4" />
) : theme === "light" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
<span className="sr-only">{t`Toggle theme (current: ${label})`}</span>
{resolvedTheme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="sr-only">{label}</span>
</Button>
);
}
4 changes: 2 additions & 2 deletions packages/admin/tests/components/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const { Header } = await import("../../src/components/Header");
// Constants
// ---------------------------------------------------------------------------

const THEME_BUTTON_REGEX = /Toggle theme/;
const THEME_BUTTON_REGEX = /Switch to (light|dark)/;

function TestWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
Expand All @@ -80,7 +80,7 @@ describe("Header", () => {
<Header />
</TestWrapper>,
);
// ThemeToggle renders a button with aria-label "Toggle theme (current: …)"
// ThemeToggle exposes its next action in the aria-label.
// (Kumo 2.x wraps `<Button title>` as a Tooltip popup, not a DOM title.)
const themeButton = screen.getByLabelText(THEME_BUTTON_REGEX);
await expect.element(themeButton).toBeInTheDocument();
Expand Down
65 changes: 39 additions & 26 deletions packages/admin/tests/components/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from "react";
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";

import { ThemeProvider } from "../../src/components/ThemeProvider";
import { ThemeToggle } from "../../src/components/ThemeToggle";
Expand All @@ -13,60 +13,73 @@ function TestThemeToggle({ defaultTheme = "system" as "system" | "light" | "dark
);
}

function mockSystemTheme(theme: "light" | "dark") {
vi.spyOn(window, "matchMedia").mockImplementation(
(query) =>
({
matches: query === "(prefers-color-scheme: dark)" && theme === "dark",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}) satisfies MediaQueryList,
);
}

describe("ThemeToggle", () => {
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
document.documentElement.removeAttribute("data-theme");
});

// Kumo 2.x's <Button title="..."> wraps the button in a Tooltip popup
// rather than setting the native `title` attribute. The current theme is
// also exposed in `aria-label`, which is what these assertions read.
// rather than setting the native `title` attribute. The action is also
// exposed in `aria-label`, which is what these assertions read.

it("renders with system theme by default", async () => {
it("offers dark when the system theme is light", async () => {
mockSystemTheme("light");
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");
await expect.element(button).toBeInTheDocument();
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("System"));
await expect.element(button).toHaveAttribute("aria-label", "Switch to dark");
});

it("cycles from system to light on click", async () => {
it("switches from the light system theme to a dark override", async () => {
mockSystemTheme("light");
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");
await button.click();
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Light"));
await expect.element(document.documentElement).toHaveAttribute("data-mode", "dark");
expect(localStorage.getItem("emdash-theme")).toBe("dark");
});

it("cycles through system -> light -> dark -> system", async () => {
it("switches from the dark system theme to a light override", async () => {
mockSystemTheme("dark");
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");

// Start: system
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("System"));

// Click 1: light
await button.click();
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Light"));

// Click 2: dark
await button.click();
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Dark"));

// Click 3: back to system
await button.click();
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("System"));
await expect.element(document.documentElement).toHaveAttribute("data-mode", "light");
expect(localStorage.getItem("emdash-theme")).toBe("light");
});

it("persists theme to localStorage", async () => {
it("returns to the system theme by removing a matching override", async () => {
mockSystemTheme("light");
localStorage.setItem("emdash-theme", "dark");
const screen = await render(<TestThemeToggle />);
const button = screen.getByRole("button");
await button.click(); // system -> light
expect(localStorage.getItem("emdash-theme")).toBe("light");
await button.click();
await expect.element(document.documentElement).toHaveAttribute("data-mode", "light");
expect(localStorage.getItem("emdash-theme")).toBeNull();
});

it("starts with light theme when defaultTheme is light", async () => {
mockSystemTheme("light");
const screen = await render(<TestThemeToggle defaultTheme="light" />);
const button = screen.getByRole("button");
await expect.element(button).toHaveAttribute("aria-label", expect.stringContaining("Light"));
await expect.element(button).toHaveAttribute("aria-label", "Switch to dark");
});
});
Loading