Last active
December 10, 2020 11:54
-
-
Save crisu83/b534109128dc1065a7cd3730c47e30d4 to your computer and use it in GitHub Desktop.
React Portal implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import {Button} from '@/design-system'; | |
import {useModal} from './modal'; | |
export default function ModalExample() { | |
const {Modal, closePortal, openPortal} = useModal(); | |
return ( | |
<> | |
<Button onClick={openPortal}>Open modal</Button> | |
<Modal> | |
Hello from the modal! | |
<Button onClick={closePortal}>Close</Button> | |
</Modal> | |
</> | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Box} from '@/design-system'; | |
import {isServer} from '@/utils'; | |
import useWindowSize from '@/utils/use-window-size'; | |
import React, {useEffect} from 'react'; | |
import {animated, useSpring} from 'react-spring'; | |
import {PortalConfig, PortalProps, usePortal} from './portal'; | |
export function useModal(config?: PortalConfig) { | |
const calculateCoords = () => | |
!isServer | |
? { | |
left: window.innerWidth / 2, | |
top: window.innerHeight / 2, | |
} | |
: undefined; | |
const {Portal, isOpen, setCoords, ...rest} = usePortal({ | |
closeOnOverlayClick: false, | |
initialCoords: calculateCoords(), | |
overlayOpacity: 0.3, | |
...config, | |
}); | |
const windowSize = useWindowSize(); | |
useEffect(() => { | |
setCoords(calculateCoords()); | |
}, [windowSize, setCoords]); | |
const spring = useSpring({ | |
opacity: isOpen ? 1 : 0, | |
transform: `translateY(${isOpen ? '0' : '30px'})`, | |
}); | |
return { | |
Modal({children, sx, ...props}: PortalProps) { | |
return ( | |
<Portal sx={{transform: 'translate(-50%, -50%)', ...sx}} {...props}> | |
<animated.div style={spring}> | |
<Box | |
bg="noContrast" | |
borderRadius="medium" | |
boxShadow="large" | |
minWidth="600px" | |
p={4} | |
> | |
{children} | |
</Box> | |
</animated.div> | |
</Portal> | |
); | |
}, | |
isOpen, | |
...rest, | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import {Button} from '@/design-system'; | |
import {usePopover} from './popover'; | |
export default function PopoverExample() { | |
const calculateCoords = (rect: DOMRect) => ({ | |
// Implementation detail | |
}); | |
const {Popover, openPortal, setCoords} = usePopover(); | |
const handleOpen = (event: any) => { | |
openPortal(); | |
const rect = (event.target as HTMLElement).getBoundingClientRect(); | |
const coords = calculateCoords(rect); | |
setCoords(coords); | |
}; | |
return ( | |
<> | |
<Button onClick={handleOpen}>Open popover</Button> | |
<Popover> | |
Hello from a popover! | |
</Popover> | |
</> | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Box, BoxPropsWithoutRef} from '@/design-system'; | |
import React, {PropsWithChildren} from 'react'; | |
import {animated, useSpring} from 'react-spring'; | |
import {PortalConfig, usePortal} from './portal'; | |
type PopoverProps = PropsWithChildren<BoxPropsWithoutRef<'div'>>; | |
export function usePopover(config?: PortalConfig) { | |
const {Portal, isOpen, ...rest} = usePortal(config); | |
const spring = useSpring({ | |
opacity: isOpen ? 1 : 0, | |
transform: `translateY(${isOpen ? '0' : '10px'})`, | |
}); | |
return { | |
Popover({children, sx, ...props}: PopoverProps) { | |
return ( | |
<Portal sx={{position: 'absolute', ...sx}} {...props}> | |
<animated.div style={spring}> | |
<Box | |
bg="noContrast" | |
borderRadius="small" | |
boxShadow="small" | |
minWidth="160px" | |
overflow="hidden" | |
> | |
{children} | |
</Box> | |
</animated.div> | |
</Portal> | |
); | |
}, | |
isOpen, | |
...rest, | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// See: https://blog.logrocket.com/learn-react-portals-by-example/ | |
import {Box, BoxPropsWithoutRef} from '@/design-system'; | |
import {noop, safeDocument} from '@/utils'; | |
import {PropsWithChildren, useLayoutEffect, useState} from 'react'; | |
import React from 'react'; | |
import {createPortal} from 'react-dom'; | |
import {animated, useSpring} from 'react-spring'; | |
const PORTAL_ZINDEX = 1010; | |
function PortalContainer({ | |
children, | |
...props | |
}: PropsWithChildren<BoxPropsWithoutRef<'div'>>) { | |
return ( | |
<Box fontFamily="body" lineHeight="body" {...props}> | |
{children} | |
</Box> | |
); | |
} | |
type PortalOverlayProps = BoxPropsWithoutRef<'div'> & {opacity: number}; | |
function PortalOverlay({onClick, opacity}: PortalOverlayProps) { | |
const spring = useSpring({ | |
from: {opacity: opacity}, | |
to: {opacity}, | |
}); | |
return ( | |
<animated.div style={spring}> | |
<Box | |
onClick={onClick} | |
sx={{ | |
bg: 'maxContrast', | |
left: 0, | |
height: '100%', | |
position: 'fixed', | |
top: 0, | |
width: '100%', | |
zIndex: PORTAL_ZINDEX - 10, | |
}} | |
/> | |
</animated.div> | |
); | |
} | |
const portalRoot = safeDocument.getElementById('portal-root'); | |
type PortalRootProps = PropsWithChildren< | |
BoxPropsWithoutRef<'div'> & { | |
onClose: () => void; | |
overlayOpacity: number; | |
enableOverlay: boolean; | |
closeOnOverlayClick: boolean; | |
isOpen: boolean; | |
} | |
>; | |
export function PortalRoot({ | |
children, | |
closeOnOverlayClick, | |
enableOverlay, | |
isOpen, | |
onClose, | |
overlayOpacity, | |
sx, | |
...props | |
}: PortalRootProps) { | |
// TODO: Figure out if there is a way to remove this extra `<div>` element | |
const el = safeDocument.createElement('div'); | |
useLayoutEffect(() => { | |
portalRoot.appendChild(el); | |
return () => portalRoot.removeChild(el); | |
}, [el]); | |
return isOpen && el | |
? createPortal( | |
<> | |
<PortalContainer | |
onClick={event => { | |
event.stopPropagation(); | |
}} | |
sx={{...sx, zIndex: PORTAL_ZINDEX}} | |
{...props} | |
> | |
{children} | |
</PortalContainer> | |
{enableOverlay && ( | |
<PortalOverlay | |
onClick={closeOnOverlayClick ? onClose : noop} | |
opacity={overlayOpacity} | |
/> | |
)} | |
</>, | |
el | |
) | |
: null; | |
} | |
type Position = number | string; | |
type Coords = { | |
bottom?: Position; | |
left?: Position; | |
right?: Position; | |
top?: Position; | |
}; | |
export type PortalConfig = { | |
closeOnOverlayClick?: boolean; | |
enableOverlay?: boolean; | |
initialCoords?: Coords; | |
overlayOpacity?: number; | |
}; | |
export type PortalProps = PropsWithChildren<BoxPropsWithoutRef<'div'>>; | |
export function usePortal({ | |
closeOnOverlayClick = true, | |
enableOverlay = true, | |
initialCoords = {}, | |
overlayOpacity = 0, | |
}: PortalConfig = {}) { | |
const [isOpen, setIsOpen] = useState(false); | |
const [coords, setCoords] = useState<Coords>(initialCoords); | |
const openPortal = () => setIsOpen(true); | |
const closePortal = () => setIsOpen(false); | |
return { | |
Portal({sx, ...props}: PortalProps) { | |
return ( | |
<PortalRoot | |
closeOnOverlayClick={closeOnOverlayClick} | |
enableOverlay={enableOverlay} | |
isOpen={isOpen} | |
onClose={closePortal} | |
overlayOpacity={overlayOpacity} | |
sx={{position: 'fixed', ...coords, ...sx}} | |
{...props} | |
/> | |
); | |
}, | |
closePortal, | |
isOpen, | |
openPortal, | |
setCoords, | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment