✨(react) add Modal
Here it is! Our really wanted Modal component, based on Figma sketches.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import React, { useMemo } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Button } from ":/components/Button";
|
||||
import { AlertProps, AlertType } from ":/components/Alert/index";
|
||||
import { AlertProps } from ":/components/Alert/index";
|
||||
import { useCunningham } from ":/components/Provider";
|
||||
import { ToastType } from ":/components/Toast/ToastProvider";
|
||||
import { iconFromType } from ":/utils/VariantUtils";
|
||||
|
||||
export const AlertWrapper = (props: AlertProps) => {
|
||||
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) => {
|
||||
const icon = useMemo(() => iconFromType(type), [type]);
|
||||
if (props.icon) {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
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 { CunninghamProvider } from ":/components/Provider";
|
||||
import { VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
describe("<Alert/>", () => {
|
||||
it.each([
|
||||
[AlertType.INFO, "info"],
|
||||
[AlertType.SUCCESS, "check_circle"],
|
||||
[AlertType.WARNING, "error_outline"],
|
||||
[AlertType.ERROR, "cancel"],
|
||||
[AlertType.NEUTRAL, undefined],
|
||||
[VariantType.INFO, "info"],
|
||||
[VariantType.SUCCESS, "check_circle"],
|
||||
[VariantType.WARNING, "error_outline"],
|
||||
[VariantType.ERROR, "cancel"],
|
||||
[VariantType.NEUTRAL, undefined],
|
||||
])("renders % alert with according icon", (type, icon) => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
@@ -28,7 +29,7 @@ describe("<Alert/>", () => {
|
||||
it("renders additional information", () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Alert type={AlertType.INFO} additional="Additional information">
|
||||
<Alert type={VariantType.INFO} additional="Additional information">
|
||||
Alert component
|
||||
</Alert>
|
||||
</CunninghamProvider>,
|
||||
@@ -38,7 +39,7 @@ describe("<Alert/>", () => {
|
||||
it("renders primary button when primaryLabel is provided", () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Alert type={AlertType.INFO} primaryLabel="Primary">
|
||||
<Alert type={VariantType.INFO} primaryLabel="Primary">
|
||||
Alert component
|
||||
</Alert>
|
||||
</CunninghamProvider>,
|
||||
@@ -48,7 +49,7 @@ describe("<Alert/>", () => {
|
||||
it("renders tertiary button when tertiaryLabel is provided", () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Alert type={AlertType.INFO} tertiaryLabel="Tertiary">
|
||||
<Alert type={VariantType.INFO} tertiaryLabel="Tertiary">
|
||||
Alert component
|
||||
</Alert>
|
||||
</CunninghamProvider>,
|
||||
@@ -59,7 +60,7 @@ describe("<Alert/>", () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Alert
|
||||
type={AlertType.INFO}
|
||||
type={VariantType.INFO}
|
||||
primaryLabel="Primary"
|
||||
tertiaryLabel="Tertiary"
|
||||
>
|
||||
@@ -74,7 +75,7 @@ describe("<Alert/>", () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Alert
|
||||
type={AlertType.INFO}
|
||||
type={VariantType.INFO}
|
||||
buttons={
|
||||
<>
|
||||
<Button color="primary">Primary Custom</Button>
|
||||
@@ -92,7 +93,7 @@ describe("<Alert/>", () => {
|
||||
it("can close the alert non controlled", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Alert type={AlertType.INFO} canClose={true}>
|
||||
<Alert type={VariantType.INFO} canClose={true}>
|
||||
Alert component
|
||||
</Alert>
|
||||
</CunninghamProvider>,
|
||||
@@ -113,7 +114,7 @@ describe("<Alert/>", () => {
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<Alert
|
||||
type={AlertType.INFO}
|
||||
type={VariantType.INFO}
|
||||
canClose={true}
|
||||
closed={closed}
|
||||
onClose={(flag) => setClosed(flag)}
|
||||
@@ -158,7 +159,7 @@ describe("<Alert/>", () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<Alert
|
||||
type={AlertType.INFO}
|
||||
type={VariantType.INFO}
|
||||
additional="Additional information"
|
||||
expandable={true}
|
||||
>
|
||||
@@ -192,7 +193,7 @@ describe("<Alert/>", () => {
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<Alert
|
||||
type={AlertType.INFO}
|
||||
type={VariantType.INFO}
|
||||
additional="Additional information"
|
||||
expandable={true}
|
||||
expanded={expanded}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Alert, AlertProps, AlertType } from ":/components/Alert";
|
||||
import { Alert, AlertProps } from ":/components/Alert";
|
||||
import { Button } from ":/components/Button";
|
||||
import { VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: "Components/Alert",
|
||||
@@ -13,7 +14,7 @@ type Story = StoryObj<typeof Alert>;
|
||||
|
||||
export const All: Story = {
|
||||
render: (args) => {
|
||||
const customProps: AlertProps = { type: args.type ?? AlertType.INFO };
|
||||
const customProps: AlertProps = { type: args.type ?? VariantType.INFO };
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
|
||||
<Alert {...Info.args} primaryLabel={undefined} {...customProps} />
|
||||
@@ -69,7 +70,7 @@ export const Success: Story = {
|
||||
children: "Alert component Success",
|
||||
canClose: true,
|
||||
primaryLabel: "Primary",
|
||||
type: AlertType.SUCCESS,
|
||||
type: VariantType.SUCCESS,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -78,7 +79,7 @@ export const Warning: Story = {
|
||||
children: "Alert component Warning",
|
||||
canClose: true,
|
||||
primaryLabel: "Primary",
|
||||
type: AlertType.WARNING,
|
||||
type: VariantType.WARNING,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -87,7 +88,7 @@ export const Error: Story = {
|
||||
children: "Alert component Error",
|
||||
canClose: true,
|
||||
primaryLabel: "Primary",
|
||||
type: AlertType.ERROR,
|
||||
type: VariantType.ERROR,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -96,7 +97,7 @@ export const Neutral: Story = {
|
||||
children: "Alert component Neutral",
|
||||
canClose: true,
|
||||
primaryLabel: "Primary",
|
||||
type: AlertType.NEUTRAL,
|
||||
type: VariantType.NEUTRAL,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@ import { useControllableState } from ":/hooks/useControllableState";
|
||||
import { AlertAdditionalExpandable } from ":/components/Alert/AlertAdditionalExpandable";
|
||||
import { AlertAdditional } from ":/components/Alert/AlertAdditional";
|
||||
import { AlertOneLine } from ":/components/Alert/AlertOneLine";
|
||||
|
||||
export enum AlertType {
|
||||
INFO = "info",
|
||||
SUCCESS = "success",
|
||||
WARNING = "warning",
|
||||
ERROR = "error",
|
||||
NEUTRAL = "neutral",
|
||||
}
|
||||
import { VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
export interface AlertProps extends PropsWithChildren {
|
||||
additional?: React.ReactNode;
|
||||
@@ -31,7 +24,7 @@ export interface AlertProps extends PropsWithChildren {
|
||||
tertiaryLabel?: string;
|
||||
tertiaryOnClick?: ButtonProps["onClick"];
|
||||
tertiaryProps?: ButtonProps;
|
||||
type?: AlertType;
|
||||
type?: VariantType;
|
||||
}
|
||||
|
||||
export const Alert = (props: AlertProps) => {
|
||||
@@ -42,7 +35,7 @@ export const Alert = (props: AlertProps) => {
|
||||
);
|
||||
|
||||
const propsWithDefault = {
|
||||
type: AlertType.INFO,
|
||||
type: VariantType.INFO,
|
||||
...props,
|
||||
onClose,
|
||||
};
|
||||
|
||||
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%",
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import * as frFR from ":/locales/fr-FR.json";
|
||||
import { PartialNested } from ":/types";
|
||||
import { Locales } from ":/components/Provider/Locales";
|
||||
import { ToastProvider } from ":/components/Toast/ToastProvider";
|
||||
import { ModalProvider } from ":/components/Modal/ModalProvider";
|
||||
|
||||
type TranslationSet = PartialNested<typeof enUS>;
|
||||
|
||||
@@ -101,7 +102,9 @@ export const CunninghamProvider = ({
|
||||
|
||||
return (
|
||||
<CunninghamContext.Provider value={context}>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
<ModalProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</ModalProvider>
|
||||
</CunninghamContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ToastType } from ":/components/Toast/ToastProvider";
|
||||
import { ButtonProps } from ":/components/Button";
|
||||
import { VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
/**
|
||||
* This function is used for doc purpose only.
|
||||
@@ -16,7 +16,7 @@ export const toast = ({
|
||||
/** Message displayed inside the toast */
|
||||
message: string;
|
||||
/** Type of the toast */
|
||||
type?: ToastType;
|
||||
type?: VariantType;
|
||||
/** Various options */
|
||||
options?: {
|
||||
duration: number;
|
||||
|
||||
@@ -2,19 +2,12 @@ import React, { PropsWithChildren, useContext, useMemo, useRef } from "react";
|
||||
import { Toast, ToastProps } from ":/components/Toast/index";
|
||||
import { tokens } from ":/cunningham-tokens";
|
||||
import { Queue } from ":/utils/Queue";
|
||||
|
||||
export enum ToastType {
|
||||
INFO = "info",
|
||||
SUCCESS = "success",
|
||||
WARNING = "warning",
|
||||
ERROR = "error",
|
||||
NEUTRAL = "neutral",
|
||||
}
|
||||
import { VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
export interface ToastProviderContext {
|
||||
toast: (
|
||||
message: string,
|
||||
type?: ToastType,
|
||||
type?: VariantType,
|
||||
options?: Partial<Omit<ToastInterface, "message" | "type">>,
|
||||
) => void;
|
||||
}
|
||||
@@ -57,7 +50,7 @@ export const ToastProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
const context: ToastProviderContext = useMemo(
|
||||
() => ({
|
||||
toast: (message, type = ToastType.NEUTRAL, options = {}) => {
|
||||
toast: (message, type = VariantType.NEUTRAL, options = {}) => {
|
||||
// We want to wait for the previous toast to be added ( taking into account the animation )
|
||||
// before adding a new one, that's why we use a queue.
|
||||
queue.current?.push(
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { CunninghamProvider } from ":/components/Provider";
|
||||
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider";
|
||||
import { useToastProvider } from ":/components/Toast/ToastProvider";
|
||||
import { Button } from ":/components/Button";
|
||||
import { VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
describe("<Toast />", () => {
|
||||
const Wrapper = ({ children }: PropsWithChildren) => {
|
||||
@@ -22,7 +23,7 @@ describe("<Toast />", () => {
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast("Toast content", ToastType.NEUTRAL, { duration: 50 })
|
||||
toast("Toast content", VariantType.NEUTRAL, { duration: 50 })
|
||||
}
|
||||
>
|
||||
Create toast
|
||||
@@ -54,7 +55,7 @@ describe("<Toast />", () => {
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast("Toast content", ToastType.NEUTRAL, {
|
||||
toast("Toast content", VariantType.NEUTRAL, {
|
||||
primaryLabel: "Action",
|
||||
primaryOnClick: () => {
|
||||
flag = true;
|
||||
@@ -95,7 +96,7 @@ describe("<Toast />", () => {
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
toast("Toast content", ToastType.NEUTRAL, {
|
||||
toast("Toast content", VariantType.NEUTRAL, {
|
||||
actions: <Button color="tertiary">Tertiary</Button>,
|
||||
})
|
||||
}
|
||||
@@ -123,11 +124,11 @@ describe("<Toast />", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ToastType.INFO, "info"],
|
||||
[ToastType.SUCCESS, "check_circle"],
|
||||
[ToastType.WARNING, "error_outline"],
|
||||
[ToastType.ERROR, "cancel"],
|
||||
[ToastType.NEUTRAL, undefined],
|
||||
[VariantType.INFO, "info"],
|
||||
[VariantType.SUCCESS, "check_circle"],
|
||||
[VariantType.WARNING, "error_outline"],
|
||||
[VariantType.ERROR, "cancel"],
|
||||
[VariantType.NEUTRAL, undefined],
|
||||
])("shows a %s toast", async (type, iconName) => {
|
||||
const Inner = () => {
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
@@ -3,7 +3,8 @@ import React, { useEffect } from "react";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { ProgressBar, Toast } from ":/components/Toast/index";
|
||||
import { Button } from ":/components/Button";
|
||||
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider";
|
||||
import { useToastProvider } from ":/components/Toast/ToastProvider";
|
||||
import { VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
const meta: Meta<typeof Toast> = {
|
||||
title: "Components/Toast",
|
||||
@@ -22,15 +23,15 @@ export const Demo: Story = {
|
||||
render: () => {
|
||||
const { toast } = useToastProvider();
|
||||
const TYPES = [
|
||||
ToastType.INFO,
|
||||
ToastType.SUCCESS,
|
||||
ToastType.WARNING,
|
||||
ToastType.ERROR,
|
||||
ToastType.NEUTRAL,
|
||||
VariantType.INFO,
|
||||
VariantType.SUCCESS,
|
||||
VariantType.WARNING,
|
||||
VariantType.ERROR,
|
||||
VariantType.NEUTRAL,
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
toast(faker.lorem.sentence({ min: 5, max: 10 }), ToastType.SUCCESS, {
|
||||
toast(faker.lorem.sentence({ min: 5, max: 10 }), VariantType.SUCCESS, {
|
||||
primaryLabel: "Read more",
|
||||
primaryOnClick: () => {
|
||||
// eslint-disable-next-line no-alert
|
||||
@@ -62,20 +63,20 @@ export const Demo: Story = {
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
type: ToastType.INFO,
|
||||
type: VariantType.INFO,
|
||||
},
|
||||
};
|
||||
|
||||
export const InfoWithButton: Story = {
|
||||
args: {
|
||||
type: ToastType.INFO,
|
||||
type: VariantType.INFO,
|
||||
primaryLabel: "Primary",
|
||||
},
|
||||
};
|
||||
|
||||
export const InfoCustom: Story = {
|
||||
args: {
|
||||
type: ToastType.INFO,
|
||||
type: VariantType.INFO,
|
||||
actions: (
|
||||
<>
|
||||
<Button color="primary">Primary</Button>
|
||||
@@ -87,25 +88,25 @@ export const InfoCustom: Story = {
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
type: ToastType.SUCCESS,
|
||||
type: VariantType.SUCCESS,
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
type: ToastType.WARNING,
|
||||
type: VariantType.WARNING,
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
type: ToastType.ERROR,
|
||||
type: VariantType.ERROR,
|
||||
},
|
||||
};
|
||||
|
||||
export const Neutral: Story = {
|
||||
args: {
|
||||
type: ToastType.NEUTRAL,
|
||||
type: VariantType.NEUTRAL,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,13 +6,12 @@ import React, {
|
||||
useRef,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { ToastType } from ":/components/Toast/ToastProvider";
|
||||
import { iconFromType } from ":/components/Alert/Utils";
|
||||
import { Button, ButtonProps } from ":/components/Button";
|
||||
import { iconFromType, VariantType } from ":/utils/VariantUtils";
|
||||
|
||||
export interface ToastProps extends PropsWithChildren {
|
||||
duration: number;
|
||||
type: ToastType;
|
||||
type: VariantType;
|
||||
onDelete?: () => void;
|
||||
icon?: ReactNode;
|
||||
primaryLabel?: string;
|
||||
|
||||
@@ -119,6 +119,18 @@
|
||||
--c--components--toast--font-weight: var(--c--theme--font--weights--regular);
|
||||
--c--components--toast--icon-size: 19px;
|
||||
--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-size: var(--c--theme--font--sizes--l);
|
||||
--c--components--forms-textarea--border-radius: 8px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -143,6 +143,20 @@ $themes: (
|
||||
'icon-size': 19px,
|
||||
'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': (
|
||||
'font-weight': 400,
|
||||
'font-size': 1rem,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -19,6 +19,7 @@
|
||||
@use "./components/Forms/Switch";
|
||||
@use "./components/Forms/DatePicker";
|
||||
@use "./components/Loader";
|
||||
@use "./components/Modal";
|
||||
@use "./components/Pagination";
|
||||
@use "./components/Popover";
|
||||
@use "./components/Toast";
|
||||
|
||||
@@ -17,11 +17,13 @@ export * from "./components/Forms/Select";
|
||||
export * from "./components/Forms/Switch";
|
||||
export * from "./components/Forms/TextArea";
|
||||
export * from "./components/Loader";
|
||||
export * from "./components/Modal";
|
||||
export * from "./components/Pagination";
|
||||
export * from "./components/Popover";
|
||||
export * from "./components/Provider";
|
||||
export * from "./components/Toast";
|
||||
export * from "./components/Toast/ToastProvider";
|
||||
export * from "./utils/VariantUtils";
|
||||
|
||||
export type DefaultTokens = PartialNested<typeof tokens.themes.default>;
|
||||
export const defaultTokens = tokens.themes.default;
|
||||
|
||||
@@ -48,6 +48,27 @@
|
||||
"year_select_button_aria_label": "Select a year",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
packages/react/src/utils/VariantUtils.ts
Normal file
37
packages/react/src/utils/VariantUtils.ts
Normal 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 "";
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user