feat: adds animation and position props to toast component

This commit is contained in:
jongomez 2023-10-05 13:38:06 +01:00 committed by Jon
parent dc9ce1f4a8
commit 1f249490ce
5 changed files with 209 additions and 6 deletions

View File

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

View File

@ -13,15 +13,43 @@ export default {
value: ['small', 'medium', 'large'], 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 } as Meta
export const Root: Story<ToastProps> = ({ buttonText, ...args }) => { export const Root: Story<ToastProps> = ({ buttonText, ...args }) => {
const [isVisible, setIsVisible] = useState(args.isOpen) 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 ( return (
<div style={{ width: 400, position: 'relative' }}> <div style={{ width: 400, position: 'relative' }}>
<Button onClick={() => setIsVisible(!isVisible)}>Show Toast</Button> <Button onClick={handleClick}>Show Toast</Button>
<Toast <Toast
{...args} {...args}
isOpen={isVisible} isOpen={isVisible}
@ -37,7 +65,12 @@ Root.args = {
title: 'Toast Title', title: 'Toast Title',
information: '', information: '',
size: 'large', size: 'large',
xOffset: 20,
yOffset: 20,
buttonText: 'Click me', buttonText: 'Click me',
animation: 'auto',
position: 'top',
isOpen: false, isOpen: false,
inline: true, inline: true,
openTimeMilliseconds: 0,
} }

View File

@ -6,13 +6,14 @@ export const ToastStyles = css`
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
position: fixed; position: fixed;
bottom: 20px; align-items: center;
left: 20px;
background: rgb(var(--lsd-surface-primary)); background: rgb(var(--lsd-surface-primary));
border: 1px solid rgb(var(--lsd-border-primary)); border: 1px solid rgb(var(--lsd-border-primary));
padding: 8px; padding: 8px;
align-items: center;
z-index: 9999; z-index: 9999;
height: fit-content;
} }
.${toastClasses.inlineButtonContainer} { .${toastClasses.inlineButtonContainer} {
@ -86,6 +87,47 @@ export const ToastStyles = css`
left: -26px; 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.large} {
.${toastClasses.textContainer} { .${toastClasses.textContainer} {
min-width: 204px; min-width: 204px;

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React from 'react' import React, { useEffect, useRef, useState } from 'react'
import { import {
CommonProps, CommonProps,
omitCommonProps, omitCommonProps,
@ -10,6 +10,10 @@ import { CloseIcon, ErrorIcon } from '../Icons'
import { IconButton } from '../IconButton' import { IconButton } from '../IconButton'
import { Typography } from '../Typography' import { Typography } from '../Typography'
import { Button } from '../Button' import { Button } from '../Button'
import {
getToastAnimationClass,
setToastPosition,
} from '../../utils/toast.utils'
export type ToastProps = CommonProps & export type ToastProps = CommonProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'label'> & { Omit<React.HTMLAttributes<HTMLDivElement>, 'label'> & {
@ -21,6 +25,17 @@ export type ToastProps = CommonProps &
onButtonClick?: () => void onButtonClick?: () => void
onClose?: () => void onClose?: () => void
size?: 'large' | 'medium' | 'small' 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
} }
export const Toast: React.FC<ToastProps> & { export const Toast: React.FC<ToastProps> & {
@ -34,11 +49,57 @@ export const Toast: React.FC<ToastProps> & {
onButtonClick, onButtonClick,
onClose, onClose,
size = 'large', size = 'large',
position = 'top',
animation = 'auto',
openTimeMilliseconds,
xOffset = 20,
yOffset = 20,
children, children,
...props ...props
}) => { }) => {
const commonProps = useCommonProps(props) const commonProps = useCommonProps(props)
const isInlineButtonHidden = !inline || !!information || !buttonText 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 the toast is not open, do not render anything.
if (!isOpen) { if (!isOpen) {
@ -47,12 +108,16 @@ export const Toast: React.FC<ToastProps> & {
return ( return (
<div <div
ref={toastRef}
{...omitCommonProps(props)} {...omitCommonProps(props)}
className={clsx( className={clsx(
commonProps.className, commonProps.className,
toastClasses.root, toastClasses.root,
toastClasses[size], toastClasses[size],
getToastAnimationClass(position, animation),
isClosing && toastClasses.closingAnimation,
)} )}
onAnimationEnd={handleAnimationEnd}
> >
<div <div
className={ className={
@ -103,7 +168,7 @@ export const Toast: React.FC<ToastProps> & {
</div> </div>
<IconButton <IconButton
onClick={onClose} onClick={handleClose}
className={toastClasses.closeButton} className={toastClasses.closeButton}
size="medium" size="medium"
> >

View File

@ -0,0 +1,58 @@
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 ''
}
}