|
|
|
<svelte:options immutable={true} /> |
|
|
|
<script>import { onMount, tick } from 'svelte'; |
|
import { toastState, useEffect } from './state'; |
|
import { cn } from './internal/helpers'; |
|
|
|
const TOAST_LIFETIME = 4000; |
|
|
|
const GAP = 14; |
|
const SWIPE_TRESHOLD = 20; |
|
const TIME_BEFORE_UNMOUNT = 200; |
|
const SCALE_MULTIPLIER = 0.05; |
|
const defaultClasses = { |
|
toast: '', |
|
title: '', |
|
description: '', |
|
loader: '', |
|
closeButton: '', |
|
cancelButton: '', |
|
actionButton: '', |
|
action: '', |
|
warning: '', |
|
error: '', |
|
success: '', |
|
default: '', |
|
info: '', |
|
loading: '' |
|
}; |
|
const { toasts, heights, removeHeight, setHeight, remove } = toastState; |
|
export let toast; |
|
export let index; |
|
export let expanded; |
|
export let invert; |
|
export let position; |
|
export let visibleToasts; |
|
export let expandByDefault; |
|
export let closeButton; |
|
export let interacting; |
|
export let cancelButtonStyle = ''; |
|
export let actionButtonStyle = ''; |
|
export let duration = 4000; |
|
export let descriptionClass = ''; |
|
export let classes = {}; |
|
export let unstyled = false; |
|
let mounted = false; |
|
let removed = false; |
|
let swiping = false; |
|
let swipeOut = false; |
|
let offsetBeforeRemove = 0; |
|
let initialHeight = 0; |
|
let toastRef; |
|
$: classes = { ...defaultClasses, ...classes }; |
|
$: isFront = index === 0; |
|
$: isVisible = index + 1 <= visibleToasts; |
|
$: toastTitle = toast.title; |
|
$: toastDescription = toast.description; |
|
$: toastType = toast.type; |
|
$: toastClass = toast.class || ''; |
|
$: toastDescriptionClass = toast.descriptionClass || ''; |
|
|
|
$: heightIndex = |
|
$heights.findIndex((height) => height.toastId === toast.id) || 0; |
|
let offset = 0; |
|
let closeTimerStartTimeRef = 0; |
|
let lastCloseTimerStartTimeRef = 0; |
|
let pointerStartRef = null; |
|
$: coords = position.split('-'); |
|
$: toastsHeightBefore = $heights.reduce((prev, curr, reducerIndex) => { |
|
// Calculate offset up untill current toast |
|
if (reducerIndex >= heightIndex) |
|
return prev; |
|
return prev + curr.height; |
|
}, 0); |
|
$: invert = toast.invert || invert; |
|
$: disabled = toastType === 'loading'; |
|
|
|
$: offset = Math.round(heightIndex * GAP + toastsHeightBefore); |
|
|
|
async function updateHeights() { |
|
if (!mounted) { |
|
return; |
|
} |
|
await tick(); |
|
let scale; |
|
if (expanded || expandByDefault) { |
|
scale = 1; |
|
} |
|
else { |
|
scale = 1 - index * SCALE_MULTIPLIER; |
|
} |
|
toastRef.style.setProperty('height', 'auto'); |
|
const offsetHeight = toastRef.offsetHeight; |
|
|
|
const rectHeight = toastRef.getBoundingClientRect().height; |
|
const scaledRectHeight = Math.round((rectHeight / scale + Number.EPSILON) * 100) / 100; |
|
toastRef.style.removeProperty('height'); |
|
let finalHeight; |
|
if (Math.abs(scaledRectHeight - offsetHeight) < 1) { |
|
// Use scaledRectHeight as it's more precise |
|
finalHeight = scaledRectHeight; |
|
} |
|
else { |
|
// toast was transitioning its scale, so scaledRectHeight isn't accurate |
|
finalHeight = offsetHeight; |
|
} |
|
initialHeight = finalHeight; |
|
setHeight({ toastId: toast.id, height: finalHeight }); |
|
} |
|
$: toastTitle, toastDescription, updateHeights(); |
|
function deleteToast() { |
|
removed = true; |
|
// Save the offset for the exit swipe animation |
|
offsetBeforeRemove = offset; |
|
removeHeight(toast.id); |
|
setTimeout(() => { |
|
remove(toast.id); |
|
}, TIME_BEFORE_UNMOUNT); |
|
} |
|
let timeoutId; |
|
let remainingTime = toast.duration || duration || TOAST_LIFETIME; |
|
$: if (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(timeoutId); |
|
remainingTime = toast.duration || duration || TOAST_LIFETIME; |
|
startTimer(); |
|
} |
|
|
|
|
|
|
|
|
|
function pauseTimer() { |
|
if (lastCloseTimerStartTimeRef < closeTimerStartTimeRef) { |
|
// Get the elapsed time since the timer started |
|
const elapsedTime = new Date().getTime() - closeTimerStartTimeRef; |
|
remainingTime = remainingTime - elapsedTime; |
|
} |
|
lastCloseTimerStartTimeRef = new Date().getTime(); |
|
} |
|
function startTimer() { |
|
closeTimerStartTimeRef = new Date().getTime(); |
|
// Let the toast know it has started |
|
timeoutId = setTimeout(() => { |
|
toast.onAutoClose?.(toast); |
|
deleteToast(); |
|
}, remainingTime); |
|
} |
|
$: isPromiseLoadingOrInfiniteDuration = |
|
(toast.promise && toastType === 'loading') || |
|
toast.duration === Number.POSITIVE_INFINITY; |
|
|
|
|
|
|
|
|
|
let effect; |
|
$: effect = useEffect(() => { |
|
if (!isPromiseLoadingOrInfiniteDuration) { |
|
if (expanded || interacting) { |
|
pauseTimer(); |
|
} |
|
else { |
|
startTimer(); |
|
} |
|
} |
|
return () => clearTimeout(timeoutId); |
|
}); |
|
$: $effect; |
|
onMount(() => { |
|
mounted = true; |
|
const height = toastRef.getBoundingClientRect().height; |
|
// Add toast height tot heights array after the toast is mounted |
|
initialHeight = height; |
|
setHeight({ toastId: toast.id, height }); |
|
return () => removeHeight(toast.id); |
|
}); |
|
$: if (toast.delete) { |
|
deleteToast(); |
|
} |
|
function onPointerDown(event) { |
|
if (disabled) { |
|
return; |
|
} |
|
offsetBeforeRemove = offset; |
|
const target = event.target; |
|
|
|
target.setPointerCapture(event.pointerId); |
|
if (target.tagName === 'BUTTON') { |
|
return; |
|
} |
|
swiping = true; |
|
pointerStartRef = { x: event.clientX, y: event.clientY }; |
|
} |
|
function onPointerUp() { |
|
if (swipeOut) { |
|
return; |
|
} |
|
pointerStartRef = null; |
|
const swipeAmount = Number(toastRef?.style |
|
.getPropertyValue('--swipe-amount') |
|
.replace('px', '') || 0); |
|
|
|
if (Math.abs(swipeAmount) >= SWIPE_TRESHOLD) { |
|
offsetBeforeRemove = offset; |
|
toast.onDismiss?.(toast); |
|
deleteToast(); |
|
swipeOut = true; |
|
return; |
|
} |
|
toastRef.style.setProperty('--swipe-amount', '0px'); |
|
swiping = false; |
|
} |
|
function onPointerMove(event) { |
|
if (!pointerStartRef) { |
|
return; |
|
} |
|
const yPosition = event.clientY - pointerStartRef.y; |
|
const xPosition = event.clientX - pointerStartRef.x; |
|
const clamp = 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) { |
|
toastRef.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 |
|
pointerStartRef = null; |
|
} |
|
} |
|
</script> |
|
|
|
<li |
|
bind:this={toastRef} |
|
aria-live={toast.important ? 'assertive' : 'polite'} |
|
aria-atomic="true" |
|
role="status" |
|
tabIndex={0} |
|
class={cn( |
|
$$props.class, |
|
toastClass, |
|
classes?.toast, |
|
toast?.classes?.toast, |
|
classes?.[toastType], |
|
toast?.classes?.[toastType] |
|
)} |
|
data-sonner-toast="" |
|
data-styled={!(toast.component || toast?.unstyled || unstyled)} |
|
data-mounted={mounted} |
|
data-promise={Boolean(toast.promise)} |
|
data-removed={removed} |
|
data-visible={isVisible} |
|
data-y-position={coords[0]} |
|
data-x-position={coords[1]} |
|
data-index={index} |
|
data-front={isFront} |
|
data-swiping={swiping} |
|
data-type={toastType} |
|
data-invert={invert} |
|
data-swipe-out={swipeOut} |
|
data-expanded={Boolean(expanded || (expandByDefault && mounted))} |
|
style={`${$$props.style} ${toast.style}`} |
|
style:--index={index} |
|
style:--toasts-before={index} |
|
style:--z-index={$toasts.length - index} |
|
style:--offset={`${removed ? offsetBeforeRemove : offset}px`} |
|
style:--initial-height={`${initialHeight}px`} |
|
on:pointerdown={onPointerDown} |
|
on:pointerup={onPointerUp} |
|
on:pointermove={onPointerMove} |
|
> |
|
{#if closeButton && !toast.component} |
|
<button |
|
aria-label="Close toast" |
|
data-disabled={disabled} |
|
data-close-button |
|
on:click={disabled |
|
? undefined |
|
: () => { |
|
deleteToast(); |
|
toast.onDismiss?.(toast); |
|
}} |
|
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} |
|
|
|
{#if toast.component} |
|
<svelte:component |
|
this={toast.component} |
|
{...toast.componentProps} |
|
on:closeToast={deleteToast} |
|
></svelte:component> |
|
{:else} |
|
{#if toastType !== 'default' || toast.icon || toast.promise} |
|
<div data-icon=""> |
|
{#if (toast.promise || toastType === 'loading') && !toast.icon} |
|
<slot name="loading-icon" /> |
|
{/if} |
|
{#if toast.icon} |
|
<svelte:component this={toast.icon}></svelte:component> |
|
{:else if toastType === 'success'} |
|
<slot name="success-icon" /> |
|
{:else if toastType === 'error'} |
|
<slot name="error-icon" /> |
|
{:else if toastType === 'warning'} |
|
<slot name="warning-icon" /> |
|
{:else if toastType === 'info'} |
|
<slot name="info-icon" /> |
|
{/if} |
|
</div> |
|
{/if} |
|
<div data-content=""> |
|
{#if toast.title} |
|
<div |
|
data-title="" |
|
class={cn(classes?.title, toast?.classes?.title)} |
|
> |
|
{#if typeof toast.title !== 'string'} |
|
<svelte:component |
|
this={toast.title} |
|
{...toast.componentProps} |
|
/> |
|
{:else} |
|
{toast.title} |
|
{/if} |
|
</div> |
|
{/if} |
|
{#if toast.description} |
|
<div |
|
data-description="" |
|
class={cn( |
|
descriptionClass, |
|
toastDescriptionClass, |
|
classes?.description, |
|
toast.classes?.description |
|
)} |
|
> |
|
{#if typeof toast.description !== 'string'} |
|
<svelte:component |
|
this={toast.description} |
|
{...toast.componentProps} |
|
/> |
|
{:else} |
|
{toast.description} |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{#if toast.cancel} |
|
<button |
|
data-button |
|
data-cancel |
|
style={cancelButtonStyle} |
|
class={cn(classes?.cancelButton, toast?.classes?.cancelButton)} |
|
on:click={() => { |
|
deleteToast(); |
|
if (toast.cancel?.onClick) { |
|
toast.cancel.onClick(); |
|
} |
|
}} |
|
> |
|
{toast.cancel.label} |
|
</button> |
|
{/if} |
|
{#if toast.action} |
|
<button |
|
data-button="" |
|
style={actionButtonStyle} |
|
class={cn(classes?.actionButton, toast?.classes?.actionButton)} |
|
on:click={(event) => { |
|
toast.action?.onClick(event); |
|
if (event.defaultPrevented) return; |
|
deleteToast(); |
|
}} |
|
> |
|
{toast.action.label} |
|
</button> |
|
{/if} |
|
{/if} |
|
</li> |
|
|