promise-loading-spinner
Version:
Advanced handling of loaders/spinners based on one or multiple Promises
209 lines (208 loc) • 8.07 kB
JavaScript
/**
* Default config.
*/
const defaults = {
loaderVisibilityCallback: undefined,
delay: 300,
closeDelay: 10,
initDelay: 1000,
loaderElement: undefined,
classActive: 'is-active',
};
/**
* Advanced handling of loaders/spinners based on one or multiple Promises.
*/
export default class Loader {
/**
* Constructor.
*
* @param cfg configuration of the loader (optional)
*/
constructor(cfg = {}) {
/**
* Contains all promises that aren't settled.
*/
this.loaderPromises = [];
/**
* Is the loader currently shown?
*/
this.loaderShows = false;
/**
* The promises for which the loader currently shows up.
*/
this.promisesForShownLoader = [];
/**
* A promise that resolves after the loader get hidden.
*/
this.currentLoadingPromise = Promise.resolve([]);
this.setCurrentLoadingPromise();
const config = { ...defaults, ...cfg };
this.config = config;
const { config: { loaderElement, loaderVisibilityCallback, initDelay } } = this;
if (loaderElement !== null && loaderElement !== void 0 ? loaderElement : !loaderVisibilityCallback) {
const element = loaderElement !== null && loaderElement !== void 0 ? loaderElement : '#js-page-loader';
this.el = element instanceof HTMLElement
? element : document.querySelector(element);
if (!this.el)
throw new Error('Element not found');
}
if (!this.el && !loaderVisibilityCallback)
throw new Error('No loader element or loaderVisibilityCallback provided');
if (loaderVisibilityCallback)
loaderVisibilityCallback(this.loaderShows);
// suppress loader in a short timeframe after initializing (page load)
this.initSuppressTimeout = setTimeout(() => this.stopSuppressLoading(), initDelay);
}
/**
* Add a promise to the loader.
*
* @param promise a promise
* @param options options for this promise (optional)
* @returns the used promise
*/
loader(promise, options) {
var _a;
const skipDelays = (_a = options === null || options === void 0 ? void 0 : options.skipDelays) !== null && _a !== void 0 ? _a : false;
if ((!this.initSuppressTimeout || skipDelays)) {
if (this.initSuppressTimeout && skipDelays)
this.stopSuppressLoading();
const isFirstLoader = this.loaderPromises.length === 0;
this.loaderPromises.push(promise);
if (this.loaderShows)
this.promisesForShownLoader.push(promise);
if (isFirstLoader) { // Only the first loader needs to initialize the show functionality
this.showLoader(skipDelays);
}
void this.handlePromise(promise);
}
return promise;
}
/**
* Returns a function that wraps the loader functionality around a function call.
*
* @param fnc A function performing some async operation
* @param options options for the operation (optional)
* @returns a function that wraps the loader functionality around a function call
*/
wrapFunction(fnc, options) {
// eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment
const loaderContext = this;
return function (...arguments_) {
return loaderContext.loader(fnc.apply(this, arguments_), options);
};
}
/**
* A decorator for methods that wraps loader functionality around a function call.
* @param options options for the operation (optional)
* @returns a decorator for methods that wraps loader functionality around a function call.
*/
decorator(options) {
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-this-alias */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable unicorn/no-this-assignment */
const loaderContext = this;
return function (target, propertyKey, descriptor) {
const oldValue = descriptor.value;
descriptor.value = function (...parameters) {
return loaderContext.loader(oldValue.apply(this, parameters), options);
};
};
/* eslint-enable @typescript-eslint/no-explicit-any */
/* eslint-enable @typescript-eslint/no-this-alias */
/* eslint-enable @typescript-eslint/no-unsafe-argument */
/* eslint-enable @typescript-eslint/no-unsafe-call */
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
/* eslint-enable unicorn/no-this-assignment */
}
/**
* Stops initial loader suppresion
*/
stopSuppressLoading() {
clearTimeout(this.initSuppressTimeout);
this.initSuppressTimeout = undefined;
}
/**
* Create the promise for the currently shown loader.
*/
setCurrentLoadingPromise() {
this.currentLoadingPromise = new Promise((resolve) => {
this.loaderShownResolver = resolve;
});
}
/**
* Show or hide the loader.
*
* @param visible is the loader visible?
*/
setLoaderVisibility(visible) {
if (this.el)
this.el.classList[visible ? 'add' : 'remove'](this.config.classActive);
if (this.config.loaderVisibilityCallback)
this.config.loaderVisibilityCallback(visible);
this.loaderShows = visible;
if (visible) {
this.promisesForShownLoader.push(...this.loaderPromises);
}
else {
/* istanbul ignore else */
if (this.loaderShownResolver) {
this.loaderShownResolver(this.promisesForShownLoader.splice(0, this.promisesForShownLoader.length));
}
this.setCurrentLoadingPromise();
}
}
/**
* Wait for promise to fulfill or reject and check if the loader can hide.
*
* @param promise the promise to process
*/
async handlePromise(promise) {
try {
await promise;
}
catch {
// nothing to do
}
const { timeout, loaderPromises, config } = this;
if (timeout && loaderPromises.length === 1) {
// We close the last operation before the loader was shown. There is no need anymore to show it.
clearTimeout(timeout);
this.timeout = undefined;
}
void loaderPromises.splice(loaderPromises.indexOf(promise), 1);
if (loaderPromises.length === 0) {
// The last operation has finished. Show loader a bit longer so there is no flickering when an operation
// starts shortly after.
this.closingTimeout = setTimeout(() => {
this.setLoaderVisibility(false);
this.closingTimeout = undefined;
}, config.closeDelay);
}
}
/**
* Show the loader. Also adds the loader delay.
*
* @param skipDelays skip delays?
*/
showLoader(skipDelays) {
if (this.closingTimeout) {
// Another operation finished shortly before. To avoid flickering the loader closes later.
// But here we don't need to close it because another operation starts.
clearTimeout(this.closingTimeout);
this.closingTimeout = undefined;
}
else if (skipDelays) {
this.setLoaderVisibility(true);
}
else {
// Show loader after a delay. For operation that are finished fast enough no loader is shown.
this.timeout = setTimeout(() => {
this.setLoaderVisibility(true);
this.timeout = undefined;
}, this.config.delay);
}
}
}