Skip to content

Commit

Permalink
Merge pull request #2160 from WordPress/update/popover-portal
Browse files Browse the repository at this point in the history
Popover: Refactor to render using React portals
  • Loading branch information
aduth authored Aug 8, 2017
2 parents a756cd8 + ecb7c70 commit d2788f5
Show file tree
Hide file tree
Showing 14 changed files with 565 additions and 103 deletions.
53 changes: 53 additions & 0 deletions components/popover/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
Popover
=======

Popover is a React component to render a floating content modal. It is similar in purpose to a tooltip, but renders content of any sort, not only simple text. It anchors itself to its parent node, optionally by a specified direction. If the popover exceeds the bounds of the page in the direction it opens, its position will be flipped automatically.

## Usage

Render a Popover within the parent to which it should anchor:

```jsx
import { Popover } from '@wordpress/components';

function ToggleButton( { isVisible, toggleVisible } ) {
return (
<button onClick={ toggleVisible }>
Toggle Popover!
<Popover
isOpen={ isVisible }
onClose={ toggleVisible }
onClick={ ( event ) => event.stopPropagation() }
>
Popover is toggled!
</Popover>
</button>
);
}
```

## Props

The component accepts the following props:

### position

The direction in which the popover should open relative to its parent node. Specify y- and x-axis as a space-separated string. Supports `"top"`, `"bottom"` y axis, and `"left"`, `"center"`, `"right"` x axis.

- Type: `String`
- Required: No
- Default: `"top center"`

### children

The content to be displayed within the popover.

- Type: `Element`
- Required: Yes

### className

An optional additional class name to apply to the rendered popover.

- Type: `String`
- Required: No
24 changes: 24 additions & 0 deletions components/popover/detect-outside.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* External dependencies
*/
import clickOutside from 'react-click-outside';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';

class PopoverDetectOutside extends Component {
handleClickOutside( event ) {
const { onClickOutside } = this.props;
if ( onClickOutside ) {
onClickOutside( event );
}
}

render() {
return this.props.children;
}
}

export default clickOutside( PopoverDetectOutside );
135 changes: 116 additions & 19 deletions components/popover/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,35 @@
* External dependencies
*/
import classnames from 'classnames';
import { isEqual, pickBy } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { createPortal, Component } from '@wordpress/element';

/**
* Internal dependencies
*/
import './style.scss';
import PopoverDetectOutside from './detect-outside';

class Popover extends Component {
/**
* Matches an event handler prop key
*
* @type {RegExp}
*/
const REGEXP_EVENT_PROP = /^on[A-Z]/;

export class Popover extends Component {
constructor() {
super( ...arguments );

this.bindContent = this.bindContent.bind( this );
this.bindNode = this.bindNode.bind( this );
this.setOffset = this.setOffset.bind( this );
this.throttledSetOffset = this.throttledSetOffset.bind( this );

this.nodes = {};

this.state = {
forcedYAxis: null,
Expand All @@ -26,7 +39,11 @@ class Popover extends Component {
}

componentDidMount() {
this.setForcedPositions();
if ( this.props.isOpen ) {
this.setOffset();
this.setForcedPositions();
this.toggleWindowEvents( true );
}
}

componentWillReceiveProps( nextProps ) {
Expand All @@ -38,14 +55,61 @@ class Popover extends Component {
}
}

componentDidUpdate( prevProps ) {
if ( this.props.position !== prevProps.position ) {
componentDidUpdate( prevProps, prevState ) {
const { isOpen, position } = this.props;
const { isOpen: prevIsOpen, position: prevPosition } = prevProps;
if ( isOpen !== prevIsOpen ) {
this.toggleWindowEvents( isOpen );
}

if ( ! isOpen ) {
return;
}

if ( isOpen !== prevIsOpen || position !== prevPosition ) {
this.setOffset();
this.setForcedPositions();
} else if ( ! isEqual( this.state, prevState ) ) {
// Need to update offset if forced positioning applied
this.setOffset();
}
}

componentWillUnmount() {
this.toggleWindowEvents( false );
}

toggleWindowEvents( isListening ) {
const handler = isListening ? 'addEventListener' : 'removeEventListener';

window.cancelAnimationFrame( this.rafHandle );
window[ handler ]( 'resize', this.throttledSetOffset );
window[ handler ]( 'scroll', this.throttledSetOffset );
}

throttledSetOffset() {
this.rafHandle = window.requestAnimationFrame( this.setOffset );
}

setOffset() {
const { anchor, popover } = this.nodes;
const { parentNode } = anchor;
if ( ! parentNode ) {
return;
}

const rect = parentNode.getBoundingClientRect();

// Set popover at parent node center
popover.style.left = Math.round( rect.left + ( rect.width / 2 ) ) + 'px';

// Set at top or bottom of parent node based on popover position
const [ yAxis ] = this.getPositions();
popover.style.top = rect[ yAxis ] + 'px';
}

setForcedPositions() {
const rect = this.content.getBoundingClientRect();
const rect = this.nodes.content.getBoundingClientRect();

// Check exceeding top or bottom of viewport
if ( rect.top < 0 ) {
Expand All @@ -62,28 +126,61 @@ class Popover extends Component {
}
}

bindContent( node ) {
this.content = node;
getPositions() {
const { position = 'top' } = this.props;
const [ yAxis, xAxis = 'center' ] = position.split( ' ' );
const { forcedYAxis, forcedXAxis } = this.state;

return [
forcedYAxis || yAxis,
forcedXAxis || xAxis,
];
}

bindNode( name ) {
return ( node ) => this.nodes[ name ] = node;
}

render() {
const { position, children, className } = this.props;
const { forcedYAxis, forcedXAxis } = this.state;
const [ yAxis = 'top', xAxis = 'center' ] = position.split( ' ' );
const { isOpen, onClose, children, className } = this.props;
const [ yAxis, xAxis ] = this.getPositions();

if ( ! isOpen ) {
return null;
}

const eventHandlers = pickBy( this.props, ( value, key ) => (
'onClose' !== key && REGEXP_EVENT_PROP.test( key )
) );

const classes = classnames(
'components-popover',
className,
'is-' + ( forcedYAxis || yAxis ),
'is-' + ( forcedXAxis || xAxis )
'is-' + yAxis,
'is-' + xAxis,
);

return (
<div className={ classes } tabIndex="0">
<div ref={ this.bindContent } className="components-popover__content">
{ children }
</div>
</div>
<span ref={ this.bindNode( 'anchor' ) }>
{ createPortal(
<PopoverDetectOutside onClickOutside={ onClose }>
<div
ref={ this.bindNode( 'popover' ) }
className={ classes }
tabIndex="0"
{ ...eventHandlers }
>
<div
ref={ this.bindNode( 'content' ) }
className="components-popover__content"
>
{ children }
</div>
</div>
</PopoverDetectOutside>,
document.body
) }
</span>
);
}
}
Expand Down
10 changes: 5 additions & 5 deletions components/popover/style.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
.components-popover {
position: absolute;
&, * {
box-sizing: border-box;
}

position: fixed;
z-index: z-index( ".components-popover" );
left: 50%;

Expand Down Expand Up @@ -77,10 +81,6 @@
bottom: 100%;
}

.components-popover.is-bottom & {
top: 100%;
}

.components-popover.is-center & {
left: 50%;
transform: translateX( -50% );
Expand Down
Loading

0 comments on commit d2788f5

Please sign in to comment.