diff --git a/package.json b/package.json index b91f5f019..9ae9a8f76 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,11 @@ "@react-aria/utils": "^3.2.1", "@react-stately/toggle": "^3.2.0", "emotion": "^10.0.27", + "react-use-gesture": "^7.0.16", "reakit": "^1.2.4", "reakit-system": "^0.14.4", - "reakit-utils": "^0.14.3" + "reakit-utils": "^0.14.3", + "uuid": "^8.3.0" }, "devDependencies": { "@babel/core": "7.11.5", @@ -48,6 +50,8 @@ "@storybook/react": "6.0.21", "@types/react": "16.9.49", "@types/react-dom": "16.9.8", + "@types/react-transition-group": "^4.4.0", + "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "4.0.1", "@typescript-eslint/parser": "4.0.1", "babel-eslint": "10.1.0", @@ -67,6 +71,8 @@ "prettier": "2.1.1", "react": "16.13.1", "react-dom": "16.13.1", + "react-spring": "^8.0.27", + "react-transition-group": "^4.4.1", "sort-package-json": "1.44.0", "typescript": "4.0.2" } diff --git a/src/toast/ToastController.tsx b/src/toast/ToastController.tsx new file mode 100644 index 000000000..db2e007cf --- /dev/null +++ b/src/toast/ToastController.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { useTimeout } from "@chakra-ui/hooks"; +import { useGesture } from "react-use-gesture"; + +interface ToastControllerProps { + id: string; + onRequestRemove: (id: string) => void; + duration?: number; + autoDismiss?: boolean; +} + +export const ToastController: React.FC = ({ + id, + duration = 0, + autoDismiss, + onRequestRemove, + children, +}) => { + const [delay, setDelay] = React.useState(duration); + const [x, setX] = React.useState(0); + + const bind = useGesture({ + onDrag: ({ down, movement: [x] }: any) => { + if (!down) setX(0); + + setX(x); + setDelay(null); + + if (x > 100 || x < -100) { + onRequestRemove(id); + } + }, + onMouseUp: () => setX(0), + }); + + const onMouseEnter = React.useCallback(() => { + autoDismiss && setDelay(null); + }, [autoDismiss]); + + const onMouseLeave = React.useCallback(() => { + autoDismiss && setDelay(duration); + }, [autoDismiss, duration]); + + useTimeout(() => { + if (autoDismiss) { + onRequestRemove(id); + } + }, delay); + + const props = { + id, + onMouseLeave, + onMouseEnter, + className: "toast", + style: { transform: `translateX(${x}px)` }, + ...bind(), + }; + + return
{children}
; +}; diff --git a/src/toast/ToastProvider.tsx b/src/toast/ToastProvider.tsx new file mode 100644 index 000000000..c1949208b --- /dev/null +++ b/src/toast/ToastProvider.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import useToastState, { IToast } from "./ToastState"; +import { ToastController } from "./ToastController"; +import { ToastStateReturn } from "./ToastState"; +import { canUseDOM } from "reakit-utils"; +import { createContext } from "@chakra-ui/utils"; + +const DEFAULT_TIMEOUT = 5000; +const PLACEMENTS = { + "top-left": { top: 0, left: 0 }, + "top-center": { top: 0, left: "50%", transform: "translateX(-50%)" }, + "top-right": { top: 0, right: 0 }, + "bottom-left": { bottom: 0, left: 0 }, + "bottom-center": { bottom: 0, left: "50%", transform: "translateX(-50%)" }, + "bottom-right": { bottom: 0, right: 0 }, +}; + +// let's infer the union types from the placement values instead of hardcoding them +export type Placements = keyof typeof PLACEMENTS; + +interface IToastContext extends ToastStateReturn { + toastTypes: ToastTypes; +} + +export const [ToastContextProvider, useToast] = createContext({ + name: "useToast", + errorMessage: + "The `useToasts` hook must be called from a descendent of the `ToastProvider`.", + strict: true, +}); + +export type ToastTypes = Record< + string, + React.FC< + Pick & { + remove: ToastStateReturn["remove"]; + } + > +>; + +export type TToastWrapper = (props: any) => React.ReactElement; +type IToastProvider = { + toastTypes: ToastTypes; + autoDismiss?: boolean; + timeout?: number; + animationTimeout?: number; + toastWrapper?: TToastWrapper; + placement?: Placements; +}; + +export const ToastProvider: React.FC = ({ + children, + toastTypes, + toastWrapper: ToastWrapperComponent = ({ children }) => children, + animationTimeout, + autoDismiss: providerAutoDismiss, + timeout: providerTimeout = DEFAULT_TIMEOUT, + placement: providerPlacement = "bottom-center", +}) => { + const portalTarget = canUseDOM ? document.body : null; + const state = useToastState({ animationTimeout }); + + const Toasts = state.getToastToRender( + providerPlacement, + (position, toastList) => { + return ( +
+ {toastList.map( + ({ id, type, content, timeout, autoDismiss, isVisible }) => { + return ( + + + {typeof content === "function" + ? content({ id, isVisible, remove: state.hide }) + : toastTypes[type || ""]?.({ + content, + id, + remove: state.hide, + isVisible, + }) || content} + + + ); + }, + )} +
+ ); + }, + ); + + return ( + + {children} + {portalTarget ? ReactDOM.createPortal(Toasts, portalTarget) : Toasts} + + ); +}; diff --git a/src/toast/ToastState.ts b/src/toast/ToastState.ts new file mode 100644 index 000000000..81148a93b --- /dev/null +++ b/src/toast/ToastState.ts @@ -0,0 +1,143 @@ +import React from "react"; +import { v4 as uuidv4 } from "uuid"; +import { Placements } from "./ToastProvider"; + +type JSXFunction = (props: any) => JSX.Element; +type StringOrElement = string | JSXFunction; + +export interface IToast { + id: string; + type?: string; + content: StringOrElement; + timeout?: number; + placement?: Placements; + autoDismiss?: boolean; + isVisible?: boolean; +} + +export type ToastList = Record; + +type GetToastToRenderType = ( + defaultPlacement: Placements, + callback: (position: Placements, toastList: IToast[]) => void, +) => Array; + +interface ToastStateProps { + animationTimeout?: number; +} + +const useToastState = ({ animationTimeout = 0 }: ToastStateProps) => { + const [toasts, setToasts] = React.useState({}); + + // toggle can be used to just hide/show the toast instead of removing it. + // used for animations, since we don't want to unmount the component directly + const toggle = React.useCallback( + ({ id, isVisible }: { id: string; isVisible: boolean }) => { + setToasts(queue => ({ + ...queue, + [id]: { + ...queue[id], + isVisible, + }, + })); + }, + [], + ); + + const show = React.useCallback( + ({ + type = "", + content, + timeout, + autoDismiss, + placement, + }: Omit) => { + const uid = uuidv4(); + /* + wait until the next frame so we can animate + wierd bug while using CSSTrasition + works fine without RAF & double render when using react spring. + maybe because of this:- https://youtu.be/mmq-KVeO-uU?t=842 + */ + requestAnimationFrame(() => { + setToasts(toasts => ({ + ...toasts, + [uid]: { + type, + id: uid, + content, + timeout, + placement, + autoDismiss, + isVisible: false, + }, + })); + + // causes rerender in order to trigger + // the animation after mount in CSSTrasition + toggle({ id: uid, isVisible: true }); + }); + }, + [toggle], + ); + + const remove = React.useCallback((id: string) => { + // need to use callback based setState otherwise + // the remove function would take the queue as dependency + // and cause render when changed which would effectively + // cause the animations to behave strangly. + setToasts(queue => { + const newQueue = { ...queue }; + delete newQueue[id]; + + return newQueue; + }); + }, []); + + const hide = React.useCallback( + (id: string) => { + toggle({ id, isVisible: false }); + + window.setTimeout(() => { + remove(id); + }, animationTimeout); + }, + [toggle, animationTimeout, remove], + ); + + // The idea here is to normalize the [...] single array to object with + // position keys & arrays containing the toasts + const getToastToRender: GetToastToRenderType = ( + defaultPlacement, + callback, + ) => { + const toastToRender = {}; + const toastList = Object.keys(toasts); + + for (let i = 0; i < toastList.length; i++) { + const toast = toasts[toastList[i]]; + const { placement = defaultPlacement } = toast; + toastToRender[placement] || (toastToRender[placement] = []); + + toastToRender[placement].push(toast); + } + + return Object.keys(toastToRender).map(position => + callback(position as Placements, toastToRender[position]), + ); + }; + + return { + setToasts, + getToastToRender, + toasts, + toggle, + show, + hide, + remove, + }; +}; + +export type ToastStateReturn = ReturnType; + +export default useToastState; diff --git a/src/toast/index.ts b/src/toast/index.ts new file mode 100644 index 000000000..27177dd34 --- /dev/null +++ b/src/toast/index.ts @@ -0,0 +1,3 @@ +export * from "./ToastController"; +export * from "./ToastState"; +export * from "./ToastProvider"; diff --git a/src/toast/stories/Animations.stories.tsx b/src/toast/stories/Animations.stories.tsx new file mode 100644 index 000000000..24f7ac77a --- /dev/null +++ b/src/toast/stories/Animations.stories.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { Meta } from "@storybook/react"; +import { useTransition, animated } from "react-spring"; +import { CSSTransition } from "react-transition-group"; + +import { ToastProvider, TToastWrapper } from "../"; +import Demo, { getTransform } from "./Demo"; + +import "./style.css"; + +export default { + title: "Component/AnimationToast", +} as Meta; + +const CSSTransitionAnimationWrapper: TToastWrapper = ({ + isVisible, + children, +}) => { + return ( + + {children} + + ); +}; + +export const CSSTransitionAnimation: React.FC = () => { + return ( + { + return ( +
+ {content} +
+ ); + }, + success: ({ remove, content, id }) => { + return ( +
+ {content} +
+ ); + }, + warning: ({ remove, content, id }) => { + return ( +
+ {content} +
+ ); + }, + }} + > + +
+ ); +}; + +const SpringAnimationWrapper: TToastWrapper = ({ + placement, + isVisible, + children, +}) => { + const translate = getTransform(placement, 50); + + const transitions = useTransition(isVisible, null, { + from: { opacity: 0, maxHeight: 0, transform: translate.from }, + enter: { + opacity: 1, + maxHeight: 200, + transform: translate.enter, + }, + leave: { opacity: 0, maxHeight: 0, transform: translate.leave }, + }); + + return ( + <> + {transitions.map( + ({ item, key, props }) => + item && ( + + {children} + + ), + )} + + ); +}; + +export const ReactSpringAnimation: React.FC = () => { + return ( + { + return ( +
+ {content} +
+ ); + }, + success: ({ remove, content, id }) => { + return ( +
+ {content} +
+ ); + }, + warning: ({ remove, content, id }) => { + return ( +
+ {content} +
+ ); + }, + }} + > + +
+ ); +}; diff --git a/src/toast/stories/Demo.tsx b/src/toast/stories/Demo.tsx new file mode 100644 index 000000000..5532670b8 --- /dev/null +++ b/src/toast/stories/Demo.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { useToast, ToastProvider } from "../"; + +const randomType = (): string => { + return ["error", "warning", "success"].splice(Math.random() * 3, 1)[0]; +}; + +const SomeDeepNestedComp = () => { + const { show } = useToast(); + + return ( +
+ + + + +
+ ); +}; + +const PlacementDemo = () => { + const { show } = useToast(); + + return ( +
+ + + + + + +
+ ); +}; + +const Demo = () => { + return ( + <> + +
+ + + ); +}; + +// Animation util +export const getTransform = (placement: string, pixels: number) => { + const pos = { from: "", enter: "", leave: "" }; + pos.enter = `translate(0, 0)`; + + if (placement === "bottom-center") { + pos.from = `translate(0, ${pixels}px)`; + pos.leave = `translate(0, ${pixels}px)`; + return pos; + } + if (placement === "top-center") { + pos.from = `translate(0, ${-pixels}px)`; + pos.leave = `translate(0, ${-pixels}px)`; + return pos; + } + if (["bottom-left", "top-left"].includes(placement)) { + pos.from = `translate(${-pixels}px, 0)`; + pos.leave = `translate(${-pixels}px, 0)`; + return pos; + } + if (["bottom-right", "top-right"].includes(placement)) { + pos.from = `translate(${pixels}px, 0)`; + pos.leave = `translate(${pixels}px, 0)`; + return pos; + } + + return pos; +}; + +export default Demo; diff --git a/src/toast/stories/Toast.stories.tsx b/src/toast/stories/Toast.stories.tsx new file mode 100644 index 000000000..d17873963 --- /dev/null +++ b/src/toast/stories/Toast.stories.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Meta } from "@storybook/react"; +import { ToastProvider } from "../"; +import Demo from "./Demo"; +import "./style.css"; + +export default { + title: "Component/Toast", +} as Meta; + +export const Default: React.FC = () => { + return ( + { + return ( +
+ {content} +
+ ); + }, + success: ({ remove, content, id }) => { + return ( +
+ {content} +
+ ); + }, + warning: ({ remove, content, id }) => { + return ( +
+ {content} +
+ ); + }, + }} + > + +
+ ); +}; diff --git a/src/toast/stories/style.css b/src/toast/stories/style.css new file mode 100644 index 000000000..9eb1ef983 --- /dev/null +++ b/src/toast/stories/style.css @@ -0,0 +1,51 @@ +.toast { + width: 200px; + padding: 5px; + text-align: center; + border-radius: 2px; + color: white; + transition: 500ms ease-in-out; + position: relative; +} + +.toast button { + width: 25px; + top: 0; + right: 0; + bottom: 0; + position: absolute; + border: none; + outline: none; + color: white; + cursor: pointer; + border-radius: 2px; + background-color: rgba(255, 255, 225, 0.4); +} + +.toast button:hover { + background-color: rgba(255, 255, 225, 0.2); +} + +.alert-enter { + transform: translateY(-50); + max-height: 0; + opacity: 0; +} + +.alert-enter-active { + transform: translateY(0); + max-height: 200px; + opacity: 1; +} + +.alert-exit { + transform: translateX(0) scale(1); + max-height: 200px; + opacity: 1; +} + +.alert-exit-active { + transform: translateX(200px) scale(0.6); + max-height: 0; + opacity: 0; +}