Skip to content

Instantly share code, notes, and snippets.

@srph
Last active May 10, 2022 15:47
Show Gist options
  • Save srph/34e1d1737938c26bb5502210ce06dacb to your computer and use it in GitHub Desktop.
Save srph/34e1d1737938c26bb5502210ce06dacb to your computer and use it in GitHub Desktop.
React: Popover - Fully wrapped Popper component
import React, { useMemo } from 'react'
import ReactDOM from 'react-dom'
import styled from 'styled-components'
import { usePopper } from 'react-popper'
import type { Placement, Offsets } from '@popperjs/core'
import { useStateRef, useUpdateEffect, useDocumentListener, useOutsideClick } from '~/src/hooks'
import { theme } from '~/src/theme'
interface Props {
open: boolean
onChangeOpen: (open: boolean) => void
trigger: React.ReactNode
placement?: Placement
container?: HTMLElement
offset?: Offsets
dependencies?: any[]
}
const PORTAL_ID = 'smarf-popover-portal'
const Popover: React.FC<Props> = ({
open,
onChangeOpen,
placement,
offset,
trigger,
container: containerElement,
dependencies,
children
}) => {
const [triggerElement, setTriggerElement, triggerElementRef] = useStateRef<HTMLElement>()
const [popperElement, setPopperElement, popperElementRef] = useStateRef<HTMLElement>()
const modifiers = useMemo(() => {
const result = []
if (offset) {
result.push({
name: 'offset',
options: {
offset: [offset.x, offset.y]
}
})
}
return result
}, [offset])
const { styles, attributes, forceUpdate } = usePopper(containerElement || triggerElement, popperElement, {
placement,
modifiers
})
const portalElement = useMemo(() => {
return document.getElementById(PORTAL_ID)
}, [])
// Force-calculate position when dependencies change
// e.g., category height increases
useUpdateEffect(
() => {
forceUpdate?.()
},
dependencies ? [forceUpdate, ...dependencies] : [forceUpdate]
)
useDocumentListener('keydown', (evt) => {
if (open && evt.key === 'Escape') {
onChangeOpen(false)
}
})
useOutsideClick([popperElementRef, triggerElementRef], (evt) => {
if (open) {
onChangeOpen(false)
}
})
return (
<>
<div ref={setTriggerElement}>{trigger}</div>
{portalElement &&
open &&
ReactDOM.createPortal(
<PopperContainer ref={setPopperElement} style={styles.popper} {...attributes.popper}>
{children}
</PopperContainer>,
portalElement
)}
</>
)
}
const PopoverPortal = () => {
return <div id={PORTAL_ID} />
}
const PopperContainer = styled.div`
position: absolute;
z-index: ${theme.zIndex.popover};
`
export { Popover, PopoverPortal }
import { useEffect } from 'react'
import { useLatestValue } from './useLatestValue'
type Listener<T extends keyof WindowEventMap> = (event: WindowEventMap[T]) => void
const useDocumentListener = <T extends keyof WindowEventMap>(event: T, listener: Listener<T>) => {
const listenerRef = useLatestValue(listener)
useEffect(() => {
const handler: Listener<T> = (event) => listenerRef.current(event)
document.addEventListener(event, handler)
return () => document.addEventListener(event, handler)
}, [listenerRef])
}
export { useDocumentListener }
import { MutableRefObject, useEffect, useRef } from 'react'
function useLatestValue<T>(value: T): MutableRefObject<T> {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
export { useLatestValue }
import { MutableRefObject } from 'react'
import { useDocumentListener } from './useDocumentListener'
const useOutsideClick = (containers: MutableRefObject<HTMLElement>[], listener) => {
useDocumentListener('mousedown', (e) => {
for (let container of containers) {
if (container.current?.contains(e.target as Node)) {
return
}
}
listener(e)
})
}
export { useOutsideClick }
import { useState, useRef, MutableRefObject } from 'react'
/**
* Access a state's fresh value. Useful for events.
*/
function useStateRef<T>(defaultValue?: T): [T, (v: T) => void, MutableRefObject<T>] {
const [state, internalSetState] = useState(defaultValue)
const stateRef = useRef(defaultValue)
const setState = (v: T) => {
stateRef.current = v
internalSetState(v)
}
return [state, setState, stateRef]
}
export { useStateRef }
import { useState, useEffect } from 'react'
/**
* Avoid running the effect on mount.
*/
export const useUpdateEffect: typeof useEffect = (fn, deps) => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
if (!isMounted) {
setIsMounted(true)
return
}
fn()
}, deps)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment