UNPKG

@itwin/core-react

Version:

A react component library of iTwin.js UI general purpose components

467 lines 20.6 kB
/*--------------------------------------------------------------------------------------------- * 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