diff --git a/src/components/hocs/Portal/Portal.stories.mdx b/src/components/hocs/Portal/Portal.stories.mdx new file mode 100644 index 0000000..e975424 --- /dev/null +++ b/src/components/hocs/Portal/Portal.stories.mdx @@ -0,0 +1,15 @@ +import { Meta } from '@storybook/addon-docs' + + + +# Poralt + +This component renders the children in a root div after all app nodes. see [the docs](https://reactjs.org/docs/portals.html) for more information. + +This component depends on ThemeProvider to run correctly. + +## Usage + +```tsx +Your components to render in a React Portal here... +``` diff --git a/src/components/hocs/Portal/Portal.tsx b/src/components/hocs/Portal/Portal.tsx new file mode 100644 index 0000000..18adc1c --- /dev/null +++ b/src/components/hocs/Portal/Portal.tsx @@ -0,0 +1,37 @@ +/* eslint-disable react/jsx-no-useless-fragment */ + +import React, { useEffect, useState, memo, PropsWithChildren } from 'react' +import { createPortal } from 'react-dom' + +type PortalProps = { + enabled?: boolean +} + +const PortalBase = ({ children, enabled }: PropsWithChildren) => { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + + return () => setMounted(false) + }, []) + + if (!enabled) { + return <>{children}> + } + + return mounted + ? createPortal( + children, + document.querySelector('#root-design-system-portals') as Element + ) + : null +} + +PortalBase.defaultProps = { + enabled: true, +} + +const Portal = memo(PortalBase) + +export { Portal } diff --git a/src/components/hocs/Portal/index.ts b/src/components/hocs/Portal/index.ts new file mode 100644 index 0000000..737680d --- /dev/null +++ b/src/components/hocs/Portal/index.ts @@ -0,0 +1 @@ +export * from './Portal' diff --git a/src/components/hocs/index.ts b/src/components/hocs/index.ts new file mode 100644 index 0000000..737680d --- /dev/null +++ b/src/components/hocs/index.ts @@ -0,0 +1 @@ +export * from './Portal' diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..e5ce4b5 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,3 @@ +export * from './atoms' +export * from './hocs' +export * from './molecules' diff --git a/src/components/molecules/Modal/Modal.stories.mdx b/src/components/molecules/Modal/Modal.stories.mdx new file mode 100644 index 0000000..3358ecb --- /dev/null +++ b/src/components/molecules/Modal/Modal.stories.mdx @@ -0,0 +1,61 @@ +import { Meta } from '@storybook/addon-docs' + +import { Canvas } from 'core/storybookWrappers' + +import ModalStory from './ModalStory' + + + +# Modal + +This is a Modal component with xstyled super powers. + +## Props & Children + +Obs.: Types used in this Doc: + +```ts +import React, { ReactNode, Dispatch, SetStateAction } from 'react' + +type ModalFunctionNotation = { + isOpen: boolean + setIsOpen: Dispatch> +} +``` + +- render: (params: ModalFunctionNotation) => ReactNode + + A function that rentuns the components to render inside Modal. + +- externalIsOpen?: boolean + + A boolean that indicates if the Modal is open or not. When used, internal state is ignored, and setIsOpen does nothing. + +- children?: (params: ModalFunctionNotation) => ReactNode | null + + A function that returns the components to render in the place of your JSX that you putted the Modal component. + +## Usage + +```tsx + ( + + setIsOpen(false)}> + close + + I'm a Modal and i'm {isOpen ? 'opened' : 'closed'} + + )} +> + {({ setIsOpen }) => ( + setIsOpen(true)}> + Open + + )} + +``` + + + // The source code is above + diff --git a/src/components/molecules/Modal/Modal.tsx b/src/components/molecules/Modal/Modal.tsx new file mode 100644 index 0000000..8fbff22 --- /dev/null +++ b/src/components/molecules/Modal/Modal.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import React, { + useRef, + useState, + useCallback, + Dispatch, + SetStateAction, + memo, + RefObject, + ReactNode, +} from 'react' + +import { + ModalPortalDedupleContext, + ModalPortalDedupleProvider, +} from 'contexts/ModalPortalDedupleContext' +import { motion } from 'framer-motion' + +import { FlexProps } from 'components/atoms' +import { Portal } from 'components/hocs/Portal' +import { CardProps } from 'components/molecules' + +import { useOnClickOutside } from 'hooks' + +import { ModalBackground, ModalCard } from './styled' + +type ModalFunctionNotation = { + isOpen: boolean + setIsOpen: Dispatch> + excludeRef: RefObject +} + +export type ModalProps = { + render: (params: ModalFunctionNotation) => ReactNode + children?: (params: ModalFunctionNotation) => ReactNode + externalIsOpen?: boolean + backgroundProps?: FlexProps + disableUseOnClickOutside?: boolean +} & Omit + +const motionBackgroundVariants = { + hidden: { + opacity: 0, + transitionEnd: { + display: 'none', + }, + }, + visible: { + opacity: 1, + display: 'block', + }, +} + +const ModalBase = ({ + render, + externalIsOpen, + children, + backgroundProps, + disableUseOnClickOutside, + ...props +}: ModalProps) => { + const [isOpen, setIsOpen] = useState(false) + + const modalIsOpen = typeof externalIsOpen === 'boolean' ? externalIsOpen : isOpen + + const modalMainRef = useRef(null) + + const excludeRef = useRef(null) + + const hasParentModal = ModalPortalDedupleContext.useSelector( + state => state.parentModalAlreadyExistsUsingPortal + ) + + const clickOutsideHandler = useCallback(() => { + if (disableUseOnClickOutside) { + return + } + + setIsOpen(false) + }, [disableUseOnClickOutside]) + + useOnClickOutside(modalMainRef, clickOutsideHandler, excludeRef) + + return ( + <> + {children && children({ isOpen: modalIsOpen, setIsOpen, excludeRef })} + + + + + + + {render({ isOpen: modalIsOpen, setIsOpen, excludeRef })} + + + + + + > + ) +} + +ModalBase.defaultProps = { + children: undefined, + externalIsOpen: undefined, + backgroundProps: undefined, + disableUseOnClickOutside: false, +} + +const Modal = memo(ModalBase) + +export { Modal } diff --git a/src/components/molecules/Modal/ModalStory.tsx b/src/components/molecules/Modal/ModalStory.tsx new file mode 100644 index 0000000..67b3c5d --- /dev/null +++ b/src/components/molecules/Modal/ModalStory.tsx @@ -0,0 +1,38 @@ +import React from 'react' + +import { Modal } from './Modal' + +const ModalStory = () => ( + + ( + + setIsOpen(false)}> + close + + I'm a Modal and i'm {isOpen ? 'opened' : 'closed'} + + )} + > + {({ setIsOpen }) => ( + + + + + + + setIsOpen(true)}> + Open + + + + + + + + )} + + +) + +export default ModalStory diff --git a/src/components/molecules/Modal/index.ts b/src/components/molecules/Modal/index.ts new file mode 100644 index 0000000..8d3bcd7 --- /dev/null +++ b/src/components/molecules/Modal/index.ts @@ -0,0 +1 @@ +export * from './Modal' diff --git a/src/components/molecules/Modal/styled.ts b/src/components/molecules/Modal/styled.ts new file mode 100644 index 0000000..aafd586 --- /dev/null +++ b/src/components/molecules/Modal/styled.ts @@ -0,0 +1,21 @@ +import styled from '@xstyled/styled-components' + +import { Flex } from 'components/atoms/Flex' +import { Card } from 'components/molecules/Card' + +export const ModalBackground = styled(Flex)` + position: absolute; + + top: 0; + bottom: 0; + left: 0; + right: 0; + + background-color: systemTranslucidBlack; +` + +export const ModalCard = styled(Card)` + width: fit-content; + + max-width: 80vw; +` diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts new file mode 100644 index 0000000..b466dc7 --- /dev/null +++ b/src/components/molecules/index.ts @@ -0,0 +1,5 @@ +export * from './Fields' +export * from './InfiniteBarLoader' +export * from './Card' +export * from './Container' +export * from './Modal' diff --git a/src/contexts/ModalPortalDedupleContext.tsx b/src/contexts/ModalPortalDedupleContext.tsx new file mode 100644 index 0000000..9079085 --- /dev/null +++ b/src/contexts/ModalPortalDedupleContext.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import { createContext } from '@nexpy/react-easy-context-api' + +import { WithChildren } from 'types' + +type ModalPortalDedupleContextValue = { + parentModalAlreadyExistsUsingPortal: boolean +} + +export const ModalPortalDedupleContext = createContext({ + parentModalAlreadyExistsUsingPortal: false, +}) + +export const ModalPortalDedupleProvider = ({ children }: WithChildren) => ( + + {children} + +) diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..41115ce --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useOnClickOutside' diff --git a/src/hooks/useOnClickOutside.ts b/src/hooks/useOnClickOutside.ts new file mode 100644 index 0000000..1f0b817 --- /dev/null +++ b/src/hooks/useOnClickOutside.ts @@ -0,0 +1,36 @@ +import { useEffect, RefObject } from 'react' + +type Event = MouseEvent | TouchEvent + +export const useOnClickOutside = ( + ref: RefObject, + handler: (event: Event) => void, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + excludeRef?: RefObject +) => { + useEffect(() => { + const listener = (event: Event) => { + const el = ref?.current + if (!el || el.contains((event?.target as Node) || null)) { + return + } + + const elex = excludeRef?.current + if (elex) { + if (elex.contains((event?.target as Node) || null)) { + return + } + } + + handler(event) + } + + document.addEventListener('mousedown', listener) + document.addEventListener('touchstart', listener) + + return () => { + document.removeEventListener('mousedown', listener) + document.removeEventListener('touchstart', listener) + } + }, [ref, handler, excludeRef]) +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..76e6354 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './styled' +export * from './react-definitions' diff --git a/src/types/react-definitions.ts b/src/types/react-definitions.ts new file mode 100644 index 0000000..0cd6e8a --- /dev/null +++ b/src/types/react-definitions.ts @@ -0,0 +1,3 @@ +import { ReactNode } from 'react' + +export type WithChildren = T & { children?: ReactNode } diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..6725644 --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,5 @@ +import slugifyCore from 'slugify' + +export const slugify = (value: string) => { + return slugifyCore(value, { lower: true }) +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..d5434ad --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './formatters' +export * from './styled' +export * from './theme'
I'm a Modal and i'm {isOpen ? 'opened' : 'closed'}