|
import React, { useState, useEffect, useRef } from 'react' |
|
import { BehaviorSubject } from 'rxjs' |
|
import Snackbar from './Snackbar' |
|
import { cls } from './classNames' |
|
import { SwitchTransition, CSSTransition } from 'react-transition-group' |
|
import styles from './SnackbarAnimation.css' |
|
import { clamp } from 'lodash' |
|
|
|
const snackbarStream = new BehaviorSubject() |
|
let snackbarId = 1 |
|
|
|
export const triggerSnackbar = ({ message, action, actionText }) => { |
|
if (!message) throw new Error('triggerSnackbar requires a toast.message') |
|
if (actionText && !action) |
|
throw new Error( |
|
'triggerSnackbar was called with toast.actionText but no corresponding toast.action' |
|
) |
|
if (action && !actionText) |
|
throw new Error( |
|
'triggerSnackbar was called with toast.action but no corresponding toast.actionText' |
|
) |
|
const id = snackbarId++ |
|
snackbarStream.next({ id, message, action, actionText }) |
|
|
|
return function clearSnackbar() { |
|
snackbarStream.next({ id }) |
|
} |
|
} |
|
|
|
const TIMEOUT = { |
|
MIN: 4000, |
|
MAX: 10000, |
|
CALCULATE: (msgLen, atLen = 0) => (msgLen + atLen) * 100, |
|
} |
|
|
|
export default function SnackbarRoot() { |
|
const [snackbars, setSnackbars] = useState([]) |
|
const [timerPause, setTimerPause] = useState(false) |
|
const timer = useRef() |
|
|
|
const [currentSnackbar] = snackbars |
|
|
|
useEffect(() => { |
|
const sub = snackbarStream.subscribe((snackbar) => { |
|
if (snackbar) { |
|
setSnackbars((s) => { |
|
if (!snackbar.message) { |
|
return s.filter(({ id }) => id !== snackbar.id) |
|
} |
|
return s.concat({ |
|
...snackbar, |
|
// 1. Default time is calculated based on text length (100ms per character) |
|
// 2. Min of 4 seconds |
|
// 3. Max of 10 seconds |
|
// 4. Developers can provide a timeout within the min/max |
|
timeout: clamp( |
|
snackbar.timeout || |
|
TIMEOUT.CALCULATE( |
|
snackbar.message.length, |
|
snackbar.actionText?.length |
|
), |
|
TIMEOUT.MIN, |
|
TIMEOUT.MAX |
|
), |
|
}) |
|
}) |
|
} |
|
}) |
|
return () => { |
|
sub.unsubscribe() |
|
} |
|
}, []) |
|
|
|
const cancelTimer = () => { |
|
clearTimeout(timer.current) |
|
timer.current = undefined |
|
} |
|
|
|
const shiftSnackbars = React.useCallback(() => { |
|
setSnackbars((queuedSnackbars) => { |
|
if (!queuedSnackbars.length) { |
|
cancelTimer() |
|
} |
|
return queuedSnackbars.slice(1) |
|
}) |
|
}, [setSnackbars]) |
|
|
|
useEffect(() => { |
|
if (timerPause) { |
|
cancelTimer() |
|
} else if (snackbars.length && !timer.current) { |
|
timer.current = setTimeout(shiftSnackbars, currentSnackbar.timeout) |
|
} |
|
return () => { |
|
cancelTimer() |
|
} |
|
}, [snackbars, currentSnackbar, timerPause, shiftSnackbars]) |
|
|
|
const key = currentSnackbar?.id || false |
|
|
|
return ( |
|
<SwitchTransition mode="out-in"> |
|
<CSSTransition |
|
key={key} |
|
addEndListener={(node, done) => { |
|
node.addEventListener('transitionend', done, false) |
|
}} |
|
classNames={{ ...styles }} |
|
> |
|
<div |
|
className={cls('fixed bottom-0 left-0 p-4')} |
|
aria-live="polite" |
|
onMouseEnter={() => setTimerPause(true)} |
|
onMouseLeave={() => setTimerPause(false)} |
|
onClick={(e) => { |
|
// do it this way because a Snackbar may omit an action |
|
if (e.target.tagName === 'BUTTON') shiftSnackbars() |
|
}} |
|
> |
|
{key && <Snackbar {...currentSnackbar} />} |
|
</div> |
|
</CSSTransition> |
|
</SwitchTransition> |
|
) |
|
} |