feat: toast package (#1073)

This commit is contained in:
Mo
2022-06-07 13:19:45 -05:00
committed by GitHub
parent de94fb69cf
commit 6d0b6e9018
27 changed files with 524 additions and 23 deletions

View File

@@ -0,0 +1,133 @@
import type { Toast as ToastPropType } from './types'
import { CheckCircleFilledIcon, ClearCircleFilledIcon } from '@standardnotes/icons'
import { dismissToast } from './toastStore'
import { ToastType } from './enums'
import { ForwardedRef, forwardRef, RefObject, useEffect } from 'react'
const prefersReducedMotion = () => {
const mediaQuery = matchMedia('(prefers-reduced-motion: reduce)')
return mediaQuery.matches
}
const colorForToastType = (type: ToastType) => {
switch (type) {
case ToastType.Success:
return 'color-success'
case ToastType.Error:
return 'color-danger'
default:
return 'color-info'
}
}
const iconForToastType = (type: ToastType) => {
switch (type) {
case ToastType.Success:
return <CheckCircleFilledIcon className={colorForToastType(type)} />
case ToastType.Error:
return <ClearCircleFilledIcon className={colorForToastType(type)} />
case ToastType.Progress:
case ToastType.Loading:
return <div className="sk-spinner w-4 h-4 spinner-info" />
default:
return null
}
}
type Props = {
toast: ToastPropType
index: number
}
export const Toast = forwardRef(({ toast, index }: Props, ref: ForwardedRef<HTMLDivElement>) => {
const icon = iconForToastType(toast.type)
const hasActions = toast.actions && toast.actions.length > 0
const hasProgress = toast.type === ToastType.Progress && toast.progress !== undefined && toast.progress > -1
const shouldReduceMotion = prefersReducedMotion()
const enterAnimation = shouldReduceMotion ? 'fade-in-animation' : 'slide-in-right-animation'
const exitAnimation = shouldReduceMotion ? 'fade-out-animation' : 'slide-out-left-animation'
const currentAnimation = toast.dismissed ? exitAnimation : enterAnimation
useEffect(() => {
if (!ref) {
return
}
const element = (ref as RefObject<HTMLDivElement>).current
if (element && toast.dismissed) {
const { scrollHeight, style } = element
requestAnimationFrame(() => {
style.minHeight = 'initial'
style.height = scrollHeight + 'px'
style.transition = 'all 200ms'
requestAnimationFrame(() => {
style.height = '0'
style.padding = '0'
style.margin = '0'
})
})
}
}, [ref, toast.dismissed])
return (
<div
data-index={index}
role="status"
className={`flex flex-col bg-passive-5 rounded opacity-0 animation-fill-forwards select-none min-w-max relative mt-3 ${currentAnimation}`}
style={{
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.16)',
transition: shouldReduceMotion ? undefined : 'all 0.2s ease',
animationDelay: !toast.dismissed ? '50ms' : undefined,
}}
onClick={() => {
if (!hasActions && toast.type !== ToastType.Loading && toast.type !== ToastType.Progress) {
dismissToast(toast.id)
}
}}
ref={ref}
>
<div className={`flex items-center w-full ${hasActions ? 'p-2 pl-3' : hasProgress ? 'px-3 py-2.5' : 'p-3'}`}>
{icon ? <div className="flex flex-shrink-0 items-center justify-center sn-icon mr-2">{icon}</div> : null}
<div className="text-sm">{toast.message}</div>
{hasActions && (
<div className="ml-4">
{toast.actions?.map((action, index) => (
<button
style={{
paddingLeft: '0.45rem',
paddingRight: '0.45rem',
}}
className={`py-1 border-0 bg-transparent cursor-pointer font-semibold text-sm hover:bg-passive-3 rounded ${colorForToastType(
toast.type,
)} ${index !== 0 ? 'ml-2' : ''}`}
onClick={() => {
action.handler(toast.id)
}}
key={index}
>
{action.label}
</button>
))}
</div>
)}
</div>
{hasProgress && (
<div className="toast-progress-bar">
<div
className="toast-progress-bar__value"
role="progressbar"
style={{
width: `${toast.progress}%`,
...(toast.progress === 100 ? { borderTopRightRadius: 0 } : {}),
}}
aria-valuenow={toast.progress}
/>
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,20 @@
import { FunctionComponent } from 'react'
import { useStore } from '@nanostores/react'
import { toastStore } from './toastStore'
import { ToastTimer } from './ToastTimer'
export const ToastContainer: FunctionComponent = () => {
const toasts = useStore(toastStore)
if (!toasts.length) {
return null
}
return (
<div className="flex flex-col items-end fixed z-index-toast bottom-6 right-6">
{toasts.map((toast, index) => (
<ToastTimer toast={toast} index={index} key={toast.id} />
))}
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, FunctionComponent } from 'react'
import { Toast } from './Toast'
import { Toast as ToastPropType } from './types'
import { ToastType } from './enums'
import { dismissToast } from './toastStore'
type Props = {
toast: ToastPropType
index: number
}
const getDefaultForAutoClose = (hasActions: boolean, type: ToastType) => {
return !hasActions && ![ToastType.Loading, ToastType.Progress].includes(type)
}
const getDefaultToastDuration = (type: ToastType) => (type === ToastType.Error ? 8000 : 4000)
export const ToastTimer: FunctionComponent<Props> = ({ toast, index }) => {
const toastElementRef = useRef<HTMLDivElement>(null)
const toastTimerIdRef = useRef<number>()
const hasActions = Boolean(toast.actions?.length)
const shouldAutoClose = toast.autoClose ?? getDefaultForAutoClose(hasActions, toast.type)
const duration = toast.duration ?? getDefaultToastDuration(toast.type)
const startTimeRef = useRef(duration)
const remainingTimeRef = useRef(duration)
const dismissToastOnEnd = useCallback(() => {
dismissToast(toast.id)
}, [toast.id])
const clearTimer = useCallback(() => {
if (toastTimerIdRef.current) {
clearTimeout(toastTimerIdRef.current)
}
}, [])
const pauseTimer = useCallback(() => {
clearTimer()
remainingTimeRef.current -= Date.now() - startTimeRef.current
}, [clearTimer])
const resumeTimer = useCallback(() => {
startTimeRef.current = Date.now()
clearTimer()
toastTimerIdRef.current = window.setTimeout(dismissToastOnEnd, remainingTimeRef.current)
}, [clearTimer, dismissToastOnEnd])
const handleMouseEnter = useCallback(() => {
pauseTimer()
}, [pauseTimer])
const handleMouseLeave = useCallback(() => {
resumeTimer()
}, [resumeTimer])
const handlePageVisibility = useCallback(() => {
if (document.visibilityState === 'hidden') {
pauseTimer()
} else {
resumeTimer()
}
}, [pauseTimer, resumeTimer])
const handlePageFocus = useCallback(() => {
resumeTimer()
}, [resumeTimer])
const handlePageBlur = useCallback(() => {
pauseTimer()
}, [pauseTimer])
useEffect(() => {
clearTimer()
if (shouldAutoClose) {
resumeTimer()
}
const toastElement = toastElementRef.current
if (toastElement) {
toastElement.addEventListener('mouseenter', handleMouseEnter)
toastElement.addEventListener('mouseleave', handleMouseLeave)
}
document.addEventListener('visibilitychange', handlePageVisibility)
window.addEventListener('focus', handlePageFocus)
window.addEventListener('blur', handlePageBlur)
return () => {
clearTimer()
if (toastElement) {
toastElement.removeEventListener('mouseenter', handleMouseEnter)
toastElement.removeEventListener('mouseleave', handleMouseLeave)
}
document.removeEventListener('visibilitychange', handlePageVisibility)
window.removeEventListener('focus', handlePageFocus)
window.removeEventListener('blur', handlePageBlur)
}
}, [
clearTimer,
dismissToastOnEnd,
duration,
handleMouseEnter,
handleMouseLeave,
handlePageBlur,
handlePageFocus,
handlePageVisibility,
resumeTimer,
shouldAutoClose,
toast.id,
])
return <Toast toast={toast} index={index} ref={toastElementRef} />
}

View File

@@ -0,0 +1,35 @@
import { addToast, dismissToast, updateToast } from './toastStore'
import { ToastOptions } from './types'
type InitialToastOptions = Omit<ToastOptions, 'message'> & {
message: (timeRemainingInSeconds: number) => string
}
export const addTimedToast = (
initialOptions: InitialToastOptions,
callback: () => void,
timeInSeconds: number,
): [string, number] => {
let timeRemainingInSeconds = timeInSeconds
const intervalId = window.setInterval(() => {
timeRemainingInSeconds--
if (timeRemainingInSeconds > 0) {
updateToast(toastId, {
message: initialOptions.message(timeRemainingInSeconds),
})
} else {
dismissToast(toastId)
clearInterval(intervalId)
callback()
}
}, 1000)
const toastId = addToast({
...initialOptions,
message: initialOptions.message(timeRemainingInSeconds),
autoClose: false,
})
return [toastId, intervalId]
}

View File

@@ -0,0 +1,7 @@
export enum ToastType {
Regular = 'regular',
Success = 'success',
Error = 'error',
Loading = 'loading',
Progress = 'progress',
}

View File

@@ -0,0 +1,5 @@
export { ToastContainer } from './ToastContainer'
export { addToast, updateToast, dismissToast } from './toastStore'
export { ToastType } from './enums'
export { addTimedToast } from './addTimedToast'
export type { Toast, ToastAction, ToastOptions } from './types'

View File

@@ -0,0 +1,73 @@
import { nanoid } from 'nanoid'
import { action, atom, WritableAtom } from 'nanostores'
import { Toast, ToastOptions, ToastUpdateOptions } from './types'
export const toastStore = atom<Toast[]>([])
export const updateToast = action(
toastStore,
'updateToast',
(store: WritableAtom<Toast[]>, toastId: Toast['id'], options: ToastUpdateOptions) => {
const existingToasts = store.get()
store.set(
existingToasts.map((toast) => {
if (toast.id === toastId) {
return {
...toast,
...options,
}
} else {
return toast
}
}),
)
},
)
const removeToast = action(toastStore, 'removeToast', (store: WritableAtom<Toast[]>, toastId: Toast['id']) => {
const existingToasts = store.get()
store.set(existingToasts.filter((toast) => toast.id !== toastId))
})
const DelayBeforeRemovingToast = 175
export const dismissToast = action(toastStore, 'dismissToast', (store: WritableAtom<Toast[]>, toastId: Toast['id']) => {
const existingToasts = store.get()
store.set(
existingToasts.map((toast) => {
if (toast.id === toastId) {
return {
...toast,
dismissed: true,
}
} else {
return toast
}
}),
)
setTimeout(() => {
removeToast(toastId)
}, DelayBeforeRemovingToast)
})
export const addToast = action(toastStore, 'addToast', (store: WritableAtom<Toast[]>, options: ToastOptions) => {
const existingToasts = store.get()
const isToastIdDuplicate = existingToasts.findIndex((toast) => toast.id === options.id) > -1
const id = options.id && !isToastIdDuplicate ? options.id : nanoid()
if (isToastIdDuplicate) {
console.warn(`Generated new ID for toast instead of overriding toast of ID "${options.id}".
If you want to update an existing toast, use the \`updateToast()\` function instead.`)
}
const toast = {
...options,
id,
dismissed: false,
}
store.set([...existingToasts, toast])
return id
})

View File

@@ -0,0 +1,30 @@
import { ToastType } from './enums'
export type ToastAction = {
label: string
handler: (toastId: Toast['id']) => void
}
type CommonToastProperties = {
type: ToastType
message: string
actions?: ToastAction[]
progress?: number
autoClose?: boolean
duration?: number
}
export type Toast = CommonToastProperties & {
id: string
dismissed: boolean
}
export type ToastOptions = CommonToastProperties & {
id?: string
}
export type ToastUpdateOptions = Omit<Partial<ToastOptions>, 'id'>
export type ToastState = {
toasts: Toast[]
}