UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

292 lines (286 loc) • 9.98 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import React from 'react'; import createFocusTrap from 'focus-trap'; import rafSchedule from 'raf-schd'; import { createPortal, flushSync } from 'react-dom'; import { akEditorFloatingPanelZIndex } from '@atlaskit/editor-shared-styles'; import { calculatePlacement, calculatePosition, findOverflowScrollParent, validatePosition } from './utils'; export default class Popup extends React.Component { constructor(...args) { var _window; super(...args); _defineProperty(this, "rafIds", new Set()); _defineProperty(this, "state", { validPosition: true }); _defineProperty(this, "popupRef", /*#__PURE__*/React.createRef()); _defineProperty(this, "placement", ['', '']); _defineProperty(this, "handleRef", popup => { if (!popup) { return; } this.initPopup(popup); }); _defineProperty(this, "scheduledUpdatePosition", rafSchedule(props => { this.updatePosition(this.props); })); _defineProperty(this, "onResize", () => this.scheduledUpdatePosition(this.props)); _defineProperty(this, "resizeObserver", (_window = window) !== null && _window !== void 0 && _window.ResizeObserver ? new ResizeObserver(() => { this.scheduledUpdatePosition(this.props); }) : undefined); /** * Raf scheduled so that it also occurs after the initial update position */ _defineProperty(this, "initFocusTrap", rafSchedule(() => { const popup = this.popupRef.current; if (!popup) { return; } const trapConfig = { clickOutsideDeactivates: true, escapeDeactivates: true, initialFocus: popup, fallbackFocus: popup, returnFocusOnDeactivate: false }; this.focusTrap = createFocusTrap(popup, trapConfig); this.focusTrap.activate(); })); } /** * Calculates new popup position */ calculatePosition(props, popup) { const { target, fitHeight, fitWidth, boundariesElement, offset, onPositionCalculated, onPlacementChanged, alignX, alignY, stick, forcePlacement, allowOutOfBounds, rect, preventOverflow, absoluteOffset } = props; if (!target || !popup) { return {}; } const placement = calculatePlacement(target, boundariesElement || document.body, fitWidth, fitHeight, alignX, alignY, forcePlacement, preventOverflow); if (onPlacementChanged && this.placement.join('') !== placement.join('')) { onPlacementChanged(placement); this.placement = placement; } let position = calculatePosition({ placement, popup, target, stick, offset: offset, allowOutOfBounds, rect }); position = onPositionCalculated ? onPositionCalculated(position) : position; if (typeof position.top !== 'undefined' && absoluteOffset !== null && absoluteOffset !== void 0 && absoluteOffset.top) { position.top = position.top + absoluteOffset.top; } if (typeof position.bottom !== 'undefined' && absoluteOffset !== null && absoluteOffset !== void 0 && absoluteOffset.bottom) { position.bottom = position.bottom + absoluteOffset.bottom; } if (typeof position.right !== 'undefined' && absoluteOffset !== null && absoluteOffset !== void 0 && absoluteOffset.right) { position.right = position.right + absoluteOffset.right; } if (typeof position.left !== 'undefined' && absoluteOffset !== null && absoluteOffset !== void 0 && absoluteOffset.left) { position.left = position.left + absoluteOffset.left; } return { position, validPosition: validatePosition(target) }; } updatePosition(props = this.props, state = this.state) { const { popup } = state; const { position, validPosition } = this.calculatePosition(props, popup); if (position && validPosition) { flushSync(() => { this.setState({ position, validPosition }); }); } } cannotSetPopup(popup, target, overflowScrollParent) { /** * Check whether: * 1. Popup's offset targets which means whether or not its possible to correctly position popup along with given target. * 2. Popup is inside "overflow: scroll" container, but its offset parent isn't. * * Currently Popup isn't capable of position itself correctly in case 2, * Add "position: relative" to "overflow: scroll" container or to some other FloatingPanel wrapper inside it. */ return !target || document.body.contains(target) && popup.offsetParent && !popup.offsetParent.contains(target) || overflowScrollParent && !overflowScrollParent.contains(popup.offsetParent); } /** * Popup initialization. * Checks whether it's possible to position popup along given target, and if it's not throws an error. */ initPopup(popup) { this.popupRef.current = popup; const { target } = this.props; const overflowScrollParent = findOverflowScrollParent(popup); if (this.cannotSetPopup(popup, target, overflowScrollParent)) { return; } this.setState({ popup }); /** * Some plugins (like image) have async rendering of component in floating toolbar(which is popup). * Now, floating toolbar position depends on it's size. * Size of floating toolbar changes, when async component renders. * There is currently, no way to re position floating toolbar or * better to not show floating toolbar till all the async component are ready to render. * Also, it is not even Popup's responsibility to take care of it as popup's children are passed * as a prop. * So, calling scheduledUpdatePosition to position popup on next request animation frame, * which is currently working for most of the floating toolbar and other popups. */ this.scheduledUpdatePosition(this.props); if (this.props.focusTrap) { this.initFocusTrap(); } } UNSAFE_componentWillReceiveProps(newProps) { // We are delaying `updatePosition` otherwise it happens before the children // get rendered and we end up with a wrong position this.scheduledUpdatePosition(newProps); } /** * Cancels the initialisation of the focus trap if it has not yet occured * Deactivates the focus trap if it exists */ destroyFocusTrap() { var _this$focusTrap; this.initFocusTrap.cancel(); (_this$focusTrap = this.focusTrap) === null || _this$focusTrap === void 0 ? void 0 : _this$focusTrap.deactivate(); } /** * Handle pausing, unpausing, and initialising (if not yet initialised) of the focus trap */ handleChangedFocusTrapProp(prevProps) { if (prevProps.focusTrap !== this.props.focusTrap) { // If currently set to disable, then pause the trap if it exists if (!this.props.focusTrap) { var _this$focusTrap2; return (_this$focusTrap2 = this.focusTrap) === null || _this$focusTrap2 === void 0 ? void 0 : _this$focusTrap2.pause(); } // If set to enabled and trap already exists, unpause if (this.focusTrap) { this.focusTrap.unpause(); } // Else initialise the focus trap return this.initFocusTrap(); } } componentDidUpdate(prevProps) { this.handleChangedFocusTrapProp(prevProps); } componentDidMount() { window.addEventListener('resize', this.onResize); const { stick } = this.props; this.scrollParentElement = findOverflowScrollParent(this.props.target); if (this.scrollParentElement && this.resizeObserver) { this.resizeObserver.observe(this.scrollParentElement); } if (stick) { this.scrollElement = this.scrollParentElement; } else { this.scrollElement = this.props.scrollableElement; } if (this.scrollElement) { this.scrollElement.addEventListener('scroll', this.onResize); } } componentWillUnmount() { window.removeEventListener('resize', this.onResize); if (this.scrollElement) { this.scrollElement.removeEventListener('scroll', this.onResize); } if (this.scrollParentElement && this.resizeObserver) { this.resizeObserver.unobserve(this.scrollParentElement); } this.scheduledUpdatePosition.cancel(); this.destroyFocusTrap(); const { onUnmount } = this.props; if (onUnmount) { onUnmount(); } } renderPopup() { const { position } = this.state; const { shouldRenderPopup } = this.props; if (shouldRenderPopup && !shouldRenderPopup(position || {})) { return null; } //In some cases we don't want to use default "Popup" text as an aria-label. It might be tedious for screen reader users. const ariaLabel = this.props.ariaLabel === null ? undefined : this.props.ariaLabel || 'Popup'; return /*#__PURE__*/React.createElement("div", { ref: this.handleRef, style: { position: 'absolute', zIndex: this.props.zIndex || akEditorFloatingPanelZIndex, ...position, ...this.props.style }, "aria-label": ariaLabel, "data-testid": "popup-wrapper" // Indicates component is an editor pop. Required for focus handling in Message.tsx , "data-editor-popup": true }, this.props.children); } render() { const { target, mountTo } = this.props; const { validPosition } = this.state; if (!target || !validPosition) { return null; } if (mountTo) { return /*#__PURE__*/createPortal(this.renderPopup(), mountTo); } // Without mountTo property renders popup as is, // which means it will be cropped by "overflow: hidden" container. return this.renderPopup(); } } _defineProperty(Popup, "defaultProps", { offset: [0, 0], allowOutOfBound: false }); export { findOverflowScrollParent } from './utils';