UNPKG

ngx-sonner

Version:

An opinionated toast component for Angular.

1,139 lines (1,126 loc) 85 kB
import * as i0 from '@angular/core'; import { signal, input, Component, ChangeDetectionStrategy, booleanAttribute, Pipe, viewChild, computed, linkedSignal, effect, afterRenderEffect, inject, PLATFORM_ID, numberAttribute, ViewEncapsulation } from '@angular/core'; import { NgComponentOutlet, isPlatformBrowser } from '@angular/common'; let toastsCounter = 0; function createToastState() { const toasts = signal([]); const heights = signal([]); function addToast(data) { toasts.update(prev => [data, ...prev]); } function create(data) { const { message, ...rest } = data; const id = typeof data?.id === 'number' || (data.id && data.id?.length > 0) ? data.id : toastsCounter++; const dismissible = data.dismissible ?? true; const type = data.type ?? 'default'; const alreadyExists = toasts().find(toast => toast.id === id); if (alreadyExists) { toasts.update(prev => prev.map(toast => { if (toast.id === id) { return { ...toast, ...data, id, title: message, dismissible, type, updated: true, }; } else return { ...toast, updated: false }; })); } else { addToast({ ...rest, id, title: message, dismissible: dismissible, type }); } return id; } function dismiss(id) { if (id === undefined) { toasts.set([]); return; } toasts.update(prev => prev.filter(toast => toast.id !== id)); return id; } function message(message, data) { return create({ ...data, type: 'default', message }); } function error(message, data) { return create({ ...data, type: 'error', message }); } function success(message, data) { return create({ ...data, type: 'success', message }); } function info(message, data) { return create({ ...data, type: 'info', message }); } function warning(message, data) { return create({ ...data, type: 'warning', message }); } function loading(message, data) { return create({ ...data, type: 'loading', message }); } function promise(promise, data) { if (!data) return; let id = undefined; if (data.loading !== undefined) { id = create({ ...data, promise, type: 'loading', message: data.loading, }); } const p = promise instanceof Promise ? promise : promise(); let shouldDismiss = id !== undefined; p.then(response => { // @ts-expect-error: Incorrect response type if (response && typeof response.ok === 'boolean' && !response.ok) { shouldDismiss = false; const message = typeof data.error === 'function' ? // @ts-expect-error: TODO: Better function checking data.error(`HTTP error! status: ${response.status}`) : data.error; create({ id, type: 'error', message }); } else if (data.success !== undefined) { shouldDismiss = false; const message = typeof data.success === 'function' ? // @ts-expect-error: TODO: Better function checking data.success(response) : data.success; create({ id, type: 'success', message }); } }) .catch(error => { if (data.error !== undefined) { shouldDismiss = false; const message = // @ts-expect-error: TODO: Better function checking typeof data.error === 'function' ? data.error(error) : data.error; create({ id, type: 'error', message }); } }) .finally(() => { if (shouldDismiss) { // Toast is still in load state (and will be indefinitely — dismiss it) dismiss(id); id = undefined; } data.finally?.(); }); return id; } function custom(component, data) { const id = data?.id ?? toastsCounter++; create({ component, id, ...data }); return id; } function removeHeight(id) { heights.update(prev => prev.filter(height => height.toastId !== id)); } function addHeight(height) { heights.update(prev => [height, ...prev].sort(sortHeights)); } const sortHeights = (a, b) => toasts().findIndex(t => t.id === a.toastId) - toasts().findIndex(t => t.id === b.toastId); function reset() { toasts.set([]); heights.set([]); } return { //methods create, addToast, dismiss, message, error, success, info, warning, loading, promise, custom, removeHeight, addHeight, reset, // signals toasts: toasts.asReadonly(), heights: heights.asReadonly(), }; } const toastState = createToastState(); // bind this to the toast function function toastFunction(message, data) { return toastState.create({ message, ...data, }); } const basicToast = toastFunction; const toast = Object.assign(basicToast, { success: toastState.success, info: toastState.info, warning: toastState.warning, error: toastState.error, custom: toastState.custom, message: toastState.message, promise: toastState.promise, dismiss: toastState.dismiss, loading: toastState.loading, }); class IconComponent { constructor() { this.type = input('default'); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: IconComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.0.3", type: IconComponent, isStandalone: true, selector: "ngx-sonner-icon", inputs: { type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: ` @switch (type()) { @case ('success') { <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /> </svg> } @case ('error') { <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> </svg> } @case ('info') { <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /> </svg> } @case ('warning') { <svg viewBox="0 0 64 64" fill="currentColor" height="20" width="20" xmlns="http://www.w3.org/2000/svg"> <path d="M32.427,7.987c2.183,0.124 4,1.165 5.096,3.281l17.936,36.208c1.739,3.66 -0.954,8.585 -5.373,8.656l-36.119,0c-4.022,-0.064 -7.322,-4.631 -5.352,-8.696l18.271,-36.207c0.342,-0.65 0.498,-0.838 0.793,-1.179c1.186,-1.375 2.483,-2.111 4.748,-2.063Zm-0.295,3.997c-0.687,0.034 -1.316,0.419 -1.659,1.017c-6.312,11.979 -12.397,24.081 -18.301,36.267c-0.546,1.225 0.391,2.797 1.762,2.863c12.06,0.195 24.125,0.195 36.185,0c1.325,-0.064 2.321,-1.584 1.769,-2.85c-5.793,-12.184 -11.765,-24.286 -17.966,-36.267c-0.366,-0.651 -0.903,-1.042 -1.79,-1.03Z" /> <path d="M33.631,40.581l-3.348,0l-0.368,-16.449l4.1,0l-0.384,16.449Zm-3.828,5.03c0,-0.609 0.197,-1.113 0.592,-1.514c0.396,-0.4 0.935,-0.601 1.618,-0.601c0.684,0 1.223,0.201 1.618,0.601c0.395,0.401 0.593,0.905 0.593,1.514c0,0.587 -0.193,1.078 -0.577,1.473c-0.385,0.395 -0.929,0.593 -1.634,0.593c-0.705,0 -1.249,-0.198 -1.634,-0.593c-0.384,-0.395 -0.576,-0.886 -0.576,-1.473Z" /> </svg> } } `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: IconComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-sonner-icon', template: ` @switch (type()) { @case ('success') { <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /> </svg> } @case ('error') { <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> </svg> } @case ('info') { <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" height="20" width="20"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /> </svg> } @case ('warning') { <svg viewBox="0 0 64 64" fill="currentColor" height="20" width="20" xmlns="http://www.w3.org/2000/svg"> <path d="M32.427,7.987c2.183,0.124 4,1.165 5.096,3.281l17.936,36.208c1.739,3.66 -0.954,8.585 -5.373,8.656l-36.119,0c-4.022,-0.064 -7.322,-4.631 -5.352,-8.696l18.271,-36.207c0.342,-0.65 0.498,-0.838 0.793,-1.179c1.186,-1.375 2.483,-2.111 4.748,-2.063Zm-0.295,3.997c-0.687,0.034 -1.316,0.419 -1.659,1.017c-6.312,11.979 -12.397,24.081 -18.301,36.267c-0.546,1.225 0.391,2.797 1.762,2.863c12.06,0.195 24.125,0.195 36.185,0c1.325,-0.064 2.321,-1.584 1.769,-2.85c-5.793,-12.184 -11.765,-24.286 -17.966,-36.267c-0.366,-0.651 -0.903,-1.042 -1.79,-1.03Z" /> <path d="M33.631,40.581l-3.348,0l-0.368,-16.449l4.1,0l-0.384,16.449Zm-3.828,5.03c0,-0.609 0.197,-1.113 0.592,-1.514c0.396,-0.4 0.935,-0.601 1.618,-0.601c0.684,0 1.223,0.201 1.618,0.601c0.395,0.401 0.593,0.905 0.593,1.514c0,0.587 -0.193,1.078 -0.577,1.473c-0.385,0.395 -0.929,0.593 -1.634,0.593c-0.705,0 -1.249,-0.198 -1.634,-0.593c-0.384,-0.395 -0.576,-0.886 -0.576,-1.473Z" /> </svg> } } `, changeDetection: ChangeDetectionStrategy.OnPush, }] }] }); // Visible toasts amount const VISIBLE_TOASTS_AMOUNT = 3; // Viewport padding const VIEWPORT_OFFSET = '32px'; // Default lifetime of a toasts (in ms) const TOAST_LIFETIME = 4000; // Default toast width const TOAST_WIDTH = 356; // Default gap between toasts const GAP = 14; // Threshold to dismiss a toast const SWIPE_THRESHOLD = 20; // Equal to exit animation duration const TIME_BEFORE_UNMOUNT = 200; const defaultClasses = { toast: '', title: '', description: '', loader: '', closeButton: '', cancelButton: '', actionButton: '', action: '', warning: '', error: '', success: '', default: '', info: '', loading: '', }; class LoaderComponent { constructor() { this.isVisible = input.required({ transform: booleanAttribute }); this.bars = Array(12).fill(0); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: LoaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.0.3", type: LoaderComponent, isStandalone: true, selector: "ngx-sonner-loader", inputs: { isVisible: { classPropertyName: "isVisible", publicName: "isVisible", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: ` <div class="sonner-loading-wrapper" [attr.data-visible]="isVisible()"> <div class="sonner-spinner"> @for (_ of bars; track $index) { <div class="sonner-loading-bar"></div> } </div> </div> `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: LoaderComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-sonner-loader', template: ` <div class="sonner-loading-wrapper" [attr.data-visible]="isVisible()"> <div class="sonner-spinner"> @for (_ of bars; track $index) { <div class="sonner-loading-bar"></div> } </div> </div> `, changeDetection: ChangeDetectionStrategy.OnPush, }] }] }); class ToastFilterPipe { transform(toasts, index, position) { return toasts.filter(toast => (!toast.position && index === 0) || toast.position === position); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: ToastFilterPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "19.0.3", ngImport: i0, type: ToastFilterPipe, isStandalone: true, name: "toastFilter" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: ToastFilterPipe, decorators: [{ type: Pipe, args: [{ name: 'toastFilter' }] }] }); function cn(...classes) { return classes.filter(Boolean).join(' '); } class AsComponentPipe { transform(value) { return value; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: AsComponentPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "19.0.3", ngImport: i0, type: AsComponentPipe, isStandalone: true, name: "asComponent" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: AsComponentPipe, decorators: [{ type: Pipe, args: [{ name: 'asComponent' }] }] }); class IsStringPipe { transform(value) { return typeof value === 'string'; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: IsStringPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "19.0.3", ngImport: i0, type: IsStringPipe, isStandalone: true, name: "isString" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: IsStringPipe, decorators: [{ type: Pipe, args: [{ name: 'isString' }] }] }); class ToastComponent { constructor() { this.cn = cn; this.toasts = toastState.toasts; this.heights = toastState.heights; this.removeHeight = toastState.removeHeight; this.addHeight = toastState.addHeight; this.dismiss = toastState.dismiss; this.toast = input.required(); this.index = input.required(); this.expanded = input.required(); this._invert = input.required({ alias: 'invert' }); this.position = input.required(); this.visibleToasts = input.required(); this.expandByDefault = input.required(); this._closeButton = input.required({ alias: 'closeButton', }); this.interacting = input.required(); this.cancelButtonStyle = input(); this.actionButtonStyle = input(); this.duration = input(TOAST_LIFETIME); this.descriptionClass = input(''); this._classes = input({}, { alias: 'classes' }); this.unstyled = input(false); this._class = input('', { alias: 'class' }); this._style = input({}, { alias: 'style' }); this.mounted = signal(false); this.removed = signal(false); this.swiping = signal(false); this.swipeOut = signal(false); this.offsetBeforeRemove = signal(0); this.initialHeight = signal(0); this.toastRef = viewChild.required('toastRef'); this.classes = computed(() => ({ ...defaultClasses, ...this._classes(), })); this.isFront = computed(() => this.index() === 0); this.isVisible = computed(() => this.index() + 1 <= this.visibleToasts()); this.toastType = computed(() => this.toast().type ?? 'default'); this.toastClass = computed(() => this.toast().class ?? ''); this.toastPosition = computed(() => this.toast().position ?? this.position()); this.toastDescriptionClass = computed(() => this.toast().descriptionClass ?? ''); this.heightIndex = computed(() => this.heights().findIndex(height => height.toastId === this.toast().id)); this.offset = linkedSignal({ source: () => ({ heightIndex: this.heightIndex(), toastsHeightBefore: this.toastsHeightBefore(), }), computation: ({ heightIndex, toastsHeightBefore }) => Math.round(heightIndex * GAP + toastsHeightBefore), }); this.closeTimerStartTimeRef = 0; this.lastCloseTimerStartTimeRef = 0; this.pointerStartRef = null; this.coords = computed(() => this.toastPosition().split('-')); this.toastsHeightBefore = computed(() => this.heights().reduce((prev, curr, reducerIndex) => { if (reducerIndex >= this.heightIndex()) return prev; return prev + curr.height; }, 0)); this.invert = computed(() => this.toast().invert ?? this._invert()); this.closeButton = computed(() => this.toast().closeButton ?? this._closeButton()); this.disabled = computed(() => this.toastType() === 'loading'); this.remainingTime = 0; this.isPromiseLoadingOrInfiniteDuration = computed(() => (this.toast().promise && this.toastType() === 'loading') || this.toast().duration === Number.POSITIVE_INFINITY); this.toastClasses = computed(() => cn(this._class(), this.toastClass(), this.classes().toast, this.toast().classes?.toast, this.classes()[this.toastType()], this.toast().classes?.[this.toastType()])); this.toastStyle = computed(() => ({ '--index': `${this.index()}`, '--toasts-before': `${this.index()}`, '--z-index': `${this.toasts().length - this.index()}`, '--offset': `${this.removed() ? this.offsetBeforeRemove() : this.offset()}px`, '--initial-height': this.expandByDefault() ? 'auto' : `${this.initialHeight()}px`, ...this._style(), })); effect(() => { if (this.toast().updated) { // if the toast has been updated after the initial render, // we want to reset the timer and set the remaining time to the // new duration clearTimeout(this.timeoutId); this.remainingTime = this.toast().duration ?? this.duration() ?? TOAST_LIFETIME; this.startTimer(); } }); afterRenderEffect(onCleanup => { if (!this.isPromiseLoadingOrInfiniteDuration()) { if (this.expanded() || this.interacting()) { this.pauseTimer(); } else { this.startTimer(); } } onCleanup(() => clearTimeout(this.timeoutId)); }); effect(() => { if (this.toast().delete) { this.deleteToast(); } }); } ngAfterViewInit() { this.remainingTime = this.toast().duration ?? this.duration() ?? TOAST_LIFETIME; this.mounted.set(true); const height = this.toastRef().nativeElement.getBoundingClientRect().height; this.initialHeight.set(height); this.addHeight({ toastId: this.toast().id, height }); } ngOnDestroy() { clearTimeout(this.timeoutId); this.removeHeight(this.toast().id); } deleteToast() { this.removed.set(true); this.offsetBeforeRemove.set(this.offset()); this.removeHeight(this.toast().id); setTimeout(() => { this.dismiss(this.toast().id); }, TIME_BEFORE_UNMOUNT); } // If toast's duration changes, it will be out of sync with the // remainingAtTimeout, so we know we need to restart the timer // with the new duration // Pause the timer on each hover pauseTimer() { if (this.lastCloseTimerStartTimeRef < this.closeTimerStartTimeRef) { // Get the elapsed time since the timer started const elapsedTime = new Date().getTime() - this.closeTimerStartTimeRef; this.remainingTime = this.remainingTime - elapsedTime; } this.lastCloseTimerStartTimeRef = new Date().getTime(); } startTimer() { this.closeTimerStartTimeRef = new Date().getTime(); // Let the toast know it has started this.timeoutId = setTimeout(() => { this.toast().onAutoClose?.(this.toast()); this.deleteToast(); }, this.remainingTime); } onPointerDown(event) { if (this.disabled() || !this.toast().dismissible) return; this.offsetBeforeRemove.set(this.offset()); const target = event.target; // Ensure we maintain correct pointer capture even when going outside the toast (e.g. when swiping) target.setPointerCapture(event.pointerId); if (target.tagName === 'BUTTON') { return; } this.swiping.set(true); this.pointerStartRef = { x: event.clientX, y: event.clientY }; } onPointerUp() { if (this.swipeOut() || !this.toast().dismissible) return; this.pointerStartRef = null; const swipeAmount = Number(this.toastRef() .nativeElement.style.getPropertyValue('--swipe-amount') .replace('px', '') || 0); // Remove only if threshold is met if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD) { this.offsetBeforeRemove.set(this.offset()); this.toast().onDismiss?.(this.toast()); this.deleteToast(); this.swipeOut.set(true); return; } this.toastRef().nativeElement.style.setProperty('--swipe-amount', '0px'); this.swiping.set(false); } onPointerMove(event) { if (!this.pointerStartRef || !this.toast().dismissible) return; const yPosition = event.clientY - this.pointerStartRef.y; const xPosition = event.clientX - this.pointerStartRef.x; const clamp = this.coords()[0] === 'top' ? Math.min : Math.max; const clampedY = clamp(0, yPosition); const swipeStartThreshold = event.pointerType === 'touch' ? 10 : 2; const isAllowedToSwipe = Math.abs(clampedY) > swipeStartThreshold; if (isAllowedToSwipe) { this.toastRef().nativeElement.style.setProperty('--swipe-amount', `${yPosition}px`); } else if (Math.abs(xPosition) > swipeStartThreshold) { // User is swiping in wrong direction, so we disable swipe gesture // for the current pointer down interaction this.pointerStartRef = null; } } onCloseButtonClick() { if (this.disabled() || !this.toast().dismissible) return; this.deleteToast(); this.toast().onDismiss?.(this.toast()); } onCancelClick() { const toast = this.toast(); if (!toast.dismissible) return; this.deleteToast(); if (toast.cancel?.onClick) { toast.cancel.onClick(); } } onActionClick(event) { const toast = this.toast(); toast.action?.onClick(event); if (event.defaultPrevented) return; this.deleteToast(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: ToastComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.0.3", type: ToastComponent, isStandalone: true, selector: "ngx-sonner-toast", inputs: { toast: { classPropertyName: "toast", publicName: "toast", isSignal: true, isRequired: true, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: true, transformFunction: null }, expanded: { classPropertyName: "expanded", publicName: "expanded", isSignal: true, isRequired: true, transformFunction: null }, _invert: { classPropertyName: "_invert", publicName: "invert", isSignal: true, isRequired: true, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: true, transformFunction: null }, visibleToasts: { classPropertyName: "visibleToasts", publicName: "visibleToasts", isSignal: true, isRequired: true, transformFunction: null }, expandByDefault: { classPropertyName: "expandByDefault", publicName: "expandByDefault", isSignal: true, isRequired: true, transformFunction: null }, _closeButton: { classPropertyName: "_closeButton", publicName: "closeButton", isSignal: true, isRequired: true, transformFunction: null }, interacting: { classPropertyName: "interacting", publicName: "interacting", isSignal: true, isRequired: true, transformFunction: null }, cancelButtonStyle: { classPropertyName: "cancelButtonStyle", publicName: "cancelButtonStyle", isSignal: true, isRequired: false, transformFunction: null }, actionButtonStyle: { classPropertyName: "actionButtonStyle", publicName: "actionButtonStyle", isSignal: true, isRequired: false, transformFunction: null }, duration: { classPropertyName: "duration", publicName: "duration", isSignal: true, isRequired: false, transformFunction: null }, descriptionClass: { classPropertyName: "descriptionClass", publicName: "descriptionClass", isSignal: true, isRequired: false, transformFunction: null }, _classes: { classPropertyName: "_classes", publicName: "classes", isSignal: true, isRequired: false, transformFunction: null }, unstyled: { classPropertyName: "unstyled", publicName: "unstyled", isSignal: true, isRequired: false, transformFunction: null }, _class: { classPropertyName: "_class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, _style: { classPropertyName: "_style", publicName: "style", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "toastRef", first: true, predicate: ["toastRef"], descendants: true, isSignal: true }], ngImport: i0, template: ` <li #toastRef data-sonner-toast [attr.aria-live]="toast().important ? 'assertive' : 'polite'" aria-atomic="true" role="status" tabindex="0" [class]="toastClasses()" [attr.data-styled]=" !(toast().component || toast().unstyled || unstyled()) " [attr.data-mounted]="mounted()" [attr.data-promise]="!!toast().promise" [attr.data-removed]="removed()" [attr.data-visible]="isVisible()" [attr.data-y-position]="coords()[0]" [attr.data-x-position]="coords()[1]" [attr.data-index]="index()" [attr.data-front]="isFront()" [attr.data-swiping]="swiping()" [attr.data-dismissible]="toast().dismissible" [attr.data-type]="toastType()" [attr.data-invert]="invert()" [attr.data-swipe-out]="swipeOut()" [attr.data-expanded]="expanded() || (expandByDefault() && mounted())" [style]="toastStyle()" (pointerdown)="onPointerDown($event)" (pointerup)="onPointerUp()" (pointermove)="onPointerMove($event)"> @if (closeButton() && !toast().component) { <button aria-label="Close toast" [attr.data-disabled]="disabled()" data-close-button (click)="onCloseButtonClick()" [class]="cn(classes().closeButton, toast().classes?.closeButton)"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> </button> } @if (toast().component) { <ng-container *ngComponentOutlet=" toast().component | asComponent; inputs: toast().componentProps " /> } @else { @if (toastType() !== 'default' || toast().icon || toast().promise) { <div data-icon> @if (toastType() === 'loading' && !toast().icon) { <ng-content select="[loading-icon]" /> } @if (toast().icon) { <ng-container *ngComponentOutlet=" toast().icon | asComponent; inputs: toast().componentProps " /> } @else { @switch (toastType()) { @case ('success') { <ng-content select="[success-icon]" /> } @case ('error') { <ng-content select="[error-icon]" /> } @case ('warning') { <ng-content select="[warning-icon]" /> } @case ('info') { <ng-content select="[info-icon]" /> } } } </div> } <div data-content> @if (toast().title; as title) { <div data-title [class]="cn(classes().title, toast().classes?.title)"> @if (title | isString) { {{ toast().title }} } @else { <ng-container *ngComponentOutlet=" title | asComponent; inputs: toast().componentProps " /> } </div> } @if (toast().description; as description) { <div data-description [class]=" cn( descriptionClass(), toastDescriptionClass(), classes().description, toast().classes?.description ) "> @if (description | isString) { {{ toast().description }} } @else { <ng-container *ngComponentOutlet=" description | asComponent; inputs: toast().componentProps " /> } </div> } </div> @if (toast().cancel; as cancel) { <button data-button data-cancel [style]="cancelButtonStyle() ?? toast().cancelButtonStyle" [class]="cn(classes().cancelButton, toast().classes?.cancelButton)" (click)="onCancelClick()"> {{ cancel.label }} </button> } @if (toast().action; as action) { <button data-button [style]="actionButtonStyle() ?? toast().actionButtonStyle" [class]="cn(classes().actionButton, toast().classes?.actionButton)" (click)="onActionClick($event)"> {{ action.label }} </button> } } </li> `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }, { kind: "pipe", type: IsStringPipe, name: "isString" }, { kind: "pipe", type: AsComponentPipe, name: "asComponent" }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: ToastComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-sonner-toast', imports: [NgComponentOutlet, IsStringPipe, AsComponentPipe], template: ` <li #toastRef data-sonner-toast [attr.aria-live]="toast().important ? 'assertive' : 'polite'" aria-atomic="true" role="status" tabindex="0" [class]="toastClasses()" [attr.data-styled]=" !(toast().component || toast().unstyled || unstyled()) " [attr.data-mounted]="mounted()" [attr.data-promise]="!!toast().promise" [attr.data-removed]="removed()" [attr.data-visible]="isVisible()" [attr.data-y-position]="coords()[0]" [attr.data-x-position]="coords()[1]" [attr.data-index]="index()" [attr.data-front]="isFront()" [attr.data-swiping]="swiping()" [attr.data-dismissible]="toast().dismissible" [attr.data-type]="toastType()" [attr.data-invert]="invert()" [attr.data-swipe-out]="swipeOut()" [attr.data-expanded]="expanded() || (expandByDefault() && mounted())" [style]="toastStyle()" (pointerdown)="onPointerDown($event)" (pointerup)="onPointerUp()" (pointermove)="onPointerMove($event)"> @if (closeButton() && !toast().component) { <button aria-label="Close toast" [attr.data-disabled]="disabled()" data-close-button (click)="onCloseButtonClick()" [class]="cn(classes().closeButton, toast().classes?.closeButton)"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> </button> } @if (toast().component) { <ng-container *ngComponentOutlet=" toast().component | asComponent; inputs: toast().componentProps " /> } @else { @if (toastType() !== 'default' || toast().icon || toast().promise) { <div data-icon> @if (toastType() === 'loading' && !toast().icon) { <ng-content select="[loading-icon]" /> } @if (toast().icon) { <ng-container *ngComponentOutlet=" toast().icon | asComponent; inputs: toast().componentProps " /> } @else { @switch (toastType()) { @case ('success') { <ng-content select="[success-icon]" /> } @case ('error') { <ng-content select="[error-icon]" /> } @case ('warning') { <ng-content select="[warning-icon]" /> } @case ('info') { <ng-content select="[info-icon]" /> } } } </div> } <div data-content> @if (toast().title; as title) { <div data-title [class]="cn(classes().title, toast().classes?.title)"> @if (title | isString) { {{ toast().title }} } @else { <ng-container *ngComponentOutlet=" title | asComponent; inputs: toast().componentProps " /> } </div> } @if (toast().description; as description) { <div data-description [class]=" cn( descriptionClass(), toastDescriptionClass(), classes().description, toast().classes?.description ) "> @if (description | isString) { {{ toast().description }} } @else { <ng-container *ngComponentOutlet=" description | asComponent; inputs: toast().componentProps " /> } </div> } </div> @if (toast().cancel; as cancel) { <button data-button data-cancel [style]="cancelButtonStyle() ?? toast().cancelButtonStyle" [class]="cn(classes().cancelButton, toast().classes?.cancelButton)" (click)="onCancelClick()"> {{ cancel.label }} </button> } @if (toast().action; as action) { <button data-button [style]="actionButtonStyle() ?? toast().actionButtonStyle" [class]="cn(classes().actionButton, toast().classes?.actionButton)" (click)="onActionClick($event)"> {{ action.label }} </button> } } </li> `, changeDetection: ChangeDetectionStrategy.OnPush, }] }], ctorParameters: () => [] }); // eslint-disable-next-line @angular-eslint/component-class-suffix class NgxSonnerToaster { constructor() { this.platformId = inject(PLATFORM_ID); this.toasts = toastState.toasts; this.heights = toastState.heights; this.reset = toastState.reset; this.invert = input(false, { transform: booleanAttribute, }); this.theme = input('light'); this.position = input('bottom-right'); this.hotKey = input(['altKey', 'KeyT']); this.richColors = input(false, { transform: booleanAttribute, }); this.expand = input(false, { transform: booleanAttribute, }); this.duration = input(TOAST_LIFETIME, { transform: numberAttribute, }); this.visibleToasts = input(VISIBLE_TOASTS_AMOUNT, { transform: numberAttribute }); this.closeButton = input(false, { transform: booleanAttribute, }); this.toastOptions = input({}); this.offset = input(null); this.dir = input(this.getDocumentDirection()); this._class = input('', { alias: 'class' }); this._style = input({}, { alias: 'style' }); this.possiblePositions = computed(() => Array.from(new Set([ this.position(), ...this.toasts() .filter(toast => toast.position) .map(toast => toast.position), ].filter(Boolean)))); this.expanded = linkedSignal({ source: this.toasts, computation: toasts => toasts.length < 1, }); this.actualTheme = linkedSignal({ source: this.theme, computation: newTheme => this.getActualTheme(newTheme), }); this.interacting = signal(false); this.listRef = viewChild('listRef'); this.lastFocusedElementRef = signal(null); this.isFocusWithinRef = signal(false); this.hotKeyLabel = computed(() => this.hotKey().join('+').replace(/Key/g, '').replace(/Digit/g, '')); this.toasterStyles = computed(() => ({ '--front-toast-height': `${this.heights()[0]?.height}px`, '--offset': typeof this.offset() === 'number' ? `${this.offset()}px` : this.offset() ?? `${VIEWPORT_OFFSET}`, '--width': `${TOAST_WIDTH}px`, '--gap': `${GAP}px`, ...this._style(), })); this.handleKeydown = (event) => { const listRef = this.listRef()?.nativeElement; if (!listRef) return; const isHotkeyPressed = this.hotKey().every(key => event[key] || event.code === key); if (isHotkeyPressed) { this.expanded.set(true); listRef.focus(); } if (event.code === 'Escape' && (document.activeElement === listRef || listRef.contains(document.activeElement))) { this.expanded.set(false); } }; this.handleThemePreferenceChange = ({ matches }) => { if (this.theme() === 'system') { this.actualTheme.set(matches ? 'dark' : 'light'); } }; this.reset(); if (isPlatformBrowser(this.platformId)) { document.addEventListener('keydown', this.handleKeydown); window .matchMedia('(prefers-color-scheme: dark)') .addEventListener('change', this.handleThemePreferenceChange); } } ngOnDestroy() { if (isPlatformBrowser(this.platformId)) { document.removeEventListener('keydown', this.handleKeydown); window .matchMedia('(prefers-color-scheme: dark)') .removeEventListener('change', this.handleThemePreferenceChange); } } handleBlur(event) { if (this.isFocusWithinRef() && !event.target.contains(event.relatedTarget)) { this.isFocusWithinRef.set(false); if (this.lastFocusedElementRef()) { this.lastFocusedElementRef()?.focus({ preventScroll: true }); this.lastFocusedElementRef.set(null); } } } handleFocus(event) { const isNotDismissible = event.target instanceof HTMLElement && event.target.dataset['dismissible'] === 'false'; if (isNotDismissible) return; if (!this.isFocusWithinRef()) { this.isFocusWithinRef.set(true); this.lastFocusedElementRef.set(event.relatedTarget); } } handlePointerDown(event) { const isNotDismissible = event.target instanceof HTMLElement && event.target.dataset['dismissible'] === 'false'; if (isNotDismissible) return; this.interacting.set(true); } handleMouseLeave() { if (!this.interacting()) { this.expanded.set(false); } } getActualTheme(theme) { if (theme !== 'system') { return theme; } if (isPlatformBrowser(this.platformId)) { const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches; return prefersDark ? 'dark' : 'light'; } return 'light'; } getDocumentDirection() { if (typeof window === 'undefined' || typeof document === 'undefined') { return 'ltr'; } const dirAttribute = document.documentElement.getAttribute('dir'); if (!dirAttribute || dirAttribute === 'auto') { return window.getComputedStyle(document.documentElement) .direction; } return dirAttribute; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.3", ngImport: i0, type: NgxSonnerToaster, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.0.3", type: NgxSonnerToaster, isStandalone: true, selector: "ngx-sonner-toaster", inputs: { invert: { classPropertyName: "invert", publicName: "invert", isSignal: true, isRequired: false, transformFunction: null }, theme: { classPropertyName: "theme", publicName: "theme", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, hotKey: { classPropertyName: "hotKey", publicName: "hotKey", isSignal: true, isRequired: false, transformFunction: null }, richColors: { classPropertyName: "richColors", publicName: "richColors", isSignal: true, isRequired: false, transformFunction: null }, expand: { classPropertyName: "expand", publicName: "expand", isSignal: true, isRequired: false, transformFunction: null }, duration: { classPropertyName: "duration", publicName: "duration", isSignal: true, isRequired: false, transformFunction: null }, visibleToasts: { classPropertyName: "visibleToasts", publicName: "visibleToasts", isSignal: true, isRequired: false, transformFunction: null }, closeButton: { classPropertyName: "closeButton", publicName: "closeButton", isSignal: true, isRequired: false, transformFunction: null }, toastOptions: { classPropertyName: "toastOptions", publicName: "toastOptions", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null }, dir: { classPropertyName: "dir", publicName: "dir", isSignal: true, isRequired: false, transformFunction: null }, _class: { classPropertyName: "_class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, _style: { classPropertyName: "_style", publicName: "style", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "listRef", first: true, predicate: ["listRef"], descendants: true, isSignal: true }], ngImport: i0, template: ` @if (toasts().length > 0) { <section [attr.aria-label]="'Notifications ' + hotKeyLabel()" [tabIndex]="-1"> @for (pos of possiblePositions(); track pos) { <ol #listRef [tabIndex]="-1" [class]="_class()" data-sonner-toaster [attr.data-theme]="actualTheme()" [attr.data-rich-colors]="richColors()" [attr.dir]="dir() === 'auto' ? getDocumentDirection() : dir()" [attr.data-y-position]="pos.split('-')[0]" [attr.data-x-position]="pos.split('-')[1]" (blur)="handleBlur($event)" (focus)="handleFocus($event)" (mouseenter)="expanded.set(true)" (mousemove)="expanded.set(true)" (mouseleave)="handleMouseLeave()" (pointerdown)="handlePointerDown($event)" (pointerup)="interacting.set(false)" [style]="toasterStyles()"> @for ( toast of toasts() | toastFilter: $index : pos; track toast.id ) { <ngx-sonner-toast [index]="$index" [toast]="toast" [invert]="invert()" [visibleToasts]="visibleToasts()" [closeButton]="closeButton()" [interacting]="interacting()" [position]="position()" [expandByDefault]="expand()" [expanded]="expanded()" [actionButtonStyle]="toastOptions().actionButtonStyle" [cancelButtonStyle]="toastOptions().cancelButtonStyle" [class]="toastOptions().class ?? ''" [descriptionClass]="toastOptions().descriptionClass ?? ''" [classes]="toastOptions().classes ?? {}" [duration]="toastOptions().duration ?? duration()" [unstyled]="toastOptions().unstyled ?? false"> <ng-content select="[loading-icon]" loading-icon> <ngx-sonner-loader [isVisible]="toast.type === 'loading'" /> </ng-content> <ng-content select="[success-icon]" success-icon> <ngx-sonner-icon type="success" /> </ng-content> <ng-content select="[error-icon]" error-icon> <ngx-sonner-icon type="error" />