448 lines
17 KiB
JavaScript
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 };
|
|
}
|