'use client' import { forwardRef, useCallback, useEffect, useReducer, useRef, useState, } from 'react' import ReactDOM from 'react-dom' import classNames from 'classnames' import useEventHandler from '~/src/hooks/useEventHandler' import { useIsomorphicLayoutEffect } from '~/src/hooks/useIsomorphicLayoutEffect' import useMergeRefs from '~/src/hooks/useMergeRefs' import { getZIndexClassName } from '~/src/types/props-helpers' import { useModalContainerContext } from '~/src/components/Modal' import { ThemeProvider, useThemeName } from '~/src/components/ThemeProvider' import { useWindow } from '~/src/components/WindowProvider' import type { ContainerRectAttr, OverlayProps, TargetRectAttr, } from './Overlay.types' import { getOverlayStyle } from './utils' import styles from './Overlay.module.scss' export const CONTAINER_TEST_ID = 'bezier-container' export const OVERLAY_TEST_ID = 'bezier-overlay' export const ESCAPE_KEY = 'Escape' export const Overlay = forwardRef( function Overlay( { children, style, className, show = false, container: givenContainer, target, position = 'left-center', marginX = 0, marginY = 0, keepInContainer = false, withTransition = false, enableClickOutside = false, containerStyle, containerClassName, onHide, onTransitionEnd, zIndex = 'overlay', ...rest }, forwardedRef ) { const { window, document, rootElement } = useWindow() const [shouldRender, setShouldRender] = useState(false) const [shouldShow, setShouldShow] = useState(false) const containerRect = useRef(null) const targetRect = useRef(null) const [, forceUpdate] = useReducer((x) => !x, true) const containerRef = useRef(null) const overlayRef = useRef(null) const mergedRef = useMergeRefs(overlayRef, forwardedRef) const modalContainer = useModalContainerContext() const hasContainer = Boolean(givenContainer || modalContainer) const container = givenContainer ?? modalContainer ?? rootElement const handleOverlayForceUpdate = useCallback(() => { forceUpdate() }, []) const handleContainerRect = useCallback(() => { const { width: containerWidth, height: containerHeight, top: containerTop, left: containerLeft, } = container.getBoundingClientRect() containerRect.current = { containerWidth, containerHeight, containerTop, containerLeft, scrollTop: hasContainer ? container.scrollTop : 0, scrollLeft: hasContainer ? container.scrollLeft : 0, } }, [container, hasContainer]) useIsomorphicLayoutEffect( function initContainerRect() { if (show) { handleContainerRect() } }, [show, handleContainerRect] ) const handleTargetRect = useCallback(() => { if (!target) { return } const { width: targetWidth, height: targetHeight, top: targetTop, left: targetLeft, } = target.getBoundingClientRect() const { clientTop, clientLeft } = target targetRect.current = { targetWidth, targetHeight, targetTop, targetLeft, clientTop, clientLeft, } }, [target]) useIsomorphicLayoutEffect( function initTargetRect() { if (show) { handleTargetRect() } }, [show, handleTargetRect] ) const handleTransitionEnd = useCallback< React.TransitionEventHandler >( (event) => { onTransitionEnd?.(event) if (!show) { setShouldRender(false) } }, [show, onTransitionEnd] ) const handleBlockMouseWheel = useCallback( (event: HTMLElementEventMap['wheel']) => { event.stopPropagation() }, [] ) const handleHideOverlay = useCallback( (event: MouseEvent) => { /** * NOTE: Type checking with instanceof makes it difficult to handle cases where the window object is different. */ if ( !event.target || !(event.target as HTMLElement).closest(`.${styles.Overlay}`) ) { onHide?.() if (!enableClickOutside) { event.stopPropagation() } } }, [enableClickOutside, onHide] ) const handleKeydown = useCallback( (event: HTMLElementEventMap['keyup']) => { if (event.key === ESCAPE_KEY) { onHide?.() } }, [onHide] ) useEventHandler(document, 'click', handleHideOverlay, show, true) useEventHandler(document, 'keydown', handleKeydown, show) useEventHandler(containerRef.current, 'wheel', handleBlockMouseWheel, show) useEffect(() => { handleOverlayForceUpdate() }, [children, handleOverlayForceUpdate]) /** * Case 1: show === true * show -> shouldRender -> shouldShow * shouldRender 를 true 로 설정하고, 직후에 shouldShow 를 true 로 설정하여 transition 유발 * * Case 2: show === false * show -> shouldShow -> (...) -> shouldRender * shouldShow 를 false 로 설정하고, shouldRender 는 transition 필요 여부에 따라 다르게 결정함 * Case 2-1: withTransition === true * shouldShow -> onTransitionEnd -> shouldRender * onTransitionEnd handler 를 이용해 transition 이 끝난 다음 shouldRender 를 false 로 설정 * Case 2-2: withTransition === false * shouldShow && shouldRender * transition 을 기다릴 필요가 없으므로 바로 shouldRender 를 false 로 설정 */ useEffect(() => { if (show) { if (shouldRender) { window.requestAnimationFrame(() => setShouldShow(true)) } else { window.requestAnimationFrame(() => setShouldRender(true)) } } if (!show) { window.requestAnimationFrame(() => setShouldShow(false)) if (!withTransition) { window.requestAnimationFrame(() => setShouldRender(false)) } } }, [show, withTransition, shouldRender, shouldShow, window]) const themeName = useThemeName() if (!shouldRender) { return null } const Content = (
{children}
) return ReactDOM.createPortal( hasContainer ? ( Content ) : (
{Content}
), container ) } )