ngx-sonner
Version:
An opinionated toast component for Angular.
1,139 lines (1,126 loc) • 85 kB
JavaScript
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" />