mirror of
https://github.com/acid-info/lsd.git
synced 2025-01-11 17:44:14 +00:00
feat: adds toast provider component
This commit is contained in:
parent
a535cfaa61
commit
24ccbd04b2
@ -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": {
|
||||
|
@ -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 }> = ({
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -0,0 +1,3 @@
|
||||
export const toastProviderClasses = {
|
||||
toastsContainer: `lsd-toast-provider__toasts-container`,
|
||||
}
|
@ -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,
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { css } from '@emotion/react'
|
||||
import { toastProviderClasses } from './ToastProvider.classes'
|
||||
|
||||
export const ToastProviderStyles = css`
|
||||
.${toastProviderClasses.toastsContainer} {
|
||||
}
|
||||
`
|
@ -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>
|
||||
)
|
||||
}
|
1
packages/lsd-react/src/components/ToastProvider/index.ts
Normal file
1
packages/lsd-react/src/components/ToastProvider/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './ToastProvider'
|
@ -38,3 +38,4 @@ export * from './components/DateField'
|
||||
export * from './components/DatePicker'
|
||||
export * from './components/Calendar'
|
||||
export * from './components/Toast'
|
||||
export * from './components/ToastProvider'
|
||||
|
@ -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 ''
|
||||
}
|
||||
}
|
35
yarn.lock
35
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user