@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.
560 lines (559 loc) • 22.5 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 } 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 = {}));
/**
* Defines the possible reference types for positioning a popup element.
*/
export var TargetType;
(function (TargetType) {
/**
* Uses the immediate container element as the reference for positioning.
* The popup will be positioned relative to its parent container element.
*/
TargetType["Container"] = "Container";
/**
* Uses a custom specified element as the reference for positioning.
* The popup will be positioned relative to this specified element.
*/
TargetType["Relative"] = "Relative";
/**
* Uses the document body as the reference for positioning.
* The popup will be positioned relative to the document body, allowing it to be
* placed anywhere on the page regardless of parent container boundaries.
*/
TargetType["Body"] = "Body";
})(TargetType || (TargetType = {}));
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 }, showAnimation = {
name: 'FadeIn',
duration: 0,
timingFunction: 'ease-out'
}, hideAnimation = {
name: 'FadeOut',
duration: 0,
timingFunction: 'ease-out'
}, relateTo = 'body', viewPortElementRef, zIndex = 1000, width = 'auto', height = 'auto', className = '', actionOnScroll = ActionOnScrollType.Reposition, onOpen, onClose, targetType = TargetType.Container, onTargetExitViewport, style, ...rest } = props;
let targetTypes = targetType.toString();
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(showAnimation);
const [currentHideAnimation, setCurrentHideAnimation] = useState(hideAnimation);
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, showAnimation)) {
setCurrentShowAnimation(showAnimation);
}
}, [showAnimation]);
useEffect(() => {
if (!isEqual(currentHideAnimation, hideAnimation)) {
setCurrentHideAnimation(hideAnimation);
}
}, [hideAnimation]);
useEffect(() => {
if (!isOpen && initialOpenState.current === isOpen) {
return;
}
initialOpenState.current = isOpen;
if (isOpen) {
show(showAnimation, currentRelatedElement);
}
else {
hide(hideAnimation);
}
}, [isOpen]);
useEffect(() => {
setPopupZIndex(zIndex);
}, [zIndex]);
useEffect(() => {
setRelativeElement(relativeElement);
}, [relativeElement]);
/**
* Compares two objects for equality by converting them to JSON strings.
*
* @param {any} obj1 - The first object to compare
* @param {any} obj2 - The second object to compare
* @returns {boolean} True if the objects are equal, false otherwise
*/
function isEqual(obj1, obj2) {
return JSON.stringify(obj1) === JSON.stringify(obj2);
}
/**
* Based on the `relative` element and `offset` values, `Popup` element position will refreshed.
*
* @param {HTMLElement} target - The target element.
* @param {boolean} collision - Specifies whether to check for collision.
* @returns {void}
*/
function refreshPosition(target, collision) {
if (target) {
checkFixedParent(target);
}
updatePosition();
if (!collision) {
checkCollision();
}
}
/**
* Update the position of the popup based on the target reference and specified position and offset.
*
* @returns {void}
*/
function updatePosition() {
if (!popupRef.current) {
return;
}
const relateToElement = getRelateToElement();
let pos = { left: 0, top: 0 };
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')) && targetRef?.current) {
const anchorPosition = getAnchorPosition(targetRef.current, popupRef.current, position, offsetX, offsetY);
if (typeof position.X === 'string') {
pos = { left: anchorPosition.left, top: position.Y };
}
else {
pos = { left: position.X, top: anchorPosition.top };
}
}
else if (typeof position.X === 'string' && typeof position.Y === 'string' && targetRef?.current) {
pos = calculatePosition(targetRef, position.X.toLowerCase(), position.Y.toLowerCase());
pos.left += offsetX;
pos.top += offsetY;
}
else if (relateToElement) {
pos = getAnchorPosition(relateToElement, popupRef.current, position, offsetX, offsetY);
}
if (pos) {
setLeftPosition(pos.left);
setTopPosition(pos.top);
}
}
/**
* Shows the popup element from screen.
*
* @returns {void}
* @param {AnimationOptions } animationOptions - specifies the model
* @param { HTMLElement } relativeElement - To calculate the zIndex value dynamically.
*/
function 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);
}
}
}
}
}
/**
* Hides the popup element from screen.
*
* @param {AnimationOptions} animationOptions - To give the animation options.
* @returns {void}
*/
function 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();
}
/**
* Update the position of the popup based on the target reference and specified position and offset.
*
* @returns {void}
*/
function checkCollision() {
if (!popupRef.current || !targetRef?.current) {
return;
}
const pos = { left: 0, top: 0 };
let isPositionUpdated = false;
if (collision.X !== CollisionType.None || collision.Y !== CollisionType.None) {
const flippedPos = flip(popupRef, targetRef, offsetX, offsetY, typeof position.X === 'string' ? position.X : 'left', typeof position.Y === 'string' ? position.Y : 'top', viewPortElementRef, { X: collision.X === CollisionType.Flip, Y: collision.Y === CollisionType.Flip });
if (flippedPos) {
pos.left = flippedPos.left;
pos.top = flippedPos.top;
isPositionUpdated = true;
}
if (collision.X === CollisionType.Fit || collision.Y === CollisionType.Fit) {
const fittedPos = fit(popupRef, viewPortElementRef, {
X: collision.X === CollisionType.Fit,
Y: collision.Y === CollisionType.Fit
}, pos);
if (fittedPos) {
pos.left = fittedPos.left;
pos.top = fittedPos.top;
isPositionUpdated = true;
}
}
}
if (isPositionUpdated) {
setLeftPosition(pos.left);
setTopPosition(pos.top);
}
}
/**
* Calculates and returns the anchor position for a popup element.
* This function takes into account the current position, any offsets provided,
* and the dimensions of the target and popup elements to calculate the appropriate
* position for the popup.
*
* @param {HTMLElement} anchorEle - The reference to the target element. This is the element to which the popup is anchored.
* @param {HTMLElement} element - The reference to the popup element. This is the element that needs to be positioned.
* @param {PositionAxis} position - An object defining the initial x and y positions.
* @param {number} offsetX - Optional x-axis offset for fine-tuning the popup's position.
* @param {number} offsetY - Optional y-axis offset for fine-tuning the popup's position.
* @returns {OffsetPosition} An object containing the calculated x and y positions for the popup.
*/
function getAnchorPosition(anchorEle, element, position, offsetX, offsetY) {
const anchorRect = anchorEle.getBoundingClientRect();
const eleRect = element.getBoundingClientRect();
const anchorPos = { left: 0, top: 0 };
targetTypes = anchorEle.tagName.toUpperCase() === 'BODY' ? 'body' : 'container';
switch (position.X) {
case 'center':
anchorPos.left = targetTypes === 'body' ? window.innerWidth / 2 - eleRect.width / 2
: anchorRect.left + (anchorRect.width / 2 - eleRect.width / 2);
break;
case 'right':
anchorPos.left = targetTypes === 'body' ? window.innerWidth - eleRect.width
: anchorRect.left + (anchorRect.width - eleRect.width);
break;
default:
anchorPos.left = anchorRect.left;
}
switch (position.Y) {
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;
default:
anchorPos.top = anchorRect.top;
}
anchorPos.left += offsetX;
anchorPos.top += offsetY;
return anchorPos;
}
/**
* Adds scroll listeners to handle popup positioning when the target container is scrolled.
*
* @returns {void}
*/
function addScrollListeners() {
if (actionOnScroll !== ActionOnScrollType.None && getRelateToElement()) {
const scrollParents = getScrollableParent(getRelateToElement());
scrollParents.forEach((parent) => {
parent.addEventListener('scroll', handleScroll);
});
}
}
/**
* Removes scroll listeners.
*
* @returns {void}
*/
function removeScrollListeners() {
if (actionOnScroll !== ActionOnScrollType.None && getRelateToElement()) {
const scrollParents = getScrollableParent(getRelateToElement());
scrollParents.forEach((parent) => {
parent.removeEventListener('scroll', handleScroll);
});
}
}
/**
* Determines the element to which the popup is related.
*
* @returns {HTMLElement} The HTMLElement that the popup is related to.
*/
function getRelateToElement() {
const relateToElement = relateTo === '' || relateTo == null ? document.body : relateTo;
return typeof relateToElement === 'string' ? document.querySelector(relateToElement) : relateToElement;
}
/**
* Handle scroll event to optionally reposition or hide the popup.
*
* @returns {void}
*/
function handleScroll() {
if (actionOnScroll === ActionOnScrollType.Reposition) {
refreshPosition();
}
else if (actionOnScroll === ActionOnScrollType.Hide) {
hide();
onClose?.();
}
if (targetRef?.current && !isElementOnViewport(targetRef?.current)) {
onTargetExitViewport?.();
}
}
/**
* Identifies scrollable parents of a given element, used to attach scroll event listeners.
*
* @param {HTMLElement} element - The element to find scrollable parents for.
* @returns {Element[]} An array of scrollable parent elements.
*/
function getScrollableParent(element) {
checkFixedParent(element);
return getFixedScrollableParent(element, fixedParent);
}
/**
* Checks for fixed or sticky positioned ancestors and updates popup positioning.
*
* @param {HTMLElement} element - The starting element to check for fixed or sticky parents.
* @returns {void} This function does not return a value.
*/
function 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;
}
}
}
/**
* Checks if a given element is fully visible in the current viewport.
*
* @param {Element} element - The element to check.
* @returns {boolean} True if the element is within the viewport, otherwise false.
*/
function 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 };
/**
* Gets the maximum z-index of the given element.
*
* @returns {void}
* @param { HTMLElement } element - Specify the element to get the maximum z-index of it.
* @private
*/
function 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;
}
/**
* Gets scrollable parent elements for the given element.
*
* @param {HTMLElement} element - The element to get the scrollable parents of.
* @param {boolean} [fixedParent] - Whether to include fixed-positioned parents.
* @returns {HTMLElement[]} An array of scrollable parent elements.
*/
function 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;
}