React Portal implementation
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> | |
</> | |
); | |
} |
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, | |
}; | |
} |
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> | |
</> | |
); | |
} |
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, | |
}; | |
} |
// 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