alex-cory/react-useportal

View on GitHub
usePortal.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { useState, useRef, useEffect, useCallback, useMemo, ReactNode, DOMAttributes, SyntheticEvent, MutableRefObject, MouseEvent } from 'react'
import { createPortal, findDOMNode } from 'react-dom'
import useSSR from 'use-ssr'

type HTMLElRef = MutableRefObject<HTMLElement>
type CustomEvent = {
  event?: SyntheticEvent<any, Event>
  portal: HTMLElRef
  targetEl: HTMLElRef
} & SyntheticEvent<any, Event>

type CustomEventHandler = (customEvent: CustomEvent) => void
type CustomEventHandlers = {
  [K in keyof DOMAttributes<K>]?: CustomEventHandler
}

type EventListenerMap = { [K in keyof DOMAttributes<K>]: keyof GlobalEventHandlersEventMap }
type EventListenersRef = MutableRefObject<{
  [K in keyof DOMAttributes<K>]?: (event: SyntheticEvent<any, Event>) => void
}>

export type UsePortalOptions = {
  closeOnOutsideClick?: boolean
  closeOnEsc?: boolean
  bindTo?: HTMLElement // attach the portal to this node in the DOM
  isOpen?: boolean
  onOpen?: CustomEventHandler
  onClose?: CustomEventHandler
  onPortalClick?: CustomEventHandler
  programmaticallyOpen?: boolean
} & CustomEventHandlers

type UsePortalObjectReturn = {} // TODO
type UsePortalArrayReturn = [] // TODO

export const errorMessage1 = 'You must either add a `ref` to the element you are interacting with or pass an `event` to openPortal(e) or togglePortal(e) when the `programmaticallyOpen` option is not set to `true`.'

export default function usePortal({
  closeOnOutsideClick = true,
  closeOnEsc = true,
  bindTo, // attach the portal to this node in the DOM
  isOpen: defaultIsOpen = false,
  onOpen,
  onClose,
  onPortalClick,
  programmaticallyOpen = false,
  ...eventHandlers
}: UsePortalOptions = {}): any {
  const { isServer, isBrowser } = useSSR()
  const [isOpen, makeOpen] = useState(defaultIsOpen)
  // we use this ref because `isOpen` is stale for handleOutsideMouseClick
  const open = useRef(isOpen)

  const setOpen = useCallback((v: boolean) => {
    // workaround to not have stale `isOpen` in the handleOutsideMouseClick
    open.current = v
    makeOpen(v)
  }, [])

  const targetEl = useRef() as HTMLElRef // this is the element you are clicking/hovering/whatever, to trigger opening the portal
  const portal = useRef(isBrowser ? document.createElement('div') : null) as HTMLElRef

  useEffect(() => {
    if (isBrowser && !portal.current) portal.current = document.createElement('div')
  }, [isBrowser, portal])

  const elToMountTo = useMemo(() => {
    if (isServer) return
    return (bindTo && findDOMNode(bindTo)) || document.body
  }, [isServer, bindTo])

  const createCustomEvent = (e: any) => {
    if (!e) return { portal, targetEl, event: e }
    const event = e || {}
    if (event.persist) event.persist()
    event.portal = portal
    event.targetEl = targetEl
    event.event = e
    const { currentTarget } = e
    if (!targetEl.current && currentTarget && currentTarget !== document) targetEl.current = event.currentTarget
    return event
  }

  // this should handle all eventHandlers like onClick, onMouseOver, etc. passed into the config
  const customEventHandlers: CustomEventHandlers = Object
    .entries(eventHandlers)
    .reduce<any>((acc, [handlerName, eventHandler]) => {
      acc[handlerName] = (event?: SyntheticEvent<any, Event>) => {
        if (isServer) return
        eventHandler(createCustomEvent(event))
      }
      return acc
    }, {})

  const openPortal = useCallback((e: any) => {
    if (isServer) return
    const customEvent = createCustomEvent(e)
    // for some reason, when we don't have the event argument, there
    // is a weird race condition. Would like to see if we can remove
    // setTimeout, but for now this works
    if (targetEl.current == null && !programmaticallyOpen) {
      setTimeout(() => setOpen(true), 0)
      throw Error(errorMessage1)
    }
    if (onOpen) onOpen(customEvent)
    setOpen(true)
  }, [isServer, portal, setOpen, targetEl, onOpen])

  const closePortal = useCallback((e: any) => {
    if (isServer) return
    const customEvent = createCustomEvent(e)
    if (onClose && open.current) onClose(customEvent)
    if (open.current) setOpen(false)
  }, [isServer, onClose, setOpen])

  const togglePortal = useCallback((e: SyntheticEvent<any, Event>): void => 
    open.current ? closePortal(e) : openPortal(e),
    [closePortal, openPortal]
  )

  const handleKeydown = useCallback((e: KeyboardEvent): void => 
    (e.key === 'Escape' && closeOnEsc) ? closePortal(e) : undefined,
    [closeOnEsc, closePortal]
  )

  const handleOutsideMouseClick = useCallback((e: MouseEvent): void => {
    const containsTarget = (target: HTMLElRef) => target.current.contains(e.target as HTMLElement)
    // There might not be a targetEl if the portal was opened programmatically.
    if (containsTarget(portal) || (e as any).button !== 0 || !open.current || (targetEl.current && containsTarget(targetEl))) return
    if (closeOnOutsideClick) closePortal(e)
  }, [isServer, closePortal, closeOnOutsideClick, portal])

  const handleMouseDown = useCallback((e: MouseEvent): void => {
    if (isServer || !(portal.current instanceof HTMLElement)) return
    const customEvent = createCustomEvent(e)
    if (portal.current.contains(customEvent.target as HTMLElement) && onPortalClick) onPortalClick(customEvent)
    handleOutsideMouseClick(e)
  }, [handleOutsideMouseClick])

  // used to remove the event listeners on unmount
  const eventListeners = useRef({}) as EventListenersRef

  useEffect(() => {
    if (isServer) return
    if (!(elToMountTo instanceof HTMLElement) || !(portal.current instanceof HTMLElement)) return

    // TODO: eventually will need to figure out a better solution for this.
    // Surely we can find a way to map onScroll/onWheel -> scroll/wheel better,
    // but for all other event handlers. For now this works.
    const eventHandlerMap: EventListenerMap = {
      onScroll: 'scroll',
      onWheel: 'wheel',
    }
    const node = portal.current
    elToMountTo.appendChild(portal.current)
    // handles all special case handlers. Currently only onScroll and onWheel
    Object.entries(eventHandlerMap).forEach(([handlerName /* onScroll */, eventListenerName /* scroll */]) => {
      if (!eventHandlers[handlerName as keyof EventListenerMap]) return
      eventListeners.current[handlerName as keyof EventListenerMap] = (e: any) => (eventHandlers[handlerName as keyof EventListenerMap] as any)(createCustomEvent(e))
      document.addEventListener(eventListenerName as keyof GlobalEventHandlersEventMap, eventListeners.current[handlerName as keyof EventListenerMap] as any)
    })
    document.addEventListener('keydown', handleKeydown)
    document.addEventListener('mousedown', handleMouseDown as any)

    return () => {
      // handles all special case handlers. Currently only onScroll and onWheel
      Object.entries(eventHandlerMap).forEach(([handlerName, eventListenerName]) => {
        if (!eventHandlers[handlerName as keyof EventListenerMap]) return
        document.removeEventListener(eventListenerName as keyof GlobalEventHandlersEventMap, eventListeners.current[handlerName as keyof EventListenerMap] as any)
        delete eventListeners.current[handlerName as keyof EventListenerMap]
      })
      document.removeEventListener('keydown', handleKeydown)
      document.removeEventListener('mousedown', handleMouseDown as any)
      elToMountTo.removeChild(node)
    }
  }, [isServer, handleOutsideMouseClick, handleKeydown, elToMountTo, portal])

  const Portal = useCallback(({ children }: { children: ReactNode }) => {
    if (portal.current != null) return createPortal(children, portal.current)
    return null
  }, [portal])

  return Object.assign(
    [openPortal, closePortal, open.current, Portal, togglePortal, targetEl, portal],
    {
      isOpen: open.current,
      openPortal,
      ref: targetEl,
      closePortal,
      togglePortal,
      Portal,
      portalRef: portal,
      ...customEventHandlers,
      bind: { // used if you want to spread all html attributes onto the target element
        ref: targetEl,
        ...customEventHandlers
      }
    }
  )
}