| 1 | // Run: tsx src/__tests__/theme-editor-keyboard.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import { ThemeGallery } from "../components/ThemeGallery"; |
| 7 | import { LocaleProvider } from "../lib/i18n"; |
| 8 | import type { ThemeExperienceView } from "../lib/themeExperience"; |
| 9 | import type { ThemePackView } from "../lib/themePack"; |
| 10 | |
| 11 | let passed = 0; |
| 12 | let failed = 0; |
| 13 | |
| 14 | function ok(value: boolean, label: string) { |
| 15 | if (value) { |
| 16 | process.stdout.write(` PASS ${label}\n`); |
| 17 | passed += 1; |
| 18 | } else { |
| 19 | process.stdout.write(` FAIL ${label}\n`); |
| 20 | failed += 1; |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | async function flush() { |
| 25 | await new Promise((resolve) => setTimeout(resolve, 20)); |
| 26 | } |
| 27 | |
| 28 | const experience: ThemeExperienceView = { |
| 29 | themeMode: "dark", |
| 30 | baseStyle: "graphite", |
| 31 | effectiveStyle: "graphite", |
| 32 | activePack: null, |
| 33 | }; |
| 34 | |
| 35 | const savedPack: ThemePackView = { |
| 36 | id: "my-theme", |
| 37 | name: "My Theme", |
| 38 | baseStyle: "graphite", |
| 39 | builtin: false, |
| 40 | kind: "user", |
| 41 | active: false, |
| 42 | hasBackground: false, |
| 43 | tokens: {}, |
| 44 | recipes: { density: "comfortable", corners: "soft" }, |
| 45 | }; |
| 46 | |
| 47 | console.log("\ntheme editor keyboard ownership"); |
| 48 | |
| 49 | const dom = new JSDOM("<!doctype html><html><body><button id=\"opener\">Open editor</button><div id=\"root\"></div></body></html>", { |
| 50 | pretendToBeVisual: true, |
| 51 | url: "http://localhost/", |
| 52 | }); |
| 53 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 54 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 55 | globalThis.document = dom.window.document; |
| 56 | globalThis.Node = dom.window.Node; |
| 57 | globalThis.Element = dom.window.Element; |
| 58 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 59 | globalThis.HTMLInputElement = dom.window.HTMLInputElement; |
| 60 | globalThis.Event = dom.window.Event; |
| 61 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 62 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 63 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 64 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 65 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 66 | (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {}; |
| 67 | (dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {}; |
| 68 | |
| 69 | let resolveSave: ((pack: ThemePackView) => void) | null = null; |
| 70 | const pendingSave = new Promise<ThemePackView>((resolve) => { |
| 71 | resolveSave = resolve; |
| 72 | }); |
| 73 | window.go = { |
| 74 | main: { |
| 75 | App: { |
| 76 | ListThemePacks: async () => [], |
| 77 | SaveThemePack: async () => pendingSave, |
| 78 | }, |
| 79 | }, |
| 80 | }; |
| 81 | |
| 82 | const rootElement = document.getElementById("root"); |
| 83 | const opener = document.getElementById("opener") as HTMLButtonElement | null; |
| 84 | if (!rootElement || !opener) throw new Error("missing test root"); |
| 85 | const root = createRoot(rootElement); |
| 86 | |
| 87 | function gallery(key: string) { |
| 88 | return ( |
| 89 | <LocaleProvider> |
| 90 | <ThemeGallery |
| 91 | key={key} |
| 92 | experience={experience} |
| 93 | initialCreateBaseStyle="graphite" |
| 94 | onExperienceChange={() => {}} |
| 95 | onBack={() => {}} |
| 96 | /> |
| 97 | </LocaleProvider> |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | let outerEscapeCount = 0; |
| 102 | const onOuterEscape = (event: KeyboardEvent) => { |
| 103 | if (event.key === "Escape") outerEscapeCount += 1; |
| 104 | }; |
| 105 | document.addEventListener("keydown", onOuterEscape); |
| 106 | |
| 107 | opener.focus(); |
| 108 | await act(async () => { |
| 109 | root.render(gallery("normal")); |
| 110 | await flush(); |
| 111 | }); |
| 112 | const normalDialog = document.querySelector<HTMLElement>(".theme-gallery__editor"); |
| 113 | if (!normalDialog) throw new Error("theme editor did not render"); |
| 114 | const normalEscape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); |
| 115 | await act(async () => { |
| 116 | normalDialog.dispatchEvent(normalEscape); |
| 117 | await flush(); |
| 118 | }); |
| 119 | ok(normalEscape.defaultPrevented, "normal Escape is consumed by the nested editor"); |
| 120 | ok(outerEscapeCount === 0, "normal Escape does not reach the outer Settings handler"); |
| 121 | ok(document.querySelector(".theme-gallery__editor") === null, "normal Escape closes only the theme editor"); |
| 122 | ok(document.activeElement === opener, "normal Escape restores focus to the opener"); |
| 123 | |
| 124 | opener.focus(); |
| 125 | await act(async () => { |
| 126 | root.render(gallery("busy")); |
| 127 | await flush(); |
| 128 | }); |
| 129 | const saveButton = Array.from(document.querySelectorAll<HTMLButtonElement>(".theme-editor__actions button")) |
| 130 | .find((button) => button.textContent === "Save"); |
| 131 | if (!saveButton) throw new Error("save button did not render"); |
| 132 | await act(async () => { |
| 133 | saveButton.click(); |
| 134 | await flush(); |
| 135 | }); |
| 136 | ok(saveButton.disabled, "save puts the editor into its busy state"); |
| 137 | const busyDialog = document.querySelector<HTMLElement>(".theme-gallery__editor"); |
| 138 | if (!busyDialog) throw new Error("busy theme editor did not remain mounted"); |
| 139 | const busyEscape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); |
| 140 | await act(async () => { |
| 141 | busyDialog.dispatchEvent(busyEscape); |
| 142 | await flush(); |
| 143 | }); |
| 144 | ok(busyEscape.defaultPrevented, "busy Escape is still consumed by the nested editor"); |
| 145 | ok(outerEscapeCount === 0, "busy Escape does not close the outer Settings panel"); |
| 146 | ok(document.querySelector(".theme-gallery__editor") !== null, "busy Escape keeps the editor mounted until save completes"); |
| 147 | |
| 148 | await act(async () => { |
| 149 | resolveSave?.(savedPack); |
| 150 | await pendingSave; |
| 151 | await flush(); |
| 152 | }); |
| 153 | |
| 154 | document.removeEventListener("keydown", onOuterEscape); |
| 155 | await act(async () => root.unmount()); |
| 156 | dom.window.close(); |
| 157 | |
| 158 | console.log(`\n${passed} passed, ${failed} failed`); |
| 159 | if (failed > 0) process.exit(1); |
| 160 |