fix: implements PR comment changes

This commit is contained in:
jongomez 2023-10-14 14:39:41 +01:00 committed by Jon
parent 24ccbd04b2
commit 7041359c44
8 changed files with 263 additions and 149 deletions

View File

@ -2,23 +2,23 @@ export const toastClasses = {
root: `lsd-toast`, root: `lsd-toast`,
inlineContainer: 'lsd-toast__inline-container', inlineContainer: 'lsd-toast__inline-container',
blockContainer: 'lsd-toast__block-container', columnContainer: 'lsd-toast__column-container',
large: 'lsd-toast--large', large: 'lsd-toast--large',
medium: 'lsd-toast--medium', medium: 'lsd-toast--medium',
small: 'lsd-toast--small', small: 'lsd-toast--small',
icon: 'lsd-toast__icon',
textContainer: 'lsd-toast__text-container', textContainer: 'lsd-toast__text-container',
columnIconContainer: 'lsd-toast__column-icon-container',
inlineIconContainer: 'lsd-toast__inline-icon-container',
title: 'lsd-toast__title', title: 'lsd-toast__title',
information: 'lsd-toast__information', information: 'lsd-toast__information',
inlineButtonContainer: 'lsd-toast__inline-button-container', inlineButtonContainer: 'lsd-toast__inline-button-container',
hiddenButtonContainer: 'lsd-toast__hidden-button-container', columnButtonContainer: 'lsd-toast__column-button-container',
blockButton: 'lsd-toast__block-button', buttonContainer: 'lsd-toast__button-container',
closeButton: 'lsd-toast__close-button', closeButton: 'lsd-toast__close-button',
actionButton: 'lsd-toast__action-button',
errorIcon: 'lsd-toast__error-icon',
errorIconContainer: 'lsd-toast__error-icon-container',
} }

View File

@ -1,5 +1,6 @@
import { Meta, Story } from '@storybook/react' import { Meta, Story } from '@storybook/react'
import { Toast, ToastProps } from './Toast' import { Toast, ToastProps } from './Toast'
import { Button } from '../Button'
export default { export default {
title: 'Toast', title: 'Toast',
@ -15,13 +16,11 @@ export default {
} as Meta } as Meta
export const Root: Story<ToastProps> = (args) => { export const Root: Story<ToastProps> = (args) => {
return <Toast {...args} /> return <Toast {...args} actions={<Button>Button</Button>} />
} }
Root.args = { Root.args = {
title: 'Toast Title', title: 'Toast Title',
information: '', information: '',
size: 'large', size: 'large',
buttonText: 'Click me',
inline: true,
} }

View File

@ -4,51 +4,42 @@ import { toastClasses } from './Toast.classes'
export const ToastStyles = css` export const ToastStyles = css`
.${toastClasses.root} { .${toastClasses.root} {
box-sizing: border-box; box-sizing: border-box;
display: flex; display: inline-flex;
position: fixed;
align-items: center; align-items: center;
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;
z-index: 9999;
height: fit-content; height: fit-content;
} }
.${toastClasses.inlineButtonContainer} { .${toastClasses.inlineButtonContainer} {
margin: 0 8px; flex-shrink: 0;
} }
.${toastClasses.hiddenButtonContainer} { .${toastClasses.columnButtonContainer} {
visibility: hidden;
}
.${toastClasses.blockButton} {
margin-top: 18px; margin-top: 18px;
margin-bottom: 12px; margin-bottom: 6px;
} }
.${toastClasses.inlineContainer} { .${toastClasses.inlineContainer} {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%; flex-grow: 1;
} }
.${toastClasses.blockContainer} { .${toastClasses.columnContainer} {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; justify-content: space-between;
width: 100%;
} }
.${toastClasses.textContainer} { .${toastClasses.textContainer} {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1;
color: rgb(var(--lsd-text-secondary)); color: rgb(var(--lsd-text-secondary));
margin-left: 32px; padding-left: 12px;
} }
.${toastClasses.title} { .${toastClasses.title} {
@ -59,11 +50,11 @@ export const ToastStyles = css`
margin-top: 4px; margin-top: 4px;
} }
.${toastClasses.actionButton} { .${toastClasses.buttonContainer} {
height: 28px; min-height: 28px;
min-width: 60px; min-width: 60px;
width: fit-content; width: fit-content;
padding: 6px 12px; padding: 0px 12px;
} }
.${toastClasses.closeButton} { .${toastClasses.closeButton} {
@ -71,39 +62,43 @@ export const ToastStyles = css`
flex-shrink: 0; flex-shrink: 0;
height: 28px; height: 28px;
width: 28px; width: 28px;
margin-left: auto;
} }
.${toastClasses.errorIconContainer} { .${toastClasses.columnIconContainer} {
width: 26px;
display: flex; display: flex;
position: relative; justify-content: center;
margin-bottom: auto; margin-bottom: auto;
position: relative;
top: 4px;
padding-left: 4px;
} }
.${toastClasses.errorIcon} { .${toastClasses.inlineIconContainer} {
position: absolute; display: flex;
top: 3px; align-items: center;
left: -26px; justify-content: center;
padding-left: 4px;
}
.${toastClasses.icon} {
position: relative;
} }
.${toastClasses.large} { .${toastClasses.large} {
.${toastClasses.textContainer} { width: 364px;
min-width: 204px;
}
} }
.${toastClasses.medium} { .${toastClasses.medium} {
.${toastClasses.textContainer} { width: 336px;
min-width: 184px;
}
} }
.${toastClasses.small} { .${toastClasses.small} {
.${toastClasses.textContainer} { width: 296px;
min-width: 144px;
}
.${toastClasses.errorIcon} { .${toastClasses.icon} {
top: 0px; top: 0px;
} }
} }

View File

@ -5,60 +5,63 @@ import {
useCommonProps, useCommonProps,
} from '../../utils/useCommonProps' } from '../../utils/useCommonProps'
import { toastClasses } from './Toast.classes' import { toastClasses } from './Toast.classes'
import { CloseIcon, ErrorIcon } from '../Icons' import { CloseIcon, ErrorIcon, LsdIconProps } from '../Icons'
import { IconButton } from '../IconButton' import { IconButton } from '../IconButton'
import { Typography } from '../Typography' import { Typography } from '../Typography'
import { Button } from '../Button'
export type ToastProps = CommonProps & export type ToastProps = CommonProps &
Omit<React.HTMLAttributes<HTMLDivElement>, 'label'> & { Omit<React.HTMLAttributes<HTMLDivElement>, 'label'> & {
isOpen?: boolean
title: string title: string
information?: string information?: string
inline?: boolean
buttonText?: string
onButtonClick?: () => void
onClose?: () => void onClose?: () => void
size?: 'large' | 'medium' | 'small' size?: 'large' | 'medium' | 'small'
toastRefFunction?: (el: HTMLDivElement | null) => void toastRef?: React.Ref<HTMLDivElement>
icon?: React.ComponentType<LsdIconProps> | null | false
actions?: React.ReactNode
} }
export const Toast: React.FC<ToastProps> & { export const Toast: React.FC<ToastProps> & {
classes: typeof toastClasses classes: typeof toastClasses
} = ({ } = ({
isOpen,
title, title,
information, information,
inline = true,
buttonText,
onButtonClick,
onClose, onClose,
size = 'large', size = 'large',
toastRefFunction, toastRef,
children, children,
icon,
actions,
...props ...props
}) => { }) => {
const commonProps = useCommonProps(props) const commonProps = useCommonProps(props)
const isInlineButtonHidden = !inline || !!information || !buttonText const isInline = !information
// If the toast is not open, do not render anything. const Icon = typeof icon === 'undefined' ? ErrorIcon : icon
if (isOpen === false) {
return null
}
return ( return (
<div <div
ref={toastRefFunction} ref={toastRef}
{...omitCommonProps(props)} {...omitCommonProps(props)}
className={clsx( className={clsx(
props.className,
commonProps.className, commonProps.className,
toastClasses.root, toastClasses.root,
toastClasses[size], toastClasses[size],
)} )}
> >
<div
className={clsx(
isInline
? toastClasses.inlineIconContainer
: toastClasses.columnIconContainer,
)}
>
{Icon && <Icon color="primary" className={toastClasses.icon} />}
</div>
<div <div
className={ className={
inline ? toastClasses.inlineContainer : toastClasses.blockContainer isInline ? toastClasses.inlineContainer : toastClasses.columnContainer
} }
> >
<div className={clsx(toastClasses.textContainer)}> <div className={clsx(toastClasses.textContainer)}>
@ -68,11 +71,6 @@ export const Toast: React.FC<ToastProps> & {
component="div" component="div"
variant={size === 'small' ? 'label2' : 'label1'} variant={size === 'small' ? 'label2' : 'label1'}
> >
<ErrorIcon
color="primary"
className={toastClasses.errorIcon}
style={{ width: '16px' }}
/>
{title} {title}
</Typography> </Typography>
)} )}
@ -86,30 +84,20 @@ export const Toast: React.FC<ToastProps> & {
{information} {information}
</Typography> </Typography>
)} )}
{!inline && !!buttonText && (
<Button
onClick={onButtonClick}
className={clsx(
toastClasses.actionButton,
toastClasses.blockButton,
)}
>
{buttonText}
</Button>
)}
</div> </div>
<div {!!actions && (
className={clsx( <div
toastClasses.inlineButtonContainer, className={clsx(
isInlineButtonHidden && toastClasses.hiddenButtonContainer, toastClasses.buttonContainer,
)} isInline
> ? toastClasses.inlineButtonContainer
<Button onClick={onButtonClick} className={toastClasses.actionButton}> : toastClasses.columnButtonContainer,
{`${isInlineButtonHidden ? '' : buttonText}`} )}
</Button> >
</div> {actions}
</div>
)}
</div> </div>
<IconButton <IconButton

View File

@ -1,3 +1,9 @@
export const toastProviderClasses = { export const toastProviderClasses = {
toastsContainer: `lsd-toast-provider__toasts-container`, toastContainer: `lsd-toast-provider__toast-container`,
topLeft: `lsd-toast-provider__toast--top-left`,
topCenter: `lsd-toast-provider__toast--top-center`,
topRight: `lsd-toast-provider__toast--top-right`,
bottomLeft: `lsd-toast-provider__toast--bottom-left`,
bottomCenter: `lsd-toast-provider__toast--bottom-center`,
bottomRight: `lsd-toast-provider__toast--bottom-right`,
} }

View File

@ -1,9 +1,11 @@
import { Meta, Story } from '@storybook/react' import { Meta, Story } from '@storybook/react'
import { ToastProps } from '../Toast' import {
import { ToastProvider, useLSDToast } from './ToastProvider' ToastContantAndOptions,
ToastProvider,
useToast,
} from './ToastProvider'
import { FC } from 'react' import { FC } from 'react'
import { Button } from '../Button' import { Button } from '../Button'
import { ToastOptions } from 'react-hot-toast'
import { pickCommonProps } from '../../utils/useCommonProps' import { pickCommonProps } from '../../utils/useCommonProps'
export default { export default {
@ -16,31 +18,43 @@ export default {
value: ['small', 'medium', 'large'], value: ['small', 'medium', 'large'],
}, },
}, },
position: {
type: {
name: 'enum',
value: [
'top-left',
'top-center',
'top-right',
'bottom-left',
'bottom-center',
'bottom-right',
],
},
},
}, },
} as Meta } as Meta
type ToastButtonProps = { const ToastButton: FC<ToastContantAndOptions> = ({
toastArgs: ToastProps & ToastOptions information,
} title,
...toastArgs
const ToastButton: FC<ToastButtonProps> = ({ toastArgs }) => { }) => {
const showToast = useLSDToast() const showToast = useToast()
return ( return (
<Button <Button
{...pickCommonProps(toastArgs)} {...pickCommonProps(toastArgs)}
onClick={() => showToast(toastArgs, { duration: toastArgs.duration })} onClick={() => showToast({ title, information }, { ...toastArgs })}
style={{ marginBottom: 8 }}
> >
Show Toast Show Toast
</Button> </Button>
) )
} }
export const Root: Story<ToastProps & ToastOptions> = (args) => { export const Root: Story<ToastContantAndOptions> = (args) => {
return ( return (
<ToastProvider> <ToastProvider>
<ToastButton toastArgs={args} /> <ToastButton {...args} />
</ToastProvider> </ToastProvider>
) )
} }
@ -49,7 +63,6 @@ Root.args = {
title: 'Toast Title', title: 'Toast Title',
information: '', information: '',
size: 'large', size: 'large',
buttonText: 'Click me', position: 'top-center',
inline: true,
duration: 4000, duration: 4000,
} }

View File

@ -2,6 +2,31 @@ import { css } from '@emotion/react'
import { toastProviderClasses } from './ToastProvider.classes' import { toastProviderClasses } from './ToastProvider.classes'
export const ToastProviderStyles = css` 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;
} }
` `

View File

@ -1,41 +1,94 @@
import React, { createContext, useContext, ReactNode } from 'react' import React, { createContext, useContext, ReactNode } from 'react'
import { import {
ToastOptions, ToastOptions as HotToastOptions,
useToaster, useToaster,
toast as hotToast, toast as hotToast,
} from 'react-hot-toast/headless' } from 'react-hot-toast/headless'
import { Toast, ToastProps } from '../Toast' import { Toast, ToastProps } from '../Toast'
import { toastProviderClasses } from './ToastProvider.classes' 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 type ToastContent = {
title: ToastProps['title']
export const ToastContext = createContext<null | ShowToastType>(null) information: ToastProps['information']
type ToastsProps = {
toastsPropsMap: Map<string, ToastProps>
} }
const Toasts: React.FC<ToastsProps> = ({ toastsPropsMap }) => { export type ToastOptions = Pick<HotToastOptions, 'position' | 'duration'> &
Omit<ToastProps, 'title' | 'information'>
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<ToastContextType>(null)
type ToastContainerProps = React.HTMLAttributes<HTMLDivElement> & {
toastsPropsMap: Map<string, ToastContantAndOptions>
}
const ToastContainer: React.FC<ToastContainerProps> = ({
toastsPropsMap,
className,
...containerProps
}) => {
const { toasts, handlers } = useToaster() const { toasts, handlers } = useToaster()
const { startPause, endPause, calculateOffset, updateHeight } = handlers const { startPause, endPause, calculateOffset, updateHeight } = handlers
return ( return (
<div <Portal id="toast">
onMouseEnter={startPause}
onMouseLeave={endPause}
className={toastProviderClasses.toastsContainer}
>
{toasts.map((toast) => { {toasts.map((toast) => {
const customProps = toastsPropsMap.get(toast.id) const propsMapValue = toastsPropsMap.get(toast.id)
if (!customProps) { if (!propsMapValue) {
console.error('Could not find toast with id', toast.id) console.warn('Could not find toast with id', toast.id)
return null return null
} }
const { position, duration, ...customProps } = propsMapValue
const offset = calculateOffset(toast, { const offset = calculateOffset(toast, {
reverseOrder: false, reverseOrder: false,
gutter: 8, gutter: 8,
defaultPosition: position,
}) })
const ref = (el: HTMLDivElement | null) => { const ref = (el: HTMLDivElement | null) => {
@ -45,29 +98,47 @@ const Toasts: React.FC<ToastsProps> = ({ toastsPropsMap }) => {
} }
} }
const { transform: positionTransform, positionClassName } =
getPositionStyle(position, offset)
return ( return (
<Toast <div
key={toast.id} key={`container-${toast.id}`}
toastRefFunction={ref} onMouseEnter={startPause}
onMouseLeave={endPause}
{...containerProps}
className={clsx(
toastProviderClasses.toastContainer,
positionClassName,
className,
)}
style={{ style={{
transition: 'all 0.5s ease-out', transform: positionTransform,
opacity: toast.visible ? 1 : 0, ...containerProps.style,
transform: `translateY(${offset}px)`,
}} }}
{...toast.ariaProps} >
{...customProps} <Toast
onClose={() => { key={toast.id}
hotToast.dismiss(toast.id) className={clsx(customProps.className)}
customProps.onClose?.() toastRef={ref}
}} {...customProps}
/> style={{
opacity: toast.visible ? 1 : 0,
...customProps.style,
}}
onClose={() => {
hotToast.dismiss(toast.id)
customProps.onClose?.()
}}
/>
</div>
) )
})} })}
</div> </Portal>
) )
} }
export function useLSDToast() { export function useToast() {
const context = useContext(ToastContext) const context = useContext(ToastContext)
if (!context) { if (!context) {
throw new Error('useToast must be used within a ToastProvider') throw new Error('useToast must be used within a ToastProvider')
@ -75,25 +146,39 @@ export function useLSDToast() {
return context return context
} }
type ToastProviderProps = { type ToastProviderProps = React.HTMLAttributes<HTMLDivElement> & {
providerToastOptions?: ToastOptions
children: ReactNode children: ReactNode
} }
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => { export const ToastProvider: React.FC<ToastProviderProps> = ({
providerToastOptions,
children,
...toastsContainerProps
}) => {
const [toastsPropsMap, setToastsPropsMap] = React.useState< const [toastsPropsMap, setToastsPropsMap] = React.useState<
Map<string, ToastProps> Map<string, ToastContantAndOptions>
>(new 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 toast function displays the toast, and returns its ID.
// The message is '' because we're not using it - currently // The message is '' because we're not using it - currently
// we use the Toast component's 'title' and 'information' props to display info. // 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) => { setToastsPropsMap((prev) => {
const newMap = new Map(prev) const newMap = new Map(prev)
newMap.set(toastId, toastProps) newMap.set(toastId, { ...content, ...options })
return newMap return newMap
}) })
} }
@ -102,7 +187,10 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
return ( return (
<ToastContext.Provider value={showToast}> <ToastContext.Provider value={showToast}>
{children} {children}
<Toasts toastsPropsMap={toastsPropsMap} /> <ToastContainer
toastsPropsMap={toastsPropsMap}
{...toastsContainerProps}
/>
</ToastContext.Provider> </ToastContext.Provider>
) )
} }