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 = + 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 = + 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 = - 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 = - 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 = - 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 = - 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 = + 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 = + 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 = + 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 = + 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,