diff --git a/docs/examples/PopoverTriggerBehaviors.js b/docs/examples/PopoverTriggerBehaviors.js new file mode 100644 index 0000000000..20e68a15b6 --- /dev/null +++ b/docs/examples/PopoverTriggerBehaviors.js @@ -0,0 +1,18 @@ +const positionerInstance = ( + + Holy guacamole! Check this info.}> + + + Holy guacamole! Check this info.}> + + + Holy guacamole! Check this info.}> + + + Holy guacamole! Check this info.}> + + + +); + +React.render(positionerInstance, mountNode); diff --git a/docs/src/ComponentsPage.js b/docs/src/ComponentsPage.js index a907056101..f8e502581a 100644 --- a/docs/src/ComponentsPage.js +++ b/docs/src/ComponentsPage.js @@ -272,6 +272,9 @@ const ComponentsPage = React.createClass({

Positioned popover component.

+

Trigger behaviors. It's inadvisable to use "hover" or "focus" triggers for popovers, because they have poor accessibility from keyboard and on mobile devices.

+ +

Popover component in container.

diff --git a/docs/src/Samples.js b/docs/src/Samples.js index 9803e1bca9..3fc3a163a5 100644 --- a/docs/src/Samples.js +++ b/docs/src/Samples.js @@ -40,6 +40,7 @@ export default { TooltipInCopy: require('fs').readFileSync(__dirname + '/../examples/TooltipInCopy.js', 'utf8'), PopoverBasic: require('fs').readFileSync(__dirname + '/../examples/PopoverBasic.js', 'utf8'), PopoverPositioned: require('fs').readFileSync(__dirname + '/../examples/PopoverPositioned.js', 'utf8'), + PopoverTriggerBehaviors: require('fs').readFileSync(__dirname + '/../examples/PopoverTriggerBehaviors.js', 'utf8'), PopoverContained: require('fs').readFileSync(__dirname + '/../examples/PopoverContained.js', 'utf8'), PopoverPositionedScrolling: require('fs').readFileSync(__dirname + '/../examples/PopoverPositionedScrolling.js', 'utf8'), ProgressBarBasic: require('fs').readFileSync(__dirname + '/../examples/ProgressBarBasic.js', 'utf8'), diff --git a/src/OverlayTrigger.js b/src/OverlayTrigger.js index 4a0e695bd5..d40faedcd2 100644 --- a/src/OverlayTrigger.js +++ b/src/OverlayTrigger.js @@ -1,10 +1,12 @@ import React, { cloneElement } from 'react'; + import OverlayMixin from './OverlayMixin'; -import domUtils from './utils/domUtils'; +import RootCloseWrapper from './RootCloseWrapper'; import createChainedFunction from './utils/createChainedFunction'; -import assign from './utils/Object.assign'; import createContextWrapper from './utils/createContextWrapper'; +import domUtils from './utils/domUtils'; +import assign from './utils/Object.assign'; /** * Check if value one is inside or equal to the of value @@ -34,7 +36,8 @@ const OverlayTrigger = React.createClass({ delayHide: React.PropTypes.number, defaultOverlayShown: React.PropTypes.bool, overlay: React.PropTypes.node.isRequired, - containerPadding: React.PropTypes.number + containerPadding: React.PropTypes.number, + rootClose: React.PropTypes.bool }, getDefaultProps() { @@ -83,7 +86,7 @@ const OverlayTrigger = React.createClass({ return ; } - return cloneElement( + const overlay = cloneElement( this.props.overlay, { onRequestHide: this.hide, @@ -94,6 +97,16 @@ const OverlayTrigger = React.createClass({ arrowOffsetTop: this.state.arrowOffsetTop } ); + + if (this.props.rootClose) { + return ( + + {overlay} + + ); + } else { + return overlay; + } }, render() { diff --git a/src/RootCloseWrapper.js b/src/RootCloseWrapper.js new file mode 100644 index 0000000000..ac5fd7cf18 --- /dev/null +++ b/src/RootCloseWrapper.js @@ -0,0 +1,82 @@ +import React from 'react'; +import domUtils from './utils/domUtils'; +import EventListener from './utils/EventListener'; + +// TODO: Merge this logic with dropdown logic once #526 is done. + +/** + * Checks whether a node is within + * a root nodes tree + * + * @param {DOMElement} node + * @param {DOMElement} root + * @returns {boolean} + */ +function isNodeInRoot(node, root) { + while (node) { + if (node === root) { + return true; + } + node = node.parentNode; + } + + return false; +} + +export default class RootCloseWrapper extends React.Component { + constructor(props) { + super(props); + + this.handleDocumentClick = this.handleDocumentClick.bind(this); + this.handleDocumentKeyUp = this.handleDocumentKeyUp.bind(this); + } + + bindRootCloseHandlers() { + const doc = domUtils.ownerDocument(this); + + this._onDocumentClickListener = + EventListener.listen(doc, 'click', this.handleDocumentClick); + this._onDocumentKeyupListener = + EventListener.listen(doc, 'keyup', this.handleDocumentKeyUp); + } + + handleDocumentClick(e) { + // If the click originated from within this component, don't do anything. + if (isNodeInRoot(e.target, React.findDOMNode(this))) { + return; + } + + this.props.onRootClose(); + } + + handleDocumentKeyUp(e) { + if (e.keyCode === 27) { + this.props.onRootClose(); + } + } + + unbindRootCloseHandlers() { + if (this._onDocumentClickListener) { + this._onDocumentClickListener.remove(); + } + + if (this._onDocumentKeyupListener) { + this._onDocumentKeyupListener.remove(); + } + } + + componentDidMount() { + this.bindRootCloseHandlers(); + } + + render() { + return React.Children.only(this.props.children); + } + + componentWillUnmount() { + this.unbindRootCloseHandlers(); + } +} +RootCloseWrapper.propTypes = { + onRootClose: React.PropTypes.func.isRequired +}; diff --git a/test/OverlayTriggerSpec.js b/test/OverlayTriggerSpec.js index d1e921ddae..756cb4a46d 100644 --- a/test/OverlayTriggerSpec.js +++ b/test/OverlayTriggerSpec.js @@ -27,6 +27,18 @@ describe('OverlayTrigger', function() { callback.called.should.be.true; }); + it('Should show after click trigger', function() { + const instance = ReactTestUtils.renderIntoDocument( + test}> + + + ); + const overlayTrigger = React.findDOMNode(instance); + ReactTestUtils.Simulate.click(overlayTrigger); + + instance.state.isOverlayShown.should.be.true; + }); + it('Should forward requested context', function() { const contextTypes = { key: React.PropTypes.string @@ -193,4 +205,44 @@ describe('OverlayTrigger', function() { }); }); }); + + describe('rootClose', function() { + [ + { + label: 'true', + rootClose: true, + shownAfterClick: false + }, + { + label: 'default (false)', + rootClose: null, + shownAfterClick: true + } + ].forEach(function(testCase) { + describe(testCase.label, function() { + let instance; + + beforeEach(function () { + instance = ReactTestUtils.renderIntoDocument( + test} + trigger='click' rootClose={testCase.rootClose} + > + + + ); + const overlayTrigger = React.findDOMNode(instance); + ReactTestUtils.Simulate.click(overlayTrigger); + }); + + it('Should have correct isOverlayShown state', function () { + const event = document.createEvent('HTMLEvents'); + event.initEvent('click', true, true); + document.documentElement.dispatchEvent(event); + + instance.state.isOverlayShown.should.equal(testCase.shownAfterClick); + }); + }); + }); + }); });