-
-
Notifications
You must be signed in to change notification settings - Fork 440
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
27 changed files
with
524 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module.exports = { | ||
babelrcRoots: ['.', './packages/*'], | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
node_modules | ||
dist |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"extends": ["../../node_modules/@standardnotes/config/src/.eslintrc"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
dist |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"extends": "../../node_modules/@standardnotes/config/src/linter.tsconfig.json" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} /> | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
Oops, something went wrong.