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.
366 lines (311 loc) • 11.8 kB
JavaScript
'use strict';
/**
* Represents the possible states of the loading screen.
* - `'none'` → Not visible
* - `'fadeIn'` → Appearing with fade-in animation
* - `'active'` → Fully visible and active
* - `'fadeOut'` → Disappearing with fade-out animation
* @typedef {'none'|'active'|'fadeIn'|'fadeOut'} LoadingStatus
*/
/**
* Configuration options for the loading screen.
* @typedef {Object} LoadingSettings
* @property {number|null} fadeIn - Duration of fade-in animation in milliseconds, or `null` to disable.
* @property {number|null} fadeOut - Duration of fade-out animation in milliseconds, or `null` to disable.
* @property {number} zIndex - CSS z-index of the overlay element.
*/
/**
* TinyLoadingScreen
*
* A lightweight, fully-configurable loading overlay component that can be appended to any HTMLElement.
*
* Key features:
* - Configurable fadeIn/fadeOut durations (milliseconds) and zIndex.
* - Accepts string or HTMLElement messages.
* - Optionally allows HTML inside string messages when `allowHtmlText` is enabled.
* - Exposes `overlay`, `messageElement`, `status`, `options` and helpers for testing and integration.
*
* @class
*/
class TinyLoadingScreen {
/** @type {HTMLDivElement|null} Overlay container element */
#overlay = null;
/** @returns {HTMLDivElement|null} The overlay element if active, otherwise `null`. */
get overlay() {
return this.#overlay;
}
/** @type {HTMLDivElement|null} Element containing the loading message */
#messageElement = null;
/** @returns {HTMLDivElement|null} The element used to render the message, or `null` if inactive. */
get messageElement() {
return this.#messageElement;
}
/** @type {HTMLElement} Container where the overlay will be attached */
#container;
/** @returns {HTMLElement} The container element that holds the overlay. */
get container() {
return this.#container;
}
/** @type {LoadingSettings} Internal configuration */
#options = { fadeIn: null, fadeOut: null, zIndex: 9999 };
/** @returns {LoadingSettings} A copy of the current configuration options. */
get options() {
return { ...this.#options };
}
/**
* Updates the loading screen options.
* @param {Partial<LoadingSettings>} value - New configuration values.
* @throws {TypeError} If any option has an invalid type or value.
*/
set options(value) {
if (typeof value !== 'object' || value === null)
throw new TypeError('options must be an object');
if (
typeof value.fadeIn !== 'undefined' &&
value.fadeIn !== null &&
(typeof value.fadeIn !== 'number' || value.fadeIn < 0)
)
throw new TypeError('fadeIn must be a non-negative number or null');
if (
typeof value.fadeOut !== 'undefined' &&
value.fadeOut !== null &&
(typeof value.fadeOut !== 'number' || value.fadeOut < 0)
)
throw new TypeError('fadeOut must be a non-negative number or null');
if (
typeof value.zIndex !== 'undefined' &&
(typeof value.zIndex !== 'number' || !Number.isInteger(value.zIndex))
)
throw new TypeError('zIndex must be an integer number');
this.#options = {
fadeIn: value.fadeIn ?? null,
fadeOut: value.fadeOut ?? null,
zIndex: value.zIndex ?? 9999,
};
}
/** @type {LoadingStatus} Current status of the loading screen */
#status = 'none';
/** @returns {LoadingStatus} The current loading screen status. */
get status() {
return this.#status;
}
/** @type {string|HTMLElement} Default message shown when no custom message is provided */
#defaultMessage = '';
/** @returns {string|HTMLElement} The default message. */
get defaultMessage() {
return this.#defaultMessage;
}
/**
* @param {string|HTMLElement} value - New default message.
* @throws {TypeError} If the value is neither a string nor an HTMLElement.
*/
set defaultMessage(value) {
if (typeof value !== 'string' && !(value instanceof HTMLElement))
throw new TypeError('defaultMessage must be a string or an HTMLElement');
this.#defaultMessage = value;
}
/** @type {string|HTMLElement|null} Current active message */
#message = null;
/** @returns {string|HTMLElement|null} The currently displayed message. */
get message() {
return this.#message;
}
/** @type {boolean} Whether HTML is allowed in string messages */
#allowHtmlText = false;
/** @returns {boolean} True if HTML is allowed inside string messages. */
get allowHtmlText() {
return this.#allowHtmlText;
}
/**
* Enables or disables HTML rendering in string messages.
* @param {boolean} value - Whether to allow HTML.
* @throws {TypeError} If value is not a boolean.
*/
set allowHtmlText(value) {
if (typeof value !== 'boolean') throw new TypeError('allowHtmlText must be a boolean');
this.#allowHtmlText = value;
}
/** @type {NodeJS.Timeout|null} Timeout handler for fadeIn */
#fadeInTimeout = null;
/** @returns {boolean} Whether a fadeIn timeout is currently pending. */
get fadeInTimeout() {
return this.#fadeInTimeout !== null;
}
/** @type {NodeJS.Timeout|null} Timeout handler for fadeOut */
#fadeOutTimeout = null;
/** @returns {boolean} Whether a fadeOut timeout is currently pending. */
get fadeOutTimeout() {
return this.#fadeOutTimeout !== null;
}
/** @returns {boolean} True if the overlay is currently visible. */
get visible() {
return !!this.#overlay;
}
/**
* Optional callback fired whenever the loading screen status changes.
* @type {((status: LoadingStatus) => void) | null}
*/
#onChange = null;
/**
* Returns the current status-change callback.
* @returns {((status: LoadingStatus) => void) | null}
*/
get onChange() {
return this.#onChange;
}
/**
* Sets the status-change callback.
* @param {((status: LoadingStatus) => void) | null} value
* @throws {TypeError} If value is neither a function nor null.
*/
set onChange(value) {
if (value !== null && typeof value !== 'function') {
throw new TypeError('onChange must be a function or null');
}
this.#onChange = value;
}
/**
* Internal helper to emit the onChange callback.
* @private
*/
_emitChange() {
if (typeof this.#onChange === 'function') this.#onChange(this.#status);
}
/**
* Creates a new TinyLoadingScreen instance.
* @param {HTMLElement} [container=document.body] - The container element where the overlay should be appended.
* @throws {TypeError} If container is not an HTMLElement.
*/
constructor(container = document.body) {
if (!(container instanceof HTMLElement))
throw new TypeError('container must be an HTMLElement');
this.#container = container;
}
/**
* Internal helper to update the displayed message.
* @param {string|HTMLElement} [message=this.#defaultMessage] - The new message.
* @throws {TypeError} If the message is not a string or HTMLElement.
* @throws {Error} If trying to use HTMLElement without allowHtmlText enabled.
* @private
*/
_updateMessage(message = this.#defaultMessage) {
if (!this.#messageElement) throw new Error('messageElement is not initialized');
if (typeof message !== 'string' && !(message instanceof HTMLElement))
throw new TypeError('message must be a string or an HTMLElement');
this.#message = message;
if (typeof message === 'string') {
if (!this.#allowHtmlText) this.#messageElement.textContent = message;
else this.#messageElement.innerHTML = message;
} else {
if (!this.#allowHtmlText)
throw new Error('HTMLElement messages require allowHtmlText = true');
this.#messageElement.textContent = '';
this.#messageElement.appendChild(message);
}
}
/**
* Removes all status-related CSS classes (`active`, `fadeIn`, `fadeOut`)
* from the overlay element, if it exists.
*
* @private
* @returns {void}
*/
_removeOldClasses() {
this.#overlay?.classList.remove('active');
this.#overlay?.classList.remove('fadeIn');
this.#overlay?.classList.remove('fadeOut');
}
/**
* Starts the loading screen or updates its message if already active.
* @param {string|HTMLElement} [message=this.#defaultMessage] - Message to display.
* @returns {boolean} `true` if the overlay was created, `false` if only the message was updated.
* @throws {TypeError} If message is not a string or HTMLElement.
*/
start(message = this.#defaultMessage) {
if (typeof message !== 'string' && !(message instanceof HTMLElement))
throw new TypeError('message must be a string or an HTMLElement');
if (!this.#overlay) {
this.#overlay = document.createElement('div');
this.#overlay.classList.add('loading-overlay');
this.#overlay.style.zIndex = String(this.#options.zIndex);
const contentBase = document.createElement('div');
contentBase.classList.add('loading-container');
const content = document.createElement('div');
content.classList.add('loading-content');
const spinner = document.createElement('div');
spinner.classList.add('loading-spinner');
this.#messageElement = document.createElement('div');
this.#messageElement.classList.add('loading-message');
content.appendChild(spinner);
content.appendChild(this.#messageElement);
contentBase.appendChild(content);
this.#overlay.appendChild(contentBase);
this.#container.appendChild(this.#overlay);
// trigger fade in
this._removeOldClasses();
this.#status = 'fadeIn';
this.#overlay.classList.add('fadeIn');
this._emitChange();
const fadeIn = () => {
this._removeOldClasses();
this.#fadeInTimeout = null;
this.#status = 'active';
this.#overlay?.classList.add('active');
this._emitChange();
};
if (typeof this.#options.fadeIn === 'number') {
if (this.#fadeInTimeout) clearTimeout(this.#fadeInTimeout);
this.#fadeInTimeout = setTimeout(fadeIn, this.#options.fadeIn);
} else fadeIn();
this._updateMessage(message);
return true;
}
if (this.#messageElement) this._updateMessage(message);
return false;
}
/**
* Updates the loading screen with a new message.
* @param {string|HTMLElement} [message=this.#defaultMessage] - The new message.
* @returns {boolean} `true` if the message was updated, `false` if overlay is not active.
* @throws {TypeError} If message is not a string or HTMLElement.
*/
update(message = this.#defaultMessage) {
if (typeof message !== 'string' && !(message instanceof HTMLElement))
throw new TypeError('message must be a string or an HTMLElement');
if (this.#messageElement) {
this._updateMessage(message);
return true;
}
return false;
}
/**
* Stops and removes the loading screen.
* @returns {boolean} `true` if the overlay was removed, `false` if not active.
*/
stop() {
if (this.#overlay) {
this._removeOldClasses();
this.#status = 'fadeOut';
this.#overlay.classList.add('fadeOut');
this._emitChange();
// trigger fade out
const fadeOut = () => {
this._removeOldClasses();
this.#fadeOutTimeout = null;
this.#status = 'none';
this.#overlay?.remove();
this.#overlay = null;
this.#messageElement = null;
this.#message = null;
this._emitChange();
};
if (typeof this.#options.fadeOut === 'number') {
if (this.#fadeOutTimeout) clearTimeout(this.#fadeOutTimeout);
this.#fadeOutTimeout = setTimeout(fadeOut, this.#options.fadeOut);
} else fadeOut();
return true;
}
return false;
}
}
module.exports = TinyLoadingScreen;