diff --git a/src/components/Modal/Layouts/CallToAction.tsx b/src/components/Modal/Layouts/CallToAction.tsx new file mode 100644 index 0000000..8aa63ad --- /dev/null +++ b/src/components/Modal/Layouts/CallToAction.tsx @@ -0,0 +1,21 @@ +export interface CallToActionModalContentProps { + title: React.ReactNode; + content: React.ReactNode; + button: React.ReactNode; +} + +export const CallToActionModalContent = ({ + title, + content, + button, +}: CallToActionModalContentProps) => { + return ( +
+
+
{title}
+
{content}
+
+ {button} +
+ ); +}; diff --git a/src/components/Modal/Layouts/index.ts b/src/components/Modal/Layouts/index.ts new file mode 100644 index 0000000..29a6138 --- /dev/null +++ b/src/components/Modal/Layouts/index.ts @@ -0,0 +1,4 @@ +export { + CallToActionModalContent as CallToAction, + type CallToActionModalContentProps as CallToActionProps, +} from "./CallToAction"; diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx new file mode 100644 index 0000000..bd685a4 --- /dev/null +++ b/src/components/Modal/Modal.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Modal, ModalProps } from "./Modal"; +import { Button } from "../Button"; +import { ModalProvider } from "./ModalContext"; +import { useModalContext } from "./ModalContext"; +import { fn } from "@storybook/test"; +import { ModalLayout } from "."; + +const meta: Meta = { + title: "Components/Modal", + decorators: [ + (Story, { args }) => { + function ModalContent() { + const { isModalOpen, openModal } = useModalContext(args.id); + return ( +
+ + +
+ ); + } + + return ( + + + + ); + }, + ], + component: Modal, + parameters: { + backgrounds: { + default: "container", + }, + }, + tags: ["autodocs"], + argTypes: {}, + args: { + id: "modal", + title: "Example modal", + hasBackdrop: false, + onClose: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +const ModalContent = ({ + closeModal, +}: { + closeModal: (success: boolean) => void; +}) => { + return ( + closeModal(true)} + > + Let's go + + } + /> + ); +}; + +export const Simple: Story = { + args: { + children: ModalContent, + }, +}; + +export const Nested: Story = { + decorators: [ + (Story) => { + return ( + <> + + + {({ closeModal }) => ( + closeModal()} + > + Close Nested + + } + /> + )} + + + ); + }, + ], + args: { + children: () => { + const { openModal } = useModalContext("nested"); + return ( +
+ +
+ ); + }, + }, +}; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..7e10e4b --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,108 @@ +import { + Dialog, + DialogBackdrop, + DialogPanel, + DialogTitle, +} from "@headlessui/react"; +import { useModalContext } from "./ModalContext"; +import { + classNames, + resetClasses, + variantClassNames, +} from "../../util/classes"; +import { InkIcon } from "../.."; +import { useEffect, useRef } from "react"; + +export interface ModalProps { + id: string; + title?: string; + size?: "lg" | "md"; + hasBackdrop?: boolean; + openOnMount?: boolean; + onClose?: (props?: TOnCloseProps) => void; + children: ({ + closeModal, + }: { + closeModal: (props?: TOnCloseProps) => void; + }) => React.ReactNode; +} + +export const Modal = ({ + id, + title, + size = "lg", + hasBackdrop, + openOnMount, + onClose, + children, +}: ModalProps) => { + const { isModalOpen, closeModal, modalIndex, openModal } = + useModalContext(id); + + const wasOpenedOnMount = useRef(false); + useEffect(() => { + if (openOnMount && !wasOpenedOnMount.current) { + openModal(); + wasOpenedOnMount.current = true; + } + }, [openModal, openOnMount]); + + const handleClose = (props?: TOnCloseProps) => { + closeModal(); + onClose?.(props); + }; + + return ( + <> + handleClose()} + transition + className="ink-relative ink-font-default" + style={{ zIndex: 5 + modalIndex }} + > + {hasBackdrop && ( + + )} +
+ + +
{title}
+ handleClose()} + /> +
+
+ {children({ closeModal: handleClose })} +
+
+
+
+ + ); +}; diff --git a/src/components/Modal/ModalContext.tsx b/src/components/Modal/ModalContext.tsx new file mode 100644 index 0000000..364533b --- /dev/null +++ b/src/components/Modal/ModalContext.tsx @@ -0,0 +1,79 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +export interface ModalManagementContextProps { + openModals: string[]; + openModal: (id: string) => void; + closeModal: (id: string) => void; + isModalOpen: (id: string) => boolean; + closeAllModals: () => void; + getModalIndex: (id: string) => number; +} + +export const ModalManagementContext = + createContext({ + openModals: [], + openModal: () => {}, + closeModal: () => {}, + isModalOpen: () => false, + closeAllModals: () => {}, + getModalIndex: () => 0, + }); + +export interface ModalContextProps { + openModal: () => void; + closeModal: () => void; + isModalOpen: boolean; + modalIndex: number; +} + +export const useModalContext = (id: string): ModalContextProps => { + const { openModals, openModal, closeModal, isModalOpen, getModalIndex } = + useContext(ModalManagementContext); + return useMemo( + () => ({ + openModal: () => openModal(id), + closeModal: () => closeModal(id), + isModalOpen: isModalOpen(id), + modalIndex: getModalIndex(id), + }), + [id, openModals] + ); +}; + +export const useModalManagementContext = (): ModalManagementContextProps => { + return useContext(ModalManagementContext); +}; + +export const ModalProvider = ({ children }: { children: React.ReactNode }) => { + const [openModals, setOpenModals] = useState([]); + + const modalManagementContext: ModalManagementContextProps = useMemo( + () => ({ + openModals, + openModal: (id: string) => { + setOpenModals((prev) => (prev.includes(id) ? prev : [...prev, id])); + }, + closeModal: (id: string) => { + setOpenModals((prev) => prev.filter((modalId) => modalId !== id)); + }, + isModalOpen: (id: string) => openModals.includes(id), + closeAllModals: () => { + setOpenModals([]); + }, + getModalIndex: (id: string) => openModals.indexOf(id), + }), + [openModals, setOpenModals] + ); + + return ( + + {children} + + ); +}; diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts new file mode 100644 index 0000000..730b8bd --- /dev/null +++ b/src/components/Modal/index.ts @@ -0,0 +1,3 @@ +export * from "./ModalContext"; +export * from "./Modal"; +export * as ModalLayout from "./Layouts"; diff --git a/src/components/index.ts b/src/components/index.ts index 7331bcc..ba153d8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ export * from "./Button"; +export * from "./Modal"; export * from "./SegmentedControl"; export * from "./Wallet"; diff --git a/src/styles/shadow.css b/src/styles/shadow.css index adeb0a7..4939815 100644 --- a/src/styles/shadow.css +++ b/src/styles/shadow.css @@ -1,3 +1,4 @@ :root { --ink-box-shadow-menu: 0px 8px 24px -8px #160f1f1a; + --ink-box-shadow-modal: 0px 16px 64px -32px #160f1f1a; } diff --git a/tailwind.config.mjs b/tailwind.config.mjs index ee4d30f..4c7734f 100644 --- a/tailwind.config.mjs +++ b/tailwind.config.mjs @@ -1,4 +1,5 @@ export const spacing = { + 0: "0px", 0.5: "var(--ink-spacing-4)", 1: "var(--ink-spacing-8)", 1.5: "var(--ink-spacing-12)", @@ -92,6 +93,7 @@ const config = { }, boxShadow: { menu: "var(--ink-box-shadow-menu)", + modal: "var(--ink-box-shadow-modal)", }, }, plugins: [],