Skip to content

Commit

Permalink
feat: added Modal and Portal components, useOnClickOutside hook and e…
Browse files Browse the repository at this point in the history
…xternal types
  • Loading branch information
camilaffonseca committed Sep 8, 2022
1 parent 778d8b2 commit 6b24803
Show file tree
Hide file tree
Showing 18 changed files with 374 additions and 0 deletions.
15 changes: 15 additions & 0 deletions src/components/hocs/Portal/Portal.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Meta } from '@storybook/addon-docs'

<Meta title='Components/Portal' />

# 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
<Portal>Your components to render in a React Portal here...</Portal>
```
37 changes: 37 additions & 0 deletions src/components/hocs/Portal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -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<PortalProps>) => {
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 }
1 change: 1 addition & 0 deletions src/components/hocs/Portal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Portal'
1 change: 1 addition & 0 deletions src/components/hocs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Portal'
3 changes: 3 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './atoms'
export * from './hocs'
export * from './molecules'
61 changes: 61 additions & 0 deletions src/components/molecules/Modal/Modal.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Meta } from '@storybook/addon-docs'

import { Canvas } from 'core/storybookWrappers'

import ModalStory from './ModalStory'

<Meta title='Components/Modal' />

# 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<SetStateAction<boolean>>
}
```
- 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
<Modal
render={({ isOpen, setIsOpen }) => (
<div>
<button type='button' onClick={() => setIsOpen(false)}>
close
</button>
<p>I'm a Modal and i'm {isOpen ? 'opened' : 'closed'}</p>
</div>
)}
>
{({ setIsOpen }) => (
<button type='button' onClick={() => setIsOpen(true)}>
Open
</button>
)}
</Modal>
```
<Canvas>
<ModalStory /> // The source code is above
</Canvas>
120 changes: 120 additions & 0 deletions src/components/molecules/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<boolean>>
excludeRef: RefObject<any>
}

export type ModalProps = {
render: (params: ModalFunctionNotation) => ReactNode
children?: (params: ModalFunctionNotation) => ReactNode
externalIsOpen?: boolean
backgroundProps?: FlexProps
disableUseOnClickOutside?: boolean
} & Omit<CardProps, 'children'>

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<boolean>(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 })}

<ModalPortalDedupleProvider>
<Portal enabled={!hasParentModal}>
<motion.div
variants={motionBackgroundVariants}
initial='hidden'
animate={modalIsOpen ? 'visible' : 'hidden'}
transition={{
default: { duration: 0.3 },
}}
>
<ModalBackground variant='center' {...backgroundProps}>
<ModalCard ref={modalMainRef} {...props}>
{render({ isOpen: modalIsOpen, setIsOpen, excludeRef })}
</ModalCard>
</ModalBackground>
</motion.div>
</Portal>
</ModalPortalDedupleProvider>
</>
)
}

ModalBase.defaultProps = {
children: undefined,
externalIsOpen: undefined,
backgroundProps: undefined,
disableUseOnClickOutside: false,
}

const Modal = memo(ModalBase)

export { Modal }
38 changes: 38 additions & 0 deletions src/components/molecules/Modal/ModalStory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react'

import { Modal } from './Modal'

const ModalStory = () => (
<div>
<Modal
render={({ isOpen, setIsOpen }) => (
<div>
<button type='button' onClick={() => setIsOpen(false)}>
close
</button>
<p>I'm a Modal and i'm {isOpen ? 'opened' : 'closed'}</p>
</div>
)}
>
{({ setIsOpen }) => (
<div>
<br />
<br />
<br />
<br />

<button type='button' onClick={() => setIsOpen(true)}>
Open
</button>

<br />
<br />
<br />
<br />
</div>
)}
</Modal>
</div>
)

export default ModalStory
1 change: 1 addition & 0 deletions src/components/molecules/Modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Modal'
21 changes: 21 additions & 0 deletions src/components/molecules/Modal/styled.ts
Original file line number Diff line number Diff line change
@@ -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;
`
5 changes: 5 additions & 0 deletions src/components/molecules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './Fields'
export * from './InfiniteBarLoader'
export * from './Card'
export * from './Container'
export * from './Modal'
21 changes: 21 additions & 0 deletions src/contexts/ModalPortalDedupleContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ModalPortalDedupleContextValue>({
parentModalAlreadyExistsUsingPortal: false,
})

export const ModalPortalDedupleProvider = ({ children }: WithChildren) => (
<ModalPortalDedupleContext.Provider
value={{ parentModalAlreadyExistsUsingPortal: true }}
>
{children}
</ModalPortalDedupleContext.Provider>
)
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useOnClickOutside'
36 changes: 36 additions & 0 deletions src/hooks/useOnClickOutside.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, RefObject } from 'react'

type Event = MouseEvent | TouchEvent

export const useOnClickOutside = <T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
handler: (event: Event) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
excludeRef?: RefObject<any>
) => {
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])
}
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './styled'
export * from './react-definitions'
3 changes: 3 additions & 0 deletions src/types/react-definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ReactNode } from 'react'

export type WithChildren<T = unknown> = T & { children?: ReactNode }
5 changes: 5 additions & 0 deletions src/utils/formatters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import slugifyCore from 'slugify'

export const slugify = (value: string) => {
return slugifyCore(value, { lower: true })
}
3 changes: 3 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './formatters'
export * from './styled'
export * from './theme'

0 comments on commit 6b24803

Please sign in to comment.