| 1 | // Run: tsx src/__tests__/anchored-overlay-lifecycle.test.tsx |
| 2 | |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import React, { act } from "react"; |
| 6 | import { createRoot } from "react-dom/client"; |
| 7 | import { ComposerChoice } from "../components/ComposerChoice"; |
| 8 | import { Tooltip } from "../components/Tooltip"; |
| 9 | |
| 10 | const dom = new JSDOM('<div id="root"></div>', { pretendToBeVisual: true, url: "http://localhost/" }); |
| 11 | Object.assign(globalThis, { |
| 12 | window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, |
| 13 | MouseEvent: dom.window.MouseEvent, KeyboardEvent: dom.window.KeyboardEvent, |
| 14 | IS_REACT_ACT_ENVIRONMENT: true, |
| 15 | }); |
| 16 | window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as typeof window.matchMedia; |
| 17 | let nextFrameId = 1; |
| 18 | const pendingFrames = new Map<number, FrameRequestCallback>(); |
| 19 | const requestFrame = ((callback: FrameRequestCallback) => { |
| 20 | const id = nextFrameId++; |
| 21 | pendingFrames.set(id, callback); |
| 22 | return id; |
| 23 | }) as typeof window.requestAnimationFrame; |
| 24 | const cancelFrame = ((id: number) => pendingFrames.delete(id)) as typeof window.cancelAnimationFrame; |
| 25 | window.requestAnimationFrame = requestFrame; |
| 26 | window.cancelAnimationFrame = cancelFrame; |
| 27 | Object.assign(globalThis, { requestAnimationFrame: requestFrame, cancelAnimationFrame: cancelFrame }); |
| 28 | const originalRect = dom.window.HTMLElement.prototype.getBoundingClientRect; |
| 29 | let tooltipHasLayout = true; |
| 30 | dom.window.HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() { |
| 31 | if (this.matches("button, .tooltip-trigger")) return new dom.window.DOMRect(300, 500, 160, 32); |
| 32 | if (this.matches("[data-anchored-popover]")) return new dom.window.DOMRect(0, 0, 240, 100); |
| 33 | if (this.matches("[role=tooltip]")) return tooltipHasLayout |
| 34 | ? new dom.window.DOMRect(0, 0, 80, 28) |
| 35 | : new dom.window.DOMRect(0, 0, 0, 0); |
| 36 | return originalRect.call(this); |
| 37 | }; |
| 38 | async function settle() { |
| 39 | await act(async () => { |
| 40 | await new Promise((resolve) => setTimeout(resolve, 1)); |
| 41 | }); |
| 42 | await act(async () => { |
| 43 | const callbacks = [...pendingFrames.values()]; |
| 44 | pendingFrames.clear(); |
| 45 | callbacks.forEach((callback) => callback(performance.now())); |
| 46 | }); |
| 47 | } |
| 48 | |
| 49 | const root = createRoot(document.getElementById("root")!); |
| 50 | async function renderChoice(disabled: boolean, dismissSignal = 0) { |
| 51 | await act(async () => root.render(<ComposerChoice label="Effort" value="a" disabled={disabled} |
| 52 | dismissSignal={dismissSignal} |
| 53 | options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onPick={() => {}} />)); |
| 54 | } |
| 55 | await renderChoice(false); |
| 56 | await act(async () => document.querySelector<HTMLButtonElement>(".composer-choice")!.click()); |
| 57 | assert.ok(document.querySelector("[data-anchored-popover]"), "choice opens its menu"); |
| 58 | await renderChoice(true); |
| 59 | await settle(); |
| 60 | assert.equal(document.querySelector("[data-anchored-popover]"), null, "disabled closes the menu"); |
| 61 | await renderChoice(false); |
| 62 | assert.equal(document.querySelector("[data-anchored-popover]"), null, "disabled then enabled does not reopen the menu"); |
| 63 | assert.equal(document.querySelector(".composer-choice")?.getAttribute("aria-expanded"), "false"); |
| 64 | |
| 65 | await act(async () => document.querySelector<HTMLButtonElement>(".composer-choice")!.click()); |
| 66 | assert.ok(document.querySelector("[data-anchored-popover]"), "choice reopens after becoming enabled"); |
| 67 | await renderChoice(false, 1); |
| 68 | await settle(); |
| 69 | assert.equal(document.querySelector("[data-anchored-popover]"), null, "dismiss signal closes the choice menu"); |
| 70 | await renderChoice(false, 2); |
| 71 | assert.equal(document.querySelector("[data-anchored-popover]"), null, "a later dismiss signal does not reopen the menu"); |
| 72 | |
| 73 | await act(async () => root.render(<div id="tooltip-host"><Tooltip label="Help" delay={0}><button>Info</button></Tooltip></div>)); |
| 74 | const trigger = document.querySelector<HTMLElement>(".tooltip-trigger")!; |
| 75 | await act(async () => trigger.dispatchEvent(new dom.window.MouseEvent("mouseover", { bubbles: true }))); |
| 76 | await settle(); |
| 77 | assert.ok(document.querySelector("[role=tooltip]"), "tooltip opens from a valid anchor"); |
| 78 | await act(async () => { (document.getElementById("tooltip-host") as HTMLElement).hidden = true; }); |
| 79 | await settle(); |
| 80 | assert.equal(document.querySelector("[role=tooltip]"), null, "tooltip closes when its anchor becomes hidden"); |
| 81 | |
| 82 | await act(async () => { |
| 83 | (document.getElementById("tooltip-host") as HTMLElement).hidden = false; |
| 84 | trigger.dispatchEvent(new dom.window.MouseEvent("mouseover", { bubbles: true })); |
| 85 | (document.getElementById("tooltip-host") as HTMLElement).hidden = true; |
| 86 | }); |
| 87 | await settle(); |
| 88 | assert.equal(document.querySelector("[role=tooltip]"), null, "hidden anchor cancels delayed tooltip display"); |
| 89 | |
| 90 | await act(async () => { (document.getElementById("tooltip-host") as HTMLElement).hidden = false; }); |
| 91 | tooltipHasLayout = false; |
| 92 | await act(async () => trigger.dispatchEvent(new dom.window.MouseEvent("mouseover", { bubbles: true }))); |
| 93 | await settle(); |
| 94 | assert.equal((document.querySelector<HTMLElement>("[role=tooltip]")?.style.visibility), "hidden", "unmeasured tooltip stays hidden"); |
| 95 | tooltipHasLayout = true; |
| 96 | await settle(); |
| 97 | assert.equal((document.querySelector<HTMLElement>("[role=tooltip]")?.style.visibility), "visible", "tooltip becomes visible after delayed layout"); |
| 98 | |
| 99 | await act(async () => root.unmount()); |
| 100 | dom.window.close(); |
| 101 | console.log("PASS anchored overlay owner state and tooltip lifecycle"); |
| 102 |