wix-style-react
Version:
wix-style-react
336 lines • 14.7 kB
JavaScript
import React from 'react';
import Portal from '../../utils/ReactPortal/ReactPortal';
import { Manager, Reference, Popper } from 'react-popper';
import { CSSTransition } from 'react-transition-group';
import { ClickOutside } from './utils/ClickOutside';
import { createModifiers } from './utils/modifiers';
import { filterDataProps } from './utils/filter-data-props';
import uniqueId from 'lodash/uniqueId';
import { buildChildrenObject, createComponentThatRendersItsChildren, shouldAnimatePopover, attachClasses, detachClasses, getArrowShift, } from './utils/utils';
import { popoverTestUtils } from './utils/helpers';
import { getAppendToElement } from './utils/getAppendToElement';
import { WixStyleReactContext } from '../../WixStyleReactProvider/context';
import { st, classes } from '../Popover.st.css';
// This is here and not in the test setup because we don't want consumers to need to run it as well
let testId = '0';
const isTestEnv = process.env.NODE_ENV === 'test';
if (isTestEnv && typeof document !== 'undefined' && !document.createRange) {
popoverTestUtils.createRange();
}
/**
* Popover
*/
export class PopoverCore extends React.Component {
constructor(props) {
super(props);
this.portalNode = null;
this.portalClasses = '';
this.appendToNode = null;
this.clickOutsideRef = null;
// Timer instances for the show/hide delays
this._hideTimeout = null;
this._showTimeout = null;
this._handleClickOutside = (event) => {
const { onClickOutside: onClickOutsideCallback, shown, disableClickOutsideWhenClosed, } = this.props;
if (onClickOutsideCallback && !(disableClickOutsideWhenClosed && !shown)) {
onClickOutsideCallback(event);
}
};
this._onKeyDown = (e) => {
const { onEscPress } = this.props;
if (onEscPress && e.key === 'Escape') {
onEscPress(e);
}
};
/**
* Checks to see if the focused element is outside the Popover content
*/
this._onDocumentKeyUp = (e) => {
const { onTabOut } = this.props;
if (typeof document !== 'undefined' &&
this.popoverContentRef.current &&
!this.popoverContentRef.current.contains(document.activeElement)) {
onTabOut && onTabOut(e);
}
};
this.state = {
isMounted: false,
shown: props.shown || false,
};
if (isTestEnv) {
testId = popoverTestUtils.generateId();
}
this.clickOutsideRef = React.createRef();
this.popoverContentRef = React.createRef();
this.clickOutsideClass = uniqueId('clickOutside');
this.contentHook = `popover-content-${props.dataHook || ''}-${testId}`;
}
focus() {
if (this.popoverContentRef.current) {
this.popoverContentRef.current.focus();
}
}
getPopperContentStructure(childrenObject) {
const { shown } = this.state;
const { moveBy, appendTo, placement, showArrow, moveArrowTo, flip, fixed, customArrow, role, id, zIndex, minWidth, maxWidth, width, dynamicWidth, onEscPress, tabIndex, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedBy, timeout, theme, } = this.props;
const shouldAnimate = shouldAnimatePopover({ timeout });
const modifiers = createModifiers({
minWidth,
width,
dynamicWidth,
moveBy,
appendTo,
shouldAnimate,
flip,
placement,
fixed,
isTestEnv,
});
const mergeRefs = (...refs) => {
const filteredRefs = refs.filter(Boolean);
if (!filteredRefs.length) {
return null;
}
if (filteredRefs.length === 0) {
return filteredRefs[0];
}
return (inst) => {
for (const ref of filteredRefs) {
if (typeof ref === 'function') {
ref(inst);
}
else if (ref) {
ref.current = inst;
}
}
};
};
const popperWithArrow = (React.createElement(Popper, { modifiers: modifiers, placement: placement }, ({ ref, style: popperStyles, placement: popperPlacement, arrowProps, scheduleUpdate, }) => {
this.popperScheduleUpdate = scheduleUpdate;
return (React.createElement("div", { "data-hook": "popover-content", className: st(this.clickOutsideClass, this.context.newBrandingClass), "data-content-element": this.contentHook, ref: ref, style: { ...popperStyles, zIndex } },
showArrow &&
this.renderArrow(arrowProps, moveArrowTo, popperPlacement || placement, customArrow),
React.createElement("div", { id: id, role: role, tabIndex: tabIndex, ref: this.popoverContentRef, style: { maxWidth }, className: st(classes.content, {
skin: theme,
placement: popperPlacement || placement,
hasArrow: true,
}), onKeyDown: shown && onEscPress ? this._onKeyDown : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedBy }, childrenObject.Content)));
}));
const popper = (React.createElement(Popper, { modifiers: modifiers, placement: placement }, ({ ref, style: popperStyles, placement: popperPlacement, scheduleUpdate, }) => {
this.popperScheduleUpdate = scheduleUpdate;
return (React.createElement("div", { id: id, ref: mergeRefs(ref, this.popoverContentRef), role: role, tabIndex: tabIndex, className: st(classes.content, {
skin: theme,
placement: popperPlacement || placement,
}, this.clickOutsideClass, this.context.newBrandingClass), "data-hook": "popover-content", style: { ...popperStyles, zIndex, maxWidth }, "data-content-element": this.contentHook, onKeyDown: shown && onEscPress ? this._onKeyDown : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedBy }, childrenObject.Content));
}));
return this.wrapWithAnimations(showArrow ? popperWithArrow : popper);
}
applyStylesToPortaledNode() {
const { shown } = this.state;
const shouldAnimate = shouldAnimatePopover(this.props);
if (shouldAnimate || shown) {
attachClasses(this.portalNode, this.portalClasses);
}
else {
detachClasses(this.portalNode, this.portalClasses);
}
}
wrapWithAnimations(popper) {
const { timeout } = this.props;
const { shown } = this.state;
const shouldAnimate = shouldAnimatePopover(this.props);
return shouldAnimate ? (React.createElement(CSSTransition, { in: shown, timeout: timeout, unmountOnExit: true, classNames: {
enter: classes.animationEnter,
enterActive: classes.animationEnterActive,
exit: classes.animationExit,
exitActive: classes.animationExitActive,
}, addEndListener: () => { }, onExited: () => detachClasses(this.portalNode, this.portalClasses) }, popper)) : (popper);
}
renderPopperContent(childrenObject) {
const popper = this.getPopperContentStructure(childrenObject);
return this.portalNode ? (React.createElement(Portal, { node: this.portalNode }, popper)) : (popper);
}
renderArrow(arrowProps, moveArrowTo, placement, customArrow) {
const { theme } = this.props;
const commonProps = {
ref: arrowProps.ref,
key: 'popover-arrow',
'data-hook': 'popover-arrow',
style: {
...arrowProps.style,
...getArrowShift(moveArrowTo, placement),
},
};
if (customArrow) {
return customArrow(placement, commonProps);
}
return (React.createElement("div", { ...commonProps, className: st(classes.arrow, { skin: theme, placement }) }));
}
componentDidMount() {
const { shown, onTabOut } = this.props;
this.initAppendToNode();
if (onTabOut && shown) {
this._setBlurByKeyboardListener();
}
this.setState({ isMounted: true });
}
_setBlurByKeyboardListener() {
if (typeof document !== 'undefined') {
document.addEventListener('keyup', this._onDocumentKeyUp, true);
}
}
_removeBlurListener() {
if (typeof document !== 'undefined') {
document.removeEventListener('keyup', this._onDocumentKeyUp, true);
}
}
initAppendToNode() {
const { appendTo } = this.props;
this.appendToNode = getAppendToElement(appendTo, this.targetRef);
if (this.appendToNode) {
this.portalNode = document.createElement('div');
this.portalNode.setAttribute('data-hook', 'popover-portal');
/**
* reset overlay wrapping layer
* so that styles from copied classnames
* won't break the overlay:
* - content is position relative to body
* - overlay layer is hidden
*/
Object.assign(this.portalNode.style, {
position: 'static',
display: 'block',
top: 0,
left: 0,
width: 0,
height: 0,
});
this.appendToNode.appendChild(this.portalNode);
}
}
hidePopover() {
const { isMounted } = this.state;
const { hideDelay, onTabOut, onHide } = this.props;
if (!isMounted || this._hideTimeout) {
return;
}
if (this._showTimeout) {
clearTimeout(this._showTimeout);
this._showTimeout = null;
}
if (onTabOut) {
this._removeBlurListener();
}
if (hideDelay) {
this._hideTimeout = setTimeout(() => {
this.setState({ shown: false });
onHide?.();
}, hideDelay);
}
else {
this.setState({ shown: false });
onHide?.();
}
}
showPopover() {
const { isMounted } = this.state;
const { showDelay, onTabOut, onShow } = this.props;
if (!isMounted || this._showTimeout) {
return;
}
if (this._hideTimeout) {
clearTimeout(this._hideTimeout);
this._hideTimeout = null;
}
if (onTabOut) {
this._setBlurByKeyboardListener();
}
if (showDelay) {
this._showTimeout = setTimeout(() => {
this.setState({ shown: true });
onShow?.();
}, showDelay);
}
else {
this.setState({ shown: true });
onShow?.();
}
}
componentWillUnmount() {
if (this.portalNode &&
this.appendToNode &&
this.appendToNode.children.length) {
// FIXME: What if component is updated with a different appendTo? It is a far-fetched use-case,
// but we would need to remove the portaled node, and created another one.
this.appendToNode.removeChild(this.portalNode);
}
this.portalNode = null;
if (this._hideTimeout) {
clearTimeout(this._hideTimeout);
this._hideTimeout = null;
}
if (this._showTimeout) {
clearTimeout(this._showTimeout);
this._showTimeout = null;
}
}
updatePosition() {
if (this.popperScheduleUpdate) {
this.popperScheduleUpdate();
}
}
componentDidUpdate(prevProps) {
const { theme: skin, className, shown } = this.props;
if (this.portalNode) {
// Re-calculate the portal's styles
this.portalClasses = st(classes.root, { skin }, className);
// Apply the styles to the portal
this.applyStylesToPortaledNode();
}
// Update popover visibility
if (prevProps.shown !== shown) {
if (shown) {
this.showPopover();
}
else {
this.hidePopover();
}
}
else {
// Update popper's position
this.updatePosition();
}
}
render() {
const { onMouseEnter, onMouseLeave, onKeyDown, onClick, children, className, style, fluid, theme: skin, dataHook, zIndex, excludeClass, } = this.props;
const { isMounted, shown } = this.state;
const childrenObject = buildChildrenObject(children, {
Element: null,
Content: null,
});
const shouldAnimate = shouldAnimatePopover(this.props);
const shouldRenderPopper = isMounted && (shouldAnimate || shown);
return (
// @ts-ignore
React.createElement(Manager, null,
React.createElement(ClickOutside, { rootRef: this.clickOutsideRef, onClickOutside: shown ? this._handleClickOutside : undefined, excludeClass: excludeClass
? [this.clickOutsideClass, excludeClass]
: this.clickOutsideClass },
React.createElement("div", { ref: this.clickOutsideRef, style: style, "data-hook": dataHook, "data-content-hook": this.contentHook, className: st(classes.root, { fluid, skin }, className), onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, "data-zindex": zIndex, ...filterDataProps(this.props) },
React.createElement(Reference, { innerRef: r => (this.targetRef = r) }, ({ ref }) => (React.createElement("div", { ref: ref, className: classes.element, "data-hook": "popover-element", onClick: onClick, onKeyDown: onKeyDown }, childrenObject.Element))),
shouldRenderPopper && this.renderPopperContent(childrenObject)))));
}
}
PopoverCore.displayName = 'Popover';
PopoverCore.defaultProps = {
flip: true,
fixed: false,
zIndex: 1000,
shown: false,
placement: 'bottom',
excludeClass: '',
};
PopoverCore.contextType = WixStyleReactContext;
PopoverCore.Element = createComponentThatRendersItsChildren('Popover.Element');
PopoverCore.Content = createComponentThatRendersItsChildren('Popover.Content');
//# sourceMappingURL=PopoverCore.js.map