diff --git a/packages/braid-design-system/src/lib/components/useToast/Toast.tsx b/packages/braid-design-system/src/lib/components/useToast/Toast.tsx index d7e1427ee00..05b35b01e3d 100644 --- a/packages/braid-design-system/src/lib/components/useToast/Toast.tsx +++ b/packages/braid-design-system/src/lib/components/useToast/Toast.tsx @@ -15,13 +15,15 @@ import type { InternalToast, ToastAction } from './ToastTypes'; import { lineHeightContainer } from '../../css/lineHeightContainer.css'; import buildDataAttributes from '../private/buildDataAttributes'; import * as styles from './Toast.css'; +import { toastGap } from './consts'; const toneToIcon = { critical: IconCritical, positive: IconPositive, }; -export const toastDuration = 10000; +// todo - revert change +export const toastDuration = 1000 * 4; const borderRadius = 'large'; @@ -61,6 +63,7 @@ const ToastIcon = ({ tone, icon }: Pick) => { interface ToastProps extends InternalToast { onClose: (dedupeKey: string, id: string) => void; + expanded: boolean; } const Toast = React.forwardRef( ( @@ -77,6 +80,7 @@ const Toast = React.forwardRef( action, shouldRemove, data, + expanded, ...restProps }, ref, @@ -97,6 +101,11 @@ const Toast = React.forwardRef( } }, [shouldRemove, remove, stopTimeout]); + useEffect( + () => (expanded ? stopTimeout() : startTimeout()), + [expanded, startTimeout, stopTimeout], + ); + assert( !icon || (icon.props.size === undefined && icon.props.tone === undefined), "Icons cannot set the 'size' or 'tone' prop when passed to a Toast component", @@ -141,58 +150,56 @@ const Toast = React.forwardRef( textAlign="left" role="alert" ref={ref} - onMouseEnter={stopTimeout} - onMouseLeave={startTimeout} - className={vanillaTheme} + className={[vanillaTheme, styles.toast]} + overflow="hidden" + boxShadow="large" + borderRadius={borderRadius} {...buildDataAttributes({ data, validateRestProps: restProps })} > - - - - - {tone !== 'neutral' || (tone === 'neutral' && icon) ? ( - - - - - - ) : null} - {content} + + + + {tone !== 'neutral' || (tone === 'neutral' && icon) ? ( - - } - variant="transparent" - onClick={remove} - label={closeLabel} - data={ - process.env.NODE_ENV !== 'production' - ? { testid: 'clearToast' } - : {} - } - /> + + - - - - + ) : null} + {content} + + + } + variant="transparent" + onClick={remove} + label={closeLabel} + data={ + process.env.NODE_ENV !== 'production' + ? { testid: 'clearToast' } + : {} + } + /> + + + + + ); }, diff --git a/packages/braid-design-system/src/lib/components/useToast/Toaster.css.ts b/packages/braid-design-system/src/lib/components/useToast/Toaster.css.ts new file mode 100644 index 00000000000..5a5be352407 --- /dev/null +++ b/packages/braid-design-system/src/lib/components/useToast/Toaster.css.ts @@ -0,0 +1,8 @@ +import { style } from '@vanilla-extract/css'; + +export const toaster = style({ + bottom: '0', + left: '50%', + transform: 'translateX(-50%)', + width: '420px', +}); diff --git a/packages/braid-design-system/src/lib/components/useToast/Toaster.tsx b/packages/braid-design-system/src/lib/components/useToast/Toaster.tsx index da67b0fb72c..f39ac6f2583 100644 --- a/packages/braid-design-system/src/lib/components/useToast/Toaster.tsx +++ b/packages/braid-design-system/src/lib/components/useToast/Toaster.tsx @@ -1,16 +1,20 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Box } from '../Box/Box'; import ToastComponent from './Toast'; import { useFlipList } from './useFlipList'; import type { InternalToast } from './ToastTypes'; +import * as styles from './Toaster.css'; + interface ToasterProps { toasts: InternalToast[]; removeToast: (key: string) => void; } + export const Toaster = ({ toasts, removeToast }: ToasterProps) => { - const { itemRef, remove } = useFlipList(); + const [expanded, setExpanded] = useState(false); + const { itemRef, remove } = useFlipList(expanded); const onClose = useCallback( (dedupeKey: string, id: string) => { @@ -25,20 +29,23 @@ export const Toaster = ({ toasts, removeToast }: ToasterProps) => { setExpanded(true)} + onMouseLeave={() => setExpanded(false)} + onFocus={() => setExpanded(true)} + onBlur={() => setExpanded(false)} + className={styles.toaster} > {toasts.map(({ id, ...rest }) => ( - - - + ))} ); diff --git a/packages/braid-design-system/src/lib/components/useToast/consts.ts b/packages/braid-design-system/src/lib/components/useToast/consts.ts new file mode 100644 index 00000000000..abbc2d2d94a --- /dev/null +++ b/packages/braid-design-system/src/lib/components/useToast/consts.ts @@ -0,0 +1 @@ +export const toastGap = 'xxsmall'; diff --git a/packages/braid-design-system/src/lib/components/useToast/useFlipList.ts b/packages/braid-design-system/src/lib/components/useToast/useFlipList.ts index 626724bec15..aeef69c3600 100644 --- a/packages/braid-design-system/src/lib/components/useToast/useFlipList.ts +++ b/packages/braid-design-system/src/lib/components/useToast/useFlipList.ts @@ -1,13 +1,20 @@ import { useMemo, useCallback } from 'react'; import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect'; +import { calc } from '@vanilla-extract/css-utils'; +import { vars } from '../../../entries/css'; +import { toastGap } from './consts'; + +const px = (v: string | number) => `${v}px`; const animationTimeout = 300; -const entranceTransition = 'transform 0.2s ease, opacity 0.2s ease'; -const exitTransition = 'opacity 0.1s ease'; +const entranceTransition = 'all 0.2s ease'; +const exitTransition = 'opacity 0.2s ease, height 0.2s ease'; + +const visibleStackedToasts = 3; interface Transform { - property: 'opacity' | 'transform' | 'scale'; + property: 'opacity' | 'transform' | 'scale' | 'height'; from?: string; to?: string; } @@ -58,7 +65,7 @@ const animate = ( }); }; -export const useFlipList = () => { +export const useFlipList = (expanded: boolean) => { const refs = useMemo(() => new Map(), []); const positions = useMemo(() => new Map(), []); @@ -71,35 +78,85 @@ export const useFlipList = () => { Array.from(refs.entries()).forEach(([id, element]) => { if (element) { + const index = Array.from(refs.keys()).indexOf(id); + const toastsLength = refs.size; + const position = toastsLength - index - 1; + + const { scale, opacity } = element.style; + const height = element.getBoundingClientRect().height; + + element.style.scale = '1'; + element.style.height = 'auto'; + const fullHeight = element.getBoundingClientRect().height; + + element.style.height = px(height); + element.style.scale = scale; + + const collapsedHeight = '8px'; + const prevTop = positions.get(id); - const { top, height } = element.getBoundingClientRect(); + const isNew = typeof prevTop !== 'number'; + + const collapsedScale = position === 1 ? 0.9 : 0.8; - if (typeof prevTop === 'number' && prevTop !== top) { - // Move animation + if (position > 0) { + // Move animation for toasts that are not the first animations.push({ element, transition: entranceTransition, transforms: [ { - property: 'transform', - from: `translateY(${prevTop - top}px)`, + property: 'height', + from: px(height), + to: expanded + ? px(fullHeight) + : `${calc(collapsedHeight).add(vars.space[toastGap])}`, + }, + { + property: 'scale', + from: scale, + to: expanded ? '1' : `${collapsedScale}`, + }, + { + property: 'opacity', + from: opacity, + to: position < visibleStackedToasts || expanded ? '1' : '0', }, ], }); - } else if (typeof prevTop !== 'number') { + } else if (isNew) { // Enter animation animations.push({ element, transition: entranceTransition, transforms: [ - { - property: 'transform', - from: `translateY(${height}px)`, - }, { property: 'opacity', from: '0', }, + { + property: 'height', + from: '0px', + to: px(fullHeight), + }, + ], + }); + } else { + // Move animation for the first toast + animations.push({ + element, + transition: entranceTransition, + transforms: [ + { + property: 'height', + from: px(height), + to: px(fullHeight), + }, + { + property: 'scale', + from: scale, + to: '1', + }, ], }); } @@ -128,6 +185,11 @@ export const useFlipList = () => { property: 'opacity', to: '0', }, + { + property: 'height', + from: element.style.height, + to: '0px', + }, ], exitTransition, cb,