diff --git a/src/features/settings/__tests__/themePreview.test.tsx b/src/features/settings/__tests__/themePreview.test.tsx new file mode 100644 index 000000000..38f04e116 --- /dev/null +++ b/src/features/settings/__tests__/themePreview.test.tsx @@ -0,0 +1,82 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { applyThemeAttributes, currentThemeId, useThemeOverride } from "@/workbench/theme"; +import { THEME_PREVIEW_DELAY } from "@/workbench/quickSwitch"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { openSettings, resetPage } from "../model/staging"; +import { PageContext } from "../components/context"; +import { BEHAVIOR_PAGES } from "../pages/behavior"; + +const root = document.documentElement; +const shown = () => [root.dataset.theme, root.dataset.colorTheme ?? null]; + +function renderThemeRow() { + const page = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!; + const option = page.sections!.flatMap((s) => s.options).find((o) => o.kind === "custom" && o.label === "Theme"); + if (option?.kind !== "custom") throw new Error("no Theme row"); + const Row = option.component; + return render( + + + , + ); +} + +const pick = (label: string) => { + fireEvent.click(screen.getByRole("button", { name: "Theme" })); + fireEvent.click(within(screen.getByRole("listbox")).getByText(label)); +}; + +beforeEach(() => { + vi.useFakeTimers(); + useThemeOverride.setState({ theme: null }); + useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null }); + applyThemeAttributes(currentThemeId()); + openSettings("appearance"); +}); + +afterEach(() => { + vi.useRealTimers(); + useThemeOverride.setState({ theme: null }); + applyThemeAttributes(currentThemeId()); +}); + +describe("Settings › Appearance › Theme preview", () => { + it("shows a picked theme in this window until Apply", () => { + renderThemeRow(); + pick("Solarized Dark"); + expect(shown()).toEqual(["dark", "solarized-dark"]); + }); + + it("goes back to the selected theme when it is picked again before the hover preview shows", () => { + renderThemeRow(); + expect(shown()).toEqual(["light", null]); + pick("Solarized Dark"); + expect(shown()).toEqual(["dark", "solarized-dark"]); + // Clicked quicker than the hover delay: the list itself never previewed anything. + act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY / 2)); + pick("Islands Light"); + act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY)); + expect(screen.getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light"); + expect(shown()).toEqual(["light", null]); + }); + + it("goes back to the selected theme after hovering it long enough", () => { + renderThemeRow(); + pick("Solarized Dark"); + fireEvent.click(screen.getByRole("button", { name: "Theme" })); + fireEvent.pointerEnter(within(screen.getByRole("listbox")).getByText("Islands Light")); + act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY)); + fireEvent.click(within(screen.getByRole("listbox")).getByText("Islands Light")); + expect(shown()).toEqual(["light", null]); + }); + + it("goes back to the selected theme when the page is reset", () => { + renderThemeRow(); + pick("Solarized Dark"); + act(() => resetPage("appearance")); + expect(screen.getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light"); + expect(shown()).toEqual(["light", null]); + }); +}); diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 23a4ff5be..f7a88d0a0 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -53,6 +53,9 @@ function Indented({ children }: { children: ReactNode }) { // --------------------------------------------------------------------------- // Appearance +/** Shows the theme picked in Settings in this window until Apply, or the selected one again. */ +const showPicked = (id: ConcreteTheme) => previewTheme(id !== currentThemeChoice() ? id : null); + function ThemeRow() { const [theme, setTheme] = useSetting("appearance.theme"); const [sync, setSync] = useSetting("appearance.syncWithOs"); @@ -72,11 +75,16 @@ function ThemeRow() { }, [], ); + // The staged theme can also change without the list, e.g. by Reset. + useEffect(() => { + if (previewed.current) showPicked(shown); + }, [shown]); const preview = (id: ConcreteTheme | null) => { previewed.current = true; if (id !== null) return picker.preview(id); + // The picker takes back only its own hover preview, not a theme picked here earlier. picker.restore(); - if (shown !== currentThemeChoice()) previewTheme(shown); + showPicked(shown); }; return (