reactjsmodal-dialogpopupaddeventlistenerportal

How to close only one pop-up in a specific portal?


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

Main pop-up

Pop-over in the main pop-up

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

Solution

  • 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.