All files / src/web toast.js

96.07% Statements 49/51
94.44% Branches 17/18
100% Functions 9/9
95.65% Lines 44/46

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              33x     137x 137x 110x 110x 110x 110x           27x 27x   1x 1x         1x 1x                               140x   137x 137x 137x 137x 137x 137x 137x                           137x 137x   137x   140x 140x 140x 13x 13x 8x 8x       137x 137x 137x   137x   137x 54x 54x   137x 137x         25x     25x 21x   4x      
import * as bootstrap from "bootstrap";
 
/**
 * Passing notices: something happened that is worth knowing and needs nothing doing about it, though a notice
 * may offer one thing to do. The error dialog is for the other kind.
 */
 
let nextQuietId = 0;
 
function toastContainer() {
    const existing = document.querySelector(".toast-container");
    if (existing) return existing;
    const container = document.createElement("div");
    container.className = "toast-container position-fixed bottom-0 end-0 p-3";
    document.body.appendChild(container);
    return container;
}
 
// Storage can be unreachable or full, and a notice that nothing needs doing about is not worth
// failing over: forget the answer and say the thing again.
function remembered(key) {
    try {
        return !!window.localStorage.getItem(key);
    } catch (e) {
        console.log(`Unable to read ${key}: ${e}`);
        return false;
    }
}
 
function remember(key, wanted) {
    try {
        if (wanted) window.localStorage.setItem(key, "yes");
        else Ewindow.localStorage.removeItem(key);
    } catch (e) {
        console.log(`Unable to remember ${key}: ${e}`);
    }
}
 
/**
 * @param {string} message
 * @param {object} [options]
 * @param {string} [options.title] a heading, for a notice whose message does not say where it came from
 * @param {string} [options.quietKey] offer to stop showing this kind of notice, remembering the answer here
 * @param {{label: string, run: function(): void}} [options.action] a button that does something about it and closes
 *     the notice; a notice with one stays until it is closed
 */
export function toast(message, { title = "", quietKey = "", action = null } = {}) {
    if (quietKey && remembered(quietKey)) return;
 
    const element = document.createElement("div");
    element.className = "toast text-bg-dark";
    element.setAttribute("role", "status");
    element.setAttribute("aria-live", "polite");
    element.setAttribute("aria-atomic", "true");
    const quietId = `toast-quiet-${nextQuietId++}`;
    element.innerHTML = `
        <div class="toast-header text-bg-dark">
            <strong class="me-auto"></strong>
            <button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
        </div>
        <div class="toast-body">
            <div class="message"></div>
            <button type="button" class="btn btn-sm btn-light mt-2 toast-action"></button>
            <div class="form-check mt-2">
                <input class="form-check-input" type="checkbox" id="${quietId}" />
                <label class="form-check-label small" for="${quietId}">Stop telling me this</label>
            </div>
        </div>`;
    // Disc names and the like come from the outside world, so they are set as text, never as markup.
    element.querySelector(".message").textContent = message;
    element.querySelector(".toast-header strong").textContent = title;
    // The header holds the close button, which a notice that stays needs.
    element.querySelector(".toast-header").hidden = !title && !action;
 
    const actionButton = element.querySelector(".toast-action");
    actionButton.hidden = !action;
    if (action) {
        actionButton.textContent = action.label;
        actionButton.addEventListener("click", () => {
            dismissToast(element);
            action.run();
        });
    }
 
    const quiet = element.querySelector(".form-check");
    quiet.hidden = !quietKey;
    quiet.querySelector("input").addEventListener("change", (event) => remember(quietKey, event.target.checked));
 
    toastContainer().appendChild(element);
    // Bootstrap keys its instances by element, so one only removed stays held.
    element.addEventListener("hidden.bs.toast", () => {
        bootstrap.Toast.getInstance(element)?.dispose();
        element.remove();
    });
    bootstrap.Toast.getOrCreateInstance(element, { autohide: !action }).show();
    return element;
}
 
/** Closes a notice `toast` showed, if it is still showing. */
export function dismissToast(element) {
    const instance = bootstrap.Toast.getInstance(element);
    // Bootstrap marks a toast "showing" through its show and its hide. Hidden part way through showing, the end of
    // the show would touch the instance disposed on hiding; one already hiding goes regardless.
    if (element.classList.contains("showing")) {
        element.addEventListener("shown.bs.toast", () => instance?.hide(), { once: true });
    } else {
        instance?.hide();
    }
}