✨(react) add Modal
Here it is! Our really wanted Modal component, based on Figma sketches.
This commit is contained in:
39
packages/react/src/components/Modal/ConfirmationModal.tsx
Normal file
39
packages/react/src/components/Modal/ConfirmationModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
47
packages/react/src/components/Modal/MessageModal.tsx
Normal file
47
packages/react/src/components/Modal/MessageModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
159
packages/react/src/components/Modal/ModalProvider.tsx
Normal file
159
packages/react/src/components/Modal/ModalProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
89
packages/react/src/components/Modal/PreBuilt.stories.tsx
Normal file
89
packages/react/src/components/Modal/PreBuilt.stories.tsx
Normal 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),
|
||||
};
|
||||
266
packages/react/src/components/Modal/index.mdx
Normal file
266
packages/react/src/components/Modal/index.mdx
Normal 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 |
|
||||
139
packages/react/src/components/Modal/index.scss
Normal file
139
packages/react/src/components/Modal/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
419
packages/react/src/components/Modal/index.spec.tsx
Normal file
419
packages/react/src/components/Modal/index.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
170
packages/react/src/components/Modal/index.stories.tsx
Normal file
170
packages/react/src/components/Modal/index.stories.tsx
Normal 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),
|
||||
},
|
||||
};
|
||||
165
packages/react/src/components/Modal/index.tsx
Normal file
165
packages/react/src/components/Modal/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
packages/react/src/components/Modal/tokens.ts
Normal file
19
packages/react/src/components/Modal/tokens.ts
Normal 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%",
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user