Skip to content

Commit

Permalink
feat: toast package (#1073)
Browse files Browse the repository at this point in the history
  • Loading branch information
moughxyz authored Jun 7, 2022
1 parent de94fb6 commit 6d0b6e9
Show file tree
Hide file tree
Showing 27 changed files with 524 additions and 23 deletions.
3 changes: 3 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
babelrcRoots: ['.', './packages/*'],
}
2 changes: 1 addition & 1 deletion packages/desktop/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"../../node_modules/@standardnotes/config/src/.eslintrc"
],
"parser": "@typescript-eslint/parser",
"ignorePatterns": ["test", "scripts", ".eslintrc", "tsconfig.json", "node_modules"],
"ignorePatterns": ["test", "scripts", ".eslintrc", "tsconfig.json", "node_modules", "*.webpack.*.js"],
"rules": {
/** Style */
"quotes": ["error", "single", { "avoidEscape": true }],
Expand Down
2 changes: 2 additions & 0 deletions packages/toast/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
3 changes: 3 additions & 0 deletions packages/toast/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["../../node_modules/@standardnotes/config/src/.eslintrc"]
}
1 change: 1 addition & 0 deletions packages/toast/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
3 changes: 3 additions & 0 deletions packages/toast/linter.tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../node_modules/@standardnotes/config/src/linter.tsconfig.json"
}
32 changes: 32 additions & 0 deletions packages/toast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@standardnotes/toast",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "yarn run tsc",
"clean": "rm -fr dist",
"prestart": "yarn clean",
"start": "tsc -p tsconfig.json --watch",
"prebuild": "yarn clean",
"lint": "eslint ./src"
},
"dependencies": {
"@nanostores/react": "^0.2.0",
"@standardnotes/config": "^2.4.3",
"@standardnotes/icons": "^1.1.7",
"nanoid": "^3.3.4",
"nanostores": "^0.5.12"
},
"devDependencies": {
"@babel/preset-env": "^7.18.0",
"@babel/preset-react": "^7.17.12",
"@babel/preset-typescript": "^7.17.12"
},
"peerDependencies": {
"react": "17"
}
}
133 changes: 133 additions & 0 deletions packages/toast/src/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions packages/toast/src/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 115 additions & 0 deletions packages/toast/src/ToastTimer.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 35 additions & 0 deletions packages/toast/src/addTimedToast.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions packages/toast/src/enums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export enum ToastType {
Regular = 'regular',
Success = 'success',
Error = 'error',
Loading = 'loading',
Progress = 'progress',
}
5 changes: 5 additions & 0 deletions packages/toast/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading

0 comments on commit 6d0b6e9

Please sign in to comment.