/* Toasts and dialogs. Replaces window.confirm/prompt, which cannot be styled, ignore the theme, and block the whole tab. Dialogs are built on , so focus trapping, Escape and inertness of the page behind come from the browser rather than from hand-written key handling. Everything returns a Promise, so callers read as if they were still using the built-ins: if (await lembas.confirm({ message: "Delete this?" })) { ... } const name = await lembas.prompt({ message: "New name", value: old }); lembas.notify("Saved.", { kind: "success" }); */ (function () { "use strict"; var TOAST_MS = 4000; function el(tag, className, text) { var node = document.createElement(tag); if (className) node.className = className; // textContent, never innerHTML: these messages carry filenames, chat // titles and upstream error text, none of which is ours to trust. if (text != null) node.textContent = text; return node; } /* --- Toasts ------------------------------------------------------------ */ function toastHost() { var host = document.getElementById("toasts"); if (!host) { host = el("div", "toasts"); host.id = "toasts"; // Announced politely so a screen reader hears it without being yanked // away from whatever it was reading. host.setAttribute("role", "status"); host.setAttribute("aria-live", "polite"); document.body.appendChild(host); } return host; } function notify(message, options) { options = options || {}; var toast = el("div", "toast toast--" + (options.kind || "info")); toast.appendChild(el("span", "toast__text", message)); var close = el("button", "toast__close"); close.type = "button"; close.setAttribute("aria-label", "Dismiss"); close.textContent = "×"; close.addEventListener("click", function () { dismiss(toast); }); toast.appendChild(close); toastHost().appendChild(toast); // Next frame, so the entry transition has a state to move from. requestAnimationFrame(function () { toast.classList.add("is-in"); }); var timeout = options.timeout == null ? TOAST_MS : options.timeout; if (timeout > 0) setTimeout(function () { dismiss(toast); }, timeout); return toast; } function dismiss(toast) { if (!toast || toast.dataset.going) return; toast.dataset.going = "1"; toast.classList.remove("is-in"); setTimeout(function () { toast.remove(); }, 180); } /* --- Dialogs ----------------------------------------------------------- */ function buildDialog(options) { var dialog = el("dialog", "dialog"); var form = el("form", "dialog__form"); form.method = "dialog"; if (options.title) form.appendChild(el("h2", "dialog__title", options.title)); if (options.message) form.appendChild(el("p", "dialog__message", options.message)); var input = null; if (options.kind === "prompt") { input = el("input", "input"); input.type = "text"; input.value = options.value || ""; if (options.placeholder) input.placeholder = options.placeholder; input.setAttribute("aria-label", options.title || options.message || "Value"); form.appendChild(input); } var actions = el("div", "dialog__actions"); var cancel = el("button", "btn", options.cancelLabel || "Cancel"); cancel.type = "button"; cancel.value = "cancel"; actions.appendChild(cancel); var accept = el( "button", "btn " + (options.danger ? "btn--danger-solid" : "btn--primary"), options.confirmLabel || "OK" ); accept.type = "submit"; accept.value = "accept"; actions.appendChild(accept); form.appendChild(actions); dialog.appendChild(form); document.body.appendChild(dialog); return { dialog: dialog, form: form, input: input, cancel: cancel, accept: accept }; } function open(options) { return new Promise(function (resolve) { var parts = buildDialog(options); var settled = false; function finish(value) { if (settled) return; settled = true; resolve(value); parts.dialog.close(); // Let the closing transition finish before the node disappears. setTimeout(function () { parts.dialog.remove(); }, 200); } parts.cancel.addEventListener("click", function () { finish(options.kind === "prompt" ? null : false); }); parts.form.addEventListener("submit", function (event) { event.preventDefault(); finish(options.kind === "prompt" ? (parts.input.value || "") : true); }); // Escape and the backdrop both mean "no". parts.dialog.addEventListener("cancel", function (event) { event.preventDefault(); finish(options.kind === "prompt" ? null : false); }); parts.dialog.addEventListener("click", function (event) { if (event.target === parts.dialog) finish(options.kind === "prompt" ? null : false); }); parts.dialog.showModal(); if (parts.input) { parts.input.focus(); parts.input.select(); } else { (options.danger ? parts.cancel : parts.accept).focus(); } }); } function confirm(options) { if (typeof options === "string") options = { message: options }; return open(Object.assign({ kind: "confirm", confirmLabel: "OK" }, options)); } function prompt(options) { if (typeof options === "string") options = { message: options }; return open(Object.assign({ kind: "prompt", confirmLabel: "Save" }, options)); } window.lembas = window.lembas || {}; window.lembas.notify = notify; window.lembas.confirm = confirm; window.lembas.prompt = prompt; /* --- htmx integration -------------------------------------------------- hx-confirm normally calls window.confirm. Intercepting the event lets every existing hx-confirm attribute keep working while getting the themed dialog, with no change at the call sites. */ document.addEventListener("htmx:confirm", function (event) { if (!event.detail.question) return; // no confirmation asked for event.preventDefault(); var trigger = event.detail.elt; confirm({ title: trigger && trigger.dataset.confirmTitle, message: event.detail.question, confirmLabel: (trigger && trigger.dataset.confirmLabel) || "Delete", danger: !trigger || trigger.dataset.confirmDanger !== "false", }).then(function (ok) { if (ok) event.detail.issueRequest(true); }); }); /* A submit button that acts on its own (formaction) rather than the form it sits in. Confirming the whole form would be wrong: the same form also has a plain Save. */ document.addEventListener("click", function (event) { var button = event.target.closest("[data-confirm-button]"); if (!button || button.dataset.confirmed) return; event.preventDefault(); event.stopPropagation(); confirm({ title: button.dataset.confirmTitle, message: button.dataset.confirmButton, confirmLabel: button.dataset.confirmLabel || "Delete", danger: button.dataset.confirmDanger !== "false", }).then(function (ok) { if (!ok) return; button.dataset.confirmed = "1"; button.click(); delete button.dataset.confirmed; }); }, true); /* Plain forms opt in with data-confirm, so they need no inline onsubmit. */ document.addEventListener("submit", function (event) { var form = event.target; if (!form.dataset || !form.dataset.confirm || form.dataset.confirmed) return; event.preventDefault(); confirm({ title: form.dataset.confirmTitle, message: form.dataset.confirm, confirmLabel: form.dataset.confirmLabel || "Delete", danger: form.dataset.confirmDanger !== "false", }).then(function (ok) { if (!ok) return; form.dataset.confirmed = "1"; form.submit(); }); }, true); })(); /* The model picker. A