Skip to content

Commit

Permalink
feat: modal component
Browse files Browse the repository at this point in the history
  • Loading branch information
fran-ink committed Nov 20, 2024
1 parent edda062 commit 13f9cbf
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 0 deletions.
21 changes: 21 additions & 0 deletions src/components/Modal/Layouts/CallToActionModalContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface CallToActionModalContentProps {
title: React.ReactNode;
content: React.ReactNode;
button: React.ReactNode;
}

export const CallToActionModalContent = ({
title,
content,
button,
}: CallToActionModalContentProps) => {
return (
<div className="ink-flex ink-flex-col ink-justify-center ink-items-center ink-gap-5 ink-max-w-sm">
<div className="ink-flex ink-flex-col ink-items-center ink-gap-2">
<div className="ink-text-h4 ink-font-bold">{title}</div>
<div className="ink-text-body-2 ink-text-center">{content}</div>
</div>
{button}
</div>
);
};
4 changes: 4 additions & 0 deletions src/components/Modal/Layouts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
CallToActionModalContent as CallToAction,
type CallToActionModalContentProps as CallToActionProps,
} from "./CallToActionModalContent";
125 changes: 125 additions & 0 deletions src/components/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<ModalProps> = {
title: "Components/Modal",
decorators: [
(Story, { args }) => {
function ModalContent() {
const { isModalOpen, openModal } = useModalContext(args.id);
return (
<div className="ink-p-4">
<Button variant="primary" size="sm" onClick={openModal}>
{isModalOpen ? "Close Modal" : "Open Modal"}
</Button>
<Story />
</div>
);
}

return (
<ModalProvider>
<ModalContent />
</ModalProvider>
);
},
],
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<typeof meta>;

const ModalContent = ({
closeModal,
}: {
closeModal: (success: boolean) => void;
}) => {
return (
<ModalLayout.CallToAction
title="Get started"
content="Keep it simple, keep it actionable, give them a goal and they will come"
button={
<Button
className="ink-w-full"
variant="primary"
size="md"
onClick={() => closeModal(true)}
>
Let's go
</Button>
}
/>
);
};

export const Simple: Story = {
args: {
children: ModalContent,
},
};

export const Nested: Story = {
decorators: [
(Story) => {
return (
<>
<Story />
<Modal id="nested" title="Nested modal" size="md" hasBackdrop>
{({ closeModal }) => (
<ModalLayout.CallToAction
title="A nested modal example"
content="This one uses the backdrop and size='md'"
button={
<Button
variant="primary"
size="md"
onClick={() => closeModal()}
>
Close Nested
</Button>
}
/>
)}
</Modal>
</>
);
},
],
args: {
children: () => {
const { openModal } = useModalContext("nested");
return (
<div>
<Button
className="ink-w-full"
variant="primary"
size="md"
onClick={openModal}
>
Open Nested
</Button>
</div>
);
},
},
};
108 changes: 108 additions & 0 deletions src/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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<TOnCloseProps = boolean> {
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 = <TOnCloseProps,>({
id,
title,
size = "lg",
hasBackdrop,
openOnMount,
onClose,
children,
}: ModalProps<TOnCloseProps>) => {
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 (
<>
<Dialog
open={isModalOpen}
onClose={() => handleClose()}
transition
className="ink-relative ink-font-default"
style={{ zIndex: 5 + modalIndex }}
>
{hasBackdrop && (
<DialogBackdrop
transition
className="ink-fixed ink-inset-0 ink-transition-opacity ink-duration-200 ink-backdrop-blur-lg data-[closed]:ink-opacity-0"
/>
)}
<div
className={classNames(
"ink-fixed ink-inset-0 ink-p-4",
"ink-flex ink-items-center ink-justify-center"
)}
>
<DialogPanel
transition
className={classNames(
"ink-flex ink-flex-col ink-justify-between ink-gap-3 ink-p-3",
"ink-bg-background-light ink-shadow-modal ink-rounded-24",
"ink-duration-200 ink-ease-out data-[closed]:ink-scale-95 data-[closed]:ink-opacity-0",
variantClassNames(size, {
lg: "ink-min-w-[320px] sm:ink-min-w-[640px] ink-min-h-[480px] ink-max-w-4xl",
md: "ink-min-w-[200px] sm:ink-min-w-[300px] ink-min-h-[300px]",
})
)}
>
<DialogTitle
className={classNames(
resetClasses,
"ink-w-full ink-flex ink-items-center ink-justify-between"
)}
>
<div className="ink-font-bold ink-text-h4">{title}</div>
<InkIcon.Close
className="ink-cursor-pointer ink-size-3"
onClick={() => handleClose()}
/>
</DialogTitle>
<div className="ink-flex-1 ink-flex ink-flex-col ink-justify-center ink-items-center">
{children({ closeModal: handleClose })}
</div>
</DialogPanel>
</div>
</Dialog>
</>
);
};
73 changes: 73 additions & 0 deletions src/components/Modal/ModalContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createContext, 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<ModalManagementContextProps>({
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<string[]>([]);

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 (
<ModalManagementContext.Provider value={modalManagementContext}>
{children}
</ModalManagementContext.Provider>
);
};
3 changes: 3 additions & 0 deletions src/components/Modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./ModalContext";
export * from "./Modal";
export * as ModalLayout from "./Layouts";
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./Button";
export * from "./Modal";
export * from "./SegmentedControl";
export * from "./Wallet";
1 change: 1 addition & 0 deletions src/styles/shadow.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
:root {
--ink-box-shadow-menu: 0px 8px 24px -8px #160f1f1a;
--ink-box-shadow-modal: 0px 16px 64px -32px #160f1f1a;
}
2 changes: 2 additions & 0 deletions tailwind.config.mjs
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down Expand Up @@ -92,6 +93,7 @@ const config = {
},
boxShadow: {
menu: "var(--ink-box-shadow-menu)",
modal: "var(--ink-box-shadow-modal)",
},
},
plugins: [],
Expand Down

0 comments on commit 13f9cbf

Please sign in to comment.