Skip to content

Instantly share code, notes, and snippets.

@srph
Created July 22, 2017 00:56
Show Gist options
  • Select an option

  • Save srph/42c78a2101ab897714e4dfe39550b0cc to your computer and use it in GitHub Desktop.

Select an option

Save srph/42c78a2101ab897714e4dfe39550b0cc to your computer and use it in GitHub Desktop.
React.js: Popover with boundary checking
import PropTypes from 'prop-types';
import React, { Component, cloneElement } from 'react';
import {findDOMNode} from 'react-dom';
import Transition from 'react-addons-css-transition-group';
import {Gateway} from 'react-gateway';
import Outside from 'react-click-outside';
import classnames from 'classnames';
import withinBoundaries from './withinBoundaries';
// @TODO: Animations. This became "undoable" when we added boundary checking
// (so it always shows inside our view port). The issue was that the actual
// box's properties (height, width, etc) were skewed during the transition.
// @TODO: Implement accessibility.
// @?REFACTOR: Remove `ref` for possible issues with HOCs
// https://facebook.github.io/react/docs/higher-order-components.html#refs-arent-passed-through
class Popover extends Component {
constructor(props, context) {
super(props, context);
this.state = {
open: false,
top: 0,
left: 0,
height: 0,
width: 0
};
this.node = null;
this.menu = null;
this.open = this.open.bind(this);
this.close = this.close.bind(this);
this.outside = this.outside.bind(this);
this.toggle = this.toggle.bind(this);
this.escape = this.escape.bind(this);
}
componentDidMount() {
document.addEventListener('keyup', this.escape);
}
componentWillUnmount() {
document.removeEventListener('keyup', this.escape);
}
render() {
const {top, left, open} = this.state;
const render = this.props.render((c) => this.node = c);
const cn = classnames('popover', {
'popover--paddingless': this.props.paddingless,
'popover--small': this.props.size === 'small'
});
return (
<div>
{render}
<Gateway into="portal">
{this.state.open ?
<Outside className={cn} onClickOutside={this.outside} style={{ position: 'absolute', top: top, left: left }} ref={(c) => this.menu = c}>
{this.props.close && <div className="popover__close">
<button className="plain-button" onClick={this.close}>
</button>
</div>}
{this.props.children}
</Outside> : null}
</Gateway>
</div>
);
}
escape(evt) {
if ( this.state.open && evt.keyCode === 27 ) {
this.close();
}
}
outside() {
// @NOTE: We're setting the `closing` on next tick
// to let `toggle` handle when the user presses
// the trigger node (this.props.render).
// `setTimeout` is a workaround for 'edge-cases' such as this.
setTimeout(() => {
if (this.state.open) {
this.close();
}
}, 0);
}
toggle() {
if (this.state.open) {
this.close();
} else {
this.open();
}
}
open() {
this.setState({ open: true }, () => {
const node = this.node.getBoundingClientRect();
const {height, width} = findDOMNode(this.menu).getBoundingClientRect();
const position = withinBoundaries({
top: node.top + node.height + 20,
left: node.left,
height,
width
// @TODO: Allow customization. Right now this popover may
// be buggy on all pages other than the board page.
}, '.board > .body');
this.setState(position);
});
}
close() {
this.setState({ open: false, height: 0, width: 0 });
}
}
Popover.propTypes = {
render: PropTypes.func,
close: PropTypes.bool,
paddingless: PropTypes.bool,
size: PropTypes.string,
onClose: PropTypes.func
};
Popover.defaultProps = {
render: () => {},
close: true,
paddingless: false,
size: ''
};
export default Popover;
import offset from 'dom-helpers/query/offset';
/**
* Makes sure that the element is within the viewport (left, bottom, right)
* - Assumes that the element is/will be centered
* - Always allots a `20` allowance
* - Doesn't handle going beyond the top since our popover
* is always placed on the bottom.
* - Doesn't handle auto-height
*
* @param {Object: top, left, height, width} box
* @param {String|DOMElement} container
* @return {Object: Number top, Number left}
*/
export default function withinBoundaries(box, container = window) {
if (typeof container === 'string') {
container = document.querySelector(container);
}
const containerOffset = offset(container);
const containerLeft = containerOffset.left;
const containerBottom = containerOffset.top + containerOffset.height;
const containerRight = containerOffset.left + containerOffset.width;
const boxLeft = box.left - (box.width / 2);
const boxBottom = box.top + box.height;
const boxRight = box.left + (box.width / 2);
let reposition = { top: box.top, left: boxLeft };
// Beyond the bottom of the view port
if (boxBottom > containerBottom) {
const difference = boxBottom - containerBottom;
reposition.top = reposition.top - difference - 20;
}
// Beyond the right of the view port
if (boxRight > containerRight) {
const difference = boxRight - containerRight;
reposition.left = reposition.left - difference - 20;
// Beyond the left of the view port
} else if (boxLeft < containerLeft) {
const difference = Math.abs(boxLeft - containerLeft);
reposition.left = reposition.left + difference + 20;
}
return reposition;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment