diff --git a/src/addons/Portal/Portal.js b/src/addons/Portal/Portal.js
index be9e110fe2..29003f1d8f 100644
--- a/src/addons/Portal/Portal.js
+++ b/src/addons/Portal/Portal.js
@@ -34,9 +34,19 @@ class Portal extends Component {
/** Controls whether or not the portal should close when escape is pressed is displayed. */
closeOnEscape: PropTypes.bool,
+ /**
+ * Controls whether or not the portal should close when mousing out of the portal.
+ * NOTE: This will prevent `closeOnTriggerMouseLeave` when mousing over the
+ * gap from the trigger to the portal.
+ */
+ closeOnPortalMouseLeave: PropTypes.bool,
+
/** Controls whether or not the portal should close on blur of the trigger. */
closeOnTriggerBlur: PropTypes.bool,
+ /** Controls whether or not the portal should close on click of the trigger. */
+ closeOnTriggerClick: PropTypes.bool,
+
/** Controls whether or not the portal should close when mousing out of the trigger. */
closeOnTriggerMouseLeave: PropTypes.bool,
@@ -46,6 +56,12 @@ class Portal extends Component {
/** The node where the portal should mount.. */
mountNode: PropTypes.any,
+ /** Milliseconds to wait before closing on mouse leave */
+ mouseLeaveDelay: PropTypes.number,
+
+ /** Milliseconds to wait before opening on mouse over */
+ mouseOverDelay: PropTypes.number,
+
/** Called when a close event happens */
onClose: PropTypes.func,
@@ -110,6 +126,10 @@ class Portal extends Component {
componentWillUnmount() {
this.unmountPortal()
+
+ // Clean up timers
+ clearTimeout(this.mouseOverTimer)
+ clearTimeout(this.mouseLeaveTimer)
}
// ----------------------------------------
@@ -118,6 +138,8 @@ class Portal extends Component {
closeOnDocumentClick = (e) => {
if (!this.props.closeOnDocumentClick) return
+
+ // If event happened in the portal, ignore it
if (this.portal.contains(e.target)) return
debug('closeOnDocumentClick()')
@@ -140,6 +162,26 @@ class Portal extends Component {
// Component Event Handlers
// ----------------------------------------
+ handlePortalMouseLeave = (e) => {
+ const { closeOnPortalMouseLeave, mouseLeaveDelay } = this.props
+
+ if (!closeOnPortalMouseLeave) return
+
+ debug('handlePortalMouseLeave()')
+ this.mouseLeaveTimer = this.closeWithTimeout(e, mouseLeaveDelay)
+ }
+
+ handlePortalMouseOver = (e) => {
+ // In order to enable mousing from the trigger to the portal, we need to
+ // clear the mouseleave timer that was set when leaving the trigger.
+ const { closeOnPortalMouseLeave } = this.props
+
+ if (!closeOnPortalMouseLeave) return
+
+ debug('handlePortalMouseOver()')
+ clearTimeout(this.mouseLeaveTimer)
+ }
+
handleTriggerBlur = (e) => {
const { trigger, closeOnTriggerBlur } = this.props
@@ -153,22 +195,24 @@ class Portal extends Component {
}
handleTriggerClick = (e) => {
- const { trigger, openOnTriggerClick } = this.props
+ const { trigger, closeOnTriggerClick, openOnTriggerClick } = this.props
+ const { open } = this.state
// Call original event handler
_.invoke(trigger, 'props.onClick', e)
- if (!openOnTriggerClick) return
-
- debug('handleTriggerClick()')
-
- e.stopPropagation()
-
- // Prevents closeOnDocumentClick from closing the portal when
- // openOnTriggerFocus is set. Focus shifts on mousedown so the portal opens
- // before the click finishes so it may actually wind up on the document.
- e.nativeEvent.stopImmediatePropagation()
- this.open(e)
+ if (open && closeOnTriggerClick) {
+ e.stopPropagation()
+ this.close(e)
+ } else if (!open && openOnTriggerClick) {
+ // Prevents closeOnDocumentClick from closing the portal when
+ // openOnTriggerFocus is set. Focus shifts on mousedown so the portal opens
+ // before the click finishes so it may actually wind up on the document.
+ e.nativeEvent.stopImmediatePropagation()
+
+ e.stopPropagation()
+ this.open(e)
+ }
}
handleTriggerFocus = (e) => {
@@ -184,7 +228,9 @@ class Portal extends Component {
}
handleTriggerMouseLeave = (e) => {
- const { trigger, closeOnTriggerMouseLeave } = this.props
+ clearTimeout(this.mouseOverTimer)
+
+ const { trigger, closeOnTriggerMouseLeave, mouseLeaveDelay } = this.props
// Call original event handler
_.invoke(trigger, 'props.onMouseLeave', e)
@@ -192,11 +238,13 @@ class Portal extends Component {
if (!closeOnTriggerMouseLeave) return
debug('handleTriggerMouseLeave()')
- this.close(e)
+ this.mouseLeaveTimer = this.closeWithTimeout(e, mouseLeaveDelay)
}
handleTriggerMouseOver = (e) => {
- const { trigger, openOnTriggerMouseOver } = this.props
+ clearTimeout(this.mouseLeaveTimer)
+
+ const { trigger, mouseOverDelay, openOnTriggerMouseOver } = this.props
// Call original event handler
_.invoke(trigger, 'props.onMouseOver', e)
@@ -204,7 +252,7 @@ class Portal extends Component {
if (!openOnTriggerMouseOver) return
debug('handleTriggerMouseOver()')
- this.open(e)
+ this.mouseOverTimer = this.openWithTimeout(e, mouseOverDelay)
}
// ----------------------------------------
@@ -220,6 +268,14 @@ class Portal extends Component {
this.trySetState({ open: true })
}
+ openWithTimeout = (e, delay) => {
+ // React wipes the entire event object and suggests using e.persist() if
+ // you need the event for async access. However, even with e.persist
+ // certain required props (e.g. currentTarget) are null so we're forced to clone.
+ const eventClone = { ...e }
+ return setTimeout(() => this.open(eventClone), delay || 0)
+ }
+
close = (e) => {
debug('close()')
@@ -229,6 +285,14 @@ class Portal extends Component {
this.trySetState({ open: false })
}
+ closeWithTimeout = (e, delay) => {
+ // React wipes the entire event object and suggests using e.persist() if
+ // you need the event for async access. However, even with e.persist
+ // certain required props (e.g. currentTarget) are null so we're forced to clone.
+ const eventClone = { ...e }
+ return setTimeout(() => this.close(eventClone), delay || 0)
+ }
+
renderPortal() {
const { children, className } = this.props
@@ -241,6 +305,9 @@ class Portal extends Component {
Children.only(children),
this.node
)
+
+ this.portal.addEventListener('mouseleave', this.handlePortalMouseLeave)
+ this.portal.addEventListener('mouseover', this.handlePortalMouseOver)
}
mountPortal = () => {
@@ -264,6 +331,9 @@ class Portal extends Component {
ReactDOM.unmountComponentAtNode(this.node)
this.node.parentNode.removeChild(this.node)
+ this.portal.removeEventListener('mouseleave', this.handlePortalMouseLeave)
+ this.portal.removeEventListener('mouseover', this.handlePortalMouseOver)
+
this.node = null
this.portal = null
diff --git a/test/specs/addons/Portal/Portal-test.js b/test/specs/addons/Portal/Portal-test.js
index b5434bb147..d611145c44 100644
--- a/test/specs/addons/Portal/Portal-test.js
+++ b/test/specs/addons/Portal/Portal-test.js
@@ -213,47 +213,182 @@ describe('Portal', () => {
})
})
+ describe('closeOnTriggerClick', () => {
+ it('should not close portal on click', () => {
+ const spy = sandbox.spy()
+ const trigger = button
+ wrapperMount(Hi
)
+
+ wrapper.find('button').simulate('click', nativeEvent)
+ document.body.lastElementChild.should.equal(wrapper.instance().node)
+ spy.should.have.been.calledOnce()
+ })
+
+ it('should close portal on click when set', () => {
+ const spy = sandbox.spy()
+ const trigger = button
+ wrapperMount(Hi
)
+
+ wrapper.find('button').simulate('click', nativeEvent)
+ document.body.childElementCount.should.equal(0)
+ spy.should.have.been.calledOnce()
+ })
+ })
+
describe('openOnTriggerMouseOver', () => {
- it('should not open portal on mouseover when not set', () => {
+ it('should not open portal on mouseover when not set', (done) => {
const spy = sandbox.spy()
const trigger = button
- wrapperMount(Hi
)
+ const mouseOverDelay = 100
+ wrapperMount(Hi
)
wrapper.find('button').simulate('mouseover')
document.body.childElementCount.should.equal(0)
spy.should.have.been.calledOnce()
+
+ setTimeout(() => {
+ document.body.childElementCount.should.equal(0)
+ spy.should.have.been.calledOnce()
+ done()
+ }, mouseOverDelay + 1)
})
- it('should open portal on mouseover when set', () => {
+ it('should open portal on mouseover when set', (done) => {
const spy = sandbox.spy()
const trigger = button
- wrapperMount(Hi
)
+ const mouseOverDelay = 100
+ wrapperMount(
+ Hi
+ )
wrapper.find('button').simulate('mouseover')
- document.body.lastElementChild.should.equal(wrapper.instance().node)
- spy.should.have.been.calledOnce()
+ setTimeout(() => {
+ document.body.childElementCount.should.equal(0)
+ spy.should.have.been.calledOnce()
+ }, mouseOverDelay - 1)
+
+ setTimeout(() => {
+ document.body.lastElementChild.should.equal(wrapper.instance().node)
+ spy.should.have.been.calledOnce()
+ done()
+ }, mouseOverDelay + 1)
})
})
describe('closeOnTriggerMouseLeave', () => {
- it('should not close portal on mouseleave when not set', () => {
+ it('should not close portal on mouseleave when not set', (done) => {
const spy = sandbox.spy()
const trigger = button
- wrapperMount(Hi
)
+ const delay = 100
+ wrapperMount(Hi
)
wrapper.find('button').simulate('mouseleave')
- document.body.lastElementChild.should.equal(wrapper.instance().node)
- spy.should.have.been.calledOnce()
+ setTimeout(() => {
+ document.body.lastElementChild.should.equal(wrapper.instance().node)
+ spy.should.have.been.calledOnce()
+ done()
+ }, delay + 1)
})
- it('should close portal on mouseleave when set', () => {
+ it('should close portal on mouseleave when set', (done) => {
const spy = sandbox.spy()
const trigger = button
- wrapperMount(Hi
)
+ const delay = 100
+ wrapperMount(
+ Hi
+ )
wrapper.find('button').simulate('mouseleave')
- document.body.childElementCount.should.equal(0)
- spy.should.have.been.calledOnce()
+ setTimeout(() => {
+ document.body.lastElementChild.should.equal(wrapper.instance().node)
+ spy.should.have.been.calledOnce()
+ }, delay - 1)
+
+ setTimeout(() => {
+ document.body.childElementCount.should.equal(0)
+ spy.should.have.been.calledOnce()
+ done()
+ }, delay + 1)
+ })
+ })
+
+ describe('closeOnPortalMouseLeave', () => {
+ it('should not close portal on mouseleave of portal when not set', (done) => {
+ const trigger = button
+ const delay = 100
+ wrapperMount(Hi
)
+
+ domEvent.mouseLeave(wrapper.instance().node.firstElementChild)
+
+ setTimeout(() => {
+ document.body.lastElementChild.should.equal(wrapper.instance().node)
+ done()
+ }, delay + 1)
+ })
+
+ it('should close portal on mouseleave of portal when set', (done) => {
+ const trigger = button
+ const delay = 100
+ wrapperMount(
+ Hi
+ )
+
+ domEvent.mouseLeave(wrapper.instance().node.firstElementChild)
+
+ setTimeout(() => {
+ document.body.lastElementChild.should.equal(wrapper.instance().node)
+ }, delay - 1)
+
+ setTimeout(() => {
+ document.body.childElementCount.should.equal(0)
+ done()
+ }, delay + 1)
+ })
+ })
+
+ describe('closeOnTriggerMouseLeave + closeOnPortalMouseLeave', () => {
+ it('should close portal on trigger mouseleave even when portal receives mouseover within limit', (done) => {
+ const trigger = button
+ const delay = 100
+ wrapperMount(
+ Hi
+ )
+
+ wrapper.find('button').simulate('mouseleave')
+
+ // Fire a mouseOver on the portal within the time limit
+ setTimeout(() => {
+ domEvent.mouseOver(wrapper.instance().node.firstElementChild)
+ }, delay - 1)
+
+ // The portal should close because closeOnPortalMouseLeave not set
+ setTimeout(() => {
+ document.body.childElementCount.should.equal(0)
+ done()
+ }, delay + 1)
+ })
+
+ it('should not close portal on trigger mouseleave when portal receives mouseover within limit', (done) => {
+ const trigger = button
+ const delay = 100
+ wrapperMount(
+ Hi
+ )
+
+ wrapper.find('button').simulate('mouseleave')
+
+ // Fire a mouseOver on the portal within the time limit
+ setTimeout(() => {
+ domEvent.mouseOver(wrapper.instance().node.firstElementChild)
+ }, delay - 1)
+
+ // The portal should not have closed
+ setTimeout(() => {
+ document.body.lastElementChild.should.equal(wrapper.instance().node)
+ done()
+ }, delay + 1)
})
})
diff --git a/test/utils/domEvent.js b/test/utils/domEvent.js
index fa62359ceb..4774a02fe0 100644
--- a/test/utils/domEvent.js
+++ b/test/utils/domEvent.js
@@ -26,6 +26,22 @@ export const fire = (node, eventType, data = {}) => {
*/
export const keyDown = (node, data) => fire(node, 'keydown', data)
+/**
+ * Dispatch a 'mouseleave' event on a DOM node.
+ * @param {String|Object} node A querySelector string or DOM node.
+ * @param {Object} [data] Additional event data.
+ * @returns {Object} The event
+ */
+export const mouseLeave = (node, data) => fire(node, 'mouseleave', data)
+
+/**
+ * Dispatch a 'mouseover' event on a DOM node.
+ * @param {String|Object} node A querySelector string or DOM node.
+ * @param {Object} [data] Additional event data.
+ * @returns {Object} The event
+ */
+export const mouseOver = (node, data) => fire(node, 'mouseover', data)
+
/**
* Dispatch a 'mouseup' event on a DOM node.
* @param {String|Object} node A querySelector string or DOM node.
@@ -44,6 +60,8 @@ export const click = (node, data) => fire(node, 'click', data)
export default {
fire,
+ mouseLeave,
+ mouseOver,
mouseUp,
keyDown,
click,