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.}>
+ Click
+
+ Holy guacamole! Check this info.}>
+ Hover
+
+ Holy guacamole! Check this info.}>
+ Focus
+
+ Holy guacamole! Check this info.}>
+ Click + rootClose
+
+
+);
+
+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}>
+ button
+
+ );
+ 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}
+ >
+ button
+
+ );
+ 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);
+ });
+ });
+ });
+ });
});