(react) add Modal

Here it is! Our really wanted Modal component, based on Figma sketches.
This commit is contained in:
Nathan Vasse
2024-01-18 14:38:48 +01:00
committed by NathanVss
parent 81e4da1d36
commit 1445f4a222
32 changed files with 1723 additions and 111 deletions

View File

@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---
Add Modal component

View File

@@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import { import {
Alert, Alert,
AlertType,
Button, Button,
Checkbox, Checkbox,
DatePicker, DatePicker,
@@ -13,8 +12,8 @@ import {
Select, Select,
Switch, Switch,
TextArea, TextArea,
ToastType,
useToastProvider, useToastProvider,
VariantType,
} from "@openfun/cunningham-react"; } from "@openfun/cunningham-react";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { Character, database, randomDates } from "./Character"; import { Character, database, randomDates } from "./Character";
@@ -35,7 +34,7 @@ export const Create = ({ changePage }: PageProps) => {
database.unshift(character); database.unshift(character);
changePage(Page.HOME); changePage(Page.HOME);
toast("Character created successfully", ToastType.SUCCESS); toast("Character created successfully", VariantType.SUCCESS);
}; };
return ( return (
@@ -43,7 +42,7 @@ export const Create = ({ changePage }: PageProps) => {
<h1>Add a character</h1> <h1>Add a character</h1>
<div className="card"> <div className="card">
<h3 className="fw-bold fs-h3">General Information</h3> <h3 className="fw-bold fs-h3">General Information</h3>
<Alert type={AlertType.INFO}> <Alert type={VariantType.INFO}>
You are about to add a new character to the collection You are about to add a new character to the collection
</Alert> </Alert>
<Input <Input
@@ -77,7 +76,7 @@ export const Create = ({ changePage }: PageProps) => {
</div> </div>
<div className="card mt-l"> <div className="card mt-l">
<h3 className="fw-bold fs-h3">Bio</h3> <h3 className="fw-bold fs-h3">Bio</h3>
<Alert type={AlertType.WARNING}> <Alert type={VariantType.WARNING}>
Please be exhaustive, every detail counts! Please be exhaustive, every detail counts!
</Alert> </Alert>
<TextArea <TextArea

View File

@@ -3,7 +3,7 @@ import {
Button, Button,
DataGrid, DataGrid,
SortModel, SortModel,
ToastType, VariantType,
usePagination, usePagination,
useToastProvider, useToastProvider,
} from "@openfun/cunningham-react"; } from "@openfun/cunningham-react";
@@ -119,7 +119,10 @@ export const Home = ({ changePage }: PageProps) => {
); );
database.splice(index, 1); database.splice(index, 1);
setRefresh(refresh + 1); setRefresh(refresh + 1);
toast("Character deleted successfully", ToastType.WARNING); toast(
"Character deleted successfully",
VariantType.WARNING,
);
}} }}
icon={<span className="material-icons">delete</span>} icon={<span className="material-icons">delete</span>}
/> />

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { AlertProps, AlertType } from ":/components/Alert/index"; import { AlertProps } from ":/components/Alert/index";
import { useCunningham } from ":/components/Provider"; import { useCunningham } from ":/components/Provider";
import { ToastType } from ":/components/Toast/ToastProvider"; import { iconFromType } from ":/utils/VariantUtils";
export const AlertWrapper = (props: AlertProps) => { export const AlertWrapper = (props: AlertProps) => {
return ( return (
@@ -22,36 +22,6 @@ export const AlertWrapper = (props: AlertProps) => {
); );
}; };
export const iconFromType = (type?: AlertType | ToastType) => {
switch (type) {
case AlertType.INFO:
return "info";
case AlertType.SUCCESS:
return "check_circle";
case AlertType.WARNING:
return "error_outline";
case AlertType.ERROR:
return "cancel";
default:
return "";
}
};
export const colorFromType = (type?: AlertType | ToastType) => {
switch (type) {
case AlertType.INFO:
return "info";
case AlertType.SUCCESS:
return "success";
case AlertType.WARNING:
return "warning";
case AlertType.ERROR:
return "danger";
default:
return "neutral";
}
};
export const AlertIcon = ({ type, ...props }: AlertProps) => { export const AlertIcon = ({ type, ...props }: AlertProps) => {
const icon = useMemo(() => iconFromType(type), [type]); const icon = useMemo(() => iconFromType(type), [type]);
if (props.icon) { if (props.icon) {

View File

@@ -1,17 +1,18 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import React from "react"; import React from "react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { Alert, AlertType } from ":/components/Alert/index"; import { Alert } from ":/components/Alert/index";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { CunninghamProvider } from ":/components/Provider"; import { CunninghamProvider } from ":/components/Provider";
import { VariantType } from ":/utils/VariantUtils";
describe("<Alert/>", () => { describe("<Alert/>", () => {
it.each([ it.each([
[AlertType.INFO, "info"], [VariantType.INFO, "info"],
[AlertType.SUCCESS, "check_circle"], [VariantType.SUCCESS, "check_circle"],
[AlertType.WARNING, "error_outline"], [VariantType.WARNING, "error_outline"],
[AlertType.ERROR, "cancel"], [VariantType.ERROR, "cancel"],
[AlertType.NEUTRAL, undefined], [VariantType.NEUTRAL, undefined],
])("renders % alert with according icon", (type, icon) => { ])("renders % alert with according icon", (type, icon) => {
render( render(
<CunninghamProvider> <CunninghamProvider>
@@ -28,7 +29,7 @@ describe("<Alert/>", () => {
it("renders additional information", () => { it("renders additional information", () => {
render( render(
<CunninghamProvider> <CunninghamProvider>
<Alert type={AlertType.INFO} additional="Additional information"> <Alert type={VariantType.INFO} additional="Additional information">
Alert component Alert component
</Alert> </Alert>
</CunninghamProvider>, </CunninghamProvider>,
@@ -38,7 +39,7 @@ describe("<Alert/>", () => {
it("renders primary button when primaryLabel is provided", () => { it("renders primary button when primaryLabel is provided", () => {
render( render(
<CunninghamProvider> <CunninghamProvider>
<Alert type={AlertType.INFO} primaryLabel="Primary"> <Alert type={VariantType.INFO} primaryLabel="Primary">
Alert component Alert component
</Alert> </Alert>
</CunninghamProvider>, </CunninghamProvider>,
@@ -48,7 +49,7 @@ describe("<Alert/>", () => {
it("renders tertiary button when tertiaryLabel is provided", () => { it("renders tertiary button when tertiaryLabel is provided", () => {
render( render(
<CunninghamProvider> <CunninghamProvider>
<Alert type={AlertType.INFO} tertiaryLabel="Tertiary"> <Alert type={VariantType.INFO} tertiaryLabel="Tertiary">
Alert component Alert component
</Alert> </Alert>
</CunninghamProvider>, </CunninghamProvider>,
@@ -59,7 +60,7 @@ describe("<Alert/>", () => {
render( render(
<CunninghamProvider> <CunninghamProvider>
<Alert <Alert
type={AlertType.INFO} type={VariantType.INFO}
primaryLabel="Primary" primaryLabel="Primary"
tertiaryLabel="Tertiary" tertiaryLabel="Tertiary"
> >
@@ -74,7 +75,7 @@ describe("<Alert/>", () => {
render( render(
<CunninghamProvider> <CunninghamProvider>
<Alert <Alert
type={AlertType.INFO} type={VariantType.INFO}
buttons={ buttons={
<> <>
<Button color="primary">Primary Custom</Button> <Button color="primary">Primary Custom</Button>
@@ -92,7 +93,7 @@ describe("<Alert/>", () => {
it("can close the alert non controlled", async () => { it("can close the alert non controlled", async () => {
render( render(
<CunninghamProvider> <CunninghamProvider>
<Alert type={AlertType.INFO} canClose={true}> <Alert type={VariantType.INFO} canClose={true}>
Alert component Alert component
</Alert> </Alert>
</CunninghamProvider>, </CunninghamProvider>,
@@ -113,7 +114,7 @@ describe("<Alert/>", () => {
return ( return (
<CunninghamProvider> <CunninghamProvider>
<Alert <Alert
type={AlertType.INFO} type={VariantType.INFO}
canClose={true} canClose={true}
closed={closed} closed={closed}
onClose={(flag) => setClosed(flag)} onClose={(flag) => setClosed(flag)}
@@ -158,7 +159,7 @@ describe("<Alert/>", () => {
render( render(
<CunninghamProvider> <CunninghamProvider>
<Alert <Alert
type={AlertType.INFO} type={VariantType.INFO}
additional="Additional information" additional="Additional information"
expandable={true} expandable={true}
> >
@@ -192,7 +193,7 @@ describe("<Alert/>", () => {
return ( return (
<CunninghamProvider> <CunninghamProvider>
<Alert <Alert
type={AlertType.INFO} type={VariantType.INFO}
additional="Additional information" additional="Additional information"
expandable={true} expandable={true}
expanded={expanded} expanded={expanded}

View File

@@ -1,7 +1,8 @@
import { Meta, StoryObj } from "@storybook/react"; import { Meta, StoryObj } from "@storybook/react";
import React from "react"; import React from "react";
import { Alert, AlertProps, AlertType } from ":/components/Alert"; import { Alert, AlertProps } from ":/components/Alert";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { VariantType } from ":/utils/VariantUtils";
const meta: Meta<typeof Alert> = { const meta: Meta<typeof Alert> = {
title: "Components/Alert", title: "Components/Alert",
@@ -13,7 +14,7 @@ type Story = StoryObj<typeof Alert>;
export const All: Story = { export const All: Story = {
render: (args) => { render: (args) => {
const customProps: AlertProps = { type: args.type ?? AlertType.INFO }; const customProps: AlertProps = { type: args.type ?? VariantType.INFO };
return ( return (
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}> <div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
<Alert {...Info.args} primaryLabel={undefined} {...customProps} /> <Alert {...Info.args} primaryLabel={undefined} {...customProps} />
@@ -69,7 +70,7 @@ export const Success: Story = {
children: "Alert component Success", children: "Alert component Success",
canClose: true, canClose: true,
primaryLabel: "Primary", primaryLabel: "Primary",
type: AlertType.SUCCESS, type: VariantType.SUCCESS,
}, },
}; };
@@ -78,7 +79,7 @@ export const Warning: Story = {
children: "Alert component Warning", children: "Alert component Warning",
canClose: true, canClose: true,
primaryLabel: "Primary", primaryLabel: "Primary",
type: AlertType.WARNING, type: VariantType.WARNING,
}, },
}; };
@@ -87,7 +88,7 @@ export const Error: Story = {
children: "Alert component Error", children: "Alert component Error",
canClose: true, canClose: true,
primaryLabel: "Primary", primaryLabel: "Primary",
type: AlertType.ERROR, type: VariantType.ERROR,
}, },
}; };
@@ -96,7 +97,7 @@ export const Neutral: Story = {
children: "Alert component Neutral", children: "Alert component Neutral",
canClose: true, canClose: true,
primaryLabel: "Primary", primaryLabel: "Primary",
type: AlertType.NEUTRAL, type: VariantType.NEUTRAL,
}, },
}; };

View File

@@ -4,14 +4,7 @@ import { useControllableState } from ":/hooks/useControllableState";
import { AlertAdditionalExpandable } from ":/components/Alert/AlertAdditionalExpandable"; import { AlertAdditionalExpandable } from ":/components/Alert/AlertAdditionalExpandable";
import { AlertAdditional } from ":/components/Alert/AlertAdditional"; import { AlertAdditional } from ":/components/Alert/AlertAdditional";
import { AlertOneLine } from ":/components/Alert/AlertOneLine"; import { AlertOneLine } from ":/components/Alert/AlertOneLine";
import { VariantType } from ":/utils/VariantUtils";
export enum AlertType {
INFO = "info",
SUCCESS = "success",
WARNING = "warning",
ERROR = "error",
NEUTRAL = "neutral",
}
export interface AlertProps extends PropsWithChildren { export interface AlertProps extends PropsWithChildren {
additional?: React.ReactNode; additional?: React.ReactNode;
@@ -31,7 +24,7 @@ export interface AlertProps extends PropsWithChildren {
tertiaryLabel?: string; tertiaryLabel?: string;
tertiaryOnClick?: ButtonProps["onClick"]; tertiaryOnClick?: ButtonProps["onClick"];
tertiaryProps?: ButtonProps; tertiaryProps?: ButtonProps;
type?: AlertType; type?: VariantType;
} }
export const Alert = (props: AlertProps) => { export const Alert = (props: AlertProps) => {
@@ -42,7 +35,7 @@ export const Alert = (props: AlertProps) => {
); );
const propsWithDefault = { const propsWithDefault = {
type: AlertType.INFO, type: VariantType.INFO,
...props, ...props,
onClose, onClose,
}; };

View File

@@ -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 (
<Modal
title={title ?? t("components.modals.helpers.confirmation.title")}
size={ModalSize.SMALL}
actions={
<>
<Button
color="secondary"
fullWidth={true}
onClick={() => onDecide(null)}
>
{t("components.modals.helpers.confirmation.cancel")}
</Button>
<Button fullWidth={true} onClick={() => onDecide("yes")}>
{t("components.modals.helpers.confirmation.yes")}
</Button>
</>
}
{...props}
>
{children ?? t("components.modals.helpers.confirmation.children")}
</Modal>
);
};

View File

@@ -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 (
<Modal
title={title ?? t("components.modals.helpers.delete_confirmation.title")}
titleIcon={<span className="material-icons clr-danger-600">delete</span>}
size={ModalSize.SMALL}
actions={
<>
<Button
color="secondary"
fullWidth={true}
onClick={() => onDecide(null)}
>
{t("components.modals.helpers.delete_confirmation.cancel")}
</Button>
<Button
fullWidth={true}
onClick={() => onDecide("delete")}
color="danger"
>
{t("components.modals.helpers.delete_confirmation.delete")}
</Button>
</>
}
{...props}
>
{children ?? t("components.modals.helpers.delete_confirmation.children")}
</Modal>
);
};

View File

@@ -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 (
<Modal
title={title ?? t("components.modals.helpers.disclaimer.title")}
titleIcon={
messageType !== VariantType.NEUTRAL && (
<span
className={classNames(
"material-icons",
`clr-${colorFromType(messageType)}-600`,
)}
>
{iconFromType(messageType)}
</span>
)
}
size={ModalSize.SMALL}
actions={
<Button fullWidth={true} onClick={() => onDecide("ok")}>
{t("components.modals.helpers.disclaimer.ok")}
</Button>
}
{...props}
>
{children ?? t("components.modals.helpers.disclaimer.children")}
</Modal>
);
};

View File

@@ -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<ModalProps, "size"> & {
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<DecisionModalProps & any>;
interface ModalContextType {
deleteConfirmationModal: (
props?: Partial<DeleteConfirmationModalProps>,
) => Promise<Decision>;
confirmationModal: (
props?: Partial<ConfirmationModalProps>,
) => Promise<Decision>;
messageModal: (props?: Partial<MessageModalProps>) => Promise<Decision>;
}
const ModalContext = createContext<undefined | ModalContextType>(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<DecisionModalProps>;
}) => {
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<string, ReactNode>;
export const ModalProvider = ({ children }: PropsWithChildren) => {
const [modals, setModals] = useState<ModalMap>({} as ModalMap);
const addModal = (
component: FunctionComponent<DecisionModalProps>,
props: Partial<DecisionModalProps>,
) => {
const key = randomString(32);
const container = (
<ModalContainer
component={component}
props={props}
onClose={() => {
setModals((modals_) => {
// @ts-ignore
delete modals_[key];
return modals_;
});
}}
/>
);
setModals((modals_) => ({
...modals_,
[key]: container,
}));
};
const ModalHelper = (component: DecisionModalComponent) => {
return (props: Partial<DecisionModalProps> = {}) => {
return new Promise<Decision>((resolve) => {
addModal(component, {
onDecide: (decision) => {
resolve(decision);
},
...props,
});
});
};
};
const context: ModalContextType = useMemo(
() => ({
deleteConfirmationModal: ModalHelper(DeleteConfirmationModal),
confirmationModal: ModalHelper(ConfirmationModal),
messageModal: ModalHelper(MessageModal),
}),
[],
);
return (
<ModalContext.Provider value={context}>
{children}
<div id="c__modals-portal" />
{Object.entries(modals).map(([key, modal]) => (
<Fragment key={key}>{modal}</Fragment>
))}
</ModalContext.Provider>
);
};

View File

@@ -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 <Button onClick={ask}>Open</Button>;
},
};
export const ConfirmationModal = {
render: () => {
const modals = useModals();
const ask = async () => {
const decision = await modals.confirmationModal();
alert(`You decided ${decision}`);
};
useEffect(() => {
ask();
}, []);
return <Button onClick={ask}>Open</Button>;
},
};
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 <Button onClick={ask}>Open</Button>;
};
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),
};

View File

@@ -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';
<Meta of={Stories}/>
# Modal
Cunningham provides a versatile Modal component for displaying any kind of information.
<Canvas>
<Story of={Stories.ThreeButtons} inline={false}/>
</Canvas>
<Source
language='ts'
dark
format={false}
code={`import { Modal } from "@openfun/cunningham-react";`}
/>
> ⚠️ 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.
<Source
language='tsx'
dark
format={false}
code={`
import { CunninghamProvider, Button, Modal } from "@openfun/cunningham-react";
const App = () => {
const modal = useModal();
return (
<CunninghamProvider>
<Button onClick={modal.open}>Open Modal</Button>
<Modal {...modal} size={ModalSize.SMALL} title="My title">
My modal
</Modal>
</CunninghamProvider>
);
};
`}/>
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.
<Source
language='tsx'
dark
format={false}
code={`
import { CunninghamProvider, Button, Modal } from "@openfun/cunningham-react";
const App = () => {
const modal = useModal();
useEffect(() => {
modal.open();
setTimeout(() => {
modal.close();
}, 2000);
}, []);
return (
<CunninghamProvider>
<Modal {...modal} size={ModalSize.SMALL} title="My title">
My modal
</Modal>
</CunninghamProvider>
);
};
`}/>
## 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
<Canvas>
<Story of={Stories.Small} inline={false}/>
</Canvas>
### Medium
<Canvas>
<Story of={Stories.Medium} inline={false}/>
</Canvas>
### Large
<Canvas>
<Story of={Stories.Large} inline={false}/>
</Canvas>
### Full
<Canvas>
<Story of={Stories.FullWithContent} inline={false}/>
</Canvas>
## Close button
You can hide the close button by passing the `hideCloseButton` prop.
<Canvas>
<Story of={Stories.HideCloseButton} inline={false}/>
</Canvas>
## Buttons
### Right buttons
You can add buttons on the right side of the modal by passing the `rightActions` prop.
<Canvas>
<Story of={Stories.PrimaryButton} inline={false}/>
</Canvas>
### Left buttons
You can add buttons on the left side of the modal by passing the `leftActions` prop.
<Canvas>
<Story of={Stories.ThreeButtons} inline={false}/>
</Canvas>
### Center buttons
You can add buttons on the center of the modal by passing the `actions` prop.
<Canvas>
<Story of={Stories.CenterButtons} inline={false}/>
</Canvas>
## Icon
You can add an icon to the modal by passing the `titleIcon` prop.
<Canvas>
<Story of={Stories.ExampleApplication} inline={false}/>
</Canvas>
## 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 `<dialog/>` element.
You can change this behavior by passing the `closeOnClickOutside` prop.
<Canvas>
<Story of={Stories.CloseOnClickOutside} inline={false}/>
</Canvas>
## 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 `<Modal/>` 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.
<Source
language='tsx'
dark
format={false}
code={`
import { CunninghamProvider, Button, Modal } from "@openfun/cunningham-react";
const App = () => {
const modals = useModals();
const ask = async () => {
const decision = await modals.confirmationModal();
alert("You decided: " + decision);
};
return <Button onClick={ask}>Open</Button>;
};
`}/>
**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
<Canvas>
<Story of={PreBuiltStories.ConfirmationModal} inline={false}/>
</Canvas>
### Delete confirmation modal
<Canvas>
<Story of={PreBuiltStories.DeleteConfirmationModal} inline={false}/>
</Canvas>
### 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
<Canvas>
<Story of={PreBuiltStories.NeutralModal} inline={false}/>
</Canvas>
<Canvas>
<Story of={PreBuiltStories.SuccessModal} inline={false}/>
</Canvas>
<Canvas>
<Story of={PreBuiltStories.InfoModal} inline={false}/>
</Canvas>
<Canvas>
<Story of={PreBuiltStories.ErrorModal} inline={false}/>
</Canvas>
<Canvas>
<Story of={PreBuiltStories.WarningModal} inline={false}/>
</Canvas>
## Props
These are the props of `Modal`.
<ArgTypes 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 |

View File

@@ -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;
}
}
}

View File

@@ -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("<Modal/>", () => {
it("shows a modal when clicking on the button", async () => {
const Wrapper = () => {
const modal = useModal();
return (
<CunninghamProvider>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);
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 (
<CunninghamProvider>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);
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 (
<CunninghamProvider>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} {...modal} hideCloseButton>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);
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 (
<CunninghamProvider>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} closeOnClickOutside={true} {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);
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 (
<CunninghamProvider>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} closeOnClickOutside={false} {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);
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 (
<CunninghamProvider>
<button onClick={modal.open}>Open Modal</button>
<Modal size={ModalSize.SMALL} preventClose={true} {...modal}>
<div>Modal Content</div>
</Modal>
</CunninghamProvider>
);
};
render(<Wrapper />);
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 <button onClick={open}>Ask</button>;
};
render(
<CunninghamProvider>
<Wrapper />
</CunninghamProvider>,
);
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 <button onClick={open}>Ask</button>;
};
render(
<CunninghamProvider>
<Wrapper />
</CunninghamProvider>,
);
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 <button onClick={open}>Ask</button>;
};
render(
<CunninghamProvider>
<Wrapper />
</CunninghamProvider>,
);
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();
});
});

View File

@@ -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<typeof Modal> = {
title: "Components/Modal",
component: Modal,
args: {
children: "Description",
title: "Title",
},
decorators: [
(Story, context) => {
const modal = useModal();
useEffect(() => {
modal.open();
}, []);
return (
<CunninghamProvider>
<Button onClick={() => modal.open()}>Open Modal</Button>
<Story args={{ ...context.args, ...modal }} />
</CunninghamProvider>
);
},
],
parameters: {
docs: {
story: {
height: "250px",
},
},
},
};
export default meta;
type Story = StoryObj<typeof Modal>;
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: <Button color="primary">Primary</Button>,
},
};
export const SecondaryButton: Story = {
args: {
size: ModalSize.MEDIUM,
rightActions: <Button color="secondary">Secondary</Button>,
},
};
export const TwoButtons: Story = {
args: {
size: ModalSize.MEDIUM,
leftActions: <Button color="secondary">Secondary</Button>,
rightActions: <Button color="primary">Primary</Button>,
},
};
export const ThreeButtons: Story = {
args: {
size: ModalSize.MEDIUM,
leftActions: <Button color="tertiary">Tertiary</Button>,
rightActions: (
<>
<Button color="secondary">Secondary</Button>
<Button color="primary">Primary</Button>
</>
),
},
};
export const CenterButtons: Story = {
args: {
size: ModalSize.MEDIUM,
actions: (
<>
<Button color="secondary">Secondary</Button>
<Button color="primary">Primary</Button>
</>
),
},
};
export const ExampleApplication: Story = {
args: {
size: ModalSize.LARGE,
title: "Application successful",
titleIcon: <span className="material-icons clr-success-600">done</span>,
children: (
<>
Thank you for submitting your application! Your information has been
received successfully. <br />
<br />
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: <Button color="primary">I understand</Button>,
},
parameters: {
docs: {
story: {
height: "500px",
},
},
},
};
export const FullWithContent: Story = {
args: {
size: ModalSize.FULL,
leftActions: <Button color="tertiary">Tertiary</Button>,
rightActions: (
<>
<Button color="secondary">Secondary</Button>
<Button color="primary">Primary</Button>
</>
),
children: faker.lorem.lines(400),
},
};

View File

@@ -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<HTMLDialogElement>(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(
<dialog
ref={ref}
className={classNames("c__modal", "c__modal--" + props.size)}
>
{!props.hideCloseButton && !props.preventClose && (
<div className="c__modal__close">
<Button
icon={<span className="material-icons">close</span>}
color="tertiary-text"
size="nano"
onClick={() => props.onClose()}
/>
</div>
)}
{props.titleIcon && (
<div className="c__modal__title-icon">{props.titleIcon}</div>
)}
{props.title && <div className="c__modal__title">{props.title}</div>}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
<div className="c__modal__content" tabIndex={0}>
{props.children}
</div>
<ModalFooter {...props} />
</dialog>,
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 (
<div
className={classNames("c__modal__footer", {
"c__modal__footer--sided": leftActions || rightActions,
})}
>
{actions || (
<>
<div className="c__modal__footer__left">{leftActions}</div>
<div className="c__modal__footer__right">{rightActions}</div>
</>
)}
</div>
);
};

View File

@@ -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%",
};
};

View File

@@ -10,6 +10,7 @@ import * as frFR from ":/locales/fr-FR.json";
import { PartialNested } from ":/types"; import { PartialNested } from ":/types";
import { Locales } from ":/components/Provider/Locales"; import { Locales } from ":/components/Provider/Locales";
import { ToastProvider } from ":/components/Toast/ToastProvider"; import { ToastProvider } from ":/components/Toast/ToastProvider";
import { ModalProvider } from ":/components/Modal/ModalProvider";
type TranslationSet = PartialNested<typeof enUS>; type TranslationSet = PartialNested<typeof enUS>;
@@ -101,7 +102,9 @@ export const CunninghamProvider = ({
return ( return (
<CunninghamContext.Provider value={context}> <CunninghamContext.Provider value={context}>
<ToastProvider>{children}</ToastProvider> <ModalProvider>
<ToastProvider>{children}</ToastProvider>
</ModalProvider>
</CunninghamContext.Provider> </CunninghamContext.Provider>
); );
}; };

View File

@@ -1,6 +1,6 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ToastType } from ":/components/Toast/ToastProvider";
import { ButtonProps } from ":/components/Button"; import { ButtonProps } from ":/components/Button";
import { VariantType } from ":/utils/VariantUtils";
/** /**
* This function is used for doc purpose only. * This function is used for doc purpose only.
@@ -16,7 +16,7 @@ export const toast = ({
/** Message displayed inside the toast */ /** Message displayed inside the toast */
message: string; message: string;
/** Type of the toast */ /** Type of the toast */
type?: ToastType; type?: VariantType;
/** Various options */ /** Various options */
options?: { options?: {
duration: number; duration: number;

View File

@@ -2,19 +2,12 @@ import React, { PropsWithChildren, useContext, useMemo, useRef } from "react";
import { Toast, ToastProps } from ":/components/Toast/index"; import { Toast, ToastProps } from ":/components/Toast/index";
import { tokens } from ":/cunningham-tokens"; import { tokens } from ":/cunningham-tokens";
import { Queue } from ":/utils/Queue"; import { Queue } from ":/utils/Queue";
import { VariantType } from ":/utils/VariantUtils";
export enum ToastType {
INFO = "info",
SUCCESS = "success",
WARNING = "warning",
ERROR = "error",
NEUTRAL = "neutral",
}
export interface ToastProviderContext { export interface ToastProviderContext {
toast: ( toast: (
message: string, message: string,
type?: ToastType, type?: VariantType,
options?: Partial<Omit<ToastInterface, "message" | "type">>, options?: Partial<Omit<ToastInterface, "message" | "type">>,
) => void; ) => void;
} }
@@ -57,7 +50,7 @@ export const ToastProvider = ({ children }: PropsWithChildren) => {
const context: ToastProviderContext = useMemo( 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 ) // 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. // before adding a new one, that's why we use a queue.
queue.current?.push( queue.current?.push(

View File

@@ -8,8 +8,9 @@ import {
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { within } from "@testing-library/dom"; import { within } from "@testing-library/dom";
import { CunninghamProvider } from ":/components/Provider"; import { CunninghamProvider } from ":/components/Provider";
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider"; import { useToastProvider } from ":/components/Toast/ToastProvider";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { VariantType } from ":/utils/VariantUtils";
describe("<Toast />", () => { describe("<Toast />", () => {
const Wrapper = ({ children }: PropsWithChildren) => { const Wrapper = ({ children }: PropsWithChildren) => {
@@ -22,7 +23,7 @@ describe("<Toast />", () => {
return ( return (
<Button <Button
onClick={() => onClick={() =>
toast("Toast content", ToastType.NEUTRAL, { duration: 50 }) toast("Toast content", VariantType.NEUTRAL, { duration: 50 })
} }
> >
Create toast Create toast
@@ -54,7 +55,7 @@ describe("<Toast />", () => {
return ( return (
<Button <Button
onClick={() => onClick={() =>
toast("Toast content", ToastType.NEUTRAL, { toast("Toast content", VariantType.NEUTRAL, {
primaryLabel: "Action", primaryLabel: "Action",
primaryOnClick: () => { primaryOnClick: () => {
flag = true; flag = true;
@@ -95,7 +96,7 @@ describe("<Toast />", () => {
return ( return (
<Button <Button
onClick={() => onClick={() =>
toast("Toast content", ToastType.NEUTRAL, { toast("Toast content", VariantType.NEUTRAL, {
actions: <Button color="tertiary">Tertiary</Button>, actions: <Button color="tertiary">Tertiary</Button>,
}) })
} }
@@ -123,11 +124,11 @@ describe("<Toast />", () => {
}); });
it.each([ it.each([
[ToastType.INFO, "info"], [VariantType.INFO, "info"],
[ToastType.SUCCESS, "check_circle"], [VariantType.SUCCESS, "check_circle"],
[ToastType.WARNING, "error_outline"], [VariantType.WARNING, "error_outline"],
[ToastType.ERROR, "cancel"], [VariantType.ERROR, "cancel"],
[ToastType.NEUTRAL, undefined], [VariantType.NEUTRAL, undefined],
])("shows a %s toast", async (type, iconName) => { ])("shows a %s toast", async (type, iconName) => {
const Inner = () => { const Inner = () => {
const { toast } = useToastProvider(); const { toast } = useToastProvider();

View File

@@ -3,7 +3,8 @@ import React, { useEffect } from "react";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { ProgressBar, Toast } from ":/components/Toast/index"; import { ProgressBar, Toast } from ":/components/Toast/index";
import { Button } from ":/components/Button"; import { Button } from ":/components/Button";
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider"; import { useToastProvider } from ":/components/Toast/ToastProvider";
import { VariantType } from ":/utils/VariantUtils";
const meta: Meta<typeof Toast> = { const meta: Meta<typeof Toast> = {
title: "Components/Toast", title: "Components/Toast",
@@ -22,15 +23,15 @@ export const Demo: Story = {
render: () => { render: () => {
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const TYPES = [ const TYPES = [
ToastType.INFO, VariantType.INFO,
ToastType.SUCCESS, VariantType.SUCCESS,
ToastType.WARNING, VariantType.WARNING,
ToastType.ERROR, VariantType.ERROR,
ToastType.NEUTRAL, VariantType.NEUTRAL,
]; ];
useEffect(() => { useEffect(() => {
toast(faker.lorem.sentence({ min: 5, max: 10 }), ToastType.SUCCESS, { toast(faker.lorem.sentence({ min: 5, max: 10 }), VariantType.SUCCESS, {
primaryLabel: "Read more", primaryLabel: "Read more",
primaryOnClick: () => { primaryOnClick: () => {
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
@@ -62,20 +63,20 @@ export const Demo: Story = {
export const Info: Story = { export const Info: Story = {
args: { args: {
type: ToastType.INFO, type: VariantType.INFO,
}, },
}; };
export const InfoWithButton: Story = { export const InfoWithButton: Story = {
args: { args: {
type: ToastType.INFO, type: VariantType.INFO,
primaryLabel: "Primary", primaryLabel: "Primary",
}, },
}; };
export const InfoCustom: Story = { export const InfoCustom: Story = {
args: { args: {
type: ToastType.INFO, type: VariantType.INFO,
actions: ( actions: (
<> <>
<Button color="primary">Primary</Button> <Button color="primary">Primary</Button>
@@ -87,25 +88,25 @@ export const InfoCustom: Story = {
export const Success: Story = { export const Success: Story = {
args: { args: {
type: ToastType.SUCCESS, type: VariantType.SUCCESS,
}, },
}; };
export const Warning: Story = { export const Warning: Story = {
args: { args: {
type: ToastType.WARNING, type: VariantType.WARNING,
}, },
}; };
export const Error: Story = { export const Error: Story = {
args: { args: {
type: ToastType.ERROR, type: VariantType.ERROR,
}, },
}; };
export const Neutral: Story = { export const Neutral: Story = {
args: { args: {
type: ToastType.NEUTRAL, type: VariantType.NEUTRAL,
}, },
}; };

View File

@@ -6,13 +6,12 @@ import React, {
useRef, useRef,
} from "react"; } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { ToastType } from ":/components/Toast/ToastProvider";
import { iconFromType } from ":/components/Alert/Utils";
import { Button, ButtonProps } from ":/components/Button"; import { Button, ButtonProps } from ":/components/Button";
import { iconFromType, VariantType } from ":/utils/VariantUtils";
export interface ToastProps extends PropsWithChildren { export interface ToastProps extends PropsWithChildren {
duration: number; duration: number;
type: ToastType; type: VariantType;
onDelete?: () => void; onDelete?: () => void;
icon?: ReactNode; icon?: ReactNode;
primaryLabel?: string; primaryLabel?: string;

View File

@@ -119,6 +119,18 @@
--c--components--toast--font-weight: var(--c--theme--font--weights--regular); --c--components--toast--font-weight: var(--c--theme--font--weights--regular);
--c--components--toast--icon-size: 19px; --c--components--toast--icon-size: 19px;
--c--components--toast--progress-bar-height: 3px; --c--components--toast--progress-bar-height: 3px;
--c--components--modal--background-color: var(--c--theme--colors--greyscale-000);
--c--components--modal--border-radius: 4px;
--c--components--modal--border-color: var(--c--theme--colors--greyscale-300);
--c--components--modal--box-shadow: 0px 1px 2px 0px #0C1A2B4D;
--c--components--modal--color: var(--c--theme--colors--greyscale-900);
--c--components--modal--title-font-weight: var(--c--theme--font--weights--bold);
--c--components--modal--content-font-size: var(--c--theme--font--sizes--m);
--c--components--modal--content-font-weight: var(--c--theme--font--weights--regular);
--c--components--modal--content-color: var(--c--theme--colors--greyscale-800);
--c--components--modal--width-small: 300px;
--c--components--modal--width-medium: 600px;
--c--components--modal--width-large: 75%;
--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

View File

@@ -143,6 +143,20 @@ $themes: (
'icon-size': 19px, 'icon-size': 19px,
'progress-bar-height': 3px 'progress-bar-height': 3px
), ),
'modal': (
'background-color': #FFFFFF,
'border-radius': 4px,
'border-color': #E7E8EA,
'box-shadow': 0px 1px 2px 0px #0C1A2B4D,
'color': #0C1A2B,
'title-font-weight': 600,
'content-font-size': 0.8125rem,
'content-font-weight': 400,
'content-color': #303C4B,
'width-small': 300px,
'width-medium': 600px,
'width-large': 75%
),
'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

View File

@@ -19,6 +19,7 @@
@use "./components/Forms/Switch"; @use "./components/Forms/Switch";
@use "./components/Forms/DatePicker"; @use "./components/Forms/DatePicker";
@use "./components/Loader"; @use "./components/Loader";
@use "./components/Modal";
@use "./components/Pagination"; @use "./components/Pagination";
@use "./components/Popover"; @use "./components/Popover";
@use "./components/Toast"; @use "./components/Toast";

View File

@@ -17,11 +17,13 @@ export * from "./components/Forms/Select";
export * from "./components/Forms/Switch"; export * from "./components/Forms/Switch";
export * from "./components/Forms/TextArea"; export * from "./components/Forms/TextArea";
export * from "./components/Loader"; export * from "./components/Loader";
export * from "./components/Modal";
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";
export * from "./components/Toast/ToastProvider"; export * from "./components/Toast/ToastProvider";
export * from "./utils/VariantUtils";
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;

View File

@@ -48,6 +48,27 @@
"year_select_button_aria_label": "Select a year", "year_select_button_aria_label": "Select a year",
"month_select_button_aria_label": "Select a month" "month_select_button_aria_label": "Select a month"
} }
},
"modals": {
"helpers": {
"delete_confirmation": {
"title": "Are you sure?",
"children": "Are you sure to delete this item?",
"cancel": "Cancel",
"delete": "Delete"
},
"confirmation": {
"title": "Are you sure?",
"children": "Are you sure you want to do that?",
"cancel": "Cancel",
"yes": "Yes"
},
"disclaimer": {
"title": "Disclaimer",
"children": "This is a disclaimer",
"ok": "Ok"
}
}
} }
} }
} }

View File

@@ -0,0 +1,37 @@
export enum VariantType {
INFO = "info",
SUCCESS = "success",
WARNING = "warning",
ERROR = "error",
NEUTRAL = "neutral",
}
export const colorFromType = (type?: VariantType) => {
switch (type) {
case VariantType.INFO:
return "info";
case VariantType.SUCCESS:
return "success";
case VariantType.WARNING:
return "warning";
case VariantType.ERROR:
return "danger";
default:
return "neutral";
}
};
export const iconFromType = (type?: VariantType) => {
switch (type) {
case VariantType.INFO:
return "info";
case VariantType.SUCCESS:
return "check_circle";
case VariantType.WARNING:
return "error_outline";
case VariantType.ERROR:
return "cancel";
default:
return "";
}
};