/* * 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 = `
`; 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", `
${t("upload.status.preparing", {}, "Preparing...")}-
`); 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 }; }