Skip to content

Commit

Permalink
refactor(Toaster): use react-transition-group (#657)
Browse files Browse the repository at this point in the history
  • Loading branch information
oynikishin authored May 17, 2023
1 parent efbe9fc commit 95a8744
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 268 deletions.
10 changes: 8 additions & 2 deletions src/components/Toaster/Provider/ToasterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export const ToasterProvider = React.forwardRef<ToasterPublicMethods, Props>(
nextToasts = removeToast(toasts, name);
}

return [...nextToasts, {...toast, addedAt: Date.now()}];
return [
...nextToasts,
{
...toast,
addedAt: Date.now(),
ref: React.createRef<HTMLDivElement>(),
},
];
});
}, []);

Expand Down Expand Up @@ -51,7 +58,6 @@ export const ToasterProvider = React.forwardRef<ToasterPublicMethods, Props>(
...toasts[index],
...override,
isOverride: true,
updatesCounter: (toasts[index].updatesCounter ?? 0) + 1,
},
...toasts.slice(index + 1),
];
Expand Down
128 changes: 4 additions & 124 deletions src/components/Toaster/Toast/Toast.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,19 @@

$block: '.#{variables.$ns}toast';

// Function to get animation that will trigger action in `onAnimationEnd` event handler
// It always should be at the end of animations list
@function get-trailing-keyframe($keyframe, $delay) {
// Short animation duration to make sure that onAnimationEnd would be triggered
@return $keyframe 0.0001s forwards $delay;
}

#{$block} {
$containerClass: #{&}__container;
$iconClass: #{&}__icon;

// Deprecated. This custom property can be removed in next major release
// These custom properties used in ToastAnimation
--yc-toaster-margin: 10px;
--yc-toaster-padding: 16px;

display: flex;
box-sizing: border-box;
position: absolute;
opacity: 0;
position: relative;
width: inherit;
margin-bottom: 10px;
margin-bottom: var(--yc-toaster-margin);
padding: var(--yc-toaster-padding);
font-size: 13px;
border-radius: 8px;
Expand All @@ -35,52 +28,6 @@ $block: '.#{variables.$ns}toast';
width: 100%;
}

// Since the "opacity" needs to be overridden when toast appearing
// The "created" modifier must be higher in code than the "appearing" modifier.
&_created {
position: relative;
opacity: 1;
}

&_appearing {
opacity: 0;
height: 0;
margin-bottom: 0;
padding: 0;
transition: height 0.35s ease;
}

&_show-animation {
$indents-duration: 0.35s;
$move-duration: 0.25s;

animation: toast-set-indents $indents-duration ease forwards,
toast-move-left $move-duration ease forwards $indents-duration,
get-trailing-keyframe(toast-display-end, $indents-duration + $move-duration);
}

&_show-animation#{&}_mobile {
animation: toast-set-indents 0.35s ease forwards, toast-move-top 0.25s ease forwards 0.35s;
}

&_hide-animation {
$move-duration: 0.25s;
$remove-duration: 0.35s;

animation: toast-move-right $move-duration ease forwards,
toast-remove-height $remove-duration ease forwards $move-duration,
get-trailing-keyframe(toast-hide-end, $move-duration + $remove-duration);
}

&_hide-animation#{&}_mobile {
$move-duration: 0.25s;
$remove-duration: 0.35s;

animation: toast-move-bottom $move-duration ease forwards,
toast-remove-height $remove-duration ease forwards $move-duration,
get-trailing-keyframe(toast-hide-end, $move-duration + $remove-duration);
}

&_default {
background-color: var(--yc-color-base-float);
}
Expand Down Expand Up @@ -169,70 +116,3 @@ $block: '.#{variables.$ns}toast';
right: 14px;
}
}

@keyframes toast-move-right {
from {
transform: translateX(0);
}

to {
opacity: 0;
transform: translateX(10px);
}
}

@keyframes toast-move-left {
from {
opacity: 0;
transform: translateX(10px);
}

to {
opacity: 1;
transform: translateX(0);
}
}

@keyframes toast-display-end {
}

@keyframes toast-move-top {
from {
opacity: 0;
transform: translateY(10px);
}

to {
opacity: 1;
transform: translateY(0);
}
}

@keyframes toast-move-bottom {
from {
transform: translateX(0);
}

to {
opacity: 0;
transform: translateY(10px);
}
}

@keyframes toast-remove-height {
to {
height: 0;
margin-bottom: 0;
padding: 0;
}
}

@keyframes toast-hide-end {
}

@keyframes toast-set-indents {
to {
margin-bottom: 10px;
padding: 16px;
}
}
135 changes: 10 additions & 125 deletions src/components/Toaster/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import i18n from '../i18n';
import './Toast.scss';

const b = block('toast');
const FADE_IN_LAST_ANIMATION_NAME = 'toast-display-end';
const FADE_OUT_LAST_ANIMATION_NAME = 'toast-hide-end';
const DEFAULT_TIMEOUT = 5000;
const CROSS_ICON_SIZE = 12;
const TITLE_ICONS: Record<ToastType, IconProps['data']> = {
Expand All @@ -22,109 +20,12 @@ const TITLE_ICONS: Record<ToastType, IconProps['data']> = {
};

interface ToastInnerProps {
removeCallback: VoidFunction;
removeCallback: (name: string) => void;
mobile?: boolean;
}

interface ToastUnitedProps extends InternalToastProps, ToastInnerProps {}

enum ToastStatus {
Creating = 'creating',
ShowingIndents = 'showing-indents',
ShowingHeight = 'showing-height',
Hiding = 'hiding',
Shown = 'shown',
}

interface UseToastHeightProps {
isOverride: boolean;
status: ToastStatus;
updatesCounter: number;
}

function getHeight(ref: React.RefObject<HTMLDivElement>) {
return ref.current?.offsetHeight;
}

function useToastHeight({isOverride, status, updatesCounter}: UseToastHeightProps) {
const [height, setHeight] = React.useState<number | undefined>(undefined);

const ref = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
// ATTENTION: getting `offsetHeight` is important for correct transaction of `height`
// HOW THIS WORKS:
// On the step of changing state to `ShowingIndent` we set class with height `transition`
// We need now to apply this styles, to achieve this we're calling `offsetHeight`, to force repaint
// Now we call changing state to `ShowingHeight` and changing height now happen with transition
if (status === ToastStatus.ShowingIndents) {
getHeight(ref);
}
}, [status]);

React.useEffect(() => {
if (
status === ToastStatus.ShowingIndents ||
status === ToastStatus.ShowingHeight ||
status === ToastStatus.Hiding
) {
return; // Not update height during animation
}

const newHeight = getHeight(ref);
setHeight(newHeight);
}, [isOverride, updatesCounter]);

const style: React.CSSProperties = {};
if (height && status !== ToastStatus.ShowingIndents && status !== ToastStatus.Shown) {
style.height = height;
}

return {style, ref};
}

interface UseToastStatusProps {
onRemove: VoidFunction;
}

function useToastStatus({onRemove}: UseToastStatusProps) {
const [status, setStatus] = React.useState<ToastStatus>(ToastStatus.Creating);

React.useEffect(() => {
if (status === ToastStatus.Creating) {
setStatus(ToastStatus.ShowingIndents);
} else if (status === ToastStatus.ShowingIndents) {
setStatus(ToastStatus.ShowingHeight);
}
}, [status]);

const onFadeInAnimationEnd: React.AnimationEventHandler<HTMLDivElement> = (e) => {
if (e.animationName === FADE_IN_LAST_ANIMATION_NAME) {
setStatus(ToastStatus.Shown);
}
};

const onFadeOutAnimationEnd: React.AnimationEventHandler<HTMLDivElement> = (e) => {
if (e.animationName === FADE_OUT_LAST_ANIMATION_NAME) {
onRemove();
}
};

let onAnimationEnd;
if (status === ToastStatus.ShowingHeight) {
onAnimationEnd = onFadeInAnimationEnd;
}
if (status === ToastStatus.Hiding) {
onAnimationEnd = onFadeOutAnimationEnd;
}

const handleClose = React.useCallback(() => {
setStatus(ToastStatus.Hiding);
}, []);

return {status, containerProps: {onAnimationEnd}, handleClose};
}

interface RenderActionsProps {
actions?: ToastAction[];
onClose: VoidFunction;
Expand Down Expand Up @@ -173,8 +74,9 @@ function renderIconByType({type}: RenderIconProps) {
return <Icon data={TITLE_ICONS[type]} className={b('icon', {[type]: true})} />;
}

export function Toast(props: ToastUnitedProps) {
export const Toast = React.forwardRef<HTMLDivElement, ToastUnitedProps>(function Toast(props, ref) {
const {
name,
content,
actions,
title,
Expand All @@ -183,56 +85,39 @@ export function Toast(props: ToastUnitedProps) {
renderIcon,
autoHiding: timeoutProp = DEFAULT_TIMEOUT,
isClosable = true,
isOverride = false,
updatesCounter = 0,
mobile = false,
removeCallback,
} = props;

const {
status,
containerProps: statusProps,
handleClose,
} = useToastStatus({onRemove: props.removeCallback});

const heightProps = useToastHeight({isOverride, status, updatesCounter});

const onClose = React.useCallback(() => removeCallback(name), [removeCallback, name]);
const timeout = typeof timeoutProp === 'number' ? timeoutProp : undefined;
const closeOnTimeoutProps = useCloseOnTimeout<HTMLDivElement>({onClose: handleClose, timeout});
const closeOnTimeoutProps = useCloseOnTimeout<HTMLDivElement>({onClose, timeout});

const mods = {
mobile,
appearing: status === ToastStatus.ShowingIndents || status === ToastStatus.ShowingHeight,
'show-animation': status === ToastStatus.ShowingHeight,
'hide-animation': status === ToastStatus.Hiding,
created: status !== ToastStatus.Creating,
[type || 'default']: true,
};

const icon = renderIcon ? renderIcon(props) : renderIconByType({type});

return (
<div
className={b(mods, className)}
{...statusProps}
{...heightProps}
{...closeOnTimeoutProps}
>
<div ref={ref} className={b(mods, className)} {...closeOnTimeoutProps}>
{icon && <div className={b('icon-container')}>{icon}</div>}
<div className={b('container')}>
<h3 className={b('title')}>{title}</h3>
{isClosable && (
<Button
view="flat-secondary"
className={b('btn-close')}
onClick={handleClose}
onClick={onClose}
extraProps={{'aria-label': i18n('label_close-button')}}
>
<Icon data={CrossIcon} size={CROSS_ICON_SIZE} />
</Button>
)}
{Boolean(content) && <div className={b('content')}>{content}</div>}
{renderActions({actions, onClose: handleClose})}
{renderActions({actions, onClose})}
</div>
</div>
);
}
});
Loading

0 comments on commit 95a8744

Please sign in to comment.