import { closeIcon, icons } from './assets'; import { addEventListener, appendChild, setAttribute, setProperty } from './constants'; import { config } from './config'; import { assignOffset, getToaster } from './toaster'; import { ToastType } from './types'; const toastTimers = new Map(); const toastMap = new Map(); let loadingCurrentTime: CSSNumberish | null = null; const setSwiping = (toast: HTMLElement, value: 'false' | 'true') => { toast[setAttribute]('data-swiping', value); } const setSwipeAmount = (toast: HTMLElement, axis: 'x' | 'y', value: string) => { toast.style[setProperty]('--swipe-amount-' + axis, value); } const getDampening = (delta: number) => { const factor = Math.abs(delta) / 20; return 1 / (1.5 + factor); }; export function addToast(options: ToastType) { const id = options.id ?? Date.now(); const data = Object.assign({}, config.toastOptions, options); const { duration, closeButton, position, richColors, invert, onDismiss, onAutoClose } = data; const toaster = getToaster(position); const oldToast = (options.id && toastMap.get(id)?.isConnected && toastMap.get(id)) || null; const toast: HTMLElement = document.createElement('li'); toast[setAttribute]('data-sonner-toast', ''); options.type && toast[setAttribute]('data-type', options.type); invert && toast[setAttribute]('data-invert', ''); // richColors if (richColors && options.type) { toast[setAttribute]('data-rich-colors', ''); } // add close button const close = document.createElement('button'); close[setAttribute]('data-close-button', closeButton.toString()); close.innerHTML = closeIcon; close[addEventListener]('click', () => { dismissToast(id); onDismiss(); }); toast[appendChild](close); if (options.type) { const icon = document.createElement('span'); icon.innerHTML = icons[options.type]; icon[setAttribute]('data-icon', ''); toast[appendChild](icon); if (options.type === 'loading' && oldToast) { const currentTime = oldToast.querySelector('[data-icon] div')?.getAnimations()?.[0].currentTime; if (currentTime) { loadingCurrentTime = currentTime; } } } const content = document.createElement('div'); content[setAttribute]('data-content', ''); toast[appendChild](content); const title = document.createElement('div'); title[setAttribute]('data-title', ''); if (data.html) { title.innerHTML = options.title; } else { title.textContent = options.title; } content[appendChild](title); if (options.description) { const desc = document.createElement('div'); if (data.html) { desc.innerHTML = options.description; } else { desc.textContent = options.description; } desc[setAttribute]('data-description', ''); content[appendChild](desc); } if (options.action) { const button = document.createElement('span'); button[setAttribute]('data-button', ''); button.textContent = options.action.label; options.action.cancel && button[setAttribute]('data-cancel', ''); button[addEventListener]('mousedown', e => e.stopPropagation()); button[addEventListener]('click', e => { options.action?.onClick(e); onDismiss(); dismissToast(id); }); toast[appendChild](button); } // pause all timers when hover toaster toast[addEventListener]('mouseenter', () => { toastTimers.forEach(time => { clearTimeout(time.timeId); const now = new Date().getTime(); const diff = now - time.startTime; time.remainingTime -= diff; }); }); toast[addEventListener]('mouseleave', () => { toastTimers.forEach((time, _id) => { const now = new Date().getTime(); time.startTime = now; time.timeId = setTimeout(dismissToast, time.remainingTime, _id); }); }); toast[addEventListener]('transitionend', () => toast[setAttribute]('data-moving', 'false')); // close toast on timeout if (duration > 0) { const timeId = setTimeout(() => { onAutoClose(); dismissToast(id); }, duration); toastTimers.set(id, { timeId, startTime: Date.now(), remainingTime: duration }); } toast[addEventListener]('dragend', _e => { setSwiping(toast, 'false') }) // swipe toast to dismiss toast[addEventListener]('pointerdown', e => { if (e.button === 2) return; // Return early on right click if (toast.getAttribute('data-moving') == 'true') return; setSwiping(toast, 'true'); const startTime = Date.now(); // Ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping) (e.target as HTMLElement).setPointerCapture(e.pointerId); const startX = e.clientX; const startY = e.clientY; let xDelta = 0; let yDelta = 0; let swipeDirection: 'x' | 'y' | null = null; const [positionY, positionX] = position.split('-'); const liftX = positionX === 'right' ? 1 : -1; const liftY = positionY === 'bottom' ? 1 : -1; toast[setAttribute]('data-moving', 'true') const onPointerMove = (e: PointerEvent) => { xDelta = e.clientX - startX; yDelta = e.clientY - startY; if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) { swipeDirection = Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y'; } if (swipeDirection === 'x') { const resistanceCoefficient = positionX === 'center' ? 1 // No dampening in both directions : xDelta * liftX < 0 ? getDampening(xDelta) : 1; xDelta *= resistanceCoefficient; setSwipeAmount(toast, 'x', `${xDelta}px`) setSwipeAmount(toast, 'y', '0') } else if (swipeDirection === 'y') { const resistanceCoefficient = yDelta * liftY < 0 ? getDampening(yDelta) : 1; yDelta *= resistanceCoefficient; setSwipeAmount(toast, 'y', `${yDelta}px`) setSwipeAmount(toast, 'x', '0') } }; const onPointerUp = () => { toast[setAttribute]('data-moving', 'false') const duration = Date.now() - startTime; const swipeAmount = swipeDirection === 'x' ? xDelta : yDelta; const velocity = Math.abs(swipeAmount) / duration; if (swipeDirection && (Math.abs(swipeAmount) > 44 || velocity > 0.11)) { const liftAmount = swipeDirection === 'x' ? liftX : liftY; // Exit animations (on swipe 👆) if (swipeDirection === 'x') { if (positionX === 'center') { // Equally strong exit animation on both sides for center toast setSwipeAmount(toast, swipeDirection, swipeAmount > 0 ? `${-1 * liftAmount * 70}%` // Exit right : `${liftAmount * 70}%` // Exit left ) } else if (positionX === 'right') { setSwipeAmount(toast, swipeDirection, swipeAmount > 0 ? `${liftAmount * 300}%` // Exit right : `${-1 * liftAmount * 70}%` // Exit left ) } else { // positionX === 'left' setSwipeAmount(toast, swipeDirection, swipeAmount > 0 ? `${-1 * liftAmount * 70}%` // Exit right : `${liftAmount * 300}%` // Exit left ) } } else { if (positionY === 'bottom') { setSwipeAmount(toast, swipeDirection, swipeAmount > 0 ? `${liftAmount * 300}%` // Exit down : `${-1 * liftAmount * 70}%` // Exit up ) } else { setSwipeAmount(toast, swipeDirection, swipeAmount > 0 ? `${-1 * liftAmount * 70}%` // Exit down : `${liftAmount * 300}%` // Exit up ) } } onDismiss(toast); dismissToast(id, 200); } else { setSwiping(toast, 'false') setSwipeAmount(toast, 'x', '0') setSwipeAmount(toast, 'y', '0') } document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); }; document[addEventListener]('pointermove', onPointerMove); document[addEventListener]('pointerup', onPointerUp); }); if (oldToast) { toast[setAttribute]('style', oldToast.getAttribute('style') || ''); toaster.replaceChild(toast, oldToast); toastMap.set(id, toast); if (loadingCurrentTime) { const animations = toast.querySelector('[data-icon] div')?.getAnimations(); if (animations) { animations[0].currentTime = loadingCurrentTime; } } } else { toast[setAttribute]('data-state', 'created'); toaster[appendChild](toast); toastMap.set(id, toast); } return id; } export function dismissToast(id?: ToastType['id'], exitTime: number = 400) { if (toastMap.size === 0) return; if (id === undefined) { toastMap.forEach((_, index) => dismissToast(index)); return; } const toast = toastMap.get(id); if (!toast) return; toast[setAttribute]('data-state', 'deleting'); assignOffset(toast.parentElement as HTMLElement); setTimeout(() => requestAnimationFrame(() => toast.remove()), exitTime); toastMap.delete(id); toastTimers.delete(id); }