✨(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:
5
.changeset/sixty-actors-poke.md
Normal file
5
.changeset/sixty-actors-poke.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
add Toast component
|
||||||
145
packages/react/src/components/Toast/ToastProvider.tsx
Normal file
145
packages/react/src/components/Toast/ToastProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
packages/react/src/components/Toast/index.scss
Normal file
153
packages/react/src/components/Toast/index.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
162
packages/react/src/components/Toast/index.spec.tsx
Normal file
162
packages/react/src/components/Toast/index.spec.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
120
packages/react/src/components/Toast/index.stories.tsx
Normal file
120
packages/react/src/components/Toast/index.stories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
120
packages/react/src/components/Toast/index.tsx
Normal file
120
packages/react/src/components/Toast/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
13
packages/react/src/components/Toast/tokens.ts
Normal file
13
packages/react/src/components/Toast/tokens.ts
Normal 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",
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -112,6 +112,13 @@
|
|||||||
--c--theme--breakpoints--lg: 992px;
|
--c--theme--breakpoints--lg: 992px;
|
||||||
--c--theme--breakpoints--xl: 1200px;
|
--c--theme--breakpoints--xl: 1200px;
|
||||||
--c--theme--breakpoints--xxl: 1400px;
|
--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-weight: var(--c--theme--font--weights--regular);
|
||||||
--c--components--forms-textarea--font-size: var(--c--theme--font--sizes--l);
|
--c--components--forms-textarea--font-size: var(--c--theme--font--sizes--l);
|
||||||
--c--components--forms-textarea--border-radius: 8px;
|
--c--components--forms-textarea--border-radius: 8px;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -134,6 +134,15 @@ $themes: (
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
'components': (
|
'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': (
|
'forms-textarea': (
|
||||||
'font-weight': 400,
|
'font-weight': 400,
|
||||||
'font-size': 1rem,
|
'font-size': 1rem,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@
|
|||||||
@use "./components/Loader";
|
@use "./components/Loader";
|
||||||
@use "./components/Pagination";
|
@use "./components/Pagination";
|
||||||
@use "./components/Popover";
|
@use "./components/Popover";
|
||||||
|
@use "./components/Toast";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--c--theme--font--families--base);
|
font-family: var(--c--theme--font--families--base);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export * from "./components/Loader";
|
|||||||
export * from "./components/Pagination";
|
export * from "./components/Pagination";
|
||||||
export * from "./components/Popover";
|
export * from "./components/Popover";
|
||||||
export * from "./components/Provider";
|
export * from "./components/Provider";
|
||||||
|
export * from "./components/Toast";
|
||||||
|
export * from "./components/Toast/ToastProvider";
|
||||||
|
|
||||||
export type DefaultTokens = PartialNested<typeof tokens.themes.default>;
|
export type DefaultTokens = PartialNested<typeof tokens.themes.default>;
|
||||||
export const defaultTokens = tokens.themes.default;
|
export const defaultTokens = tokens.themes.default;
|
||||||
|
|||||||
Reference in New Issue
Block a user