@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
384 lines (376 loc) • 15 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import React from 'react';
import { bind } from 'bind-event-listener';
import createFocusTrap from 'focus-trap';
import rafSchedule from 'raf-schd';
import { createPortal, flushSync } from 'react-dom';
import { akEditorFloatingPanelZIndex } from '@atlaskit/editor-shared-styles';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { calculatePlacement, calculatePosition, findOverflowScrollParent, validatePosition } from './utils';
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/react/no-class-components
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 defaultTrapConfig = {
clickOutsideDeactivates: true,
escapeDeactivates: true,
initialFocus: popup,
fallbackFocus: popup,
returnFocusOnDeactivate: false
};
const trapConfig = typeof this.props.focusTrap === 'boolean' ? defaultTrapConfig : {
...defaultTrapConfig,
...this.props.focusTrap
};
this.focusTrap = createFocusTrap(popup, editorExperiment('platform_editor_block_menu', true) ? trapConfig : defaultTrapConfig);
this.focusTrap.activate();
}));
}
/**
* Calculates new popup position
*/
calculatePosition(props, popup) {
const {
target,
fitHeight,
fitWidth,
boundariesElement,
minPopupMargin,
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,
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
offset: offset,
allowOutOfBounds,
rect,
boundariesElement: boundariesElement || document.body,
minPopupMargin,
scrollableElement: stick && (expValEquals('platform_editor_fix_scrolling_popup_position', 'isEnabled', true) || expValEquals('create_work_item_modernization_exp', 'isEnabled', true)) ? this.scrollElement : undefined
});
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 &&
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
!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) {
if (this.popupRef.current) {
var _this$resizeObserver;
(_this$resizeObserver = this.resizeObserver) === null || _this$resizeObserver === void 0 ? void 0 : _this$resizeObserver.unobserve(this.popupRef.current);
}
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();
}
if (this.popupRef.current) {
var _this$resizeObserver2;
(_this$resizeObserver2 = this.resizeObserver) === null || _this$resizeObserver2 === void 0 ? void 0 : _this$resizeObserver2.observe(this.popupRef.current);
}
}
/**
* 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();
}
}
/**
* Idempotent scroll element setup — tears down any previous listener/observer
* then attaches to the current scroll parent.
*/
initScrollElement() {
var _this$unbindScroll;
const {
stick,
target,
scrollableElement
} = this.props;
(_this$unbindScroll = this.unbindScroll) === null || _this$unbindScroll === void 0 ? void 0 : _this$unbindScroll.call(this);
if (this.scrollElement && this.resizeObserver) {
this.resizeObserver.unobserve(this.scrollElement);
}
this.scrollElement = scrollableElement;
if (stick && !this.scrollElement && target) {
this.scrollElement = findOverflowScrollParent(target);
}
if (this.scrollElement) {
if (this.resizeObserver) {
this.resizeObserver.observe(this.scrollElement);
}
this.unbindScroll = bind(this.scrollElement, {
type: 'scroll',
listener: this.onResize
});
}
}
componentDidUpdate(prevProps) {
this.handleChangedFocusTrapProp(prevProps);
if ((expValEquals('platform_editor_fix_scrolling_popup_position', 'isEnabled', true) || expValEquals('create_work_item_modernization_exp', 'isEnabled', true)) && prevProps.scrollableElement !== this.props.scrollableElement) {
this.initScrollElement();
}
if (this.props !== prevProps) {
this.scheduledUpdatePosition(prevProps);
}
}
componentDidMount() {
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
window.addEventListener('resize', this.onResize);
if (expValEquals('platform_editor_fix_scrolling_popup_position', 'isEnabled', true) || expValEquals('create_work_item_modernization_exp', 'isEnabled', true)) {
this.initScrollElement();
return;
}
const {
stick
} = this.props;
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const target = this.props.target;
const scrollParentElement = findOverflowScrollParent(target);
if (scrollParentElement && this.resizeObserver) {
this.resizeObserver.observe(scrollParentElement);
}
if (stick) {
this.scrollElement = scrollParentElement;
} else {
this.scrollElement = this.props.scrollableElement;
}
if (this.scrollElement) {
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
this.scrollElement.addEventListener('scroll', this.onResize);
}
}
componentWillUnmount() {
var _this$unbindScroll2, _this$resizeObserver3;
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
window.removeEventListener('resize', this.onResize);
(_this$unbindScroll2 = this.unbindScroll) === null || _this$unbindScroll2 === void 0 ? void 0 : _this$unbindScroll2.call(this);
if (this.scrollElement) {
// Ignored via go/ees005
// eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners
this.scrollElement.removeEventListener('scroll', this.onResize);
}
(_this$resizeObserver3 = this.resizeObserver) === null || _this$resizeObserver3 === void 0 ? void 0 : _this$resizeObserver3.disconnect();
this.scheduledUpdatePosition.cancel();
this.destroyFocusTrap();
const {
onUnmount
} = this.props;
if (onUnmount) {
onUnmount();
}
}
renderPopup() {
var _this$props$ariaLabel;
const {
position
} = this.state;
const {
shouldRenderPopup
} = this.props;
if (shouldRenderPopup && !shouldRenderPopup(position || {})) {
return null;
}
/**
* https://a11y.atlassian.net/browse/A11Y-9995
* We set aria-label to undefined if it's null, no more 'Popup' fallback.
* It is meaningless for screen readers and causes confusion.
*/
const ariaLabel = fg('_editor_a11y_aria_label_removal_popup') ? (_this$props$ariaLabel = this.props.ariaLabel) !== null && _this$props$ariaLabel !== void 0 ? _this$props$ariaLabel : undefined : this.props.ariaLabel === null ? undefined : this.props.ariaLabel || 'Popup';
const getRole = () => {
// Provide a valid role only when aria-label is present to satisfy a11y rules, as when aria-label is present, role is required
// use role = dialog as default role, as dialog role itself is not a parent role that requires specific children to function as some other ARIA roles(menu) do
// if set default role to menu, tons of integration tests will fail as many of our popup usages do not have children that satisfy menu role requirements
if (ariaLabel) {
return this.props.role || 'dialog';
}
return undefined;
};
return /*#__PURE__*/React.createElement("div", {
ref: this.handleRef,
style: {
// eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop -- Ignored via go/DSP-18766
position: 'absolute',
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values -- Ignored via go/DSP-18766
zIndex: this.props.zIndex || akEditorFloatingPanelZIndex,
// eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop -- Ignored via go/DSP-18766
...position,
// eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop -- Ignored via go/DSP-18766
...this.props.style
},
role: getRole(),
"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
});