UNPKG

hot-toast-bokzor

Version:

Smoking hot Notifications for Angular. Lightweight, customizable and beautiful by default.

902 lines (890 loc) 91 kB
import { isPlatformServer } from '@angular/common'; import * as i0 from '@angular/core'; import { Input, ChangeDetectionStrategy, Component, EventEmitter, signal, inject, Injector, Renderer2, NgZone, ChangeDetectorRef, ViewChild, Output, ViewChildren, InjectionToken, PLATFORM_ID, Injectable, makeEnvironmentProviders } from '@angular/core'; import { DynamicViewDirective, isTemplateRef, isComponent, ViewService } from '@ngneat/overview'; import { Subject, race, defer } from 'rxjs'; import { map, filter, tap } from 'rxjs/operators'; const HOT_TOAST_DEFAULT_TIMEOUTS = { blank: 4000, error: 4000, success: 4000, loading: 30000, warning: 4000, info: 4000, }; const EXIT_ANIMATION_DURATION = 800; const ENTER_ANIMATION_DURATION = 350; const HOT_TOAST_MARGIN = 8; const HOT_TOAST_DEPTH_SCALE = 0.05; const HOT_TOAST_DEPTH_SCALE_ADD = 1; class HotToastRef { constructor(toast) { this.toast = toast; this.groupRefs = []; this.groupExpanded = false; /** Subject for notifying the user that the toast has been closed. */ this._onClosed = new Subject(); /** Subject for notifying the user that the toast has been closed. */ this._onGroupToggle = new Subject(); } set data(data) { this.toast.data = data; } get data() { return this.toast.data; } set dispose(value) { this._dispose = value; } getToast() { return this.toast; } /** * Used for internal purpose * Attach ToastRef to container */ appendTo(container, skipAttachToParent) { const { dispose, updateMessage, updateToast, afterClosed, afterGroupToggled, afterGroupRefsAttached } = container.addToast(this, skipAttachToParent); this.dispose = dispose; this.updateMessage = updateMessage; this.updateToast = updateToast; this.afterClosed = race(this._onClosed.asObservable(), afterClosed); this.afterGroupToggled = race(this._onGroupToggle.asObservable(), afterGroupToggled); this.afterGroupRefsAttached = afterGroupRefsAttached; return this; } /** * Closes the toast * * @param [closeData={ dismissedByAction: false }] - * Make sure to pass { dismissedByAction: true } when closing from template * @memberof HotToastRef */ close(closeData = { dismissedByAction: false }) { this.groupRefs.forEach((ref) => ref.close()); this._dispose(); this._onClosed.next({ dismissedByAction: closeData.dismissedByAction, id: this.toast.id }); this._onClosed.complete(); } toggleGroup(eventData = { byAction: false }) { this.groupExpanded = !this.groupExpanded; this._onGroupToggle.next({ byAction: eventData.byAction, id: this.toast.id, event: this.groupExpanded ? 'expand' : 'collapse', }); } show() { this.toast.visible = true; } } const animate = (renderer, element, animation) => { renderer.setStyle(element, 'animation', animation); }; class LoaderComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: LoaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: LoaderComponent, isStandalone: true, selector: "hot-toast-loader", inputs: { theme: "theme" }, ngImport: i0, template: "<div\n class=\"hot-toast-loader-icon\"\n [style.border-color]=\"theme?.primary\"\n [style.border-right-color]=\"theme?.secondary\"\n></div>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: LoaderComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-loader', changeDetection: ChangeDetectionStrategy.OnPush, template: "<div\n class=\"hot-toast-loader-icon\"\n [style.border-color]=\"theme?.primary\"\n [style.border-right-color]=\"theme?.secondary\"\n></div>\n" }] }], propDecorators: { theme: [{ type: Input }] } }); class ErrorComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: ErrorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: ErrorComponent, isStandalone: true, selector: "hot-toast-error", inputs: { theme: "theme" }, ngImport: i0, template: "<div\n class=\"hot-toast-error-icon\"\n [style.--error-primary]=\"theme?.primary\"\n [style.--error-secondary]=\"theme?.secondary\"\n></div>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: ErrorComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-error', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "<div\n class=\"hot-toast-error-icon\"\n [style.--error-primary]=\"theme?.primary\"\n [style.--error-secondary]=\"theme?.secondary\"\n></div>\n" }] }], propDecorators: { theme: [{ type: Input }] } }); class CheckMarkComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: CheckMarkComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: CheckMarkComponent, isStandalone: true, selector: "hot-toast-checkmark", inputs: { theme: "theme" }, ngImport: i0, template: "<div\n class=\"hot-toast-checkmark-icon\"\n [style.--check-primary]=\"theme?.primary\"\n [style.--check-secondary]=\"theme?.secondary\"\n></div>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: CheckMarkComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-checkmark', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "<div\n class=\"hot-toast-checkmark-icon\"\n [style.--check-primary]=\"theme?.primary\"\n [style.--check-secondary]=\"theme?.secondary\"\n></div>\n" }] }], propDecorators: { theme: [{ type: Input }] } }); class WarningComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: WarningComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: WarningComponent, isStandalone: true, selector: "hot-toast-warning", inputs: { theme: "theme" }, ngImport: i0, template: "<div\n class=\"hot-toast-warning-icon\"\n [style.--warn-primary]=\"theme?.primary\"\n [style.--warn-secondary]=\"theme?.secondary\"\n></div>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: WarningComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-warning', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "<div\n class=\"hot-toast-warning-icon\"\n [style.--warn-primary]=\"theme?.primary\"\n [style.--warn-secondary]=\"theme?.secondary\"\n></div>\n" }] }], propDecorators: { theme: [{ type: Input }] } }); class InfoComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: InfoComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: InfoComponent, isStandalone: true, selector: "hot-toast-info", inputs: { theme: "theme" }, ngImport: i0, template: "<div\n class=\"hot-toast-info-icon\"\n [style.--info-primary]=\"theme?.primary\"\n [style.--info-secondary]=\"theme?.secondary\"\n></div>\n", changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: InfoComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-info', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "<div\n class=\"hot-toast-info-icon\"\n [style.--info-primary]=\"theme?.primary\"\n [style.--info-secondary]=\"theme?.secondary\"\n></div>\n" }] }], propDecorators: { theme: [{ type: Input }] } }); class IndicatorComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: IndicatorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.3", type: IndicatorComponent, isStandalone: true, selector: "hot-toast-indicator", inputs: { theme: "theme", type: "type" }, ngImport: i0, template: "@if (type !== 'blank') {\n<div class=\"hot-toast-indicator-wrapper\">\n @if (type === 'loading') {\n <hot-toast-loader [theme]=\"theme\"></hot-toast-loader>\n } @if (type !== 'loading') {\n <div class=\"hot-toast-status-wrapper\">\n <div>\n @switch (type) { @case ('error') {\n <div>\n <hot-toast-error [theme]=\"theme\"></hot-toast-error>\n </div>\n } @case ('success') {\n <div>\n <hot-toast-checkmark [theme]=\"theme\"></hot-toast-checkmark>\n </div>\n } @case ('warning') {\n <div>\n <hot-toast-warning [theme]=\"theme\"></hot-toast-warning>\n </div>\n } @case ('info') {\n <div>\n <hot-toast-info [theme]=\"theme\"></hot-toast-info>\n </div>\n } }\n </div>\n </div>\n }\n</div>\n}\n", dependencies: [{ kind: "component", type: LoaderComponent, selector: "hot-toast-loader", inputs: ["theme"] }, { kind: "component", type: ErrorComponent, selector: "hot-toast-error", inputs: ["theme"] }, { kind: "component", type: CheckMarkComponent, selector: "hot-toast-checkmark", inputs: ["theme"] }, { kind: "component", type: WarningComponent, selector: "hot-toast-warning", inputs: ["theme"] }, { kind: "component", type: InfoComponent, selector: "hot-toast-info", inputs: ["theme"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: IndicatorComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-indicator', changeDetection: ChangeDetectionStrategy.OnPush, imports: [LoaderComponent, ErrorComponent, CheckMarkComponent, WarningComponent, InfoComponent], template: "@if (type !== 'blank') {\n<div class=\"hot-toast-indicator-wrapper\">\n @if (type === 'loading') {\n <hot-toast-loader [theme]=\"theme\"></hot-toast-loader>\n } @if (type !== 'loading') {\n <div class=\"hot-toast-status-wrapper\">\n <div>\n @switch (type) { @case ('error') {\n <div>\n <hot-toast-error [theme]=\"theme\"></hot-toast-error>\n </div>\n } @case ('success') {\n <div>\n <hot-toast-checkmark [theme]=\"theme\"></hot-toast-checkmark>\n </div>\n } @case ('warning') {\n <div>\n <hot-toast-warning [theme]=\"theme\"></hot-toast-warning>\n </div>\n } @case ('info') {\n <div>\n <hot-toast-info [theme]=\"theme\"></hot-toast-info>\n </div>\n } }\n </div>\n </div>\n }\n</div>\n}\n" }] }], propDecorators: { theme: [{ type: Input }], type: [{ type: Input }] } }); class AnimatedIconComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: AnimatedIconComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: AnimatedIconComponent, isStandalone: true, selector: "hot-toast-animated-icon", inputs: { iconTheme: "iconTheme", icon: "icon" }, ngImport: i0, template: "<div class=\"hot-toast-animated-icon\" [style.color]=\"iconTheme?.primary\">\n <ng-container *dynamicView=\"icon\"></ng-container>\n</div>\n", dependencies: [{ kind: "directive", type: DynamicViewDirective, selector: "[dynamicView]", inputs: ["dynamicView", "dynamicViewInjector", "dynamicViewContext", "dynamicViewInputs"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: AnimatedIconComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-animated-icon', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [DynamicViewDirective], template: "<div class=\"hot-toast-animated-icon\" [style.color]=\"iconTheme?.primary\">\n <ng-container *dynamicView=\"icon\"></ng-container>\n</div>\n" }] }], propDecorators: { iconTheme: [{ type: Input }], icon: [{ type: Input }] } }); class HotToastGroupItemComponent { constructor() { this.offset = 0; this._toastsAfter = 0; this.isShowingAllToasts = false; this.height = new EventEmitter(); this.beforeClosed = new EventEmitter(); this.afterClosed = new EventEmitter(); this.showAllToasts = new EventEmitter(); this.toggleGroup = new EventEmitter(); this.isManualClose = false; this.toastBarBaseStylesSignal = signal({}, ...(ngDevMode ? [{ debugName: "toastBarBaseStylesSignal" }] : [])); this.unlisteners = []; this.softClosed = false; this.injector = inject(Injector); this.renderer = inject(Renderer2); this.ngZone = inject(NgZone); this.cdr = inject(ChangeDetectorRef); // Swipe-to-dismiss implementation (vertical: bottom -> top) this.drag = { active: false, locked: false, startX: 0, startY: 0, lastY: 0, lastT: 0, vy: 0, dy: 0, }; } set toast(value) { this._toast = value; const ogStyle = this.toastBarBaseStylesSignal(); const newStyle = { ...value.style }; if (ogStyle['animation']?.includes('hotToastExitAnimation')) { // if toast is set for exit, we don't need want set the enter animation newStyle['animation'] = ogStyle['animation']; } else { const top = value.position.includes('top'); const enterAnimation = `hotToastEnterAnimation${top ? 'Negative' : 'Positive'} ${ENTER_ANIMATION_DURATION}ms cubic-bezier(0.21, 1.02, 0.73, 1) forwards`; newStyle['animation'] = enterAnimation; } this.toastBarBaseStylesSignal.set(newStyle); } get toast() { return this._toast; } get toastsAfter() { return this._toastsAfter; } set toastsAfter(value) { this._toastsAfter = value; } get toastBarBaseHeight() { return this.toastBarBase.nativeElement.offsetHeight; } get scale() { return this.defaultConfig.stacking !== 'vertical' && !this.isShowingAllToasts ? this.toastsAfter * -HOT_TOAST_DEPTH_SCALE + 1 : 1; } get translateY() { return this.offset * (this.top ? 1 : -1) + 'px'; } get exitAnimationDelay() { return this.toast.duration + 'ms'; } get top() { return this.toast.position.includes('top'); } get containerPositionStyle() { const verticalStyle = this.top ? { top: 0 } : { bottom: 0 }; const transform = `translateY(var(--hot-toast-translate-y)) scale(var(--hot-toast-scale))`; const horizontalStyle = this.toast.position.includes('left') ? { left: 0, } : this.toast.position.includes('right') ? { right: 0, } : { left: 0, right: 0, justifyContent: 'center', }; return { transform, ...verticalStyle, ...horizontalStyle, }; } get isIconString() { return typeof this.toast.icon === 'string'; } get groupChildrenToastRefs() { return this.toastRef.groupRefs.filter((ref) => !!ref); } set groupChildrenToastRefs(value) { this.toastRef.groupRefs = value; } get groupChildrenToasts() { return this.groupChildrenToastRefs.map((ref) => ref.getToast()); } get groupHeight() { return this.visibleToasts.map((t) => t.height).reduce((prev, curr) => prev + curr, 0); } get isExpanded() { return this.toastRef.groupExpanded; } ngOnChanges(changes) { if (changes.toast && !changes.toast.firstChange && changes.toast.currentValue?.message) { requestAnimationFrame(() => { this.height.emit(this.toastBarBase.nativeElement.offsetHeight); }); } } ngOnInit() { if (isTemplateRef(this.toast.message)) { this.context = { $implicit: this.toastRef }; } if (isComponent(this.toast.message)) { this.toastComponentInjector = Injector.create({ providers: [ { provide: HotToastRef, useValue: this.toastRef, }, ], parent: this.toast.injector || this.injector, }); } const nativeElement = this.toastBarBase.nativeElement; // Caretaker note: `animationstart` and `animationend` events are event tasks that trigger change detection. // We'd want to trigger the change detection only if it's an exit animation. this.ngZone.runOutsideAngular(() => { this.unlisteners.push( // Caretaker note: we have to remove these event listeners at the end (even if the element is removed from DOM). // zone.js stores its `ZoneTask`s within the `nativeElement[Zone.__symbol__('animationstart') + 'false']` property // with callback that capture `this`. this.renderer.listen(nativeElement, 'animationstart', (event) => { if (this.isExitAnimation(event)) { this.ngZone.run(() => { this.renderer.setStyle(nativeElement, 'pointer-events', 'none'); this.renderer.setStyle(nativeElement.parentElement, 'pointer-events', 'none'); this.beforeClosed.emit(); }); } }), this.renderer.listen(nativeElement, 'animationend', (event) => { if (this.isEnterAnimation(event)) { this.ngZone.run(() => { if (this.toast.autoClose) { const exitAnimation = `hotToastExitAnimation${this.top ? 'Negative' : 'Positive'} ${EXIT_ANIMATION_DURATION}ms forwards cubic-bezier(0.06, 0.71, 0.55, 1) var(--hot-toast-exit-animation-delay) var(--hot-toast-exit-animation-state)`; this.toastBarBaseStylesSignal.set({ ...this.toast.style, animation: exitAnimation }); } }); } if (this.isExitAnimation(event)) { this.ngZone.run(() => this.afterClosed.emit({ dismissedByAction: this.isManualClose, id: this.toast.id })); } })); }); } ngAfterViewInit() { const nativeElement = this.toastBarBase.nativeElement; // Caretaker note: accessing `offsetHeight` triggers the whole layout update. // Macro tasks (like `setTimeout`) might be executed within the current rendering frame and cause a frame drop. requestAnimationFrame(() => { this.height.emit(nativeElement.offsetHeight); }); this.setToastAttributes(); this.attachSwipeListeners(); } rubberband(x) { const a = 0.55; const ax = Math.abs(x); const eased = 1 - Math.pow(1 - Math.min(1, ax), a); return Math.sign(x) * eased; } decideVerticalDismiss(el, dy, vy) { const height = el.offsetHeight || 1; const distanceDismiss = dy < 0 && Math.abs(dy) > height * 0.35; // upward only const velocityDismiss = vy < 0 && Math.abs(vy) > 800; // px/s upward return distanceDismiss || velocityDismiss; } attachSwipeListeners() { const el = this.toastBarBase.nativeElement; this.ngZone.runOutsideAngular(() => { const downUn = this.renderer.listen(el, 'pointerdown', (e) => { if (e.button !== undefined && e.button !== 0) return; this.drag.active = true; this.drag.locked = false; this.drag.startX = e.clientX; this.drag.startY = e.clientY; this.drag.lastY = e.clientY; this.drag.lastT = performance.now(); el.setPointerCapture?.(e.pointerId); this.ngZone.run(() => this.showAllToasts.emit(true)); this.renderer.setStyle(el, 'will-change', 'transform, opacity'); this.renderer.setStyle(el, 'touch-action', 'pan-x'); this.renderer.setStyle(el, 'cursor', 'grabbing'); }); const moveUn = this.renderer.listen(el, 'pointermove', (e) => { if (!this.drag.active) return; const dxRaw = e.clientX - this.drag.startX; const dyRaw = e.clientY - this.drag.startY; if (!this.drag.locked) { const slop = 8; if (Math.abs(dxRaw) < slop && Math.abs(dyRaw) < slop) return; if (Math.abs(dyRaw) > Math.abs(dxRaw) && dyRaw < 0) { this.drag.locked = true; // commit to upward vertical gesture } else { cancelDrag(); return; } } const height = el.offsetHeight || 1; const eased = this.rubberband(dyRaw / height) * height; this.drag.dy = eased; const opacity = 1 - Math.min(1, Math.abs(eased) / height); this.renderer.setStyle(el, 'transform', `translate3d(0,${eased}px,0)`); this.renderer.setStyle(el, 'opacity', String(opacity)); const now = performance.now(); const dt = Math.max(1, now - this.drag.lastT); this.drag.vy = ((e.clientY - this.drag.lastY) / dt) * 1000; this.drag.lastY = e.clientY; this.drag.lastT = now; }); const upHandler = () => { if (!this.drag.active) return; const shouldDismiss = this.decideVerticalDismiss(el, this.drag.dy, this.drag.vy); if (shouldDismiss) { const height = el.offsetHeight || 1; const target = -1 * (window.innerHeight + height); // always upward const current = getComputedStyle(el); el .animate([ { transform: `translate3d(0,${this.drag.dy}px,0)`, opacity: Number(current.opacity) || 1 }, { transform: `translate3d(0,${target}px,0)`, opacity: 0 }, ], { duration: 220, easing: 'cubic-bezier(.22,.61,.36,1)' }) .finished.finally(() => { this.ngZone.run(() => { this.beforeClosed.emit(); this.afterClosed.emit({ dismissedByAction: true, id: this.toast.id }); }); navigator.vibrate?.(10); }); } else { const current = getComputedStyle(el); el .animate([ { transform: current.transform, opacity: Number(current.opacity) }, { transform: 'translate3d(0,0,0)', opacity: 1 }, ], { duration: 240, easing: 'cubic-bezier(.17,.89,.32,1.27)' }) .finished.finally(() => { this.renderer.removeStyle(el, 'transform'); this.renderer.removeStyle(el, 'opacity'); this.ngZone.run(() => this.showAllToasts.emit(false)); }); } finishDrag(); }; const upUn = this.renderer.listen(el, 'pointerup', upHandler); const cancelUn = this.renderer.listen(el, 'pointercancel', upHandler); const cancelDrag = () => { this.drag.active = false; this.drag.locked = false; this.ngZone.run(() => this.showAllToasts.emit(false)); this.renderer.removeStyle(el, 'cursor'); this.renderer.removeStyle(el, 'will-change'); }; const finishDrag = () => { this.drag.active = false; this.drag.locked = false; this.drag.dy = 0; this.drag.vy = 0; this.renderer.removeStyle(el, 'cursor'); this.renderer.removeStyle(el, 'will-change'); }; this.unlisteners.push(downUn, moveUn, upUn, cancelUn); }); } softClose() { const exitAnimation = `hotToastExitSoftAnimation${this.top ? 'Negative' : 'Positive'} ${EXIT_ANIMATION_DURATION}ms forwards cubic-bezier(0.06, 0.71, 0.55, 1)`; const nativeElement = this.toastBarBase.nativeElement; animate(this.renderer, nativeElement, exitAnimation); this.softClosed = true; } softOpen() { const softEnterAnimation = `hotToastEnterSoftAnimation${top ? 'Negative' : 'Positive'} ${ENTER_ANIMATION_DURATION}ms cubic-bezier(0.21, 1.02, 0.73, 1) forwards`; const nativeElement = this.toastBarBase.nativeElement; animate(this.renderer, nativeElement, softEnterAnimation); this.softClosed = false; } close() { this.isManualClose = true; this.cdr.markForCheck(); const exitAnimation = `hotToastExitAnimation${this.top ? 'Negative' : 'Positive'} ${EXIT_ANIMATION_DURATION}ms forwards cubic-bezier(0.06, 0.71, 0.55, 1)`; this.toastBarBaseStylesSignal.set({ ...this.toast.style, animation: exitAnimation }); } handleMouseEnter() { this.showAllToasts.emit(true); } handleMouseLeave() { this.showAllToasts.emit(false); } ngOnDestroy() { this.close(); while (this.unlisteners.length) { this.unlisteners.pop()(); } } isExitAnimation(ev) { return ev.animationName.includes('hotToastExitAnimation'); } isEnterAnimation(ev) { return ev.animationName.includes('hotToastEnterAnimation'); } setToastAttributes() { const toastAttributes = this.toast.attributes; for (const [key, value] of Object.entries(toastAttributes)) { this.renderer.setAttribute(this.toastBarBase.nativeElement, key, value); } } get visibleToasts() { return this.groupChildrenToasts.filter((t) => t.visible); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HotToastGroupItemComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.2.3", type: HotToastGroupItemComponent, isStandalone: true, selector: "hot-toast-group-item", inputs: { toast: "toast", offset: "offset", defaultConfig: "defaultConfig", toastRef: "toastRef", toastsAfter: "toastsAfter", isShowingAllToasts: "isShowingAllToasts" }, outputs: { height: "height", beforeClosed: "beforeClosed", afterClosed: "afterClosed", showAllToasts: "showAllToasts", toggleGroup: "toggleGroup" }, viewQueries: [{ propertyName: "toastBarBase", first: true, predicate: ["hotToastBarBase"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: "<div\n class=\"hot-toast-bar-base-container\"\n [style]=\"containerPositionStyle\"\n [class]=\"'hot-toast-theme-' + toast.theme\"\n [style.--hot-toast-scale]=\"scale\"\n [style.--hot-toast-translate-y]=\"translateY\"\n>\n <div class=\"hot-toast-bar-base-wrapper\" (mouseenter)=\"handleMouseEnter()\" (mouseleave)=\"handleMouseLeave()\">\n <div\n class=\"hot-toast-bar-base\"\n #hotToastBarBase\n [style]=\"toastBarBaseStylesSignal()\"\n [class]=\"toast.className\"\n [style.--hot-toast-animation-state]=\"isManualClose ? 'running' : 'paused'\"\n [style.--hot-toast-exit-animation-state]=\"isShowingAllToasts ? 'paused' : 'running'\"\n [style.--hot-toast-exit-animation-delay]=\"exitAnimationDelay\"\n [attr.aria-live]=\"toast.ariaLive\"\n [attr.role]=\"toast.role\"\n >\n <div class=\"hot-toast-icon\" aria-hidden=\"true\">\n @if (toast.icon !== undefined) { @if (isIconString) {\n <hot-toast-animated-icon [iconTheme]=\"toast.iconTheme\">{{ toast.icon }}</hot-toast-animated-icon>\n } @else {\n <div>\n <ng-container *dynamicView=\"toast.icon\"></ng-container>\n </div>\n } } @else {\n <hot-toast-indicator [theme]=\"toast.iconTheme\" [type]=\"toast.type\"></hot-toast-indicator>\n }\n </div>\n <div class=\"hot-toast-message\">\n <ng-container *dynamicView=\"toast.message; context: context; injector: toastComponentInjector\"></ng-container>\n </div>\n @if (toast.dismissible) {\n <button\n (click)=\"close()\"\n type=\"button\"\n class=\"hot-toast-close-btn\"\n aria-label=\"Close\"\n [style]=\"toast.closeStyle\"\n ></button>\n }\n </div>\n </div>\n</div>\n", dependencies: [{ kind: "component", type: AnimatedIconComponent, selector: "hot-toast-animated-icon", inputs: ["iconTheme", "icon"] }, { kind: "component", type: IndicatorComponent, selector: "hot-toast-indicator", inputs: ["theme", "type"] }, { kind: "directive", type: DynamicViewDirective, selector: "[dynamicView]", inputs: ["dynamicView", "dynamicViewInjector", "dynamicViewContext", "dynamicViewInputs"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, preserveWhitespaces: true }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: HotToastGroupItemComponent, decorators: [{ type: Component, args: [{ selector: 'hot-toast-group-item', changeDetection: ChangeDetectionStrategy.OnPush, imports: [AnimatedIconComponent, IndicatorComponent, DynamicViewDirective], template: "<div\n class=\"hot-toast-bar-base-container\"\n [style]=\"containerPositionStyle\"\n [class]=\"'hot-toast-theme-' + toast.theme\"\n [style.--hot-toast-scale]=\"scale\"\n [style.--hot-toast-translate-y]=\"translateY\"\n>\n <div class=\"hot-toast-bar-base-wrapper\" (mouseenter)=\"handleMouseEnter()\" (mouseleave)=\"handleMouseLeave()\">\n <div\n class=\"hot-toast-bar-base\"\n #hotToastBarBase\n [style]=\"toastBarBaseStylesSignal()\"\n [class]=\"toast.className\"\n [style.--hot-toast-animation-state]=\"isManualClose ? 'running' : 'paused'\"\n [style.--hot-toast-exit-animation-state]=\"isShowingAllToasts ? 'paused' : 'running'\"\n [style.--hot-toast-exit-animation-delay]=\"exitAnimationDelay\"\n [attr.aria-live]=\"toast.ariaLive\"\n [attr.role]=\"toast.role\"\n >\n <div class=\"hot-toast-icon\" aria-hidden=\"true\">\n @if (toast.icon !== undefined) { @if (isIconString) {\n <hot-toast-animated-icon [iconTheme]=\"toast.iconTheme\">{{ toast.icon }}</hot-toast-animated-icon>\n } @else {\n <div>\n <ng-container *dynamicView=\"toast.icon\"></ng-container>\n </div>\n } } @else {\n <hot-toast-indicator [theme]=\"toast.iconTheme\" [type]=\"toast.type\"></hot-toast-indicator>\n }\n </div>\n <div class=\"hot-toast-message\">\n <ng-container *dynamicView=\"toast.message; context: context; injector: toastComponentInjector\"></ng-container>\n </div>\n @if (toast.dismissible) {\n <button\n (click)=\"close()\"\n type=\"button\"\n class=\"hot-toast-close-btn\"\n aria-label=\"Close\"\n [style]=\"toast.closeStyle\"\n ></button>\n }\n </div>\n </div>\n</div>\n" }] }], propDecorators: { toast: [{ type: Input }], offset: [{ type: Input }], defaultConfig: [{ type: Input }], toastRef: [{ type: Input }], toastsAfter: [{ type: Input }], isShowingAllToasts: [{ type: Input }], height: [{ type: Output }], beforeClosed: [{ type: Output }], afterClosed: [{ type: Output }], showAllToasts: [{ type: Output }], toggleGroup: [{ type: Output }], toastBarBase: [{ type: ViewChild, args: ['hotToastBarBase', { static: true }] }] } }); class HotToastComponent { constructor() { this.offset = 0; this._toastsAfter = 0; this.isShowingAllToasts = false; this.height = new EventEmitter(); this.beforeClosed = new EventEmitter(); this.afterClosed = new EventEmitter(); this.showAllToasts = new EventEmitter(); this.toggleGroup = new EventEmitter(); this.isManualClose = false; this.isExpanded = false; this.toastBarBaseStylesSignal = signal({}, ...(ngDevMode ? [{ debugName: "toastBarBaseStylesSignal" }] : [])); this.unlisteners = []; this.softClosed = false; this.groupRefs = []; this.injector = inject(Injector); this.renderer = inject(Renderer2); this.ngZone = inject(NgZone); this.cdr = inject(ChangeDetectorRef); // Swipe-to-dismiss implementation (vertical: bottom -> top) this.drag = { active: false, locked: false, startX: 0, startY: 0, lastY: 0, lastT: 0, vy: 0, dy: 0, }; } set toast(value) { this._toast = value; const ogStyle = this.toastBarBaseStylesSignal(); const newStyle = { ...value.style }; if (ogStyle['animation']?.includes('hotToastExitAnimation')) { // if toast is set for exit, we don't need want set the enter animation newStyle['animation'] = ogStyle['animation']; } else { const top = value.position.includes('top'); const enterAnimation = `hotToastEnterAnimation${top ? 'Negative' : 'Positive'} ${ENTER_ANIMATION_DURATION}ms cubic-bezier(0.21, 1.02, 0.73, 1) forwards`; newStyle['animation'] = enterAnimation; } this.toastBarBaseStylesSignal.set(newStyle); } get toast() { return this._toast; } get toastsAfter() { return this._toastsAfter; } set toastsAfter(value) { this._toastsAfter = value; if (this.defaultConfig?.visibleToasts > 0) { if (this.toast.autoClose) { // if (value >= this.defaultConfig?.visibleToasts) { // this.close(); // } } else { if (value >= this.defaultConfig?.visibleToasts) { this.softClose(); } else if (this.softClosed) { this.softOpen(); } } } } get toastBarBaseHeight() { return this.toastBarBase.nativeElement.offsetHeight; } get scale() { return this.defaultConfig.stacking !== 'vertical' && !this.isShowingAllToasts ? this.toastsAfter * -HOT_TOAST_DEPTH_SCALE + 1 : 1; } get translateY() { return this.offset * (this.top ? 1 : -1) + 'px'; } get exitAnimationDelay() { return this.toast.duration + 'ms'; } get top() { return this.toast.position.includes('top'); } get containerPositionStyle() { const verticalStyle = this.top ? { top: 0 } : { bottom: 0 }; const transform = `translateY(var(--hot-toast-translate-y)) scale(var(--hot-toast-scale))`; const horizontalStyle = this.toast.position.includes('left') ? { left: 0, } : this.toast.position.includes('right') ? { right: 0, } : { left: 0, right: 0, justifyContent: 'center', }; return { transform, ...verticalStyle, ...horizontalStyle, }; } get isIconString() { return typeof this.toast.icon === 'string'; } get groupChildrenToastRefs() { return this.groupRefs.filter((ref) => !!ref); } set groupChildrenToastRefs(value) { this.groupRefs = value; this.toastRef.groupRefs = value; } get groupChildrenToasts() { return this.groupChildrenToastRefs.map((ref) => ref.getToast()); } get groupHeight() { return this.visibleToasts .slice(-this.defaultConfig.visibleToasts) .map((t) => t.height) .reduce((prev, curr) => prev + curr, 0); } get visibleToasts() { return this.groupChildrenToasts.filter((t) => t.visible); } ngDoCheck() { if (this.toastRef.groupRefs.length !== this.groupRefs.length) { this.groupRefs = this.toastRef.groupRefs.slice(); this.cdr.markForCheck(); this.emiHeightWithGroup(this.isExpanded); } if (this.toastRef.groupExpanded !== this.isExpanded) { this.isExpanded = this.toastRef.groupExpanded; this.cdr.markForCheck(); this.emiHeightWithGroup(this.isExpanded); } } ngOnChanges(changes) { if (changes.toast && !changes.toast.firstChange && changes.toast.currentValue?.message) { this, this.emiHeightWithGroup(this.isExpanded); } } ngOnInit() { if (isTemplateRef(this.toast.message)) { this.context = { $implicit: this.toastRef }; } if (isComponent(this.toast.message)) { this.toastComponentInjector = Injector.create({ providers: [ { provide: HotToastRef, useValue: this.toastRef, }, ], parent: this.toast.injector || this.injector, }); } const nativeElement = this.toastBarBase.nativeElement; // Caretaker note: `animationstart` and `animationend` events are event tasks that trigger change detection. // We'd want to trigger the change detection only if it's an exit animation. this.ngZone.runOutsideAngular(() => { this.unlisteners.push( // Caretaker note: we have to remove these event listeners at the end (even if the element is removed from DOM). // zone.js stores its `ZoneTask`s within the `nativeElement[Zone.__symbol__('animationstart') + 'false']` property // with callback that capture `this`. this.renderer.listen(nativeElement, 'animationstart', (event) => { if (this.isExitAnimation(event)) { this.ngZone.run(() => { this.renderer.setStyle(nativeElement, 'pointer-events', 'none'); this.renderer.setStyle(nativeElement.parentElement, 'pointer-events', 'none'); this.beforeClosed.emit(); }); } }), this.renderer.listen(nativeElement, 'animationend', (event) => { if (this.isEnterAnimation(event)) { this.ngZone.run(() => { if (this.toast.autoClose) { const exitAnimation = `hotToastExitAnimation${this.top ? 'Negative' : 'Positive'} ${EXIT_ANIMATION_DURATION}ms forwards cubic-bezier(0.06, 0.71, 0.55, 1) var(--hot-toast-exit-animation-delay) var(--hot-toast-exit-animation-state)`; this.toastBarBaseStylesSignal.set({ ...this.toast.style, animation: exitAnimation }); } }); } if (this.isExitAnimation(event)) { this.ngZone.run(() => this.afterClosed.emit({ dismissedByAction: this.isManualClose, id: this.toast.id })); } })); }); } ngAfterViewInit() { const nativeElement = this.toastBarBase.nativeElement; // Caretaker note: accessing `offsetHeight` triggers the whole layout update. // Macro tasks (like `setTimeout`) might be executed within the current rendering frame and cause a frame drop. requestAnimationFrame(() => { this.height.emit(nativeElement.offsetHeight); }); this.setToastAttributes(); this.attachSwipeListeners(); } rubberband(x) { const a = 0.55; const ax = Math.abs(x); const eased = 1 - Math.pow(1 - Math.min(1, ax), a); return Math.sign(x) * eased; } decideVerticalDismiss(el, dy, vy) { const height = el.offsetHeight || 1; const distanceDismiss = dy < 0 && Math.abs(dy) > height * 0.35; // upward only const velocityDismiss = vy < 0 && Math.abs(vy) > 800; // px/s upward return distanceDismiss || velocityDismiss; } attachSwipeListeners() { const el = this.toastBarBase.nativeElement; this.ngZone.runOutsideAngular(() => { const downUn = this.renderer.listen(el, 'pointerdown', (e) => { if (e.button !== undefined && e.button !== 0) return; this.drag.active = true; this.drag.locked = false; this.drag.startX = e.clientX; this.drag.startY = e.clientY; this.drag.lastY = e.clientY; this.drag.lastT = performance.now(); el.setPointerCapture?.(e.pointerId); this.ngZone.run(() => this.showAllToasts.emit(true)); this.renderer.setStyle(el, 'will-change', 'transform, opacity'); this.renderer.setStyle(el, 'touch-action', 'pan-x'); this.renderer.setStyle(el, 'cursor', 'grabbing'); }); const moveUn = this.renderer.listen(el, 'pointermove', (e) => { if (!this.drag.active) return; const dxRaw = e.clientX - this.drag.startX; const dyRaw = e.clientY - this.drag.startY; if (!this.drag.locked) { const slop = 8; if (Math.abs(dxRaw) < slop && Math.abs(dyRaw) < slop) return; if (Math.abs(dyRaw) > Math.abs(dxRaw) && dyRaw < 0) { this.drag.locked = true; // commit to upward vertical gesture } else { cancelDrag(); return; } } const height = el.offsetHeight || 1; const eased = this.rubberband(dyRaw / height) * height; this.drag.dy = eased; const opacity = 1 - Math.min(1, Math.abs(eased) / height); this.renderer.setStyle(el, 'transform', `translate3d(0,${eased}px,0)`); this.renderer.setStyle(el, 'opacity', String(opacity)); const now = performance.now(); const dt = Math.max(1, now - this.drag.lastT); this.drag.vy = ((e.clientY - this.drag.lastY) / dt) * 1000; this.drag.lastY = e.clientY; this.drag.lastT = now; }); const upHandler = () => { if (!this.drag.active) return; const shouldDismiss = this.decideVerticalDismiss(el, this.drag.dy, this.drag.vy); if (shouldDismiss) { const height = el.offsetHeight || 1; const target = -1 * (window.innerHeight + height); // always upward const current = getComputedStyle(el); el .animate([ { transform: `translate3d(0,${this.drag.dy}px,0)`, opacity: Number(current.opacity) || 1 }, { transform: `translate3d(0,${target}px,0)`, opacity: 0 }, ], { duration: 220, easing: 'cubic-bezier(.22,.61,.36,1)' }) .finished.finally(() => { this.ngZone.run(() => { this.beforeClosed.emit(); this.afterClosed.emit({ dismissedByAction: true, id: this.toast.id }); }); navigator.vibrate?.(10); }); } else { const current = getComputedStyle(el); el .animate([ { transform: current.transform, opacity: Number(current.opacity) }, { transform: 'translate3d(0,0,0)', opacity: 1 }, ], { duration: 240, easing: 'cubic-bezier(.17,.89,.32,1.27)' }) .finished.finally(() => { this.renderer.removeStyle(el, 'transform'); this.renderer.removeStyle(el, 'opacity'); this.ngZone.run(() => this.showAllToasts.emit(false)); }); } finishDrag(); }; const upUn = this.renderer.listen(el, 'pointerup', upHandler); const cancelUn = this.renderer.listen(el, 'pointercancel', upHandler); const cancelDrag = () => { this.drag.active = false; this.drag.locked = false; this.ngZone.run(() => this.showAllToasts.emit(false)); this.renderer.removeStyle(el, 'cursor'); this.renderer.removeStyle(el, 'will-change'); }; const finishDrag = () => { this.drag.active = false; this.drag.locked = false; this.drag.dy = 0; this.drag.vy = 0; this.renderer.removeStyle(el, 'cursor'); this.renderer.removeStyle(el, 'will-change'); }; this.unlisteners.push(downUn, moveUn, upUn, cancelUn); }); } softClose() { const exitAnimation = `hotToastExitSoftAnimation${this.top ? 'Negative' : 'Positive'} ${EXIT_ANIMATION_DURATION}ms forwards cubic-bezier(0.06, 0.71, 0.55, 1)`; const nativeElement = this.toastBarBase.nativeElement; animate(this.renderer, nativeElement, exitAnimation); this.softClosed = true; if (this.isExpanded) { this.toggleToastGroup(); } } softOpen() { const softEnterAnimation = `hotToastEnterSoftAnimation${top ? 'Negative' : 'Positive'} ${ENTER_ANIMATION_DURATION}ms cubic-bezier(0.21, 1.02, 0.73, 1) forwards`; const nativeElement = this.toastBarBase.nativeElement; animate(this.renderer, nativeElement, softEnterAnimation); this.softClosed = false; } close() { this.isManualClose = true; this.cdr.markForCheck(); const exitAnimation = `hotToastExitAnimation${this.top ? 'Negative' : 'Positive'} ${EXIT_ANIMATION_DURATION}ms forwards cubic-bezier(0.06, 0.71, 0.55, 1)`; this.toastBarBaseStylesSignal.set({ ...this.toast.style, animation: exitAnimation }); } handleMouseEnter() { this.showAllToasts.emit(true); } handleMouseLeave() { this.showAllToasts.emit(false); } ngOnDestroy() { this.close(); while (this.unlisteners.length) { this.unlisteners.pop()(); } } isExitAnimation(ev) { return ev.animationName.includes('hotToastExitAnimation'); } isEnterAnimation(ev) { return ev.animationName.includes('hotToastEnterAnimation'); } setToastAttributes() { const toastAttributes = this.toast.attributes; for (const [key, value] of Object.entries(toastAttributes)) { this.renderer.setAttribute(this.toastBarBase.nativeElement, key, value); } } calculateOffset(toastId) { const visibl