-
- {bars.map((_, i) => (
-
- ))}
-
+export const Loader = (props: { visible: boolean; class?: string }) => (
+
+);
-const SuccessIcon = (
+const SuccessIcon = () => (
);
-const WarningIcon = (
+const WarningIcon = () => (
);
-const InfoIcon = (
+const InfoIcon = () => (
);
-const ErrorIcon = (
+const ErrorIcon = () => (
);
-export const CloseIcon = (
+export const CloseIcon = () => (
);
diff --git a/src/declarations.d.ts b/src/declarations.d.ts
new file mode 100644
index 00000000..35306c6f
--- /dev/null
+++ b/src/declarations.d.ts
@@ -0,0 +1 @@
+declare module '*.css';
diff --git a/src/hooks.tsx b/src/hooks.tsx
index ef0d5244..3f9ff915 100644
--- a/src/hooks.tsx
+++ b/src/hooks.tsx
@@ -1,15 +1,18 @@
-import React from 'react';
+import { createSignal, onCleanup, onMount } from 'solid-js';
export const useIsDocumentHidden = () => {
- const [isDocumentHidden, setIsDocumentHidden] = React.useState(document.hidden);
+ const [isDocumentHidden, setIsDocumentHidden] = createSignal(document.hidden);
- React.useEffect(() => {
+ onMount(() => {
const callback = () => {
setIsDocumentHidden(document.hidden);
};
document.addEventListener('visibilitychange', callback);
- return () => window.removeEventListener('visibilitychange', callback);
- }, []);
+
+ onCleanup(() => {
+ document.removeEventListener('visibilitychange', callback);
+ });
+ });
return isDocumentHidden;
};
diff --git a/src/index.tsx b/src/index.tsx
index 6954f51a..39d3095f 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,7 +1,5 @@
-'use client';
-
-import React from 'react';
-import ReactDOM from 'react-dom';
+import { createEffect, createMemo, createSignal, For, JSX, mergeProps, onCleanup, onMount, Show } from 'solid-js';
+import { createStore, produce, reconcile } from 'solid-js/store';
import { CloseIcon, getAsset, Loader } from './assets';
import { useIsDocumentHidden } from './hooks';
@@ -9,7 +7,8 @@ import { toast, ToastState } from './state';
import './styles.css';
import {
isAction,
- SwipeDirection,
+ type Position,
+ type SwipeDirection,
type ExternalToast,
type HeightT,
type ToasterProps,
@@ -46,9 +45,9 @@ function cn(...classes: (string | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
-function getDefaultSwipeDirections(position: string): Array
{
+function getDefaultSwipeDirections(position: string): SwipeDirection[] {
const [y, x] = position.split('-');
- const directions: Array = [];
+ const directions: SwipeDirection[] = [];
if (y) {
directions.push(y as SwipeDirection);
@@ -61,280 +60,228 @@ function getDefaultSwipeDirections(position: string): Array {
return directions;
}
+const [heights, setHeights] = createSignal([]);
+
const Toast = (props: ToastProps) => {
- const {
- invert: ToasterInvert,
- toast,
- unstyled,
- interacting,
- setHeights,
- visibleToasts,
- heights,
- index,
- toasts,
- expanded,
- removeToast,
- defaultRichColors,
- closeButton: closeButtonFromToaster,
- style,
- cancelButtonStyle,
- actionButtonStyle,
- className = '',
- descriptionClassName = '',
- duration: durationFromToaster,
- position,
- gap,
- expandByDefault,
- classNames,
- icons,
- closeButtonAriaLabel = 'Close toast',
- } = props;
- const [swipeDirection, setSwipeDirection] = React.useState<'x' | 'y' | null>(null);
- const [swipeOutDirection, setSwipeOutDirection] = React.useState<'left' | 'right' | 'up' | 'down' | null>(null);
- const [mounted, setMounted] = React.useState(false);
- const [removed, setRemoved] = React.useState(false);
- const [swiping, setSwiping] = React.useState(false);
- const [swipeOut, setSwipeOut] = React.useState(false);
- const [isSwiped, setIsSwiped] = 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(null);
- const toastRef = React.useRef(null);
- const isFront = index === 0;
- const isVisible = index + 1 <= visibleToasts;
- const toastType = toast.type;
- const dismissible = toast.dismissible !== false;
- const toastClassname = toast.className || '';
- const toastDescriptionClassname = toast.descriptionClassName || '';
+ const [swipeDirection, setSwipeDirection] = createSignal<'x' | 'y' | null>(null);
+ const [swipeOutDirection, setSwipeOutDirection] = createSignal<'left' | 'right' | 'up' | 'down' | null>(null);
+ const [mounted, setMounted] = createSignal(false);
+ const [removed, setRemoved] = createSignal(false);
+ const [swiping, setSwiping] = createSignal(false);
+ const [swipeOut, setSwipeOut] = createSignal(false);
+ const [isSwiped, setIsSwiped] = createSignal(false);
+ const [offsetBeforeRemove, setOffsetBeforeRemove] = createSignal(0);
+ const [initialHeight, setInitialHeight] = createSignal(0);
+
+ // eslint-disable-next-line solid/reactivity
+ let remainingTime = props.toast.duration ?? props.duration ?? TOAST_LIFETIME;
+ let dragStartTime: Date | null = null;
+ let toastRef!: HTMLLIElement;
+
+ const isFront = () => props.index === 0;
+ const isVisible = () => props.index + 1 <= props.visibleToasts;
+
// Height index is used to calculate the offset as it gets updated before the toast array, which means we can calculate the new layout faster.
- const heightIndex = React.useMemo(
- () => heights.findIndex((height) => height.toastId === toast.id) || 0,
- [heights, toast.id],
- );
- const closeButton = React.useMemo(
- () => toast.closeButton ?? closeButtonFromToaster,
- [toast.closeButton, closeButtonFromToaster],
- );
- const duration = React.useMemo(
- () => toast.duration || durationFromToaster || TOAST_LIFETIME,
- [toast.duration, durationFromToaster],
- );
- const closeTimerStartTimeRef = React.useRef(0);
- const offset = React.useRef(0);
- const lastCloseTimerStartTimeRef = React.useRef(0);
- const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
- const [y, x] = position.split('-');
- const toastsHeightBefore = React.useMemo(() => {
- return heights.reduce((prev, curr, reducerIndex) => {
- // Calculate offset up until current toast
- if (reducerIndex >= heightIndex) {
- return prev;
- }
+ const heightIndex = () => heights().findIndex((height) => height.toastId === props.toast.id) || 0;
+ const closeButton = createMemo(() => props.toast.closeButton ?? props.closeButton);
- return prev + curr.height;
- }, 0);
- }, [heights, heightIndex]);
- const isDocumentHidden = useIsDocumentHidden();
+ let closeTimerStartTimeRef = 0;
+ let lastCloseTimerStartTimeRef = 0;
+ let pointerStartRef: { x: number; y: number } | null = null;
+
+ const coords = () => props.position.split('-');
+
+ const toastsHeightBefore = createMemo(() => {
+ let total = 0;
+ for (let i = 0; i < heights().length; i++) {
+ if (i >= heightIndex()) break;
+ total += heights()[i].height;
+ }
+ return total;
+ });
- const invert = toast.invert || ToasterInvert;
- const disabled = toastType === 'loading';
+ const isDocumentHidden = useIsDocumentHidden();
- offset.current = React.useMemo(() => heightIndex * gap + toastsHeightBefore, [heightIndex, toastsHeightBefore]);
+ const invert = () => props.toast.invert ?? props.invert;
+ const isLoading = () => props.toast.type === 'loading';
- React.useEffect(() => {
- remainingTime.current = duration;
- }, [duration]);
+ const offset = createMemo(() => heightIndex() * props.gap + toastsHeightBefore());
- React.useEffect(() => {
- // Trigger enter animation without using CSS animation
+ onMount(() => {
setMounted(true);
- }, []);
-
- React.useEffect(() => {
- const toastNode = toastRef.current;
- if (toastNode) {
- const height = toastNode.getBoundingClientRect().height;
- // Add toast height to 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;
- const originalHeight = toastNode.style.height;
- toastNode.style.height = 'auto';
- const newHeight = toastNode.getBoundingClientRect().height;
- toastNode.style.height = originalHeight;
+ onMount(() => {
+ const originalHeight = toastRef.style.height;
+ toastRef.style.height = 'auto';
+ const newHeight = toastRef.getBoundingClientRect().height;
+ toastRef.style.height = originalHeight;
setInitialHeight(newHeight);
- setHeights((heights) => {
- const alreadyExists = heights.find((height) => height.toastId === toast.id);
- if (!alreadyExists) {
- return [{ toastId: toast.id, height: newHeight, position: toast.position }, ...heights];
- } else {
- return heights.map((height) => (height.toastId === toast.id ? { ...height, height: newHeight } : height));
- }
+ createEffect(() => {
+ const toastId = props.toast.id;
+ const position = props.toast.position ?? props.position;
+ setHeights((heights) => {
+ const alreadyExists = heights.find((height) => height.toastId === toastId);
+ if (!alreadyExists) {
+ return [{ toastId, height: newHeight, position }, ...heights];
+ } else {
+ return heights.map((height) => (height.toastId === toastId ? { ...height, height: newHeight } : height));
+ }
+ });
});
- }, [mounted, toast.title, toast.description, setHeights, toast.id]);
+ });
- const deleteToast = React.useCallback(() => {
+ const deleteToast = () => {
// Save the offset for the exit swipe animation
setRemoved(true);
- setOffsetBeforeRemove(offset.current);
- setHeights((h) => h.filter((height) => height.toastId !== toast.id));
+ setOffsetBeforeRemove(offset());
+ setHeights((h) => h.filter((height) => height.toastId !== props.toast.id));
setTimeout(() => {
- removeToast(toast);
+ props.removeToast(props.toast);
}, TIME_BEFORE_UNMOUNT);
- }, [toast, removeToast, setHeights, offset]);
+ };
- React.useEffect(() => {
- if ((toast.promise && toastType === 'loading') || toast.duration === Infinity || toast.type === 'loading') return;
+ createEffect(() => {
+ if (props.toast.duration === Infinity || isLoading()) return;
let timeoutId: NodeJS.Timeout;
// Pause the timer on each hover
const pauseTimer = () => {
- if (lastCloseTimerStartTimeRef.current < closeTimerStartTimeRef.current) {
+ if (lastCloseTimerStartTimeRef < closeTimerStartTimeRef) {
// Get the elapsed time since the timer started
- const elapsedTime = new Date().getTime() - closeTimerStartTimeRef.current;
+ const elapsedTime = new Date().getTime() - closeTimerStartTimeRef;
- remainingTime.current = remainingTime.current - elapsedTime;
+ remainingTime = remainingTime - elapsedTime;
}
- lastCloseTimerStartTimeRef.current = new Date().getTime();
+ lastCloseTimerStartTimeRef = new Date().getTime();
};
const startTimer = () => {
// 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.current === Infinity) return;
+ if (remainingTime === Infinity) return;
- closeTimerStartTimeRef.current = new Date().getTime();
+ closeTimerStartTimeRef = new Date().getTime();
// Let the toast know it has started
timeoutId = setTimeout(() => {
- toast.onAutoClose?.(toast);
+ props.toast.onAutoClose?.(props.toast);
deleteToast();
- }, remainingTime.current);
+ }, remainingTime);
};
- if (expanded || interacting || isDocumentHidden) {
+ if (props.expanded || props.interacting || isDocumentHidden()) {
pauseTimer();
} else {
startTimer();
}
- return () => clearTimeout(timeoutId);
- }, [expanded, interacting, toast, toastType, isDocumentHidden, deleteToast]);
+ onCleanup(() => {
+ clearTimeout(timeoutId);
+ });
+ });
- React.useEffect(() => {
- if (toast.delete) {
+ createEffect(() => {
+ if (props.toast.delete) {
deleteToast();
}
- }, [deleteToast, toast.delete]);
+ });
function getLoadingIcon() {
- if (icons?.loading) {
+ if (props.icons?.loading) {
return (
- {icons.loading}
+ {props.icons.loading}
);
}
- return ;
+ return ;
}
- const icon = toast.icon || icons?.[toastType] || getAsset(toastType);
+ const icon = () =>
+ props.toast.icon ?? (props.toast.type ? (props.icons?.[props.toast.type] ?? getAsset(props.toast.type)()) : null);
return (
{
setSwiping(false);
setSwipeDirection(null);
- pointerStartRef.current = null;
+ pointerStartRef = null;
}}
onPointerDown={(event) => {
- if (disabled || !dismissible) return;
- dragStartTime.current = new Date();
- setOffsetBeforeRemove(offset.current);
+ if (isLoading() || !props.toast.dismissible) return;
+ dragStartTime = new Date();
+ setOffsetBeforeRemove(offset());
// Ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping)
(event.target as HTMLElement).setPointerCapture(event.pointerId);
if ((event.target as HTMLElement).tagName === 'BUTTON') return;
setSwiping(true);
- pointerStartRef.current = { x: event.clientX, y: event.clientY };
+ pointerStartRef = { x: event.clientX, y: event.clientY };
}}
onPointerUp={() => {
- if (swipeOut || !dismissible) return;
-
- pointerStartRef.current = null;
- const swipeAmountX = Number(
- toastRef.current?.style.getPropertyValue('--swipe-amount-x').replace('px', '') || 0,
- );
- const swipeAmountY = Number(
- toastRef.current?.style.getPropertyValue('--swipe-amount-y').replace('px', '') || 0,
- );
- const timeTaken = new Date().getTime() - dragStartTime.current?.getTime();
-
- const swipeAmount = swipeDirection === 'x' ? swipeAmountX : swipeAmountY;
+ if (swipeOut() || !props.toast.dismissible || !dragStartTime) return;
+
+ pointerStartRef = null;
+
+ const swipeAmountX = Number(toastRef.style.getPropertyValue('--swipe-amount-x').replace('px', ''));
+ const swipeAmountY = Number(toastRef.style.getPropertyValue('--swipe-amount-y').replace('px', ''));
+ const timeTaken = new Date().getTime() - dragStartTime.getTime();
+
+ const swipeAmount = swipeDirection() === 'x' ? swipeAmountX : swipeAmountY;
const velocity = Math.abs(swipeAmount) / timeTaken;
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
- setOffsetBeforeRemove(offset.current);
+ setOffsetBeforeRemove(offset());
+ props.toast.onDismiss?.(props.toast);
- toast.onDismiss?.(toast);
-
- if (swipeDirection === 'x') {
+ if (swipeDirection() === 'x') {
setSwipeOutDirection(swipeAmountX > 0 ? 'right' : 'left');
} else {
setSwipeOutDirection(swipeAmountY > 0 ? 'down' : 'up');
@@ -345,30 +292,30 @@ const Toast = (props: ToastProps) => {
return;
} else {
- toastRef.current?.style.setProperty('--swipe-amount-x', `0px`);
- toastRef.current?.style.setProperty('--swipe-amount-y', `0px`);
+ toastRef.style.setProperty('--swipe-amount-x', `0px`);
+ toastRef.style.setProperty('--swipe-amount-y', `0px`);
}
setIsSwiped(false);
setSwiping(false);
setSwipeDirection(null);
}}
onPointerMove={(event) => {
- if (!pointerStartRef.current || !dismissible) return;
+ if (!pointerStartRef || !props.toast.dismissible) return;
- const isHighlighted = window.getSelection()?.toString().length > 0;
+ const isHighlighted = (window.getSelection()?.toString().length ?? 0) > 0;
if (isHighlighted) return;
- const yDelta = event.clientY - pointerStartRef.current.y;
- const xDelta = event.clientX - pointerStartRef.current.x;
+ const yDelta = event.clientY - pointerStartRef.y;
+ const xDelta = event.clientX - pointerStartRef.x;
- const swipeDirections = props.swipeDirections ?? getDefaultSwipeDirections(position);
+ const swipeDirections = props.swipeDirections ?? getDefaultSwipeDirections(props.position);
// Determine swipe direction if not already locked
- if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) {
+ if (!swipeDirection() && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) {
setSwipeDirection(Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y');
}
- let swipeAmount = { x: 0, y: 0 };
+ const swipeAmount = { x: 0, y: 0 };
const getDampening = (delta: number) => {
const factor = Math.abs(delta) / 20;
@@ -377,7 +324,7 @@ const Toast = (props: ToastProps) => {
};
// Only apply swipe in the locked direction
- if (swipeDirection === 'y') {
+ if (swipeDirection() === 'y') {
// Handle vertical swipes
if (swipeDirections.includes('top') || swipeDirections.includes('bottom')) {
if ((swipeDirections.includes('top') && yDelta < 0) || (swipeDirections.includes('bottom') && yDelta > 0)) {
@@ -389,7 +336,7 @@ const Toast = (props: ToastProps) => {
swipeAmount.y = Math.abs(dampenedDelta) < Math.abs(yDelta) ? dampenedDelta : yDelta;
}
}
- } else if (swipeDirection === 'x') {
+ } else if (swipeDirection() === 'x') {
// Handle horizontal swipes
if (swipeDirections.includes('left') || swipeDirections.includes('right')) {
if ((swipeDirections.includes('left') && xDelta < 0) || (swipeDirections.includes('right') && xDelta > 0)) {
@@ -408,94 +355,89 @@ const Toast = (props: ToastProps) => {
}
// Apply transform using both x and y values
- toastRef.current?.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`);
- toastRef.current?.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`);
+ toastRef.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`);
+ toastRef.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`);
}}
>
- {closeButton && !toast.jsx && toastType !== 'loading' ? (
+
- ) : null}
- {/* TODO: This can be cleaner */}
- {(toastType || toast.icon || toast.promise) &&
- toast.icon !== null &&
- (icons?.[toastType] !== null || toast.icon) ? (
-
- {toast.promise || (toast.type === 'loading' && !toast.icon) ? toast.icon || getLoadingIcon() : null}
- {toast.type !== 'loading' ? icon : null}
+
+
+
+
+
+ {props.toast.icon ?? getLoadingIcon()}
+
+ {icon()}
- ) : null}
+
-
-
- {toast.jsx ? toast.jsx : typeof toast.title === 'function' ? toast.title() : toast.title}
+
+
+ {props.toast.jsx ?? props.toast.title}
- {toast.description ? (
+
- {typeof toast.description === 'function' ? toast.description() : toast.description}
+ {props.toast.description}
- ) : null}
+
- {React.isValidElement(toast.cancel) ? (
- toast.cancel
- ) : toast.cancel && isAction(toast.cancel) ? (
-
- ) : null}
- {React.isValidElement(toast.action) ? (
- toast.action
- ) : toast.action && isAction(toast.action) ? (
-
- ) : null}
+
+
+ {(el) => (
+
+ )}
+
+
+
+ {(el) => (
+
+ )}
+
);
};
@@ -514,7 +456,7 @@ function getDocumentDirection(): ToasterProps['dir'] {
}
function assignOffset(defaultOffset: ToasterProps['offset'], mobileOffset: ToasterProps['mobileOffset']) {
- const styles = {} as React.CSSProperties;
+ const styles: JSX.CSSProperties = {};
[defaultOffset, mobileOffset].forEach((offset, index) => {
const isMobile = index === 1;
@@ -530,7 +472,7 @@ function assignOffset(defaultOffset: ToasterProps['offset'], mobileOffset: Toast
if (typeof offset === 'number' || typeof offset === 'string') {
assignAll(offset);
} else if (typeof offset === 'object') {
- ['top', 'right', 'bottom', 'left'].forEach((key) => {
+ (['top', 'right', 'bottom', 'left'] as (keyof typeof offset)[]).forEach((key) => {
if (offset[key] === undefined) {
styles[`${prefix}-${key}`] = defaultValue;
} else {
@@ -546,149 +488,138 @@ function assignOffset(defaultOffset: ToasterProps['offset'], mobileOffset: Toast
}
function useSonner() {
- const [activeToasts, setActiveToasts] = React.useState
([]);
+ const [activeToasts, setActiveToasts] = createSignal([]);
- React.useEffect(() => {
+ createEffect(() => {
return ToastState.subscribe((toast) => {
if ((toast as ToastToDismiss).dismiss) {
setTimeout(() => {
- ReactDOM.flushSync(() => {
- setActiveToasts((toasts) => toasts.filter((t) => t.id !== toast.id));
- });
+ setActiveToasts((toasts) => toasts.filter((t) => t.id !== toast.id));
});
return;
}
// Prevent batching, temp solution.
setTimeout(() => {
- ReactDOM.flushSync(() => {
- setActiveToasts((toasts) => {
- const indexOfExistingToast = toasts.findIndex((t) => t.id === toast.id);
-
- // Update the toast if it already exists
- if (indexOfExistingToast !== -1) {
- return [
- ...toasts.slice(0, indexOfExistingToast),
- { ...toasts[indexOfExistingToast], ...toast },
- ...toasts.slice(indexOfExistingToast + 1),
- ];
- }
+ setActiveToasts((toasts) => {
+ const indexOfExistingToast = toasts.findIndex((t) => t.id === toast.id);
+
+ // Update the toast if it already exists
+ if (indexOfExistingToast !== -1) {
+ return [
+ ...toasts.slice(0, indexOfExistingToast),
+ { ...toasts[indexOfExistingToast], ...toast },
+ ...toasts.slice(indexOfExistingToast + 1),
+ ];
+ }
- return [toast, ...toasts];
- });
+ return [toast, ...toasts];
});
});
});
- }, []);
+ });
return {
toasts: activeToasts,
};
}
-const Toaster = React.forwardRef(function Toaster(props, ref) {
- const {
- invert,
- position = 'bottom-right',
- hotkey = ['altKey', 'KeyT'],
- expand,
- closeButton,
- className,
- offset,
- mobileOffset,
- theme = 'light',
- richColors,
- duration,
- style,
- visibleToasts = VISIBLE_TOASTS_AMOUNT,
- toastOptions,
- dir = getDocumentDirection(),
- gap = GAP,
- icons,
- containerAriaLabel = 'Notifications',
- } = props;
- const [toasts, setToasts] = React.useState([]);
- const possiblePositions = React.useMemo(() => {
+const Toaster = (propsRaw: ToasterProps, ref: HTMLElement) => {
+ const props = mergeProps(
+ {
+ position: 'bottom-right',
+ hotkey: ['altKey', 'KeyT'],
+ theme: 'light',
+ visibleToasts: VISIBLE_TOASTS_AMOUNT,
+ dir: getDocumentDirection(),
+ gap: GAP,
+ containerAriaLabel: 'Notifications',
+ },
+ propsRaw,
+ );
+
+ /**
+ * Use a store instead of a signal for fine-grained reactivity.
+ * All the setters only have to change the deepest part of the tree
+ * to maintain referential integrity when rendered in the DOM.
+ */
+ const [toastsStore, setToastsStore] = createStore<{ toasts: ToastT[] }>({ toasts: [] });
+ const possiblePositions = createMemo(() => {
return Array.from(
- new Set([position].concat(toasts.filter((toast) => toast.position).map((toast) => toast.position))),
+ new Set(
+ [props.position].concat(
+ toastsStore.toasts.map((toast) => toast.position).filter((position) => position !== undefined),
+ ),
+ ),
);
- }, [toasts, position]);
- const [heights, setHeights] = React.useState([]);
- const [expanded, setExpanded] = React.useState(false);
- const [interacting, setInteracting] = React.useState(false);
- const [actualTheme, setActualTheme] = React.useState(
- theme !== 'system'
- ? theme
- : typeof window !== 'undefined'
- ? window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
- ? 'dark'
- : 'light'
- : 'light',
- );
+ });
+
+ const [expanded, setExpanded] = createSignal(false);
+ const [interacting, setInteracting] = createSignal(false);
+ const [actualTheme, setActualTheme] = createSignal('system');
+
+ const hotkeyLabel = () => props.hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, '');
- const listRef = React.useRef(null);
- const hotkeyLabel = hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, '');
- const lastFocusedElementRef = React.useRef(null);
- const isFocusWithinRef = React.useRef(false);
+ let listRef!: HTMLOListElement;
+ let lastFocusedElementRef: HTMLElement | null = null;
+ let isFocusWithinRef = false;
- const removeToast = React.useCallback((toastToRemove: ToastT) => {
- setToasts((toasts) => {
+ const removeToast = (toastToRemove: ToastT) => {
+ setToastsStore('toasts', (toasts) => {
if (!toasts.find((toast) => toast.id === toastToRemove.id)?.delete) {
ToastState.dismiss(toastToRemove.id);
}
return toasts.filter(({ id }) => id !== toastToRemove.id);
});
- }, []);
+ };
- React.useEffect(() => {
- return ToastState.subscribe((toast) => {
+ onMount(() => {
+ // eslint-disable-next-line solid/reactivity
+ const unsub = ToastState.subscribe((toast) => {
if ((toast as ToastToDismiss).dismiss) {
// Prevent batching of other state updates
requestAnimationFrame(() => {
- setToasts((toasts) => toasts.map((t) => (t.id === toast.id ? { ...t, delete: true } : t)));
+ setToastsStore(
+ 'toasts',
+ produce((toasts) => {
+ toasts.forEach((t) => {
+ if (t.id === toast.id) t.delete = true;
+ });
+ }),
+ );
});
return;
}
- // Prevent batching, temp solution.
- setTimeout(() => {
- ReactDOM.flushSync(() => {
- setToasts((toasts) => {
- const indexOfExistingToast = toasts.findIndex((t) => t.id === toast.id);
-
- // Update the toast if it already exists
- if (indexOfExistingToast !== -1) {
- return [
- ...toasts.slice(0, indexOfExistingToast),
- { ...toasts[indexOfExistingToast], ...toast },
- ...toasts.slice(indexOfExistingToast + 1),
- ];
- }
+ // Update (Fine-grained)
+ const changedIndex = toastsStore.toasts.findIndex((t) => t.id === toast.id);
+ if (changedIndex !== -1) {
+ setToastsStore('toasts', [changedIndex], reconcile(toast));
+ return;
+ }
- return [toast, ...toasts];
- });
- });
- });
+ // Insert (Fine-grained)
+ setToastsStore(
+ 'toasts',
+ produce((toasts) => {
+ toasts.unshift(toast);
+ }),
+ );
});
- }, [toasts]);
- React.useEffect(() => {
- if (theme !== 'system') {
- setActualTheme(theme);
+ onCleanup(() => {
+ unsub();
+ });
+ });
+
+ createEffect(() => {
+ if (props.theme !== 'system') {
+ setActualTheme(props.theme);
return;
}
- if (theme === 'system') {
- // check if current preference is dark
- if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
- // it's currently dark
- setActualTheme('dark');
- } else {
- // it's not dark
- setActualTheme('light');
- }
- }
+ setActualTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (typeof window === 'undefined') return;
const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
@@ -702,8 +633,9 @@ const Toaster = React.forwardRef(function Toaster(pro
setActualTheme('light');
}
});
- } catch (error) {
+ } catch {
// Safari < 14
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
darkMediaQuery.addListener(({ matches }) => {
try {
if (matches) {
@@ -716,162 +648,159 @@ const Toaster = React.forwardRef(function Toaster(pro
}
});
}
- }, [theme]);
+ });
- React.useEffect(() => {
+ createEffect(() => {
// Ensure expanded is always false when no toasts are present / only one left
- if (toasts.length <= 1) {
+ if (toastsStore.toasts.length <= 1) {
setExpanded(false);
}
- }, [toasts]);
+ });
- React.useEffect(() => {
+ onMount(() => {
const handleKeyDown = (event: KeyboardEvent) => {
- const isHotkeyPressed = hotkey.every((key) => (event as any)[key] || event.code === key);
+ const isHotkeyPressed = props.hotkey.every((key) => event[key as keyof KeyboardEvent] ?? event.code === key);
if (isHotkeyPressed) {
setExpanded(true);
- listRef.current?.focus();
+ listRef.focus();
}
- if (
- event.code === 'Escape' &&
- (document.activeElement === listRef.current || listRef.current?.contains(document.activeElement))
- ) {
+ if (event.code === 'Escape' && (document.activeElement === listRef || listRef.contains(document.activeElement))) {
setExpanded(false);
}
};
+
document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [hotkey]);
+ onCleanup(() => {
+ document.removeEventListener('keydown', handleKeyDown);
+ });
+ });
- React.useEffect(() => {
- if (listRef.current) {
- return () => {
- if (lastFocusedElementRef.current) {
- lastFocusedElementRef.current.focus({ preventScroll: true });
- lastFocusedElementRef.current = null;
- isFocusWithinRef.current = false;
- }
- };
+ onCleanup(() => {
+ if (lastFocusedElementRef) {
+ lastFocusedElementRef.focus({ preventScroll: true });
+ lastFocusedElementRef = null;
+ isFocusWithinRef = false;
}
- }, [listRef.current]);
-
- return (
- // Remove item from normal navigation flow, only available via hotkey
-
- {possiblePositions.map((position, index) => {
- const [y, x] = position.split('-');
-
- if (!toasts.length) return null;
-
- return (
- 1 && !expand}
- data-x-position={x}
- style={
- {
- '--front-toast-height': `${heights[0]?.height || 0}px`,
- '--width': `${TOAST_WIDTH}px`,
- '--gap': `${gap}px`,
- ...style,
- ...assignOffset(offset, mobileOffset),
- } as React.CSSProperties
- }
- onBlur={(event) => {
- if (isFocusWithinRef.current && !event.currentTarget.contains(event.relatedTarget)) {
- isFocusWithinRef.current = false;
- if (lastFocusedElementRef.current) {
- lastFocusedElementRef.current.focus({ preventScroll: true });
- lastFocusedElementRef.current = null;
- }
- }
- }}
- onFocus={(event) => {
- const isNotDismissible =
- event.target instanceof HTMLElement && event.target.dataset.dismissible === 'false';
-
- if (isNotDismissible) return;
+ });
- if (!isFocusWithinRef.current) {
- isFocusWithinRef.current = true;
- lastFocusedElementRef.current = event.relatedTarget as HTMLElement;
- }
- }}
- onMouseEnter={() => setExpanded(true)}
- onMouseMove={() => setExpanded(true)}
- onMouseLeave={() => {
- // Avoid setting expanded to false when interacting with a toast, e.g. swiping
- if (!interacting) {
- setExpanded(false);
- }
- }}
- onDragEnd={() => setExpanded(false)}
- onPointerDown={(event) => {
- const isNotDismissible =
- event.target instanceof HTMLElement && event.target.dataset.dismissible === 'false';
+ const toastOptions = createMemo(() => props.toastOptions);
- if (isNotDismissible) return;
- setInteracting(true);
- }}
- onPointerUp={() => setInteracting(false)}
- >
- {toasts
- .filter((toast) => (!toast.position && index === 0) || toast.position === position)
- .map((toast, index) => (
- t.position == toast.position)}
- heights={heights.filter((h) => h.position == toast.position)}
- setHeights={setHeights}
- expandByDefault={expand}
- gap={gap}
- expanded={expanded}
- swipeDirections={props.swipeDirections}
- />
- ))}
-
- );
- })}
-
+ return (
+ 0}>
+ {/* Remove item from normal navigation flow, only available via hotkey */}
+
+
+ {(position, index) => {
+ const [y, x] = position.split('-');
+
+ return (
+ 1 && !props.expand}
+ data-x-position={x}
+ style={{
+ '--front-toast-height': `${heights()[0]?.height || 0}px`,
+ '--width': `${TOAST_WIDTH}px`,
+ '--gap': `${props.gap}px`,
+ ...props.style,
+ ...assignOffset(props.offset, props.mobileOffset),
+ }}
+ onBlur={(event) => {
+ if (isFocusWithinRef && !event.currentTarget.contains(event.relatedTarget as Node)) {
+ isFocusWithinRef = false;
+ if (lastFocusedElementRef) {
+ lastFocusedElementRef.focus({ preventScroll: true });
+ lastFocusedElementRef = null;
+ }
+ }
+ }}
+ onFocus={(event) => {
+ const isNotDismissible =
+ event.target instanceof HTMLElement && event.target.dataset['dismissible'] === 'false';
+
+ if (isNotDismissible) return;
+
+ if (!isFocusWithinRef) {
+ isFocusWithinRef = true;
+ lastFocusedElementRef = event.relatedTarget as HTMLElement;
+ }
+ }}
+ onMouseEnter={() => setExpanded(true)}
+ onMouseMove={() => setExpanded(true)}
+ onMouseLeave={() => {
+ // Avoid setting expanded to false when interacting with a toast, e.g. swiping
+ if (!interacting()) {
+ setExpanded(false);
+ }
+ }}
+ onDragEnd={() => setExpanded(false)}
+ onPointerDown={(event) => {
+ const isNotDismissible =
+ event.target instanceof HTMLElement && event.target.dataset['dismissible'] === 'false';
+
+ if (isNotDismissible) return;
+ setInteracting(true);
+ }}
+ onPointerUp={() => setInteracting(false)}
+ >
+ (!toast.position && index() === 0) || toast.position === position,
+ )}
+ >
+ {(toast, index) => (
+ t.position === toast.position)}
+ expandByDefault={!!props.expand}
+ gap={props.gap}
+ expanded={expanded()}
+ swipeDirections={props.swipeDirections}
+ />
+ )}
+
+
+ );
+ }}
+
+
+
);
-});
+};
-export { toast, Toaster, type ExternalToast, type ToastT, type ToasterProps, useSonner };
-export { type ToastClassnames, type ToastToDismiss, type Action } from './types';
+export { type Action, type ToastClassnames, type ToastToDismiss } from './types';
+export { toast, Toaster, useSonner, type ExternalToast, type ToasterProps, type ToastT };
diff --git a/src/state.ts b/src/state.ts
index 23aa3717..e0bc568b 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -1,3 +1,4 @@
+import { JSX } from 'solid-js';
import type {
ExternalToast,
PromiseData,
@@ -8,15 +9,13 @@ import type {
ToastTypes,
} from './types';
-import React from 'react';
-
let toastsCounter = 1;
-type titleT = (() => React.ReactNode) | React.ReactNode;
+type titleT = JSX.Element;
class Observer {
- subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>;
- toasts: Array;
+ subscribers: ((toast: ToastT | ToastToDismiss) => void)[];
+ toasts: (ToastT | ToastToDismiss)[];
dismissedToasts: Set;
constructor() {
@@ -36,7 +35,9 @@ class Observer {
};
publish = (data: ToastT) => {
- this.subscribers.forEach((subscriber) => subscriber(data));
+ this.subscribers.forEach((subscriber) => {
+ subscriber(data);
+ });
};
addToast = (data: ToastT) => {
@@ -49,15 +50,15 @@ class Observer {
message?: titleT;
type?: ToastTypes;
promise?: PromiseT;
- jsx?: React.ReactElement;
+ jsx?: JSX.Element;
},
) => {
const { message, ...rest } = data;
- const id = typeof data?.id === 'number' || data.id?.length > 0 ? data.id : toastsCounter++;
+ const id = typeof data.id === 'number' || (data.id && data.id.length > 0) ? data.id : toastsCounter++;
const alreadyExists = this.toasts.find((toast) => {
return toast.id === id;
});
- const dismissible = data.dismissible === undefined ? true : data.dismissible;
+ const dismissible = !!data.dismissible;
if (this.dismissedToasts.has(id)) {
this.dismissedToasts.delete(id);
@@ -85,40 +86,46 @@ class Observer {
return id;
};
- dismiss = (id?: number | string) => {
+ dismiss = (id: number | string) => {
if (id) {
this.dismissedToasts.add(id);
- requestAnimationFrame(() => this.subscribers.forEach((subscriber) => subscriber({ id, dismiss: true })));
+ requestAnimationFrame(() => {
+ this.subscribers.forEach((subscriber) => {
+ subscriber({ id, dismiss: true });
+ });
+ });
} else {
this.toasts.forEach((toast) => {
- this.subscribers.forEach((subscriber) => subscriber({ id: toast.id, dismiss: true }));
+ this.subscribers.forEach((subscriber) => {
+ subscriber({ id: toast.id, dismiss: true });
+ });
});
}
return id;
};
- message = (message: titleT | React.ReactNode, data?: ExternalToast) => {
+ message = (message: titleT, data?: ExternalToast) => {
return this.create({ ...data, message });
};
- error = (message: titleT | React.ReactNode, data?: ExternalToast) => {
+ error = (message: titleT, data?: ExternalToast) => {
return this.create({ ...data, message, type: 'error' });
};
- success = (message: titleT | React.ReactNode, data?: ExternalToast) => {
+ success = (message: titleT, data?: ExternalToast) => {
return this.create({ ...data, type: 'success', message });
};
- info = (message: titleT | React.ReactNode, data?: ExternalToast) => {
+ info = (message: titleT, data?: ExternalToast) => {
return this.create({ ...data, type: 'info', message });
};
- warning = (message: titleT | React.ReactNode, data?: ExternalToast) => {
+ warning = (message: titleT, data?: ExternalToast) => {
return this.create({ ...data, type: 'warning', message });
};
- loading = (message: titleT | React.ReactNode, data?: ExternalToast) => {
+ loading = (message: titleT, data?: ExternalToast) => {
return this.create({ ...data, type: 'loading', message });
};
@@ -128,16 +135,15 @@ class Observer {
return;
}
- let id: string | number | undefined = undefined;
- if (data.loading !== undefined) {
- id = this.create({
- ...data,
- promise,
- type: 'loading',
- message: data.loading,
- description: typeof data.description !== 'function' ? data.description : undefined,
- });
- }
+ let id = data.loading
+ ? this.create({
+ ...data,
+ promise,
+ type: 'loading',
+ message: data.loading,
+ description: typeof data.description !== 'function' ? data.description : undefined,
+ })
+ : undefined;
const p = Promise.resolve(promise instanceof Function ? promise() : promise);
@@ -147,8 +153,7 @@ class Observer {
const originalPromise = p
.then(async (response) => {
result = ['resolve', response];
- const isReactElementResponse = React.isValidElement(response);
- if (isReactElementResponse) {
+ if (isJSXElement(response)) {
shouldDismiss = false;
this.create({ id, type: 'default', message: response });
} else if (isHttpResponse(response) && !response.ok) {
@@ -162,11 +167,9 @@ class Observer {
? await data.description(`HTTP error! status: ${response.status}`)
: data.description;
- const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData);
+ const isExtendedResult = typeof promiseData === 'object' && !isJSXElement(promiseData);
- const toastSettings: PromiseIExtendedResult = isExtendedResult
- ? (promiseData as PromiseIExtendedResult)
- : { message: promiseData };
+ const toastSettings: PromiseIExtendedResult = isExtendedResult ? promiseData : { message: promiseData };
this.create({ id, type: 'error', description, ...toastSettings });
} else if (response instanceof Error) {
@@ -177,11 +180,9 @@ class Observer {
const description =
typeof data.description === 'function' ? await data.description(response) : data.description;
- const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData);
+ const isExtendedResult = typeof promiseData === 'object' && !isJSXElement(promiseData);
- const toastSettings: PromiseIExtendedResult = isExtendedResult
- ? (promiseData as PromiseIExtendedResult)
- : { message: promiseData };
+ const toastSettings: PromiseIExtendedResult = isExtendedResult ? promiseData : { message: promiseData };
this.create({ id, type: 'error', description, ...toastSettings });
} else if (data.success !== undefined) {
@@ -191,16 +192,14 @@ class Observer {
const description =
typeof data.description === 'function' ? await data.description(response) : data.description;
- const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData);
+ const isExtendedResult = typeof promiseData === 'object' && !isJSXElement(promiseData);
- const toastSettings: PromiseIExtendedResult = isExtendedResult
- ? (promiseData as PromiseIExtendedResult)
- : { message: promiseData };
+ const toastSettings: PromiseIExtendedResult = isExtendedResult ? promiseData : { message: promiseData };
this.create({ id, type: 'success', description, ...toastSettings });
}
})
- .catch(async (error) => {
+ .catch(async (error: unknown) => {
result = ['reject', error];
if (data.error !== undefined) {
shouldDismiss = false;
@@ -208,17 +207,15 @@ class Observer {
const description = typeof data.description === 'function' ? await data.description(error) : data.description;
- const isExtendedResult = typeof promiseData === 'object' && !React.isValidElement(promiseData);
+ const isExtendedResult = typeof promiseData === 'object' && !isJSXElement(promiseData);
- const toastSettings: PromiseIExtendedResult = isExtendedResult
- ? (promiseData as PromiseIExtendedResult)
- : { message: promiseData };
+ const toastSettings: PromiseIExtendedResult = isExtendedResult ? promiseData : { message: promiseData };
this.create({ id, type: 'error', description, ...toastSettings });
}
})
.finally(() => {
- if (shouldDismiss) {
+ if (id !== undefined && shouldDismiss) {
// Toast is still in load state (and will be indefinitely — dismiss it)
this.dismiss(id);
id = undefined;
@@ -228,20 +225,24 @@ class Observer {
});
const unwrap = () =>
- new Promise((resolve, reject) =>
- originalPromise.then(() => (result[0] === 'reject' ? reject(result[1]) : resolve(result[1]))).catch(reject),
+ new Promise(
+ (resolve, reject) =>
+ void originalPromise
+ .then(() => {
+ if (result[0] === 'reject') {
+ reject(result[1] instanceof Error ? result[1] : new Error(String(result[1])));
+ } else {
+ 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 });
- }
+ return id ? Object.assign(id, { unwrap }) : { unwrap };
};
- custom = (jsx: (id: number | string) => React.ReactElement, data?: ExternalToast) => {
- const id = data?.id || toastsCounter++;
+ custom = (jsx: (id: number | string) => JSX.Element, data?: ExternalToast) => {
+ const id = data?.id ?? toastsCounter++;
this.create({ jsx: jsx(id), id, ...data });
return id;
};
@@ -255,7 +256,7 @@ export const ToastState = new Observer();
// bind this to the toast function
const toastFunction = (message: titleT, data?: ExternalToast) => {
- const id = data?.id || toastsCounter++;
+ const id = data?.id ?? toastsCounter++;
ToastState.addToast({
title: message,
@@ -265,16 +266,16 @@ const toastFunction = (message: titleT, data?: ExternalToast) => {
return id;
};
-const isHttpResponse = (data: any): data is Response => {
- return (
- data &&
- typeof data === 'object' &&
- 'ok' in data &&
- typeof data.ok === 'boolean' &&
- 'status' in data &&
- typeof data.status === 'number'
- );
-};
+const isHttpResponse = (data: unknown): data is Response =>
+ !!data &&
+ typeof data === 'object' &&
+ 'ok' in data &&
+ typeof data.ok === 'boolean' &&
+ 'status' in data &&
+ typeof data.status === 'number';
+
+const isJSXElement = (value: unknown): value is JSX.Element =>
+ typeof value === 'function' || (typeof value === 'object' && value !== null && 'props' in value);
const basicToast = toastFunction;
diff --git a/src/types.ts b/src/types.ts
index ac0563f6..76abc927 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,33 +1,30 @@
-import React from 'react';
+import { JSX } from 'solid-js';
export type ToastTypes = 'normal' | 'action' | 'success' | 'info' | 'warning' | 'error' | 'loading' | 'default';
-export type PromiseT = Promise | (() => Promise);
+export type PromiseT = Promise | (() => Promise);
-export interface PromiseIExtendedResult extends ExternalToast {
- message: React.ReactNode;
-}
+export type PromiseIExtendedResult = {
+ message: JSX.Element;
+} & ExternalToast;
-export type PromiseTExtendedResult =
+export type PromiseTExtendedResult =
| PromiseIExtendedResult
| ((data: Data) => PromiseIExtendedResult | Promise);
-export type PromiseTResult =
- | string
- | React.ReactNode
- | ((data: Data) => React.ReactNode | string | Promise);
+export type PromiseTResult = JSX.Element | ((data: Data) => JSX.Element | Promise);
export type PromiseExternalToast = Omit;
-export type PromiseData = PromiseExternalToast & {
- loading?: string | React.ReactNode;
+export type PromiseData = PromiseExternalToast & {
+ loading?: JSX.Element;
success?: PromiseTResult | PromiseTExtendedResult;
error?: PromiseTResult | PromiseTExtendedResult;
description?: PromiseTResult;
finally?: () => void | Promise;
};
-export interface ToastClassnames {
+export type ToastClassnames = {
toast?: string;
title?: string;
description?: string;
@@ -35,82 +32,71 @@ export interface ToastClassnames {
closeButton?: string;
cancelButton?: string;
actionButton?: string;
- success?: string;
- error?: string;
- info?: string;
- warning?: string;
- loading?: string;
- default?: string;
content?: string;
icon?: string;
-}
+} & Record;
-export interface ToastIcons {
- success?: React.ReactNode;
- info?: React.ReactNode;
- warning?: React.ReactNode;
- error?: React.ReactNode;
- loading?: React.ReactNode;
- close?: React.ReactNode;
-}
+export type ToastIcons = {
+ close?: JSX.Element;
+} & Record;
-export interface Action {
- label: React.ReactNode;
- onClick: (event: React.MouseEvent) => void;
- actionButtonStyle?: React.CSSProperties;
-}
+export type Action = {
+ label: JSX.Element;
+ onClick: (event: MouseEvent) => void;
+ actionButtonStyle?: JSX.CSSProperties;
+};
-export interface ToastT {
+export type ToastT = {
id: number | string;
- title?: (() => React.ReactNode) | React.ReactNode;
+ title?: JSX.Element;
type?: ToastTypes;
- icon?: React.ReactNode;
- jsx?: React.ReactNode;
+ icon?: JSX.Element;
+ jsx?: JSX.Element;
richColors?: boolean;
invert?: boolean;
closeButton?: boolean;
dismissible?: boolean;
- description?: (() => React.ReactNode) | React.ReactNode;
+ description?: JSX.Element;
duration?: number;
delete?: boolean;
- action?: Action | React.ReactNode;
- cancel?: Action | React.ReactNode;
+ action?: Action | JSX.Element;
+ cancel?: Action | JSX.Element;
onDismiss?: (toast: ToastT) => void;
onAutoClose?: (toast: ToastT) => void;
promise?: PromiseT;
- cancelButtonStyle?: React.CSSProperties;
- actionButtonStyle?: React.CSSProperties;
- style?: React.CSSProperties;
+ cancelButtonStyle?: JSX.CSSProperties;
+ actionButtonStyle?: JSX.CSSProperties;
+ style?: JSX.CSSProperties;
unstyled?: boolean;
className?: string;
classNames?: ToastClassnames;
descriptionClassName?: string;
position?: Position;
-}
+};
-export function isAction(action: Action | React.ReactNode): action is Action {
- return (action as Action).label !== undefined;
+export function isAction(action: Action | JSX.Element): action is Action {
+ return !!action && (action as Action).label !== undefined;
}
export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center';
-export interface HeightT {
+export type HeightT = {
height: number;
toastId: number | string;
position: Position;
-}
+};
-interface ToastOptions {
+type ToastOptions = {
className?: string;
closeButton?: boolean;
descriptionClassName?: string;
- style?: React.CSSProperties;
- cancelButtonStyle?: React.CSSProperties;
- actionButtonStyle?: React.CSSProperties;
+ style?: JSX.CSSProperties;
+ cancelButtonStyle?: JSX.CSSProperties;
+ actionButtonStyle?: JSX.CSSProperties;
duration?: number;
unstyled?: boolean;
classNames?: ToastClassnames;
closeButtonAriaLabel?: string;
-}
+};
type Offset =
| {
@@ -122,7 +108,7 @@ type Offset =
| string
| number;
-export interface ToasterProps {
+export type ToasterProps = {
invert?: boolean;
theme?: 'light' | 'dark' | 'system';
position?: Position;
@@ -135,46 +121,43 @@ export interface ToasterProps {
closeButton?: boolean;
toastOptions?: ToastOptions;
className?: string;
- style?: React.CSSProperties;
+ style?: JSX.CSSProperties;
offset?: Offset;
mobileOffset?: Offset;
dir?: 'rtl' | 'ltr' | 'auto';
swipeDirections?: SwipeDirection[];
icons?: ToastIcons;
containerAriaLabel?: string;
-}
+};
export type SwipeDirection = 'top' | 'right' | 'bottom' | 'left';
-export interface ToastProps {
+export type ToastProps = {
toast: ToastT;
toasts: ToastT[];
index: number;
swipeDirections?: SwipeDirection[];
expanded: boolean;
invert: boolean;
- heights: HeightT[];
- setHeights: React.Dispatch>;
removeToast: (toast: ToastT) => void;
- gap?: number;
+ gap: number;
position: Position;
visibleToasts: number;
expandByDefault: boolean;
closeButton: boolean;
interacting: boolean;
- style?: React.CSSProperties;
- cancelButtonStyle?: React.CSSProperties;
- actionButtonStyle?: React.CSSProperties;
+ style?: JSX.CSSProperties;
+ cancelButtonStyle?: JSX.CSSProperties;
+ actionButtonStyle?: JSX.CSSProperties;
duration?: number;
- className?: string;
+ class?: string;
unstyled?: boolean;
descriptionClassName?: string;
- loadingIcon?: React.ReactNode;
classNames?: ToastClassnames;
icons?: ToastIcons;
closeButtonAriaLabel?: string;
defaultRichColors?: boolean;
-}
+};
export enum SwipeStateTypes {
SwipedOut = 'SwipedOut',
@@ -184,10 +167,10 @@ export enum SwipeStateTypes {
export type Theme = 'light' | 'dark';
-export interface ToastToDismiss {
+export type ToastToDismiss = {
id: number | string;
dismiss: boolean;
-}
+};
export type ExternalToast = Omit & {
id?: number | string;
diff --git a/tsconfig.json b/tsconfig.json
index 8ff924f7..f04417e2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,10 +1,25 @@
{
"compilerOptions": {
- "jsx": "react",
- "target": "ES2018",
"moduleResolution": "node",
+
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+
+ "allowSyntheticDefaultImports": true,
"esModuleInterop": true,
- "lib": ["es2015", "dom"]
- },
- "include": ["src"]
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+
+ "strict": true,
+ "allowUnusedLabels": false,
+ "allowUnreachableCode": false,
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noUncheckedSideEffectImports": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ }
}
diff --git a/tsup.config.ts b/tsup.config.ts
new file mode 100644
index 00000000..ff9c321e
--- /dev/null
+++ b/tsup.config.ts
@@ -0,0 +1,47 @@
+import { defineConfig } from 'tsup';
+import * as preset from 'tsup-preset-solid';
+
+const preset_options: preset.PresetOptions = {
+ // array or single object
+ entries: [
+ // default entry (index)
+ {
+ // entries with '.tsx' extension will have `solid` export condition generated
+ entry: 'src/index.tsx',
+ // will generate a separate development entry
+ dev_entry: true,
+ },
+ ],
+ // Set to `true` to remove all `console.*` calls and `debugger` statements in prod builds
+ drop_console: true,
+ // Set to `true` to generate a CommonJS build alongside ESM
+ // cjs: true,
+};
+
+const CI =
+ process.env['CI'] === 'true' ||
+ process.env['GITHUB_ACTIONS'] === 'true' ||
+ process.env['CI'] === '"1"' ||
+ process.env['GITHUB_ACTIONS'] === '"1"';
+
+export default defineConfig((config) => {
+ const watching = !!config.watch;
+
+ const parsed_options = preset.parsePresetOptions(preset_options, watching);
+
+ if (!watching && !CI) {
+ const package_fields = preset.generatePackageExports(parsed_options);
+ console.log(`package.json: \n\n${JSON.stringify(package_fields, null, 2)}\n\n`);
+ // will update ./package.json with the correct export fields
+ void preset.writePackageJson(package_fields);
+ }
+
+ config.minify = true;
+ config.target = 'es2018';
+ config.sourcemap = true;
+ config.dts = true;
+ // config.format = ['esm', 'cjs']
+ config.injectStyle = true;
+
+ return preset.generateTsupOptions(parsed_options);
+});