I am writing a small UiKit for pop-ups and i've run up into a problem. My code structure is looking somewhere like this:
<MainPopup>
<Popover />
<MainPopup />
And pop-ups structure can be presented like this:
<KeyboardListener>
<Portal>
// some logic here...
<Portal />
<KeyboardListener/>
<Portal />
component opens pop-up in the React.createPortal(...)
and <KeyboardListener />
component looks like this (it basically adds event listener to track when user presses Escape
button):
import { FC, useEffect } from "react"
interface KeyboardListenerProps {
onClose: () => void
children: React.ReactNode
}
const KeyboardListener: FC<KeyboardListenerProps> = ({onClose, children}) => {
useEffect(() => {
const closeOnEscapeKey = (e: KeyboardEvent) => {
e.stopImmediatePropagation()
if (e.key === 'Escape') onClose()
}
document.body.addEventListener('keydown', closeOnEscapeKey)
return () => document.body.removeEventListener('keydown', closeOnEscapeKey)
}, [onClose])
return (
<>
{children}
</>
)
}
export default KeyboardListener
But there is a problem! When I am opening pop-over and want to close it by pressing Escape
the main pop-up is also getting closed.
Question: Is there a way to close a specific (last-opened) portal with pop-up with an Escape
key?
Forgot to mention - every new portal is appended to the document after the previous one, here is the code:
import { FC, useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
interface PortalProps {
children: React.ReactNode
}
const Portal: FC<PortalProps> = ({ children }) => {
const [container] = useState(() => document.createElement('div'))
useEffect(() => {
document.body.appendChild(container)
return () => {
document.body.removeChild(container)
}
}, [container])
return ReactDOM.createPortal(children, container)
}
export default Portal
I solved this issue in kind of imperative way. I've added <div id="portals" />
to index.html file and now I am putting all my portals here. Also, now, when a new <Portal />
is created it must have a unique id, portal component looks like this:
import { FC, useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
import KeyboardListener from '../../KeyboardListener/KeyboardListener'
interface PortalProps {
children: React.ReactNode
onClose: () => void
id: string
}
const Portal: FC<PortalProps> = ({ children, onClose, id }) => {
const [container] = useState(() => document.createElement('div'))
container.id = id
const portals = document.getElementById('portals')!
useEffect(() => {
portals.appendChild(container)
return () => {
portals.removeChild(container)
}
}, [container, id, onClose, portals])
return ReactDOM.createPortal(
<KeyboardListener id={id} onClose={onClose} portals={portals}>
{children}
</KeyboardListener>, container)
}
export default Portal
Then I've created <KeyboardListener />
component which is listening Escape button press. Every new pop-up onClose()
is closured in different <Portal />
, so I am taking the last element from every time button is clicked and calling specific onClose
for portal with appropriate id:
import React, { FC, useEffect } from 'react'
interface KeyboardListenerProps {
portals: HTMLElement
onClose: () => void
id: string
children: React.ReactNode
}
const KeyboardListener: FC<KeyboardListenerProps> = ({
portals,
onClose,
id,
children
}) => {
useEffect(() => {
const closeOnEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
const portals = document.getElementById('portals')!
if (
portals.children &&
portals.children[portals.children.length - 1]?.id === id
) {
onClose()
}
}
}
document.body.addEventListener('keydown', closeOnEscapeKey)
return () => document.body.removeEventListener('keydown', closeOnEscapeKey)
}, [id, portals, onClose])
return <>{children}</>
}
export default KeyboardListener
Bonus:
I also know more elegant way to solve this issue, but I haven't realized it yet. You need a store in which you will put onClose()
every time a new <Portal />
is created. Then every time Escape button is pressed you will call the last onClose()
in this store.