| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | // @vitest-environment jsdom |
| 2 | |
| 3 | import { afterEach, describe, expect, it } from "vite-plus/test"; |
| 4 | |
| 5 | import { runPreviewClickKeepingHostFocus } from "./previewClickFocus"; |
| 6 | |
| 7 | const TAB = "runtime-tab"; |
| 8 | |
| 9 | const mount = (tagName: string, previewTab?: string) => { |
| 10 | const element = document.createElement(tagName); |
| 11 | element.tabIndex = -1; |
| 12 | if (previewTab) element.setAttribute("data-preview-tab", previewTab); |
| 13 | document.body.append(element); |
| 14 | return element; |
| 15 | }; |
| 16 | |
| 17 | afterEach(() => { |
| 18 | document.body.replaceChildren(); |
| 19 | }); |
| 20 | |
| 21 | describe("runPreviewClickKeepingHostFocus", () => { |
| 22 | it("gives focus back to the composer after the click focuses the page", async () => { |
| 23 | const composer = mount("textarea"); |
| 24 | const webview = mount("webview", TAB); |
| 25 | composer.focus(); |
| 26 | |
| 27 | const result = await runPreviewClickKeepingHostFocus(TAB, async () => { |
| 28 | webview.focus(); |
| 29 | return "clicked"; |
| 30 | }); |
| 31 | |
| 32 | expect(result).toBe("clicked"); |
| 33 | expect(document.activeElement).toBe(composer); |
| 34 | }); |
| 35 | |
| 36 | it("gives focus back when the user's typing interrupts the click", async () => { |
| 37 | const composer = mount("textarea"); |
| 38 | const webview = mount("webview", TAB); |
| 39 | composer.focus(); |
| 40 | const interrupted = new Error("PreviewAutomationControlInterruptedError"); |
| 41 | |
| 42 | await expect( |
| 43 | runPreviewClickKeepingHostFocus(TAB, async () => { |
| 44 | webview.focus(); |
| 45 | throw interrupted; |
| 46 | }), |
| 47 | ).rejects.toBe(interrupted); |
| 48 | |
| 49 | expect(document.activeElement).toBe(composer); |
| 50 | }); |
| 51 | |
| 52 | it("gives focus back once overlapping clicks in two tabs finish", async () => { |
| 53 | const composer = mount("textarea"); |
| 54 | const first = mount("webview", TAB); |
| 55 | const second = mount("webview", "other-tab"); |
| 56 | composer.focus(); |
| 57 | let finishSecond = () => {}; |
| 58 | |
| 59 | const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => { |
| 60 | first.focus(); |
| 61 | }); |
| 62 | // Starts while the first click holds focus in its page, and finishes last. |
| 63 | const secondClick = runPreviewClickKeepingHostFocus("other-tab", async () => { |
| 64 | second.focus(); |
| 65 | await new Promise<void>((resolve) => { |
| 66 | finishSecond = resolve; |
| 67 | }); |
| 68 | }); |
| 69 | await firstClick; |
| 70 | finishSecond(); |
| 71 | await secondClick; |
| 72 | |
| 73 | expect(document.activeElement).toBe(composer); |
| 74 | }); |
| 75 | |
| 76 | it("keeps the user's page when overlapping clicks finish in reverse order", async () => { |
| 77 | const first = mount("webview", TAB); |
| 78 | const second = mount("webview", "other-tab"); |
| 79 | second.focus(); |
| 80 | let finishFirst = () => {}; |
| 81 | |
| 82 | const firstClick = runPreviewClickKeepingHostFocus(TAB, async () => { |
| 83 | first.focus(); |
| 84 | await new Promise<void>((resolve) => { |
| 85 | finishFirst = resolve; |
| 86 | }); |
| 87 | }); |
| 88 | // Starts while the first click holds focus in its page, and finishes first. |
| 89 | await runPreviewClickKeepingHostFocus("other-tab", async () => { |
| 90 | second.focus(); |
| 91 | }); |
| 92 | finishFirst(); |
| 93 | await firstClick; |
| 94 | |
| 95 | expect(document.activeElement).toBe(second); |
| 96 | }); |
| 97 | |
| 98 | it("ignores a click that never finishes", async () => { |
| 99 | const composer = mount("textarea"); |
| 100 | const stuck = mount("webview", "stuck-tab"); |
| 101 | const webview = mount("webview", TAB); |
| 102 | const other = mount("webview", "other-tab"); |
| 103 | composer.focus(); |
| 104 | |
| 105 | // A click queued behind a page promise that never settles. |
| 106 | void runPreviewClickKeepingHostFocus("stuck-tab", () => new Promise<never>(() => {})); |
| 107 | await runPreviewClickKeepingHostFocus(TAB, async () => { |
| 108 | webview.focus(); |
| 109 | }); |
| 110 | expect(document.activeElement).toBe(composer); |
| 111 | |
| 112 | // The user opens the stuck tab's page themselves. |
| 113 | stuck.focus(); |
| 114 | await runPreviewClickKeepingHostFocus("other-tab", async () => { |
| 115 | other.focus(); |
| 116 | }); |
| 117 | expect(document.activeElement).toBe(stuck); |
| 118 | }); |
| 119 | |
| 120 | it("leaves no focus in the page when the composer can no longer take focus", async () => { |
| 121 | const composer = mount("div"); |
| 122 | const webview = mount("webview", TAB); |
| 123 | composer.focus(); |
| 124 | |
| 125 | await runPreviewClickKeepingHostFocus(TAB, async () => { |
| 126 | webview.focus(); |
| 127 | // Like the editor going read-only for an approval request. |
| 128 | composer.removeAttribute("tabindex"); |
| 129 | }); |
| 130 | |
| 131 | expect(document.activeElement).toBe(document.body); |
| 132 | }); |
| 133 | |
| 134 | it("leaves no focus in the page when nothing in the app had focus", async () => { |
| 135 | const webview = mount("webview", TAB); |
| 136 | |
| 137 | await runPreviewClickKeepingHostFocus(TAB, async () => { |
| 138 | webview.focus(); |
| 139 | }); |
| 140 | |
| 141 | expect(document.activeElement).toBe(document.body); |
| 142 | }); |
| 143 | |
| 144 | it("keeps focus in the page when the user was already typing there", async () => { |
| 145 | const webview = mount("webview", TAB); |
| 146 | webview.focus(); |
| 147 | |
| 148 | await runPreviewClickKeepingHostFocus(TAB, async () => undefined); |
| 149 | |
| 150 | expect(document.activeElement).toBe(webview); |
| 151 | }); |
| 152 | }); |