import classNames from 'classnames' import { createContext, type MouseEvent, type PropsWithChildren, useCallback, useContext, useEffect, useLayoutEffect, useState, } from 'react' import { createPortal } from 'react-dom' import { seamComponentsClassName } from 'lib/seam/SeamProvider.js' export interface MenuProps extends PropsWithChildren { verticalOffset?: number horizontalOffset?: number edgeOffset?: number renderButton: (props: { onOpen: (event: MouseEvent) => void }) => JSX.Element backgroundProps?: Partial<{ className?: string }> onClose?: () => void } interface MenuContext { close: () => void } const menuContext = createContext({ close: () => {}, }) export function Menu({ verticalOffset = 5, horizontalOffset = 0, edgeOffset = 5, children, renderButton, backgroundProps, onClose, }: MenuProps): JSX.Element | null { const { Provider } = menuContext const [documentEl, setDocumentEl] = useState(null) const [bodyEl, setBodyEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null) const [contentEl, setContentEl] = useState(null) const [top, setTop] = useState(0) const [left, setLeft] = useState(0) useEffect(() => { const documentEl = globalThis.document.documentElement setDocumentEl(documentEl) const bodyElements = documentEl?.getElementsByTagName('body') if (bodyElements[0] == null) return setBodyEl(bodyElements[0]) }, [setDocumentEl]) const handleClose = (): void => { onClose?.() setAnchorEl(null) } const handleOpen = (event: MouseEvent): void => { setAnchorEl(event.currentTarget) } const setPositions = useCallback(() => { if ( anchorEl == null || contentEl == null || bodyEl == null || documentEl == null ) return const containerRight = documentEl.offsetLeft + documentEl.clientWidth const containerBottom = documentEl.offsetTop + documentEl.clientHeight const anchorBox = anchorEl.getBoundingClientRect() const anchorTop = anchorBox.top + bodyEl.clientTop const anchorLeft = anchorBox.left + bodyEl.clientLeft const anchorHeight = anchorEl.offsetHeight const contentWidth = contentEl.offsetWidth const contentHeight = contentEl.offsetHeight const anchorBottom = anchorTop + anchorHeight const top = anchorBottom + verticalOffset const left = anchorLeft + horizontalOffset const right = left + contentWidth const bottom = top + contentHeight // If the content would overflow right, set it relative to the right of the container. const isOverflowingRight = right > containerRight const visibleLeft = isOverflowingRight ? containerRight - contentWidth - horizontalOffset - edgeOffset : left setLeft(visibleLeft) // If the content would overflow bottom, position it above the anchor. const isOverFlowingBottom = bottom > containerBottom const topWhenAboveAnchor = anchorTop - contentHeight - verticalOffset // Only open the menu above the anchor if it won't get clipped, i.e., not < 0. const visibleTop = isOverFlowingBottom && topWhenAboveAnchor > 0 ? topWhenAboveAnchor : top setTop(visibleTop) }, [ anchorEl, horizontalOffset, verticalOffset, contentEl, edgeOffset, bodyEl, documentEl, ]) useLayoutEffect(() => { setPositions() globalThis.addEventListener('scroll', setPositions) globalThis.addEventListener('resize', setPositions) return () => { globalThis.removeEventListener('scroll', setPositions) globalThis.removeEventListener('resize', setPositions) } }, [setPositions]) const isOpen = anchorEl != null const hasSetPosition = top !== 0 && left !== 0 const visible = isOpen && hasSetPosition if (bodyEl == null) { return null } return ( {renderButton({ onOpen: handleOpen })} {createPortal(
{ event.stopPropagation() handleClose() }} >
{children}
, bodyEl )} {/* * Render a shadow element to calculate the content size even if we're * not actually showing the content yet. */}
{children}
) } export function useMenu(): MenuContext { return useContext(menuContext) }