From 77e7fed957919dfe5a5976cdee734d10da3da6ba Mon Sep 17 00:00:00 2001 From: Jeff Carbonella Date: Sun, 2 Oct 2016 23:50:46 -0400 Subject: [PATCH] feat(Portal): More flexible configuration * Add closeOnTriggerClick prop * Update closeOnMouseLeave to handle mouse leave of either trigger or portal * Add configurable delay for open/close on hover --- src/addons/Portal/Portal.js | 94 +++++++++++++---- test/specs/addons/Portal/Portal-test.js | 130 +++++++++++++++++++++--- test/utils/domEvent.js | 18 ++++ 3 files changed, 208 insertions(+), 34 deletions(-) diff --git a/src/addons/Portal/Portal.js b/src/addons/Portal/Portal.js index be9e110fe2..d1ad85b640 100644 --- a/src/addons/Portal/Portal.js +++ b/src/addons/Portal/Portal.js @@ -37,8 +37,14 @@ class Portal extends Component { /** Controls whether or not the portal should close on blur of the trigger. */ closeOnTriggerBlur: PropTypes.bool, - /** Controls whether or not the portal should close when mousing out of the trigger. */ - closeOnTriggerMouseLeave: PropTypes.bool, + /** Controls whether or not the portal should close on blur of the trigger. */ + closeOnTriggerClick: PropTypes.bool, + + /** + * Controls whether or not the portal should close when mousing out of the + * trigger OR the portal content. + */ + closeOnMouseLeave: PropTypes.bool, /** Initial value of open. */ defaultOpen: PropTypes.bool, @@ -46,6 +52,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 +122,10 @@ class Portal extends Component { componentWillUnmount() { this.unmountPortal() + + // Clean up timers + clearTimeout(this.mouseOverTimer) + clearTimeout(this.mouseLeaveTimer) } // ---------------------------------------- @@ -118,6 +134,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 +158,20 @@ class Portal extends Component { // Component Event Handlers // ---------------------------------------- + handlePortalMouseLeave = (e) => { + const { closeOnMouseLeave, mouseLeaveDelay } = this.props + + if (!closeOnMouseLeave) return + + debug('handlePortalMouseLeave()') + this.mouseLeaveTimer = this.closeWithTimeout(e, mouseLeaveDelay) + } + + handlePortalMouseOver = (e) => { + debug('handlePortalMouseOver()') + clearTimeout(this.mouseLeaveTimer) + } + handleTriggerBlur = (e) => { const { trigger, closeOnTriggerBlur } = this.props @@ -153,22 +185,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,19 +218,23 @@ class Portal extends Component { } handleTriggerMouseLeave = (e) => { - const { trigger, closeOnTriggerMouseLeave } = this.props + clearTimeout(this.mouseOverTimer) + + const { trigger, closeOnMouseLeave, mouseLeaveDelay } = this.props // Call original event handler _.invoke(trigger, 'props.onMouseLeave', e) - if (!closeOnTriggerMouseLeave) return + if (!closeOnMouseLeave) 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 +242,7 @@ class Portal extends Component { if (!openOnTriggerMouseOver) return debug('handleTriggerMouseOver()') - this.open(e) + this.mouseOverTimer = this.openWithTimeout(e, mouseOverDelay) } // ---------------------------------------- @@ -220,6 +258,12 @@ class Portal extends Component { this.trySetState({ open: true }) } + openWithTimeout = (e, delay = 0) => { + // React wipes certain props (e.g. currentTarget) so we need to clone. + const eventClone = { ...e } + return setTimeout(() => this.open(eventClone), delay) + } + close = (e) => { debug('close()') @@ -229,6 +273,12 @@ class Portal extends Component { this.trySetState({ open: false }) } + closeWithTimeout = (e, delay = 0) => { + // React wipes certain props (e.g. currentTarget) so we need to clone. + const eventClone = { ...e } + return setTimeout(() => this.close(eventClone), delay) + } + renderPortal() { const { children, className } = this.props @@ -241,6 +291,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 +317,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..276aaf4c78 100644 --- a/test/specs/addons/Portal/Portal-test.js +++ b/test/specs/addons/Portal/Portal-test.js @@ -213,47 +213,147 @@ 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() + done() + }, 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', () => { + describe('closeOnMouseLeave', () => { + it('should not close portal on mouseleave when not set', (done) => { const spy = sandbox.spy() const trigger = - wrapperMount(

Hi

) + const mouseLeaveDelay = 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() + }, mouseLeaveDelay + 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 mouseLeaveDelay = 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() + done() + }, mouseLeaveDelay - 1) + + setTimeout(() => { + document.body.childElementCount.should.equal(0) + spy.should.have.been.calledOnce() + done() + }, mouseLeaveDelay + 1) + }) + + it('should close portal on mouseleave of portal when set', (done) => { + const trigger = + const mouseLeaveDelay = 100 + wrapperMount( +

Hi

+ ) + + domEvent.mouseOver(wrapper.instance().node.firstElementChild) + + setTimeout(() => { + document.body.lastElementChild.should.equal(wrapper.instance().node) + done() + }, mouseLeaveDelay - 1) + + setTimeout(() => { + document.body.childElementCount.should.equal(0) + done() + }, mouseLeaveDelay + 1) + }) + + it('should not close portal on mouseleave when portal receives mouseover within limit', (done) => { + const trigger = + const mouseLeaveDelay = 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) + done() + }, mouseLeaveDelay - 1) + + // The portal should not have closed + setTimeout(() => { + document.body.lastElementChild.should.equal(wrapper.instance().node) + done() + }, mouseLeaveDelay + 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,