All files / src/web modals.js

98.66% Statements 74/75
92.85% Branches 13/14
100% Functions 17/17
100% Lines 66/66

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115                    17x 17x 17x 17x 17x       17x 17x 17x 88x 88x 88x 88x 88x   17x     252x 25x 25x 25x       75x 17x 75x     17x 17x 142x 142x   17x 82x 82x 82x         9x 9x       8x 8x       3x 3x 3x         8x 8x 8x 8x 3x 3x     8x 5x 5x     8x 6x 6x 6x 6x 6x 6x   8x 2x 2x   8x   8x 8x   8x 8x 8x 8x 8x 8x 1x 1x          
import * as bootstrap from "bootstrap";
import { noteFeature } from "./analytics.js";
 
/**
 * The dialogs the page raises itself, and the rule that a dialog pauses the
 * emulator: the first one up stops it, and the last one down starts it again
 * if it was running before.
 */
export class Modals {
    constructor({ loop }) {
        this.errorDialog = document.getElementById("error-dialog");
        this.errorModal = new bootstrap.Modal(this.errorDialog);
        this.aysEl = document.getElementById("are-you-sure");
        this.aysModal = new bootstrap.Modal(this.aysEl);
        this.dropUnaskedQuestion = null;
 
        // Bootstrap raises a modal's hidden event only once its backdrop has faded, and that late event clears
        // body.modal-open (and, here, the modal's hold) whatever has opened since; so a show waits for it.
        const closing = new Set();
        const waiting = new Set();
        const closed = (modal) => {
            closing.delete(modal);
            Iif (closing.size !== 0) return;
            const shows = [...waiting];
            waiting.clear();
            for (const waited of shows) bootstrap.Modal.getOrCreateInstance(waited).show();
        };
        document.addEventListener(
            "show.bs.modal",
            (event) => {
                if (closing.size === 0) return;
                event.preventDefault();
                event.stopImmediatePropagation();
                waiting.add(event.target);
            },
            { capture: true },
        );
        document.addEventListener("hide.bs.modal", (event) => closing.add(event.target), { capture: true });
        document.addEventListener("hide.bs.modal", (event) => {
            if (event.defaultPrevented) closed(event.target);
        });
 
        const holds = new WeakMap();
        document.addEventListener("show.bs.modal", (event) => {
            noteFeature(event.target, "open");
            if (!holds.has(event.target)) holds.set(event.target, loop.hold(`the ${event.target.id} dialog`));
        });
        document.addEventListener("hidden.bs.modal", (event) => {
            holds.get(event.target)?.();
            holds.delete(event.target);
            closed(event.target);
        });
    }
 
    show(id) {
        const el = document.getElementById(id);
        if (el) bootstrap.Modal.getOrCreateInstance(el).show();
    }
 
    hide(id) {
        const el = document.getElementById(id);
        if (el) bootstrap.Modal.getInstance(el)?.hide();
    }
 
    showError(context, error) {
        this.errorDialog.querySelector(".context").textContent = context;
        this.errorDialog.querySelector(".error").textContent = error;
        this.errorModal.show();
    }
 
    /** @returns {Promise<boolean>} true for the yes button; false for any other way out of the dialog */
    confirm(message, yesText, noText) {
        const yesButton = this.aysEl.querySelector(".ays-yes");
        return new Promise((resolve) => {
            let confirmed = false;
            const onYes = () => {
                confirmed = true;
                this.aysModal.hide();
            };
            // The "no" button, Escape and a click outside raise no event of their own: they only hide the modal.
            const onHidden = () => {
                yesButton.removeEventListener("click", onYes);
                resolve(confirmed);
            };
            // Asked from the show, not now, so a previous opening's late hidden cannot answer this question.
            const ask = () => {
                this.dropUnaskedQuestion = null;
                this.aysEl.querySelector(".context").textContent = message;
                this.aysEl.querySelector(".ays-no").textContent = noText;
                yesButton.textContent = yesText;
                yesButton.addEventListener("click", onYes, { once: true });
                this.aysEl.addEventListener("hidden.bs.modal", onHidden, { once: true });
            };
            const unask = () => {
                this.aysEl.removeEventListener("show.bs.modal", ask);
                resolve(false);
            };
            this.aysEl.addEventListener("show.bs.modal", ask, { once: true });
            // A question still held back while a modal closes gives way to this one.
            this.dropUnaskedQuestion?.();
            this.dropUnaskedQuestion = unask;
            // Bootstrap raises no show at all while the dialog is up or in its own fade; that question goes unasked.
            let raised = false;
            const onRaised = (event) => (raised ||= event.target === this.aysEl);
            window.addEventListener("show.bs.modal", onRaised, { capture: true });
            this.aysModal.show();
            window.removeEventListener("show.bs.modal", onRaised, { capture: true });
            if (!raised) {
                this.dropUnaskedQuestion = null;
                unask();
            }
        });
    }
}