diff --git a/.gitignore b/.gitignore index ba3cd539..ba198768 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ build # misc .DS_Store *.pem +.idea # debug npm-debug.log* diff --git a/package.json b/package.json index 09e12b7d..322b37ac 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,39 @@ }, "default": "./dist/index.js" }, + "./core": { + "import": { + "types": "./dist/core/index.d.mts", + "default": "./dist/core/index.mjs" + }, + "require": { + "types": "./dist/core/index.d.ts", + "default": "./dist/core/index.js" + }, + "default": "./dist/core/index.js" + }, + "./react": { + "import": { + "types": "./dist/react/index.d.mts", + "default": "./dist/react/index.mjs" + }, + "require": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" + }, + "default": "./dist/react/index.js" + }, + "./vanilla": { + "import": { + "types": "./dist/vanilla/index.d.mts", + "default": "./dist/vanilla/index.mjs" + }, + "require": { + "types": "./dist/vanilla/index.d.ts", + "default": "./dist/vanilla/index.js" + }, + "default": "./dist/vanilla/index.js" + }, "./dist/styles.css": "./dist/styles.css" }, "main": "./dist/index.js", diff --git a/src/core/constants.ts b/src/core/constants.ts new file mode 100644 index 00000000..c573563f --- /dev/null +++ b/src/core/constants.ts @@ -0,0 +1,23 @@ +// Visible toasts amount +export const VISIBLE_TOASTS_AMOUNT = 3; + +// Viewport padding +export const VIEWPORT_OFFSET = '24px'; + +// Mobile viewport padding +export const MOBILE_VIEWPORT_OFFSET = '16px'; + +// Default lifetime of a toasts (in ms) +export const TOAST_LIFETIME = 4000; + +// Default toast width +export const TOAST_WIDTH = 356; + +// Default gap between toasts +export const GAP = 14; + +// Threshold to dismiss a toast +export const SWIPE_THRESHOLD = 45; + +// Equal to exit animation duration +export const TIME_BEFORE_UNMOUNT = 200; diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 00000000..e55c7451 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './state'; +export * from './constants'; +export * from './utils'; diff --git a/src/state.ts b/src/core/state.ts similarity index 60% rename from src/state.ts rename to src/core/state.ts index 23aa3717..4811b889 100644 --- a/src/state.ts +++ b/src/core/state.ts @@ -1,32 +1,45 @@ import type { ExternalToast, PromiseData, - PromiseIExtendedResult, + PromiseExtendedResult, PromiseT, + ToastContent, ToastT, ToastToDismiss, ToastTypes, } from './types'; -import React from 'react'; - let toastsCounter = 1; -type titleT = (() => React.ReactNode) | React.ReactNode; +export interface ContentValidator { + isValidElement: (content: any) => content is TContent; +} + +class DefaultContentValidator implements ContentValidator { + isValidElement(content: any): content is any { + return false; + } +} -class Observer { - subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>; - toasts: Array; +class Observer { + subscribers: Array<(toast: ExternalToast | ToastToDismiss) => void>; + toasts: Array | ToastToDismiss>; dismissedToasts: Set; + contentValidator: ContentValidator; - constructor() { + constructor(contentValidator?: ContentValidator) { this.subscribers = []; this.toasts = []; this.dismissedToasts = new Set(); + this.contentValidator = contentValidator || new DefaultContentValidator(); + } + + setContentValidator(validator: ContentValidator) { + this.contentValidator = validator; } // We use arrow functions to maintain the correct `this` reference - subscribe = (subscriber: (toast: ToastT | ToastToDismiss) => void) => { + subscribe = (subscriber: (toast: ToastT | ToastToDismiss) => void) => { this.subscribers.push(subscriber); return () => { @@ -35,22 +48,22 @@ class Observer { }; }; - publish = (data: ToastT) => { + publish = (data: ToastT) => { this.subscribers.forEach((subscriber) => subscriber(data)); }; - addToast = (data: ToastT) => { + addToast = (data: ToastT) => { this.publish(data); this.toasts = [...this.toasts, data]; }; create = ( - data: ExternalToast & { - message?: titleT; + data: ExternalToast & { + message?: ToastContent; type?: ToastTypes; promise?: PromiseT; - jsx?: React.ReactElement; - }, + jsx?: TContent; + } ) => { const { message, ...rest } = data; const id = typeof data?.id === 'number' || data.id?.length > 0 ? data.id : toastsCounter++; @@ -66,14 +79,15 @@ class Observer { if (alreadyExists) { this.toasts = this.toasts.map((toast) => { if (toast.id === id) { - this.publish({ ...toast, ...data, id, title: message }); - return { + const updatedToast = { ...toast, ...data, id, dismissible, title: message, }; + this.publish(updatedToast); + return updatedToast; } return toast; @@ -88,7 +102,12 @@ class Observer { dismiss = (id?: number | string) => { if (id) { this.dismissedToasts.add(id); - requestAnimationFrame(() => this.subscribers.forEach((subscriber) => subscriber({ id, dismiss: true }))); + if (typeof requestAnimationFrame !== 'undefined') { + requestAnimationFrame(() => this.subscribers.forEach((subscriber) => subscriber({ id, dismiss: true }))); + } else { + // Fallback for environments without requestAnimationFrame + setTimeout(() => this.subscribers.forEach((subscriber) => subscriber({ id, dismiss: true })), 0); + } } else { this.toasts.forEach((toast) => { this.subscribers.forEach((subscriber) => subscriber({ id: toast.id, dismiss: true })); @@ -98,31 +117,31 @@ class Observer { return id; }; - message = (message: titleT | React.ReactNode, data?: ExternalToast) => { + message = (message: ToastContent, data?: ExternalToast) => { return this.create({ ...data, message }); }; - error = (message: titleT | React.ReactNode, data?: ExternalToast) => { + error = (message: ToastContent, data?: ExternalToast) => { return this.create({ ...data, message, type: 'error' }); }; - success = (message: titleT | React.ReactNode, data?: ExternalToast) => { + success = (message: ToastContent, data?: ExternalToast) => { return this.create({ ...data, type: 'success', message }); }; - info = (message: titleT | React.ReactNode, data?: ExternalToast) => { + info = (message: ToastContent, data?: ExternalToast) => { return this.create({ ...data, type: 'info', message }); }; - warning = (message: titleT | React.ReactNode, data?: ExternalToast) => { + warning = (message: ToastContent, data?: ExternalToast) => { return this.create({ ...data, type: 'warning', message }); }; - loading = (message: titleT | React.ReactNode, data?: ExternalToast) => { + loading = (message: ToastContent, data?: ExternalToast) => { return this.create({ ...data, type: 'loading', message }); }; - promise = (promise: PromiseT, data?: PromiseData) => { + promise = (promise: PromiseT, data?: PromiseData) => { if (!data) { // Nothing to show return; @@ -147,8 +166,8 @@ class Observer { const originalPromise = p .then(async (response) => { result = ['resolve', response]; - const isReactElementResponse = React.isValidElement(response); - if (isReactElementResponse) { + const isElementResponse = this.contentValidator.isValidElement(response); + if (isElementResponse) { shouldDismiss = false; this.create({ id, type: 'default', message: response }); } else if (isHttpResponse(response) && !response.ok) { @@ -162,10 +181,10 @@ 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' && !this.contentValidator.isValidElement(promiseData); - const toastSettings: PromiseIExtendedResult = isExtendedResult - ? (promiseData as PromiseIExtendedResult) + const toastSettings: PromiseExtendedResult = isExtendedResult + ? (promiseData as PromiseExtendedResult) : { message: promiseData }; this.create({ id, type: 'error', description, ...toastSettings }); @@ -177,24 +196,24 @@ 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' && !this.contentValidator.isValidElement(promiseData); - const toastSettings: PromiseIExtendedResult = isExtendedResult - ? (promiseData as PromiseIExtendedResult) + const toastSettings: PromiseExtendedResult = isExtendedResult + ? (promiseData as PromiseExtendedResult) : { message: promiseData }; this.create({ id, type: 'error', description, ...toastSettings }); } else if (data.success !== undefined) { shouldDismiss = false; - const promiseData = typeof data.success === 'function' ? await data.success(response) : data.success; + const promiseData = typeof data.success === 'function' ? await (data.success as Function)(response) : data.success; 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' && !this.contentValidator.isValidElement(promiseData); - const toastSettings: PromiseIExtendedResult = isExtendedResult - ? (promiseData as PromiseIExtendedResult) + const toastSettings: PromiseExtendedResult = isExtendedResult + ? (promiseData as PromiseExtendedResult) : { message: promiseData }; this.create({ id, type: 'success', description, ...toastSettings }); @@ -208,10 +227,10 @@ 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' && !this.contentValidator.isValidElement(promiseData); - const toastSettings: PromiseIExtendedResult = isExtendedResult - ? (promiseData as PromiseIExtendedResult) + const toastSettings: PromiseExtendedResult = isExtendedResult + ? (promiseData as PromiseExtendedResult) : { message: promiseData }; this.create({ id, type: 'error', description, ...toastSettings }); @@ -240,7 +259,7 @@ class Observer { } }; - custom = (jsx: (id: number | string) => React.ReactElement, data?: ExternalToast) => { + custom = (jsx: (id: number | string) => TContent, data?: ExternalToast) => { const id = data?.id || toastsCounter++; this.create({ jsx: jsx(id), id, ...data }); return id; @@ -251,20 +270,6 @@ class Observer { }; } -export const ToastState = new Observer(); - -// bind this to the toast function -const toastFunction = (message: titleT, data?: ExternalToast) => { - const id = data?.id || toastsCounter++; - - ToastState.addToast({ - title: message, - ...data, - id, - }); - return id; -}; - const isHttpResponse = (data: any): data is Response => { return ( data && @@ -276,24 +281,39 @@ const isHttpResponse = (data: any): data is Response => { ); }; -const basicToast = toastFunction; - -const getHistory = () => ToastState.toasts; -const getToasts = () => ToastState.getActiveToasts(); - -// We use `Object.assign` to maintain the correct types as we would lose them otherwise -export const toast = Object.assign( - basicToast, - { - success: ToastState.success, - info: ToastState.info, - warning: ToastState.warning, - error: ToastState.error, - custom: ToastState.custom, - message: ToastState.message, - promise: ToastState.promise, - dismiss: ToastState.dismiss, - loading: ToastState.loading, - }, - { getHistory, getToasts }, -); +export function createToastState(contentValidator?: ContentValidator) { + const state = new Observer(contentValidator); + + const basicToast = (message: ToastContent, data?: ExternalToast) => { + const id = data?.id || toastsCounter++; + + state.addToast({ + title: message, + ...data, + id, + }); + return id; + }; + + const getHistory = () => state.toasts; + const getToasts = () => state.getActiveToasts(); + + // We use `Object.assign` to maintain the correct types as we would lose them otherwise + const toast = Object.assign( + basicToast, + { + success: state.success, + info: state.info, + warning: state.warning, + error: state.error, + custom: state.custom, + message: state.message, + promise: state.promise, + dismiss: state.dismiss, + loading: state.loading, + }, + { getHistory, getToasts }, + ); + + return { state, toast }; +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 00000000..8d3cd5dc --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,173 @@ +export type ToastTypes = 'normal' | 'action' | 'success' | 'info' | 'warning' | 'error' | 'loading' | 'default'; + +export type PromiseT = Promise | (() => Promise); + +export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center'; + +export type SwipeDirection = 'top' | 'right' | 'bottom' | 'left'; + +export type Theme = 'light' | 'dark'; + +export enum SwipeStateTypes { + SwipedOut = 'SwipedOut', + SwipedBack = 'SwipedBack', + NotSwiped = 'NotSwiped', +} + +export type ToastContent = T | string | (() => T | string); + +export interface Action { + label: ToastContent; + onClick: (event: TEvent) => void; + actionButtonStyle?: Record; +} + +export interface ToastClassnames { + toast?: string; + title?: string; + description?: string; + loader?: string; + closeButton?: string; + cancelButton?: string; + actionButton?: string; + success?: string; + error?: string; + info?: string; + warning?: string; + loading?: string; + default?: string; + content?: string; + icon?: string; +} + +export interface ToastIcons { + success?: TIcon; + info?: TIcon; + warning?: TIcon; + error?: TIcon; + loading?: TIcon; + close?: TIcon; +} + +export interface ToastT { + id: number | string; + toasterId?: string; + title?: ToastContent; + type?: ToastTypes; + icon?: TContent; + jsx?: TContent; + richColors?: boolean; + invert?: boolean; + closeButton?: boolean; + dismissible?: boolean; + description?: ToastContent; + duration?: number; + delete?: boolean; + action?: Action | TContent; + cancel?: Action | TContent; + onDismiss?: (toast: ToastT) => void; + onAutoClose?: (toast: ToastT) => void; + promise?: PromiseT; + cancelButtonStyle?: Record; + actionButtonStyle?: Record; + style?: Record; + unstyled?: boolean; + className?: string; + classNames?: ToastClassnames; + descriptionClassName?: string; + position?: Position; + testId?: string; +} + +export interface ToastToDismiss { + id: number | string; + dismiss: boolean; +} + +export type ExternalToast = Omit, 'id' | 'type' | 'title' | 'jsx' | 'delete' | 'promise'> & { + id?: number | string; + toasterId?: string; +}; + +export interface PromiseExtendedResult extends ExternalToast { + message: ToastContent; +} + +export type PromiseTExtendedResult = + | PromiseExtendedResult + | ((data: Data) => PromiseExtendedResult | Promise>); + +export type PromiseTResult = + | string + | TContent + | ((data: Data) => TContent | string | Promise); + +export type PromiseExternalToast = Omit, 'description'>; + +export type PromiseData = PromiseExternalToast & { + loading?: string | TContent; + success?: PromiseTResult | PromiseTExtendedResult; + error?: PromiseTResult | PromiseTExtendedResult; + description?: PromiseTResult; + finally?: () => void | Promise; +}; + +export interface HeightT { + height: number; + toastId: number | string; + position: Position; +} + +export function isAction( + action: Action | TContent +): action is Action { + return (action as Action).label !== undefined; +} + +export type Offset = + | { + top?: string | number; + right?: string | number; + bottom?: string | number; + left?: string | number; + } + | string + | number; + +export interface ToastOptions { + className?: string; + closeButton?: boolean; + descriptionClassName?: string; + style?: Record; + cancelButtonStyle?: Record; + actionButtonStyle?: Record; + duration?: number; + unstyled?: boolean; + classNames?: ToastClassnames; + closeButtonAriaLabel?: string; + toasterId?: string; +} + +export interface ToasterConfig { + id?: string; + invert?: boolean; + theme?: 'light' | 'dark' | 'system'; + position?: Position; + hotkey?: string[]; + richColors?: boolean; + expand?: boolean; + duration?: number; + gap?: number; + visibleToasts?: number; + closeButton?: boolean; + toastOptions?: ToastOptions; + className?: string; + style?: Record; + offset?: Offset; + mobileOffset?: Offset; + dir?: 'rtl' | 'ltr' | 'auto'; + swipeDirections?: SwipeDirection[]; + icons?: ToastIcons; + customAriaLabel?: string; + containerAriaLabel?: string; +} diff --git a/src/core/utils.ts b/src/core/utils.ts new file mode 100644 index 00000000..7825eac8 --- /dev/null +++ b/src/core/utils.ts @@ -0,0 +1,93 @@ +import type { Position, SwipeDirection, Offset } from './types'; +import { VIEWPORT_OFFSET, MOBILE_VIEWPORT_OFFSET } from './constants'; + +export function cn(...classes: (string | undefined)[]): string { + return classes.filter(Boolean).join(' '); +} + +export function getDefaultSwipeDirections(position: string): Array { + const [y, x] = position.split('-'); + const directions: Array = []; + + if (y) { + directions.push(y as SwipeDirection); + } + + if (x) { + directions.push(x as SwipeDirection); + } + + return directions; +} + +export function getDocumentDirection(): 'ltr' | 'rtl' | 'auto' { + if (typeof window === 'undefined') return 'ltr'; + if (typeof document === 'undefined') return 'ltr'; + + const dirAttribute = document.documentElement.getAttribute('dir'); + + if (dirAttribute === 'auto' || !dirAttribute) { + return window.getComputedStyle(document.documentElement).direction as 'ltr' | 'rtl' | 'auto'; + } + + return dirAttribute as 'ltr' | 'rtl' | 'auto'; +} + +export function assignOffset(defaultOffset?: Offset, mobileOffset?: Offset): Record { + const styles: Record = {}; + + [defaultOffset, mobileOffset].forEach((offset, index) => { + const isMobile = index === 1; + const prefix = isMobile ? '--mobile-offset' : '--offset'; + const defaultValue = isMobile ? MOBILE_VIEWPORT_OFFSET : VIEWPORT_OFFSET; + + function assignAll(offset: string | number) { + ['top', 'right', 'bottom', 'left'].forEach((key) => { + styles[`${prefix}-${key}`] = typeof offset === 'number' ? `${offset}px` : offset; + }); + } + + if (typeof offset === 'number' || typeof offset === 'string') { + assignAll(offset); + } else if (typeof offset === 'object' && offset !== null) { + ['top', 'right', 'bottom', 'left'].forEach((key) => { + if (offset[key as keyof typeof offset] === undefined) { + styles[`${prefix}-${key}`] = defaultValue; + } else { + const value = offset[key as keyof typeof offset]; + styles[`${prefix}-${key}`] = typeof value === 'number' ? `${value}px` : String(value); + } + }); + } else { + assignAll(defaultValue); + } + }); + + return styles; +} + +export function isMobile(): boolean { + if (typeof window === 'undefined') return false; + return window.innerWidth <= 768; +} + +export function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + return 'light'; +} + +export function calculateOffset( + heights: Array<{ height: number; toastId: number | string; position: Position }>, + heightIndex: number, + gap: number +): number { + return heights.reduce((prev, curr, reducerIndex) => { + if (reducerIndex >= heightIndex) { + return prev; + } + return prev + curr.height; + }, 0) + heightIndex * gap; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..5d395807 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from './react'; diff --git a/src/assets.tsx b/src/react/assets.tsx similarity index 100% rename from src/assets.tsx rename to src/react/assets.tsx diff --git a/src/hooks.tsx b/src/react/hooks.tsx similarity index 100% rename from src/hooks.tsx rename to src/react/hooks.tsx diff --git a/src/index.tsx b/src/react/index.tsx similarity index 91% rename from src/index.tsx rename to src/react/index.tsx index ce031e81..86775afe 100644 --- a/src/index.tsx +++ b/src/react/index.tsx @@ -6,60 +6,31 @@ import ReactDOM from 'react-dom'; import { CloseIcon, getAsset, Loader } from './assets'; import { useIsDocumentHidden } from './hooks'; import { toast, ToastState } from './state'; -import './styles.css'; import { isAction, - SwipeDirection, type ExternalToast, - type HeightT, type ToasterProps, type ToastProps, type ToastT, type ToastToDismiss, + type HeightT, } from './types'; - -// Visible toasts amount -const VISIBLE_TOASTS_AMOUNT = 3; - -// Viewport padding -const VIEWPORT_OFFSET = '24px'; - -// Mobile viewport padding -const MOBILE_VIEWPORT_OFFSET = '16px'; - -// Default lifetime of a toasts (in ms) -const TOAST_LIFETIME = 4000; - -// Default toast width -const TOAST_WIDTH = 356; - -// Default gap between toasts -const GAP = 14; - -// Threshold to dismiss a toast -const SWIPE_THRESHOLD = 45; - -// Equal to exit animation duration -const TIME_BEFORE_UNMOUNT = 200; - -function cn(...classes: (string | undefined)[]) { - return classes.filter(Boolean).join(' '); -} - -function getDefaultSwipeDirections(position: string): Array { - const [y, x] = position.split('-'); - const directions: Array = []; - - if (y) { - directions.push(y as SwipeDirection); - } - - if (x) { - directions.push(x as SwipeDirection); - } - - return directions; -} +import { + VISIBLE_TOASTS_AMOUNT, + VIEWPORT_OFFSET, + MOBILE_VIEWPORT_OFFSET, + TOAST_LIFETIME, + TOAST_WIDTH, + GAP, + SWIPE_THRESHOLD, + TIME_BEFORE_UNMOUNT, + cn, + getDefaultSwipeDirections, + getDocumentDirection, + assignOffset, +} from '../core'; + +import '../styles.css'; const Toast = (props: ToastProps) => { const { @@ -504,51 +475,6 @@ const Toast = (props: ToastProps) => { ); }; -function getDocumentDirection(): ToasterProps['dir'] { - if (typeof window === 'undefined') return 'ltr'; - if (typeof document === 'undefined') return 'ltr'; // For Fresh purpose - - const dirAttribute = document.documentElement.getAttribute('dir'); - - if (dirAttribute === 'auto' || !dirAttribute) { - return window.getComputedStyle(document.documentElement).direction as ToasterProps['dir']; - } - - return dirAttribute as ToasterProps['dir']; -} - -function assignOffset(defaultOffset: ToasterProps['offset'], mobileOffset: ToasterProps['mobileOffset']) { - const styles = {} as React.CSSProperties; - - [defaultOffset, mobileOffset].forEach((offset, index) => { - const isMobile = index === 1; - const prefix = isMobile ? '--mobile-offset' : '--offset'; - const defaultValue = isMobile ? MOBILE_VIEWPORT_OFFSET : VIEWPORT_OFFSET; - - function assignAll(offset: string | number) { - ['top', 'right', 'bottom', 'left'].forEach((key) => { - styles[`${prefix}-${key}`] = typeof offset === 'number' ? `${offset}px` : offset; - }); - } - - if (typeof offset === 'number' || typeof offset === 'string') { - assignAll(offset); - } else if (typeof offset === 'object') { - ['top', 'right', 'bottom', 'left'].forEach((key) => { - if (offset[key] === undefined) { - styles[`${prefix}-${key}`] = defaultValue; - } else { - styles[`${prefix}-${key}`] = typeof offset[key] === 'number' ? `${offset[key]}px` : offset[key]; - } - }); - } else { - assignAll(defaultValue); - } - }); - - return styles; -} - function useSonner() { const [activeToasts, setActiveToasts] = React.useState([]); diff --git a/src/react/state.ts b/src/react/state.ts new file mode 100644 index 00000000..b304fb13 --- /dev/null +++ b/src/react/state.ts @@ -0,0 +1,12 @@ +import React from 'react'; +import { createToastState, type ContentValidator } from '../core'; + +class ReactContentValidator implements ContentValidator { + isValidElement(content: any): content is React.ReactNode { + return React.isValidElement(content); + } +} + +const { state: ToastState, toast } = createToastState(new ReactContentValidator()); + +export { ToastState, toast }; diff --git a/src/react/types.ts b/src/react/types.ts new file mode 100644 index 00000000..38434384 --- /dev/null +++ b/src/react/types.ts @@ -0,0 +1,106 @@ +import React from 'react'; + +import type { + ToastT as CoreToastT, + ExternalToast as CoreExternalToast, + ToasterConfig as CoreToasterConfig, + ToastOptions as CoreToastOptions, + ToastIcons as CoreToastIcons, + Action as CoreAction, + PromiseData as CorePromiseData, + ToastClassnames, + Position, + SwipeDirection, + Offset, + HeightT, + ToastToDismiss, + ToastTypes, +} from '../core'; + +export type ReactNode = React.ReactNode; +export type ReactElement = React.ReactElement; +export type CSSProperties = React.CSSProperties; +export type MouseEvent = React.MouseEvent; + +export interface Action extends CoreAction { + label: ReactNode; + actionButtonStyle?: CSSProperties; +} + +export interface ToastIcons extends CoreToastIcons {} + +export interface ToastT extends Omit, 'action' | 'cancel' | 'style' | 'cancelButtonStyle' | 'actionButtonStyle'> { + title?: (() => ReactNode) | ReactNode; + description?: (() => ReactNode) | ReactNode; + icon?: ReactNode; + jsx?: ReactNode; + action?: Action | ReactNode; + cancel?: Action | ReactNode; + style?: CSSProperties; + cancelButtonStyle?: CSSProperties; + actionButtonStyle?: CSSProperties; +} + +export type ExternalToast = Omit, 'action' | 'cancel' | 'style' | 'cancelButtonStyle' | 'actionButtonStyle'> & { + action?: Action | ReactNode; + cancel?: Action | ReactNode; + style?: CSSProperties; + cancelButtonStyle?: CSSProperties; + actionButtonStyle?: CSSProperties; +}; + +export interface ToastOptions extends Omit { + style?: CSSProperties; + cancelButtonStyle?: CSSProperties; + actionButtonStyle?: CSSProperties; +} + +export interface ToasterProps extends Omit, 'toastOptions' | 'style' | 'icons'> { + toastOptions?: ToastOptions; + style?: CSSProperties; + icons?: ToastIcons; +} + +export type PromiseData = CorePromiseData; + +export interface ToastProps { + toast: ToastT; + toasts: ToastT[]; + index: number; + swipeDirections?: SwipeDirection[]; + expanded: boolean; + invert: boolean; + heights: HeightT[]; + setHeights: React.Dispatch>; + removeToast: (toast: ToastT) => void; + gap?: number; + position: Position; + visibleToasts: number; + expandByDefault: boolean; + closeButton: boolean; + interacting: boolean; + style?: CSSProperties; + cancelButtonStyle?: CSSProperties; + actionButtonStyle?: CSSProperties; + duration?: number; + className?: string; + unstyled?: boolean; + descriptionClassName?: string; + loadingIcon?: ReactNode; + classNames?: ToastClassnames; + icons?: ToastIcons; + closeButtonAriaLabel?: string; + defaultRichColors?: boolean; +} + +export type { + ToastClassnames, + Position, + SwipeDirection, + Offset, + HeightT, + ToastToDismiss, + ToastTypes, +}; + +export { isAction } from '../core'; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index e74740d6..00000000 --- a/src/types.ts +++ /dev/null @@ -1,200 +0,0 @@ -import React from 'react'; - -export type ToastTypes = 'normal' | 'action' | 'success' | 'info' | 'warning' | 'error' | 'loading' | 'default'; - -export type PromiseT = Promise | (() => Promise); - -export interface PromiseIExtendedResult extends ExternalToast { - message: React.ReactNode; -} - -export type PromiseTExtendedResult = - | PromiseIExtendedResult - | ((data: Data) => PromiseIExtendedResult | Promise); - -export type PromiseTResult = - | string - | React.ReactNode - | ((data: Data) => React.ReactNode | string | Promise); - -export type PromiseExternalToast = Omit; - -export type PromiseData = PromiseExternalToast & { - loading?: string | React.ReactNode; - success?: PromiseTResult | PromiseTExtendedResult; - error?: PromiseTResult | PromiseTExtendedResult; - description?: PromiseTResult; - finally?: () => void | Promise; -}; - -export interface ToastClassnames { - toast?: string; - title?: string; - description?: string; - loader?: string; - closeButton?: string; - cancelButton?: string; - actionButton?: string; - success?: string; - error?: string; - info?: string; - warning?: string; - loading?: string; - default?: string; - content?: string; - icon?: string; -} - -export interface ToastIcons { - success?: React.ReactNode; - info?: React.ReactNode; - warning?: React.ReactNode; - error?: React.ReactNode; - loading?: React.ReactNode; - close?: React.ReactNode; -} - -export interface Action { - label: React.ReactNode; - onClick: (event: React.MouseEvent) => void; - actionButtonStyle?: React.CSSProperties; -} - -export interface ToastT { - id: number | string; - toasterId?: string; - title?: (() => React.ReactNode) | React.ReactNode; - type?: ToastTypes; - icon?: React.ReactNode; - jsx?: React.ReactNode; - richColors?: boolean; - invert?: boolean; - closeButton?: boolean; - dismissible?: boolean; - description?: (() => React.ReactNode) | React.ReactNode; - duration?: number; - delete?: boolean; - action?: Action | React.ReactNode; - cancel?: Action | React.ReactNode; - onDismiss?: (toast: ToastT) => void; - onAutoClose?: (toast: ToastT) => void; - promise?: PromiseT; - cancelButtonStyle?: React.CSSProperties; - actionButtonStyle?: React.CSSProperties; - style?: React.CSSProperties; - unstyled?: boolean; - className?: string; - classNames?: ToastClassnames; - descriptionClassName?: string; - position?: Position; - testId?: string; -} - -export function isAction(action: Action | React.ReactNode): action is Action { - return (action as Action).label !== undefined; -} - -export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center'; -export interface HeightT { - height: number; - toastId: number | string; - position: Position; -} - -interface ToastOptions { - className?: string; - closeButton?: boolean; - descriptionClassName?: string; - style?: React.CSSProperties; - cancelButtonStyle?: React.CSSProperties; - actionButtonStyle?: React.CSSProperties; - duration?: number; - unstyled?: boolean; - classNames?: ToastClassnames; - closeButtonAriaLabel?: string; - toasterId?: string; -} - -type Offset = - | { - top?: string | number; - right?: string | number; - bottom?: string | number; - left?: string | number; - } - | string - | number; - -export interface ToasterProps { - id?: string; - invert?: boolean; - theme?: 'light' | 'dark' | 'system'; - position?: Position; - hotkey?: string[]; - richColors?: boolean; - expand?: boolean; - duration?: number; - gap?: number; - visibleToasts?: number; - closeButton?: boolean; - toastOptions?: ToastOptions; - className?: string; - style?: React.CSSProperties; - offset?: Offset; - mobileOffset?: Offset; - dir?: 'rtl' | 'ltr' | 'auto'; - swipeDirections?: SwipeDirection[]; - icons?: ToastIcons; - customAriaLabel?: string; - containerAriaLabel?: string; -} - -export type SwipeDirection = 'top' | 'right' | 'bottom' | 'left'; - -export interface ToastProps { - toast: ToastT; - toasts: ToastT[]; - index: number; - swipeDirections?: SwipeDirection[]; - expanded: boolean; - invert: boolean; - heights: HeightT[]; - setHeights: React.Dispatch>; - removeToast: (toast: ToastT) => void; - gap?: number; - position: Position; - visibleToasts: number; - expandByDefault: boolean; - closeButton: boolean; - interacting: boolean; - style?: React.CSSProperties; - cancelButtonStyle?: React.CSSProperties; - actionButtonStyle?: React.CSSProperties; - duration?: number; - className?: string; - unstyled?: boolean; - descriptionClassName?: string; - loadingIcon?: React.ReactNode; - classNames?: ToastClassnames; - icons?: ToastIcons; - closeButtonAriaLabel?: string; - defaultRichColors?: boolean; -} - -export enum SwipeStateTypes { - SwipedOut = 'SwipedOut', - SwipedBack = 'SwipedBack', - NotSwiped = 'NotSwiped', -} - -export type Theme = 'light' | 'dark'; - -export interface ToastToDismiss { - id: number | string; - dismiss: boolean; -} - -export type ExternalToast = Omit & { - id?: number | string; - toasterId?: string; -}; diff --git a/src/vanilla/assets.ts b/src/vanilla/assets.ts new file mode 100644 index 00000000..91929c09 --- /dev/null +++ b/src/vanilla/assets.ts @@ -0,0 +1,55 @@ +import type { ToastTypes } from '../core'; + +export function getAssetIcon(type: ToastTypes): string | null { + switch (type) { + case 'success': + return successIcon; + case 'info': + return infoIcon; + case 'warning': + return warningIcon; + case 'error': + return errorIcon; + default: + return null; + } +} + +export function createLoader(visible: boolean): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.className = 'sonner-loading-wrapper'; + wrapper.setAttribute('data-visible', String(visible)); + + const spinner = document.createElement('div'); + spinner.className = 'sonner-spinner'; + + for (let i = 0; i < 12; i++) { + const bar = document.createElement('div'); + bar.className = 'sonner-loading-bar'; + spinner.appendChild(bar); + } + + wrapper.appendChild(spinner); + return wrapper; +} + +const successIcon = ` + +`; + +const warningIcon = ` + +`; + +const infoIcon = ` + +`; + +const errorIcon = ` + +`; + +export const closeIcon = ` + + +`; diff --git a/src/vanilla/index.ts b/src/vanilla/index.ts new file mode 100644 index 00000000..42a1ec02 --- /dev/null +++ b/src/vanilla/index.ts @@ -0,0 +1,924 @@ +import { ToastState } from './state'; +import type { HeightT, Position, ToastClassnames, ToasterConfig, ToastT, ToastToDismiss } from '../core'; +import { + assignOffset, + cn, + GAP, + getDefaultSwipeDirections, + getDocumentDirection, + getSystemTheme, + SWIPE_THRESHOLD, + TIME_BEFORE_UNMOUNT, + TOAST_LIFETIME, + TOAST_WIDTH, + VISIBLE_TOASTS_AMOUNT, +} from '../core'; +import { closeIcon, createLoader, getAssetIcon } from './assets'; + +import '../styles.css'; + +export { toast } from './state'; +export type { + ToastT, + ExternalToast, + ToasterConfig, + ToastTypes, + Position, + Theme, + ToastClassnames, + Action, +} from '../core'; + +export interface ToasterOptions extends ToasterConfig { + /** + * Container element to render toasts in + * If not provided, will be appended to document.body + */ + container?: HTMLElement; +} + +interface ToastElementState { + element: HTMLLIElement; + toast: ToastT; + mounted: boolean; + removed: boolean; + swiping: boolean; + swipeOut: boolean; + isSwiped: boolean; + swipeDirection: 'x' | 'y' | null; + swipeOutDirection: 'left' | 'right' | 'up' | 'down' | null; + offsetBeforeRemove: number; + initialHeight: number; + remainingTime: number; + closeTimerStartTime: number; + lastCloseTimerStartTime: number; + dragStartTime: number | null; + pointerStart: { x: number; y: number } | null; + timeoutId: ReturnType | null; +} + +export class Toaster { + private container: HTMLElement; + private readonly section: HTMLElement; + private toastLists: Map = new Map(); + private toastElements: Map = new Map(); + private toasts: ToastT[] = []; + private heights: HeightT[] = []; + private expanded: boolean = false; + private interacting: boolean = false; + private unsubscribe?: () => void; + private documentHidden: boolean = false; + private actualTheme: 'light' | 'dark'; + private readonly resizeObserver?: ResizeObserver; + + // Configuration + private config: Required< + Omit< + ToasterConfig, + 'icons' | 'toastOptions' | 'customAriaLabel' | 'containerAriaLabel' | 'hotkey' | 'swipeDirections' + > + > & { + icons?: ToasterConfig['icons']; + toastOptions?: ToasterConfig['toastOptions']; + customAriaLabel?: string; + containerAriaLabel: string; + hotkey: string[]; + swipeDirections?: ToasterConfig['swipeDirections']; + }; + + constructor(options: ToasterOptions = {}) { + this.config = { + id: options.id, + invert: options.invert ?? false, + theme: options.theme ?? 'light', + position: options.position ?? 'bottom-right', + hotkey: options.hotkey ?? ['altKey', 'KeyT'], + richColors: options.richColors ?? false, + expand: options.expand ?? false, + duration: options.duration, + gap: options.gap ?? GAP, + visibleToasts: options.visibleToasts ?? VISIBLE_TOASTS_AMOUNT, + closeButton: options.closeButton ?? false, + toastOptions: options.toastOptions, + className: options.className ?? '', + style: options.style ?? {}, + offset: options.offset, + mobileOffset: options.mobileOffset, + dir: options.dir ?? getDocumentDirection(), + icons: options.icons, + customAriaLabel: options.customAriaLabel, + containerAriaLabel: options.containerAriaLabel ?? 'Notifications', + swipeDirections: options.swipeDirections, + }; + + this.actualTheme = this.config.theme !== 'system' ? this.config.theme : getSystemTheme(); + + this.container = options.container ?? document.body; + this.section = this.createSection(); + this.container.appendChild(this.section); + + this.subscribe(); + + this.setupThemeListener(); + + this.setupVisibilityListener(); + + this.setupHotkeyListener(); + + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const toastId = (entry.target as HTMLElement).dataset.toastId; + if (toastId) { + const state = this.toastElements.get(toastId); + if (state && state.mounted) { + this.updateToastHeight(toastId, entry.contentRect.height); + } + } + } + }); + } + } + + private createSection(): HTMLElement { + const section = document.createElement('section'); + const hotkeyLabel = this.config.hotkey.join('+').replace(/Key/g, '').replace(/Digit/g, ''); + + section.setAttribute( + 'aria-label', + this.config.customAriaLabel ?? `${this.config.containerAriaLabel} ${hotkeyLabel}`, + ); + section.setAttribute('tabIndex', '-1'); + section.setAttribute('aria-live', 'polite'); + section.setAttribute('aria-relevant', 'additions text'); + section.setAttribute('aria-atomic', 'false'); + section.setAttribute('data-react-aria-top-layer', ''); + + return section; + } + + private subscribe(): void { + this.unsubscribe = ToastState.subscribe((toast) => { + if ((toast as ToastToDismiss).dismiss) { + requestAnimationFrame(() => { + const existing = this.toasts.find((t) => t.id === toast.id); + if (existing) { + this.toasts = this.toasts.map((t) => (t.id === toast.id ? { ...t, delete: true } : t)); + this.updateToastElement(toast.id, { delete: true }); + } + }); + return; + } + + const indexOfExisting = this.toasts.findIndex((t) => t.id === toast.id); + + if (indexOfExisting !== -1) { + // Update existing toast + this.toasts = [ + ...this.toasts.slice(0, indexOfExisting), + { ...this.toasts[indexOfExisting], ...toast }, + ...this.toasts.slice(indexOfExisting + 1), + ]; + this.updateToastElement(toast.id, toast); + } else { + // Add new toast + this.toasts = [toast as ToastT, ...this.toasts]; + this.renderToast(toast as ToastT); + } + + this.updateToastPositions(); + }); + } + + private setupThemeListener(): void { + if (this.config.theme === 'system' && typeof window !== 'undefined') { + const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const updateTheme = (matches: boolean) => { + this.actualTheme = matches ? 'dark' : 'light'; + this.updateThemeAttribute(); + }; + + try { + // Modern browsers + darkMediaQuery.addEventListener('change', (e) => updateTheme(e.matches)); + } catch { + // Safari < 14 + darkMediaQuery.addListener((e) => updateTheme(e.matches)); + } + } + } + + private updateThemeAttribute(): void { + this.toastLists.forEach((list) => { + list.setAttribute('data-sonner-theme', this.actualTheme); + }); + } + + private setupVisibilityListener(): void { + if (typeof document === 'undefined') return; + + const handleVisibilityChange = () => { + this.documentHidden = document.hidden; + + if (this.documentHidden) { + this.pauseAllTimers(); + } else if (!this.expanded && !this.interacting) { + this.resumeAllTimers(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + private setupHotkeyListener(): void { + if (typeof document === 'undefined') return; + + const handleKeyDown = (event: KeyboardEvent) => { + const isHotkeyPressed = + this.config.hotkey.length > 0 && this.config.hotkey.every((key) => (event as any)[key] || event.code === key); + + if (isHotkeyPressed) { + this.expanded = true; + this.updateExpandedState(); + const firstList = Array.from(this.toastLists.values())[0]; + firstList?.focus(); + } + + if (event.code === 'Escape') { + const activeElement = document.activeElement; + this.toastLists.forEach((list) => { + if (activeElement === list || list.contains(activeElement)) { + this.expanded = false; + this.updateExpandedState(); + } + }); + } + }; + + document.addEventListener('keydown', handleKeyDown); + } + + private getToastList(position: Position): HTMLOListElement { + if (this.toastLists.has(position)) { + return this.toastLists.get(position)!; + } + + const list = document.createElement('ol'); + const [y, x] = position.split('-'); + + list.setAttribute('dir', this.config.dir === 'auto' ? getDocumentDirection() : this.config.dir); + list.setAttribute('tabIndex', '-1'); + list.className = this.config.className; + list.setAttribute('data-sonner-toaster', ''); + list.setAttribute('data-sonner-theme', this.actualTheme); + list.setAttribute('data-y-position', y); + list.setAttribute('data-x-position', x); + + const styles = { + '--front-toast-height': '0px', + '--width': `${TOAST_WIDTH}px`, + '--gap': `${this.config.gap}px`, + ...this.config.style, + ...assignOffset(this.config.offset, this.config.mobileOffset), + }; + + Object.entries(styles).forEach(([key, value]) => { + list.style.setProperty(key, String(value)); + }); + + list.addEventListener('mouseenter', () => { + this.expanded = true; + this.updateExpandedState(); + }); + + list.addEventListener('mousemove', () => { + this.expanded = true; + this.updateExpandedState(); + }); + + list.addEventListener('mouseleave', () => { + if (!this.interacting) { + this.expanded = false; + this.updateExpandedState(); + } + }); + + list.addEventListener('dragend', () => { + this.expanded = false; + this.updateExpandedState(); + }); + + list.addEventListener('pointerdown', (event) => { + const target = event.target as HTMLElement; + const isNotDismissible = target.dataset.dismissible === 'false'; + if (!isNotDismissible) { + this.interacting = true; + this.pauseAllTimers(); + } + }); + + list.addEventListener('pointerup', () => { + this.interacting = false; + if (!this.expanded && !this.documentHidden) { + this.resumeAllTimers(); + } + }); + + this.toastLists.set(position, list); + this.section.appendChild(list); + + return list; + } + + private renderToast(toast: ToastT): void { + const position = toast.position ?? this.config.position; + const list = this.getToastList(position); + + const toastEl = document.createElement('li'); + toastEl.setAttribute('tabIndex', '0'); + toastEl.setAttribute('data-toast-id', String(toast.id)); + toastEl.setAttribute('data-sonner-toast', ''); + + const state: ToastElementState = { + element: toastEl, + toast, + mounted: false, + removed: false, + swiping: false, + swipeOut: false, + isSwiped: false, + swipeDirection: null, + swipeOutDirection: null, + offsetBeforeRemove: 0, + initialHeight: 0, + remainingTime: toast.duration || this.config.duration || TOAST_LIFETIME, + closeTimerStartTime: 0, + lastCloseTimerStartTime: 0, + dragStartTime: null, + pointerStart: null, + timeoutId: null, + }; + + this.toastElements.set(toast.id, state); + + this.buildToastContent(toastEl, toast, state); + + this.setupToastInteractions(toastEl, toast, state); + + list.insertBefore(toastEl, list.firstChild); + + if (this.resizeObserver) { + this.resizeObserver.observe(toastEl); + } + + requestAnimationFrame(() => { + const height = toastEl.getBoundingClientRect().height; + state.initialHeight = height; + state.mounted = true; + + this.heights = [{ toastId: toast.id, height, position }, ...this.heights]; + + this.updateToastAttributes(toastEl, toast, state); + this.updateToastPositions(); + + this.startAutoCloseTimer(toast.id); + }); + } + + private buildToastContent(toastEl: HTMLLIElement, toast: ToastT, state: ToastElementState): void { + const toastType = toast.type ?? 'default'; + const dismissible = toast.dismissible !== false; + const closeButton = toast.closeButton ?? this.config.closeButton; + + if (closeButton && !toast.jsx && toastType !== 'loading') { + const closeBtn = document.createElement('button'); + closeBtn.setAttribute('aria-label', this.config.toastOptions?.closeButtonAriaLabel ?? 'Close toast'); + closeBtn.setAttribute('data-close-button', ''); + closeBtn.className = cn(this.config.toastOptions?.classNames?.closeButton, toast.classNames?.closeButton); + closeBtn.innerHTML = this.config.icons?.close ?? closeIcon; + closeBtn.addEventListener('click', () => { + if (dismissible) { + this.deleteToast(toast.id); + toast.onDismiss?.(toast); + } + }); + toastEl.appendChild(closeBtn); + } + + const customIcon = + toastType !== 'default' && toastType !== 'action' && toastType !== 'normal' && toastType !== 'loading' + ? this.config.icons?.[toastType] + : undefined; + + const icon = toast.icon ?? customIcon ?? getAssetIcon(toastType); + + const shouldShowIcon = + (toastType || toast.icon || toast.promise) && + toast.icon !== null && + (this.config.icons?.[toastType] !== null || toast.icon || toast.promise); + + if (shouldShowIcon) { + const iconDiv = document.createElement('div'); + iconDiv.setAttribute('data-icon', ''); + iconDiv.className = cn(this.config.toastOptions?.classNames?.icon, toast.classNames?.icon); + + if (toast.promise || (toast.type === 'loading' && !toast.icon)) { + if (toast.icon) { + iconDiv.innerHTML = String(toast.icon); + } else { + iconDiv.appendChild(createLoader(true)); + } + } else if (toast.type !== 'loading' && icon) { + iconDiv.innerHTML = String(icon); + } + + if (iconDiv.children.length > 0 || iconDiv.innerHTML) { + toastEl.appendChild(iconDiv); + } + } + + // Content + const contentDiv = document.createElement('div'); + contentDiv.setAttribute('data-content', ''); + contentDiv.className = cn(this.config.toastOptions?.classNames?.content, toast.classNames?.content); + + // Title + const titleDiv = document.createElement('div'); + titleDiv.setAttribute('data-title', ''); + titleDiv.className = cn(this.config.toastOptions?.classNames?.title, toast.classNames?.title); + + const titleContent = toast.jsx ?? (typeof toast.title === 'function' ? toast.title() : toast.title); + titleDiv.textContent = String(titleContent ?? ''); + contentDiv.appendChild(titleDiv); + + // Description + if (toast.description) { + const descDiv = document.createElement('div'); + descDiv.setAttribute('data-description', ''); + descDiv.className = cn( + this.config.toastOptions?.descriptionClassName, + toast.descriptionClassName, + this.config.toastOptions?.classNames?.description, + toast.classNames?.description, + ); + const descContent = typeof toast.description === 'function' ? toast.description() : toast.description; + descDiv.textContent = String(descContent ?? ''); + contentDiv.appendChild(descDiv); + } + + toastEl.appendChild(contentDiv); + + this.addActionButtons(toastEl, toast, state); + } + + private addActionButtons(toastEl: HTMLLIElement, toast: ToastT, state: ToastElementState): void { + const dismissible = toast.dismissible !== false; + + // Cancel button + if (toast.cancel && typeof toast.cancel === 'object' && 'label' in toast.cancel) { + const cancelBtn = document.createElement('button'); + cancelBtn.setAttribute('data-button', ''); + cancelBtn.setAttribute('data-cancel', ''); + cancelBtn.className = cn(this.config.toastOptions?.classNames?.cancelButton, toast.classNames?.cancelButton); + cancelBtn.textContent = String(toast.cancel.label); + + if (toast.cancelButtonStyle || this.config.toastOptions?.cancelButtonStyle) { + Object.assign(cancelBtn.style, toast.cancelButtonStyle ?? this.config.toastOptions?.cancelButtonStyle); + } + + cancelBtn.addEventListener('click', (event) => { + if (dismissible && typeof toast.cancel === 'object' && 'onClick' in toast.cancel) { + toast.cancel.onClick?.(event); + this.deleteToast(toast.id); + } + }); + + toastEl.appendChild(cancelBtn); + } + + // Action button + if (toast.action && typeof toast.action === 'object' && 'label' in toast.action) { + const actionBtn = document.createElement('button'); + actionBtn.setAttribute('data-button', ''); + actionBtn.setAttribute('data-action', ''); + actionBtn.className = cn(this.config.toastOptions?.classNames?.actionButton, toast.classNames?.actionButton); + actionBtn.textContent = String(toast.action.label); + + if (toast.actionButtonStyle || this.config.toastOptions?.actionButtonStyle) { + Object.assign(actionBtn.style, toast.actionButtonStyle ?? this.config.toastOptions?.actionButtonStyle); + } + + actionBtn.addEventListener('click', (event) => { + if (typeof toast.action === 'object' && 'onClick' in toast.action) { + toast.action.onClick?.(event); + if (!event.defaultPrevented) { + this.deleteToast(toast.id); + } + } + }); + + toastEl.appendChild(actionBtn); + } + } + + private setupToastInteractions(toastEl: HTMLLIElement, toast: ToastT, state: ToastElementState): void { + const dismissible = toast.dismissible !== false; + const disabled = toast.type === 'loading'; + const position = toast.position ?? this.config.position; + + toastEl.addEventListener('pointerdown', (event) => { + if (event.button === 2) return; // Right click + if (disabled || !dismissible) return; + + state.dragStartTime = Date.now(); + state.offsetBeforeRemove = this.getToastOffset(toast.id); + + const target = event.target as HTMLElement; + target.setPointerCapture(event.pointerId); + + if (target.tagName === 'BUTTON') return; + + state.swiping = true; + state.pointerStart = { x: event.clientX, y: event.clientY }; + }); + + toastEl.addEventListener('pointerup', () => { + if (state.swipeOut || !dismissible) return; + + state.pointerStart = null; + + const swipeAmountX = parseFloat(toastEl.style.getPropertyValue('--swipe-amount-x') || '0'); + const swipeAmountY = parseFloat(toastEl.style.getPropertyValue('--swipe-amount-y') || '0'); + const timeTaken = state.dragStartTime ? Date.now() - state.dragStartTime : 0; + + const swipeAmount = state.swipeDirection === 'x' ? swipeAmountX : swipeAmountY; + const velocity = Math.abs(swipeAmount) / timeTaken; + + if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) { + state.offsetBeforeRemove = this.getToastOffset(toast.id); + toast.onDismiss?.(toast); + + if (state.swipeDirection === 'x') { + state.swipeOutDirection = swipeAmountX > 0 ? 'right' : 'left'; + } else { + state.swipeOutDirection = swipeAmountY > 0 ? 'down' : 'up'; + } + + this.deleteToast(toast.id); + state.swipeOut = true; + } else { + toastEl.style.setProperty('--swipe-amount-x', '0px'); + toastEl.style.setProperty('--swipe-amount-y', '0px'); + } + + state.isSwiped = false; + state.swiping = false; + state.swipeDirection = null; + this.updateToastAttributes(toastEl, toast, state); + }); + + toastEl.addEventListener('pointermove', (event) => { + if (!state.pointerStart || !dismissible) return; + + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) return; + + const yDelta = event.clientY - state.pointerStart.y; + const xDelta = event.clientX - state.pointerStart.x; + + const swipeDirections = this.config.swipeDirections ?? getDefaultSwipeDirections(position); + + if (!state.swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) { + state.swipeDirection = Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y'; + } + + let swipeAmount = { x: 0, y: 0 }; + + const getDampening = (delta: number) => { + const factor = Math.abs(delta) / 20; + return 1 / (1.5 + factor); + }; + + if (state.swipeDirection === 'y') { + if (swipeDirections.includes('top') || swipeDirections.includes('bottom')) { + if ((swipeDirections.includes('top') && yDelta < 0) || (swipeDirections.includes('bottom') && yDelta > 0)) { + swipeAmount.y = yDelta; + } else { + const dampenedDelta = yDelta * getDampening(yDelta); + swipeAmount.y = Math.abs(dampenedDelta) < Math.abs(yDelta) ? dampenedDelta : yDelta; + } + } + } else if (state.swipeDirection === 'x') { + if (swipeDirections.includes('left') || swipeDirections.includes('right')) { + if ((swipeDirections.includes('left') && xDelta < 0) || (swipeDirections.includes('right') && xDelta > 0)) { + swipeAmount.x = xDelta; + } else { + const dampenedDelta = xDelta * getDampening(xDelta); + swipeAmount.x = Math.abs(dampenedDelta) < Math.abs(xDelta) ? dampenedDelta : xDelta; + } + } + } + + if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) { + state.isSwiped = true; + } + + toastEl.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`); + toastEl.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`); + this.updateToastAttributes(toastEl, toast, state); + }); + + toastEl.addEventListener('dragend', () => { + state.swiping = false; + state.swipeDirection = null; + state.pointerStart = null; + this.updateToastAttributes(toastEl, toast, state); + }); + } + + private updateToastAttributes(toastEl: HTMLLIElement, toast: ToastT, state: ToastElementState): void { + const position = toast.position ?? this.config.position; + const [y, x] = position.split('-'); + const index = this.getToastIndex(toast.id); + const isFront = index === 0; + const isVisible = index + 1 <= this.config.visibleToasts; + const toastType = toast.type ?? 'default'; + const dismissible = toast.dismissible !== false; + const invert = toast.invert ?? this.config.invert; + + toastEl.className = cn( + this.config.toastOptions?.className, + toast.className, + this.config.toastOptions?.classNames?.toast, + toast.classNames?.toast, + this.config.toastOptions?.classNames?.default, + this.config.toastOptions?.classNames?.[toastType as keyof ToastClassnames], + toast.classNames?.[toastType as keyof ToastClassnames], + ); + + toastEl.setAttribute('data-rich-colors', String(toast.richColors ?? this.config.richColors)); + toastEl.setAttribute( + 'data-styled', + String(!Boolean(toast.jsx || toast.unstyled || this.config.toastOptions?.unstyled)), + ); + toastEl.setAttribute('data-mounted', String(state.mounted)); + toastEl.setAttribute('data-promise', String(Boolean(toast.promise))); + toastEl.setAttribute('data-swiped', String(state.isSwiped)); + toastEl.setAttribute('data-removed', String(state.removed)); + toastEl.setAttribute('data-visible', String(isVisible)); + toastEl.setAttribute('data-y-position', y); + toastEl.setAttribute('data-x-position', x); + toastEl.setAttribute('data-index', String(index)); + toastEl.setAttribute('data-front', String(isFront)); + toastEl.setAttribute('data-swiping', String(state.swiping)); + toastEl.setAttribute('data-dismissible', String(dismissible)); + toastEl.setAttribute('data-type', toastType); + toastEl.setAttribute('data-invert', String(invert)); + toastEl.setAttribute('data-swipe-out', String(state.swipeOut)); + toastEl.setAttribute('data-swipe-direction', state.swipeOutDirection ?? ''); + toastEl.setAttribute('data-expanded', String(this.expanded || this.config.expand)); + + if (toast.testId) { + toastEl.setAttribute('data-testid', toast.testId); + } + + const offset = this.getToastOffset(toast.id); + const styles = { + '--index': String(index), + '--toasts-before': String(index), + '--z-index': String(this.toasts.length - index), + '--offset': `${state.removed ? state.offsetBeforeRemove : offset}px`, + '--initial-height': this.config.expand ? 'auto' : `${state.initialHeight}px`, + ...this.config.toastOptions?.style, + ...toast.style, + }; + + Object.entries(styles).forEach(([key, value]) => { + toastEl.style.setProperty(key, String(value)); + }); + } + + private getToastIndex(toastId: string | number): number { + const position = this.toasts.find((t) => t.id === toastId)?.position ?? this.config.position; + const positionToasts = this.toasts.filter((t) => (t.position ?? this.config.position) === position); + return positionToasts.findIndex((t) => t.id === toastId); + } + + private getToastOffset(toastId: string | number): number { + const position = this.toasts.find((t) => t.id === toastId)?.position ?? this.config.position; + const positionHeights = this.heights.filter((h) => h.position === position); + const heightIndex = positionHeights.findIndex((h) => h.toastId === toastId); + + if (heightIndex === -1) return 0; + + const toastsHeightBefore = positionHeights.reduce((prev, curr, reducerIndex) => { + if (reducerIndex >= heightIndex) return prev; + return prev + curr.height; + }, 0); + + return heightIndex * this.config.gap + toastsHeightBefore; + } + + private startAutoCloseTimer(toastId: string | number): void { + const state = this.toastElements.get(toastId); + if (!state) return; + + const toast = state.toast; + const toastType = toast.type; + + if ((toast.promise && toastType === 'loading') || toast.duration === Infinity || toastType === 'loading') { + return; + } + + if (!this.expanded && !this.interacting && !this.documentHidden) { + this.resumeTimer(toastId); + } + } + + private pauseTimer(toastId: string | number): void { + const state = this.toastElements.get(toastId); + if (!state) return; + + if (state.timeoutId) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + } + + if (state.closeTimerStartTime > 0) { + const elapsedTime = Date.now() - state.closeTimerStartTime; + state.remainingTime = Math.max(0, state.remainingTime - elapsedTime); + state.closeTimerStartTime = 0; + } + } + + private resumeTimer(toastId: string | number): void { + const state = this.toastElements.get(toastId); + if (!state) return; + + const toast = state.toast; + + if (state.remainingTime === Infinity || state.remainingTime <= 0) return; + + state.closeTimerStartTime = Date.now(); + + state.timeoutId = setTimeout(() => { + toast.onAutoClose?.(toast); + this.deleteToast(toastId); + }, state.remainingTime); + } + + private pauseAllTimers(): void { + this.toastElements.forEach((state, id) => { + this.pauseTimer(id); + }); + } + + private resumeAllTimers(): void { + this.toastElements.forEach((state, id) => { + const toast = state.toast; + const toastType = toast.type; + + // Don't resume loading toasts or infinite duration toasts + if ((toast.promise && toastType === 'loading') || toast.duration === Infinity || toastType === 'loading') { + return; + } + + this.resumeTimer(id); + }); + } + + private updateExpandedState(): void { + this.toastElements.forEach((state, id) => { + this.updateToastAttributes(state.element, state.toast, state); + }); + + if (this.expanded || this.interacting || this.documentHidden) { + this.pauseAllTimers(); + } else { + this.resumeAllTimers(); + } + } + + private deleteToast(toastId: string | number): void { + const state = this.toastElements.get(toastId); + if (!state) return; + + state.removed = true; + state.offsetBeforeRemove = this.getToastOffset(toastId); + this.updateToastAttributes(state.element, state.toast, state); + + this.heights = this.heights.filter((h) => h.toastId !== toastId); + this.updateToastPositions(); + + setTimeout(() => { + this.removeToastElement(toastId); + }, TIME_BEFORE_UNMOUNT); + } + + private removeToastElement(toastId: string | number): void { + const state = this.toastElements.get(toastId); + if (!state) return; + + if (this.resizeObserver) { + this.resizeObserver.unobserve(state.element); + } + + if (state.timeoutId) { + clearTimeout(state.timeoutId); + } + + state.element.remove(); + this.toastElements.delete(toastId); + + this.toasts = this.toasts.filter((t) => t.id !== toastId); + + this.updateToastPositions(); + + this.cleanupEmptyLists(); + } + + private updateToastElement(toastId: string | number, updates: Partial): void { + const state = this.toastElements.get(toastId); + if (!state) return; + + state.toast = { ...state.toast, ...updates }; + + if (updates.title || updates.description || updates.icon || updates.type) { + state.element.innerHTML = ''; + this.buildToastContent(state.element, state.toast, state); + } + + this.updateToastAttributes(state.element, state.toast, state); + + if (updates.delete) { + this.deleteToast(toastId); + } + } + + private updateToastHeight(toastId: string | number, newHeight: number): void { + const existing = this.heights.find((h) => h.toastId === toastId); + if (existing && Math.abs(existing.height - newHeight) > 1) { + this.heights = this.heights.map((h) => (h.toastId === toastId ? { ...h, height: newHeight } : h)); + this.updateToastPositions(); + } + } + + private updateToastPositions(): void { + const positions = new Set(this.toasts.map((t) => t.position ?? this.config.position)); + + positions.forEach((position) => { + const list = this.toastLists.get(position); + if (!list) return; + + const positionHeights = this.heights.filter((h) => h.position === position); + const frontHeight = positionHeights[0]?.height ?? 0; + + list.style.setProperty('--front-toast-height', `${frontHeight}px`); + + const positionToasts = this.toasts.filter((t) => (t.position ?? this.config.position) === position); + positionToasts.forEach((toast) => { + const state = this.toastElements.get(toast.id); + if (state) { + this.updateToastAttributes(state.element, toast, state); + } + }); + }); + + if (this.toasts.length <= 1) { + this.expanded = false; + } + } + + private cleanupEmptyLists(): void { + this.toastLists.forEach((list, position) => { + if (list.children.length === 0) { + list.remove(); + this.toastLists.delete(position); + } + }); + } + + destroy(): void { + this.unsubscribe?.(); + + this.toastElements.forEach((state) => { + if (state.timeoutId) { + clearTimeout(state.timeoutId); + } + }); + + this.resizeObserver?.disconnect(); + + this.section.remove(); + + this.toastElements.clear(); + this.toastLists.clear(); + this.toasts = []; + this.heights = []; + } +} + +export function createToaster(options?: ToasterOptions): Toaster { + return new Toaster(options); +} diff --git a/src/vanilla/state.ts b/src/vanilla/state.ts new file mode 100644 index 00000000..7552cd60 --- /dev/null +++ b/src/vanilla/state.ts @@ -0,0 +1,7 @@ +import { createToastState } from '../core'; + +const { state: ToastState, toast: toastFunction } = createToastState(); + +export const toast = toastFunction; + +export { ToastState };