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`,
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',
}

View File

@ -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<ToastProps> = (args) => {
return <Toast {...args} />
return <Toast {...args} actions={<Button>Button</Button>} />
}
Root.args = {
title: 'Toast Title',
information: '',
size: 'large',
buttonText: 'Click me',
inline: true,
}

View File

@ -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;
}
}

View File

@ -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<React.HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement>
icon?: React.ComponentType<LsdIconProps> | null | false
actions?: React.ReactNode
}
export const Toast: React.FC<ToastProps> & {
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 (
<div
ref={toastRefFunction}
ref={toastRef}
{...omitCommonProps(props)}
className={clsx(
props.className,
commonProps.className,
toastClasses.root,
toastClasses[size],
)}
>
<div
className={clsx(
isInline
? toastClasses.inlineIconContainer
: toastClasses.columnIconContainer,
)}
>
{Icon && <Icon color="primary" className={toastClasses.icon} />}
</div>
<div
className={
inline ? toastClasses.inlineContainer : toastClasses.blockContainer
isInline ? toastClasses.inlineContainer : toastClasses.columnContainer
}
>
<div className={clsx(toastClasses.textContainer)}>
@ -68,11 +71,6 @@ export const Toast: React.FC<ToastProps> & {
component="div"
variant={size === 'small' ? 'label2' : 'label1'}
>
<ErrorIcon
color="primary"
className={toastClasses.errorIcon}
style={{ width: '16px' }}
/>
{title}
</Typography>
)}
@ -86,30 +84,20 @@ export const Toast: React.FC<ToastProps> & {
{information}
</Typography>
)}
{!inline && !!buttonText && (
<Button
onClick={onButtonClick}
className={clsx(
toastClasses.actionButton,
toastClasses.blockButton,
)}
>
{buttonText}
</Button>
)}
</div>
<div
className={clsx(
toastClasses.inlineButtonContainer,
isInlineButtonHidden && toastClasses.hiddenButtonContainer,
)}
>
<Button onClick={onButtonClick} className={toastClasses.actionButton}>
{`${isInlineButtonHidden ? '' : buttonText}`}
</Button>
</div>
{!!actions && (
<div
className={clsx(
toastClasses.buttonContainer,
isInline
? toastClasses.inlineButtonContainer
: toastClasses.columnButtonContainer,
)}
>
{actions}
</div>
)}
</div>
<IconButton

View File

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

View File

@ -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;
}
`

View File

@ -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 | ShowToastType>(null)
type ToastsProps = {
toastsPropsMap: Map<string, ToastProps>
export type ToastContent = {
title: ToastProps['title']
information: ToastProps['information']
}
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 { startPause, endPause, calculateOffset, updateHeight } = handlers
return (
<div
onMouseEnter={startPause}
onMouseLeave={endPause}
className={toastProviderClasses.toastsContainer}
>
<Portal id="toast">
{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<ToastsProps> = ({ toastsPropsMap }) => {
}
}
const { transform: positionTransform, positionClassName } =
getPositionStyle(position, offset)
return (
<Toast
key={toast.id}
toastRefFunction={ref}
<div
key={`container-${toast.id}`}
onMouseEnter={startPause}
onMouseLeave={endPause}
{...containerProps}
className={clsx(
toastProviderClasses.toastContainer,
positionClassName,
className,
)}
style={{
transition: 'all 0.5s ease-out',
opacity: toast.visible ? 1 : 0,
transform: `translateY(${offset}px)`,
transform: positionTransform,
...containerProps.style,
}}
{...toast.ariaProps}
{...customProps}
onClose={() => {
hotToast.dismiss(toast.id)
customProps.onClose?.()
}}
/>
>
<Toast
key={toast.id}
className={clsx(customProps.className)}
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)
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<HTMLDivElement> & {
providerToastOptions?: ToastOptions
children: ReactNode
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
export const ToastProvider: React.FC<ToastProviderProps> = ({
providerToastOptions,
children,
...toastsContainerProps
}) => {
const [toastsPropsMap, setToastsPropsMap] = React.useState<
Map<string, ToastProps>
Map<string, ToastContantAndOptions>
>(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<ToastProviderProps> = ({ children }) => {
return (
<ToastContext.Provider value={showToast}>
{children}
<Toasts toastsPropsMap={toastsPropsMap} />
<ToastContainer
toastsPropsMap={toastsPropsMap}
{...toastsContainerProps}
/>
</ToastContext.Provider>
)
}