Open History to see the last commit
1import { describe, expect, it, vi } from "vite-plus/test";
2import { page, userEvent } from "vite-plus/test/browser";
3
4import { act, render } from "../../../test/render";
5import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
6import { ThemeEditorPanel } from "./ThemeEditorPanel";
7
8function dragHeader(panel: HTMLElement, toX: number, toY: number) {
9 const header = panel.querySelector<HTMLElement>("h2")!.closest<HTMLElement>(".cursor-grab")!;
10 const rect = panel.getBoundingClientRect();
11 const start = { clientX: rect.left + 40, clientY: rect.top + 10 };
12 const init = { bubbles: true, button: 0, pointerId: 1 };
13 header.dispatchEvent(new PointerEvent("pointerdown", { ...init, ...start }));
14 header.dispatchEvent(
15 new PointerEvent("pointermove", { ...init, clientX: toX + 40, clientY: toY + 10 }),
16 );
17 header.dispatchEvent(new PointerEvent("pointerup", init));
18}
19
20function dragResizeHandle(panel: HTMLElement, byX: number) {
21 const handle = panel.querySelector<HTMLElement>(".cursor-se-resize")!;
22 const rect = handle.getBoundingClientRect();
23 const init = { bubbles: true, button: 0, pointerId: 2 };
24 const start = { clientX: rect.left + 4, clientY: rect.top + 4 };
25 handle.dispatchEvent(new PointerEvent("pointerdown", { ...init, ...start }));
26 handle.dispatchEvent(
27 new PointerEvent("pointermove", {
28 ...init,
29 clientX: start.clientX + byX,
30 clientY: start.clientY,
31 }),
32 );
33 handle.dispatchEvent(new PointerEvent("pointerup", init));
34}
35
36function pointerAt(target: HTMLElement, type: string, xFraction: number) {
37 const rect = target.getBoundingClientRect();
38 target.dispatchEvent(
39 new PointerEvent(type, {
40 bubbles: true,
41 button: 0,
42 pointerId: 3,
43 clientX: rect.left + rect.width * xFraction,
44 clientY: rect.top + rect.height / 2,
45 }),
46 );
47}
48
49async function renderPanel() {
50 await page.viewport(1280, 900);
51 const stack = new AsyncDisposableStack();
52 // Synthetic pointer ids are not active pointers, so the browser would reject capture.
53 stack.use(vi.spyOn(HTMLElement.prototype, "setPointerCapture").mockImplementation(() => {}));
54 // Tailwind is not loaded here; give the panel its fixed, card-sized box.
55 const style = document.head.appendChild(document.createElement("style"));
56 style.textContent = "[data-theme-editor-panel]{position:fixed;width:400px;height:300px}";
57 stack.defer(() => style.remove());
58 const screen = stack.use(
59 await render(
60 <ThemeEditorPanel
61 open
62 onOpenChange={() => {}}
63 onSaved={() => true}
64 editingTheme={null}
65 initialAppearance="dark"
66 restoreTheme={() => {}}
67 />,
68 ),
69 );
70 const panel = screen.getByRole("dialog", { name: "Create theme" });
71 await expect.element(panel).toBeVisible();
72 return {
73 panel: panel.element() as HTMLElement,
74 [Symbol.asyncDispose]: () => stack.disposeAsync(),
75 };
76}
77
78// The editor floats beside the Settings dialog, whose Esc handler sits on the document.
79async function renderBesideSettings() {
80 const onSettingsOpenChange = vi.fn();
81 const onEditorOpenChange = vi.fn();
82 await using stack = new AsyncDisposableStack();
83 stack.use(
84 await render(
85 <>
86 <Dialog onOpenChange={onSettingsOpenChange} open>
87 <DialogPopup>
88 <DialogTitle>Settings</DialogTitle>
89 </DialogPopup>
90 </Dialog>
91 <ThemeEditorPanel
92 open
93 onOpenChange={onEditorOpenChange}
94 onSaved={() => true}
95 editingTheme={null}
96 initialAppearance="light"
97 restoreTheme={() => {}}
98 />
99 </>,
100 ),
101 );
102 // Settings moves focus in on a later frame; landing after the test focuses the editor, it steals it.
103 await expect.element(page.getByRole("button", { name: "Close" })).toHaveFocus();
104 const screen = stack.move();
105 return {
106 onSettingsOpenChange,
107 onEditorOpenChange,
108 [Symbol.asyncDispose]: () => screen.disposeAsync(),
109 };
110}
111
112// The modal Settings dialog marks the editor inert, hiding it from role queries and pointer input.
113async function editorElement(selector: string) {
114 const query = () => document.querySelector<HTMLElement>(`[data-theme-editor-panel] ${selector}`);
115 await expect.poll(query).toBeTruthy();
116 return query()!;
117}
118
119describe("ThemeEditorPanel", () => {
120 it("keeps its left edge when resized after the window shrank", async () => {
121 await using view = await renderPanel();
122 dragHeader(view.panel, 700, 40);
123 await expect.poll(() => view.panel.style.left).toBe("700px");
124
125 await page.viewport(600, 900);
126 await expect.poll(() => Number.parseFloat(view.panel.style.left)).toBeLessThan(600);
127 const clampedLeft = view.panel.style.left;
128
129 dragResizeHandle(view.panel, -100);
130 await expect.poll(() => view.panel.style.width).not.toBe("");
131 expect(view.panel.style.left).toBe(clampedLeft);
132 });
133
134 it("returns to where the user left it when the window shrinks and grows back", async () => {
135 await page.viewport(1280, 900);
136 // Synthetic pointer ids are not active pointers, so the browser would reject capture.
137 using _capture = vi
138 .spyOn(HTMLElement.prototype, "setPointerCapture")
139 .mockImplementation(() => {});
140 // Tailwind is not loaded here; give the panel its fixed, card-sized box.
141 const style = document.head.appendChild(document.createElement("style"));
142 style.textContent = "[data-theme-editor-panel]{position:fixed;width:400px;height:300px}";
143 using _style = { [Symbol.dispose]: () => style.remove() };
144 await using screen = await render(
145 <ThemeEditorPanel
146 open
147 onOpenChange={() => {}}
148 onSaved={() => true}
149 editingTheme={null}
150 initialAppearance="dark"
151 restoreTheme={() => {}}
152 />,
153 );
154 const panel = screen.getByRole("dialog", { name: "Create theme" });
155 await expect.element(panel).toBeVisible();
156
157 dragHeader(panel.element() as HTMLElement, 700, 40);
158 await expect.poll(() => (panel.element() as HTMLElement).style.left).toBe("700px");
159
160 await page.viewport(600, 900);
161 await expect
162 .poll(() => Number.parseFloat((panel.element() as HTMLElement).style.left))
163 .toBeLessThan(600);
164
165 await page.viewport(1280, 900);
166 await expect.poll(() => (panel.element() as HTMLElement).style.left).toBe("700px");
167 });
168
169 it("restores the colors from before the picker opened when Escape closes it", async () => {
170 await using _view = await renderPanel();
171 const hex = page.getByRole("textbox", { name: "Accent hex value" });
172 const original = (hex.element() as HTMLInputElement).value;
173 await userEvent.click(page.getByRole("button", { name: "Choose Accent color" }));
174 const hue = page.getByRole("slider", { name: "Accent hue" });
175 await expect.element(hue).toBeInTheDocument();
176 const hueSlider = hue.element() as HTMLElement;
177
178 await act(async () => {
179 pointerAt(hueSlider, "pointerdown", 0.5);
180 pointerAt(hueSlider, "pointerup", 0.5);
181 });
182 await expect.element(hex).not.toHaveValue(original);
183
184 await act(async () => {
185 pointerAt(hueSlider, "pointerdown", 0.25);
186 hueSlider.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
187 // The drag keeps going while the picker animates out.
188 pointerAt(hueSlider, "pointermove", 0.75);
189 pointerAt(hueSlider, "pointerup", 0.75);
190 });
191 expect((hex.element() as HTMLInputElement).value).toBe(original);
192 });
193
194 it("closes on Escape without closing Settings", async () => {
195 await using view = await renderBesideSettings();
196 (await editorElement("input")).focus();
197 await userEvent.keyboard("{Escape}");
198
199 expect(view.onEditorOpenChange).toHaveBeenCalledWith(false);
200 expect(view.onSettingsOpenChange).not.toHaveBeenCalled();
201 });
202
203 it("leaves inspection on Escape without closing the editor or Settings", async () => {
204 await using view = await renderBesideSettings();
205 (await editorElement('[aria-label="Inspect app colors"]')).click();
206 await editorElement('[aria-label="Cancel inspecting app colors"]');
207 await userEvent.keyboard("{Escape}");
208
209 await editorElement('[aria-label="Inspect app colors"]');
210 expect(view.onEditorOpenChange).not.toHaveBeenCalled();
211 expect(view.onSettingsOpenChange).not.toHaveBeenCalled();
212 });
213});