feat: toast package (#1073)
This commit is contained in:
133
packages/toast/src/Toast.tsx
Normal file
133
packages/toast/src/Toast.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
20
packages/toast/src/ToastContainer.tsx
Normal file
20
packages/toast/src/ToastContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
packages/toast/src/ToastTimer.tsx
Normal file
115
packages/toast/src/ToastTimer.tsx
Normal 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} />
|
||||
}
|
||||
35
packages/toast/src/addTimedToast.ts
Normal file
35
packages/toast/src/addTimedToast.ts
Normal 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]
|
||||
}
|
||||
7
packages/toast/src/enums.ts
Normal file
7
packages/toast/src/enums.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum ToastType {
|
||||
Regular = 'regular',
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
Loading = 'loading',
|
||||
Progress = 'progress',
|
||||
}
|
||||
5
packages/toast/src/index.ts
Normal file
5
packages/toast/src/index.ts
Normal 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'
|
||||
73
packages/toast/src/toastStore.ts
Normal file
73
packages/toast/src/toastStore.ts
Normal 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
|
||||
})
|
||||
30
packages/toast/src/types.ts
Normal file
30
packages/toast/src/types.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user