diff --git a/packages/frontend/src/components/shared/Modal/Modal.theme.ts b/packages/frontend/src/components/shared/Modal/Modal.theme.ts new file mode 100644 index 00000000..81dec838 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/Modal.theme.ts @@ -0,0 +1,67 @@ +import type { VariantProps } from 'tailwind-variants'; +import { tv } from 'tailwind-variants'; + +export const modalTheme = tv({ + slots: { + overlay: [ + 'z-modal', + 'fixed', + 'inset-0', + 'bg-bg-base/90', + 'backdrop-blur-sm', + 'overflow-y-auto', + 'flex', + 'justify-center', + 'items-center', + 'p-6', + 'sm:p-10', + 'data-[state=closed]:animate-[dialog-overlay-hide_200ms]', + 'data-[state=open]:animate-[dialog-overlay-show_200ms]', + 'data-[state=closed]:hidden', // Fix overlay not close when modal is closed + ], + close: [ + 'absolute', + 'right-6', + 'top-5', + 'sm:right-10', + 'sm:top-[38px]', + 'z-[1]', + ], + header: ['flex', 'flex-col', 'gap-2', 'items-start', 'px-6', 'sm:px-10'], + headerTitle: ['text-lg', 'sm:text-xl', 'text-text-em-high'], + headerDescription: ['text-sm', 'text-text-em-low'], + footer: ['flex', 'justify-end', 'gap-3', 'sm:gap-4', 'px-6', 'sm:px-10'], + content: [ + 'h-fit', + 'sm:min-h-0', + 'sm:m-auto', + 'relative', + 'flex', + 'flex-col', + 'gap-y-8', + 'py-6', + 'sm:py-10', + 'overflow-hidden', + 'w-full', + 'max-w-[560px]', + 'rounded-xl', + 'bg-surface-base', + 'border', + 'border-border-base', + 'text-text-em-high', + ], + body: ['flex-1', 'px-6', 'sm:px-10'], + }, + variants: { + fullPage: { + true: { + content: ['h-full'], + overlay: ['!p-0'], + }, + }, + }, + defaultVariants: { + fullPage: false, + }, +}); +export type ModalVariants = VariantProps; diff --git a/packages/frontend/src/components/shared/Modal/Modal.tsx b/packages/frontend/src/components/shared/Modal/Modal.tsx new file mode 100644 index 00000000..ef9069f4 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/Modal.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import type { DialogProps } from '@radix-ui/react-dialog'; +import { Root, Trigger } from '@radix-ui/react-dialog'; +import type { ComponentPropsWithoutRef, PropsWithChildren } from 'react'; + +import type { ModalVariants } from './Modal.theme'; +import { ModalBody } from './ModalBody'; +import { ModalContent } from './ModalContent'; +import { ModalFooter } from './ModalFooter'; +import { ModalHeader } from './ModalHeader'; +import ModalProvider from './ModalProvider'; + +export interface ModalProps + extends ComponentPropsWithoutRef<'div'>, + ModalVariants, + DialogProps { + hasCloseButton?: boolean; + hasOverlay?: boolean; + preventClickOutsideToClose?: boolean; +} +export const Modal = ({ + children, + hasCloseButton = true, + hasOverlay = true, + preventClickOutsideToClose = false, + fullPage = false, + ...props +}: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +Modal.Trigger = Trigger; +Modal.Content = ModalContent; +Modal.Header = ModalHeader; +Modal.Footer = ModalFooter; +Modal.Body = ModalBody; diff --git a/packages/frontend/src/components/shared/Modal/ModalBody/ModalBody.tsx b/packages/frontend/src/components/shared/Modal/ModalBody/ModalBody.tsx new file mode 100644 index 00000000..3079b267 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalBody/ModalBody.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import type { ComponentPropsWithoutRef, PropsWithChildren } from 'react'; +import { modalTheme } from 'components/shared/Modal/Modal.theme'; + +export interface ModalBodyProps extends ComponentPropsWithoutRef<'div'> { + className?: string; +} + +export const ModalBody = ({ + children, + className, + ...props +}: PropsWithChildren) => { + const { body } = modalTheme(); + + return ( +
+ {children} +
+ ); +}; diff --git a/packages/frontend/src/components/shared/Modal/ModalBody/index.ts b/packages/frontend/src/components/shared/Modal/ModalBody/index.ts new file mode 100644 index 00000000..ad32ff0c --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalBody/index.ts @@ -0,0 +1 @@ +export * from './ModalBody'; diff --git a/packages/frontend/src/components/shared/Modal/ModalContent/ModalContent.tsx b/packages/frontend/src/components/shared/Modal/ModalContent/ModalContent.tsx new file mode 100644 index 00000000..166256a8 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalContent/ModalContent.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import type { DialogContentProps } from '@radix-ui/react-dialog'; +import { Close, Content, Overlay, Portal } from '@radix-ui/react-dialog'; +import { Ref, forwardRef, type PropsWithChildren } from 'react'; +import { useModal } from 'components/shared/Modal/ModalProvider'; +import { modalTheme } from 'components/shared/Modal/Modal.theme'; +import { Button } from 'components/shared/Button'; +import { CrossIcon } from 'components/shared/CustomIcon'; + +type PointerDownOutsideEvent = CustomEvent<{ + originalEvent: PointerEvent; +}>; + +export interface ModalContentProps extends DialogContentProps { + className?: string; +} + +const ModalContent = forwardRef( + ( + { children, className, ...props }: PropsWithChildren, + forwardedRef, + ) => { + const { hasCloseButton, preventClickOutsideToClose, fullPage } = useModal(); + + const { content, close, overlay } = modalTheme({ fullPage }); + + const preventClickOutsideToCloseProps = preventClickOutsideToClose && { + onPointerDownOutside: (e: PointerDownOutsideEvent) => e.preventDefault(), + onEscapeKeyDown: (e: KeyboardEvent) => e.preventDefault(), + }; + + return ( + + + } + > + {hasCloseButton && ( + + + + )} + {children} + + + + ); + }, +); + +ModalContent.displayName = 'ModalContent'; + +export { ModalContent }; diff --git a/packages/frontend/src/components/shared/Modal/ModalContent/index.ts b/packages/frontend/src/components/shared/Modal/ModalContent/index.ts new file mode 100644 index 00000000..79dee456 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalContent/index.ts @@ -0,0 +1 @@ +export * from './ModalContent'; diff --git a/packages/frontend/src/components/shared/Modal/ModalFooter/ModalFooter.tsx b/packages/frontend/src/components/shared/Modal/ModalFooter/ModalFooter.tsx new file mode 100644 index 00000000..5152e884 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalFooter/ModalFooter.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import type { ComponentPropsWithoutRef, PropsWithChildren } from 'react'; +import { modalTheme } from 'components/shared/Modal/Modal.theme'; + +type ModalFooterProps = ComponentPropsWithoutRef<'div'> & { + className?: string; +}; + +export const ModalFooter = ({ + children, + className, + ...props +}: PropsWithChildren) => { + const { footer } = modalTheme({ + className, + }); + + return ( +
+ {children} +
+ ); +}; diff --git a/packages/frontend/src/components/shared/Modal/ModalFooter/index.ts b/packages/frontend/src/components/shared/Modal/ModalFooter/index.ts new file mode 100644 index 00000000..2da6856b --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalFooter/index.ts @@ -0,0 +1 @@ +export * from './ModalFooter'; diff --git a/packages/frontend/src/components/shared/Modal/ModalHeader/ModalHeader.tsx b/packages/frontend/src/components/shared/Modal/ModalHeader/ModalHeader.tsx new file mode 100644 index 00000000..ca588db9 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalHeader/ModalHeader.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import type { DialogDescriptionProps } from '@radix-ui/react-dialog'; +import { Description, Title } from '@radix-ui/react-dialog'; +import type { ComponentPropsWithoutRef, PropsWithChildren } from 'react'; +import { Heading } from 'components/shared/Heading'; +import { modalTheme } from 'components/shared/Modal/Modal.theme'; + +type ModalHeaderProps = ComponentPropsWithoutRef<'div'> & { + className?: string; + description?: string | React.ReactNode; + descriptionProps?: DialogDescriptionProps; + headingProps?: ComponentPropsWithoutRef<'h2'>; +}; + +export const ModalHeader = ({ + children, + description, + className, + descriptionProps, + headingProps, + ...props +}: PropsWithChildren) => { + const { header, headerDescription, headerTitle } = modalTheme(); + + return ( +
+ + <Heading + {...headingProps} + className={headerTitle({ className: headingProps?.className })} + > + {children} + </Heading> + + {description && ( + + {description} + + )} +
+ ); +}; diff --git a/packages/frontend/src/components/shared/Modal/ModalHeader/index.ts b/packages/frontend/src/components/shared/Modal/ModalHeader/index.ts new file mode 100644 index 00000000..4424e620 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalHeader/index.ts @@ -0,0 +1 @@ +export * from './ModalHeader'; diff --git a/packages/frontend/src/components/shared/Modal/ModalProvider.tsx b/packages/frontend/src/components/shared/Modal/ModalProvider.tsx new file mode 100644 index 00000000..2be6bebd --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/ModalProvider.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type { PropsWithChildren } from 'react'; +import { createContext, useContext } from 'react'; + +import type { ModalProps } from './Modal'; +import type { ModalVariants } from './Modal.theme'; + +export interface ModalProviderProps + extends Partial, + ModalProps {} + +type ModalProviderContext = ReturnType; + +const ModalContext = createContext | undefined>( + undefined, +); + +// For inferring return type +const useModalValues = (props: ModalProviderProps) => { + return props; +}; + +export const ModalProvider = ({ + children, + ...props +}: PropsWithChildren): JSX.Element => { + const values = useModalValues(props); + + return ( + {children} + ); +}; + +export const useModal = () => { + const context = useContext(ModalContext); + if (context === undefined) { + throw new Error('useModal was used outside of its Provider'); + } + return context; +}; + +export default ModalProvider; diff --git a/packages/frontend/src/components/shared/Modal/index.ts b/packages/frontend/src/components/shared/Modal/index.ts new file mode 100644 index 00000000..cb89ee17 --- /dev/null +++ b/packages/frontend/src/components/shared/Modal/index.ts @@ -0,0 +1 @@ +export * from './Modal';