tiny-essentials
Version:
Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.
403 lines (358 loc) • 12.1 kB
JavaScript
'use strict';
/**
* A callback function used to manually close a notification.
* Passed as a second argument to `onClick` handlers, allowing programmatic dismissal of the toast.
*
* @typedef {() => void} CloseToastFunc
*/
/**
* Represents the data used to display a notification.
* Can be a plain string (used as the message), or an object with more customization options.
*
* @typedef {string | {
* message: string, // The main message to display
* title?: string, // Optional title to appear above the message
* onClick?: function(MouseEvent, CloseToastFunc): void, // Optional click handler for the notification
* html?: boolean, // Whether the message should be interpreted as raw HTML
* avatar?: string // Optional URL to an avatar image shown on the left
* }} NotifyData
*/
/**
* A lightweight notification system designed to display timed messages inside a container.
* Supports positioning, timing customization, click actions, HTML content, and optional avatars.
*
* ## Features:
* - Positioning via `x` (`left`, `center`, `right`) and `y` (`top`, `bottom`).
* - Dynamic display time based on message length.
* - Optional `title`, `avatar`, `onClick`, and `html` message rendering.
* - Fade-out animation with customizable duration.
* - Rigid validation of inputs and internal state.
*
* ## Customization via setters:
* - `setX(position)` — horizontal alignment.
* - `setY(position)` — vertical alignment.
* - `setBaseDuration(ms)` — base visible time in milliseconds.
* - `setExtraPerChar(ms)` — extra time added per character.
* - `setFadeOutDuration(ms)` — fade-out animation duration in milliseconds.
*
* @class
*/
class TinyToastNotify {
#y;
#x;
#baseDuration;
#extraPerChar;
#fadeOutDuration;
/** @type {HTMLElement|null} */
#container;
/**
* @param {'top'|'bottom'} y - 'top' or 'bottom'
* @param {'right'|'left'|'center'} x - 'right', 'left', or 'center'
* @param {number} baseDuration - Base display time in ms
* @param {number} extraPerChar - Extra ms per character
* @param {number} fadeOutDuration - Time in ms for fade-out effect
* @param {string} [selector='.notify-container'] - Base selector for container
*/
constructor(
y = 'top',
x = 'right',
baseDuration = 3000,
extraPerChar = 50,
fadeOutDuration = 300,
selector = '.notify-container',
) {
this.#validateY(y);
this.#validateX(x);
this.#validateTiming(baseDuration, 'baseDuration');
this.#validateTiming(extraPerChar, 'extraPerChar');
this.#validateTiming(fadeOutDuration, 'fadeOutDuration');
this.#y = y;
this.#x = x;
this.#baseDuration = baseDuration;
this.#extraPerChar = extraPerChar;
this.#fadeOutDuration = fadeOutDuration;
const container = document.querySelector(`${selector}.${y}.${x}`);
if (!(container instanceof HTMLElement)) {
this.#container = document.createElement('div');
this.#container.className = `notify-container ${y} ${x}`;
document.body.appendChild(this.#container);
} else this.#container = container;
}
/**
* Returns the notification container element.
* Ensures that the container is a valid HTMLElement.
*
* @returns {HTMLElement} The notification container.
* @throws {Error} If the container is not a valid HTMLElement.
*/
getContainer() {
if (!(this.#container instanceof HTMLElement))
throw new Error('Container is not a valid HTMLElement.');
return this.#container;
}
/**
* Validates the vertical position value.
* Must be either 'top' or 'bottom'.
*
* @param {string} value - The vertical position to validate.
* @throws {Error} If the value is not 'top' or 'bottom'.
*/
#validateY(value) {
if (!['top', 'bottom'].includes(value)) {
throw new Error(`Invalid vertical direction "${value}". Expected "top" or "bottom".`);
}
}
/**
* Validates the horizontal position value.
* Must be 'left', 'right', or 'center'.
*
* @param {string} value - The horizontal position to validate.
* @throws {Error} If the value is not one of the accepted directions.
*/
#validateX(value) {
if (!['left', 'right', 'center'].includes(value)) {
throw new Error(
`Invalid horizontal position "${value}". Expected "left", "right" or "center".`,
);
}
}
/**
* Validates a numeric timing value.
* Must be a non-negative finite number.
*
* @param {number} value - The number to validate.
* @param {string} name - The name of the parameter (used for error messaging).
* @throws {Error} If the number is invalid.
*/
#validateTiming(value, name) {
if (typeof value !== 'number' || value < 0 || !Number.isFinite(value)) {
throw new Error(
`Invalid value for "${name}": ${value}. Must be a non-negative finite number.`,
);
}
}
/**
* Returns the current vertical position.
*
* @returns {'top'|'bottom'} The vertical direction of the notification container.
*/
getY() {
return this.#y;
}
/**
* Sets the vertical position of the notification container.
* Updates the container's class to reflect the new position.
*
* @param {'top'|'bottom'} value - The vertical direction to set.
* @throws {Error} If the value is invalid.
*/
setY(value) {
this.#validateY(value);
const container = this.getContainer();
container.classList.remove(this.#y);
container.classList.add(value);
this.#y = value;
}
/**
* Returns the current horizontal position.
*
* @returns {'left'|'right'|'center'} The horizontal direction of the notification container.
*/
getX() {
return this.#x;
}
/**
* Sets the horizontal position of the notification container.
* Updates the container's class to reflect the new position.
*
* @param {'left'|'right'|'center'} value - The horizontal direction to set.
* @throws {Error} If the value is invalid.
*/
setX(value) {
this.#validateX(value);
const container = this.getContainer();
container.classList.remove(this.#x);
container.classList.add(value);
this.#x = value;
}
/**
* Returns the base duration for displaying the notification.
*
* @returns {number} Base time (in milliseconds) that a notification stays on screen.
*/
getBaseDuration() {
return this.#baseDuration;
}
/**
* Sets the base duration for the notification display time.
*
* @param {number} value - Base display time in milliseconds.
* @throws {Error} If the value is not a valid non-negative finite number.
*/
setBaseDuration(value) {
this.#validateTiming(value, 'baseDuration');
this.#baseDuration = value;
}
/**
* Returns the extra display time added per character.
*
* @returns {number} Extra time (in milliseconds) per character in the notification.
*/
getExtraPerChar() {
return this.#extraPerChar;
}
/**
* Sets the additional display time per character.
*
* @param {number} value - Extra time in milliseconds per character.
* @throws {Error} If the value is not a valid non-negative finite number.
*/
setExtraPerChar(value) {
this.#validateTiming(value, 'extraPerChar');
this.#extraPerChar = value;
}
/**
* Returns the fade-out duration.
*
* @returns {number} Time (in milliseconds) used for fade-out transition.
*/
getFadeOutDuration() {
return this.#fadeOutDuration;
}
/**
* Sets the fade-out transition time for notifications.
*
* @param {number} value - Fade-out duration in milliseconds.
* @throws {Error} If the value is not a valid non-negative finite number.
*/
setFadeOutDuration(value) {
this.#validateTiming(value, 'fadeOutDuration');
this.#fadeOutDuration = value;
}
/**
* Displays a notification for a time based on message length.
* Accepts a string or an object with:
* {
* message: string,
* title?: string,
* onClick?: function(MouseEvent, CloseToastFunc): void,
* html?: boolean,
* avatar?: string // Optional avatar image URL
* }
*
* @param {NotifyData} data
*/
show(data) {
let message = '';
let title = '';
let onClick = null;
let useHTML = false;
let avatarUrl = null;
const notify = document.createElement('div');
notify.className = 'notify enter';
if (typeof data === 'string') {
message = data;
} else if (typeof data === 'object' && data !== null && typeof data.message === 'string') {
message = data.message;
title = typeof data.title === 'string' ? data.title : '';
useHTML = data.html === true;
avatarUrl = typeof data.avatar === 'string' ? data.avatar : null;
if (data.onClick !== undefined) {
if (typeof data.onClick !== 'function') {
throw new Error('onClick must be a function if defined');
}
onClick = data.onClick;
notify.classList.add('clickable');
}
} else {
throw new Error(
`Invalid argument for show(): expected string or { message: string, title?: string, onClick?: function, html?: boolean, avatar?: string }`,
);
}
// Add close button
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.className = 'close';
closeBtn.setAttribute('aria-label', 'Close');
// Optional hover effect
closeBtn.addEventListener('mouseenter', () => {
closeBtn.style.color = 'var(--notif-close-color-hover)';
});
closeBtn.addEventListener('mouseleave', () => {
closeBtn.style.color = 'var(--notif-close-color)';
});
// Avatar
if (avatarUrl) {
const avatar = document.createElement('img');
avatar.src = avatarUrl;
avatar.alt = 'avatar';
avatar.className = 'avatar';
notify.appendChild(avatar);
}
// Title
if (title) {
const titleElem = document.createElement('strong');
titleElem.textContent = title;
titleElem.style.display = 'block';
notify.appendChild(titleElem);
}
// Message
if (useHTML) {
const msgWrapper = document.createElement('div');
msgWrapper.innerHTML = message;
notify.appendChild(msgWrapper);
} else {
notify.appendChild(document.createTextNode(message));
}
notify.appendChild(closeBtn);
this.getContainer().appendChild(notify);
const visibleTime = this.#baseDuration + message.length * this.#extraPerChar;
const totalTime = visibleTime + this.#fadeOutDuration;
// Close logic
let removed = false;
const close = () => {
if (removed) return;
removed = true;
notify.classList.remove('enter', 'show');
notify.classList.add('exit');
setTimeout(() => notify.remove(), this.#fadeOutDuration);
};
// Click handler
if (typeof onClick === 'function') {
notify.addEventListener('click', (e) => {
if (e.target === closeBtn) return;
onClick(e, close);
});
}
// Close button click
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
close();
});
// Transition activation force soon after the element is added
setTimeout(() => {
notify.classList.remove('enter');
notify.classList.add('show');
}, 1);
setTimeout(() => close(), totalTime);
}
/**
* Destroys the notification container and removes all active notifications.
* This should be called when the notification system is no longer needed,
* such as when unloading a page or switching views in a single-page app.
*
* @returns {void}
*/
destroy() {
if (!(this.#container instanceof HTMLElement)) return;
// Remove all child notifications
this.#container.querySelectorAll('.notify').forEach((el) => el.remove());
// Remove the container itself from the DOM
if (this.#container.parentNode) {
this.#container.parentNode.removeChild(this.#container);
}
// Optional: Clean internal references (safe practice)
this.#container = null;
}
}
module.exports = TinyToastNotify;