diff --git a/packages/lsd-react/src/components/Toast/Toast.classes.ts b/packages/lsd-react/src/components/Toast/Toast.classes.ts index b5c49e2..523a36a 100644 --- a/packages/lsd-react/src/components/Toast/Toast.classes.ts +++ b/packages/lsd-react/src/components/Toast/Toast.classes.ts @@ -2,23 +2,23 @@ export const toastClasses = { root: `lsd-toast`, inlineContainer: 'lsd-toast__inline-container', - blockContainer: 'lsd-toast__block-container', + columnContainer: 'lsd-toast__column-container', large: 'lsd-toast--large', medium: 'lsd-toast--medium', small: 'lsd-toast--small', + icon: 'lsd-toast__icon', + textContainer: 'lsd-toast__text-container', + columnIconContainer: 'lsd-toast__column-icon-container', + inlineIconContainer: 'lsd-toast__inline-icon-container', title: 'lsd-toast__title', information: 'lsd-toast__information', inlineButtonContainer: 'lsd-toast__inline-button-container', - hiddenButtonContainer: 'lsd-toast__hidden-button-container', - blockButton: 'lsd-toast__block-button', + columnButtonContainer: 'lsd-toast__column-button-container', + buttonContainer: 'lsd-toast__button-container', closeButton: 'lsd-toast__close-button', - actionButton: 'lsd-toast__action-button', - - errorIcon: 'lsd-toast__error-icon', - errorIconContainer: 'lsd-toast__error-icon-container', } diff --git a/packages/lsd-react/src/components/Toast/Toast.stories.tsx b/packages/lsd-react/src/components/Toast/Toast.stories.tsx index 1521b5e..76f7740 100644 --- a/packages/lsd-react/src/components/Toast/Toast.stories.tsx +++ b/packages/lsd-react/src/components/Toast/Toast.stories.tsx @@ -1,5 +1,6 @@ import { Meta, Story } from '@storybook/react' import { Toast, ToastProps } from './Toast' +import { Button } from '../Button' export default { title: 'Toast', @@ -15,13 +16,11 @@ export default { } as Meta export const Root: Story = (args) => { - return + return Button} /> } Root.args = { title: 'Toast Title', information: '', size: 'large', - buttonText: 'Click me', - inline: true, } diff --git a/packages/lsd-react/src/components/Toast/Toast.styles.ts b/packages/lsd-react/src/components/Toast/Toast.styles.ts index b47b80c..823f1c7 100644 --- a/packages/lsd-react/src/components/Toast/Toast.styles.ts +++ b/packages/lsd-react/src/components/Toast/Toast.styles.ts @@ -4,51 +4,42 @@ import { toastClasses } from './Toast.classes' export const ToastStyles = css` .${toastClasses.root} { box-sizing: border-box; - display: flex; - position: fixed; + display: inline-flex; align-items: center; background: rgb(var(--lsd-surface-primary)); border: 1px solid rgb(var(--lsd-border-primary)); padding: 8px; - z-index: 9999; - height: fit-content; } .${toastClasses.inlineButtonContainer} { - margin: 0 8px; + flex-shrink: 0; } - .${toastClasses.hiddenButtonContainer} { - visibility: hidden; - } - - .${toastClasses.blockButton} { + .${toastClasses.columnButtonContainer} { margin-top: 18px; - margin-bottom: 12px; + margin-bottom: 6px; } .${toastClasses.inlineContainer} { display: flex; align-items: center; justify-content: space-between; - width: 100%; + flex-grow: 1; } - .${toastClasses.blockContainer} { + .${toastClasses.columnContainer} { display: flex; - align-items: center; + flex-direction: column; justify-content: space-between; - width: 100%; } .${toastClasses.textContainer} { display: flex; flex-direction: column; - flex-grow: 1; color: rgb(var(--lsd-text-secondary)); - margin-left: 32px; + padding-left: 12px; } .${toastClasses.title} { @@ -59,11 +50,11 @@ export const ToastStyles = css` margin-top: 4px; } - .${toastClasses.actionButton} { - height: 28px; + .${toastClasses.buttonContainer} { + min-height: 28px; min-width: 60px; width: fit-content; - padding: 6px 12px; + padding: 0px 12px; } .${toastClasses.closeButton} { @@ -71,39 +62,43 @@ export const ToastStyles = css` flex-shrink: 0; height: 28px; width: 28px; + + margin-left: auto; } - .${toastClasses.errorIconContainer} { - width: 26px; + .${toastClasses.columnIconContainer} { display: flex; - position: relative; + justify-content: center; margin-bottom: auto; + + position: relative; + top: 4px; + padding-left: 4px; } - .${toastClasses.errorIcon} { - position: absolute; - top: 3px; - left: -26px; + .${toastClasses.inlineIconContainer} { + display: flex; + align-items: center; + justify-content: center; + padding-left: 4px; + } + + .${toastClasses.icon} { + position: relative; } .${toastClasses.large} { - .${toastClasses.textContainer} { - min-width: 204px; - } + width: 364px; } .${toastClasses.medium} { - .${toastClasses.textContainer} { - min-width: 184px; - } + width: 336px; } .${toastClasses.small} { - .${toastClasses.textContainer} { - min-width: 144px; - } + width: 296px; - .${toastClasses.errorIcon} { + .${toastClasses.icon} { top: 0px; } } diff --git a/packages/lsd-react/src/components/Toast/Toast.tsx b/packages/lsd-react/src/components/Toast/Toast.tsx index 79aec92..6599fe4 100644 --- a/packages/lsd-react/src/components/Toast/Toast.tsx +++ b/packages/lsd-react/src/components/Toast/Toast.tsx @@ -5,60 +5,63 @@ import { useCommonProps, } from '../../utils/useCommonProps' import { toastClasses } from './Toast.classes' -import { CloseIcon, ErrorIcon } from '../Icons' +import { CloseIcon, ErrorIcon, LsdIconProps } from '../Icons' import { IconButton } from '../IconButton' import { Typography } from '../Typography' -import { Button } from '../Button' export type ToastProps = CommonProps & Omit, 'label'> & { - isOpen?: boolean title: string information?: string - inline?: boolean - buttonText?: string - onButtonClick?: () => void onClose?: () => void size?: 'large' | 'medium' | 'small' - toastRefFunction?: (el: HTMLDivElement | null) => void + toastRef?: React.Ref + icon?: React.ComponentType | null | false + actions?: React.ReactNode } export const Toast: React.FC & { classes: typeof toastClasses } = ({ - isOpen, title, information, - inline = true, - buttonText, - onButtonClick, onClose, size = 'large', - toastRefFunction, + toastRef, children, + icon, + actions, ...props }) => { const commonProps = useCommonProps(props) - const isInlineButtonHidden = !inline || !!information || !buttonText + const isInline = !information - // If the toast is not open, do not render anything. - if (isOpen === false) { - return null - } + const Icon = typeof icon === 'undefined' ? ErrorIcon : icon return (
+
+ {Icon && } +
+
@@ -68,11 +71,6 @@ export const Toast: React.FC & { component="div" variant={size === 'small' ? 'label2' : 'label1'} > - {title} )} @@ -86,30 +84,20 @@ export const Toast: React.FC & { {information} )} - - {!inline && !!buttonText && ( - - )}
-
- -
+ {!!actions && ( +
+ {actions} +
+ )}
= ({ toastArgs }) => { - const showToast = useLSDToast() +const ToastButton: FC = ({ + information, + title, + ...toastArgs +}) => { + const showToast = useToast() return ( ) } -export const Root: Story = (args) => { +export const Root: Story = (args) => { return ( - + ) } @@ -49,7 +63,6 @@ Root.args = { title: 'Toast Title', information: '', size: 'large', - buttonText: 'Click me', - inline: true, + position: 'top-center', duration: 4000, } diff --git a/packages/lsd-react/src/components/ToastProvider/ToastProvider.styles.ts b/packages/lsd-react/src/components/ToastProvider/ToastProvider.styles.ts index f10f070..c2d0de1 100644 --- a/packages/lsd-react/src/components/ToastProvider/ToastProvider.styles.ts +++ b/packages/lsd-react/src/components/ToastProvider/ToastProvider.styles.ts @@ -2,6 +2,31 @@ import { css } from '@emotion/react' import { toastProviderClasses } from './ToastProvider.classes' export const ToastProviderStyles = css` - .${toastProviderClasses.toastsContainer} { + .${toastProviderClasses.toastContainer} { + position: fixed; + + transition: all 230ms cubic-bezier(0.21, 1.02, 0.73, 1); + + z-index: 9999; + } + + .${toastProviderClasses.topLeft}, + .${toastProviderClasses.topCenter}, + .${toastProviderClasses.topRight} { + top: 0; + } + + .${toastProviderClasses.bottomLeft}, + .${toastProviderClasses.bottomCenter}, + .${toastProviderClasses.bottomRight} { + bottom: 0; + } + + .${toastProviderClasses.topCenter}, .${toastProviderClasses.bottomCenter} { + left: 50%; + } + + .${toastProviderClasses.topRight}, .${toastProviderClasses.bottomRight} { + right: 0; } ` diff --git a/packages/lsd-react/src/components/ToastProvider/ToastProvider.tsx b/packages/lsd-react/src/components/ToastProvider/ToastProvider.tsx index 18df1a4..98f8ecd 100644 --- a/packages/lsd-react/src/components/ToastProvider/ToastProvider.tsx +++ b/packages/lsd-react/src/components/ToastProvider/ToastProvider.tsx @@ -1,41 +1,94 @@ import React, { createContext, useContext, ReactNode } from 'react' import { - ToastOptions, + ToastOptions as HotToastOptions, useToaster, toast as hotToast, } from 'react-hot-toast/headless' import { Toast, ToastProps } from '../Toast' import { toastProviderClasses } from './ToastProvider.classes' +import { ToastPosition } from 'react-hot-toast' +import { Portal } from '../PortalProvider/Portal' +import clsx from 'clsx' -type ShowToastType = (props: ToastProps, options?: ToastOptions) => void - -export const ToastContext = createContext(null) - -type ToastsProps = { - toastsPropsMap: Map +export type ToastContent = { + title: ToastProps['title'] + information: ToastProps['information'] } -const Toasts: React.FC = ({ toastsPropsMap }) => { +export type ToastOptions = Pick & + Omit + +export type ToastContantAndOptions = ToastContent & ToastOptions + +type ShowToastType = (content: ToastContent, options?: ToastOptions) => void + +const getPositionStyle = ( + position: ToastPosition | undefined, + offset: number, +): { positionClassName: string; transform: string } => { + if (!position) + return { positionClassName: '', transform: `translateY(${offset}px)` } + + let positionClassName = '' + const isCenter = position.includes('center') + const isBottom = position.includes('bottom') + // Dynamic style part, not included in CSS classes. + const transform = `translateY(${isBottom ? -offset : offset}px) translateX(${ + isCenter ? '-50%' : '0' + })` + + if (position === 'top-left') { + positionClassName = toastProviderClasses.topLeft + } else if (position === 'top-center') { + positionClassName = toastProviderClasses.topCenter + } else if (position === 'top-right') { + positionClassName = toastProviderClasses.topRight + } else if (position === 'bottom-left') { + positionClassName = toastProviderClasses.bottomLeft + } else if (position === 'bottom-center') { + positionClassName = toastProviderClasses.bottomCenter + } else if (position === 'bottom-right') { + positionClassName = toastProviderClasses.bottomRight + } + + return { + positionClassName, + transform, + } +} + +type ToastContextType = ShowToastType | null + +export const ToastContext = createContext(null) + +type ToastContainerProps = React.HTMLAttributes & { + toastsPropsMap: Map +} + +const ToastContainer: React.FC = ({ + toastsPropsMap, + className, + ...containerProps +}) => { const { toasts, handlers } = useToaster() const { startPause, endPause, calculateOffset, updateHeight } = handlers return ( -
+ {toasts.map((toast) => { - const customProps = toastsPropsMap.get(toast.id) + const propsMapValue = toastsPropsMap.get(toast.id) - if (!customProps) { - console.error('Could not find toast with id', toast.id) + if (!propsMapValue) { + console.warn('Could not find toast with id', toast.id) return null } + const { position, duration, ...customProps } = propsMapValue + const offset = calculateOffset(toast, { reverseOrder: false, gutter: 8, + defaultPosition: position, }) const ref = (el: HTMLDivElement | null) => { @@ -45,29 +98,47 @@ const Toasts: React.FC = ({ toastsPropsMap }) => { } } + const { transform: positionTransform, positionClassName } = + getPositionStyle(position, offset) + return ( - { - hotToast.dismiss(toast.id) - customProps.onClose?.() - }} - /> + > + { + hotToast.dismiss(toast.id) + customProps.onClose?.() + }} + /> +
) })} -
+ ) } -export function useLSDToast() { +export function useToast() { const context = useContext(ToastContext) if (!context) { throw new Error('useToast must be used within a ToastProvider') @@ -75,25 +146,39 @@ export function useLSDToast() { return context } -type ToastProviderProps = { +type ToastProviderProps = React.HTMLAttributes & { + providerToastOptions?: ToastOptions children: ReactNode } -export const ToastProvider: React.FC = ({ children }) => { +export const ToastProvider: React.FC = ({ + providerToastOptions, + children, + ...toastsContainerProps +}) => { const [toastsPropsMap, setToastsPropsMap] = React.useState< - Map + Map >(new Map()) - const showToast: ShowToastType = (toastProps, options) => { + const showToast: ShowToastType = (content, showToastOptions) => { + // There are 2 ways to define the toast options: + // 1. Globally, in the ToastProvider component's props. + // 2. Per-toast, in the showToast function's second argument. + // The per-toast options override the global options. + const options = { + ...providerToastOptions, + ...showToastOptions, + } + // 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) + const toastId = hotToast('', { duration: options?.duration }) - if (toastProps) { + if (content) { setToastsPropsMap((prev) => { const newMap = new Map(prev) - newMap.set(toastId, toastProps) + newMap.set(toastId, { ...content, ...options }) return newMap }) } @@ -102,7 +187,10 @@ export const ToastProvider: React.FC = ({ children }) => { return ( {children} - + ) }