Files
rustpad/static/js/toast.js
T

448 lines
17 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { formatNumber, t, translateSource } from "@rustpad/i18n";
const TOAST_TYPES = new Set(["info", "success", "warning", "danger"]);
const DEFAULT_TITLE_KEYS = {
info: "toast.title.info",
success: "toast.title.success",
warning: "toast.title.warning",
danger: "toast.title.danger",
};
function defaultTitle(type) {
const normalized = normalizeType(type);
return t(DEFAULT_TITLE_KEYS[normalized], {}, { info: "Information", success: "Success", warning: "Warning", danger: "Something went wrong" }[normalized]);
}
const DEFAULT_DURATIONS = {
info: 4200,
success: 3800,
warning: 5600,
danger: 6500,
};
const MAX_VISIBLE_TOASTS = 4;
const FLASH_TOAST_STORAGE_KEY = "rustpad:toast:flash";
const MODAL_REGION_CLASS = "toast-region--modal";
const modalRegionBindings = new WeakSet();
function normalizeType(type) {
return TOAST_TYPES.has(type) ? type : "info";
}
function normalizeToastCopy(value) {
return String(value ?? "")
.replace(/\s+/gu, " ")
.trim()
.replace(/[.!?…]+$/gu, "")
.trim()
.toLocaleLowerCase();
}
function prepareToastRegion(element) {
if (!element) return null;
element.classList.remove("toast");
element.classList.add("toast-region");
element.setAttribute("aria-live", "polite");
element.setAttribute("aria-label", t("toast.region.label", {}, "Notifications"));
element.setAttribute("aria-relevant", "additions removals");
return element;
}
function pageToastContainer(selector = "#toast") {
return prepareToastRegion(document.querySelector(selector));
}
function activeModalDialog() {
const dialogs = [...document.querySelectorAll("dialog[open]")];
for (let index = dialogs.length - 1; index >= 0; index -= 1) {
const dialog = dialogs[index];
try {
if (dialog.matches(":modal")) return dialog;
} catch {
return dialog;
}
}
return null;
}
function rehomeModalToasts(dialog) {
const region = [...dialog.children].find(child => child.classList?.contains(MODAL_REGION_CLASS));
if (!region) return;
const pageRegion = pageToastContainer();
if (pageRegion) {
[...region.querySelectorAll(".toast-card")].forEach(card => pageRegion.append(card));
}
try {
if (region.matches(":popover-open")) region.hidePopover();
} catch { /* Popover API fallback. */ }
dialog.classList.remove("has-modal-toast-region");
region.remove();
}
function modalToastContainer(dialog) {
let region = [...dialog.children].find(child => child.classList?.contains(MODAL_REGION_CLASS));
if (!region) {
region = document.createElement("div");
region.className = `toast-region ${MODAL_REGION_CLASS}`;
region.setAttribute("popover", "manual");
dialog.append(region);
}
prepareToastRegion(region);
if (typeof region.showPopover === "function") {
try {
if (!region.matches(":popover-open")) region.showPopover();
} catch {
dialog.classList.add("has-modal-toast-region");
}
} else {
dialog.classList.add("has-modal-toast-region");
}
if (!modalRegionBindings.has(dialog)) {
dialog.addEventListener("close", () => rehomeModalToasts(dialog));
modalRegionBindings.add(dialog);
}
return region;
}
function toastContainer(selector = "#toast", { modalAware = true } = {}) {
if (modalAware && selector === "#toast") {
const dialog = activeModalDialog();
if (dialog) return modalToastContainer(dialog);
}
return pageToastContainer(selector);
}
function applyType(card, type) {
const normalized = normalizeType(type);
for (const candidate of TOAST_TYPES) card.classList.remove(`toast-card--${candidate}`);
card.classList.add(`toast-card--${normalized}`);
card.dataset.toastType = normalized;
if (normalized === "danger" || normalized === "warning") card.setAttribute("role", "alert");
else card.removeAttribute("role");
return normalized;
}
function clearAutoDismiss(card) {
clearTimeout(card._toastHideTimer);
card._toastHideTimer = null;
card._toastRemaining = null;
card._toastStartedAt = null;
const timer = card.querySelector(".toast-card__timer");
const fill = timer?.querySelector("span");
if (timer) timer.hidden = true;
if (fill) {
fill.style.animation = "none";
fill.style.animationPlayState = "running";
}
}
function pauseAutoDismiss(card) {
if (!card._toastHideTimer || !Number.isFinite(card._toastRemaining)) return;
const elapsed = Date.now() - card._toastStartedAt;
card._toastRemaining = Math.max(0, card._toastRemaining - elapsed);
clearTimeout(card._toastHideTimer);
card._toastHideTimer = null;
const fill = card.querySelector(".toast-card__timer>span");
if (fill) fill.style.animationPlayState = "paused";
}
function resumeAutoDismiss(card) {
if (card.dataset.dismissed === "true" || !Number.isFinite(card._toastRemaining) || card._toastRemaining <= 0) return;
const fill = card.querySelector(".toast-card__timer>span");
if (fill) fill.style.animationPlayState = "running";
card._toastStartedAt = Date.now();
card._toastHideTimer = window.setTimeout(() => dismissCard(card), card._toastRemaining);
}
function dismissCard(card) {
if (!card || card.dataset.dismissed === "true") return;
card.dataset.dismissed = "true";
clearAutoDismiss(card);
card.classList.remove("is-visible");
card.classList.add("is-leaving");
window.setTimeout(() => card.remove(), 180);
}
function armAutoDismiss(card, duration) {
clearAutoDismiss(card);
const timeout = Number(duration);
if (!Number.isFinite(timeout) || timeout <= 0) return;
const timer = card.querySelector(".toast-card__timer");
const fill = timer?.querySelector("span");
if (timer && fill) {
timer.hidden = false;
fill.style.animation = "none";
void fill.offsetWidth;
fill.style.animation = `toast-countdown ${timeout}ms linear forwards`;
}
card._toastRemaining = timeout;
card._toastStartedAt = Date.now();
card._toastHideTimer = window.setTimeout(() => dismissCard(card), timeout);
if (card._toastHovering || card._toastFocused) pauseAutoDismiss(card);
}
function createCard(container, { type = "info", title, dismissible = true, persistent = false } = {}) {
const card = document.createElement("section");
card.className = "toast-card";
card.dataset.persistent = String(Boolean(persistent));
card.setAttribute("aria-atomic", "true");
card.innerHTML = `
<div class="toast-card__content">
<strong class="toast-card__title"></strong>
</div>
<button class="toast-card__close" type="button" aria-label="${t("toast.close", {}, "Close notification")}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 7 10 10"></path><path d="m17 7-10 10"></path></svg>
</button>
<div class="toast-card__timer" aria-hidden="true" hidden><span></span></div>`;
const normalized = applyType(card, type);
card.querySelector(".toast-card__title").textContent = title ? translateSource(title) : defaultTitle(normalized);
const closeButton = card.querySelector(".toast-card__close");
closeButton.hidden = !dismissible;
closeButton.addEventListener("click", () => dismissCard(card));
card.addEventListener("mouseenter", () => {
card._toastHovering = true;
pauseAutoDismiss(card);
});
card.addEventListener("mouseleave", () => {
card._toastHovering = false;
if (!card._toastFocused) resumeAutoDismiss(card);
});
card.addEventListener("focusin", () => {
card._toastFocused = true;
pauseAutoDismiss(card);
});
card.addEventListener("focusout", event => {
if (card.contains(event.relatedTarget)) return;
card._toastFocused = false;
if (!card._toastHovering) resumeAutoDismiss(card);
});
if (!persistent) {
const transient = [...container.querySelectorAll('.toast-card[data-persistent="false"]')];
while (transient.length >= MAX_VISIBLE_TOASTS) dismissCard(transient.shift());
}
container.append(card);
requestAnimationFrame(() => card.classList.add("is-visible"));
return card;
}
function setCardTitle(card, title, fallbackType = "info") {
const titleElement = card.querySelector(".toast-card__title");
if (titleElement) titleElement.textContent = title ? translateSource(title) : defaultTitle(fallbackType);
}
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${formatNumber(Math.round(bytes))} B`;
if (bytes < 1024 * 1024) return `${formatNumber(bytes / 1024, { maximumFractionDigits: bytes >= 10240 ? 0 : 1 })} KB`;
if (bytes < 1024 * 1024 * 1024) return `${formatNumber(bytes / (1024 * 1024), { maximumFractionDigits: bytes >= 10 * 1024 * 1024 ? 1 : 2 })} MB`;
return `${formatNumber(bytes / (1024 * 1024 * 1024), { maximumFractionDigits: 2 })} GB`;
}
export function queueToast(message, options = {}) {
const payload = {
message: String(message ?? ""),
type: normalizeType(options.type),
title: options.title == null ? null : String(options.title),
duration: Number.isFinite(Number(options.duration)) ? Number(options.duration) : null,
};
try {
sessionStorage.setItem(FLASH_TOAST_STORAGE_KEY, JSON.stringify(payload));
return true;
} catch {
return false;
}
}
export function consumeQueuedToast() {
let raw = null;
try {
raw = sessionStorage.getItem(FLASH_TOAST_STORAGE_KEY);
sessionStorage.removeItem(FLASH_TOAST_STORAGE_KEY);
} catch {
return false;
}
if (!raw) return false;
try {
const payload = JSON.parse(raw);
if (!payload || typeof payload.message !== "string") return false;
const options = { type: normalizeType(payload.type) };
if (typeof payload.title === "string" && payload.title) options.title = payload.title;
if (Number.isFinite(payload.duration) && payload.duration > 0) options.duration = payload.duration;
toast(payload.message, options);
return true;
} catch {
return false;
}
}
export function toast(message, options = {}) {
const container = toastContainer(options.selector, { modalAware: options.modalAware !== false });
if (!container) return { dismiss() {} };
const type = normalizeType(options.type);
const card = createCard(container, {
type,
title: options.title,
dismissible: options.dismissible !== false,
});
const translatedMessage = translateSource(String(message ?? ""));
const renderedTitle = card.querySelector(".toast-card__title")?.textContent || "";
const normalizedMessage = normalizeToastCopy(translatedMessage);
if (normalizedMessage && normalizedMessage !== normalizeToastCopy(renderedTitle)) {
const body = document.createElement("p");
body.className = "toast-card__message";
body.textContent = translatedMessage;
card.querySelector(".toast-card__content").append(body);
}
armAutoDismiss(card, options.duration ?? DEFAULT_DURATIONS[type]);
return { dismiss: () => dismissCard(card), element: card };
}
toast.info = (message, options = {}) => toast(message, { ...options, type: "info" });
toast.success = (message, options = {}) => toast(message, { ...options, type: "success" });
toast.warning = (message, options = {}) => toast(message, { ...options, type: "warning" });
toast.danger = (message, options = {}) => toast(message, { ...options, type: "danger" });
export function createUploadToast(filename, options = {}) {
const container = toastContainer(options.selector, { modalAware: options.modalAware !== false });
if (!container) {
return { start() {}, update() {}, fail() {}, success() {}, dismiss() {} };
}
const card = createCard(container, {
type: "info",
title: t("upload.title.uploading", {}, "Uploading file"),
dismissible: true,
persistent: true,
});
card.classList.add("toast-card--upload");
card.setAttribute("aria-busy", "true");
const content = card.querySelector(".toast-card__content");
content.insertAdjacentHTML("beforeend", `
<span class="upload-toast__filename"></span>
<div class="upload-toast__progress" role="progressbar" aria-label="${t("upload.progress.label", {}, "File upload progress")}" data-i18n-aria-label="upload.progress.label" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span></span></div>
<div class="upload-toast__meta"><span data-upload-amount>${t("upload.status.preparing", {}, "Preparing...")}</span><span data-upload-speed>-</span></div>
<p class="upload-toast__error" data-upload-error hidden></p>
<div class="upload-toast__actions" data-upload-actions hidden>
<button type="button" class="upload-toast__retry" data-i18n="common.retry">${t("common.retry", {}, "Retry")}</button>
<button type="button" class="upload-toast__dismiss" data-i18n="common.dismiss">${t("common.dismiss", {}, "Dismiss")}</button>
</div>`);
const filenameElement = card.querySelector(".upload-toast__filename");
const progress = card.querySelector(".upload-toast__progress");
const progressFill = progress.querySelector("span");
const amount = card.querySelector("[data-upload-amount]");
const speedElement = card.querySelector("[data-upload-speed]");
const errorElement = card.querySelector("[data-upload-error]");
const actions = card.querySelector("[data-upload-actions]");
const retryButton = card.querySelector(".upload-toast__retry");
const dismissButton = card.querySelector(".upload-toast__dismiss");
let retryHandler = null;
filenameElement.textContent = String(filename || "file");
function dismiss() {
dismissCard(card);
}
function start() {
clearAutoDismiss(card);
card.dataset.dismissed = "false";
card.dataset.persistent = "true";
card.classList.remove("is-leaving");
card.classList.add("is-visible");
card.setAttribute("aria-busy", "true");
applyType(card, "info");
setCardTitle(card, t("upload.title.uploading", {}, "Uploading file"), "info");
filenameElement.textContent = String(filename || "file");
progress.classList.remove("is-error", "is-complete", "is-indeterminate");
progress.setAttribute("aria-valuenow", "0");
progressFill.style.width = "0%";
amount.textContent = t("upload.status.preparing", {}, "Preparing...");
delete speedElement.dataset.i18n;
speedElement.textContent = "-";
errorElement.hidden = true;
errorElement.textContent = "";
actions.hidden = true;
retryButton.hidden = false;
retryButton.disabled = false;
}
function update({ loaded = 0, total = 0, speed = 0, percent = null, phase = "uploading" } = {}) {
progress.classList.remove("is-indeterminate");
const normalizedPercent = Number.isFinite(percent)
? Math.max(0, Math.min(100, percent))
: total > 0 ? Math.max(0, Math.min(100, (loaded / total) * 100)) : null;
if (normalizedPercent != null) {
progress.setAttribute("aria-valuenow", String(Math.round(normalizedPercent)));
progressFill.style.width = `${normalizedPercent}%`;
} else {
progress.removeAttribute("aria-valuenow");
progressFill.style.width = "24%";
progress.classList.add("is-indeterminate");
}
amount.textContent = total > 0
? `${Math.round(normalizedPercent || 0)}% - ${formatBytes(loaded)} / ${formatBytes(total)}`
: formatBytes(loaded);
speedElement.textContent = phase === "processing" ? t("upload.status.processing", {}, "Processing...") : speed > 0 ? `${formatBytes(speed)}/s` : t("upload.status.starting", {}, "Starting...");
}
function fail(message, { retryable = true, onRetry = null } = {}) {
clearAutoDismiss(card);
card.removeAttribute("aria-busy");
card.dataset.persistent = "true";
applyType(card, "danger");
setCardTitle(card, t("upload.title.failed", {}, "Upload failed"), "danger");
progress.classList.remove("is-indeterminate", "is-complete");
progress.classList.add("is-error");
errorElement.textContent = translateSource(String(message || t("upload.error.generic", {}, "The file could not be uploaded.")));
errorElement.hidden = false;
actions.hidden = false;
retryButton.hidden = !retryable;
retryButton.disabled = false;
retryHandler = typeof onRetry === "function" ? onRetry : null;
}
function success(message = t("upload.title.complete", {}, "File uploaded")) {
card.removeAttribute("aria-busy");
card.dataset.persistent = "false";
applyType(card, "success");
setCardTitle(card, message, "success");
progress.classList.remove("is-error", "is-indeterminate");
progress.classList.add("is-complete");
progress.setAttribute("aria-valuenow", "100");
progressFill.style.width = "100%";
amount.textContent = "100%";
speedElement.dataset.i18n = "upload.status.complete";
speedElement.textContent = t("upload.status.complete", {}, "Complete");
errorElement.hidden = true;
actions.hidden = true;
armAutoDismiss(card, 3200);
}
retryButton.addEventListener("click", async () => {
if (!retryHandler) return;
retryButton.disabled = true;
try {
await retryHandler();
} finally {
if (card.isConnected && !card.hasAttribute("aria-busy")) retryButton.disabled = false;
}
});
dismissButton.addEventListener("click", dismiss);
start();
return { start, update, fail, success, dismiss };
}