UNPKG

@syncfusion/react-popups

Version:

A package of Pure React popup components such as Tooltip that is used to display information or messages in separate pop-ups.

490 lines (489 loc) 19.6 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 } 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 * isOpen={true} * relateTo={elementRef} * position={{ X: 'left', Y: 'bottom' }} * > * <div>Popup content</div> * </Popup> * ``` */ export const Popup = forwardRef((props, ref) => { const { children, isOpen = 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, onOpen, onClose, onTargetExitViewport, style, ...rest } = props; const popupRef = useRef(null); const initialOpenState = useRef(isOpen); const [leftPosition, setLeftPosition] = useState(0); const [topPosition, setTopPosition] = useState(0); const [popupClass, setPopupClass] = useState(CLASSNAME_CLOSE); const [fixedParent, setFixedParent] = useState(false); const [popupZIndex, setPopupZIndex] = useState(1000); const { dir } = useProviderContext(); const [currentShowAnimation, setCurrentShowAnimation] = useState(animation.show); const [currentHideAnimation, setCurrentHideAnimation] = useState(animation.hide); const [currentRelatedElement, setRelativeElement] = useState(relativeElement); useImperativeHandle(ref, () => ({ getScrollableParent: (element) => { return getScrollableParent(element); }, refreshPosition: (target, collision) => { refreshPosition(target, collision); }, element: popupRef.current }), []); useEffect(() => { preRender('popup'); }, []); useEffect(() => { updatePosition(); }, [targetRef, position, offsetX, offsetY, viewPortElementRef]); useEffect(() => { checkCollision(); }, [collision]); useEffect(() => { if (!isEqual(currentShowAnimation, animation.show)) { setCurrentShowAnimation(animation.show); } }, [animation.show]); useEffect(() => { if (!isEqual(currentHideAnimation, animation.hide)) { setCurrentHideAnimation(animation.hide); } }, [currentHideAnimation, animation.hide]); useEffect(() => { if (!isOpen && initialOpenState.current === isOpen) { return; } initialOpenState.current = isOpen; if (isOpen) { show(animation.show, currentRelatedElement); } else { hide(animation.hide); } }, [isOpen]); useEffect(() => { setPopupZIndex(zIndex); }, [zIndex]); useEffect(() => { setRelativeElement(relativeElement); }, [relativeElement]); const isEqual = (previousAnimation, currentAnimation) => { return JSON.stringify(previousAnimation) === JSON.stringify(currentAnimation); }; const refreshPosition = (target, collision) => { if (target) { checkFixedParent(target); } updatePosition(); if (!collision) { checkCollision(); } }; const updatePosition = () => { const element = popupRef.current; const relateToElement = getRelateToElement(); let pos = { left: 0, top: 0 }; if (!element) { return; } if (typeof position.X === 'number' && typeof position.Y === 'number') { pos = { left: position.X, top: position.Y }; } 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 = 'block'; pos = getAnchorPosition(relateToElement, element, position, offsetX, offsetY); element.style.display = display; } if ((pos !== null)) { 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) { setPopupClass(CLASSNAME_OPEN); checkCollision(); setPopupClass(CLASSNAME_CLOSE); } if (animationOptions) { animationOptions.begin = () => { setPopupClass(CLASSNAME_OPEN); }; animationOptions.end = () => { onOpen?.(); }; if (Animation) { const animationInstance = Animation(animationOptions); if (animationInstance.animate) { animationInstance.animate(popupRef.current); } } } } }; const hide = (animationOptions) => { if (animationOptions) { 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); } } } removeScrollListeners(); }; const callFit = (param) => { const element = popupRef.current; const viewPortElement = viewPortElementRef?.current; if (!element || !viewPortElement) { return; } if (isCollide(element, viewPortElement).length !== 0) { let data = { left: 0, top: 0 }; if (!viewPortElement) { data = fit(element, viewPortElement, param); } else { const elementRect = element.getBoundingClientRect(); const viewPortRect = viewPortElement.getBoundingClientRect(); if (!elementRect || !viewPortRect) { return; } if (param.Y) { if (viewPortRect.top > elementRect.top) { element.style.top = '0px'; } else if (viewPortRect.bottom < elementRect.bottom) { element.style.top = `${parseInt(element.style.top, 10) - (elementRect.bottom - viewPortRect.bottom)}px`; } } if (param.X) { if (viewPortRect.right < elementRect.right) { element.style.left = `${parseInt(element.style.left, 10) - (elementRect.right - viewPortRect.right)}px`; } else if (viewPortRect.left > elementRect.left) { element.style.left = `${parseInt(element.style.left, 10) + (viewPortRect.left - elementRect.left)}px`; } } } if (param.X) { element.style.left = `${data.left}px`; } if (param.Y) { element.style.top = `${data.top}px`; } } }; 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) { 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 anchorRect = anchorEle.getBoundingClientRect(); const eleRect = element.getBoundingClientRect(); const anchorPos = { left: 0, top: 0 }; const targetTypes = anchorEle.tagName.toUpperCase() === 'BODY' ? 'body' : 'container'; switch (position.X) { default: case 'left': break; case 'center': anchorPos.left = targetTypes === 'body' ? window.innerWidth / 2 - eleRect.width / 2 : anchorRect.left + (anchorRect.width / 2 - eleRect.width / 2); break; case 'right': if (targetTypes === 'container') { const scaleX = 1; anchorPos.left += ((anchorRect.width - eleRect.width) / scaleX); } else { anchorPos.left += (anchorRect.width); } break; } switch (position.Y) { case 'top': break; case 'center': anchorPos.top = targetTypes === 'body' ? window.innerHeight / 2 - eleRect.height / 2 : anchorRect.top + (anchorRect.height / 2 - eleRect.height / 2); break; case 'bottom': anchorPos.top = targetTypes === 'body' ? window.innerHeight - eleRect.height : anchorRect.top + (anchorRect.height - eleRect.height); break; } anchorPos.left += offsetX; anchorPos.top += offsetY; return anchorPos; }; const addScrollListeners = () => { if (actionOnScroll !== ActionOnScrollType.None && getRelateToElement()) { const scrollParents = getScrollableParent(getRelateToElement()); scrollParents.forEach((parent) => { parent.addEventListener('scroll', handleScroll); }); } }; const removeScrollListeners = () => { if (actionOnScroll !== ActionOnScrollType.None && getRelateToElement()) { const scrollParents = getScrollableParent(getRelateToElement()); scrollParents.forEach((parent) => { parent.removeEventListener('scroll', handleScroll); }); } }; const getRelateToElement = () => { const relateToElement = relateTo === '' || relateTo === null || relateTo === 'body' ? document.body : relateTo; return relateToElement; }; const handleScroll = () => { if (actionOnScroll === ActionOnScrollType.Reposition) { refreshPosition(); } else if (actionOnScroll === ActionOnScrollType.Hide) { hide(); onClose?.(); } if (targetRef?.current && !isElementOnViewport(targetRef?.current)) { onTargetExitViewport?.(); } }; const getScrollableParent = (element) => { checkFixedParent(element); return getFixedScrollableParent(element, fixedParent); }; 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') { setFixedParent(true); } parent = parent.parentElement; } } }; const isElementOnViewport = (element) => { const rect = element.getBoundingClientRect(); return (rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth); }; const popupStyle = { position: 'absolute', left: `${leftPosition}px`, top: `${topPosition}px`, zIndex: 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 }; const getZindexPartial = (element) => { let parent = element.parentElement; const parentZindex = []; while (parent) { if (parent.tagName !== 'BODY') { const computedStyle = window.getComputedStyle(parent); const index = computedStyle.zIndex; const position = computedStyle.position; if (index !== 'auto' && position !== 'static') { parentZindex.push(index); } parent = parent.parentElement; } else { break; } } const childrenZindex = []; for (let i = 0; i < document.body.children.length; i++) { const child = document.body.children[i]; if (!element.isEqualNode(child) && child instanceof HTMLElement) { const computedStyle = window.getComputedStyle(child); const index = computedStyle.zIndex; const position = computedStyle.position; if (index !== 'auto' && position !== 'static') { childrenZindex.push(index); } } } childrenZindex.push('999'); const siblingsZindex = []; if (element.parentElement && element.parentElement.tagName !== 'BODY') { const childNodes = Array.from(element.parentElement.children); for (let i = 0; i < childNodes.length; i++) { const child = childNodes[i]; if (!element.isEqualNode(child) && child instanceof HTMLElement) { const computedStyle = window.getComputedStyle(child); const index = computedStyle.zIndex; const position = computedStyle.position; if (index !== 'auto' && position !== 'static') { siblingsZindex.push(index); } } } } const finalValue = parentZindex.concat(childrenZindex, siblingsZindex); const currentZindexValue = Math.max(...finalValue.map(Number)) + 1; return currentZindexValue > 2147483647 ? 2147483647 : currentZindexValue; }; const getFixedScrollableParent = (element, fixedParent = false) => { const scrollParents = []; const overflowRegex = /(auto|scroll)/; let parent = element.parentElement; while (parent && parent.tagName !== 'HTML') { const { position, overflow, overflowY, overflowX } = getComputedStyle(parent); if (!(getComputedStyle(element).position === 'absolute' && position === 'static') && overflowRegex.test(`${overflow} ${overflowY} ${overflowX}`)) { scrollParents.push(parent); } parent = parent.parentElement; } if (!fixedParent) { scrollParents.push(document.documentElement); } return scrollParents; };