optimizely-oui
Version:
Optimizely's Component Library.
302 lines (264 loc) • 9.86 kB
JavaScript
import React from "react";
import PropTypes from "prop-types";
import Tether from "tether";
/**
* Intelligently position elements on a page.
* @param {Object} props - Properties passed to component
* @returns {ReactElement}
*/
class OverlayWrapper extends React.Component {
constructor(props) {
super(props);
this.state = { isOverlayOpen: false };
this.onChildClick = this.onChildClick.bind(this);
this.onChildMouseOver = this.onChildMouseOver.bind(this);
this.onChildMouseOut = this.onChildMouseOut.bind(this);
this.disableTether = this.disableTether.bind(this);
}
getChildContext() {
return {
hideOverlay: this.disableTether,
};
}
componentDidMount() {
let options = {
element: this._overlayEl,
target: this._activatorEl.firstChild,
attachment: `${this.props.verticalAttachment} ${this.props.horizontalAttachment}`,
constraints: [
{
to: "window",
attachment: this.props.attachmentConstraint,
pin: this.props.isConstrainedToScreen,
},
],
};
if (this.props.verticalTargetAttachment && this.props.horizontalTargetAttachment) {
options.targetAttachment = `${this.props.verticalTargetAttachment} ${this.props.horizontalTargetAttachment}`;
}
this.initTether = () => new Tether(options);
}
enableTether() {
// If tether hasn't yet been created, do it now.
if (!this._tether) {
this._tether = this.initTether();
}
this.setState({ isOverlayOpen: true });
this._tether.enable();
if (typeof this.props.onShow === "function") {
this.props.onShow();
}
// Reposition once the overlay is visible because Tether can't
// properly calculate the positioning when the overlay is not
// displayed.
setTimeout(() => {
this._tether.position();
});
// These functions must be stored so that it can be still be removed even
// though `bind` was used: http://stackoverflow.com/a/22870717/316602
this._bodyClickListener = this.props.shouldHideOnClick && this.isClickWithinOverlayOrChildren.bind(this);
this._documentEscapeListener = this.onEscapeKey.bind(this);
if (this._bodyClickListener) {
document.addEventListener("click", this._bodyClickListener);
}
document.addEventListener("keyup", this._documentEscapeListener);
}
disableTether() {
// If tether hasn't been created, return early
if (!this._tether) {
return;
}
let shouldHide = true;
if (typeof this.props.onHide === "function") {
shouldHide = this.props.onHide();
}
if (shouldHide === false) {
// Exit out of this function before hiding the `overlay` if the supplied
// `onHide` function returns false. This is useful for fine-tuned control
// of when the `overlay` should close.
return;
}
this.setState({ isOverlayOpen: false });
this._tether.disable();
this.removeBodyEventListener();
}
removeBodyEventListener() {
if (this._bodyClickListener) {
document.removeEventListener("click", this._bodyClickListener);
}
if (this._documentEscapeListener) {
document.removeEventListener("keyup", this._documentEscapeListener);
}
}
/**
* Disable Tether if a user clicks outside of the `overlay` and `children`.
* @param {Object} event - Click event
*/
isClickWithinOverlayOrChildren(event) {
let target = event.target;
let shouldCloseOverlay = true;
while (target.parentNode !== null && shouldCloseOverlay) {
if (target === this._overlayEl || target === this._activatorEl) {
shouldCloseOverlay = false;
}
target = target.parentNode;
}
if (shouldCloseOverlay) {
this.disableTether();
}
}
componentWillUnmount() {
this.removeBodyEventListener();
// If tether hasn't been created, return early
if (!this._tether) {
return;
}
this._tether.destroy();
if (this._tether.element && this._tether.element.remove) {
this._tether.element.remove();
}
}
onChildClick(event, child) {
// Toggle the `overlay` by enabling or disabling Tether.
if (this.state.isOverlayOpen) {
// Disable Tether when not visible for performance reasons.
this.disableTether();
} else {
// Enable Tether when visible.
this.enableTether();
}
// Run the `children`'s `onClick` if it exists.
if (child.props.onClick) {
child.props.onClick(event);
}
}
onChildMouseOver(event, child) {
// Enable Tether when visible.
this.enableTether();
// Run the `children`'s `onMouseOver` if it exists.
if (child.props.onMouseOver) {
child.props.onMouseOver(event);
}
}
onChildMouseOut(event, child) {
// Disable Tether when not visible for performance reasons.
this.disableTether();
// Run the `children`'s `onMouseOut` if it exists.
if (child.props.onMouseOut) {
child.props.onMouseOut(event);
}
}
onEscapeKey(event) {
// Escape key
if (event.keyCode === 27) {
this.disableTether();
}
}
render() {
const {
attachmentConstraint,
behavior,
children,
className,
horizontalAttachment,
horizontalTargetAttachment,
isConstrainedToScreen,
onHide,
onShow,
overlay,
shouldHideOnClick,
testSection,
verticalAttachment,
verticalTargetAttachment,
...props
} = this.props;
let eventHandlerProps;
let child = React.Children.only(children);
switch (behavior) {
case "click":
eventHandlerProps = {
onClick: event => this.onChildClick.call(null, event, child),
};
break;
case "hover":
eventHandlerProps = {
onMouseOver: event => this.onChildMouseOver.call(null, event, child),
onMouseOut: event => this.onChildMouseOut.call(null, event, child),
};
break;
default:
}
const block = { display: "inline-block" };
const overlayState = this.state.isOverlayOpen ? { display: "block" } : { display: "none" };
return (
/* eslint-disable react/jsx-no-bind */
<div className={className} data-oui-component={true} data-test-section={testSection} style={block} {...props}>
<div
className="click-area"
style={block}
ref={ref => {
this._activatorEl = ref;
}}
{...eventHandlerProps}
>
{child}
</div>
<div
ref={ref => {
this._overlayEl = ref;
}}
style={overlayState}
>
{overlay}
</div>
</div>
/* eslint-enable */
);
}
}
OverlayWrapper.propTypes = {
/** Determines how the attachment is constrained to the target */
attachmentConstraint: PropTypes.oneOf(["both", "element", "none", "target", "together"]),
/** Event to listen to and open the overlay */
behavior: PropTypes.oneOf(["click", "hover"]),
/** Element that the `overlay` should attach to */
children: PropTypes.node.isRequired,
/** CSS class names. */
className: PropTypes.string,
/** Side of the `overlay` that should attach to the `children` */
horizontalAttachment: PropTypes.oneOf(["left", "center", "right"]),
/** Side of `children` that should attach to the `overlay` */
horizontalTargetAttachment: PropTypes.oneOf(["left", "center", "right"]),
/** Attach `overlay` to an edge of the screen if it is going to move off */
isConstrainedToScreen: PropTypes.bool,
/**
* Function that runs when the `overlay` is hidden. Return `false` to prevent
* the `overlay` from closing.
*/
onHide: PropTypes.func,
/** Function that runs when the `overlay` is shown */
onShow: PropTypes.func,
/** The element that is attached to the children */
overlay: PropTypes.node.isRequired,
/** Should the `overlay` close when clicking outside of it */
shouldHideOnClick: PropTypes.bool,
/** Hook for automated JavaScript tests */
testSection: PropTypes.string,
/** Vertical edge of the `overlay` that should touch the `children` */
verticalAttachment: PropTypes.oneOf(["top", "middle", "bottom"]),
/** Vertical edge of the `children` that should touch the `overlay` */
verticalTargetAttachment: PropTypes.oneOf(["top", "middle", "bottom"]),
};
OverlayWrapper.defaultProps = {
attachmentConstraint: "together",
behavior: "click",
horizontalAttachment: "center",
isConstrainedToScreen: false,
onHide: () => null,
shouldHideOnClick: true,
verticalAttachment: "top",
};
OverlayWrapper.childContextTypes = {
hideOverlay: PropTypes.func,
};
export default OverlayWrapper;