-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Popover: Refactor popover to clarify computations #6799
Changes from all commits
3a34c44
66b3444
05e8db6
63cf64c
a445099
5884da6
2e1f8d3
8788478
9ad4297
a96d4cd
3e1d6be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,19 +2,20 @@ | |
* External dependencies | ||
*/ | ||
import classnames from 'classnames'; | ||
import { isEqual, noop } from 'lodash'; | ||
import { noop } from 'lodash'; | ||
|
||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { Component } from '@wordpress/element'; | ||
import { Component, createRef } from '@wordpress/element'; | ||
import { focus } from '@wordpress/dom'; | ||
import { keycodes } from '@wordpress/utils'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import './style.scss'; | ||
import { computePopoverPosition } from './utils'; | ||
import withFocusReturn from '../higher-order/with-focus-return'; | ||
import PopoverDetectOutside from './detect-outside'; | ||
import IconButton from '../icon-button'; | ||
|
@@ -31,54 +32,42 @@ const { ESCAPE } = keycodes; | |
* @type {String} | ||
*/ | ||
const SLOT_NAME = 'Popover'; | ||
const isMobile = () => window.innerWidth < 782; | ||
|
||
class Popover extends Component { | ||
constructor() { | ||
super( ...arguments ); | ||
|
||
this.focus = this.focus.bind( this ); | ||
this.bindNode = this.bindNode.bind( this ); | ||
this.getAnchorRect = this.getAnchorRect.bind( this ); | ||
this.setOffset = this.setOffset.bind( this ); | ||
this.throttledSetOffset = this.throttledSetOffset.bind( this ); | ||
this.computePopoverPosition = this.computePopoverPosition.bind( this ); | ||
this.throttledComputePopoverPosition = this.throttledComputePopoverPosition.bind( this ); | ||
this.maybeClose = this.maybeClose.bind( this ); | ||
|
||
this.nodes = {}; | ||
this.contentNode = createRef(); | ||
this.anchorNode = createRef(); | ||
|
||
this.state = { | ||
forcedYAxis: null, | ||
forcedXAxis: null, | ||
popoverLeft: null, | ||
popoverTop: null, | ||
yAxis: 'top', | ||
xAxis: 'center', | ||
contentHeight: null, | ||
contentWidth: null, | ||
isMobile: false, | ||
popoverSize: null, | ||
}; | ||
} | ||
|
||
componentDidMount() { | ||
this.setOffset(); | ||
this.setForcedPositions(); | ||
const popoverSize = this.updatePopoverSize(); | ||
this.computePopoverPosition( popoverSize ); | ||
this.toggleWindowEvents( true ); | ||
this.focus(); | ||
} | ||
|
||
componentWillReceiveProps( nextProps ) { | ||
if ( this.props.position !== nextProps.position ) { | ||
this.setState( { | ||
forcedYAxis: null, | ||
forcedXAxis: null, | ||
} ); | ||
} | ||
} | ||
|
||
componentDidUpdate( prevProps, prevState ) { | ||
const { position } = this.props; | ||
const { position: prevPosition } = prevProps; | ||
|
||
if ( position !== prevPosition ) { | ||
this.setOffset(); | ||
this.setForcedPositions(); | ||
} else if ( ! isEqual( this.state, prevState ) ) { | ||
// Need to update offset if forced positioning applied | ||
this.setOffset(); | ||
componentDidUpdate( prevProps ) { | ||
if ( prevProps.position !== this.props.position ) { | ||
this.computePopoverPosition(); | ||
} | ||
} | ||
|
||
|
@@ -90,8 +79,15 @@ class Popover extends Component { | |
const handler = isListening ? 'addEventListener' : 'removeEventListener'; | ||
|
||
window.cancelAnimationFrame( this.rafHandle ); | ||
window[ handler ]( 'resize', this.throttledSetOffset ); | ||
window[ handler ]( 'scroll', this.throttledSetOffset, true ); | ||
window[ handler ]( 'resize', this.throttledComputePopoverPosition ); | ||
window[ handler ]( 'scroll', this.throttledComputePopoverPosition, true ); | ||
} | ||
|
||
throttledComputePopoverPosition( event ) { | ||
if ( event.type === 'scroll' && this.contentNode.current.contains( event.target ) ) { | ||
return; | ||
} | ||
this.rafHandle = window.requestAnimationFrame( () => this.computePopoverPosition() ); | ||
} | ||
|
||
focus() { | ||
|
@@ -100,27 +96,22 @@ class Popover extends Component { | |
return; | ||
} | ||
|
||
const { content } = this.nodes; | ||
if ( ! content ) { | ||
if ( ! this.contentNode.current ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Related to earlier comment, we shouldn't need to check this here. If the component causes |
||
return; | ||
} | ||
|
||
// Without the setTimeout, the dom node is not being focused | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is treating the symptom, not the cause, and the comment reads as such. The real cause is a combination of:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See also: #8218 |
||
// Related https://stackoverflow.com/questions/35522220/react-ref-with-focus-doesnt-work-without-settimeout-my-example | ||
const focusNode = ( domNode ) => setTimeout( () => domNode.focus() ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where are we canceling this timeout if the component is unmounted? Why aren't we using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I did use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is already done in master |
||
|
||
// Find first tabbable node within content and shift focus, falling | ||
// back to the popover panel itself. | ||
const firstTabbable = focus.tabbable.find( content )[ 0 ]; | ||
if ( firstTabbable ) { | ||
firstTabbable.focus(); | ||
} else { | ||
content.focus(); | ||
} | ||
} | ||
|
||
throttledSetOffset() { | ||
this.rafHandle = window.requestAnimationFrame( this.setOffset ); | ||
const firstTabbable = focus.tabbable.find( this.contentNode.current )[ 0 ]; | ||
focusNode( firstTabbable ? firstTabbable : this.contentNode.current ); | ||
} | ||
|
||
getAnchorRect() { | ||
const { anchor } = this.nodes; | ||
const anchor = this.anchorNode.current; | ||
if ( ! anchor || ! anchor.parentNode ) { | ||
return; | ||
} | ||
|
@@ -141,85 +132,42 @@ class Popover extends Component { | |
}; | ||
} | ||
|
||
setOffset() { | ||
const { getAnchorRect = this.getAnchorRect, expandOnMobile = false } = this.props; | ||
const { popover } = this.nodes; | ||
|
||
if ( isMobile() && expandOnMobile ) { | ||
popover.style.left = 0; | ||
popover.style.top = 0; | ||
popover.style.right = 0; | ||
popover.style.bottom = 0; | ||
if ( ! this.state.isMobile ) { | ||
this.setState( { | ||
isMobile: true, | ||
} ); | ||
} | ||
return; | ||
} | ||
|
||
if ( this.state.isMobile ) { | ||
this.setState( { | ||
isMobile: false, | ||
} ); | ||
} | ||
|
||
const [ yAxis, xAxis ] = this.getPositions(); | ||
const isTop = 'top' === yAxis; | ||
const isLeft = 'left' === xAxis; | ||
const isRight = 'right' === xAxis; | ||
|
||
const rect = getAnchorRect( { isTop, isLeft, isRight } ); | ||
if ( ! rect ) { | ||
return; | ||
updatePopoverSize() { | ||
const rect = this.contentNode.current.getBoundingClientRect(); | ||
if ( | ||
! this.state.popoverSize || | ||
rect.width !== this.state.popoverSize.width || | ||
rect.height !== this.state.popoverSize.height | ||
) { | ||
const popoverSize = { | ||
height: rect.height, | ||
width: rect.width, | ||
}; | ||
this.setState( { popoverSize } ); | ||
return popoverSize; | ||
} | ||
|
||
popover.style.bottom = 'auto'; | ||
popover.style.right = 'auto'; | ||
|
||
// 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 | ||
popover.style.top = rect[ yAxis ] + 'px'; | ||
return this.state.popoverSize; | ||
} | ||
|
||
setForcedPositions() { | ||
const anchor = this.getAnchorRect(); | ||
const rect = this.nodes.content.getBoundingClientRect(); | ||
|
||
// Check exceeding top or bottom of viewport and switch direction if the space is begger | ||
if ( rect.top < 0 || rect.bottom > window.innerHeight ) { | ||
const overflowBottom = window.innerHeight - ( anchor.bottom + rect.height ); | ||
const overflowTop = anchor.top - rect.height; | ||
const direction = overflowTop < overflowBottom ? 'bottom' : 'top'; | ||
if ( direction !== this.state.forcedYAxis ) { | ||
this.setState( { forcedYAxis: direction } ); | ||
} | ||
} | ||
computePopoverPosition( popoverSize ) { | ||
const { getAnchorRect = this.getAnchorRect, position = 'top', expandOnMobile } = this.props; | ||
const newPopoverPosition = computePopoverPosition( | ||
getAnchorRect(), popoverSize || this.state.popoverSize, position, expandOnMobile | ||
); | ||
|
||
// Check exceeding left or right of viewport and switch direction if the space is begger | ||
if ( rect.left < 0 || rect.right > window.innerWidth ) { | ||
const overflowLeft = anchor.left - rect.width; | ||
const overflowRight = window.innerWidth - ( anchor.right + rect.width ); | ||
const direction = overflowLeft < overflowRight ? 'right' : 'left'; | ||
if ( direction !== this.state.forcedXAxis ) { | ||
this.setState( { forcedXAxis: direction } ); | ||
} | ||
if ( | ||
this.state.yAxis !== newPopoverPosition.yAxis || | ||
this.state.xAxis !== newPopoverPosition.xAxis || | ||
this.state.popoverLeft !== newPopoverPosition.popoverLeft || | ||
this.state.popoverTop !== newPopoverPosition.popoverTop || | ||
this.state.contentHeight !== newPopoverPosition.contentHeight || | ||
this.state.contentWidth !== newPopoverPosition.contentWidth || | ||
this.state.isMobile !== newPopoverPosition.isMobile | ||
) { | ||
this.setState( newPopoverPosition ); | ||
} | ||
} | ||
|
||
getPositions() { | ||
const { position = 'top' } = this.props; | ||
const [ yAxis, xAxis = 'center' ] = position.split( ' ' ); | ||
const { forcedYAxis, forcedXAxis } = this.state; | ||
|
||
return [ | ||
forcedYAxis || yAxis, | ||
forcedXAxis || xAxis, | ||
]; | ||
} | ||
|
||
maybeClose( event ) { | ||
const { onKeyDown, onClose } = this.props; | ||
|
||
|
@@ -235,10 +183,6 @@ class Popover extends Component { | |
} | ||
} | ||
|
||
bindNode( name ) { | ||
return ( node ) => this.nodes[ name ] = node; | ||
} | ||
|
||
render() { | ||
const { | ||
headerTitle, | ||
|
@@ -257,15 +201,24 @@ class Popover extends Component { | |
/* eslint-enable no-unused-vars */ | ||
...contentProps | ||
} = this.props; | ||
const [ yAxis, xAxis ] = this.getPositions(); | ||
const { | ||
popoverLeft, | ||
popoverTop, | ||
yAxis, | ||
xAxis, | ||
contentHeight, | ||
contentWidth, | ||
popoverSize, | ||
isMobile, | ||
} = this.state; | ||
|
||
const classes = classnames( | ||
'components-popover', | ||
className, | ||
'is-' + yAxis, | ||
'is-' + xAxis, | ||
{ | ||
'is-mobile': this.state.isMobile, | ||
'is-mobile': isMobile, | ||
} | ||
); | ||
|
||
|
@@ -276,12 +229,16 @@ class Popover extends Component { | |
let content = ( | ||
<PopoverDetectOutside onClickOutside={ onClickOutside }> | ||
<div | ||
ref={ this.bindNode( 'popover' ) } | ||
className={ classes } | ||
style={ { | ||
top: ! isMobile && popoverTop ? popoverTop + 'px' : undefined, | ||
left: ! isMobile && popoverLeft ? popoverLeft + 'px' : undefined, | ||
visibility: popoverSize ? undefined : 'hidden', | ||
} } | ||
{ ...contentProps } | ||
onKeyDown={ this.maybeClose } | ||
> | ||
{ this.state.isMobile && ( | ||
{ isMobile && ( | ||
<div className="components-popover__header"> | ||
<span className="components-popover__header-title"> | ||
{ headerTitle } | ||
|
@@ -290,8 +247,12 @@ class Popover extends Component { | |
</div> | ||
) } | ||
<div | ||
ref={ this.bindNode( 'content' ) } | ||
ref={ this.contentNode } | ||
className="components-popover__content" | ||
style={ { | ||
maxHeight: ! isMobile && contentHeight ? contentHeight + 'px' : undefined, | ||
maxWidth: ! isMobile && contentWidth ? contentWidth + 'px' : undefined, | ||
} } | ||
tabIndex="-1" | ||
> | ||
{ children } | ||
|
@@ -314,17 +275,19 @@ class Popover extends Component { | |
content = <Fill name={ SLOT_NAME }>{ content }</Fill>; | ||
} | ||
|
||
return <span ref={ this.bindNode( 'anchor' ) }> | ||
return <span ref={ this.anchorNode }> | ||
{ content } | ||
{ this.state.isMobile && expandOnMobile && <ScrollLock /> } | ||
{ isMobile && expandOnMobile && <ScrollLock /> } | ||
</span>; | ||
} | ||
} | ||
|
||
Popover.contextTypes = { | ||
const PopoverContainer = Popover; | ||
|
||
PopoverContainer.contextTypes = { | ||
getSlot: noop, | ||
}; | ||
|
||
Popover.Slot = () => <Slot bubblesVirtually name={ SLOT_NAME } />; | ||
PopoverContainer.Slot = () => <Slot bubblesVirtually name={ SLOT_NAME } />; | ||
|
||
export default Popover; | ||
export default PopoverContainer; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It took me more than a minute to grasp why this condition is here. It seems obvious in retrospect, but a comment would have been nice.