Files
project-minnesota/resources/js/sonner.js
2024-06-12 19:51:59 +02:00

627 lines
21 KiB
JavaScript

////////////////////////
// Sonner
////////////////////////
////////////////////////
// Constants
////////////////////////
const VISIBLE_TOASTS_AMOUNT = 4;
const VIEWPORT_OFFSET = "32px";
const TOAST_LIFETIME = 4000;
const TOAST_WIDTH = 356;
const GAP = 14;
const SWIPE_THRESHOLD = 20;
const TIME_BEFORE_UNMOUNT = 200;
////////////////////////
// Sonner
// The Sonner object is a singleton that provides methods to show different types of toasts.
////////////////////////
window.Sonner = {
/**
* Initializes the toasters in the DOM.
* The function creates a new section element and an ordered list element inside it.
* @param {Object} options - An object with the following properties:
* @param {boolean} options.closeButton - A boolean to control the visibility of the close button on the toasts.
* @param {boolean} options.richColors - A boolean to control the use of rich colors for the toasts.
* @param {string} options.position - A string to control the position of the toasts. The string is a combination of two values: the vertical position (top or bottom) and the horizontal position (left or right).
* @returns {void}
* @example
* Sonner.init({ closeButton: true, richColors: true, position: "bottom-right" });
*/
init({
closeButton = false,
richColors = false,
position = "bottom-right",
} = {}) {
if (reinitializeToaster()) {
return;
}
renderToaster({ closeButton, richColors, position });
// loadSonnerStyles();
const ol = document.getElementById("sonner-toaster-list");
registerMouseOver(ol);
registerKeyboardShortcuts(ol);
},
/**
* Shows a new success toast with a specific message.
* @param {string} msg - The message to display in the toast.
* @returns {void}
*/
success(msg, opts = {}) {
return Sonner.show(msg, { icon: 'success', type: "success", ...opts });
},
/**
* Shows a new error toast with a specific message.
* @param {string} msg - The message to display in the toast.
* @returns {void}
*/
error(msg, opts = {}) {
return Sonner.show(msg, { icon: 'error', type: "error", ...opts });
},
/**
* Shows a new info toast with a specific message.
* @param {string} msg - The message to display in the toast.
* @returns {void}
*/
info(msg, opts = {}) {
return Sonner.show(msg, { icon: 'info', type: "info", ...opts });
},
/**
* Shows a new warning toast with a specific message.
* @param {string} msg - The message to display in the toast.
* @returns {void}
*/
warning(msg, opts = {}) {
return Sonner.show(msg, { icon: 'warning', type: "warning", ...opts });
},
/**
* Shows a promise loading toast
* @template T promise data type
* @param {Promise<T>} promise
* @param {Object} opts options
* @param {string} opts.loading message to display while loading
* @param {string|(data : T) => string} opts.success function callback / message to show when loaded
* @param {string|(data : Error) => string} opts.success function callback / message to show when errored
*/
promise(promise, opts = {}) {
const toast = Sonner.show(opts.loading ?? 'Loading...', {
icon: 'loading',
type: 'loading',
...opts,
duration: -1,
});
promise
.then(result => {
// Update the message and start the timeout
const msg = typeof opts.success === 'string' ? opts.success : opts.success(result);
toast.setTitle(msg).setIcon('success').setDuration(opts.duration ?? TOAST_LIFETIME);
return result;
})
.catch(err => {
const msg = typeof opts.error === 'string' ? opts.error : opts.error(err);
toast.setTitle(msg).setIcon('error').setDuration(opts.duration ?? TOAST_LIFETIME);
throw err;
});
return promise;
},
/**
* Shows a new toast with a specific message, description, and type.
* @param {string} msg - The message to display in the toast.
* @param {Object} options - An object with the following properties:
* @param {string} options.type - The type of the toast. The type can be one of the following values: "success", "error", "info", "warning", or "neutral".
* @param {string} options.description - The description to display in the toast.
* @returns {void}
*/
show(msg, opts = {}) {
const list = document.getElementById("sonner-toaster-list");
const { toast, id } = renderToast(list, msg, opts);
// Wait for the toast to be mounted before registering swipe events
//window.setTimeout(function () {
const el = list.children[0];
const height = el.getBoundingClientRect().height;
el.setAttribute("data-mounted", "true");
el.setAttribute("data-initial-height", height);
el.style.setProperty("--initial-height", `${height}px`);
list.style.setProperty("--front-toast-height", `${height}px`);
registerSwipe(id);
refreshProperties();
toast.setDuration(opts.duration ?? TOAST_LIFETIME);
//}, 16);
return toast;
},
/**
* Removes an element with a specific id from the DOM after a delay.
* The element is marked as removed and any previous unmount timeout is cleared.
* A new timeout is set to remove the element from its parent.
* The timeout ensures that all CSS transitions complete before the element is removed.
*
* @param {string} id - The data-id attribute of the element to remove.
*/
remove(id) {
const el = document.querySelector(`[data-id="${id}"]`);
if (!el) return;
el.setAttribute("data-removed", "true");
refreshProperties();
const previousTid = el.getAttribute("data-unmount-tid");
if (previousTid) window.clearTimeout(previousTid);
const tid = window.setTimeout(function () {
el.parentElement?.removeChild(el);
}, TIME_BEFORE_UNMOUNT);
el.setAttribute("data-unmount-tid", tid);
},
};
////////////////////////
// Assets
////////////////////////
const getIcon = (type) => {
switch (type) {
case "success":
return SuccessIcon;
case "info":
return InfoIcon;
case "warning":
return WarningIcon;
case "error":
return ErrorIcon;
case 'loading':
return Loader;
default:
return undefined;
}
};
const bars = Array(12).fill(0);
const Loader = `
<div class="sonner-loading-wrapper" data-visible='${true}'>
<div class="sonner-spinner">
${bars.map((_, i) => `<div class="sonner-loading-bar" key="spinner-bar-${i}"></div>`).join('\n')}
</div>
</div>
`;
const SuccessIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>`;
const WarningIcon = `
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
height="20"
width="20"
>
<path
fill-rule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clip-rule="evenodd"
/>
</svg>`;
const InfoIcon = `
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
height="20"
width="20"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>`;
const ErrorIcon = `
<svg
xmlns="http://www.w3.org/2000/svg"
viewbox="0 0 20 20"
fill="currentColor"
height="20"
width="20"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>`;
////////////////////////
// Auxiliary functions
////////////////////////
/**
* Generates a unique id for a toast.
* The function generates a unique id by combining the current timestamp with a random string.
* The function returns the unique id as a string.
* @returns {string} - The unique id.
* @example
* const id = genid();
*/
function genid() {
return (
Date.now().toString(36) +
Math.random().toString(36).substring(2, 12).padStart(12, 0)
);
}
/**
* Creates a new toast element and returns it along with its id.
* The function creates a new list item element and sets its outerHTML to a string containing the toast structure.
* The function also generates a unique id for the toast and returns it along with the toast element.
* @param {Element} list - The list element to append the toast to.
* @param {string} msg - The message to display in the toast.
* @param {Object} options - An object with the following properties:
* @param {string} options.type - The type of the toast. The type can be one of the following values: "success", "error", "info", "warning", or "neutral".
* @param {string} options.description - The description to display in the toast.
* @returns {Object} - An object with the following properties:
* @returns {Element} toast - The toast element.
* @returns {string} id - The unique id of the toast.
*/
function renderToast(list, msg, opts = {}) {
const toast = document.createElement("div");
list.prepend(toast);
const id = genid();
const count = list.children.length;
const asset = getIcon(opts.icon) ?? opts.icon;
toast.outerHTML = `<li
aria-live="polite"
aria-atomic="true"
role="status"
tabindex="0"
data-id="${id}"
data-type="${opts.type}"
data-sonner-toast=""
data-mounted="false"
data-styled="true"
data-promise="false"
data-removed="false"
data-visible="true"
data-y-position="${list.getAttribute("data-y-position")}"
data-x-position="${list.getAttribute("data-x-position")}"
data-index="${0}"
data-front="true"
data-swiping="false"
data-dismissible="true"
data-swipe-out="false"
data-expanded="false"
style="--index: 0; --toasts-before: ${0}; --z-index: ${count}; --offset: 0px; --initial-height: 0px;"
>
${list.getAttribute("data-close-button") === "true"
? `<button
aria-label="Close"
data-disabled=""
class="absolute top-0.5 right-0.5 border border-neutral-800 text-neutral-800 bg-neutral-100 rounded-sm"
onclick="Sonner.remove('${id}')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`
: ""
}
${asset
? `<div data-icon="" class="">${asset}</div>`
: `<div data-icon="" class=""></div>`
}
<div
data-content=""
class="">
<div data-title="" class="">
${msg}
</div>
${opts.description
? `<div data-description="" class="">${opts.description}</div>`
: ""
}
</div>
</li>
`;
return {
id,
toast: {
target: document.querySelector(`[data-id="${id}"]`),
setTitle: function (msg, raw = false) {
const title = document.querySelector(`[data-sonner-toast][data-id=${id}] [data-title]`);
if (raw) title.innerHTML = msg;
else title.textContent = msg;
return this;
},
setIcon: function (icon) {
const ico = getIcon(icon) ?? '';
document.querySelector(`[data-sonner-toast][data-id=${id}] [data-icon]`).innerHTML = ico;
return this;
},
setDuration: function(duration) {
this.target.setAttribute('data-duration', duration);
registerRemoveTimeout(this.target);
return this;
},
dismiss: function() {
Sonner.remove(this.target.getAttribute("data-id"))
return this;
}
}
};
}
/**
* Registers a new remove timeout for a specific element.
* The function sets a new timeout to remove the element from its parent after a delay.
* The timeout ensures that all CSS transitions complete before the element is removed.
* @param {Element} el - The element to register the remove timeout for.
* @param {number} lifetime - How long the toast will last for
* @returns {void}
*/
function registerRemoveTimeout(el) {
if (!el.getAttribute("data-id"))
throw new Error('invalid target for removal');
const lifetime = el.getAttribute('data-duration') ?? TOAST_LIFETIME;
if (lifetime < 0)
return;
// Clear previous duration
if (el.getAttribute("data-remove-tid"))
window.clearTimeout(el.getAttribute("data-remove-tid"));
// Set new timeout
const tid = window.setTimeout(() => {
Sonner.remove(el.getAttribute("data-id"));
}, lifetime);
el.setAttribute("data-remove-tid", tid);
}
/**
* Reinitializes the toaster and its children in the DOM.
* @returns {Element} - The ordered list element with the sonner-toaster-list id.
*/
function reinitializeToaster() {
const ol = document.getElementById("sonner-toaster-list");
if (!ol) return;
for (let i = 0; i < ol.children.length; i++) {
const el = ol.children[i];
const id = el.getAttribute("data-id");
registerSwipe(id);
refreshProperties();
registerRemoveTimeout(el);
}
return ol;
}
/**
* Creates the toaster in the DOM.
* @param {Object} options - An object with the following properties:
* @param {boolean} options.closeButton - A boolean to control the visibility of the close button on the toasts.
* @param {boolean} options.richColors - A boolean to control the use of rich colors for the toasts.
* @param {string} options.position - A string to control the position of the toasts. The string is a combination of two values: the vertical position (top or bottom) and the horizontal position (left or right).
* @returns {void}
*/
function renderToaster({ closeButton, richColors, position }) {
const el = document.createElement("div");
document.body.appendChild(el);
position = position.split("-");
el.outerHTML = `
<section aria-label="Notifications alt+T" tabindex="-1">
<ol
dir="ltr"
tabindex="-1"
data-sonner-toaster="true"
data-theme="light"
data-close-button="${closeButton}"
data-rich-colors="${richColors}"
data-y-position="${position[0]}"
data-x-position="${position[1]}"
style="--front-toast-height: 0px; --offset: ${VIEWPORT_OFFSET}; --width: ${TOAST_WIDTH}px; --gap: ${GAP}px;"
id="sonner-toaster-list"
></ol>
</section>
`;
}
/**
* Loads the Sonner styles in the DOM.
* @returns {void}
*/
function loadSonnerStyles() {
var link = document.createElement("link");
link.href = "./sonner.css";
link.type = "text/css";
link.rel = "stylesheet";
link.media = "screen";
document.getElementsByTagName("head")[0].appendChild(link);
}
/**
* Registers mouse over events on a specific ordered list element in the DOM.
* @param {Element} ol - The ordered list element to register mouse over events on.
* @returns {void}
*/
function registerMouseOver(ol) {
ol.addEventListener("mouseenter", function () {
for (let i = 0; i < ol.children.length; i++) {
const el = ol.children[i];
if (el.getAttribute("data-expanded") === "true") continue;
el.setAttribute("data-expanded", "true");
clearRemoveTimeout(el);
}
});
ol.addEventListener("mouseleave", function () {
for (let i = 0; i < ol.children.length; i++) {
const el = ol.children[i];
if (el.getAttribute("data-expanded") === "false") continue;
el.setAttribute("data-expanded", "false");
registerRemoveTimeout(el);
}
});
}
/**
* Registers keyboard shortcuts for the ordered list element in the DOM.
* The function listens for the Alt+T key combination to expand or collapse the toasts.
* @param {Element} ol - The ordered list element to register keyboard shortcuts for.
* @returns {void}
*/
function registerKeyboardShortcuts(ol) {
window.addEventListener("keydown", function (e) {
if (e.altKey && e.code === "KeyT") {
if (ol.children.length === 0) return;
const expanded = ol.children[0].getAttribute("data-expanded");
const newExpanded = expanded === "true" ? "false" : "true";
for (let i = 0; i < ol.children.length; i++) {
ol.children[i].setAttribute("data-expanded", newExpanded);
}
}
});
}
/**
* Clears the remove timeout for a specific element.
* @param {Element} el - The element to clear the remove timeout for.
* @returns {void}
*/
function clearRemoveTimeout(el) {
const tid = el.getAttribute("data-remove-tid");
if (tid) window.clearTimeout(tid);
}
/**
* Refreshes the properties of the children of a specific list element in the DOM.
* The function iterates over each child of the list, skipping those marked as removed.
* For each remaining child, it updates various data attributes and CSS properties related to its index, visibility, offset, and z-index.
* The function also keeps track of the cumulative height of the elements processed so far to calculate the offset for each element.
*/
function refreshProperties() {
const list = document.getElementById("sonner-toaster-list");
let heightsBefore = 0;
let removed = 0;
for (let i = 0; i < list.children.length; i++) {
const el = list.children[i];
if (el.getAttribute("data-removed") === "true") {
removed++;
continue;
}
const idx = i - removed;
el.setAttribute("data-index", idx);
el.setAttribute("data-front", idx === 0 ? "true" : "false");
el.setAttribute(
"data-visible",
idx < VISIBLE_TOASTS_AMOUNT ? "true" : "false",
);
el.style.setProperty("--index", idx);
el.style.setProperty("--toasts-before", idx);
el.style.setProperty("--offset", `${GAP * idx + heightsBefore}px`);
el.style.setProperty("--z-index", list.children.length - i);
heightsBefore += Number(el.getAttribute("data-initial-height"));
}
}
/**
* Registers swipe events on an element with a specific id.
* The element is selected using the id and event listeners are added for pointerdown, pointerup, and pointermove events.
* The swipe gesture is calculated based on the movement of the pointer and the time taken for the swipe.
* If the swipe meets a certain threshold or velocity, the element is marked for removal.
* If the swipe does not meet the threshold, the swipe is reset.
* For more information on the swipe gesture, see the following article:
* https://emilkowal.ski/ui/building-a-toast-component
*
* @param {string} id - The data-id attribute of the element to register swipe events on.
*/
function registerSwipe(id) {
const el = document.querySelector(`[data-id="${id}"]`);
if (!el) return;
let dragStartTime = null;
let pointerStart = null;
const y = el.getAttribute("data-y-position");
el.addEventListener("pointerdown", function (event) {
dragStartTime = new Date();
event.target.setPointerCapture(event.pointerId);
if (event.target.tagName === "BUTTON") return;
el.setAttribute("data-swiping", "true");
pointerStart = { x: event.clientX, y: event.clientY };
});
el.addEventListener("pointerup", function (event) {
pointerStart = null;
const swipeAmount = Number(
el.style.getPropertyValue("--swipe-amount").replace("px", "") || 0,
);
const timeTaken = new Date().getTime() - dragStartTime.getTime();
const velocity = Math.abs(swipeAmount) / timeTaken;
// Remove only if threshold is met
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
el.setAttribute("data-swipe-out", "true");
Sonner.remove(id);
return;
}
el.style.setProperty("--swipe-amount", "0px");
el.setAttribute("data-swiping", "false");
});
el.addEventListener("pointermove", function (event) {
if (!pointerStart) return;
const yPosition = event.clientY - pointerStart.y;
const xPosition = event.clientX - pointerStart.x;
const clamp = y === "top" ? Math.min : Math.max;
const clampedY = clamp(0, yPosition);
const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
const isAllowedToSwipe = Math.abs(clampedY) > swipeStartThreshold;
if (isAllowedToSwipe) {
el.style.setProperty("--swipe-amount", `${yPosition}px`);
} else if (Math.abs(xPosition) > swipeStartThreshold) {
// User is swiping in wrong direction so we disable swipe gesture
// for the current pointer down interaction
pointerStart = null;
}
});
}