Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ build
# misc
.DS_Store
*.pem
.idea

# debug
npm-debug.log*
Expand Down
33 changes: 33 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './types';
export * from './state';
export * from './constants';
export * from './utils';
172 changes: 96 additions & 76 deletions src/state.ts → src/core/state.ts
Original file line number Diff line number Diff line change
@@ -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<TContent = any> {
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<ToastT | ToastToDismiss>;
class Observer<TContent = any> {
subscribers: Array<(toast: ExternalToast<TContent> | ToastToDismiss) => void>;
toasts: Array<ToastT<TContent> | ToastToDismiss>;
dismissedToasts: Set<string | number>;
contentValidator: ContentValidator<TContent>;

constructor() {
constructor(contentValidator?: ContentValidator<TContent>) {
this.subscribers = [];
this.toasts = [];
this.dismissedToasts = new Set();
this.contentValidator = contentValidator || new DefaultContentValidator();
}

setContentValidator(validator: ContentValidator<TContent>) {
this.contentValidator = validator;
}

// We use arrow functions to maintain the correct `this` reference
subscribe = (subscriber: (toast: ToastT | ToastToDismiss) => void) => {
subscribe = (subscriber: (toast: ToastT<TContent> | ToastToDismiss) => void) => {
this.subscribers.push(subscriber);

return () => {
Expand All @@ -35,22 +48,22 @@ class Observer {
};
};

publish = (data: ToastT) => {
publish = (data: ToastT<TContent>) => {
this.subscribers.forEach((subscriber) => subscriber(data));
};

addToast = (data: ToastT) => {
addToast = (data: ToastT<TContent>) => {
this.publish(data);
this.toasts = [...this.toasts, data];
};

create = (
data: ExternalToast & {
message?: titleT;
data: ExternalToast<TContent> & {
message?: ToastContent<TContent>;
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++;
Expand All @@ -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;
Expand All @@ -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 }));
Expand All @@ -98,31 +117,31 @@ class Observer {
return id;
};

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

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

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

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

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

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

promise = <ToastData>(promise: PromiseT<ToastData>, data?: PromiseData<ToastData>) => {
promise = <ToastData>(promise: PromiseT<ToastData>, data?: PromiseData<ToastData, TContent>) => {
if (!data) {
// Nothing to show
return;
Expand All @@ -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) {
Expand All @@ -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<TContent> = isExtendedResult
? (promiseData as PromiseExtendedResult<TContent>)
: { message: promiseData };

this.create({ id, type: 'error', description, ...toastSettings });
Expand All @@ -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<TContent> = isExtendedResult
? (promiseData as PromiseExtendedResult<TContent>)
: { 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<TContent> = isExtendedResult
? (promiseData as PromiseExtendedResult<TContent>)
: { message: promiseData };

this.create({ id, type: 'success', description, ...toastSettings });
Expand All @@ -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<TContent> = isExtendedResult
? (promiseData as PromiseExtendedResult<TContent>)
: { message: promiseData };

this.create({ id, type: 'error', description, ...toastSettings });
Expand Down Expand Up @@ -240,7 +259,7 @@ class Observer {
}
};

custom = (jsx: (id: number | string) => React.ReactElement, data?: ExternalToast) => {
custom = (jsx: (id: number | string) => TContent, data?: ExternalToast<TContent>) => {
const id = data?.id || toastsCounter++;
this.create({ jsx: jsx(id), id, ...data });
return id;
Expand All @@ -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 &&
Expand All @@ -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<TContent = any>(contentValidator?: ContentValidator<TContent>) {
const state = new Observer<TContent>(contentValidator);

const basicToast = (message: ToastContent<TContent>, data?: ExternalToast<TContent>) => {
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 };
}
Loading