(react) add Toast component

This component allows to create dynamic Toast appearing at the bottom
of the screen for few seconds.
This commit is contained in:
Nathan Vasse
2024-01-05 11:54:39 +01:00
committed by NathanVss
parent dd1a677a76
commit 132b676ff7
13 changed files with 739 additions and 2 deletions

View File

@@ -0,0 +1,145 @@
import React, { PropsWithChildren, useContext, useMemo, useRef } from "react";
import { Toast, ToastProps } from ":/components/Toast/index";
import { tokens } from ":/cunningham-tokens";
import { Queue } from ":/utils/Queue";
export enum ToastType {
INFO = "info",
SUCCESS = "success",
WARNING = "warning",
ERROR = "error",
NEUTRAL = "neutral",
}
export interface ToastProviderContext {
toast: (
message: string,
type?: ToastType,
options?: Partial<Omit<ToastInterface, "message" | "type">>,
) => void;
}
const ToastContext = React.createContext<ToastProviderContext>(
undefined as any,
);
export const useToastProvider = () => {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error("useToastProvider must be used within a ToastProvider.");
}
return context;
};
type ToastInterface = ToastProps & {
i: number;
message: string;
};
let toastsCounter = 1;
const DEFAULT_TOAST_DURATION = 6000;
const getSlideInDuration = (): number => {
return parseInt(
tokens.themes.default.components.toast["slide-in-duration"].replace(
"ms",
"",
),
10,
);
};
export const ToastProvider = ({ children }: PropsWithChildren) => {
const container = useRef<HTMLDivElement>(null);
const previousContainerHeight = useRef(0);
const [list, setList] = React.useState<ToastInterface[]>([]);
const queue = useRef(new Queue());
const context: ToastProviderContext = useMemo(
() => ({
toast: (message, type = ToastType.NEUTRAL, options = {}) => {
// We want to wait for the previous toast to be added ( taking into account the animation )
// before adding a new one, that's why we use a queue.
queue.current?.push(
() =>
new Promise<void>((resolve) => {
// We need to snapshot the value as the setList execution is async.
const currentIndex = toastsCounter;
toastsCounter += 1;
previousContainerHeight.current = container.current!.offsetHeight;
setList((currentList) => {
return [
...currentList,
{
...options,
message,
i: currentIndex,
type,
duration: options?.duration ?? DEFAULT_TOAST_DURATION,
},
];
});
setTimeout(() => {
// We consider that the toast is added when its animation is done.
resolve();
}, getSlideInDuration());
}),
);
},
}),
[container, previousContainerHeight, list],
);
const animateContainer = async () => {
// FLIP pattern: https://aerotwist.com/blog/flip-your-animations/
// FIRST
const first = previousContainerHeight.current;
// LAST
const last = container.current!.offsetHeight;
// INVERT
const invert = last - first;
// PLAY
container.current!.animate(
[
{ transform: `translateY(${invert}px)` },
{ transform: "translateY(0)" },
],
{
duration: getSlideInDuration(),
easing: "ease",
},
);
};
// We only want this to be triggered when an item gets ADDED to the list, not when
// it gets removed, that's why we use toastsCounter as a dependency and not only
// list.
React.useEffect(() => {
animateContainer();
}, [toastsCounter]);
return (
<ToastContext.Provider value={context}>
{children}
<div className="c__toast__container" ref={container}>
{list.map((toast) => (
<Toast
key={toast.i}
onDelete={() => {
setList((value) => {
return value.filter((t) => t.i !== toast.i);
});
}}
{...toast}
>
{toast.message}
</Toast>
))}
</div>
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,153 @@
@use "../../utils/responsive" as *;
.c__toast__container {
position: fixed;
bottom: 0;
left: 0;
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 0.5rem;
padding-left: 3rem;
padding-bottom: 3rem;
}
@include media(sm) {
.c__toast__container {
padding: 0.5rem;
right: 0;
.c__toast {
width: 100%;
min-width: auto;
max-width: none;
}
}
}
.c__toast {
border-radius: 4px;
box-shadow: 0 2px 6px 2px #0C1A2B26;
background-color: var(--c--components--toast--background-color);
min-width: 18rem;
max-width: 27rem;
overflow: hidden;
will-change: transform;
color: var(--c--components--toast--color);
font-weight: var(--c--components--toast--font-weight);
&:not(.c__toast--no-animate) {
animation: slide-in var(--c--components--toast--slide-in-duration) ease;
}
&--disappear:not(.c__toast--no-animate) {
animation: slide-out var(--c--components--toast--slide-out-duration) ease forwards;
}
&__icon {
display: flex;
align-items: center;
width: 1.5rem;
height: 1.5rem;
justify-content: center;
flex-shrink: 0;
span {
font-size: var(--c--components--toast--icon-size);
}
}
&__content {
padding: 1rem;
display: flex;
flex-direction: row-reverse;
align-items: center;
gap: 0.5rem;
&__buttons {
flex-shrink: 0;
}
&__children {
flex-grow: 1;
}
}
.c__progress-bar {
--c--progress--color: var(--c--theme--colors--greyscale-600);
}
&--info {
.c__progress-bar {
--c--progress--color: var(--c--theme--colors--primary-500);
}
.c__toast__icon {
color: var(--c--theme--colors--primary-600);
}
}
&--success {
.c__progress-bar {
--c--progress--color: var(--c--theme--colors--success-500);
}
.c__toast__icon {
color: var(--c--theme--colors--success-600);
}
}
&--warning {
.c__progress-bar {
--c--progress--color: var(--c--theme--colors--warning-500);
}
.c__toast__icon {
color: var(--c--theme--colors--warning-600);
}
}
&--error {
.c__progress-bar {
--c--progress--color: var(--c--theme--colors--danger-500);
}
.c__toast__icon {
color: var(--c--theme--colors--danger-600);
}
}
}
@keyframes slide-out {
from {
transform: translateX(0)
}
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0
}
}
@keyframes slide-in {
from {
transform: translateY(200px)
}
}
.c__progress-bar {
height: var(--c--components--toast--progress-bar-height);
--c--progress--color: var(--c--theme--colors--primary-500);
&__content {
height: 100%;
background-color: var(--c--progress--color);
}
}

View File

@@ -0,0 +1,162 @@
import React, { PropsWithChildren } from "react";
import {
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { within } from "@testing-library/dom";
import { CunninghamProvider } from ":/components/Provider";
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider";
import { Button } from ":/components/Button";
describe("<Toast />", () => {
const Wrapper = ({ children }: PropsWithChildren) => {
return <CunninghamProvider>{children}</CunninghamProvider>;
};
it("shows a toast when clicking on the button and disappears", async () => {
const Inner = () => {
const { toast } = useToastProvider();
return (
<Button
onClick={() =>
toast("Toast content", ToastType.NEUTRAL, { duration: 10 })
}
>
Create toast
</Button>
);
};
render(<Inner />, { wrapper: Wrapper });
const user = userEvent.setup();
const button = screen.getByText("Create toast");
// No toast displayed.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await user.click(button);
// Toast displayed.
const toast = await screen.findByRole("alert");
expect(toast).toHaveTextContent("Toast content");
await waitForElementToBeRemoved(toast);
});
it("shows a toast with a primary button", async () => {
let flag = false;
const Inner = () => {
const { toast } = useToastProvider();
return (
<Button
onClick={() =>
toast("Toast content", ToastType.NEUTRAL, {
primaryLabel: "Action",
primaryOnClick: () => {
flag = true;
},
})
}
>
Create toast
</Button>
);
};
render(<Inner />, { wrapper: Wrapper });
const user = userEvent.setup();
const button = screen.getByText("Create toast");
// No toast displayed.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await user.click(button);
// Toast displayed.
const toast = await screen.findByRole("alert");
expect(toast).toHaveTextContent("Toast content");
// Toast has a button.
const $button = within(toast).getByRole("button", { name: "Action" });
// Button is not clicked yet.
expect(flag).toBe(false);
await user.click($button);
// Button is clicked.
await waitFor(() => expect(flag).toBe(true));
});
it("shows a toast with custom buttons", async () => {
const Inner = () => {
const { toast } = useToastProvider();
return (
<Button
onClick={() =>
toast("Toast content", ToastType.NEUTRAL, {
actions: <Button color="tertiary">Tertiary</Button>,
})
}
>
Create toast
</Button>
);
};
render(<Inner />, { wrapper: Wrapper });
const user = userEvent.setup();
const button = screen.getByText("Create toast");
// No toast displayed.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await user.click(button);
// Toast displayed.
const toast = await screen.findByRole("alert");
expect(toast).toHaveTextContent("Toast content");
// Toast has custom button.
within(toast).getByRole("button", { name: "Tertiary" });
});
it.each([
[ToastType.INFO, "info"],
[ToastType.SUCCESS, "task_alt"],
[ToastType.WARNING, "warning"],
[ToastType.ERROR, "cancel"],
[ToastType.NEUTRAL, undefined],
])("shows a %s toast", async (type, iconName) => {
const Inner = () => {
const { toast } = useToastProvider();
return (
<Button onClick={() => toast("Toast content", type)}>
Create toast
</Button>
);
};
render(<Inner />, { wrapper: Wrapper });
const user = userEvent.setup();
const button = screen.getByText("Create toast");
// No toast displayed.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
await user.click(button);
// Toast displayed.
const toast = await screen.findByRole("alert");
expect(toast).toHaveTextContent("Toast content");
if (iconName === undefined) {
const icon = document.querySelector(".c__toast__icon");
expect(icon).not.toBeInTheDocument();
} else {
const icon = document.querySelector(".c__toast__icon");
expect(icon).toHaveTextContent(iconName);
}
});
});

View File

@@ -0,0 +1,120 @@
import { Meta, StoryObj } from "@storybook/react";
import React, { useEffect } from "react";
import { faker } from "@faker-js/faker";
import { ProgressBar, Toast } from ":/components/Toast/index";
import { Button } from ":/components/Button";
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider";
const meta: Meta<typeof Toast> = {
title: "Components/Toast",
component: Toast,
args: {
children: "Corrupti vestigium aiunt aeneus demulceo consequatur.",
duration: 30000,
disableAnimate: true,
},
};
export default meta;
type Story = StoryObj<typeof Toast>;
export const Demo: Story = {
render: () => {
const { toast } = useToastProvider();
const TYPES = [
ToastType.INFO,
ToastType.SUCCESS,
ToastType.WARNING,
ToastType.ERROR,
ToastType.NEUTRAL,
];
useEffect(() => {
toast(faker.lorem.sentence({ min: 5, max: 10 }), ToastType.SUCCESS, {
primaryLabel: "Read more",
primaryOnClick: () => {
// eslint-disable-next-line no-alert
alert("Clicked here !");
},
});
}, []);
return (
<div style={{ height: "300px" }}>
<Button
onClick={() => {
const type = TYPES[Math.floor(Math.random() * TYPES.length)];
toast(faker.lorem.sentence({ min: 5, max: 10 }), type, {
primaryLabel: "Primary",
primaryOnClick: () => {
// eslint-disable-next-line no-alert
alert("Clicked here !");
},
});
}}
>
Create toast!
</Button>
</div>
);
},
};
export const Info: Story = {
args: {
type: ToastType.INFO,
},
};
export const InfoWithButton: Story = {
args: {
type: ToastType.INFO,
primaryLabel: "Primary",
},
};
export const InfoCustom: Story = {
args: {
type: ToastType.INFO,
actions: (
<>
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
</>
),
},
};
export const Success: Story = {
args: {
type: ToastType.SUCCESS,
},
};
export const Warning: Story = {
args: {
type: ToastType.WARNING,
},
};
export const Error: Story = {
args: {
type: ToastType.ERROR,
},
};
export const Neutral: Story = {
args: {
type: ToastType.NEUTRAL,
},
};
export const ProgressBarExample: Story = {
render: () => {
return (
<div>
<ProgressBar duration={6000} />
</div>
);
},
};

View File

@@ -0,0 +1,120 @@
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useMemo,
useRef,
} from "react";
import classNames from "classnames";
import { ToastType } from ":/components/Toast/ToastProvider";
import { iconFromType } from ":/components/Alert/Utils";
import { Button, ButtonProps } from ":/components/Button";
export interface ToastProps extends PropsWithChildren {
duration: number;
type: ToastType;
onDelete?: () => void;
icon?: ReactNode;
primaryLabel?: string;
primaryOnClick?: ButtonProps["onClick"];
primaryProps?: ButtonProps;
disableAnimate?: boolean;
actions?: ReactNode;
}
export const Toast = (props: ToastProps) => {
const [animateDisappear, setAnimateDisappear] = React.useState(false);
const container = useRef<HTMLDivElement>(null);
const disappearTimeout = useRef<NodeJS.Timeout>();
// Register a timeout to remove the toast after the duration.
useEffect(() => {
if (props.disableAnimate) {
return;
}
disappearTimeout.current = setTimeout(async () => {
setAnimateDisappear(true);
disappearTimeout.current = undefined;
}, props.duration);
return () => {
if (disappearTimeout.current) {
clearTimeout(disappearTimeout.current);
}
};
}, []);
const removeAfterAnimation = async () => {
await Promise.allSettled(
container.current!.getAnimations().map((animation) => animation.finished),
);
props.onDelete?.();
};
// Remove the toast after the animation finishes.
useEffect(() => {
if (animateDisappear) {
removeAfterAnimation();
}
}, [animateDisappear]);
return (
<div
ref={container}
className={classNames("c__toast", "c__toast--" + props.type, {
"c__toast--disappear": animateDisappear,
"c__toast--no-animate": props.disableAnimate,
})}
role="alert"
>
<ProgressBar duration={props.duration} />
<div className="c__toast__content">
{props.primaryLabel && (
<div className="c__toast__content__buttons">
<Button
color="primary-text"
onClick={props.primaryOnClick}
{...props.primaryProps}
>
{props.primaryLabel}
</Button>
</div>
)}
{props.actions}
<div className="c__toast__content__children">{props.children}</div>
<ToastIcon {...props} />
</div>
</div>
);
};
export const ToastIcon = ({ type, ...props }: ToastProps) => {
const icon = useMemo(() => iconFromType(type), [type]);
if (props.icon) {
return props.icon;
}
if (!icon) {
return null;
}
return (
<div className="c__toast__icon">
<span className="material-icons">{icon}</span>
</div>
);
};
export const ProgressBar = ({ duration }: { duration: number }) => {
const content = useRef<HTMLDivElement>(null);
useEffect(() => {
content.current!.animate([{ width: "0%" }, { width: "100%" }], {
duration,
easing: "linear",
});
}, []);
return (
<div className="c__progress-bar">
<div className="c__progress-bar__content" ref={content} />
</div>
);
};

View File

@@ -0,0 +1,13 @@
import { DefaultTokens } from "@openfun/cunningham-tokens";
export const tokens = (defaults: DefaultTokens) => {
return {
"slide-in-duration": "1000ms",
"slide-out-duration": "300ms",
"background-color": defaults.theme.colors["greyscale-100"],
color: defaults.theme.colors["greyscale-900"],
"font-weight": defaults.theme.font.weights.regular,
"icon-size": defaults.theme.font.sizes.l,
"progress-bar-height": "3px",
};
};

View File

@@ -112,6 +112,13 @@
--c--theme--breakpoints--lg: 992px;
--c--theme--breakpoints--xl: 1200px;
--c--theme--breakpoints--xxl: 1400px;
--c--components--toast--slide-in-duration: 1000ms;
--c--components--toast--slide-out-duration: 300ms;
--c--components--toast--background-color: var(--c--theme--colors--greyscale-100);
--c--components--toast--color: var(--c--theme--colors--greyscale-900);
--c--components--toast--font-weight: var(--c--theme--font--weights--regular);
--c--components--toast--icon-size: var(--c--theme--font--sizes--l);
--c--components--toast--progress-bar-height: 3px;
--c--components--forms-textarea--font-weight: var(--c--theme--font--weights--regular);
--c--components--forms-textarea--font-size: var(--c--theme--font--sizes--l);
--c--components--forms-textarea--border-radius: 8px;

File diff suppressed because one or more lines are too long

View File

@@ -134,6 +134,15 @@ $themes: (
)
),
'components': (
'toast': (
'slide-in-duration': 1000ms,
'slide-out-duration': 300ms,
'background-color': #FAFAFB,
'color': #0C1A2B,
'font-weight': 400,
'icon-size': 1rem,
'progress-bar-height': 3px
),
'forms-textarea': (
'font-weight': 400,
'font-size': 1rem,

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,7 @@
@use "./components/Loader";
@use "./components/Pagination";
@use "./components/Popover";
@use "./components/Toast";
body {
font-family: var(--c--theme--font--families--base);

View File

@@ -20,6 +20,8 @@ export * from "./components/Loader";
export * from "./components/Pagination";
export * from "./components/Popover";
export * from "./components/Provider";
export * from "./components/Toast";
export * from "./components/Toast/ToastProvider";
export type DefaultTokens = PartialNested<typeof tokens.themes.default>;
export const defaultTokens = tokens.themes.default;