Skip to content

Commit

Permalink
feat(Portal): More flexible configuration
Browse files Browse the repository at this point in the history
* Add closeOnTriggerClick prop
* Update closeOnMouseLeave to handle mouse leave of either trigger or portal
* Add configurable delay for open/close on hover
  • Loading branch information
jeffcarbs committed Oct 3, 2016
1 parent 628e21c commit 77e7fed
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 34 deletions.
94 changes: 75 additions & 19 deletions src/addons/Portal/Portal.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,27 @@ 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,

/** 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,

Expand Down Expand Up @@ -110,6 +122,10 @@ class Portal extends Component {

componentWillUnmount() {
this.unmountPortal()

// Clean up timers
clearTimeout(this.mouseOverTimer)
clearTimeout(this.mouseLeaveTimer)
}

// ----------------------------------------
Expand All @@ -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()')
Expand All @@ -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

Expand All @@ -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) => {
Expand All @@ -184,27 +218,31 @@ 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)

if (!openOnTriggerMouseOver) return

debug('handleTriggerMouseOver()')
this.open(e)
this.mouseOverTimer = this.openWithTimeout(e, mouseOverDelay)
}

// ----------------------------------------
Expand All @@ -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()')

Expand All @@ -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

Expand All @@ -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 = () => {
Expand All @@ -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

Expand Down
130 changes: 115 additions & 15 deletions test/specs/addons/Portal/Portal-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -213,47 +213,147 @@ describe('Portal', () => {
})
})

describe('closeOnTriggerClick', () => {
it('should not close portal on click', () => {
const spy = sandbox.spy()
const trigger = <button onClick={spy}>button</button>
wrapperMount(<Portal trigger={trigger} defaultOpen><p>Hi</p></Portal>)

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 onClick={spy}>button</button>
wrapperMount(<Portal trigger={trigger} defaultOpen closeOnTriggerClick><p>Hi</p></Portal>)

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 onMouseOver={spy}>button</button>
wrapperMount(<Portal trigger={trigger}><p>Hi</p></Portal>)
const mouseOverDelay = 100
wrapperMount(<Portal trigger={trigger} mouseOverDelay={mouseOverDelay}><p>Hi</p></Portal>)

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 onMouseOver={spy}>button</button>
wrapperMount(<Portal trigger={trigger} openOnTriggerMouseOver><p>Hi</p></Portal>)
const mouseOverDelay = 100
wrapperMount(
<Portal trigger={trigger} openOnTriggerMouseOver mouseOverDelay={mouseOverDelay}><p>Hi</p></Portal>
)

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 = <button onMouseLeave={spy}>button</button>
wrapperMount(<Portal trigger={trigger} defaultOpen><p>Hi</p></Portal>)
const mouseLeaveDelay = 100
wrapperMount(<Portal trigger={trigger} defaultOpen mouseLeaveDelay={mouseLeaveDelay}><p>Hi</p></Portal>)

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 = <button onMouseLeave={spy}>button</button>
wrapperMount(<Portal trigger={trigger} defaultOpen closeOnTriggerMouseLeave><p>Hi</p></Portal>)
const mouseLeaveDelay = 100
wrapperMount(
<Portal trigger={trigger} defaultOpen closeOnMouseLeave mouseLeaveDelay={mouseLeaveDelay}><p>Hi</p></Portal>
)

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 = <button>button</button>
const mouseLeaveDelay = 100
wrapperMount(
<Portal trigger={trigger} defaultOpen closeOnMouseLeave mouseLeaveDelay={mouseLeaveDelay}><p>Hi</p></Portal>
)

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 = <button>button</button>
const mouseLeaveDelay = 100
wrapperMount(
<Portal trigger={trigger} defaultOpen closeOnMouseLeave mouseLeaveDelay={mouseLeaveDelay}><p>Hi</p></Portal>
)

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)
})
})

Expand Down
18 changes: 18 additions & 0 deletions test/utils/domEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -44,6 +60,8 @@ export const click = (node, data) => fire(node, 'click', data)

export default {
fire,
mouseLeave,
mouseOver,
mouseUp,
keyDown,
click,
Expand Down

0 comments on commit 77e7fed

Please sign in to comment.