Created
July 22, 2017 00:56
-
-
Save srph/42c78a2101ab897714e4dfe39550b0cc to your computer and use it in GitHub Desktop.
React.js: Popover with boundary checking
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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