hot-toast-bokzor
Version:
Smoking hot Notifications for Angular. Lightweight, customizable and beautiful by default.
902 lines (890 loc) • 91 kB
JavaScript
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