@itwin/core-react
Version:
A react component library of iTwin.js UI general purpose components
467 lines • 20.6 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module Popup
*/
import "./Popup.scss";
import classnames from "classnames";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Key } from "ts-key-enum";
import { RelativePosition } from "@itwin/appui-abstract";
import { FocusTrap } from "../focustrap/FocusTrap.js";
import { Rectangle } from "../utils/Rectangle.js";
import { ThemeProvider } from "@itwin/itwinui-react";
/** @internal */
export const PopupContext = React.createContext(undefined);
/** Popup React component displays a popup relative to an optional target element.
* @note Avoid nesting {@link Popup} and {@link https://itwinui.bentley.com/docs/popover iTwinUI Popover} components.
* @public
* @deprecated in 4.15.0. Use {@link https://itwinui.bentley.com/docs/popover iTwinUI Popover} instead.
*/
// eslint-disable-next-line @typescript-eslint/no-deprecated
export class Popup extends React.Component {
// eslint-disable-next-line @typescript-eslint/no-deprecated
constructor(props) {
super(props);
this._popup = null;
this._bindWindowEvents = () => {
const activeWindow = this.getParentWindow();
activeWindow.addEventListener("pointerdown", this._handleOutsideClick);
activeWindow.addEventListener("resize", this._resize);
activeWindow.addEventListener("contextmenu", this._handleContextMenu);
activeWindow.addEventListener("scroll", this._hide);
activeWindow.addEventListener("wheel", this._handleWheel);
activeWindow.addEventListener("keydown", this._handleKeyboard);
};
this._unBindWindowEvents = () => {
const activeWindow = this.getParentWindow();
activeWindow.removeEventListener("pointerdown", this._handleOutsideClick);
activeWindow.removeEventListener("resize", this._resize);
activeWindow.removeEventListener("contextmenu", this._handleContextMenu);
activeWindow.removeEventListener("scroll", this._hide);
activeWindow.removeEventListener("wheel", this._handleWheel);
activeWindow.removeEventListener("keydown", this._handleKeyboard);
};
this._handleWheel = (event) => {
if (this._popup && this._popup.contains(event.target))
return;
if (this.props.onWheel)
return this.props.onWheel(event);
const closeOnWheel = this.props.closeOnWheel !== undefined ? this.props.closeOnWheel : true;
if (closeOnWheel)
this._hide();
};
this._handleOutsideClick = (event) => {
if (this._popup && this._popup.contains(event.target))
return;
if (this.props.isPinned)
return;
if (!this.props.closeOnNestedPopupOutsideClick &&
this.isInCorePopup(event.target))
return;
if (this.props.onOutsideClick)
return this.props.onOutsideClick(event);
if (this.props.target && this.props.target.contains(event.target))
return;
this._onClose();
};
this._handleContextMenu = (event) => {
if (this._popup && this._popup.contains(event.target))
return;
if (this.props.onContextMenu)
return this.props.onContextMenu(event);
const closeOnContextMenu = this.props.closeOnContextMenu !== undefined
? this.props.closeOnContextMenu
: true;
if (closeOnContextMenu)
this._hide();
};
this._handleKeyboard = (event) => {
if (this.props.isPinned)
return;
if (event.key === Key.Escape.valueOf() ||
event.key === Key.Enter.valueOf()) {
const closeOnEnter = this.props.closeOnEnter !== undefined ? this.props.closeOnEnter : true;
if (event.key === Key.Enter.valueOf()) {
if (closeOnEnter)
this._onClose(true);
else
this.props.onEnter && this.props.onEnter();
}
else {
this._onClose(false);
}
}
};
this._resize = () => {
if (this.props.repositionOnResize) {
const position = this._toggleRelativePosition();
const point = this._fitPopup(this._getPosition(position));
this.setState({ left: point.x, top: point.y, position });
}
else {
this._hide(); // legacy behavior
}
};
this._hide = () => {
if (this.props.isPinned)
return;
this._onClose();
};
this._getPosition = (position) => {
const activeWindow = this.getParentWindow();
const { target, offset, top, left } = this.props;
const offsetArrow = this.props.showArrow ? 6 : 0;
// absolute position
if (this._isPositionAbsolute())
return { x: left, y: top };
// sanity check
const point = { x: 0, y: 0 };
if (!this._popup || !target)
return point;
// relative position
const scrollY = activeWindow.scrollY;
const scrollX = activeWindow.scrollX;
const container = this.getContainer();
const containerBounds = container.getBoundingClientRect();
const targetRect = Rectangle.create(target.getBoundingClientRect()).offset({
x: -containerBounds.left,
y: -containerBounds.top,
});
const { popupWidth, popupHeight } = this._getPopupDimensions();
switch (position) {
case RelativePosition.Top:
point.y = scrollY + targetRect.top - popupHeight - offset - offsetArrow;
point.x =
scrollX +
targetRect.left +
targetRect.getWidth() / 2 -
popupWidth / 2;
break;
case RelativePosition.TopLeft:
point.y = scrollY + targetRect.top - popupHeight - offset - offsetArrow;
point.x = scrollX + targetRect.left;
break;
case RelativePosition.TopRight:
point.y = scrollY + targetRect.top - popupHeight - offset - offsetArrow;
point.x = scrollX + targetRect.right - popupWidth;
break;
case RelativePosition.Bottom:
point.y = scrollY + targetRect.bottom + offset + offsetArrow;
point.x =
scrollX +
targetRect.left +
targetRect.getWidth() / 2 -
popupWidth / 2;
break;
case RelativePosition.BottomLeft:
point.y = scrollY + targetRect.bottom + offset + offsetArrow;
point.x = scrollX + targetRect.left;
break;
case RelativePosition.BottomRight:
point.y = scrollY + targetRect.bottom + offset + offsetArrow;
point.x = scrollX + targetRect.right - popupWidth;
break;
case RelativePosition.Left:
point.y =
scrollY +
targetRect.top +
targetRect.getHeight() / 2 -
popupHeight / 2;
point.x = scrollX + targetRect.left - popupWidth - offset - offsetArrow;
break;
case RelativePosition.LeftTop:
point.y = scrollY + targetRect.top;
point.x = scrollX + targetRect.left - popupWidth - offset - offsetArrow;
break;
case RelativePosition.Right:
point.y =
scrollY +
targetRect.top +
targetRect.getHeight() / 2 -
popupHeight / 2;
point.x = scrollX + targetRect.right + offset + offsetArrow;
break;
case RelativePosition.RightTop:
point.y = scrollY + targetRect.top;
point.x = scrollX + targetRect.right + offset + offsetArrow;
break;
}
return point;
};
// fit the popup within the extents of the view port
this._fitPopup = (point) => {
const fittedPoint = point;
if (!this._popup) {
return fittedPoint;
}
const { popupWidth, popupHeight } = this._getPopupDimensions();
const container = this.getContainer();
const containerBounds = container.getBoundingClientRect();
if (fittedPoint.y + popupHeight > containerBounds.height) {
fittedPoint.y = containerBounds.height - popupHeight;
}
if (fittedPoint.x + popupWidth > containerBounds.width) {
fittedPoint.x = containerBounds.width - popupWidth;
}
if (fittedPoint.y < 0) {
fittedPoint.y = 0;
}
if (fittedPoint.x < 0) {
fittedPoint.x = 0;
}
return fittedPoint;
};
this._handleAnimationEnd = (event) => {
if (event.target === this._popup) {
this.setState({ animationEnded: true });
}
};
this._handleThemeProviderRef = (el) => {
this.setState({ portalContainer: el ?? undefined });
};
const parentDocument = this.props.target?.ownerDocument ?? document;
this.state = {
animationEnded: false,
isOpen: this.props.isOpen,
top: 0,
left: 0,
position: this.props.position,
parentDocument,
};
}
componentDidMount() {
if (this.props.isOpen) {
this._onShow();
}
}
getParentWindow() {
return this.state.parentDocument.defaultView ?? window;
}
/** @internal */
componentDidUpdate(previousProps, // eslint-disable-line @typescript-eslint/no-deprecated
prevState) {
if (this.state.position !== prevState.position) {
this.setState({ animationEnded: false });
}
if (this.props.target !== previousProps.target) {
{
const parentDocument = this.props.target?.ownerDocument ?? document;
if (parentDocument !== this.state.parentDocument) {
this._unBindWindowEvents();
this.setState({ parentDocument });
}
}
}
if (this.props.isOpen === previousProps.isOpen) {
if (this.props.isOpen) {
const position = this._toggleRelativePosition();
const point = this._fitPopup(this._getPosition(position));
if (Math.abs(this.state.left - point.x) < 3 &&
Math.abs(this.state.top - point.y) < 3 &&
this.state.position === position)
return;
this.setState({
left: point.x,
top: point.y,
position,
});
}
return;
}
if (this.props.isOpen) {
this._onShow();
}
else {
this._onClose();
}
}
componentWillUnmount() {
this._unBindWindowEvents();
}
isInCorePopup(element) {
if (element.nodeName === "DIV") {
if (element.classList && element.classList.contains("core-popup"))
return true;
if (element.parentElement && this.isInCorePopup(element.parentElement))
return true;
}
else {
if (element.parentElement && this.isInCorePopup(element.parentElement))
return true;
}
return false;
}
_onShow() {
this._bindWindowEvents();
const position = this._toggleRelativePosition();
const point = this._fitPopup(this._getPosition(position));
this.setState({ left: point.x, top: point.y, isOpen: true, position }, () => {
if (this.props.onOpen)
this.props.onOpen();
});
}
_onClose(enterKey) {
if (!this.state.isOpen)
return;
this._unBindWindowEvents();
this.setState({ isOpen: false }, () => {
if (enterKey && this.props.onEnter)
this.props.onEnter();
if (this.props.onClose)
this.props.onClose();
});
}
_isPositionAbsolute() {
return this.props.top !== -1 && this.props.left !== -1;
}
_getClassNameByPosition(position) {
if (!this._isPositionAbsolute()) {
switch (position) {
case RelativePosition.TopLeft:
return "core-popup-top-left";
case RelativePosition.TopRight:
return "core-popup-top-right";
case RelativePosition.BottomLeft:
return "core-popup-bottom-left";
case RelativePosition.BottomRight:
return "core-popup-bottom-right";
case RelativePosition.Top:
return "core-popup-top";
case RelativePosition.Left:
return "core-popup-left";
case RelativePosition.Right:
return "core-popup-right";
case RelativePosition.Bottom:
return "core-popup-bottom";
case RelativePosition.LeftTop:
return "core-popup-left-top";
case RelativePosition.RightTop:
return "core-popup-right-top";
}
}
return "";
}
_getPopupDimensions() {
let popupWidth = 0;
let popupHeight = 0;
if (this._popup) {
const activeWindow = this.getParentWindow();
const style = activeWindow.getComputedStyle(this._popup);
const borderLeftWidth = parsePxString(style.borderLeftWidth);
const borderRightWidth = parsePxString(style.borderRightWidth);
const borderTopWidth = parsePxString(style.borderTopWidth);
const borderBottomWidth = parsePxString(style.borderBottomWidth);
popupWidth = this._popup.clientWidth + borderLeftWidth + borderRightWidth;
popupHeight =
this._popup.clientHeight + borderTopWidth + borderBottomWidth;
}
return { popupWidth, popupHeight };
}
_toggleRelativePosition() {
const { target, position, offset } = this.props;
if (!this._popup || !target)
return position;
if (this._isPositionAbsolute())
return position;
let newPosition = position;
const activeWindow = this.getParentWindow();
// Note: Cannot use DOMRect yet since it's experimental and not available in all browsers (Nov. 2018)
const viewportRect = {
left: activeWindow.scrollX,
top: activeWindow.scrollY,
right: activeWindow.scrollX + activeWindow.innerWidth,
bottom: activeWindow.scrollY + activeWindow.innerHeight,
};
const targetRect = target.getBoundingClientRect();
const { popupWidth, popupHeight } = this._getPopupDimensions();
const containerStyle = activeWindow.getComputedStyle(target);
const offsetArrow = this.props.showArrow ? 10 : 2;
const bottomMargin = parseMargin(containerStyle.marginBottom);
if (targetRect.bottom + popupHeight + bottomMargin + offsetArrow + offset >
viewportRect.bottom) {
if (newPosition === RelativePosition.Bottom)
newPosition = RelativePosition.Top;
else if (newPosition === RelativePosition.BottomLeft)
newPosition = RelativePosition.TopLeft;
else if (newPosition === RelativePosition.BottomRight)
newPosition = RelativePosition.TopRight;
}
const topMargin = parseMargin(containerStyle.marginTop);
if (targetRect.top - popupHeight - topMargin - offsetArrow - offset <
viewportRect.top) {
if (newPosition === RelativePosition.Top)
newPosition = RelativePosition.Bottom;
else if (newPosition === RelativePosition.TopLeft)
newPosition = RelativePosition.BottomLeft;
else if (newPosition === RelativePosition.TopRight)
newPosition = RelativePosition.BottomRight;
}
const leftMargin = parseMargin(containerStyle.marginLeft);
if (targetRect.left - popupWidth - leftMargin - offsetArrow - offset <
viewportRect.left) {
if (newPosition === RelativePosition.Left)
newPosition = RelativePosition.Right;
else if (newPosition === RelativePosition.LeftTop)
newPosition = RelativePosition.RightTop;
}
const rightMargin = parseMargin(containerStyle.marginRight);
if (targetRect.right + popupWidth + rightMargin + offsetArrow + offset >
viewportRect.right) {
if (newPosition === RelativePosition.Right)
newPosition = RelativePosition.Left;
else if (newPosition === RelativePosition.RightTop)
newPosition = RelativePosition.LeftTop;
}
return newPosition;
}
render() {
const animate = this.props.animate !== undefined ? this.props.animate : true;
const className = classnames("core-popup", this._getClassNameByPosition(this.state.position), this.props.showShadow && "core-popup-shadow", this.props.showArrow && "arrow", !animate && "core-popup-animation-none", this.props.className, this.state.animationEnded && "core-animation-ended", !this.props.isOpen &&
this.props.keepContentsMounted &&
"core-popup-hidden");
const style = {
top: this.state.top,
left: this.state.left,
...this.props.style,
};
const role = this.props.role ? this.props.role : "dialog"; // accessibility property
if (!this.props.isOpen && !this.props.keepContentsMounted) {
return null;
}
return ReactDOM.createPortal(React.createElement("div", { className: className, "data-testid": "core-popup", ref: (element) => {
this._popup = element;
}, style: style, role: role, "aria-modal": true, tabIndex: -1, "aria-label": this.props.ariaLabel, onAnimationEnd: this._handleAnimationEnd },
React.createElement(ThemeProvider, { ref: this._handleThemeProviderRef, portalContainer: this.state.portalContainer },
React.createElement(FocusTrap, { active: !!this.props.moveFocus, initialFocusElement: this.props.focusTarget, returnFocusOnDeactivate: true }, this.props.children))), this.getContainer());
}
getContainer() {
return (this.props.portalTarget ??
this.context ??
this.state.parentDocument.body.querySelector('[data-root-container="appui-root-id"]') ??
this.state.parentDocument.body);
}
}
/** @internal */
Popup.contextType = PopupContext;
// eslint-disable-next-line @typescript-eslint/no-deprecated
Popup.defaultProps = {
position: RelativePosition.Bottom,
showShadow: true,
showArrow: false,
isOpen: false,
offset: 4,
top: -1,
left: -1,
};
function parsePxString(pxStr) {
const parsed = parseInt(pxStr, 10);
return parsed || 0;
}
function parseMargin(value) {
return value ? parseFloat(value) : 0;
}
//# sourceMappingURL=Popup.js.map