@@ -69,7 +70,7 @@ export const Success: Story = {
children: "Alert component Success",
canClose: true,
primaryLabel: "Primary",
- type: AlertType.SUCCESS,
+ type: VariantType.SUCCESS,
},
};
@@ -78,7 +79,7 @@ export const Warning: Story = {
children: "Alert component Warning",
canClose: true,
primaryLabel: "Primary",
- type: AlertType.WARNING,
+ type: VariantType.WARNING,
},
};
@@ -87,7 +88,7 @@ export const Error: Story = {
children: "Alert component Error",
canClose: true,
primaryLabel: "Primary",
- type: AlertType.ERROR,
+ type: VariantType.ERROR,
},
};
@@ -96,7 +97,7 @@ export const Neutral: Story = {
children: "Alert component Neutral",
canClose: true,
primaryLabel: "Primary",
- type: AlertType.NEUTRAL,
+ type: VariantType.NEUTRAL,
},
};
diff --git a/packages/react/src/components/Alert/index.tsx b/packages/react/src/components/Alert/index.tsx
index 44c9468..d88900c 100644
--- a/packages/react/src/components/Alert/index.tsx
+++ b/packages/react/src/components/Alert/index.tsx
@@ -4,14 +4,7 @@ import { useControllableState } from ":/hooks/useControllableState";
import { AlertAdditionalExpandable } from ":/components/Alert/AlertAdditionalExpandable";
import { AlertAdditional } from ":/components/Alert/AlertAdditional";
import { AlertOneLine } from ":/components/Alert/AlertOneLine";
-
-export enum AlertType {
- INFO = "info",
- SUCCESS = "success",
- WARNING = "warning",
- ERROR = "error",
- NEUTRAL = "neutral",
-}
+import { VariantType } from ":/utils/VariantUtils";
export interface AlertProps extends PropsWithChildren {
additional?: React.ReactNode;
@@ -31,7 +24,7 @@ export interface AlertProps extends PropsWithChildren {
tertiaryLabel?: string;
tertiaryOnClick?: ButtonProps["onClick"];
tertiaryProps?: ButtonProps;
- type?: AlertType;
+ type?: VariantType;
}
export const Alert = (props: AlertProps) => {
@@ -42,7 +35,7 @@ export const Alert = (props: AlertProps) => {
);
const propsWithDefault = {
- type: AlertType.INFO,
+ type: VariantType.INFO,
...props,
onClose,
};
diff --git a/packages/react/src/components/Modal/ConfirmationModal.tsx b/packages/react/src/components/Modal/ConfirmationModal.tsx
new file mode 100644
index 0000000..bb8d483
--- /dev/null
+++ b/packages/react/src/components/Modal/ConfirmationModal.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { Modal, ModalSize } from ":/components/Modal/index";
+import { useCunningham } from ":/components/Provider";
+import { Button } from ":/components/Button";
+import { DecisionModalProps } from ":/components/Modal/ModalProvider";
+
+export type ConfirmationModalProps = DecisionModalProps;
+
+export const ConfirmationModal = ({
+ title,
+ children,
+ onDecide,
+ ...props
+}: ConfirmationModalProps) => {
+ const { t } = useCunningham();
+ return (
+
+
+
+ >
+ }
+ {...props}
+ >
+ {children ?? t("components.modals.helpers.confirmation.children")}
+
+ );
+};
diff --git a/packages/react/src/components/Modal/DeleteConfirmationModal.tsx b/packages/react/src/components/Modal/DeleteConfirmationModal.tsx
new file mode 100644
index 0000000..325b8e9
--- /dev/null
+++ b/packages/react/src/components/Modal/DeleteConfirmationModal.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import { Modal, ModalSize } from ":/components/Modal/index";
+import { useCunningham } from ":/components/Provider";
+import { Button } from ":/components/Button";
+import { DecisionModalProps } from ":/components/Modal/ModalProvider";
+
+export type DeleteConfirmationModalProps = DecisionModalProps;
+
+export const DeleteConfirmationModal = ({
+ title,
+ children,
+ onDecide,
+ ...props
+}: DeleteConfirmationModalProps) => {
+ const { t } = useCunningham();
+ return (
+
delete}
+ size={ModalSize.SMALL}
+ actions={
+ <>
+
+
+ >
+ }
+ {...props}
+ >
+ {children ?? t("components.modals.helpers.delete_confirmation.children")}
+
+ );
+};
diff --git a/packages/react/src/components/Modal/MessageModal.tsx b/packages/react/src/components/Modal/MessageModal.tsx
new file mode 100644
index 0000000..f60e074
--- /dev/null
+++ b/packages/react/src/components/Modal/MessageModal.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import classNames from "classnames";
+import { Modal, ModalSize } from ":/components/Modal/index";
+import { useCunningham } from ":/components/Provider";
+import { Button } from ":/components/Button";
+import { DecisionModalProps } from ":/components/Modal/ModalProvider";
+import { colorFromType, iconFromType, VariantType } from ":/utils/VariantUtils";
+
+export type MessageModalProps = DecisionModalProps & {
+ messageType: VariantType;
+};
+
+export const MessageModal = ({
+ title,
+ children,
+ onDecide,
+ messageType,
+ ...props
+}: MessageModalProps) => {
+ const { t } = useCunningham();
+ return (
+
+ {iconFromType(messageType)}
+
+ )
+ }
+ size={ModalSize.SMALL}
+ actions={
+
+ }
+ {...props}
+ >
+ {children ?? t("components.modals.helpers.disclaimer.children")}
+
+ );
+};
diff --git a/packages/react/src/components/Modal/ModalProvider.tsx b/packages/react/src/components/Modal/ModalProvider.tsx
new file mode 100644
index 0000000..6451db4
--- /dev/null
+++ b/packages/react/src/components/Modal/ModalProvider.tsx
@@ -0,0 +1,159 @@
+import React, {
+ createContext,
+ createElement,
+ Fragment,
+ FunctionComponent,
+ PropsWithChildren,
+ ReactNode,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+import {
+ DeleteConfirmationModal,
+ DeleteConfirmationModalProps,
+} from ":/components/Modal/DeleteConfirmationModal";
+import { randomString } from ":/utils";
+import { ModalProps, useModal } from ":/components/Modal/index";
+import {
+ ConfirmationModal,
+ ConfirmationModalProps,
+} from ":/components/Modal/ConfirmationModal";
+import { WithOptional } from ":/types";
+import {
+ MessageModal,
+ MessageModalProps,
+} from ":/components/Modal/MessageModal";
+
+export type Decision = string | null | undefined;
+export type DecisionModalProps = WithOptional
& {
+ onDecide: (decision?: Decision) => void;
+};
+// TODO: I don't really like this "& any", but it's the only way I found to make it work for MessageModal.
+type DecisionModalComponent = FunctionComponent;
+
+interface ModalContextType {
+ deleteConfirmationModal: (
+ props?: Partial,
+ ) => Promise;
+ confirmationModal: (
+ props?: Partial,
+ ) => Promise;
+ messageModal: (props?: Partial) => Promise;
+}
+
+const ModalContext = createContext(undefined);
+
+export const useModals = () => {
+ const context = useContext(ModalContext);
+ if (context === undefined) {
+ throw new Error("useModals must be used within a ModalProvider.");
+ }
+ return context;
+};
+
+/**
+ * This component is used to wrap a modal component and add the modal context to it.
+ * It handles creating a generic `component` modal, and closing it when the `onClose` callback is called.
+ *
+ * @param component
+ * @param onClose
+ * @param props
+ * @constructor
+ */
+const ModalContainer = ({
+ component,
+ onClose,
+ props,
+}: {
+ component: DecisionModalComponent;
+ onClose: Function;
+ props: Partial;
+}) => {
+ const modal = useModal({ isOpenDefault: true });
+
+ const close = () => {
+ modal.onClose();
+ onClose();
+ };
+
+ const onCloseLocal = () => {
+ close();
+ // Closing sends a "undefined" decision.
+ props.onDecide?.();
+ };
+
+ const onDecide = (decision?: Decision) => {
+ close();
+ props.onDecide?.(decision);
+ };
+
+ return createElement(component, {
+ ...modal,
+ ...props,
+ onClose: onCloseLocal,
+ onDecide,
+ });
+};
+
+type ModalMap = Map;
+
+export const ModalProvider = ({ children }: PropsWithChildren) => {
+ const [modals, setModals] = useState({} as ModalMap);
+
+ const addModal = (
+ component: FunctionComponent,
+ props: Partial,
+ ) => {
+ const key = randomString(32);
+ const container = (
+ {
+ setModals((modals_) => {
+ // @ts-ignore
+ delete modals_[key];
+ return modals_;
+ });
+ }}
+ />
+ );
+ setModals((modals_) => ({
+ ...modals_,
+ [key]: container,
+ }));
+ };
+
+ const ModalHelper = (component: DecisionModalComponent) => {
+ return (props: Partial = {}) => {
+ return new Promise((resolve) => {
+ addModal(component, {
+ onDecide: (decision) => {
+ resolve(decision);
+ },
+ ...props,
+ });
+ });
+ };
+ };
+
+ const context: ModalContextType = useMemo(
+ () => ({
+ deleteConfirmationModal: ModalHelper(DeleteConfirmationModal),
+ confirmationModal: ModalHelper(ConfirmationModal),
+ messageModal: ModalHelper(MessageModal),
+ }),
+ [],
+ );
+
+ return (
+
+ {children}
+
+ {Object.entries(modals).map(([key, modal]) => (
+ {modal}
+ ))}
+
+ );
+};
diff --git a/packages/react/src/components/Modal/PreBuilt.stories.tsx b/packages/react/src/components/Modal/PreBuilt.stories.tsx
new file mode 100644
index 0000000..5d504d5
--- /dev/null
+++ b/packages/react/src/components/Modal/PreBuilt.stories.tsx
@@ -0,0 +1,89 @@
+import { Meta } from "@storybook/react";
+import React, { useEffect } from "react";
+import { Button } from ":/components/Button";
+import { useModals } from ":/components/Modal/ModalProvider";
+import { VariantType } from ":/utils/VariantUtils";
+
+const meta: Meta = {
+ title: "Components/Modal",
+ parameters: {
+ docs: {
+ story: {
+ height: "350px",
+ },
+ },
+ },
+};
+
+export default meta;
+
+export const DeleteConfirmationModal = {
+ render: () => {
+ const modals = useModals();
+
+ const ask = async () => {
+ const decision = await modals.deleteConfirmationModal();
+ alert(`You decided ${decision}`);
+ };
+
+ useEffect(() => {
+ ask();
+ }, []);
+
+ return ;
+ },
+};
+
+export const ConfirmationModal = {
+ render: () => {
+ const modals = useModals();
+
+ const ask = async () => {
+ const decision = await modals.confirmationModal();
+ alert(`You decided ${decision}`);
+ };
+
+ useEffect(() => {
+ ask();
+ }, []);
+
+ return ;
+ },
+};
+
+const MessageWrapper = (type: VariantType) => () => {
+ const modals = useModals();
+
+ const ask = async () => {
+ const decision = await modals.messageModal({
+ title: "Watch out!",
+ children: "This is a custom message!",
+ messageType: type,
+ });
+ alert(`You decided ${decision}`);
+ };
+
+ useEffect(() => {
+ ask();
+ }, []);
+
+ return ;
+};
+
+export const SuccessModal = {
+ render: MessageWrapper(VariantType.SUCCESS),
+};
+export const InfoModal = {
+ render: MessageWrapper(VariantType.INFO),
+};
+
+export const ErrorModal = {
+ render: MessageWrapper(VariantType.ERROR),
+};
+
+export const NeutralModal = {
+ render: MessageWrapper(VariantType.NEUTRAL),
+};
+export const WarningModal = {
+ render: MessageWrapper(VariantType.WARNING),
+};
diff --git a/packages/react/src/components/Modal/index.mdx b/packages/react/src/components/Modal/index.mdx
new file mode 100644
index 0000000..39fee84
--- /dev/null
+++ b/packages/react/src/components/Modal/index.mdx
@@ -0,0 +1,266 @@
+import { Canvas, Meta, Story, Source, ArgTypes } from '@storybook/blocks';
+import * as Stories from './index.stories';
+import * as PreBuiltStories from './PreBuilt.stories';
+import { Modal } from './index';
+
+
+
+# Modal
+
+Cunningham provides a versatile Modal component for displaying any kind of information.
+
+
+
+
+
+> ⚠️ If you want to try dark theme on the modal, you need to go on individual stories. It will not work on this page due to iframe wrapping.
+
+## Usage
+
+The component is easy to use. You need to use the `useModal()` hook to get all the needed props and helper functions.
+
+ {
+ const modal = useModal();
+ return (
+
+
+
+ My modal
+
+
+ );
+};
+ `}/>
+
+Here you go! You bring to live your first modal! 🥳
+
+### About `useModal()` hook
+
+This hook is just a simple wrapper around the `useState()` hook to store some internal states, avoiding you to do it yourself each time you use a modal.
+It returns an object with the following properties:
+
+- `isOpen`: A boolean indicating if the modal is open or not.
+- `onClose`: A function called when modal closes.
+- `open`: A function to open the modal.
+- `close`: A function to close the modal. It does the same as `onClose` but it makes more semantic sense to use it in your code.
+
+### Programatically close the modal
+
+You can close the modal by calling the `close()` function returned by the `useModal()` hook.
+
+Here is an example of a modal automatically closing after 2 seconds.
+
+ {
+ const modal = useModal();
+ useEffect(() => {
+ modal.open();
+ setTimeout(() => {
+ modal.close();
+ }, 2000);
+ }, []);
+ return (
+
+
+ My modal
+
+
+ );
+};
+ `}/>
+
+## Sizes
+
+The modal component comes with multiple sizes: `ModalSize.SMALL`, `ModalSize.MEDIUM`, `ModalSize.LARGE` and `ModalSize.FULL`.
+
+You can set the size of the modal by passing the `size` prop.
+
+
+### Small
+
+
+
+
+### Medium
+
+
+
+### Large
+
+
+
+### Full
+
+
+
+## Close button
+
+You can hide the close button by passing the `hideCloseButton` prop.
+
+
+
+## Buttons
+
+### Right buttons
+
+You can add buttons on the right side of the modal by passing the `rightActions` prop.
+
+
+
+### Left buttons
+
+You can add buttons on the left side of the modal by passing the `leftActions` prop.
+
+
+
+### Center buttons
+
+You can add buttons on the center of the modal by passing the `actions` prop.
+
+
+
+## Icon
+
+You can add an icon to the modal by passing the `titleIcon` prop.
+
+
+
+## Close on click outside
+
+By default, the modal will not be closed when you click outside of it in order to match the default behavior of the `` element.
+You can change this behavior by passing the `closeOnClickOutside` prop.
+
+
+
+## Pre Built Modals
+
+As we know that developers love to have handy shortcuts for common use cases, we provide some pre built modals that we
+prevent you from adding `` and using `useModal()` each time you want to use those pre built modals.
+
+The way you will be able to use those pre built modals is by using async calls that return the decision of the user.
+
+> For instance the following code shows how to display a confirmation modal asking the user if he wants to continue or not.
+
+ {
+ const modals = useModals();
+ const ask = async () => {
+ const decision = await modals.confirmationModal();
+ alert("You decided: " + decision);
+ };
+ return ;
+};
+`}/>
+
+**About the `decision` variable:**
+
+- `decision` is truthy if the user accepted.
+- `decision` is null if the user denied.
+- `decision` is undefined if the user closed the modal via `esc` or close button.
+
+**Of course you can customize the title and the content of the modal by using the `props` first argument of the functions.**
+
+### Confirmation modal
+
+
+
+### Delete confirmation modal
+
+
+
+### Message modal
+
+This modal can be used to display a message to the user. It is possible to use multiple variant of it by using `messageType` with `VariantType` enum.
+
+#### Neutral
+
+
+
+
+
+
+
+## Props
+
+These are the props of `Modal`.
+
+
+
+## Design tokens
+
+Here a the custom design tokens defined by the Toast.
+
+| Token | Description |
+|--------------- |----------------------------- |
+| background-color | Default background color |
+| border-radius | Border radius of the modal |
+| border-color | Border color of the modal |
+| box-shadow | Box shadow of the modal |
+| title-font-weight | Font weight of the modal title |
+| color | Color of the modal title |
+| content-font-size | Font size of the modal content |
+| content-font-weight | Font weight of the modal content |
+| content-color | Font Color of the modal content |
+| width-small | Width of the small modal size |
+| width-medium | Width of the medium modal size |
+| width-large | Width of the large modal size |
diff --git a/packages/react/src/components/Modal/index.scss b/packages/react/src/components/Modal/index.scss
new file mode 100644
index 0000000..4a88f95
--- /dev/null
+++ b/packages/react/src/components/Modal/index.scss
@@ -0,0 +1,139 @@
+@use "../../utils/responsive" as *;
+
+.c__modal {
+ padding: 1.5rem;
+ background-color: var(--c--components--modal--background-color);
+ border-radius: var(--c--components--modal--border-radius);
+ border-color: var(--c--components--modal--border-color);
+ box-shadow: var(--c--components--modal--box-shadow);
+ color: var(--c--components--modal--color);
+ box-sizing: border-box;
+
+ &::backdrop {
+ // ::backdrop does not inherit from its element so CSS vars does not work.
+ // ( https://stackoverflow.com/a/77393321 ) So we need to use the color directly.
+ // In the future we will maybe be able to use the following:
+ // background: var(--c--components--modal--backdrop-color);
+ background: #0C1A2B99;
+ }
+
+ &__title {
+ --font-size: var(--c--theme--font--sizes--h2);
+ margin-bottom: 1.5rem;
+ font-size: var(--font-size);
+ font-weight: var(--c--components--modal--title-font-weight);
+ text-align: center;
+ // To avoid any collision with the close button.
+ padding: 0 0.75rem;
+ }
+
+ &__title-icon {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 1.5rem;
+ --icon-size: 2rem;
+
+ * {
+ font-size: var(--icon-size);
+ }
+ }
+
+ &__content {
+ text-align: center;
+ font-size: var(--c--components--modal--content-font-size);
+ font-weight: var(--c--components--modal--content-font-weight);
+ color: var(--c--components--modal--content-color);
+ }
+
+ &__close {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ }
+
+ &__footer {
+ margin-top: 1.5rem;
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ background-color: var(--c--components--modal--background-color);
+
+ &--sided {
+ justify-content: space-between;
+ gap: 0;
+ }
+
+ &__left, &__right {
+ display: flex;
+ gap: 1rem;
+ }
+ }
+
+ &--small {
+ width: var(--c--components--modal--width-small);
+
+ @include media(sm) {
+ width: calc(100vw - 1rem);
+ }
+
+ .c__modal__title-icon {
+ --icon-size: 3rem;
+ }
+ }
+
+ &--medium {
+ width: var(--c--components--modal--width-medium);
+
+ @include media(sm) {
+ width: calc(100vw - 1rem);
+ }
+
+ .c__modal__title-icon {
+ --icon-size: 4rem;
+ }
+ }
+
+ &--large {
+ width: var(--c--components--modal--width-large);
+
+ @include media(md) {
+ width: calc(100vw - 1rem);
+ }
+
+ .c__modal__title {
+ --font-size: var(--c--theme--font--sizes--h1);
+ }
+
+ .c__modal__title-icon {
+ --icon-size: 6rem;
+ }
+ }
+
+ &--full {
+ position: absolute;
+ inset: 0;
+ margin: 0;
+ width: auto;
+ height: 100vh;
+ max-width: none;
+ max-height: 100vh;
+ border-radius: 0;
+ box-shadow: none;
+ border: none;
+ display: flex;
+ flex-direction: column;
+
+ .c__modal__content {
+ flex-grow: 1;
+ overflow-y:scroll;
+ }
+
+ .c__modal__title {
+ --font-size: var(--c--theme--font--sizes--h1);
+ }
+
+ .c__modal__title-icon {
+ --icon-size: 6rem;
+ }
+ }
+}
diff --git a/packages/react/src/components/Modal/index.spec.tsx b/packages/react/src/components/Modal/index.spec.tsx
new file mode 100644
index 0000000..5ddfd13
--- /dev/null
+++ b/packages/react/src/components/Modal/index.spec.tsx
@@ -0,0 +1,419 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Modal, ModalSize, useModal } from ":/components/Modal/index";
+import { CunninghamProvider } from ":/components/Provider";
+import { useModals } from ":/components/Modal/ModalProvider";
+import { VariantType } from ":/utils/VariantUtils";
+
+describe("", () => {
+ it("shows a modal when clicking on the button", async () => {
+ const Wrapper = () => {
+ const modal = useModal();
+ return (
+
+
+
+ Modal Content
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modal");
+
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ await user.click(button);
+ expect(screen.getByText("Modal Content")).toBeInTheDocument();
+ });
+ it("closes the modal when clicking on the close button", async () => {
+ const Wrapper = () => {
+ const modal = useModal();
+ return (
+
+
+
+ Modal Content
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modal");
+
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ await user.click(button);
+ expect(screen.getByText("Modal Content")).toBeInTheDocument();
+
+ const closeButton = screen.getByRole("button", {
+ name: "close",
+ });
+ await user.click(closeButton);
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ });
+ it("does not display close button", async () => {
+ const Wrapper = () => {
+ const modal = useModal();
+ return (
+
+
+
+ Modal Content
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modal");
+
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ await user.click(button);
+ expect(screen.getByText("Modal Content")).toBeInTheDocument();
+
+ const closeButton = screen.queryByRole("button", {
+ name: "close",
+ });
+ expect(closeButton).not.toBeInTheDocument();
+ });
+ it("closes on click outside when using closeOnClickOutside=true", async () => {
+ const Wrapper = () => {
+ const modal = useModal();
+ return (
+
+
+
+ Modal Content
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modal");
+
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ await user.click(button);
+ expect(screen.getByText("Modal Content")).toBeInTheDocument();
+
+ const modal = screen.getByRole("dialog");
+ // We move the pointer on the edge of the screen in order to simulate the click outside.
+ await user.pointer({
+ coords: {
+ x: 1,
+ y: 1,
+ },
+ });
+
+ await user.click(modal);
+
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ });
+ it("does not close on click outside when using closeOnClickOutside=false", async () => {
+ const Wrapper = () => {
+ const modal = useModal();
+ return (
+
+
+
+ Modal Content
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modal");
+
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ await user.click(button);
+ expect(screen.getByText("Modal Content")).toBeInTheDocument();
+
+ const modal = screen.getByRole("dialog");
+ await user.click(modal);
+ expect(screen.queryByText("Modal Content")).toBeInTheDocument();
+ });
+
+ /**
+ * It should also prevent the modal from closing when pressing the escape key, but it appears
+ * that jest-dom does not handle dialog shortcuts, so we can't test it.
+ */
+ it("does not display close button when using preventClose=true", async () => {
+ const Wrapper = () => {
+ const modal = useModal();
+ return (
+
+
+
+ Modal Content
+
+
+ );
+ };
+
+ render();
+ const user = userEvent.setup();
+ const button = screen.getByText("Open Modal");
+
+ expect(screen.queryByText("Modal Content")).not.toBeInTheDocument();
+ await user.click(button);
+ expect(screen.getByText("Modal Content")).toBeInTheDocument();
+
+ const closeButton = screen.queryByRole("button", {
+ name: "close",
+ });
+ expect(closeButton).not.toBeInTheDocument();
+ });
+
+ it("displays pre-built confirmation modal and gives decision", async () => {
+ let decision;
+ const Wrapper = () => {
+ const modals = useModals();
+ const open = async () => {
+ decision = await modals.confirmationModal();
+ };
+ return ;
+ };
+
+ render(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ const button = screen.getByText("Ask");
+
+ // Modal is not open.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Modal is open.
+ expect(screen.getByText("Are you sure?")).toBeInTheDocument();
+ expect(
+ screen.getByText("Are you sure you want to do that?"),
+ ).toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+
+ // Click the yes button.
+ const yesButton = screen.getByRole("button", {
+ name: "Yes",
+ });
+ await user.click(yesButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is true.
+ expect(decision).toBeTruthy();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Discard.
+ const cancelButton = screen.getByRole("button", {
+ name: "Cancel",
+ });
+ await user.click(cancelButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is false.
+ expect(decision).toBeNull();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Close the modal.
+ const closeButton = screen.getByRole("button", {
+ name: "close",
+ });
+ await user.click(closeButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+ });
+ it("displays pre-built delete confirmation modal", async () => {
+ let decision;
+ const Wrapper = () => {
+ const modals = useModals();
+ const open = async () => {
+ decision = await modals.deleteConfirmationModal();
+ };
+ return ;
+ };
+
+ render(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ const button = screen.getByText("Ask");
+
+ // Modal is not open.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Modal is open.
+ expect(screen.getByText("Are you sure?")).toBeInTheDocument();
+ expect(
+ screen.getByText("Are you sure to delete this item?"),
+ ).toBeInTheDocument();
+ const modalIcon = document.querySelector(".c__modal .c__modal__title-icon");
+ expect(modalIcon).toHaveTextContent("delete");
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+
+ // Click the yes button.
+ const yesButton = screen.getByRole("button", {
+ name: "Delete",
+ });
+ await user.click(yesButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is true.
+ expect(decision).toBeTruthy();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Discard.
+ const cancelButton = screen.getByRole("button", {
+ name: "Cancel",
+ });
+ await user.click(cancelButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is false.
+ expect(decision).toBeNull();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Close the modal.
+ const closeButton = screen.getByRole("button", {
+ name: "close",
+ });
+ await user.click(closeButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Are you sure?")).not.toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+ });
+
+ it.each([
+ [VariantType.INFO, "info"],
+ [VariantType.SUCCESS, "check_circle"],
+ [VariantType.WARNING, "error_outline"],
+ [VariantType.ERROR, "cancel"],
+ [VariantType.NEUTRAL, undefined],
+ ])("renders % modal with according icon", async (type, icon) => {
+ let decision;
+ const Wrapper = () => {
+ const modals = useModals();
+ const open = async () => {
+ decision = await modals.messageModal({
+ messageType: type,
+ title: "Watch out!",
+ children: "This is a custom message!",
+ });
+ };
+ return ;
+ };
+
+ render(
+
+
+ ,
+ );
+ const user = userEvent.setup();
+ const button = screen.getByText("Ask");
+
+ // Modal is not open.
+ expect(screen.queryByText("Watch out!")).not.toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Modal is open.
+ expect(screen.getByText("Watch out!")).toBeInTheDocument();
+ expect(screen.getByText("This is a custom message!")).toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+
+ // Modal has icon.
+ const modalIcon = document.querySelector(".c__modal .c__modal__title-icon");
+ if (icon) {
+ expect(modalIcon).toHaveTextContent(icon!);
+ } else {
+ expect(modalIcon).toBeNull();
+ }
+
+ // Click on ok
+ const okButton = screen.getByRole("button", {
+ name: "Ok",
+ });
+ await user.click(okButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Watch out!")).not.toBeInTheDocument();
+
+ // Decision is true.
+ expect(decision).toBeTruthy();
+
+ // Display the modal.
+ await user.click(button);
+
+ // Modal is open.
+ expect(screen.getByText("Watch out!")).toBeInTheDocument();
+
+ // Decision is still true.
+ expect(decision).toBeTruthy();
+
+ // Close the modal.
+ const closeButton = screen.getByRole("button", {
+ name: "close",
+ });
+ await user.click(closeButton);
+
+ // Modal is closed.
+ expect(screen.queryByText("Watch out!")).not.toBeInTheDocument();
+
+ // Decision is undefined.
+ expect(decision).toBeUndefined();
+ });
+});
diff --git a/packages/react/src/components/Modal/index.stories.tsx b/packages/react/src/components/Modal/index.stories.tsx
new file mode 100644
index 0000000..8d33fe5
--- /dev/null
+++ b/packages/react/src/components/Modal/index.stories.tsx
@@ -0,0 +1,170 @@
+import { Meta, StoryObj } from "@storybook/react";
+import React, { useEffect } from "react";
+import { faker } from "@faker-js/faker";
+import { Modal, ModalSize, useModal } from ":/components/Modal/index";
+import { Button } from ":/components/Button";
+import { CunninghamProvider } from ":/components/Provider";
+
+const meta: Meta = {
+ title: "Components/Modal",
+ component: Modal,
+ args: {
+ children: "Description",
+ title: "Title",
+ },
+ decorators: [
+ (Story, context) => {
+ const modal = useModal();
+
+ useEffect(() => {
+ modal.open();
+ }, []);
+
+ return (
+
+
+
+
+ );
+ },
+ ],
+ parameters: {
+ docs: {
+ story: {
+ height: "250px",
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Small: Story = {
+ args: {
+ size: ModalSize.SMALL,
+ },
+};
+export const Medium: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ },
+};
+
+export const Large: Story = {
+ args: {
+ size: ModalSize.LARGE,
+ },
+};
+export const Full: Story = {
+ args: {
+ size: ModalSize.FULL,
+ },
+};
+
+export const HideCloseButton: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ hideCloseButton: true,
+ },
+};
+export const CloseOnClickOutside: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ hideCloseButton: true,
+ closeOnClickOutside: true,
+ },
+};
+export const PreventClose: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ preventClose: true,
+ },
+};
+
+export const PrimaryButton: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ rightActions: ,
+ },
+};
+
+export const SecondaryButton: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ rightActions: ,
+ },
+};
+
+export const TwoButtons: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ leftActions: ,
+ rightActions: ,
+ },
+};
+
+export const ThreeButtons: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ leftActions: ,
+ rightActions: (
+ <>
+
+
+ >
+ ),
+ },
+};
+
+export const CenterButtons: Story = {
+ args: {
+ size: ModalSize.MEDIUM,
+ actions: (
+ <>
+
+
+ >
+ ),
+ },
+};
+
+export const ExampleApplication: Story = {
+ args: {
+ size: ModalSize.LARGE,
+ title: "Application successful",
+ titleIcon: done,
+ children: (
+ <>
+ Thank you for submitting your application! Your information has been
+ received successfully.
+
+ You will receive a confirmation email shortly with the details of your
+ submission. If there are any further steps required our team will be in
+ touch.
+ >
+ ),
+ rightActions: ,
+ },
+ parameters: {
+ docs: {
+ story: {
+ height: "500px",
+ },
+ },
+ },
+};
+
+export const FullWithContent: Story = {
+ args: {
+ size: ModalSize.FULL,
+ leftActions: ,
+ rightActions: (
+ <>
+
+
+ >
+ ),
+ children: faker.lorem.lines(400),
+ },
+};
diff --git a/packages/react/src/components/Modal/index.tsx b/packages/react/src/components/Modal/index.tsx
new file mode 100644
index 0000000..59967df
--- /dev/null
+++ b/packages/react/src/components/Modal/index.tsx
@@ -0,0 +1,165 @@
+import React, { PropsWithChildren, useEffect } from "react";
+import classNames from "classnames";
+import { createPortal } from "react-dom";
+import { Button } from ":/components/Button";
+
+export type ModalHandle = {};
+
+export enum ModalSize {
+ SMALL = "small",
+ MEDIUM = "medium",
+ LARGE = "large",
+ FULL = "full",
+}
+
+export const useModal = ({
+ isOpenDefault,
+}: { isOpenDefault?: boolean } = {}) => {
+ const [isOpen, setIsOpen] = React.useState(!!isOpenDefault);
+ const onClose = () => {
+ setIsOpen(false);
+ };
+
+ const open = () => {
+ setIsOpen(true);
+ };
+
+ const close = () => {
+ onClose();
+ };
+
+ return {
+ isOpen,
+ onClose,
+ open,
+ close,
+ };
+};
+
+export interface ModalProps
+ extends PropsWithChildren<{
+ size: ModalSize;
+ isOpen: boolean;
+ onClose: () => void;
+ leftActions?: React.ReactNode;
+ rightActions?: React.ReactNode;
+ actions?: React.ReactNode;
+ title?: string;
+ titleIcon?: React.ReactNode;
+ hideCloseButton?: boolean;
+ closeOnClickOutside?: boolean;
+ preventClose?: boolean;
+ }> {}
+
+export const Modal = (props: ModalProps) => {
+ const ref = React.useRef(null);
+
+ useEffect(() => {
+ if (props.isOpen) {
+ ref.current?.showModal();
+ } else {
+ ref.current?.close();
+ }
+ }, [props.isOpen]);
+
+ useEffect(() => {
+ ref.current?.addEventListener("close", () => props.onClose(), {
+ once: true,
+ });
+
+ const onClick = (event: MouseEvent) => {
+ const rect = ref.current!.getBoundingClientRect();
+ const isInDialog =
+ rect.top <= event.clientY &&
+ event.clientY <= rect.top + rect.height &&
+ rect.left <= event.clientX &&
+ event.clientX <= rect.left + rect.width;
+ if (!isInDialog) {
+ props.onClose();
+ }
+ };
+
+ if (props.closeOnClickOutside) {
+ ref.current?.addEventListener("click", onClick);
+ }
+
+ const preventClose = (event: Event) => {
+ event.preventDefault();
+ };
+
+ if (props.preventClose) {
+ ref.current?.addEventListener("cancel", preventClose);
+ }
+
+ return () => {
+ ref.current?.removeEventListener("click", onClick);
+ ref.current?.removeEventListener("click", preventClose);
+ };
+ }, [props.isOpen]);
+
+ if (!props.isOpen) {
+ return null;
+ }
+
+ return (
+ <>
+ {createPortal(
+ ,
+ document.getElementById("c__modals-portal")!,
+ )}
+ >
+ );
+};
+
+export const ModalFooter = ({
+ leftActions,
+ rightActions,
+ actions,
+}: ModalProps) => {
+ if ((leftActions || rightActions) && actions) {
+ throw new Error("Cannot use leftActions or rightActions with actions");
+ }
+
+ if (!leftActions && !rightActions && !actions) {
+ return null;
+ }
+
+ return (
+
+ {actions || (
+ <>
+
{leftActions}
+
{rightActions}
+ >
+ )}
+
+ );
+};
diff --git a/packages/react/src/components/Modal/tokens.ts b/packages/react/src/components/Modal/tokens.ts
new file mode 100644
index 0000000..817d1c5
--- /dev/null
+++ b/packages/react/src/components/Modal/tokens.ts
@@ -0,0 +1,19 @@
+import { DefaultTokens } from "@openfun/cunningham-tokens";
+
+export const tokens = (defaults: DefaultTokens) => {
+ return {
+ "background-color": defaults.theme.colors["greyscale-000"],
+ "border-radius": "4px",
+ "border-color": defaults.theme.colors["greyscale-300"],
+ "box-shadow": "0px 1px 2px 0px #0C1A2B4D",
+ color: defaults.theme.colors["greyscale-900"],
+ // "backdrop-color": "#0C1A2B99", // Does not work yet due to backdrop CSS var support.
+ "title-font-weight": defaults.theme.font.weights.bold,
+ "content-font-size": defaults.theme.font.sizes.m,
+ "content-font-weight": defaults.theme.font.weights.regular,
+ "content-color": defaults.theme.colors["greyscale-800"],
+ "width-small": "300px",
+ "width-medium": "600px",
+ "width-large": "75%",
+ };
+};
diff --git a/packages/react/src/components/Provider/index.tsx b/packages/react/src/components/Provider/index.tsx
index 6eb353b..af8288c 100644
--- a/packages/react/src/components/Provider/index.tsx
+++ b/packages/react/src/components/Provider/index.tsx
@@ -10,6 +10,7 @@ import * as frFR from ":/locales/fr-FR.json";
import { PartialNested } from ":/types";
import { Locales } from ":/components/Provider/Locales";
import { ToastProvider } from ":/components/Toast/ToastProvider";
+import { ModalProvider } from ":/components/Modal/ModalProvider";
type TranslationSet = PartialNested;
@@ -101,7 +102,9 @@ export const CunninghamProvider = ({
return (
- {children}
+
+ {children}
+
);
};
diff --git a/packages/react/src/components/Toast/DocUtils.tsx b/packages/react/src/components/Toast/DocUtils.tsx
index c134e9d..e8d8b05 100644
--- a/packages/react/src/components/Toast/DocUtils.tsx
+++ b/packages/react/src/components/Toast/DocUtils.tsx
@@ -1,6 +1,6 @@
import { ReactNode } from "react";
-import { ToastType } from ":/components/Toast/ToastProvider";
import { ButtonProps } from ":/components/Button";
+import { VariantType } from ":/utils/VariantUtils";
/**
* This function is used for doc purpose only.
@@ -16,7 +16,7 @@ export const toast = ({
/** Message displayed inside the toast */
message: string;
/** Type of the toast */
- type?: ToastType;
+ type?: VariantType;
/** Various options */
options?: {
duration: number;
diff --git a/packages/react/src/components/Toast/ToastProvider.tsx b/packages/react/src/components/Toast/ToastProvider.tsx
index 4cf8ee4..3e0b15a 100644
--- a/packages/react/src/components/Toast/ToastProvider.tsx
+++ b/packages/react/src/components/Toast/ToastProvider.tsx
@@ -2,19 +2,12 @@ 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",
-}
+import { VariantType } from ":/utils/VariantUtils";
export interface ToastProviderContext {
toast: (
message: string,
- type?: ToastType,
+ type?: VariantType,
options?: Partial>,
) => void;
}
@@ -57,7 +50,7 @@ export const ToastProvider = ({ children }: PropsWithChildren) => {
const context: ToastProviderContext = useMemo(
() => ({
- toast: (message, type = ToastType.NEUTRAL, options = {}) => {
+ toast: (message, type = VariantType.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(
diff --git a/packages/react/src/components/Toast/index.spec.tsx b/packages/react/src/components/Toast/index.spec.tsx
index 6419ea6..c96b5e8 100644
--- a/packages/react/src/components/Toast/index.spec.tsx
+++ b/packages/react/src/components/Toast/index.spec.tsx
@@ -8,8 +8,9 @@ import {
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 { useToastProvider } from ":/components/Toast/ToastProvider";
import { Button } from ":/components/Button";
+import { VariantType } from ":/utils/VariantUtils";
describe("", () => {
const Wrapper = ({ children }: PropsWithChildren) => {
@@ -22,7 +23,7 @@ describe("", () => {
return (