Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: emilkowalski/sonner
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: b74c8d82d40783d18cc7b2268faf3b8b2c254baf
Choose a base ref
...
head repository: emilkowalski/sonner
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 1849ab46b9c58f3ee0bba4ab6598619c1dd1832d
Choose a head ref

Commits on Jun 8, 2024

  1. v1.5.0

    emilkowalski committed Jun 8, 2024
    Copy the full SHA
    bc74b2d View commit details

Commits on Jun 14, 2024

  1. Allow dismissing toasts with id = 0 (#442)

    MrFlashAccount authored Jun 14, 2024
    Copy the full SHA
    a2bbec0 View commit details

Commits on Jun 23, 2024

  1. fix toast timer (#458)

    emilkowalski authored Jun 23, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    b974a5b View commit details

Commits on Jun 25, 2024

  1. fix: add "main" entry to package.json (#457)

    adamhenson authored Jun 25, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    fcb3df0 View commit details
  2. fix: missing exported types (#459)

    alex-mcgovern authored Jun 25, 2024

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    9c2591d View commit details
  3. Fix dependency array (#461)

    * Fix dependency array
    
    * small twewaks
    emilkowalski authored Jun 25, 2024
    Copy the full SHA
    b4ffb60 View commit details
  4. fix: reverse left position on mobile rtl (#453)

    zaaakher authored Jun 25, 2024
    Copy the full SHA
    2b99cd8 View commit details

Commits on Sep 10, 2024

  1. Allow awaiting toast.promise (#462)

    ajmnz authored Sep 10, 2024
    Copy the full SHA
    ee55353 View commit details
  2. feat: add support for custom close icon (#481)

    alexandernanberg authored Sep 10, 2024
    Copy the full SHA
    8fe7045 View commit details
  3. update API reference for toast() (#471)

    also update `cancel` toastOption docs
    plbstl authored Sep 10, 2024
    Copy the full SHA
    953ebd5 View commit details

Commits on Oct 13, 2024

  1. fix: allow React.js v19 in peerDependencies (#493)

    theoludwig authored Oct 13, 2024
    Copy the full SHA
    344fa5e View commit details
  2. feat: allow ref forwarding for Toaster (#491)

    freshgiammi authored Oct 13, 2024
    Copy the full SHA
    5d3a179 View commit details

Commits on Oct 30, 2024

  1. fix: support dark mode for safari < 14 using matchMedia().addListener (

    …#495)
    
    * fix: support matchMedia for safari
    
    * fix: edit comment
    yunsteel authored Oct 30, 2024
    Copy the full SHA
    6fca6ab View commit details

Commits on Nov 2, 2024

  1. feat: support show react element from react server function (#492)

    * feat: support show react element from react server function
    
    * fix: test id
    himself65 authored Nov 2, 2024
    Copy the full SHA
    682702f View commit details
  2. Include styles.css in dist (#446)

    kevlened authored Nov 2, 2024
    Copy the full SHA
    07286fe View commit details
  3. fix: implement loader class from config (#489)

    joewinger authored Nov 2, 2024
    Copy the full SHA
    cb8354d View commit details
  4. fix: remove repeat effect hook to resolve mix up (#392)

    Tzyito authored Nov 2, 2024
    Copy the full SHA
    5159aec View commit details
  5. Fix inability to prevent toast from closing on action button click (#484

    )
    iuriiiurevich authored Nov 2, 2024
    Copy the full SHA
    8d33b51 View commit details
  6. fix: incorrect stacking (#499)

    emilkowalski authored Nov 2, 2024
    Copy the full SHA
    51ed654 View commit details
  7. chore: remove templates (#500)

    emilkowalski authored Nov 2, 2024
    Copy the full SHA
    5a711d3 View commit details
  8. fix: incorrect timer on hover (#501)

    emilkowalski authored Nov 2, 2024
    Copy the full SHA
    ff91e0a View commit details
  9. feat: allow to pass custom elements to title and description (#502)

    * allow to pass custom elements
    
    * cleanup hero
    emilkowalski authored Nov 2, 2024
    Copy the full SHA
    8789d52 View commit details
  10. fix: turn section into live region #306 (#436)

    Co-authored-by: Emil Kowalski <36730035+emilkowalski@users.noreply.github.com>
    tricinel and emilkowalski authored Nov 2, 2024
    Copy the full SHA
    971bfe1 View commit details
  11. docs: remove important property from docs (#503)

    emilkowalski authored Nov 2, 2024
    Copy the full SHA
    3071305 View commit details
  12. fix: background for close button (#504)

    emilkowalski authored Nov 2, 2024
    Copy the full SHA
    0f320bf View commit details
  13. feat: improve swiping (#505)

    emilkowalski authored Nov 2, 2024
    Copy the full SHA
    1849ab4 View commit details
9 changes: 0 additions & 9 deletions .github/issue-template.md

This file was deleted.

11 changes: 0 additions & 11 deletions .github/pull-request-template.md

This file was deleted.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
{
"name": "sonner",
"version": "1.4.41",
"version": "1.5.0",
"description": "An opinionated toast component for React.",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup src/index.tsx",
"build": "tsup src/index.tsx && cp src/styles.css dist/styles.css",
"dev": "tsup src/index.tsx --watch",
"dev:website": "turbo run dev --filter=website...",
"dev:test": "turbo run dev --filter=test...",
@@ -48,8 +49,8 @@
"typescript": "^4.8.4"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"packageManager": "pnpm@8.12.1"
}
3,802 changes: 2,634 additions & 1,168 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

21 changes: 19 additions & 2 deletions src/assets.tsx
Original file line number Diff line number Diff line change
@@ -23,9 +23,9 @@ export const getAsset = (type: ToastTypes): JSX.Element | null => {

const bars = Array(12).fill(0);

export const Loader = ({ visible }: { visible: boolean }) => {
export const Loader = ({ visible, className }: { visible: boolean, className?: string }) => {
return (
<div className="sonner-loading-wrapper" data-visible={visible}>
<div className={['sonner-loading-wrapper', className].filter(Boolean).join(' ')} data-visible={visible}>
<div className="sonner-spinner">
{bars.map((_, i) => (
<div className="sonner-loading-bar" key={`spinner-bar-${i}`} />
@@ -74,3 +74,20 @@ const ErrorIcon = (
/>
</svg>
);

export const CloseIcon = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);
176 changes: 87 additions & 89 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use client';

import React from 'react';
import React, { forwardRef } from 'react';
import ReactDOM from 'react-dom';

import { getAsset, Loader } from './assets';
import { CloseIcon, getAsset, Loader } from './assets';
import { useIsDocumentHidden } from './hooks';
import { toast, ToastState } from './state';
import './styles.css';
@@ -79,6 +79,7 @@ const Toast = (props: ToastProps) => {
const [swipeOut, setSwipeOut] = React.useState(false);
const [offsetBeforeRemove, setOffsetBeforeRemove] = React.useState(0);
const [initialHeight, setInitialHeight] = React.useState(0);
const remainingTime = React.useRef(toast.duration || durationFromToaster || TOAST_LIFETIME);
const dragStartTime = React.useRef<Date | null>(null);
const toastRef = React.useRef<HTMLLIElement>(null);
const isFront = index === 0;
@@ -127,6 +128,17 @@ const Toast = (props: ToastProps) => {
setMounted(true);
}, []);

React.useEffect(() => {
const toastNode = toastRef.current;
if (toastNode) {
const height = toastNode.getBoundingClientRect().height;
// Add toast height tot heights array after the toast is mounted
setInitialHeight(height);
setHeights((h) => [{ toastId: toast.id, height, position: toast.position }, ...h]);
return () => setHeights((h) => h.filter((height) => height.toastId !== toast.id));
}
}, [setHeights, toast.id]);

React.useLayoutEffect(() => {
if (!mounted) return;
const toastNode = toastRef.current;
@@ -161,15 +173,14 @@ const Toast = (props: ToastProps) => {
React.useEffect(() => {
if ((toast.promise && toastType === 'loading') || toast.duration === Infinity || toast.type === 'loading') return;
let timeoutId: NodeJS.Timeout;
let remainingTime = duration;

// Pause the timer on each hover
const pauseTimer = () => {
if (lastCloseTimerStartTimeRef.current < closeTimerStartTimeRef.current) {
// Get the elapsed time since the timer started
const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current;

remainingTime = remainingTime - elapsedTime;
remainingTime.current = remainingTime.current - elapsedTime;
}

lastCloseTimerStartTimeRef.current = new Date().getTime();
@@ -179,15 +190,15 @@ const Toast = (props: ToastProps) => {
// setTimeout(, Infinity) behaves as if the delay is 0.
// As a result, the toast would be closed immediately, giving the appearance that it was never rendered.
// See: https://github.com/denysdovhan/wtfjs?tab=readme-ov-file#an-infinite-timeout
if (remainingTime === Infinity) return;
if (remainingTime.current === Infinity) return;

closeTimerStartTimeRef.current = new Date().getTime();

// Let the toast know it has started
timeoutId = setTimeout(() => {
toast.onAutoClose?.(toast);
deleteToast();
}, remainingTime);
}, remainingTime.current);
};

if (expanded || interacting || (pauseWhenPageIsHidden && isDocumentHidden)) {
@@ -197,32 +208,7 @@ const Toast = (props: ToastProps) => {
}

return () => clearTimeout(timeoutId);
}, [
expanded,
interacting,
expandByDefault,
toast,
duration,
deleteToast,
toast.promise,
toastType,
pauseWhenPageIsHidden,
isDocumentHidden,
]);

React.useEffect(() => {
const toastNode = toastRef.current;

if (toastNode) {
const height = toastNode.getBoundingClientRect().height;

// Add toast height tot heights array after the toast is mounted
setInitialHeight(height);
setHeights((h) => [{ toastId: toast.id, height, position: toast.position }, ...h]);

return () => setHeights((h) => h.filter((height) => height.toastId !== toast.id));
}
}, [setHeights, toast.id]);
}, [expanded, interacting, toast, toastType, pauseWhenPageIsHidden, isDocumentHidden, deleteToast]);

React.useEffect(() => {
if (toast.delete) {
@@ -233,27 +219,30 @@ const Toast = (props: ToastProps) => {
function getLoadingIcon() {
if (icons?.loading) {
return (
<div className="sonner-loader" data-visible={toastType === 'loading'}>
<div
className={cn(classNames?.loader, toast?.classNames?.loader, 'sonner-loader')}
data-visible={toastType === 'loading'}
>
{icons.loading}
</div>
);
}

if (loadingIconProp) {
return (
<div className="sonner-loader" data-visible={toastType === 'loading'}>
<div
className={cn(classNames?.loader, toast?.classNames?.loader, 'sonner-loader')}
data-visible={toastType === 'loading'}
>
{loadingIconProp}
</div>
);
}
return <Loader visible={toastType === 'loading'} />;
return <Loader className={cn(classNames?.loader, toast?.classNames?.loader)} visible={toastType === 'loading'} />;
}

return (
<li
aria-live={toast.important ? 'assertive' : 'polite'}
aria-atomic="true"
role="status"
tabIndex={0}
ref={toastRef}
className={cn(
@@ -327,20 +316,11 @@ const Toast = (props: ToastProps) => {
if (!pointerStartRef.current || !dismissible) return;

const yPosition = event.clientY - pointerStartRef.current.y;
const xPosition = event.clientX - pointerStartRef.current.x;

const clamp = y === '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.current?.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.current = null;
}
const isHighlighted = window.getSelection()?.toString().length > 0;

if (isHighlighted) return;

toastRef.current?.style.setProperty('--swipe-amount', `${Math.max(0, yPosition)}px`);
}}
>
{closeButton && !toast.jsx ? (
@@ -358,24 +338,18 @@ const Toast = (props: ToastProps) => {
}
className={cn(classNames?.closeButton, toast?.classNames?.closeButton)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
{icons?.close ?? CloseIcon}
</button>
) : null}
{/* TODO: This can be cleaner */}
{toast.jsx || React.isValidElement(toast.title) ? (
toast.jsx || toast.title
toast.jsx ? (
toast.jsx
) : typeof toast.title === 'function' ? (
toast.title()
) : (
toast.title
)
) : (
<>
{toastType || toast.icon || toast.promise ? (
@@ -387,7 +361,7 @@ const Toast = (props: ToastProps) => {

<div data-content="" className={cn(classNames?.content, toast?.classNames?.content)}>
<div data-title="" className={cn(classNames?.title, toast?.classNames?.title)}>
{toast.title}
{typeof toast.title === 'function' ? toast.title() : toast.title}
</div>
{toast.description ? (
<div
@@ -397,9 +371,9 @@ const Toast = (props: ToastProps) => {
toastDescriptionClassname,
classNames?.description,
toast?.classNames?.description,
)}
)}
>
{toast.description}
{typeof toast.description === 'function' ? toast.description() : toast.description}
</div>
) : null}
</div>
@@ -432,8 +406,8 @@ const Toast = (props: ToastProps) => {
onClick={(event) => {
// We need to check twice because typescript
if (!isAction(toast.action)) return;
if (event.defaultPrevented) return;
toast.action.onClick?.(event);
if (event.defaultPrevented) return;
deleteToast();
}}
className={cn(classNames?.actionButton, toast?.classNames?.actionButton)}
@@ -487,7 +461,7 @@ function useSonner() {
};
}

const Toaster = (props: ToasterProps) => {
const Toaster = forwardRef<HTMLElement, ToasterProps>(function Toaster(props, ref) {
const {
invert,
position = 'bottom-right',
@@ -534,16 +508,15 @@ const Toaster = (props: ToasterProps) => {
const lastFocusedElementRef = React.useRef<HTMLElement>(null);
const isFocusWithinRef = React.useRef(false);

const removeToast = React.useCallback(
(toastToRemove: ToastT) => {
const removeToast = React.useCallback((toastToRemove: ToastT) => {
setToasts((toasts) => {
if (!toasts.find((toast) => toast.id === toastToRemove.id)?.delete) {
ToastState.dismiss(toastToRemove.id);
}

setToasts((toasts) => toasts.filter(({ id }) => id !== toastToRemove.id))
},
[toasts],
);
return toasts.filter(({ id }) => id !== toastToRemove.id);
});
}, []);

React.useEffect(() => {
return ToastState.subscribe((toast) => {
@@ -592,14 +565,31 @@ const Toaster = (props: ToasterProps) => {
}

if (typeof window === 'undefined') return;
const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ({ matches }) => {
if (matches) {
setActualTheme('dark');
} else {
setActualTheme('light');
}
});
try {
// Chrome & Firefox
darkMediaQuery.addEventListener('change', ({ matches }) => {
if (matches) {
setActualTheme('dark');
} else {
setActualTheme('light');
}
});
} catch (error) {
// Safari < 14
darkMediaQuery.addListener(({ matches }) => {
try {
if (matches) {
setActualTheme('dark');
} else {
setActualTheme('light');
}
} catch (e) {
console.error(e);
}
});
}
}, [theme]);

React.useEffect(() => {
@@ -642,13 +632,20 @@ const Toaster = (props: ToasterProps) => {
}
}, [listRef.current]);

if (!toasts.length) return null;

return (
// Remove item from normal navigation flow, only available via hotkey
<section aria-label={`${containerAriaLabel} ${hotkeyLabel}`} tabIndex={-1}>
<section
aria-label={`${containerAriaLabel} ${hotkeyLabel}`}
tabIndex={-1}
aria-live="polite"
aria-relevant="additions text"
aria-atomic="false"
>
{possiblePositions.map((position, index) => {
const [y, x] = position.split('-');

if (!toasts.length) return null;

return (
<ol
key={position}
@@ -745,5 +742,6 @@ const Toaster = (props: ToasterProps) => {
})}
</section>
);
};
});
export { toast, Toaster, type ExternalToast, type ToastT, type ToasterProps, useSonner };
export { type ToastClassnames, type ToastToDismiss, type Action } from './types';
74 changes: 47 additions & 27 deletions src/state.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@ import React from 'react';

let toastsCounter = 1;

type titleT = (() => React.ReactNode) | React.ReactNode;

class Observer {
subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>;
toasts: Array<ToastT | ToastToDismiss>;
@@ -34,7 +36,7 @@ class Observer {

create = (
data: ExternalToast & {
message?: string | React.ReactNode;
message?: titleT;
type?: ToastTypes;
promise?: PromiseT;
jsx?: React.ReactElement;
@@ -80,27 +82,27 @@ class Observer {
return id;
};

message = (message: string | React.ReactNode, data?: ExternalToast) => {
message = (message: titleT | React.ReactNode, data?: ExternalToast) => {
return this.create({ ...data, message });
};

error = (message: string | React.ReactNode, data?: ExternalToast) => {
error = (message: titleT | React.ReactNode, data?: ExternalToast) => {
return this.create({ ...data, message, type: 'error' });
};

success = (message: string | React.ReactNode, data?: ExternalToast) => {
success = (message: titleT | React.ReactNode, data?: ExternalToast) => {
return this.create({ ...data, type: 'success', message });
};

info = (message: string | React.ReactNode, data?: ExternalToast) => {
info = (message: titleT | React.ReactNode, data?: ExternalToast) => {
return this.create({ ...data, type: 'info', message });
};

warning = (message: string | React.ReactNode, data?: ExternalToast) => {
warning = (message: titleT | React.ReactNode, data?: ExternalToast) => {
return this.create({ ...data, type: 'warning', message });
};

loading = (message: string | React.ReactNode, data?: ExternalToast) => {
loading = (message: titleT | React.ReactNode, data?: ExternalToast) => {
return this.create({ ...data, type: 'loading', message });
};

@@ -124,26 +126,34 @@ class Observer {
const p = promise instanceof Promise ? promise : promise();

let shouldDismiss = id !== undefined;
let result: ['resolve', ToastData] | ['reject', unknown];

p.then(async (response) => {
if (isHttpResponse(response) && !response.ok) {
shouldDismiss = false;
const message =
typeof data.error === 'function' ? await data.error(`HTTP error! status: ${response.status}`) : data.error;
const description =
typeof data.description === 'function'
? await data.description(`HTTP error! status: ${response.status}`)
: data.description;
this.create({ id, type: 'error', message, description });
} else if (data.success !== undefined) {
shouldDismiss = false;
const message = typeof data.success === 'function' ? await data.success(response) : data.success;
const description =
typeof data.description === 'function' ? await data.description(response) : data.description;
this.create({ id, type: 'success', message, description });
}
})
const originalPromise = p
.then(async (response) => {
result = ['resolve', response];
const isReactElementResponse = React.isValidElement(response);
if (isReactElementResponse) {
shouldDismiss = false;
this.create({ id, type: 'default', message: response });
} else if (isHttpResponse(response) && !response.ok) {
shouldDismiss = false;
const message =
typeof data.error === 'function' ? await data.error(`HTTP error! status: ${response.status}`) : data.error;
const description =
typeof data.description === 'function'
? await data.description(`HTTP error! status: ${response.status}`)
: data.description;
this.create({ id, type: 'error', message, description });
} else if (data.success !== undefined) {
shouldDismiss = false;
const message = typeof data.success === 'function' ? await data.success(response) : data.success;
const description =
typeof data.description === 'function' ? await data.description(response) : data.description;
this.create({ id, type: 'success', message, description });
}
})
.catch(async (error) => {
result = ['reject', error];
if (data.error !== undefined) {
shouldDismiss = false;
const message = typeof data.error === 'function' ? await data.error(error) : data.error;
@@ -161,7 +171,17 @@ class Observer {
data.finally?.();
});

return id;
const unwrap = () =>
new Promise<ToastData>((resolve, reject) =>
originalPromise.then(() => (result[0] === 'reject' ? reject(result[1]) : resolve(result[1]))).catch(reject),
);

if (typeof id !== 'string' && typeof id !== 'number') {
// cannot Object.assign on undefined
return { unwrap };
} else {
return Object.assign(id, { unwrap });
}
};

custom = (jsx: (id: number | string) => React.ReactElement, data?: ExternalToast) => {
@@ -174,7 +194,7 @@ class Observer {
export const ToastState = new Observer();

// bind this to the toast function
const toastFunction = (message: string | React.ReactNode, data?: ExternalToast) => {
const toastFunction = (message: titleT, data?: ExternalToast) => {
const id = data?.id || toastsCounter++;

ToastState.addToast({
10 changes: 9 additions & 1 deletion src/styles.css
Original file line number Diff line number Diff line change
@@ -213,7 +213,6 @@
justify-content: center;
align-items: center;
padding: 0;
background: var(--gray1);
color: var(--gray12);
border: 1px solid var(--gray4);
transform: var(--toast-close-button-transform);
@@ -223,6 +222,10 @@
transition: opacity 100ms, background 200ms, border-color 200ms;
}

[data-sonner-toast] [data-close-button] {
background: var(--gray1);
}

:where([data-sonner-toast]) :where([data-close-button]):focus-visible {
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(0, 0, 0, 0.2);
}
@@ -329,6 +332,7 @@
[data-sonner-toast][data-swiping='true'] {
transform: var(--y) translateY(var(--swipe-amount, 0px));
transition: none;
user-select: none;
}

[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'],
@@ -357,6 +361,10 @@
width: 100%;
}

[data-sonner-toaster][dir='rtl'] {
left: calc(var(--mobile-offset) * -1);
}

[data-sonner-toaster] [data-sonner-toast] {
left: 0;
right: 0;
8 changes: 4 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -43,28 +43,28 @@ export interface ToastIcons {
warning?: React.ReactNode;
error?: React.ReactNode;
loading?: React.ReactNode;
close?: React.ReactNode;
}

interface Action {
export interface Action {
label: React.ReactNode;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
actionButtonStyle?: React.CSSProperties;
}

export interface ToastT {
id: number | string;
title?: string | React.ReactNode;
title?: (() => React.ReactNode) | React.ReactNode;
type?: ToastTypes;
icon?: React.ReactNode;
jsx?: React.ReactNode;
richColors?: boolean;
invert?: boolean;
closeButton?: boolean;
dismissible?: boolean;
description?: React.ReactNode;
description?: (() => React.ReactNode) | React.ReactNode;
duration?: number;
delete?: boolean;
important?: boolean;
action?: Action | React.ReactNode;
cancel?: Action | React.ReactNode;
onDismiss?: (toast: ToastT) => void;
6 changes: 1 addition & 5 deletions test/next.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
appDir: true,
},
};
const nextConfig = {};

module.exports = nextConfig;
7 changes: 4 additions & 3 deletions test/package.json
Original file line number Diff line number Diff line change
@@ -12,12 +12,13 @@
"@types/node": "18.15.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"ai": "^3.4.9",
"eslint": "8.35.0",
"eslint-config-next": "13.2.4",
"next": "13.4.19",
"react": "18.2.0",
"next": "14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1",
"sonner": "workspace:*",
"react-dom": "18.2.0",
"typescript": "4.9.5"
}
}
17 changes: 17 additions & 0 deletions test/src/app/action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use server';
import { createStreamableUI } from 'ai/rsc';

export async function action() {
'use server';
let progress = 0;
const ui = createStreamableUI('loading 0%');
const interval = setInterval(() => {
progress += 10;
ui.update('loading ' + progress + '%');
if (progress >= 100) {
clearInterval(interval);
ui.update('load complete');
}
}, 100);
return ui.value;
}
42 changes: 42 additions & 0 deletions test/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@

import React from 'react';
import { Toaster, toast } from 'sonner';
import { action } from '@/app/action';

const promise = () => new Promise((resolve) => setTimeout(resolve, 2000));

@@ -74,6 +75,21 @@ export default function Home({ searchParams }: any) {
>
Render Promise Toast
</button>
<button
data-testid="rsf-promise"
data-finally={isFinally ? '1' : '0'}
className="button"
onClick={() =>
toast.promise(action(), {
loading: 'Loading...',
success: 'Loaded',
error: 'Error',
finally: () => setIsFinally(true),
})
}
>
Render React Server Function Toast
</button>
<button
data-testid="custom"
className="button"
@@ -169,6 +185,13 @@ export default function Home({ searchParams }: any) {
>
ReactNode Description
</button>
<button
data-testid="close-button"
className="button"
onClick={() => toast('Toast with close button', { closeButton: true })}
>
Render close button
</button>
{showAutoClose ? <div data-testid="auto-close-el" /> : null}
{showDismiss ? <div data-testid="dismiss-el" /> : null}
<Toaster
@@ -179,6 +202,25 @@ export default function Home({ searchParams }: any) {
}}
theme={theme}
dir={searchParams.dir || 'auto'}
icons={{
close:
searchParams.customCloseIcon === '' ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
) : undefined,
}}
/>
</>
);
12 changes: 12 additions & 0 deletions test/tests/basic.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { toast } from 'sonner';

test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -28,6 +29,17 @@ test.describe('Basic functionality', () => {
await expect(page.getByText('Loaded')).toHaveCount(1);
});

test('handle toast promise rejections', async ({ page }) => {
const rejectedPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Promise rejected')), 100));
try {
toast.promise(rejectedPromise, {});
} catch {
throw new Error('Promise should not have rejected without unwrap');
}

await expect(toast.promise(rejectedPromise, {}).unwrap()).rejects.toThrow('Promise rejected');
});

test('render custom jsx in toast', async ({ page }) => {
await page.getByTestId('custom').click();
await expect(page.getByText('jsx')).toHaveCount(1);
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -3,6 +3,6 @@
"jsx": "react",
"moduleResolution": "node",
"esModuleInterop": true,
"lib": ["es2015", "dom"],
},
"lib": ["es2015", "dom"]
}
}
5 changes: 1 addition & 4 deletions website/src/components/CodeBlock/code-block.module.css
Original file line number Diff line number Diff line change
@@ -35,10 +35,7 @@
cursor: pointer;
opacity: 0;
color: var(--gray12);
transition:
background 200ms,
box-shadow 200ms,
opacity 200ms;
transition: background 200ms, box-shadow 200ms, opacity 200ms;
}

.copyButton:focus-visible {
20 changes: 5 additions & 15 deletions website/src/components/Hero/hero.module.css
Original file line number Diff line number Diff line change
@@ -51,12 +51,8 @@
font-weight: 600;
flex-shrink: 0;
font-family: inherit;
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04),
0px 3px 3px 0px rgba(0, 0, 0, 0.02),
0px 4px 4px 0px rgba(0, 0, 0, 0.01);
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01);
position: relative;
overflow: hidden;
cursor: pointer;
@@ -66,9 +62,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
transition:
box-shadow 200ms,
background 200ms;
transition: box-shadow 200ms, background 200ms;
width: 152px;
}

@@ -79,12 +73,8 @@
}
.button:focus-visible {
outline: none;
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04),
0px 3px 3px 0px rgba(0, 0, 0, 0.02),
0px 4px 4px 0px rgba(0, 0, 0, 0.01),
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01),
0 0 0 2px rgba(0, 0, 0, 0.15);
}

13 changes: 3 additions & 10 deletions website/src/globals.css
Original file line number Diff line number Diff line change
@@ -126,10 +126,7 @@ code {
font-family: var(--font-sans);
cursor: pointer;
color: var(--gray12);
transition:
border-color 200ms,
background 200ms,
box-shadow 200ms;
transition: border-color 200ms, background 200ms, box-shadow 200ms;
}

.button:hover {
@@ -144,12 +141,8 @@ code {

.button:focus-visible {
outline: none;
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04),
0px 3px 3px 0px rgba(0, 0, 0, 0.02),
0px 4px 4px 0px rgba(0, 0, 0, 0.01),
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01),
0 0 0 2px rgba(0, 0, 0, 0.15);
}

59 changes: 39 additions & 20 deletions website/src/pages/toast.mdx
Original file line number Diff line number Diff line change
@@ -89,11 +89,11 @@ toast('My cancel toast', {
});
```

You can also render jsx as your action.
You can also render jsx in the cancel option.

```jsx
toast('My cancel toast', {
action: <Button onClick={() => console.log('Cancel!')}>Cancel</Button>,
cancel: <Button onClick={() => console.log('Cancel!')}>Cancel</Button>,
});
```

@@ -204,23 +204,42 @@ You can also dismiss all toasts at once by calling `toast.dismiss()` without an
toast.dismiss();
```

### Rendering custom elements

You can render custom elements inside the toast like `<a />` or custom components by passing a function instead of a string. This work for both the title and description.

```jsx
toast(
() => (
<>
View{' '}
<a href="https://google.com" target="_blank">
Animation on the Web
</a>
</>
),
{
description: () => <button>This is a button element!</button>,
},
);
```

## API Reference

| Property | Description | Default |
| :----------------- | :----------------------------------------------------------------------------------------------------: | -------------: |
| description | Toast's description, renders underneath the title. | `-` |
| closeButton | Adds a close button. | `false` |
| invert | Dark toast in light mode and vice versa. | `false` |
| important | Control the sensitivity of the toast for screen readers | `false` |
| duration | Time in milliseconds that should elapse before automatically closing the toast. | `4000` |
| position | Position of the toast. | `bottom-right` |
| dismissible | If `false`, it'll prevent the user from dismissing the toast. | `true` |
| icon | Icon displayed in front of toast's text, aligned vertically. | `-` |
| action | Renders a primary button, clicking it will close the toast. | `-` |
| cancel | Renders a secondary button, clicking it will close the toast. | `-` |
| id | Custom id for the toast. | `-` |
| onDismiss | The function gets called when either the close button is clicked, or the toast is swiped. | `-` |
| onAutoClose | Function that gets called when the toast disappears automatically after it's timeout (duration` prop). | `-` |
| unstyled | Removes the default styling, which allows for easier customization. | `false` |
| actionButtonStyles | Styles for the action button | `{}` |
| cancelButtonStyles | Styles for the cancel button | `{}` |
| Property | Description | Default |
| :---------------- | :----------------------------------------------------------------------------------------------------: | -------------: |
| description | Toast's description, renders underneath the title. | `-` |
| closeButton | Adds a close button. | `false` |
| invert | Dark toast in light mode and vice versa. | `false` |
| duration | Time in milliseconds that should elapse before automatically closing the toast. | `4000` |
| position | Position of the toast. | `bottom-right` |
| dismissible | If `false`, it'll prevent the user from dismissing the toast. | `true` |
| icon | Icon displayed in front of toast's text, aligned vertically. | `-` |
| action | Renders a primary button, clicking it will close the toast. | `-` |
| cancel | Renders a secondary button, clicking it will close the toast. | `-` |
| id | Custom id for the toast. | `-` |
| onDismiss | The function gets called when either the close button is clicked, or the toast is swiped. | `-` |
| onAutoClose | Function that gets called when the toast disappears automatically after it's timeout (duration` prop). | `-` |
| unstyled | Removes the default styling, which allows for easier customization. | `false` |
| actionButtonStyle | Styles for the action button | `{}` |
| cancelButtonStyle | Styles for the cancel button | `{}` |
13 changes: 3 additions & 10 deletions website/src/style.css
Original file line number Diff line number Diff line change
@@ -56,10 +56,7 @@ body {
font-family: var(--font-sans);
cursor: pointer;
color: var(--gray12);
transition:
border-color 200ms,
background 200ms,
box-shadow 200ms;
transition: border-color 200ms, background 200ms, box-shadow 200ms;
margin: 1.5rem 0 0;
}

@@ -79,12 +76,8 @@ body {

.button:focus-visible {
outline: none;
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04),
0px 3px 3px 0px rgba(0, 0, 0, 0.02),
0px 4px 4px 0px rgba(0, 0, 0, 0.01),
box-shadow: 0px 0px 0px 1px rgba(0, 0, 0, 0.06), 0px 1px 0px 0px rgba(0, 0, 0, 0.08),
0px 2px 2px 0px rgba(0, 0, 0, 0.04), 0px 3px 3px 0px rgba(0, 0, 0, 0.02), 0px 4px 4px 0px rgba(0, 0, 0, 0.01),
0 0 0 2px rgba(0, 0, 0, 0.15);
}