Skip to content

Commit

Permalink
feat(Toast): improve animation (mondaycom#2391)
Browse files Browse the repository at this point in the history
Co-authored-by: Yossi Saadi <[email protected]>
  • Loading branch information
talkor and YossiSaadi authored Sep 29, 2024
1 parent cd6c7ad commit 968c852
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 38 deletions.
66 changes: 55 additions & 11 deletions packages/core/src/components/Toast/Toast.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@
-moz-osx-font-smoothing: var(--font-smoothing-moz);
margin: var(--spacing-medium);
position: fixed;
right: 0;
left: 0;
left: 50%;
top: 0;
margin-right: auto;
margin-left: auto;
padding: var(--spacing-small);
align-items: center;
display: flex;
min-width: 200px;
width: auto;
border-radius: var(--border-radius-small);
color: var(--fixed-light-color);
transform: translateX(-50%);
transition: background-color 80ms cubic-bezier(0.6, 0, 0.4, 1), width 200ms cubic-bezier(0, 0, 0.4, 1);

&.typeNormal {
background-color: var(--primary-color);
Expand Down Expand Up @@ -66,6 +68,13 @@
margin-left: var(--spacing-small);
}

.actionButton.withTransition {
opacity: 0;
animation: bounceIn 150ms cubic-bezier(0, 0, 0.4, 1);
animation-delay: 300ms;
animation-fill-mode: forwards;
}

.content {
margin: 0 var(--spacing-small);
flex: 1;
Expand All @@ -76,28 +85,63 @@
flex-grow: 1;
}

// Animation
.enterActive {
animation-iteration-count: 1;
animation-fill-mode: forwards;
animation-duration: var(--motion-expressive-long);
animation-name: toast-slide-in-elastic;
animation-name: slideIn;
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.6, 0, 0.4, 1);
}

.exitActive {
animation-iteration-count: 1;
animation-fill-mode: forwards;
animation-duration: var(--motion-productive-long);
animation-name: toast-slide-out;
animation-name: slideOut;
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.6, 0, 0.4, 1);
}

.closeButton {
margin-left: var(--spacing-small);
}

@include keyframe(toast-slide-in-elastic) {
@include slide-in-elastic();
@keyframes slideIn {
0% {
transform: translate(-50%, -100px);
}

40% {
transform: translate(-50%, 16px);
}

100% {
transform: translate(-50%, 0px);
}
}

@include keyframe(toast-slide-out) {
@include slide-out();
@keyframes slideOut {
0% {
transform: translate(-50%, 0);
}

100% {
transform: translate(-50%, -100px);
opacity: 0;
}
}

@keyframes bounceIn {
0% {
transform: scale(0.8);
opacity: 0;
}

50% {
opacity: 1;
}

100% {
opacity: 1;
transform: scale(1);
}
}
32 changes: 30 additions & 2 deletions packages/core/src/components/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { getStyle } from "../../helpers/typesciptCssModulesHelper";
import { withStaticProps, VibeComponentProps } from "../../types";
import styles from "./Toast.module.scss";
import IconButton from "../IconButton/IconButton";
import usePrevious from "../../hooks/usePrevious";

export interface ToastProps extends VibeComponentProps {
actions?: ToastAction[];
Expand Down Expand Up @@ -59,6 +60,8 @@ const Toast: FC<ToastProps> & { types?: typeof ToastType; actionTypes?: typeof T
closeButtonAriaLabel = "Close",
"data-testid": dataTestId
}) => {
const ref = useRef(null);
const prevActions = usePrevious(actions?.length);
const toastLinks = useMemo(() => {
return actions
? actions
Expand All @@ -69,17 +72,25 @@ const Toast: FC<ToastProps> & { types?: typeof ToastType; actionTypes?: typeof T
: null;
}, [actions]);

const shouldShowButtonTransition = useMemo(() => {
return prevActions !== undefined && actions?.length !== prevActions;
}, [actions, prevActions]);

const toastButtons: JSX.Element[] | null = useMemo(() => {
return actions
? actions
.filter(action => action.type === ToastActionType.BUTTON)
.map(({ type: _type, content, ...otherProps }, index) => (
<ToastButton key={`alert-button-${index}`} className={styles.actionButton} {...otherProps}>
<ToastButton
key={`alert-button-${index}`}
className={cx(styles.actionButton, { [styles.withTransition]: shouldShowButtonTransition })}
{...otherProps}
>
{content}
</ToastButton>
))
: null;
}, [actions]);
}, [actions, shouldShowButtonTransition]);

const classNames = useMemo(
() => cx(styles.toast, getStyle(styles, camelCase("type-" + type)), className),
Expand Down Expand Up @@ -120,6 +131,22 @@ const Toast: FC<ToastProps> & { types?: typeof ToastType; actionTypes?: typeof T

const iconElement = !hideIcon && getIcon(type, icon);

// https://n12v.com/css-transition-to-from-auto/
const recalculateElementWidth = useCallback((element: HTMLElement) => {
const prevWidth = element.style.width;
element.style.width = "auto";
const endWidth = getComputedStyle(element).width;
element.style.width = prevWidth;
element.offsetWidth; // force repaint
element.style.width = endWidth;
}, []);

useEffect(() => {
if (ref.current) {
recalculateElementWidth(ref.current);
}
}, [children, recalculateElementWidth]);

return (
<CSSTransition
in={open}
Expand All @@ -136,6 +163,7 @@ const Toast: FC<ToastProps> & { types?: typeof ToastType; actionTypes?: typeof T
className={classNames}
role="alert"
aria-live="polite"
ref={ref}
>
{iconElement && <div className={cx(styles.icon)}>{iconElement}</div>}
<Flex align={Flex.align.CENTER} gap={Flex.gaps.LARGE} className={styles.content}>
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/components/Toast/__stories__/Toast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from "../../../storybook/components/related-components/component-description-map";
import * as ToastStories from "./Toast.stories";
import { TipAlertBanner } from "./Toast.stories.helpers";
import doImage from "./assets/do.png";
import dontImage from "./assets/dont.png";

<Meta of={ToastStories} />

Expand Down Expand Up @@ -39,7 +41,8 @@ A toast notification is a message object that presents timely information, inclu
guidelines={[
"Use toast notifications immediately after a specific event such as a user action that does not relate to an object on the page. Toast used as a feedback loop to a user’s action.",
"Toasts should appear one at a time, and only disappear when no longer required.",
"Always be clear, concise and, where possible, give follow up actions to allow the user to become more informed or resolve the issue.",
"Always be concise, write a short and clear message.",
"Where possible, give follow up actions to allow the user to become more informed or resolve the issue.",
"Always provide an action button or option to undo.",
"Toast should overlay permanent UI elements without blocking important actions."
]}
Expand Down Expand Up @@ -142,6 +145,16 @@ Use when something was deleted, a problem has occurred, etc.
),
description: "Don’t offer an action without letting the user undo it."
}
},
{
positive: {
component: <img src={doImage} width="100%" />,
description: "If the toast message has 2 steps, make sure both toasts have roughly the same width."
},
negative: {
component: <img src={dontImage} width="100%" />,
description: "If the toast message has 2 steps, don’t use toasts with very different widths."
}
}
]}
/>
Expand All @@ -154,6 +167,12 @@ After a user has done an action, provide feedback to close the loop. For example

<Canvas of={ToastStories.FeedbackLoop} />

### Animation

Our toast includes 2 animations: slide-in and transform. The transform animation is triggered when the toast changes from one state to another (for example, from loading to success).

<Canvas of={ToastStories.Animation} />

## Related components

<RelatedComponents componentsNames={[ALERT_BANNER, TOOLTIP, ATTENTION_BOX]} />
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,4 @@
margin: 0;
transform: translate(0, 0);
}

&_box {
width: 390px;
// overriding margin for storybook wrapper
margin-right: auto !important;
margin-left: auto !important;
}
}
80 changes: 63 additions & 17 deletions packages/core/src/components/Toast/__stories__/Toast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { createComponentTemplate } from "vibe-storybook-components";
import Toast from "../Toast";
import { createStoryMetaSettingsDecorator } from "../../../storybook";
import Button from "../../Button/Button";
import { Delete } from "../../Icon/Icons";
import "./Toast.stories.scss";

const metaSettings = createStoryMetaSettingsDecorator({
Expand Down Expand Up @@ -210,12 +209,49 @@ export const DarkMessage = {
name: "Dark message"
};

// TODO storybook 7 migration: toast isn't opening at the top of the page, but inside of the story instead
export const FeedbackLoop = {
render: () => {
const [toastOpen, setToastOpen] = useState(false);
const onClickCallback = useCallback(() => setToastOpen(toastOpen => !toastOpen), [setToastOpen]);
const onCloseCallback = useCallback(() => setToastOpen(false), [setToastOpen]);
const actions = useMemo(
() => [
{
type: Toast.actionTypes.BUTTON,
content: "Undo"
}
],
[]
);

return (
<Toast open type={Toast.types.POSITIVE} actions={actions} className="monday-storybook-toast_wrapper">
We successfully deleted 1 item
</Toast>
);
}
};

export const Animation = {
render: () => {
const [successToastOpen, setSuccessToastOpen] = useState(false);
const [failureToastOpen, setFailureToastOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);

const onSuccessClick = useCallback(() => {
setSuccessToastOpen(true);
setIsDeleting(true);

setTimeout(() => {
setIsDeleting(false);
}, 1000);
}, []);

const onFailureClick = useCallback(() => {
setFailureToastOpen(true);
setIsDeleting(true);

setTimeout(() => {
setIsDeleting(false);
}, 1000);
}, []);

const actions = useMemo(
() => [
Expand All @@ -229,22 +265,32 @@ export const FeedbackLoop = {

return (
<>
<Button leftIcon={Delete} onClick={onClickCallback}>
Delete item
<Button onClick={onSuccessClick} kind={Button.kinds.SECONDARY}>
Success action
</Button>
<Button onClick={onFailureClick} kind={Button.kinds.SECONDARY}>
Failure action
</Button>
<Toast
open={successToastOpen}
type={isDeleting ? Toast.types.NORMAL : Toast.types.POSITIVE}
actions={isDeleting ? [] : actions}
onClose={() => setSuccessToastOpen(false)}
autoHideDuration={2000}
loading={isDeleting}
>
{isDeleting ? "Deleting 1 selected item..." : "We successfully deleted 1 item"}
</Toast>
<Toast
open={toastOpen}
type={Toast.types.POSITIVE}
actions={actions}
onClose={onCloseCallback}
autoHideDuration={5000}
className="monday-storybook-toast_box"
open={failureToastOpen}
type={isDeleting ? Toast.types.NORMAL : Toast.types.NEGATIVE}
onClose={() => setFailureToastOpen(false)}
autoHideDuration={2000}
loading={isDeleting}
>
We successfully deleted 1 item
{isDeleting ? "Deleting 1 selected item..." : "Something went wrong"}
</Toast>
</>
);
},

name: "Feedback loop"
}
};
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 968c852

Please sign in to comment.