diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 8fcdd68..f4fd2da 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { styled } from 'goober'; +import { keyframes, styled } from 'goober'; const StyledCheck = styled('i')` & { @@ -44,6 +44,8 @@ const StyledClose = styled('i')` border: 2px solid transparent; border-radius: 40px; color: #ff4b4b; + border: 2px solid red; + border-radius: 9999px; } &::after, &::before { @@ -56,8 +58,8 @@ const StyledClose = styled('i')` height: 3px; background: currentColor; transform: rotate(45deg); - top: 10px; - left: 4px; + top: 12px; + left: 3px; } &::after { transform: rotate(-45deg); @@ -66,3 +68,73 @@ const StyledClose = styled('i')` export const Close: React.FC = () => { return ; }; + +const spinnerTwoAltAnimation = keyframes` + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +`; +const StyledSpinner = styled('i')` + position: relative; + &, + &::before { + box-sizing: border-box; + display: block; + width: 30px; + height: 30px; + } + &::before { + content: ''; + position: absolute; + border-radius: 100px; + animation: ${spinnerTwoAltAnimation} 1s cubic-bezier(0.6, 0, 0.4, 1) + infinite; + border: 3px solid #5fbdff; + border-bottom-color: transparent; + border-top-color: transparent; + } +`; +export const Spinner: React.FC = () => { + return ; +}; + +const StyledWarning = styled('i')` + & { + box-sizing: border-box; + position: relative; + display: block; + width: 30px; + height: 30px; + border: 2px solid; + border-radius: 40px; + color: #f6d776; + } + &::after, + &::before { + content: ''; + display: block; + box-sizing: border-box; + position: absolute; + border-radius: 3px; + width: 3px; + background: currentColor; + left: 11.5px; + color: #f6d776; + } + &::after { + top: 4px; + height: 14px; + } + &::before { + height: 3px; + bottom: 3px; + width: 3px; + border-radius: 9999px; + } +`; +export const Warning: React.FC = () => { + return ; +}; diff --git a/src/components/toast-icon.tsx b/src/components/toast-icon.tsx index 6aaf2db..2c5f6b0 100644 --- a/src/components/toast-icon.tsx +++ b/src/components/toast-icon.tsx @@ -1,7 +1,7 @@ import { keyframes, styled } from 'goober'; import React from 'react'; import { Toast } from '../core/types'; -import { Check, Close } from './icons'; +import { Check, Close, Spinner, Warning } from './icons'; interface ToastIconProps { icon?: Toast['icon']; @@ -42,6 +42,12 @@ const ToastIcon: React.FC = (props) => { if (type === 'error') { return ; } + if (type === 'loading') { + return ; + } + if (type === 'warning') { + return ; + } return null; }; diff --git a/src/components/toaster.tsx b/src/components/toaster.tsx index 50e0259..76f89ef 100644 --- a/src/components/toaster.tsx +++ b/src/components/toaster.tsx @@ -7,7 +7,7 @@ import ToastIcon from './toast-icon'; setup(React.createElement); -interface ToasterProps { +export interface ToasterProps { position?: 'left' | 'center' | 'right'; toastOptions?: ToastsOptions; } diff --git a/src/core/store.ts b/src/core/store.ts index b20c9c4..7868640 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -1,10 +1,15 @@ import React from 'react'; import { Toast, ToastsOptions } from './types'; +export const TOAST_EXPIRE_DISMISS_DELAY = 1000; +const TOAST_LIMIT = 3; + type ActionType = { ADD_TOAST: 'ADD_TOAST'; REMOVE_TOAST: 'REMOVE_TOAST'; DISMISS_TOAST: 'DISMISS_TOAST'; + UPSERT_TOAST: 'UPSERT_TOAST'; + UPDATE_TOAST: 'UPDATE_TOAST'; PAUSE: 'PAUSE'; RESUME: 'RESUME'; }; @@ -22,6 +27,14 @@ type Action = toastId?: Toast['id']; type: ActionType['REMOVE_TOAST']; } + | { + type: ActionType['UPSERT_TOAST']; + toast: Toast; + } + | { + type: ActionType['UPDATE_TOAST']; + toast: Partial; + } | { type: ActionType['PAUSE']; time: number; @@ -39,9 +52,6 @@ interface State { const toastTimeouts = new Map>(); -export const TOAST_EXPIRE_DISMISS_DELAY = 0; -const TOAST_LIMIT = 3; - const addToRemoveQueue = (toastId: string) => { if (toastTimeouts.has(toastId)) { return; @@ -58,6 +68,13 @@ const addToRemoveQueue = (toastId: string) => { toastTimeouts.set(toastId, timeout); }; +const clearFromRemoveQueue = (toastId: string) => { + const timeout = toastTimeouts.get(toastId); + if (timeout) { + clearTimeout(timeout); + } +}; + export const reducer = (state: State, action: Action): State => { switch (action.type) { case 'ADD_TOAST': { @@ -70,7 +87,6 @@ export const reducer = (state: State, action: Action): State => { case 'DISMISS_TOAST': { const { toastId } = action; - // ! Side effects ! - This could be execrated into a dismissToast() action, but I'll keep it here for simplicity if (toastId) { addToRemoveQueue(toastId); } else { @@ -107,6 +123,25 @@ export const reducer = (state: State, action: Action): State => { }; } + case 'UPSERT_TOAST': + const { toast } = action; + return state.toasts.find((t) => t.id === toast.id) + ? reducer(state, { type: 'UPDATE_TOAST', toast }) + : reducer(state, { type: 'ADD_TOAST', toast }); + + case 'UPDATE_TOAST': { + if (action.toast.id) { + clearFromRemoveQueue(action.toast.id); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; + } + case 'PAUSE': { return { ...state, diff --git a/src/core/types.ts b/src/core/types.ts index a2b4246..0a3adfb 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,6 +1,11 @@ import React, { CSSProperties } from 'react'; -export type ToasterType = 'success' | 'error' | 'default'; +export type ToasterType = + | 'success' + | 'error' + | 'default' + | 'loading' + | 'warning'; export type Toast = { id: string; diff --git a/src/core/use-toast.ts b/src/core/use-toast.ts index 7642070..0efc2f0 100644 --- a/src/core/use-toast.ts +++ b/src/core/use-toast.ts @@ -4,12 +4,15 @@ import { dispatch, useStore } from './store'; import { Toast, ToastArg, + ToastOptions, ToasterType, ToastsOptions, isFunction, } from './types'; import { genId } from './utils'; +const DEFAULT_DURATION = 3 * 1000; + function createToast(type: ToasterType = 'default', arg: ToastArg): Toast { if (isFunction(arg)) { const id = genId(); @@ -45,7 +48,7 @@ function createHandler(type?: ToasterType) { return (options: ToastArg) => { const toast = createToast(type, options); dispatch({ - type: 'ADD_TOAST', + type: 'UPSERT_TOAST', toast, }); return toast.id; @@ -55,6 +58,28 @@ function createHandler(type?: ToasterType) { const toast = (opts: ToastArg) => createHandler('default')(opts); toast.error = createHandler('error'); toast.success = createHandler('success'); +toast.loading = createHandler('loading'); +toast.warning = createHandler('warning'); + +toast.promise = ( + promise: Promise, + content: { + loading: ToastOptions; + success: ToastOptions; + error: ToastOptions; + } +) => { + const id = toast.loading(content.loading); + promise + .then((p) => { + toast.success({ ...content.success, id }); + return p; + }) + .catch(() => { + toast.error({ ...content.error, id }); + }); + return promise; +}; toast.dismiss = (toastId?: string) => { dispatch({ @@ -63,8 +88,6 @@ toast.dismiss = (toastId?: string) => { }); }; -const DEFAULT_DURATION = 3 * 1000; - const pause = () => { dispatch({ type: 'PAUSE', diff --git a/src/stories/Toast.stories.tsx b/src/stories/Toast.stories.tsx index db64a91..14fe067 100644 --- a/src/stories/Toast.stories.tsx +++ b/src/stories/Toast.stories.tsx @@ -71,7 +71,30 @@ export const Error: StoryFn = () => { toast.error({ title: `Scheduled: Catch up ${count}`, description: 'Friday, February 10, 2023 at 5:57 PM', - duration: 3 * 1000, + duration: 300 * 1000, + }); + setCount((prev) => prev + 1); + }} + > + Show Toast + + + ); +}; + +export const Loading: StoryFn = () => { + const [count, setCount] = useState(0); + + return ( +
+ + +
+ ); +}; + export const CustomIcon: StoryFn = () => { const [count, setCount] = useState(0); @@ -274,3 +321,40 @@ export const CustomClassName: StoryFn = () => { ); }; + +export const PromiseSupport: StoryFn = () => { + return ( +
+ + +
+ ); +}; diff --git a/tsconfig.json b/tsconfig.json index 78efe6c..729ec70 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,9 @@ "compilerOptions": { "esModuleInterop": true, "jsx": "react-jsx", + "lib": ["dom", "esnext"], + "declaration": true, + "sourceMap": true, "module": "ESNext", "moduleResolution": "node", "skipLibCheck": true,