UNPKG

@syncfusion/react-popups

Version:

Syncfusion React popup package is a feature-rich collection of UI components such as Dialog and Tooltip, used to display contextual information or messages in separate pop-ups.

530 lines (529 loc) 22.2 kB
import { jsx as _jsx } from "react/jsx-runtime"; import * as React from 'react'; import { useRef, useEffect, useState, forwardRef, useImperativeHandle } from 'react'; import { calculatePosition, calculateRelativeBasedPosition } from '../common/position'; import { preRender, useProviderContext } from '@syncfusion/react-base'; import { Animation } from '@syncfusion/react-base'; import { flip, fit, isCollide, getFixedScrollableParent, getZindexPartial, getElementReact, getTransformElement, getZoomValue } from '../common/collision'; /** * Defines the available collision handling types for popup positioning. */ export var CollisionType; (function (CollisionType) { /** * No collision handling - the popup will maintain its original position * regardless of viewport boundaries. */ CollisionType["None"] = "None"; /** * Flip collision handling - the popup will flip to the opposite side of its * anchor element when it would otherwise extend beyond viewport boundaries. */ CollisionType["Flip"] = "Flip"; /** * Fit collision handling - the popup will be adjusted to fit within the viewport * boundaries while maintaining its original side relative to the anchor element. */ CollisionType["Fit"] = "Fit"; })(CollisionType || (CollisionType = {})); /** * Defines how the popup should behave when scroll events occur in the parent container. */ export var ActionOnScrollType; (function (ActionOnScrollType) { /** * The popup will recalculate and update its position to maintain proper alignment * with the target element when scrolling occurs. */ ActionOnScrollType["Reposition"] = "Reposition"; /** * The popup will be hidden when scrolling occurs in the parent container, * helping to improve performance or prevent UI clutter during scrolling. */ ActionOnScrollType["Hide"] = "Hide"; /** * The popup will not respond to scroll events and will maintain its absolute * position on the page regardless of scrolling. */ ActionOnScrollType["None"] = "None"; })(ActionOnScrollType || (ActionOnScrollType = {})); const CLASSNAME_OPEN = 'sf-popup-open'; const CLASSNAME_CLOSE = 'sf-popup-close'; /** * Popup component for displaying content in a floating container positioned relative to a target element. * * ```typescript * <Popup * open={true} * relateTo={elementRef} * position={{ X: 'left', Y: 'bottom' }} * > * <div>Popup content</div> * </Popup> * ``` */ export const Popup = forwardRef((props, ref) => { const { children, open = false, targetRef, relativeElement = null, position = { X: 'left', Y: 'top' }, offsetX = 0, offsetY = 0, collision = { X: CollisionType.None, Y: CollisionType.None }, animation = { show: { name: 'FadeIn', duration: 0, timingFunction: 'ease-out' }, hide: { name: 'FadeOut', duration: 0, timingFunction: 'ease-out' } }, relateTo = 'body', viewPortElementRef, zIndex = 1000, width = 'auto', height = 'auto', className = '', actionOnScroll = ActionOnScrollType.Reposition, autoReposition = false, targetType = 'relative', onOpen, onClose, onTargetExitViewport, style, ...rest } = props; const popupRef = useRef(null); const initialOpenState = useRef(open); const [leftPosition, setLeftPosition] = useState(0); const [topPosition, setTopPosition] = useState(0); const [popupClass, setPopupClass] = useState(CLASSNAME_CLOSE); const [popupZIndex, setPopupZIndex] = useState(1000); const { dir } = useProviderContext(); const [currentRelatedElement, setRelativeElement] = useState(relativeElement); const scrollParents = useRef(null); const resizeObserverRef = useRef(null); const fixedParent = useRef(false); const targetInvisibleRef = React.useRef(false); useImperativeHandle(ref, () => ({ getScrollableParent: (element) => { return getScrollableParent(element); }, refreshPosition: (target, collision) => { refreshPosition(target, collision); }, element: popupRef.current }), []); useEffect(() => { preRender('popup'); return () => { setRelativeElement(null); removeScrollListeners(); }; }, []); useEffect(() => { updatePosition(); }, [targetRef, position, offsetX, offsetY, viewPortElementRef]); useEffect(() => { checkCollision(); }, [collision]); useEffect(() => { if (!open && initialOpenState.current === open) { return; } initialOpenState.current = open; if (open) { show(animation.show, currentRelatedElement); } else { hide(animation.hide); } }, [open]); useEffect(() => { setPopupZIndex(zIndex); }, [zIndex]); useEffect(() => { setRelativeElement(relativeElement); }, [relativeElement]); useEffect(() => { if (animation?.show?.duration === 0 && onOpen && popupClass === CLASSNAME_OPEN && open) { onOpen(); } }, [popupClass]); useEffect(() => { if (!open || !autoReposition || !popupRef.current || typeof ResizeObserver === 'undefined') { return; } if (resizeObserverRef.current) { resizeObserverRef.current.disconnect(); resizeObserverRef.current = null; } const resizeInstance = new ResizeObserver(() => { refreshPosition(); }); resizeInstance.observe(popupRef.current); resizeObserverRef.current = resizeInstance; return () => { resizeInstance.disconnect(); if (resizeObserverRef.current === resizeInstance) { resizeObserverRef.current = null; } }; }, [open, position]); useEffect(() => { if (!open) { return; } let rafId = null; const onResize = () => { if (rafId != null) { return; } rafId = requestAnimationFrame(() => { rafId = null; refreshPosition(); }); }; window.addEventListener('resize', onResize); window.addEventListener('orientationchange', onResize); onResize(); return () => { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } window.removeEventListener('resize', onResize); window.removeEventListener('orientationchange', onResize); }; }, [open, position?.X, position?.Y, offsetX, offsetY, targetType, relateTo, collision?.X, collision?.Y]); const refreshPosition = (target, collision) => { if (target) { checkFixedParent(target); } updatePosition(); if (!collision) { checkCollision(); } }; const updatePosition = () => { const element = popupRef.current; const relateToElement = getRelateToElement(); if (!element) { return; } let pos = { left: 0, top: 0 }; if (typeof position.X === 'number' && typeof position.Y === 'number') { pos = { left: position.X, top: position.Y }; } else if (style?.top && style?.left) { pos = { left: style.left, top: style.top }; } else if ((typeof position.X === 'string' && typeof position.Y === 'number') || (typeof position.X === 'number' && typeof position.Y === 'string')) { const anchorPos = getAnchorPosition(relateToElement, element, position, offsetX, offsetY); pos = typeof position.X === 'string' ? { left: anchorPos.left, top: position.Y } : { left: position.X, top: anchorPos.top }; } else if (relateToElement) { const display = element.style.display; element.style.display = ''; pos = getAnchorPosition(relateToElement, element, position, offsetX, offsetY); element.style.display = display; } if (pos) { element.style.left = `${pos.left}px`; element.style.top = `${pos.top}px`; setLeftPosition(pos.left); setTopPosition(pos.top); } }; const show = (animationOptions, relativeElement) => { if (popupRef?.current) { addScrollListeners(); if (relativeElement || zIndex === 1000) { const zIndexElement = !relativeElement ? popupRef?.current : relativeElement; setPopupZIndex(getZindexPartial(zIndexElement)); } if (collision.X !== CollisionType.None || collision.Y !== CollisionType.None) { const originalDisplay = popupRef.current.style.display; popupRef.current.style.visibility = 'hidden'; popupRef.current.style.display = ''; checkCollision(); popupRef.current.style.visibility = ''; popupRef.current.style.display = originalDisplay; } if (animationOptions && animationOptions.duration && animationOptions.duration > 0) { animationOptions.begin = () => { setPopupClass(CLASSNAME_OPEN); }; animationOptions.end = () => { onOpen?.(); }; if (Animation) { const animationInstance = Animation(animationOptions); if (animationInstance.animate) { animationInstance.animate(popupRef.current); } } } else { setPopupClass(CLASSNAME_OPEN); } } }; const hide = (animationOptions) => { if (animationOptions && animationOptions.duration && animationOptions.duration > 0) { animationOptions.begin = () => { let duration = animationOptions.duration ? animationOptions.duration - 30 : 0; duration = duration > 0 ? duration : 0; setTimeout(() => { setPopupClass(CLASSNAME_CLOSE); }, duration); }; animationOptions.end = () => { onClose?.(); }; if (Animation) { const animationInstance = Animation(animationOptions); if (animationInstance.animate) { animationInstance.animate(popupRef.current); } } } else { setPopupClass(CLASSNAME_CLOSE); onClose?.(); } removeScrollListeners(); }; const callFit = (param) => { const element = popupRef.current; const viewPortElement = viewPortElementRef?.current; if (!element) { return; } if (isCollide(element, viewPortElement || null).length !== 0) { if (!viewPortElement) { const currentPos = { left: parseFloat(element.style.left) || leftPosition, top: parseFloat(element.style.top) || topPosition }; const data = fit(element, null, param, currentPos); if (param.X) { element.style.left = `${data.left}px`; setLeftPosition(data.left); } if (param.Y) { element.style.top = `${data.top}px`; setTopPosition(data.top); } } else { const elementRect = getElementReact(element); const viewPortRect = getElementReact(viewPortElement); if (!elementRect || !viewPortRect) { return; } if (param.Y) { if (viewPortRect.top > elementRect.top) { element.style.top = '0px'; setTopPosition(0); } else if (viewPortRect.bottom < elementRect.bottom) { const newTop = parseInt(element.style.top, 10) - (elementRect.bottom - viewPortRect.bottom); element.style.top = `${newTop}px`; setTopPosition(newTop); } } if (param.X) { if (viewPortRect.right < elementRect.right) { const newLeft = parseInt(element.style.left, 10) - (elementRect.right - viewPortRect.right); element.style.left = `${newLeft}px`; setLeftPosition(newLeft); } else if (viewPortRect.left > elementRect.left) { const newLeft = parseInt(element.style.left, 10) + (viewPortRect.left - elementRect.left); element.style.left = `${newLeft}px`; setLeftPosition(newLeft); } } } } }; const callFlip = (param) => { const element = popupRef.current; const relateToElement = getRelateToElement(); const viewPortElement = viewPortElementRef?.current; if (!element || !relateToElement) { return; } const flippedPos = flip(element, relateToElement, offsetX, offsetY, typeof position.X === 'string' ? position.X : 'left', typeof position.Y === 'string' ? position.Y : 'top', viewPortElement, param); if (flippedPos) { element.style.left = `${flippedPos.left}px`; element.style.top = `${flippedPos.top}px`; setLeftPosition(flippedPos.left); setTopPosition(flippedPos.top); } }; const checkCollision = () => { const horz = collision.X; const vert = collision.Y; if (horz === CollisionType.None && vert === CollisionType.None) { return; } if (horz === CollisionType.Flip && vert === CollisionType.Flip) { callFlip({ X: true, Y: true }); } else if (horz === CollisionType.Fit && vert === CollisionType.Fit) { callFit({ X: true, Y: true }); } else { if (horz === CollisionType.Flip) { callFlip({ X: true, Y: false }); } else if (vert === CollisionType.Flip) { callFlip({ Y: true, X: false }); } if (horz === CollisionType.Fit) { callFit({ X: true, Y: false }); } else if (vert === CollisionType.Fit) { callFit({ X: false, Y: true }); } } }; const getAnchorPosition = (anchorEle, element, position, offsetX, offsetY) => { const eleRect = getElementReact(element); const anchorRect = getElementReact(anchorEle); if (!eleRect || !anchorRect) { return { left: 0, top: 0 }; } const isBody = anchorEle.tagName === 'BODY'; const posX = typeof position.X === 'string' ? position.X : 'left'; const posY = typeof position.Y === 'string' ? position.Y : 'top'; const useDocBase = element.offsetParent && element.offsetParent.tagName === 'BODY' && isBody; const anchorPos = useDocBase ? calculatePosition(anchorEle, posX, posY) : calculateRelativeBasedPosition(anchorEle, element); let scaleX = 1; let scaleY = 1; const transformElement = getTransformElement(element); if (transformElement) { const transformStyle = getComputedStyle(transformElement); const transform = transformStyle.transform; if (transform && transform !== 'none') { const values = transform.match(/matrix\(([^)]+)\)/); if (values && values[1]) { const parts = values[1].split(',').map(parseFloat); scaleX = parts[0]; scaleY = parts[3]; } } const bodyZoom = getZoomValue(document.body); scaleX = bodyZoom * scaleX; scaleY = bodyZoom * scaleY; } if (targetType === 'relative') { anchorPos.left += posX === 'center' ? (anchorRect.width / 2) : (posX === 'right' ? anchorRect.width : 0); anchorPos.top += posY === 'center' ? (anchorRect.height / 2) : (posY === 'bottom' ? anchorRect.height : 0); } else if (isBody) { anchorPos.left += posX === 'center' ? ((window.innerWidth - eleRect.width) / 2) : (posX === 'right' ? (window.innerWidth - eleRect.width) : 0); anchorPos.top += posY === 'center' ? ((window.innerHeight - eleRect.height) / 2) : (posY === 'bottom' ? (window.innerHeight - eleRect.height) : 0); } else { anchorPos.left += posX === 'center' ? ((anchorRect.width - (eleRect.width / scaleX)) / 2) : (posX === 'right' ? ((anchorRect.width - (eleRect.width / scaleX))) : 0); anchorPos.top += posY === 'center' ? ((anchorRect.height - (eleRect.height / scaleY)) / 2) : (posY === 'bottom' ? ((anchorRect.height - (eleRect.height / scaleY))) : 0); } anchorPos.left += offsetX; anchorPos.top += offsetY; return anchorPos; }; const addScrollListeners = () => { if (actionOnScroll !== ActionOnScrollType.None && getRelateToElement()) { const scrollableParents = getScrollableParent(getRelateToElement()); scrollParents.current = scrollableParents[scrollableParents.length - 1]; scrollParents.current?.addEventListener('scroll', handleScroll, true); } }; const removeScrollListeners = () => { if (actionOnScroll !== ActionOnScrollType.None && getRelateToElement()) { scrollParents.current?.removeEventListener('scroll', handleScroll, true); scrollParents.current = null; } }; const getRelateToElement = () => { const relateToElement = relateTo === '' || relateTo === null || relateTo === 'body' ? document.body : relateTo; return relateToElement; }; const isPartiallyVisibleInContainer = (element, container) => { const elRect = getElementReact(element); if (!elRect) { return false; } if (container === window) { const viewRect = { top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight }; const interWidth = Math.min(elRect.right, viewRect.right) - Math.max(elRect.left, viewRect.left); const interHeight = Math.min(elRect.bottom, viewRect.bottom) - Math.max(elRect.top, viewRect.top); return interWidth > 0 && interHeight > 0; } const cRect = getElementReact(container); if (!cRect) { return false; } const interWidth = Math.min(elRect.right, cRect.right) - Math.max(elRect.left, cRect.left); const interHeight = Math.min(elRect.bottom, cRect.bottom) - Math.max(elRect.top, cRect.top); return interWidth > 0 && interHeight > 0; }; const isElementVisibleAcrossScrollParents = (targetEl) => { const parents = getFixedScrollableParent(targetEl, fixedParent.current); const containers = parents.map((parent) => { return (parent === document.documentElement) ? window : parent; }); if (!containers.includes(window)) { containers.push(window); } for (const container of containers) { if (!isPartiallyVisibleInContainer(targetEl, container)) { return false; } } return true; }; const handleScroll = () => { if (actionOnScroll === ActionOnScrollType.Reposition) { refreshPosition(); } else if (actionOnScroll === ActionOnScrollType.Hide) { hide(); onClose?.(); } const targetEl = targetRef?.current || getRelateToElement(); if (targetEl) { const isVisible = isElementVisibleAcrossScrollParents(targetEl); if (!isVisible && !targetInvisibleRef.current) { onTargetExitViewport?.(); targetInvisibleRef.current = true; } else if (isVisible && targetInvisibleRef.current) { targetInvisibleRef.current = false; } } }; const getScrollableParent = (element) => { checkFixedParent(element); return getFixedScrollableParent(element, fixedParent.current); }; const checkFixedParent = (element) => { let parent = element.parentElement; while (parent && parent.tagName !== 'HTML') { const { position } = getComputedStyle(parent); if (popupRef?.current) { const popupElement = popupRef.current; const popupElementStyle = getComputedStyle(popupElement); if (!popupElement?.offsetParent && position === 'fixed' && popupElementStyle && popupElementStyle.position === 'fixed') { fixedParent.current = true; } parent = parent.parentElement; } } }; const popupStyle = { position: 'absolute', left: `${leftPosition}px`, top: `${topPosition}px`, zIndex: isNaN(popupZIndex) ? 1000 : popupZIndex, width: width, height: height, ...style }; const popupClasses = [ 'sf-popup sf-control sf-lib', (dir === 'rtl') ? 'sf-rtl' : '', popupClass, className ] .filter(Boolean) .join(' '); return (_jsx("div", { ref: popupRef, className: popupClasses, style: popupStyle, ...rest, children: children })); }); export default React.memo(Popup); export { calculatePosition, calculateRelativeBasedPosition, flip, fit, isCollide, getZindexPartial, getFixedScrollableParent };