UNPKG

ng-toast-notify

Version:

Lightweight and flexible toast notifications for Angular

354 lines (347 loc) 24 kB
import * as i0 from '@angular/core'; import { Injectable, HostListener, Input, Component } from '@angular/core'; import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; import { NgClass, NgStyle, AsyncPipe } from '@angular/common'; class ToastysoundService { constructor() { this.oscillatorType = 'sine'; this.frequency = 430; this.audioCtx = new AudioContext(); } notify() { if (this.audioCtx.state === 'suspended') { this.audioCtx.resume(); } const osc = this.audioCtx.createOscillator(); const gain = this.audioCtx.createGain(); osc.type = this.oscillatorType; osc.frequency.value = this.frequency; gain.gain.setValueAtTime(0.3, this.audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.00009, this.audioCtx.currentTime + 0.4); osc.connect(gain); gain.connect(this.audioCtx.destination); osc.start(0); osc.stop(this.audioCtx.currentTime + 1); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastysoundService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastysoundService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastysoundService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class Queue { constructor(capacity = Infinity) { this.capacity = capacity; this.storage = []; } setCapacity(c) { this.capacity = c; } getCapacity() { return this.capacity; } enqueue(item) { if (this.size() >= this.capacity) { throw Error('Toasty queue has reached max capacity, you cannot add more items.'); } this.storage.push(item); } dequeue() { return this.storage.shift(); } size() { return this.storage.length; } getElements() { return this.storage; } removeId(id) { const index = this.storage.findIndex(item => item.id === id); if (index !== -1) { const [removed] = this.storage.splice(index, 1); return removed; } return undefined; } } var ToastType; (function (ToastType) { ToastType["Basic"] = "basic"; ToastType["Success"] = "success"; ToastType["Error"] = "error"; ToastType["Info"] = "info"; ToastType["Warning"] = "warning"; ToastType["Custom"] = "custom"; })(ToastType || (ToastType = {})); class ToastyService { constructor(_toastySoundService) { this._toastySoundService = _toastySoundService; this.TOASTY_SERVICE_CONFIG = { capacity: 10, duration: 5000, messages: { now: 'just now', seconds: '{0} seconds ago', minutes: '{0} minutes ago' }, threshold_offsetX: 170 }; this.queue = new Queue(this.TOASTY_SERVICE_CONFIG.capacity); this.counter = 0; this.newToastBehaviorSubject = new BehaviorSubject([]); this.newToast$ = this.newToastBehaviorSubject.asObservable(); this.deleteToastBehaviorSubject = new BehaviorSubject(undefined); this.deleteToast$ = this.deleteToastBehaviorSubject.asObservable(); this.updateToastBehaviorSubject = new BehaviorSubject(undefined); this.updateToast$ = this.updateToastBehaviorSubject.asObservable(); } setDefaultDuration(d) { this.TOASTY_SERVICE_CONFIG.duration = d; } getDefaultDuration() { return this.TOASTY_SERVICE_CONFIG.duration; } setCapacity(c) { this.TOASTY_SERVICE_CONFIG.capacity = c; this.queue.setCapacity(c); } getCapacity() { return this.queue.getCapacity(); } showToast(title, message, toastConfig, isPromise) { this.counter++; // if sticky is true duration is infinity else use the provided duration or the default one const duration = toastConfig?.sticky ? Infinity : (toastConfig?.duration ?? this.TOASTY_SERVICE_CONFIG.duration); const toast = { id: this.counter, title: title, message: message, type: toastConfig?.type ?? ToastType.Basic, timestamp: Date.now(), duration: duration, expires: isPromise ? Infinity : Date.now() + duration, config: toastConfig, get icon() { if (toastConfig?.type == ToastType.Success) return "✅"; if (toastConfig?.type == ToastType.Info) return "ℹ️"; if (toastConfig?.type == ToastType.Warning) return "⚠️"; if (toastConfig?.type == ToastType.Error) return "❌"; return ""; } }; this.queue.enqueue(toast); this.newToastBehaviorSubject.next([...this.queue.getElements()]); if (toast.config?.beep) { this._toastySoundService.notify(); } // if sticky progress bar is not animated and toast will not expire if (!toast.config?.sticky) { if (toast.config?.progressBar) { setTimeout(() => { this.updateToast(toast.id, { animatePbar: true }); }, 100); } if (!isPromise) { setTimeout(() => { this.removeToast(toast.id); }, duration + 5); } } return this.counter; } showToastPromise(title, promise, config) { const idtoast = this.showToast(title, promise.loading, { ...config, loading: true }, true); const duration = config?.duration ?? this.TOASTY_SERVICE_CONFIG.duration; promise.promise .then((result) => { const successMessage = typeof promise.success === 'function' ? promise.success(result) : promise.success; this.updateToast(idtoast, { message: successMessage, type: ToastType.Success, expires: Date.now() + duration, config: { loading: false } }); }) .catch((err) => { const errorMessage = typeof promise.error === 'function' ? promise.error(err) : promise.error; this.updateToast(idtoast, { message: errorMessage, type: ToastType.Error, expires: Date.now() + duration, config: { loading: false } }); }) .finally(() => { if (!config?.sticky) setTimeout(() => this.removeToast(idtoast), duration); }); } removeToast(id) { this.newToastBehaviorSubject.next([...this.queue.getElements()]); // Trigger re-render to start exit animation setTimeout(() => { const t = this.queue.removeId(id); this.newToastBehaviorSubject.next([...this.queue.getElements()]); }, 500); // Wait for animation to finish before removing from queue } closeToast(id) { const t = this.queue.removeId(id); this.newToastBehaviorSubject.next([...this.queue.getElements()]); } updateTouchStart(id, touch) { const gesture = { date: Date.now(), touchStart: touch }; this.updateToast(id, { gesture: gesture }); } // Loops through the toasts to find which one was touched and updates its touchEnd updateTouchEnd(touch) { const toasts = this.queue.getElements().forEach(t => { if (t.gesture) { const deltaY = Math.abs(touch.clientY - t.gesture.touchStart.clientY); const deltaX = Math.abs(touch.clientX - t.gesture.touchStart.clientX); const deltaTime = Date.now() - t.gesture.date; if (deltaTime < 500 && deltaX > this.TOASTY_SERVICE_CONFIG.threshold_offsetX) { this.closeToast(t.id); } else { t.gesture = undefined; // Reset gesture if not swiped } } }); } updateTouchMove(touch) { const toasts = this.queue.getElements(); toasts.forEach(t => { if (t.gesture) { const deltaX = touch.clientX - t.gesture.touchStart.clientX; t.gesture.offsetX = deltaX; // Store the offset for potential use in UI updates } }); } updateToast(id, newData) { const toasts = this.queue.getElements(); const index = toasts.findIndex(t => t.id === id); if (index !== -1) { toasts[index] = { ...toasts[index], ...newData }; this.newToastBehaviorSubject.next([...toasts]); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastyService, deps: [{ token: ToastysoundService }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastyService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastyService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: ToastysoundService }] }); var ToastyContainerPosition; (function (ToastyContainerPosition) { ToastyContainerPosition["TOP_LEFT"] = "top-left"; ToastyContainerPosition["TOP"] = "top"; ToastyContainerPosition["TOP_RIGHT"] = "top-right"; ToastyContainerPosition["BOTTOM_LEFT"] = "bottom-left"; ToastyContainerPosition["BOTTOM"] = "bottom"; ToastyContainerPosition["BOTTOM_RIGHT"] = "bottom-right"; })(ToastyContainerPosition || (ToastyContainerPosition = {})); class ToastyComponent { constructor(_toastService) { this._toastService = _toastService; this.position = ToastyContainerPosition.BOTTOM_RIGHT; this.duration = this._toastService.getDefaultDuration(); this.capacity = this._toastService.getCapacity(); this.toasts$ = this._toastService.newToast$; this.positionMap = { 'top-left': ['top', 'left'], 'top': ['top', 'center'], 'top-right': ['top', 'right'], 'bottom-left': ['bottom', 'left'], 'bottom': ['bottom', 'center'], 'bottom-right': ['bottom', 'right'], }; } ngOnInit() { // Subscribe to the toast service to receive new toasts // this._toastService.newToast$.subscribe(t => { // if (t) { // // console.log(t); // } // }); this._toastService.deleteToast$.subscribe(t => { if (t) { // we dont need to delete toast, because internally in the service the toast has been deleted and in future toasts, only live toasts will be received from the service // console.log(t); } }); this._toastService.updateToast$.subscribe(t => { if (t) { // console.log(t); } }); } ngOnChanges(changes) { if (changes['duration']) { this._toastService.setDefaultDuration(this.duration); } if (changes['capacity']) { this._toastService.setCapacity(changes['capacity'].currentValue); } } closeToast(id) { this._toastService.closeToast(id); } containerClass() { return this.positionMap[this.position] ?? [ToastyContainerPosition.BOTTOM_RIGHT]; } expires(t) { return (t.expires < Date.now()); } getDurationUntilExpire(expires) { const duration = expires - Date.now(); return duration > 0 ? duration : 0; } // Swipe to close functionality onTouchStart(event) { const toastid = this.localizeToastIdFromTouch(event); this._toastService.updateTouchStart(toastid, event.changedTouches[0]); } onTouchEnd(event) { this._toastService.updateTouchEnd(event.changedTouches[0]); } onTouchMove(event) { this._toastService.updateTouchMove(event.changedTouches[0]); } localizeToastIdFromTouch(event) { let current = event.target; let maxnesting = 4; while (!current.classList.contains('toasty') && maxnesting > 0) { current = current.parentElement; maxnesting--; } return parseInt(current.getAttribute('data-toast-id') || '0'); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastyComponent, deps: [{ token: ToastyService }], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ToastyComponent, isStandalone: true, selector: "toasty", inputs: { position: "position", duration: "duration", capacity: "capacity" }, host: { listeners: { "touchstart": "onTouchStart($event)", "touchend": "onTouchEnd($event)", "touchmove": "onTouchMove($event)" } }, usesOnChanges: true, ngImport: i0, template: "<div class=\"toasty-container\" [ngClass]=\"containerClass()\">\r\n @for (item of toasts$ | async; track item.id) {\r\n <div class=\"toasty-wrapper\" [style.transform]=\"'translateX(' + (item.gesture?.offsetX || 0) + 'px)'\"\r\n [style.transition]=\"item.gesture ? 'none' : 'transform 0.3s ease'\">\r\n <div class=\"toasty\" [attr.data-toast-id]=\"item.id\" [ngClass]=\"item.type\" [class.fadeout]=\"expires(item)\"\r\n [ngStyle]=\"item.config?.customStyle\">\r\n @if (item.icon) {\r\n <div class=\"toasty-icon\">{{item.icon}}</div>\r\n } @else if (item.config?.loading){\r\n <div class=\"toasty-loading\"></div>\r\n } @else {\r\n <div class=\"toasty-noicon\"></div>\r\n }\r\n <div class=\"toasty-content\">\r\n @if (item.title) {\r\n <div class=\"toasty-header\">\r\n <strong class=\"toasty-title\">{{item.title}}</strong>\r\n <!-- <small class=\"toasty-time\">{{item.timestamp}}</small> -->\r\n </div>\r\n }\r\n @if (item.config?.enableHtml) {\r\n <div class=\"toasty-body\" [innerHTML]=\"item.message\"></div>\r\n } @else {\r\n <div class=\"toasty-body\">{{item.message}}</div>\r\n }\r\n </div>\r\n <div class=\"toasty-close\">\r\n <button type=\"button\" class=\"toasty-close\" (click)=\"closeToast(item.id)\">&times;</button>\r\n </div>\r\n @if (item.config?.progressBar) {\r\n <div class=\"toasty-progress\" [class.animate]=\"item.animatePbar\" [style.transition-duration.ms]=\"item.duration\"></div>\r\n }\r\n </div>\r\n </div>\r\n }\r\n</div>", styles: [".toasty-container{position:fixed;width:350px;padding:.5rem;right:0;bottom:0;pointer-events:none}.toasty-container.left{left:12px}.toasty-container.center{left:50%;transform:translate(-50%)}.toasty-container.right{right:12px}.toasty-container.top{top:10px;display:flex;flex-direction:column-reverse;justify-content:flex-end}.toasty-container.bottom{bottom:0}.toasty{padding:10px;border:1px solid rgba(0,0,0,.15);border-radius:8px;box-shadow:0 4px 10px #00000026;background-color:#fff;overflow:hidden;margin-bottom:1rem;font-family:Arial,Helvetica,sans-serif;animation-name:toasty_fadein;animation-duration:.3s;animation-timing-function:ease-out;animation-delay:0s;animation-iteration-count:1;animation-fill-mode:forwards;display:flex;align-items:center;pointer-events:auto;touch-action:manipulation}@keyframes toasty_fadein{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toasty_fadeout{0%{opacity:1}to{opacity:0;pointer-events:none}}.toasty.fadeout{animation-name:toasty_fadeout;animation-duration:.5s;animation-timing-function:ease-in;animation-delay:0s;animation-iteration-count:1;animation-fill-mode:forwards;pointer-events:none}.toasty-icon{width:20px;height:20px;margin-right:.5rem;border-radius:4px}.toasty-loading{width:10px;height:10px;margin-right:.6rem;border-radius:50%;display:inline-block;border-top:3px solid;border-right:3px solid transparent;box-sizing:border-box;animation:loading-toasty-anim 1s linear infinite}@keyframes loading-toasty-anim{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.toasty-noicon{width:7px}.toasty-content{flex-direction:column;flex:1}.toasty-header{display:flex;align-items:center}.toasty-title{font-size:.9rem;font-weight:700;margin-right:auto}.toasty-time{font-size:.85rem;color:#6c757d;margin-right:1rem}.toasty-close{background:none;border:none;font-size:1.4rem;line-height:1;cursor:pointer;color:inherit;opacity:.6}.toasty-body{font-size:.85rem;margin-top:1px}.toasty-close:hover{opacity:1}.toasty.none{background-color:#fff;border-color:#ededed;color:#171717}.toasty.success{background-color:#ecfdf3;border-color:#bffcd9;color:#008a2e}.toasty.error{background-color:#fff0f0;border-color:#ffe0e1;color:#e60000}.toasty.info{background-color:#f0f8ff;border-color:#dde7fd;color:#0973dc}.toasty.warning{background-color:#fffcf0;border-color:#fbeeb1;color:#dc7609}.toasty.custom{background-color:#fff}.toasty-progress{position:absolute;bottom:0;left:0;height:4px;width:100%;background-color:currentColor;opacity:.3;transform-origin:left;transform:scaleX(1);transition-property:transform;transition-timing-function:linear}.toasty-progress.animate{transform:scaleX(0)}\n"], dependencies: [{ kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "pipe", type: AsyncPipe, name: "async" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ToastyComponent, decorators: [{ type: Component, args: [{ selector: 'toasty', standalone: true, imports: [NgClass, NgStyle, AsyncPipe], template: "<div class=\"toasty-container\" [ngClass]=\"containerClass()\">\r\n @for (item of toasts$ | async; track item.id) {\r\n <div class=\"toasty-wrapper\" [style.transform]=\"'translateX(' + (item.gesture?.offsetX || 0) + 'px)'\"\r\n [style.transition]=\"item.gesture ? 'none' : 'transform 0.3s ease'\">\r\n <div class=\"toasty\" [attr.data-toast-id]=\"item.id\" [ngClass]=\"item.type\" [class.fadeout]=\"expires(item)\"\r\n [ngStyle]=\"item.config?.customStyle\">\r\n @if (item.icon) {\r\n <div class=\"toasty-icon\">{{item.icon}}</div>\r\n } @else if (item.config?.loading){\r\n <div class=\"toasty-loading\"></div>\r\n } @else {\r\n <div class=\"toasty-noicon\"></div>\r\n }\r\n <div class=\"toasty-content\">\r\n @if (item.title) {\r\n <div class=\"toasty-header\">\r\n <strong class=\"toasty-title\">{{item.title}}</strong>\r\n <!-- <small class=\"toasty-time\">{{item.timestamp}}</small> -->\r\n </div>\r\n }\r\n @if (item.config?.enableHtml) {\r\n <div class=\"toasty-body\" [innerHTML]=\"item.message\"></div>\r\n } @else {\r\n <div class=\"toasty-body\">{{item.message}}</div>\r\n }\r\n </div>\r\n <div class=\"toasty-close\">\r\n <button type=\"button\" class=\"toasty-close\" (click)=\"closeToast(item.id)\">&times;</button>\r\n </div>\r\n @if (item.config?.progressBar) {\r\n <div class=\"toasty-progress\" [class.animate]=\"item.animatePbar\" [style.transition-duration.ms]=\"item.duration\"></div>\r\n }\r\n </div>\r\n </div>\r\n }\r\n</div>", styles: [".toasty-container{position:fixed;width:350px;padding:.5rem;right:0;bottom:0;pointer-events:none}.toasty-container.left{left:12px}.toasty-container.center{left:50%;transform:translate(-50%)}.toasty-container.right{right:12px}.toasty-container.top{top:10px;display:flex;flex-direction:column-reverse;justify-content:flex-end}.toasty-container.bottom{bottom:0}.toasty{padding:10px;border:1px solid rgba(0,0,0,.15);border-radius:8px;box-shadow:0 4px 10px #00000026;background-color:#fff;overflow:hidden;margin-bottom:1rem;font-family:Arial,Helvetica,sans-serif;animation-name:toasty_fadein;animation-duration:.3s;animation-timing-function:ease-out;animation-delay:0s;animation-iteration-count:1;animation-fill-mode:forwards;display:flex;align-items:center;pointer-events:auto;touch-action:manipulation}@keyframes toasty_fadein{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:translate(0)}}@keyframes toasty_fadeout{0%{opacity:1}to{opacity:0;pointer-events:none}}.toasty.fadeout{animation-name:toasty_fadeout;animation-duration:.5s;animation-timing-function:ease-in;animation-delay:0s;animation-iteration-count:1;animation-fill-mode:forwards;pointer-events:none}.toasty-icon{width:20px;height:20px;margin-right:.5rem;border-radius:4px}.toasty-loading{width:10px;height:10px;margin-right:.6rem;border-radius:50%;display:inline-block;border-top:3px solid;border-right:3px solid transparent;box-sizing:border-box;animation:loading-toasty-anim 1s linear infinite}@keyframes loading-toasty-anim{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.toasty-noicon{width:7px}.toasty-content{flex-direction:column;flex:1}.toasty-header{display:flex;align-items:center}.toasty-title{font-size:.9rem;font-weight:700;margin-right:auto}.toasty-time{font-size:.85rem;color:#6c757d;margin-right:1rem}.toasty-close{background:none;border:none;font-size:1.4rem;line-height:1;cursor:pointer;color:inherit;opacity:.6}.toasty-body{font-size:.85rem;margin-top:1px}.toasty-close:hover{opacity:1}.toasty.none{background-color:#fff;border-color:#ededed;color:#171717}.toasty.success{background-color:#ecfdf3;border-color:#bffcd9;color:#008a2e}.toasty.error{background-color:#fff0f0;border-color:#ffe0e1;color:#e60000}.toasty.info{background-color:#f0f8ff;border-color:#dde7fd;color:#0973dc}.toasty.warning{background-color:#fffcf0;border-color:#fbeeb1;color:#dc7609}.toasty.custom{background-color:#fff}.toasty-progress{position:absolute;bottom:0;left:0;height:4px;width:100%;background-color:currentColor;opacity:.3;transform-origin:left;transform:scaleX(1);transition-property:transform;transition-timing-function:linear}.toasty-progress.animate{transform:scaleX(0)}\n"] }] }], ctorParameters: () => [{ type: ToastyService }], propDecorators: { position: [{ type: Input }], duration: [{ type: Input }], capacity: [{ type: Input }], onTouchStart: [{ type: HostListener, args: ['touchstart', ['$event']] }], onTouchEnd: [{ type: HostListener, args: ['touchend', ['$event']] }], onTouchMove: [{ type: HostListener, args: ['touchmove', ['$event']] }] } }); /* * Public API Surface of ng-toast-notify */ /** * Generated bundle index. Do not edit. */ export { ToastType, ToastyComponent, ToastyService }; //# sourceMappingURL=ng-toast-notify.mjs.map