feat: adds toast provider component

This commit is contained in:
jongomez 2023-10-10 19:17:35 +01:00 committed by Jon
parent a535cfaa61
commit 24ccbd04b2
14 changed files with 221 additions and 224 deletions

View File

@ -20,6 +20,7 @@
"@emotion/styled": "^11.10.5",
"clsx": "^1.2.1",
"lodash": "^4.17.21",
"react-hot-toast": "^2.4.1",
"react-use": "^17.4.0"
},
"devDependencies": {

View File

@ -40,6 +40,7 @@ import { ModalStyles } from '../Modal/Modal.styles'
import { ModalFooterStyles } from '../ModalFooter/ModalFooter.styles'
import { ModalBodyStyles } from '../ModalBody/ModalBody.styles'
import { ToastStyles } from '../Toast/Toast.styles'
import { ToastProviderStyles } from '../ToastProvider/ToastProvider.styles'
const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
[
@ -82,6 +83,7 @@ const componentStyles: Array<ReturnType<typeof withTheme> | SerializedStyles> =
DateFieldStyles,
CalendarStyles,
ToastStyles,
ToastProviderStyles,
]
export const CSSBaseline: React.FC<{ theme?: Theme }> = ({

View File

@ -21,9 +21,4 @@ export const toastClasses = {
errorIcon: 'lsd-toast__error-icon',
errorIconContainer: 'lsd-toast__error-icon-container',
moveUp: 'lsd-toast--move-up',
moveDown: 'lsd-toast--move-down',
closingAnimation: 'lsd-toast--closing-animation',
}

View File

@ -1,7 +1,5 @@
import React, { useState } from 'react'
import { Meta, Story } from '@storybook/react'
import { Toast, ToastProps } from './Toast'
import { Button } from '../Button'
export default {
title: 'Toast',
@ -13,64 +11,17 @@ export default {
value: ['small', 'medium', 'large'],
},
},
position: {
type: {
name: 'enum',
value: [
'top-left',
'top',
'top-right',
'bottom-left',
'bottom',
'bottom-right',
],
},
},
animation: {
type: {
name: 'enum',
value: ['auto', 'moveDown', 'moveUp'],
},
},
},
} as Meta
export const Root: Story<ToastProps> = ({ buttonText, ...args }) => {
const [isVisible, setIsVisible] = useState(args.isOpen)
// When the "Show toast" button is clicked, close the toast and then open it again.
const handleClick = () => {
setIsVisible(false)
setTimeout(() => {
setIsVisible(true)
}, 200)
}
return (
<div style={{ width: 400, position: 'relative' }}>
<Button onClick={handleClick}>Show Toast</Button>
<Toast
{...args}
isOpen={isVisible}
onClose={() => setIsVisible(false)}
buttonText={buttonText}
onButtonClick={() => alert('Button clicked!')}
/>
</div>
)
export const Root: Story<ToastProps> = (args) => {
return <Toast {...args} />
}
Root.args = {
title: 'Toast Title',
information: '',
size: 'large',
xOffset: 20,
yOffset: 20,
buttonText: 'Click me',
animation: 'auto',
position: 'top',
isOpen: false,
inline: true,
openTimeMilliseconds: 0,
}

View File

@ -86,47 +86,6 @@ export const ToastStyles = css`
left: -26px;
}
@keyframes moveDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes moveUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeOutAnimation {
to {
opacity: 0;
transform: translateY(-20px);
}
}
.${toastClasses.moveDown} {
animation: moveDown 0.3s ease-out forwards;
}
.${toastClasses.moveUp} {
animation: moveUp 0.3s ease-out forwards;
}
.${toastClasses.closingAnimation} {
animation: fadeOutAnimation 0.3s forwards;
}
.${toastClasses.large} {
.${toastClasses.textContainer} {
min-width: 204px;

View File

@ -1,5 +1,4 @@
import clsx from 'clsx'
import React, { useEffect, useRef, useState } from 'react'
import {
CommonProps,
omitCommonProps,
@ -10,14 +9,10 @@ import { CloseIcon, ErrorIcon } from '../Icons'
import { IconButton } from '../IconButton'
import { Typography } from '../Typography'
import { Button } from '../Button'
import {
getToastAnimationClass,
setToastPosition,
} from '../../utils/toast.utils'
export type ToastProps = CommonProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'label'> & {
isOpen: boolean
isOpen?: boolean
title: string
information?: string
inline?: boolean
@ -25,17 +20,7 @@ export type ToastProps = CommonProps &
onButtonClick?: () => void
onClose?: () => void
size?: 'large' | 'medium' | 'small'
position?:
| 'top-left'
| 'top'
| 'top-right'
| 'bottom-left'
| 'bottom'
| 'bottom-right'
animation?: 'auto' | 'moveDown' | 'moveUp'
openTimeMilliseconds?: number
xOffset?: number
yOffset?: number
toastRefFunction?: (el: HTMLDivElement | null) => void
}
export const Toast: React.FC<ToastProps> & {
@ -49,75 +34,27 @@ export const Toast: React.FC<ToastProps> & {
onButtonClick,
onClose,
size = 'large',
position = 'top',
animation = 'auto',
openTimeMilliseconds,
xOffset = 20,
yOffset = 20,
toastRefFunction,
children,
...props
}) => {
const commonProps = useCommonProps(props)
const isInlineButtonHidden = !inline || !!information || !buttonText
const toastRef = useRef<HTMLDivElement | null>(null)
// Used for the closing animation.
const [isClosing, setIsClosing] = useState(false)
// Closing flow:
// 1. handleClose is called.
// 2. handleClose sets isClosing to true.
// 3. isClosing triggers the closing animation.
// 4. When the closing animation ends, handleAnimationEnd is called.
const handleClose = () => {
setIsClosing(true)
}
const handleAnimationEnd = () => {
if (isClosing && onClose) {
onClose()
setIsClosing(false) // reset the state for potential reuse of the component
}
}
// Close the timer after the specified time.
useEffect(() => {
if (!isOpen || !openTimeMilliseconds) return
let timer: ReturnType<typeof setTimeout> | null = null
if (openTimeMilliseconds && onClose) {
timer = setTimeout(onClose, openTimeMilliseconds)
}
return () => {
if (timer) clearTimeout(timer)
}
}, [isOpen, openTimeMilliseconds, onClose])
// Sets the toast position.
useEffect(() => {
if (isOpen && toastRef.current) {
setToastPosition(toastRef.current, position, xOffset, yOffset)
}
}, [isOpen, position])
// If the toast is not open, do not render anything.
if (!isOpen) {
if (isOpen === false) {
return null
}
return (
<div
ref={toastRef}
ref={toastRefFunction}
{...omitCommonProps(props)}
className={clsx(
commonProps.className,
toastClasses.root,
toastClasses[size],
getToastAnimationClass(position, animation),
isClosing && toastClasses.closingAnimation,
)}
onAnimationEnd={handleAnimationEnd}
>
<div
className={
@ -176,7 +113,7 @@ export const Toast: React.FC<ToastProps> & {
</div>
<IconButton
onClick={handleClose}
onClick={onClose}
className={toastClasses.closeButton}
size="medium"
>

View File

@ -0,0 +1,3 @@
export const toastProviderClasses = {
toastsContainer: `lsd-toast-provider__toasts-container`,
}

View File

@ -0,0 +1,55 @@
import { Meta, Story } from '@storybook/react'
import { ToastProps } from '../Toast'
import { ToastProvider, useLSDToast } from './ToastProvider'
import { FC } from 'react'
import { Button } from '../Button'
import { ToastOptions } from 'react-hot-toast'
import { pickCommonProps } from '../../utils/useCommonProps'
export default {
title: 'ToastProvider',
component: ToastProvider,
argTypes: {
size: {
type: {
name: 'enum',
value: ['small', 'medium', 'large'],
},
},
},
} as Meta
type ToastButtonProps = {
toastArgs: ToastProps & ToastOptions
}
const ToastButton: FC<ToastButtonProps> = ({ toastArgs }) => {
const showToast = useLSDToast()
return (
<Button
{...pickCommonProps(toastArgs)}
onClick={() => showToast(toastArgs, { duration: toastArgs.duration })}
style={{ marginBottom: 8 }}
>
Show Toast
</Button>
)
}
export const Root: Story<ToastProps & ToastOptions> = (args) => {
return (
<ToastProvider>
<ToastButton toastArgs={args} />
</ToastProvider>
)
}
Root.args = {
title: 'Toast Title',
information: '',
size: 'large',
buttonText: 'Click me',
inline: true,
duration: 4000,
}

View File

@ -0,0 +1,7 @@
import { css } from '@emotion/react'
import { toastProviderClasses } from './ToastProvider.classes'
export const ToastProviderStyles = css`
.${toastProviderClasses.toastsContainer} {
}
`

View File

@ -0,0 +1,108 @@
import React, { createContext, useContext, ReactNode } from 'react'
import {
ToastOptions,
useToaster,
toast as hotToast,
} from 'react-hot-toast/headless'
import { Toast, ToastProps } from '../Toast'
import { toastProviderClasses } from './ToastProvider.classes'
type ShowToastType = (props: ToastProps, options?: ToastOptions) => void
export const ToastContext = createContext<null | ShowToastType>(null)
type ToastsProps = {
toastsPropsMap: Map<string, ToastProps>
}
const Toasts: React.FC<ToastsProps> = ({ toastsPropsMap }) => {
const { toasts, handlers } = useToaster()
const { startPause, endPause, calculateOffset, updateHeight } = handlers
return (
<div
onMouseEnter={startPause}
onMouseLeave={endPause}
className={toastProviderClasses.toastsContainer}
>
{toasts.map((toast) => {
const customProps = toastsPropsMap.get(toast.id)
if (!customProps) {
console.error('Could not find toast with id', toast.id)
return null
}
const offset = calculateOffset(toast, {
reverseOrder: false,
gutter: 8,
})
const ref = (el: HTMLDivElement | null) => {
if (el && typeof toast.height !== 'number') {
const height = el.getBoundingClientRect().height
updateHeight(toast.id, height)
}
}
return (
<Toast
key={toast.id}
toastRefFunction={ref}
style={{
transition: 'all 0.5s ease-out',
opacity: toast.visible ? 1 : 0,
transform: `translateY(${offset}px)`,
}}
{...toast.ariaProps}
{...customProps}
onClose={() => {
hotToast.dismiss(toast.id)
customProps.onClose?.()
}}
/>
)
})}
</div>
)
}
export function useLSDToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
type ToastProviderProps = {
children: ReactNode
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toastsPropsMap, setToastsPropsMap] = React.useState<
Map<string, ToastProps>
>(new Map())
const showToast: ShowToastType = (toastProps, options) => {
// The toast function displays the toast, and returns its ID.
// The message is '' because we're not using it - currently
// we use the Toast component's 'title' and 'information' props to display info.
const toastId = hotToast('', options)
if (toastProps) {
setToastsPropsMap((prev) => {
const newMap = new Map(prev)
newMap.set(toastId, toastProps)
return newMap
})
}
}
return (
<ToastContext.Provider value={showToast}>
{children}
<Toasts toastsPropsMap={toastsPropsMap} />
</ToastContext.Provider>
)
}

View File

@ -0,0 +1 @@
export * from './ToastProvider'

View File

@ -38,3 +38,4 @@ export * from './components/DateField'
export * from './components/DatePicker'
export * from './components/Calendar'
export * from './components/Toast'
export * from './components/ToastProvider'

View File

@ -1,58 +0,0 @@
import { ToastProps } from '../components/Toast'
import { toastClasses } from '../components/Toast/Toast.classes'
export const setToastPosition = (
toastElement: HTMLDivElement,
position: ToastProps['position'],
xOffset: number = 0,
yOffset: number = 0,
) => {
switch (position) {
case 'top-left':
toastElement.style.top = `${yOffset}px`
toastElement.style.left = `${xOffset}px`
break
case 'top':
toastElement.style.top = `${yOffset}px`
toastElement.style.left = `${
(window.innerWidth - toastElement.offsetWidth) / 2 + xOffset
}px`
break
case 'top-right':
toastElement.style.top = `${yOffset}px`
toastElement.style.right = `${xOffset}px`
break
case 'bottom-left':
toastElement.style.bottom = `${yOffset}px`
toastElement.style.left = `${xOffset}px`
break
case 'bottom':
toastElement.style.bottom = `${yOffset}px`
toastElement.style.left = `${
(window.innerWidth - toastElement.offsetWidth) / 2 + xOffset
}px`
break
case 'bottom-right':
toastElement.style.bottom = `${yOffset}px`
toastElement.style.right = `${xOffset}px`
break
}
}
export const getToastAnimationClass = (
position: ToastProps['position'],
animation: ToastProps['animation'],
) => {
switch (animation) {
case 'moveDown':
return toastClasses.moveDown
case 'moveUp':
return toastClasses.moveUp
case 'auto':
return position!.startsWith('top')
? toastClasses.moveDown
: toastClasses.moveUp
default:
return ''
}
}

View File

@ -7939,6 +7939,25 @@ globby@^11.0.1, globby@^11.0.2:
merge2 "^1.4.1"
slash "^3.0.0"
globby@^9.2.0:
version "9.2.0"
resolved "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz"
integrity sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==
dependencies:
"@types/glob" "^7.1.1"
array-union "^1.0.2"
dir-glob "^2.2.2"
fast-glob "^2.2.6"
glob "^7.1.3"
ignore "^4.0.3"
pify "^4.0.1"
slash "^2.0.0"
goober@^2.1.10:
version "2.1.13"
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c"
integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz"
@ -10827,6 +10846,22 @@ react-inspector@^6.0.0:
version "6.0.2"
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-6.0.2.tgz#aa3028803550cb6dbd7344816d5c80bf39d07e9d"
integrity sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==
react-hot-toast@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994"
integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==
dependencies:
goober "^2.1.10"
react-inspector@^5.1.0:
version "5.1.1"
resolved "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz"
integrity sha512-GURDaYzoLbW8pMGXwYPDBIv6nqei4kK7LPRZ9q9HCZF54wqXz/dnylBp/kfE9XmekBhHvLDdcYeyIwSrvtOiWg==
dependencies:
"@babel/runtime" "^7.0.0"
is-dom "^1.0.0"
prop-types "^15.0.0"
react-is@18.1.0:
version "18.1.0"