@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
JavaScript
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 };