// Overlay behaviour: stacking, dismissal order, and the promises. import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; const here = dirname(fileURLToPath(import.meta.url)); import { setupDom, teardownDom, key, fire } from "./_dom.js"; import { EDGES, sectorAt, sectorInArc } from "../elements/wheel.js"; let composer, liveCount, LADDER; let entries; let DEMOS; let ROOT_ID; let baseOf; test.before(async () => { setupDom(); ({ composer, entries } = await import("../composer.js")); ({ DEMOS } = await import("../../catalogue/demos.js")); ({ ROOT_ID } = await import("../internals/portal.js")); ({ baseOf, liveCount, LADDER } = await import("../internals/zlayer.js")); }); test.after(() => teardownDom()); // Overlays live in the portal, not under the test's own node, so a test that // leaves one open would be counted by the next one. test.beforeEach(() => { const root = document.getElementById(ROOT_ID); if (root) root.replaceChildren(); }); const portalRoot = () => document.getElementById(ROOT_ID); const zOf = (node) => Number(node.closest("[style*='z-index']")?.style.zIndex || node.style.zIndex); // --- the bug v4 had --------------------------------------------------------- test("Escape inside a modal closes the autocomplete first, then the modal", () => { // v4's modal and its autocomplete each owned an Escape handler, so one press // closed both and you lost the dialog you were filling in. const closed = []; const modal = composer.modal.generic({ title: "Edit", onClose: () => closed.push("modal") }); const input = composer.input.md({}); modal.node.appendChild(input.node); const ac = composer.autocomplete.default({ input, source: () => [{ label: "rooftop" }], minChars: 1, }); input.node.value = "r"; fire(input.node, "input"); assert.ok(document.querySelector(".cx-autocomplete"), "the menu opened"); key(document.body, "Escape"); assert.deepEqual(closed, [], "the modal survived"); assert.equal(document.querySelector(".cx-autocomplete"), null, "the menu closed"); key(document.body, "Escape"); assert.deepEqual(closed, ["modal"]); ac.destroy(); }); test("an autocomplete outranks a modal, so it is not hidden behind it", () => { const modal = composer.modal.generic({ title: "Edit" }); const input = composer.input.md({}); modal.node.appendChild(input.node); const ac = composer.autocomplete.default({ input, source: () => [{ label: "x" }], minChars: 1 }); input.node.value = "x"; fire(input.node, "input"); const menu = document.querySelector(".cx-autocomplete"); assert.ok(Number(menu.style.zIndex) >= baseOf("autocomplete")); assert.ok(Number(menu.style.zIndex) > baseOf("modal")); ac.destroy(); modal.destroy(); }); // --- modals ----------------------------------------------------------------- test("a modal mounts into the portal and leaves nothing behind", () => { const m = composer.modal.generic({ title: "Export" }); assert.ok(portalRoot().contains(m.node)); m.destroy(); assert.equal(portalRoot().childElementCount, 0); }); test("a modal is announced as a dialog and labelled by its title", () => { const m = composer.modal.generic({ title: "Export settings" }); assert.equal(m.node.getAttribute("role"), "dialog"); assert.equal(m.node.getAttribute("aria-modal"), "true"); const labelled = document.getElementById(m.node.getAttribute("aria-labelledby")); assert.equal(labelled.textContent, "Export settings"); m.destroy(); }); test("dialogue resolves true on confirm and false on cancel", async () => { const yes = composer.modal.dialogue({ title: "Delete?", message: "Gone for good.", confirmLabel: "Delete" }); yes.node.querySelectorAll(".cx-modal-foot button")[1].click(); assert.equal(await yes.result, true); const no = composer.modal.dialogue({ title: "Delete?", message: "Gone for good." }); no.node.querySelectorAll(".cx-modal-foot button")[0].click(); assert.equal(await no.result, false); }); test("dialogue resolves false when dismissed, rather than hanging", async () => { // A promise nobody settles is a UI that quietly stops responding. const d = composer.modal.dialogue({ title: "Delete?", message: "Gone for good." }); key(document.body, "Escape"); assert.equal(await d.result, false); }); test("prompt resolves its value, and null when dismissed", async () => { const p = composer.modal.prompt({ title: "New project", label: "Name", value: "Untitled" }); const input = p.node.querySelector("input"); input.value = "Rooftop"; p.node.querySelectorAll(".cx-modal-foot button")[1].click(); assert.equal(await p.result, "Rooftop"); const q = composer.modal.prompt({ title: "New project", label: "Name" }); key(document.body, "Escape"); assert.equal(await q.result, null); }); test("prompt refuses to submit a value its validator rejects", async () => { const p = composer.modal.prompt({ title: "New project", label: "Name", value: "", validate: (v) => (v.trim() ? null : "Give it a name."), }); p.node.querySelectorAll(".cx-modal-foot button")[1].click(); assert.equal(p.node.querySelector(".cx-field-error").textContent, "Give it a name."); assert.ok(portalRoot().contains(p.node), "still open"); p.destroy(); }); test("choice resolves the picked id", async () => { const c = composer.modal.choice({ title: "Start from", items: [ { id: "blank", label: "Empty" }, { id: "image", label: "From an image" }, ] }); c.node.querySelectorAll(".cx-choice-row")[1].click(); assert.equal(await c.result, "image"); }); test("a stacked modal sits above the one it opened from", () => { const first = composer.modal.generic({ title: "One" }); const second = composer.modal.stacked({ title: "Two" }); assert.ok(zOf(second.node) > zOf(first.node)); key(document.body, "Escape"); assert.equal(portalRoot().childElementCount, 1, "only the top one closed"); first.destroy(); }); // --- popovers and menus ----------------------------------------------------- test("a menu reports the item picked and closes", () => { let picked = null; const anchor = composer.button.md({ label: "Scene" }); document.body.appendChild(anchor.node); const menu = composer.menu.dropdown({ anchor, onPick: (id) => { picked = id; }, items: [{ id: "split", label: "Split" }, { id: "dup", label: "Duplicate" }], }); menu.node.querySelectorAll(".cx-menu-item")[1].click(); assert.equal(picked, "dup"); assert.equal(portalRoot().childElementCount, 0); }); test("separators are not clickable items", () => { const anchor = composer.button.md({ label: "Scene" }); const menu = composer.menu.dropdown({ anchor, items: [ { id: "a", label: "A" }, { separator: true }, { id: "b", label: "B" }, ] }); assert.equal(menu.node.querySelectorAll(".cx-menu-item").length, 2); assert.equal(menu.node.querySelectorAll(".cx-menu-sep").length, 1); menu.destroy(); }); test("clicking the trigger again does not close-then-reopen", () => { const anchor = composer.button.md({ label: "Scene" }); document.body.appendChild(anchor.node); const menu = composer.menu.dropdown({ anchor, items: [{ id: "a", label: "A" }] }); fire(anchor.node, "pointerdown"); assert.ok(portalRoot().contains(menu.node), "the anchor counts as inside"); menu.destroy(); }); test("a tooltip shows and hides on demand", () => { const anchor = composer.iconButton.md({ icon: "✎", label: "Rename" }); document.body.appendChild(anchor.node); const tip = composer.tooltip.default({ anchor, text: "Rename this scene", trigger: false }); tip.show(); assert.ok(document.querySelector(".cx-tooltip")); tip.hide(); assert.equal(document.querySelector(".cx-tooltip"), null); tip.destroy(); }); test("autocomplete needs minChars before it opens", () => { const input = composer.input.md({}); document.body.appendChild(input.node); const ac = composer.autocomplete.default({ input, source: () => [{ label: "x" }], minChars: 2 }); input.node.value = "a"; fire(input.node, "input"); assert.equal(document.querySelector(".cx-autocomplete"), null); input.node.value = "ab"; fire(input.node, "input"); assert.ok(document.querySelector(".cx-autocomplete")); ac.destroy(); }); // --- windows ---------------------------------------------------------------- test("a window does not close on an outside click, but does on Escape", () => { // It is a window, not a menu: clicking the app behind it is normal. let closed = false; const w = composer.floating.window({ id: "t1", title: "Composer", onClose: () => { closed = true; } }); fire(document.body, "pointerdown"); assert.equal(closed, false); key(document.body, "Escape"); assert.equal(closed, true); }); test("clicking a window raises it above its peers", () => { const a = composer.floating.window({ id: "t2", title: "A" }); const b = composer.floating.window({ id: "t3", title: "B" }); assert.ok(zOf(b.node) > zOf(a.node)); a.toFront(); assert.ok(zOf(a.node) > zOf(b.node)); a.destroy(); b.destroy(); }); test("a blocking overlay traps focus and clears on close", () => { const o = composer.overlay.blocking({ message: "Restarting…" }); assert.equal(o.node.getAttribute("aria-busy"), "true"); o.destroy(); assert.equal(portalRoot().childElementCount, 0); }); // --- wheel ------------------------------------------------------------------ test("the wheel's dead zone means cancel", () => { assert.equal(sectorAt(0, 0, 6), -1); assert.equal(sectorAt(10, 10, 6), -1); }); test("sector 0 is straight up, and sectors run clockwise", () => { // The muscle memory is the whole point: the same choice must always be at the // same angle. assert.equal(sectorAt(0, -100, 4), 0, "up"); assert.equal(sectorAt(100, 0, 4), 1, "right"); assert.equal(sectorAt(0, 100, 4), 2, "down"); assert.equal(sectorAt(-100, 0, 4), 3, "left"); }); test("every angle lands in a valid sector", () => { for (const count of [2, 3, 5, 6, 8, 12]) { for (let deg = 0; deg < 360; deg += 1) { const rad = (deg - 90) * (Math.PI / 180); const i = sectorAt(Math.cos(rad) * 100, Math.sin(rad) * 100, count); assert.ok(i >= 0 && i < count, `${deg}deg with ${count} items gave ${i}`); } } }); test("boundaries between sectors do not fall through", () => { const count = 6; const slice = 360 / count; for (let i = 0; i < count; i += 1) { const edge = (i * slice + slice / 2 - 0.01 - 90) * (Math.PI / 180); const got = sectorAt(Math.cos(edge) * 100, Math.sin(edge) * 100, count); assert.ok(got >= 0 && got < count); } }); test("the click that opens the wheel does not also pick", () => { // The opening click delivers its own pointerup to the wheel, at wherever the // button was -- which used to commit whatever wedge that direction pointed at. let picked = null; const w = composer.wheel.picker({ items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Remove" }], onPick: (item) => { picked = item.label; }, x: 400, y: 300, }); // 80px straight up from the centre: inside the ring, pointing at item 0. window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true })); assert.equal(picked, null, "a release with no movement is not a pick"); assert.ok(portalRoot().childElementCount > 0, "the wheel is still open"); window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true })); window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true })); assert.equal(picked, "Split", "after moving, a release picks"); w.destroy(); }); test("a pointer beyond the rim points at nothing", () => { // The lit wedge IS the region that picks the item. Highlighting one from // outside the ring promises a pick that release would not deliver. const w = composer.wheel.picker({ items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Rate" }], x: 400, y: 300, }); window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 60, bubbles: true })); assert.equal(w.active, -1, "240px out is off the wheel"); window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true })); assert.equal(w.active, 0, "80px out is on it"); w.destroy(); }); test("releasing in the dead zone cancels without picking", () => { let picked = null; const w = composer.wheel.picker({ items: [{ label: "Split" }, { label: "Duplicate" }], onPick: (item) => { picked = item.label; }, x: 400, y: 300, }); window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 405, clientY: 305, bubbles: true })); window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 405, clientY: 305, bubbles: true })); assert.equal(picked, null); w.destroy(); }); // --- half wheel ------------------------------------------------------------- test("an edge panel's arc faces into the screen, not off it", () => { // The point of pinning to an edge is that every item stays reachable; an arc // pointing the wrong way would put half of them off-screen. const count = 4; for (const [edge, spec] of Object.entries(EDGES)) { const inward = { right: 270, left: 90, bottom: 0, top: 180 }[edge]; const rad = (inward - 90) * (Math.PI / 180); const i = sectorInArc(Math.cos(rad) * 100, Math.sin(rad) * 100, count, spec); assert.ok(i >= 0, `${edge} panel does not cover its own inward direction`); } }); test("a direction outside a half wheel's arc selects nothing", () => { // Off the edge is off the panel: it must not wrap round to the far item. const spec = EDGES.right; // opens leftward const right = sectorInArc(100, 0, 4, spec); // pointing off-screen assert.equal(right, -1); }); test("every inward angle of a half wheel lands in a sector", () => { const spec = EDGES.right; for (let deg = spec.startDeg + 1; deg < spec.startDeg + spec.spanDeg; deg += 1) { const rad = (deg - 90) * (Math.PI / 180); const i = sectorInArc(Math.cos(rad) * 100, Math.sin(rad) * 100, 4, spec); assert.ok(i >= 0 && i < 4, `${deg}deg gave ${i}`); } }); test("a half wheel opens on its edge and picks by key", () => { let picked = null; const w = composer.wheel.half({ edge: "right", at: 300, items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Rate" }], onPick: (item) => { picked = item.label; }, }); assert.ok(w.node.classList.contains("cx-wheel-right")); key(document.body, "3"); assert.equal(picked, "Rate"); w.destroy(); }); test("an edge panel's hub sits fully on screen, not sliced by the edge", () => { // A hub centred on the edge is cut in half by it, and half a cancel target is // not a target. It is pushed inward by its own radius. const w = composer.wheel.half({ edge: "right", at: 300, items: [{ label: "a" }, { label: "b" }] }); const hub = w.node.querySelector(".cx-wheel-hub"); const size = Number(w.node.style.width.replace("px", "")); assert.ok(parseFloat(hub.style.left) < size / 2, "the hub leans into the screen"); const radius = parseFloat(hub.style.width) / 2; assert.ok(parseFloat(hub.style.left) + radius <= size / 2 + 0.01, "and its far edge does not cross the screen edge"); w.destroy(); }); test("cancel is where the hub actually is, not where the centre is", () => { const spec = EDGES.right; const hubR = 40; const arc = { ...spec, rIn: 84, rOut: 147, hubX: -hubR, hubY: 0, hubR }; // A point over the drawn hub cancels... assert.equal(sectorInArc(-hubR, 0, 4, arc), -1); // ...while the geometric centre, which is now outside the hub, is simply off // the ring rather than a pick. assert.equal(sectorInArc(0, 0, 4, arc), -1); }); test("an edge panel follows its edge when the window resizes", () => { // It is pinned to an edge; the edge moving is the one thing it must follow. const w = composer.wheel.half({ edge: "right", items: [{ label: "a" }, { label: "b" }] }); const before = w.centre.x; window.innerWidth = window.innerWidth + 400; window.dispatchEvent(new window.Event("resize")); assert.equal(w.centre.x, before + 400, "the centre moved with the edge"); const size = Number(w.node.style.width.replace("px", "")); assert.equal(parseFloat(w.node.style.left), w.centre.x - size / 2); window.innerWidth = window.innerWidth - 400; w.destroy(); }); test("edge panels stay pinned at any viewport shape", () => { for (const [w, h] of [[320, 640], [1920, 1080], [2560, 1080], [800, 2000]]) { window.innerWidth = w; window.innerHeight = h; for (const edge of Object.keys(EDGES)) { const p = composer.wheel.half({ edge, items: [{ label: "a" }, { label: "b" }, { label: "c" }] }); const [ex, ey] = EDGES[edge].anchor(w, h); assert.deepEqual(p.centre, { x: ex, y: ey }, `${edge} at ${w}x${h}`); p.destroy(); } } }); test("an unknown edge is refused rather than guessed", () => { assert.throws(() => composer.wheel.half({ edge: "diagonal", items: [{ label: "a" }, { label: "b" }] }), RangeError); }); test("the wheel refuses a count nobody could aim at", () => { assert.throws(() => composer.wheel.picker({ items: [{ label: "only one" }] }), RangeError); const many = Array.from({ length: 13 }, (_, i) => ({ label: String(i) })); assert.throws(() => composer.wheel.picker({ items: many }), RangeError); }); test("number keys pick directly", () => { let picked = null; const w = composer.wheel.picker({ items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Remove" }], onPick: (item) => { picked = item.label; }, }); key(document.body, "2"); assert.equal(picked, "Duplicate"); assert.equal(portalRoot().childElementCount, 0, "picking closes it"); w.destroy(); }); // --- gallery ---------------------------------------------------------------- test("the gallery sizes from its container, not the viewport", () => { const g = composer.gallery.adaptive({ id: "g1", cols: 3, items: [{ id: "1", label: "a.mp4" }] }); const grid = g.node.querySelector(".cx-gallery"); assert.equal(grid.dataset.cols, "3"); assert.match(grid.style.getPropertyValue("--cell"), /cqw$/); g.setCols(0); assert.match(grid.style.getPropertyValue("--cell"), /clamp\(/, "auto falls back to the clamp"); g.destroy(); }); test("an empty gallery says it is empty", () => { const g = composer.gallery.adaptive({ id: "g2", items: [], empty: "No media yet" }); assert.match(g.node.textContent, /No media yet/); g.destroy(); }); test("gallery thumbnails are decorative, because the caption names them", () => { const g = composer.gallery.adaptive({ id: "g3", items: [{ id: "1", label: "a.mp4", thumb: "/x.png" }] }); assert.equal(g.node.querySelector("img").getAttribute("alt"), "", "an alt here would make a screen reader say the name twice"); g.destroy(); }); // --- z-index staleness ------------------------------------------------------ test("a peer closing does not leave two overlays sharing a z-index", () => { // A rung compacts when a claim is released, so every overlay's z can move // without it doing anything. A caller that reads z once at open time keeps a // number that is later handed to something else: two overlays at one level, // where DOM order decides which one wins and which one eats the clicks. const anchor = composer.button.md({ label: "x" }); document.body.appendChild(anchor.node); const a = composer.menu.dropdown({ anchor, items: [{ id: "a", label: "A" }] }); const b = composer.menu.dropdown({ anchor, items: [{ id: "b", label: "B" }] }); assert.notEqual(a.node.style.zIndex, b.node.style.zIndex); a.close(); const c = composer.menu.dropdown({ anchor, items: [{ id: "c", label: "C" }] }); assert.notEqual(b.node.style.zIndex, c.node.style.zIndex, "the newest overlay must not land on a number still in use"); assert.ok(Number(c.node.style.zIndex) > Number(b.node.style.zIndex), "and it must be above, not merely different"); b.close(); c.close(); }); test("every overlay keeps its z-index bound, not just windows", () => { // The failure above was one call site remembering and six forgetting, so the // test walks every overlay the registry has rather than naming them. const anchor = composer.button.md({ label: "x" }); document.body.appendChild(anchor.node); const opened = []; for (const { group, variant, factory } of entries()) { const demo = DEMOS[`${group}.${variant}`]; if (!demo) continue; const handle = factory(typeof demo === "function" ? demo(composer) : demo); if (!handle.isOverlay || !handle.node.style.zIndex) { handle.destroy(); continue; } opened.push({ name: `${group}.${variant}`, handle }); } // Closing the oldest compacts every rung it was in; nothing may go stale. // // `before` used to be computed and then discarded with `void before`, so this // checked only that survivors do not collide -- which is trivially true when // nothing moves at all. It would have passed with zlayer's notify() removed, // and notify() is the whole mechanism this names. const before = new Map(opened.map(({ name, handle }) => [name, handle.node.style.zIndex])); const closed = opened.length > 1 ? opened.shift() : null; // Some demos close themselves as they are built (a wheel that resolves on // creation), so calling close() on one is a no-op and nothing SHOULD move. // Counting live claims says whether a slot was actually freed; asserting // compaction after a no-op close failed on correct code. const rungOf = (z) => LADDER[Math.floor(Number(z) / 100) - 1]; const closedRung = closed ? rungOf(before.get(closed.name)) : null; const liveBefore = closedRung ? liveCount(closedRung) : 0; const seen = new Map(); for (const { name, handle } of opened) { const z = handle.node.style.zIndex; assert.ok(!seen.has(z), `${name} collides with ${seen.get(z)} at z ${z}`); seen.set(z, name); } const freed = Boolean(closed && closedRung && liveCount(closedRung) < liveBefore); if (freed) { // What "nothing goes stale" means, stated exactly: everything that sat // ABOVE the closed overlay in its rung moved down by one, and everything // below did not move at all. // // Not "the rung is contiguous from its base" -- earlier tests in this file // leave live claims, so this test does not own the whole rung and a gap at // the base is somebody else's slot, not a fault. That version failed on // correct code. const closedZ = Number(before.get(closed.name)); const rung = (z) => Math.floor(z / 100); for (const { name, handle } of opened) { const was = Number(before.get(name)); const now = Number(handle.node.style.zIndex); if (rung(was) !== rung(closedZ)) { assert.equal(now, was, `${name} moved although it is in another rung`); } else if (was > closedZ) { assert.equal(now, was - 1, `${name} was above ${closed.name} and did not compact down`); } else { assert.equal(now, was, `${name} was below ${closed.name} and moved anyway`); } } } for (const { handle } of opened) handle.destroy(); // The sweep above only checks compaction when a demo happened to leave a live // claim, which is not something to rely on. Two overlays this test opens // itself, so the invariant is exercised every run whatever the demos do. const first = composer.popover.anchored({ anchor: anchor.node, body: composer.text.md({ text: "a" }) }); const second = composer.popover.anchored({ anchor: anchor.node, body: composer.text.md({ text: "b" }) }); const firstZ = Number(first.node.style.zIndex); const secondZ = Number(second.node.style.zIndex); assert.equal(secondZ, firstZ + 1, "two peers did not stack"); first.close(); assert.equal(Number(second.node.style.zIndex), firstZ, "the survivor did not compact into the freed slot"); second.close(); anchor.destroy(); }); test("a stacked modal dims what is under it with a real backdrop, once", () => { // The dimming used to come from TWO places at once: the layer's own backdrop // element, and a box-shadow with a 100vmax spread on the card. So the area // under a stacked modal was darkened twice, and a shadow the size of the // viewport diagonal -- on an element in a fixed layer, with an entry // animation running -- is how a stale compositor layer gets made. One showed // up as the whole app painted twice until a resize forced a repaint. const under = composer.modal.generic({ title: "Under", body: composer.text.sm({ text: "a" }) }); const over = composer.modal.stacked({ title: "Over", body: composer.text.sm({ text: "b" }) }); const layer = over.node.closest(".cx-modal-layer"); assert.ok(layer, "a stacked modal has no layer of its own"); assert.equal(layer.querySelectorAll(".cx-backdrop").length, 1, "a stacked modal must dim through its own backdrop element, exactly once"); over.close(); under.close(); }); test("no overlay paints a backdrop with a viewport-sized shadow spread", () => { // Read from the stylesheet, because jsdom applies no CSS: asserting on // node.style.boxShadow here passes against any rule at all, since the value // lives in the sheet and the inline style is always empty. The first version // of this check did exactly that. const css = readFileSync(join(here, "..", "elements", "overlay.css"), "utf8"); const offenders = css.split("\n") .filter((line) => /box-shadow/.test(line) && /vmax|vmin|\b\d{3,}(px|vh|vw)\b/.test(line)); assert.deepEqual(offenders, [], "a shadow spread that large is a stale-compositor-layer bug waiting to happen; " + "dim with a .cx-backdrop element instead"); }); test("a button inside a drag handle is pressed, not dragged (a window's ✕ in its title bar)", async () => { const { drag } = await import("../internals/drag.js"); const handle = document.createElement("div"), x = document.createElement("button"); handle.append(x); document.body.append(handle); let started = 0; drag(handle, { onStart: () => { started += 1; } }); const down = (target) => target.dispatchEvent(Object.assign(new window.Event("pointerdown", { bubbles: true, cancelable: true }), { button: 0, clientX: 1, clientY: 1, pointerId: 1 })); down(x); assert.equal(started, 0); down(handle); assert.equal(started, 1); }); test("a popover whose content grows after opening is placed again for its new size, not at the next scroll", () => { let observed = null; globalThis.ResizeObserver = class { constructor(fn) { observed = fn; } observe() {} disconnect() {} }; try { const anchor = document.createElement("button"); anchor.getBoundingClientRect = () => ({ x: 500, y: 400, width: 40, height: 20 }); document.body.append(anchor); const body = composer.region.stack({}); const pop = composer.popover.anchored({ anchor, body, side: "top", align: "end" }); let width = 100; pop.node.getBoundingClientRect = () => ({ x: 0, y: 0, width, height: 50 }); pop.reposition(); assert.equal(pop.node.style.left, "440px", "end-aligned to the anchor's right edge"); width = 300; observed(); // the sampler's rows arrive assert.equal(pop.node.style.left, "240px"); pop.close(); anchor.remove(); } finally { delete globalThis.ResizeObserver; } }); test("closing a window while a field in it is focused still commits what was typed", () => { let got = null; const field = composer.input.md({ label: "Taste key", onCommit: (v) => { got = v; } }); const win = composer.floating.window({ id: "commit-on-close", title: "T", body: composer.region.stack({ children: [field] }) }); field.node.focus(); field.node.value = "portraits"; win.close(); assert.equal(got, "portraits"); }); test("a toast with no words is not shown at all", () => { const before = document.querySelectorAll(".cx-toast").length; for (const text of [null, undefined, "", " "]) composer.toast.warn({ text }).destroy(); composer.toast.warn({ text: null }); assert.equal(document.querySelectorAll(".cx-toast").length, before); });