import { IoMdCloseCircleOutline } from 'react-icons/io'; import { createContext, Dispatch, ReactNode, useCallback, useContext, useEffect, useReducer, useState, } from 'react'; import { SMALL_AND_UP } from '../lib/style'; import { keyframes } from '@emotion/react'; const slidein = keyframes` from { margin-left: 100%; } to { margin-left: 0%; } `; enum ActionTypes { ShowSnackbar, CloseSnackbar, AddToast, RemoveToast, } type Action = | { type: ActionTypes.ShowSnackbar; message: string } | { type: ActionTypes.CloseSnackbar } | { type: ActionTypes.AddToast; id: number; message: string } | { type: ActionTypes.RemoveToast; id: number }; interface SnackbarState { isOpen: boolean; message: string; } interface ToastState { id: number; message: string; } interface State extends SnackbarState { toasts: Array<ToastState>; } const initialState = { isOpen: false, message: '', toasts: [], }; const SnackbarContext = createContext<{ state: State; dispatch: Dispatch<Action>; }>({ state: initialState, dispatch: () => null, }); export function Snackbar({ message, isOpen, }: { message: string; isOpen: boolean; }) { return ( <div css={{ zIndex: 10000000, position: 'fixed', bottom: '1em', left: '1em', backgroundColor: 'var(--snackbar-bg)', color: 'var(--snackbar-text)', padding: '0.5em', borderRadius: 3, minHeight: 32, maxWidth: 'calc(100vw - 2em)', boxShadow: '0px 0px 3px 3px rgba(0,0,0,0.5)', opacity: 0, visibility: 'hidden', transition: 'all ' + ANIMATION_DELAY + 'ms ease-in-out 0s', ...(message && isOpen && { opacity: 1, visibility: 'visible', }), }} > {message} </div> ); } function Toast({ id, message }: { id: number; message: string }) { const { dispatch } = useContext(SnackbarContext); const [closing, setClosing] = useState(false); const [closed, setClosed] = useState(false); const close = useCallback(() => { // Close the toast which causes it to slide right setClosing(true); // After slide right we set closing which causes it to shrink vertically setTimeout(() => { setClosed(true); }, ANIMATION_DELAY); // After shrink vertically we remove the toast setTimeout(() => { dispatch({ type: ActionTypes.RemoveToast, id }); }, 2 * ANIMATION_DELAY); }, [dispatch, id]); useEffect(() => { const timer = setTimeout(() => { close(); }, DURATION); return () => clearTimeout(timer); }, [close]); return ( <div css={{ transition: 'all ' + ANIMATION_DELAY + 'ms ease-in-out 0s', maxHeight: 500, ...(closed && { maxHeight: 0 }), [SMALL_AND_UP]: { '& + &': { marginTop: '1em', }, }, }} > <div role="button" tabIndex={0} css={{ cursor: 'pointer', backgroundColor: 'var(--overlay-inner)', color: 'var(--text)', padding: '1em', width: '100%', marginLeft: '110%', boxShadow: '0px 0px 3px 3px rgba(0, 0, 0, 0.5)', animation: `${slidein} 0.3s ease-in-out`, transition: 'all ' + ANIMATION_DELAY + 'ms ease-in-out 0s', ...(message && !closing && { marginLeft: 0, }), }} onClick={close} onKeyPress={close} > <IoMdCloseCircleOutline css={{ float: 'right', }} /> {message} </div> </div> ); } const reducer = (state: State, action: Action): State => { switch (action.type) { case ActionTypes.ShowSnackbar: return { ...state, isOpen: true, message: action.message, }; case ActionTypes.CloseSnackbar: return { ...state, isOpen: false, }; case ActionTypes.AddToast: state.toasts.push({ message: action.message, id: action.id, }); return { ...state }; case ActionTypes.RemoveToast: return { ...state, toasts: state.toasts.filter((i) => i.id !== action.id), }; } }; export function SnackbarProvider(props: { children?: ReactNode }) { const [state, dispatch] = useReducer(reducer, initialState); return ( <SnackbarContext.Provider value={{ state, dispatch }}> {props.children} <Snackbar {...state} /> <div css={{ zIndex: 10000000, position: 'fixed', overflow: 'hidden', width: '100vw', paddingBottom: 4, top: 0, right: 0, ...(state.toasts.length === 0 && { display: 'none' }), [SMALL_AND_UP]: { padding: 4, top: '1em', right: '1em', width: 320, }, }} > {state.toasts.map((t) => ( <Toast key={t.id} {...t} /> ))} </div> </SnackbarContext.Provider> ); } const DURATION = 4000; const ANIMATION_DELAY = 250; const toastId = () => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); export function useSnackbar() { const context = useContext(SnackbarContext); const [snackbarTimeout, setSnackbarTimeout] = useState<ReturnType< typeof setTimeout > | null>(null); function openSnackbar(message: string) { context.dispatch({ type: ActionTypes.ShowSnackbar, message }); setSnackbarTimeout( setTimeout(() => { close(); }, DURATION) ); } function showSnackbar(message: string) { if (context.state.isOpen) { close(); setTimeout(() => { openSnackbar(message); }, ANIMATION_DELAY); } else { openSnackbar(message); } } function close() { if (snackbarTimeout) { clearTimeout(snackbarTimeout); setSnackbarTimeout(null); } context.dispatch({ type: ActionTypes.CloseSnackbar }); } function addToast(message: string, delay = 0) { const id = toastId(); if (delay) { setTimeout(() => { context.dispatch({ type: ActionTypes.AddToast, id, message }); }, delay); } else { context.dispatch({ type: ActionTypes.AddToast, id, message }); } } return { showSnackbar, addToast }; }