All files / src/web toast.js

94.87% Statements 37/39
90.9% Branches 10/11
100% Functions 6/6
94.28% Lines 33/35

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              19x     57x 57x 53x 53x 53x 53x           18x 18x   1x 1x         1x 1x                           59x   57x 57x 57x 57x 57x 57x 57x                         57x 57x 57x   57x 57x 57x   57x   57x 28x 28x   57x 57x    
import * as bootstrap from "bootstrap";
 
/**
 * Passing notices: something happened that is worth knowing and needs nothing doing about it.
 * 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
 */
export function toast(message, { title = "", quietKey = "" } = {}) {
    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>
            <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;
    element.querySelector(".toast-header").hidden = !title;
 
    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).show();
    return element;
}