✨(react) add Modal
Here it is! Our really wanted Modal component, based on Figma sketches.
This commit is contained in:
5
.changeset/ten-teachers-rescue.md
Normal file
5
.changeset/ten-teachers-rescue.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Modal component
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertType,
|
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
@@ -13,8 +12,8 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
TextArea,
|
TextArea,
|
||||||
ToastType,
|
|
||||||
useToastProvider,
|
useToastProvider,
|
||||||
|
VariantType,
|
||||||
} from "@openfun/cunningham-react";
|
} from "@openfun/cunningham-react";
|
||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import { Character, database, randomDates } from "./Character";
|
import { Character, database, randomDates } from "./Character";
|
||||||
@@ -35,7 +34,7 @@ export const Create = ({ changePage }: PageProps) => {
|
|||||||
database.unshift(character);
|
database.unshift(character);
|
||||||
|
|
||||||
changePage(Page.HOME);
|
changePage(Page.HOME);
|
||||||
toast("Character created successfully", ToastType.SUCCESS);
|
toast("Character created successfully", VariantType.SUCCESS);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +42,7 @@ export const Create = ({ changePage }: PageProps) => {
|
|||||||
<h1>Add a character</h1>
|
<h1>Add a character</h1>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="fw-bold fs-h3">General Information</h3>
|
<h3 className="fw-bold fs-h3">General Information</h3>
|
||||||
<Alert type={AlertType.INFO}>
|
<Alert type={VariantType.INFO}>
|
||||||
You are about to add a new character to the collection
|
You are about to add a new character to the collection
|
||||||
</Alert>
|
</Alert>
|
||||||
<Input
|
<Input
|
||||||
@@ -77,7 +76,7 @@ export const Create = ({ changePage }: PageProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="card mt-l">
|
<div className="card mt-l">
|
||||||
<h3 className="fw-bold fs-h3">Bio</h3>
|
<h3 className="fw-bold fs-h3">Bio</h3>
|
||||||
<Alert type={AlertType.WARNING}>
|
<Alert type={VariantType.WARNING}>
|
||||||
Please be exhaustive, every detail counts!
|
Please be exhaustive, every detail counts!
|
||||||
</Alert>
|
</Alert>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
SortModel,
|
SortModel,
|
||||||
ToastType,
|
VariantType,
|
||||||
usePagination,
|
usePagination,
|
||||||
useToastProvider,
|
useToastProvider,
|
||||||
} from "@openfun/cunningham-react";
|
} from "@openfun/cunningham-react";
|
||||||
@@ -119,7 +119,10 @@ export const Home = ({ changePage }: PageProps) => {
|
|||||||
);
|
);
|
||||||
database.splice(index, 1);
|
database.splice(index, 1);
|
||||||
setRefresh(refresh + 1);
|
setRefresh(refresh + 1);
|
||||||
toast("Character deleted successfully", ToastType.WARNING);
|
toast(
|
||||||
|
"Character deleted successfully",
|
||||||
|
VariantType.WARNING,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
icon={<span className="material-icons">delete</span>}
|
icon={<span className="material-icons">delete</span>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
import { AlertProps, AlertType } from ":/components/Alert/index";
|
import { AlertProps } from ":/components/Alert/index";
|
||||||
import { useCunningham } from ":/components/Provider";
|
import { useCunningham } from ":/components/Provider";
|
||||||
import { ToastType } from ":/components/Toast/ToastProvider";
|
import { iconFromType } from ":/utils/VariantUtils";
|
||||||
|
|
||||||
export const AlertWrapper = (props: AlertProps) => {
|
export const AlertWrapper = (props: AlertProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -22,36 +22,6 @@ export const AlertWrapper = (props: AlertProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const iconFromType = (type?: AlertType | ToastType) => {
|
|
||||||
switch (type) {
|
|
||||||
case AlertType.INFO:
|
|
||||||
return "info";
|
|
||||||
case AlertType.SUCCESS:
|
|
||||||
return "check_circle";
|
|
||||||
case AlertType.WARNING:
|
|
||||||
return "error_outline";
|
|
||||||
case AlertType.ERROR:
|
|
||||||
return "cancel";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const colorFromType = (type?: AlertType | ToastType) => {
|
|
||||||
switch (type) {
|
|
||||||
case AlertType.INFO:
|
|
||||||
return "info";
|
|
||||||
case AlertType.SUCCESS:
|
|
||||||
return "success";
|
|
||||||
case AlertType.WARNING:
|
|
||||||
return "warning";
|
|
||||||
case AlertType.ERROR:
|
|
||||||
return "danger";
|
|
||||||
default:
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AlertIcon = ({ type, ...props }: AlertProps) => {
|
export const AlertIcon = ({ type, ...props }: AlertProps) => {
|
||||||
const icon = useMemo(() => iconFromType(type), [type]);
|
const icon = useMemo(() => iconFromType(type), [type]);
|
||||||
if (props.icon) {
|
if (props.icon) {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Alert, AlertType } from ":/components/Alert/index";
|
import { Alert } from ":/components/Alert/index";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
import { CunninghamProvider } from ":/components/Provider";
|
import { CunninghamProvider } from ":/components/Provider";
|
||||||
|
import { VariantType } from ":/utils/VariantUtils";
|
||||||
|
|
||||||
describe("<Alert/>", () => {
|
describe("<Alert/>", () => {
|
||||||
it.each([
|
it.each([
|
||||||
[AlertType.INFO, "info"],
|
[VariantType.INFO, "info"],
|
||||||
[AlertType.SUCCESS, "check_circle"],
|
[VariantType.SUCCESS, "check_circle"],
|
||||||
[AlertType.WARNING, "error_outline"],
|
[VariantType.WARNING, "error_outline"],
|
||||||
[AlertType.ERROR, "cancel"],
|
[VariantType.ERROR, "cancel"],
|
||||||
[AlertType.NEUTRAL, undefined],
|
[VariantType.NEUTRAL, undefined],
|
||||||
])("renders % alert with according icon", (type, icon) => {
|
])("renders % alert with according icon", (type, icon) => {
|
||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
@@ -28,7 +29,7 @@ describe("<Alert/>", () => {
|
|||||||
it("renders additional information", () => {
|
it("renders additional information", () => {
|
||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert type={AlertType.INFO} additional="Additional information">
|
<Alert type={VariantType.INFO} additional="Additional information">
|
||||||
Alert component
|
Alert component
|
||||||
</Alert>
|
</Alert>
|
||||||
</CunninghamProvider>,
|
</CunninghamProvider>,
|
||||||
@@ -38,7 +39,7 @@ describe("<Alert/>", () => {
|
|||||||
it("renders primary button when primaryLabel is provided", () => {
|
it("renders primary button when primaryLabel is provided", () => {
|
||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert type={AlertType.INFO} primaryLabel="Primary">
|
<Alert type={VariantType.INFO} primaryLabel="Primary">
|
||||||
Alert component
|
Alert component
|
||||||
</Alert>
|
</Alert>
|
||||||
</CunninghamProvider>,
|
</CunninghamProvider>,
|
||||||
@@ -48,7 +49,7 @@ describe("<Alert/>", () => {
|
|||||||
it("renders tertiary button when tertiaryLabel is provided", () => {
|
it("renders tertiary button when tertiaryLabel is provided", () => {
|
||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert type={AlertType.INFO} tertiaryLabel="Tertiary">
|
<Alert type={VariantType.INFO} tertiaryLabel="Tertiary">
|
||||||
Alert component
|
Alert component
|
||||||
</Alert>
|
</Alert>
|
||||||
</CunninghamProvider>,
|
</CunninghamProvider>,
|
||||||
@@ -59,7 +60,7 @@ describe("<Alert/>", () => {
|
|||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert
|
<Alert
|
||||||
type={AlertType.INFO}
|
type={VariantType.INFO}
|
||||||
primaryLabel="Primary"
|
primaryLabel="Primary"
|
||||||
tertiaryLabel="Tertiary"
|
tertiaryLabel="Tertiary"
|
||||||
>
|
>
|
||||||
@@ -74,7 +75,7 @@ describe("<Alert/>", () => {
|
|||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert
|
<Alert
|
||||||
type={AlertType.INFO}
|
type={VariantType.INFO}
|
||||||
buttons={
|
buttons={
|
||||||
<>
|
<>
|
||||||
<Button color="primary">Primary Custom</Button>
|
<Button color="primary">Primary Custom</Button>
|
||||||
@@ -92,7 +93,7 @@ describe("<Alert/>", () => {
|
|||||||
it("can close the alert non controlled", async () => {
|
it("can close the alert non controlled", async () => {
|
||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert type={AlertType.INFO} canClose={true}>
|
<Alert type={VariantType.INFO} canClose={true}>
|
||||||
Alert component
|
Alert component
|
||||||
</Alert>
|
</Alert>
|
||||||
</CunninghamProvider>,
|
</CunninghamProvider>,
|
||||||
@@ -113,7 +114,7 @@ describe("<Alert/>", () => {
|
|||||||
return (
|
return (
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert
|
<Alert
|
||||||
type={AlertType.INFO}
|
type={VariantType.INFO}
|
||||||
canClose={true}
|
canClose={true}
|
||||||
closed={closed}
|
closed={closed}
|
||||||
onClose={(flag) => setClosed(flag)}
|
onClose={(flag) => setClosed(flag)}
|
||||||
@@ -158,7 +159,7 @@ describe("<Alert/>", () => {
|
|||||||
render(
|
render(
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert
|
<Alert
|
||||||
type={AlertType.INFO}
|
type={VariantType.INFO}
|
||||||
additional="Additional information"
|
additional="Additional information"
|
||||||
expandable={true}
|
expandable={true}
|
||||||
>
|
>
|
||||||
@@ -192,7 +193,7 @@ describe("<Alert/>", () => {
|
|||||||
return (
|
return (
|
||||||
<CunninghamProvider>
|
<CunninghamProvider>
|
||||||
<Alert
|
<Alert
|
||||||
type={AlertType.INFO}
|
type={VariantType.INFO}
|
||||||
additional="Additional information"
|
additional="Additional information"
|
||||||
expandable={true}
|
expandable={true}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Meta, StoryObj } from "@storybook/react";
|
import { Meta, StoryObj } from "@storybook/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Alert, AlertProps, AlertType } from ":/components/Alert";
|
import { Alert, AlertProps } from ":/components/Alert";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
|
import { VariantType } from ":/utils/VariantUtils";
|
||||||
|
|
||||||
const meta: Meta<typeof Alert> = {
|
const meta: Meta<typeof Alert> = {
|
||||||
title: "Components/Alert",
|
title: "Components/Alert",
|
||||||
@@ -13,7 +14,7 @@ type Story = StoryObj<typeof Alert>;
|
|||||||
|
|
||||||
export const All: Story = {
|
export const All: Story = {
|
||||||
render: (args) => {
|
render: (args) => {
|
||||||
const customProps: AlertProps = { type: args.type ?? AlertType.INFO };
|
const customProps: AlertProps = { type: args.type ?? VariantType.INFO };
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
|
<div style={{ display: "flex", gap: "1rem", flexDirection: "column" }}>
|
||||||
<Alert {...Info.args} primaryLabel={undefined} {...customProps} />
|
<Alert {...Info.args} primaryLabel={undefined} {...customProps} />
|
||||||
@@ -69,7 +70,7 @@ export const Success: Story = {
|
|||||||
children: "Alert component Success",
|
children: "Alert component Success",
|
||||||
canClose: true,
|
canClose: true,
|
||||||
primaryLabel: "Primary",
|
primaryLabel: "Primary",
|
||||||
type: AlertType.SUCCESS,
|
type: VariantType.SUCCESS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ export const Warning: Story = {
|
|||||||
children: "Alert component Warning",
|
children: "Alert component Warning",
|
||||||
canClose: true,
|
canClose: true,
|
||||||
primaryLabel: "Primary",
|
primaryLabel: "Primary",
|
||||||
type: AlertType.WARNING,
|
type: VariantType.WARNING,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export const Error: Story = {
|
|||||||
children: "Alert component Error",
|
children: "Alert component Error",
|
||||||
canClose: true,
|
canClose: true,
|
||||||
primaryLabel: "Primary",
|
primaryLabel: "Primary",
|
||||||
type: AlertType.ERROR,
|
type: VariantType.ERROR,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ export const Neutral: Story = {
|
|||||||
children: "Alert component Neutral",
|
children: "Alert component Neutral",
|
||||||
canClose: true,
|
canClose: true,
|
||||||
primaryLabel: "Primary",
|
primaryLabel: "Primary",
|
||||||
type: AlertType.NEUTRAL,
|
type: VariantType.NEUTRAL,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ import { useControllableState } from ":/hooks/useControllableState";
|
|||||||
import { AlertAdditionalExpandable } from ":/components/Alert/AlertAdditionalExpandable";
|
import { AlertAdditionalExpandable } from ":/components/Alert/AlertAdditionalExpandable";
|
||||||
import { AlertAdditional } from ":/components/Alert/AlertAdditional";
|
import { AlertAdditional } from ":/components/Alert/AlertAdditional";
|
||||||
import { AlertOneLine } from ":/components/Alert/AlertOneLine";
|
import { AlertOneLine } from ":/components/Alert/AlertOneLine";
|
||||||
|
import { VariantType } from ":/utils/VariantUtils";
|
||||||
export enum AlertType {
|
|
||||||
INFO = "info",
|
|
||||||
SUCCESS = "success",
|
|
||||||
WARNING = "warning",
|
|
||||||
ERROR = "error",
|
|
||||||
NEUTRAL = "neutral",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AlertProps extends PropsWithChildren {
|
export interface AlertProps extends PropsWithChildren {
|
||||||
additional?: React.ReactNode;
|
additional?: React.ReactNode;
|
||||||
@@ -31,7 +24,7 @@ export interface AlertProps extends PropsWithChildren {
|
|||||||
tertiaryLabel?: string;
|
tertiaryLabel?: string;
|
||||||
tertiaryOnClick?: ButtonProps["onClick"];
|
tertiaryOnClick?: ButtonProps["onClick"];
|
||||||
tertiaryProps?: ButtonProps;
|
tertiaryProps?: ButtonProps;
|
||||||
type?: AlertType;
|
type?: VariantType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Alert = (props: AlertProps) => {
|
export const Alert = (props: AlertProps) => {
|
||||||
@@ -42,7 +35,7 @@ export const Alert = (props: AlertProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const propsWithDefault = {
|
const propsWithDefault = {
|
||||||
type: AlertType.INFO,
|
type: VariantType.INFO,
|
||||||
...props,
|
...props,
|
||||||
onClose,
|
onClose,
|
||||||
};
|
};
|
||||||
|
|||||||
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 { PartialNested } from ":/types";
|
||||||
import { Locales } from ":/components/Provider/Locales";
|
import { Locales } from ":/components/Provider/Locales";
|
||||||
import { ToastProvider } from ":/components/Toast/ToastProvider";
|
import { ToastProvider } from ":/components/Toast/ToastProvider";
|
||||||
|
import { ModalProvider } from ":/components/Modal/ModalProvider";
|
||||||
|
|
||||||
type TranslationSet = PartialNested<typeof enUS>;
|
type TranslationSet = PartialNested<typeof enUS>;
|
||||||
|
|
||||||
@@ -101,7 +102,9 @@ export const CunninghamProvider = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CunninghamContext.Provider value={context}>
|
<CunninghamContext.Provider value={context}>
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<ModalProvider>
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</ModalProvider>
|
||||||
</CunninghamContext.Provider>
|
</CunninghamContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { ToastType } from ":/components/Toast/ToastProvider";
|
|
||||||
import { ButtonProps } from ":/components/Button";
|
import { ButtonProps } from ":/components/Button";
|
||||||
|
import { VariantType } from ":/utils/VariantUtils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used for doc purpose only.
|
* This function is used for doc purpose only.
|
||||||
@@ -16,7 +16,7 @@ export const toast = ({
|
|||||||
/** Message displayed inside the toast */
|
/** Message displayed inside the toast */
|
||||||
message: string;
|
message: string;
|
||||||
/** Type of the toast */
|
/** Type of the toast */
|
||||||
type?: ToastType;
|
type?: VariantType;
|
||||||
/** Various options */
|
/** Various options */
|
||||||
options?: {
|
options?: {
|
||||||
duration: number;
|
duration: number;
|
||||||
|
|||||||
@@ -2,19 +2,12 @@ import React, { PropsWithChildren, useContext, useMemo, useRef } from "react";
|
|||||||
import { Toast, ToastProps } from ":/components/Toast/index";
|
import { Toast, ToastProps } from ":/components/Toast/index";
|
||||||
import { tokens } from ":/cunningham-tokens";
|
import { tokens } from ":/cunningham-tokens";
|
||||||
import { Queue } from ":/utils/Queue";
|
import { Queue } from ":/utils/Queue";
|
||||||
|
import { VariantType } from ":/utils/VariantUtils";
|
||||||
export enum ToastType {
|
|
||||||
INFO = "info",
|
|
||||||
SUCCESS = "success",
|
|
||||||
WARNING = "warning",
|
|
||||||
ERROR = "error",
|
|
||||||
NEUTRAL = "neutral",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToastProviderContext {
|
export interface ToastProviderContext {
|
||||||
toast: (
|
toast: (
|
||||||
message: string,
|
message: string,
|
||||||
type?: ToastType,
|
type?: VariantType,
|
||||||
options?: Partial<Omit<ToastInterface, "message" | "type">>,
|
options?: Partial<Omit<ToastInterface, "message" | "type">>,
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
@@ -57,7 +50,7 @@ export const ToastProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
const context: ToastProviderContext = useMemo(
|
const context: ToastProviderContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
toast: (message, type = ToastType.NEUTRAL, options = {}) => {
|
toast: (message, type = VariantType.NEUTRAL, options = {}) => {
|
||||||
// We want to wait for the previous toast to be added ( taking into account the animation )
|
// We want to wait for the previous toast to be added ( taking into account the animation )
|
||||||
// before adding a new one, that's why we use a queue.
|
// before adding a new one, that's why we use a queue.
|
||||||
queue.current?.push(
|
queue.current?.push(
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { within } from "@testing-library/dom";
|
import { within } from "@testing-library/dom";
|
||||||
import { CunninghamProvider } from ":/components/Provider";
|
import { CunninghamProvider } from ":/components/Provider";
|
||||||
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider";
|
import { useToastProvider } from ":/components/Toast/ToastProvider";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
|
import { VariantType } from ":/utils/VariantUtils";
|
||||||
|
|
||||||
describe("<Toast />", () => {
|
describe("<Toast />", () => {
|
||||||
const Wrapper = ({ children }: PropsWithChildren) => {
|
const Wrapper = ({ children }: PropsWithChildren) => {
|
||||||
@@ -22,7 +23,7 @@ describe("<Toast />", () => {
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
toast("Toast content", ToastType.NEUTRAL, { duration: 50 })
|
toast("Toast content", VariantType.NEUTRAL, { duration: 50 })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Create toast
|
Create toast
|
||||||
@@ -54,7 +55,7 @@ describe("<Toast />", () => {
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
toast("Toast content", ToastType.NEUTRAL, {
|
toast("Toast content", VariantType.NEUTRAL, {
|
||||||
primaryLabel: "Action",
|
primaryLabel: "Action",
|
||||||
primaryOnClick: () => {
|
primaryOnClick: () => {
|
||||||
flag = true;
|
flag = true;
|
||||||
@@ -95,7 +96,7 @@ describe("<Toast />", () => {
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
toast("Toast content", ToastType.NEUTRAL, {
|
toast("Toast content", VariantType.NEUTRAL, {
|
||||||
actions: <Button color="tertiary">Tertiary</Button>,
|
actions: <Button color="tertiary">Tertiary</Button>,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,11 +124,11 @@ describe("<Toast />", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[ToastType.INFO, "info"],
|
[VariantType.INFO, "info"],
|
||||||
[ToastType.SUCCESS, "check_circle"],
|
[VariantType.SUCCESS, "check_circle"],
|
||||||
[ToastType.WARNING, "error_outline"],
|
[VariantType.WARNING, "error_outline"],
|
||||||
[ToastType.ERROR, "cancel"],
|
[VariantType.ERROR, "cancel"],
|
||||||
[ToastType.NEUTRAL, undefined],
|
[VariantType.NEUTRAL, undefined],
|
||||||
])("shows a %s toast", async (type, iconName) => {
|
])("shows a %s toast", async (type, iconName) => {
|
||||||
const Inner = () => {
|
const Inner = () => {
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import React, { useEffect } from "react";
|
|||||||
import { faker } from "@faker-js/faker";
|
import { faker } from "@faker-js/faker";
|
||||||
import { ProgressBar, Toast } from ":/components/Toast/index";
|
import { ProgressBar, Toast } from ":/components/Toast/index";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
import { ToastType, useToastProvider } from ":/components/Toast/ToastProvider";
|
import { useToastProvider } from ":/components/Toast/ToastProvider";
|
||||||
|
import { VariantType } from ":/utils/VariantUtils";
|
||||||
|
|
||||||
const meta: Meta<typeof Toast> = {
|
const meta: Meta<typeof Toast> = {
|
||||||
title: "Components/Toast",
|
title: "Components/Toast",
|
||||||
@@ -22,15 +23,15 @@ export const Demo: Story = {
|
|||||||
render: () => {
|
render: () => {
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
const TYPES = [
|
const TYPES = [
|
||||||
ToastType.INFO,
|
VariantType.INFO,
|
||||||
ToastType.SUCCESS,
|
VariantType.SUCCESS,
|
||||||
ToastType.WARNING,
|
VariantType.WARNING,
|
||||||
ToastType.ERROR,
|
VariantType.ERROR,
|
||||||
ToastType.NEUTRAL,
|
VariantType.NEUTRAL,
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toast(faker.lorem.sentence({ min: 5, max: 10 }), ToastType.SUCCESS, {
|
toast(faker.lorem.sentence({ min: 5, max: 10 }), VariantType.SUCCESS, {
|
||||||
primaryLabel: "Read more",
|
primaryLabel: "Read more",
|
||||||
primaryOnClick: () => {
|
primaryOnClick: () => {
|
||||||
// eslint-disable-next-line no-alert
|
// eslint-disable-next-line no-alert
|
||||||
@@ -62,20 +63,20 @@ export const Demo: Story = {
|
|||||||
|
|
||||||
export const Info: Story = {
|
export const Info: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: ToastType.INFO,
|
type: VariantType.INFO,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InfoWithButton: Story = {
|
export const InfoWithButton: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: ToastType.INFO,
|
type: VariantType.INFO,
|
||||||
primaryLabel: "Primary",
|
primaryLabel: "Primary",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InfoCustom: Story = {
|
export const InfoCustom: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: ToastType.INFO,
|
type: VariantType.INFO,
|
||||||
actions: (
|
actions: (
|
||||||
<>
|
<>
|
||||||
<Button color="primary">Primary</Button>
|
<Button color="primary">Primary</Button>
|
||||||
@@ -87,25 +88,25 @@ export const InfoCustom: Story = {
|
|||||||
|
|
||||||
export const Success: Story = {
|
export const Success: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: ToastType.SUCCESS,
|
type: VariantType.SUCCESS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Warning: Story = {
|
export const Warning: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: ToastType.WARNING,
|
type: VariantType.WARNING,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Error: Story = {
|
export const Error: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: ToastType.ERROR,
|
type: VariantType.ERROR,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Neutral: Story = {
|
export const Neutral: Story = {
|
||||||
args: {
|
args: {
|
||||||
type: ToastType.NEUTRAL,
|
type: VariantType.NEUTRAL,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { ToastType } from ":/components/Toast/ToastProvider";
|
|
||||||
import { iconFromType } from ":/components/Alert/Utils";
|
|
||||||
import { Button, ButtonProps } from ":/components/Button";
|
import { Button, ButtonProps } from ":/components/Button";
|
||||||
|
import { iconFromType, VariantType } from ":/utils/VariantUtils";
|
||||||
|
|
||||||
export interface ToastProps extends PropsWithChildren {
|
export interface ToastProps extends PropsWithChildren {
|
||||||
duration: number;
|
duration: number;
|
||||||
type: ToastType;
|
type: VariantType;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
primaryLabel?: string;
|
primaryLabel?: string;
|
||||||
|
|||||||
@@ -119,6 +119,18 @@
|
|||||||
--c--components--toast--font-weight: var(--c--theme--font--weights--regular);
|
--c--components--toast--font-weight: var(--c--theme--font--weights--regular);
|
||||||
--c--components--toast--icon-size: 19px;
|
--c--components--toast--icon-size: 19px;
|
||||||
--c--components--toast--progress-bar-height: 3px;
|
--c--components--toast--progress-bar-height: 3px;
|
||||||
|
--c--components--modal--background-color: var(--c--theme--colors--greyscale-000);
|
||||||
|
--c--components--modal--border-radius: 4px;
|
||||||
|
--c--components--modal--border-color: var(--c--theme--colors--greyscale-300);
|
||||||
|
--c--components--modal--box-shadow: 0px 1px 2px 0px #0C1A2B4D;
|
||||||
|
--c--components--modal--color: var(--c--theme--colors--greyscale-900);
|
||||||
|
--c--components--modal--title-font-weight: var(--c--theme--font--weights--bold);
|
||||||
|
--c--components--modal--content-font-size: var(--c--theme--font--sizes--m);
|
||||||
|
--c--components--modal--content-font-weight: var(--c--theme--font--weights--regular);
|
||||||
|
--c--components--modal--content-color: var(--c--theme--colors--greyscale-800);
|
||||||
|
--c--components--modal--width-small: 300px;
|
||||||
|
--c--components--modal--width-medium: 600px;
|
||||||
|
--c--components--modal--width-large: 75%;
|
||||||
--c--components--forms-textarea--font-weight: var(--c--theme--font--weights--regular);
|
--c--components--forms-textarea--font-weight: var(--c--theme--font--weights--regular);
|
||||||
--c--components--forms-textarea--font-size: var(--c--theme--font--sizes--l);
|
--c--components--forms-textarea--font-size: var(--c--theme--font--sizes--l);
|
||||||
--c--components--forms-textarea--border-radius: 8px;
|
--c--components--forms-textarea--border-radius: 8px;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -143,6 +143,20 @@ $themes: (
|
|||||||
'icon-size': 19px,
|
'icon-size': 19px,
|
||||||
'progress-bar-height': 3px
|
'progress-bar-height': 3px
|
||||||
),
|
),
|
||||||
|
'modal': (
|
||||||
|
'background-color': #FFFFFF,
|
||||||
|
'border-radius': 4px,
|
||||||
|
'border-color': #E7E8EA,
|
||||||
|
'box-shadow': 0px 1px 2px 0px #0C1A2B4D,
|
||||||
|
'color': #0C1A2B,
|
||||||
|
'title-font-weight': 600,
|
||||||
|
'content-font-size': 0.8125rem,
|
||||||
|
'content-font-weight': 400,
|
||||||
|
'content-color': #303C4B,
|
||||||
|
'width-small': 300px,
|
||||||
|
'width-medium': 600px,
|
||||||
|
'width-large': 75%
|
||||||
|
),
|
||||||
'forms-textarea': (
|
'forms-textarea': (
|
||||||
'font-weight': 400,
|
'font-weight': 400,
|
||||||
'font-size': 1rem,
|
'font-size': 1rem,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -19,6 +19,7 @@
|
|||||||
@use "./components/Forms/Switch";
|
@use "./components/Forms/Switch";
|
||||||
@use "./components/Forms/DatePicker";
|
@use "./components/Forms/DatePicker";
|
||||||
@use "./components/Loader";
|
@use "./components/Loader";
|
||||||
|
@use "./components/Modal";
|
||||||
@use "./components/Pagination";
|
@use "./components/Pagination";
|
||||||
@use "./components/Popover";
|
@use "./components/Popover";
|
||||||
@use "./components/Toast";
|
@use "./components/Toast";
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export * from "./components/Forms/Select";
|
|||||||
export * from "./components/Forms/Switch";
|
export * from "./components/Forms/Switch";
|
||||||
export * from "./components/Forms/TextArea";
|
export * from "./components/Forms/TextArea";
|
||||||
export * from "./components/Loader";
|
export * from "./components/Loader";
|
||||||
|
export * from "./components/Modal";
|
||||||
export * from "./components/Pagination";
|
export * from "./components/Pagination";
|
||||||
export * from "./components/Popover";
|
export * from "./components/Popover";
|
||||||
export * from "./components/Provider";
|
export * from "./components/Provider";
|
||||||
export * from "./components/Toast";
|
export * from "./components/Toast";
|
||||||
export * from "./components/Toast/ToastProvider";
|
export * from "./components/Toast/ToastProvider";
|
||||||
|
export * from "./utils/VariantUtils";
|
||||||
|
|
||||||
export type DefaultTokens = PartialNested<typeof tokens.themes.default>;
|
export type DefaultTokens = PartialNested<typeof tokens.themes.default>;
|
||||||
export const defaultTokens = tokens.themes.default;
|
export const defaultTokens = tokens.themes.default;
|
||||||
|
|||||||
@@ -48,6 +48,27 @@
|
|||||||
"year_select_button_aria_label": "Select a year",
|
"year_select_button_aria_label": "Select a year",
|
||||||
"month_select_button_aria_label": "Select a month"
|
"month_select_button_aria_label": "Select a month"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"modals": {
|
||||||
|
"helpers": {
|
||||||
|
"delete_confirmation": {
|
||||||
|
"title": "Are you sure?",
|
||||||
|
"children": "Are you sure to delete this item?",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"confirmation": {
|
||||||
|
"title": "Are you sure?",
|
||||||
|
"children": "Are you sure you want to do that?",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"yes": "Yes"
|
||||||
|
},
|
||||||
|
"disclaimer": {
|
||||||
|
"title": "Disclaimer",
|
||||||
|
"children": "This is a disclaimer",
|
||||||
|
"ok": "Ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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