Newer
Older
vmk-ui-kit / src / js / components / toasts.js
/**
 * VMK UI Kit browser toast helper.
 *
 * Mirrors the public surface of gnexus-ui-kit/src/js/components/toasts.js:
 *   create, createInfo, createSuccess, createWarning, createError, createDanger
 */

const ICON_BY_TYPE = {
  info: "essentials/information/circle",
  success: "essentials/check/circle",
  warning: "essentials/alert-sign",
  danger: "essentials/alert/circle",
  error: "essentials/alert/circle"
};

const TITLE_BY_TYPE = {
  info: "Info",
  success: "Success",
  warning: "Warning",
  danger: "Error",
  error: "Error"
};

let indexPromise = null;

function loadIconIndex() {
  if (indexPromise) return indexPromise;
  indexPromise = fetch("/assets/icons/index.json")
    .then(r => r.json())
    .catch(err => {
      console.error(err);
      return {};
    });
  return indexPromise;
}

function svgUrl(relativePath) {
  return `/assets/icons/svg/${relativePath}`;
}

function sanitizeSvg(svgText) {
  return svgText
    .replace(/<script[\s\S]*?<\/script>/gi, "")
    .replace(/on\w+="[^"]*"/gi, "")
    .replace(/on\w+='[^']*'/gi, "")
    .replace(/<style[\s\S]*?<\/style>/gi, "")
    .replace(/<!--[\s\S]*?-->/g, "");
}

async function fetchIconSvg(name) {
  const index = await loadIconIndex();
  const rel = index[name];
  if (!rel) return "";
  const res = await fetch(svgUrl(rel));
  if (!res.ok) return "";
  return sanitizeSvg(await res.text());
}

function appendIcon(container, svgText) {
  if (!svgText) return;
  const wrap = document.createElement("span");
  wrap.innerHTML = svgText;
  const svg = wrap.querySelector("svg");
  if (svg) {
    svg.setAttribute("aria-hidden", "true");
    svg.classList.add("vmk-icon");
    container.append(svg);
  }
}

function template(type, iconSvg, title, text) {
  const toast = document.createElement("div");
  toast.className = `toast toast-${type}`;
  toast.setAttribute("role", "alert");

  const content = document.createElement("div");
  content.className = "toast-content";

  const header = document.createElement("div");
  header.className = "toast-header";
  appendIcon(header, iconSvg);
  header.append(document.createTextNode(` ${title ?? ""}`));

  content.append(header);

  if (text) {
    const toastText = document.createElement("p");
    toastText.className = "toast-text";
    toastText.textContent = text;
    content.append(toastText);
  }

  const progress = document.createElement("div");
  progress.className = "toast-progress";

  const progressBar = document.createElement("div");
  progressBar.className = "toast-progress-bar";
  progress.append(progressBar);

  const close = document.createElement("button");
  close.className = "btn-icon toast-close";
  close.type = "button";
  close.setAttribute("aria-label", "Close");

  fetchIconSvg("essentials/close-cross").then(svg => {
    appendIcon(close, svg);
  });

  toast.append(content, close, progress);

  return toast;
}

function init(toast, props) {
  const lifetime = props?.lifetime !== undefined ? props.lifetime : 4000;

  if (props?.alone) {
    document.querySelectorAll(".toast").forEach(i => i.close?.());
  }

  const progressBar = toast.querySelector(".toast-progress-bar");
  if (progressBar && lifetime > 0) {
    progressBar.style.animationDuration = `${lifetime}ms`;
  }

  toast.close = function () {
    this.classList.add("a-hide");
    setTimeout(() => {
      this.remove();
    }, 300);
  };

  toast.show = function () {
    document.querySelector("body").append(toast);
    setTimeout(() => {
      toast.classList.add("a-show");
    }, 10);
  };

  toast.addEventListener("mouseover", () => {
    toast.ishovered = true;
  });
  toast.addEventListener("mouseout", () => {
    toast.ishovered = false;
  });

  toast.querySelector(".toast-close").addEventListener("click", () => {
    toast.close();
  });

  if (lifetime > 0) {
    let elapsed = 0;
    const interval = setInterval(() => {
      elapsed += lifetime;
      if (!toast.ishovered && elapsed >= lifetime) {
        toast.close();
        clearInterval(interval);
      }
    }, lifetime);
  }

  return toast;
}

async function create(type, iconOverride, title, text, props) {
  const resolvedType = type === "error" ? "danger" : type;
  const iconName = iconOverride || ICON_BY_TYPE[resolvedType] || ICON_BY_TYPE.info;
  const iconSvg = await fetchIconSvg(iconName);
  const toast = template(resolvedType, iconSvg, title, text);
  return init(toast, props);
}

function applyDefaults(props) {
  if (typeof props === "undefined") {
    props = {};
  }
  if (typeof props.lifetime === "undefined") {
    props.lifetime = 4000;
  }
  if (typeof props.alone === "undefined") {
    props.alone = true;
  }
  return props;
}

async function createSuccess(title, text, props) {
  props = applyDefaults(props);
  return create("success", null, title, text, props);
}

async function createInfo(title, text, props) {
  props = applyDefaults(props);
  return create("info", null, title, text, props);
}

async function createWarning(title, text, props) {
  props = applyDefaults(props);
  return create("warning", null, title, text, props);
}

async function createError(title, text, props) {
  props = applyDefaults(props);
  return create("danger", null, title, text, props);
}

export default {
  create,
  createInfo,
  createSuccess,
  createWarning,
  createError,
  createDanger: createError
};