diff --git a/docs/examples/OverlayTrigger.js b/docs/examples/OverlayTrigger.js new file mode 100644 index 0000000000..667c6ce2d2 --- /dev/null +++ b/docs/examples/OverlayTrigger.js @@ -0,0 +1,35 @@ + +const Example = React.createClass({ + render(){ + const style = { + position: 'absolute', + backgroundColor: '#EEE', + border: '1px solid #CCC', + borderRadius: 3, + marginLeft: 5, + padding: 10 + }; + + const overlay = ( +
+ Holy guacamole! Check this info. +
+ ); + + return ( +
+ + + +
+ ); + } +}); + +React.render(, mountNode); diff --git a/docs/src/ComponentsPage.js b/docs/src/ComponentsPage.js index a04a1097c7..3ac37f435e 100644 --- a/docs/src/ComponentsPage.js +++ b/docs/src/ComponentsPage.js @@ -306,49 +306,79 @@ const ComponentsPage = React.createClass({ - {/* Tooltip */} + {/* overlays */}
-

Tooltips Tooltip

-

Example tooltips

+

Overlays Overlay, Tooltip, Popover

-

Tooltip component.

- +

Overlay

+

+ Overlays allow components to be rendered and positioned to the left, right, top, or bottom of another component. + They are perfect for simple tooltips or even more complicated popups. +

+ -

Positioned tooltip component.

- +

Overlay Trigger

+

+ Often you will want to show or hide and Overlay in response to an action by its target, such as hovering over a link. + Since this is such a common pattern we provide the OverlayTrigger component to reduce the amount of boilerplate + you need to write to implement this pattern. +

+ -

Positioned tooltip in copy.

- +

Props

-

Props

+

Overlay

+ - +

OverlayTrigger

+ -
+ {/* Tooltip */} +
+

Tooltip

+

+ You don't always need to create custom styling for your overlays. Bootstrap provides two great options out of the box. + Tooltips can be used inside an Overlay Component, or an OverlayTrigger +

+ - {/* Popover */} -
-

Popovers Popover

-

Example popovers

+

Positioned tooltip component.

+ -

Popover component.

- +

Positioned tooltip in copy.

+ -

Positioned popover component.

- +

Props

+ -

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 */} +
+

Popovers

+ +

+ The Popover component, like the Tooltip can be used with an Overlay Component, or an OverlayTrigger. + Unlike the Tooltip popovers are designed to display more reobust information about their targets. +

+ -

Popover component in container.

- +

Positioned popover component.

+ -

Positioned popover components in scrolling container.

- +

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

+ -

Props

+

Popover component in container.

+ - +

Positioned popover components in scrolling container.

+ + +

Props

+ + +
{/* Progress Bar */} @@ -833,6 +863,7 @@ const ComponentsPage = React.createClass({ Panels Modals + Overlays Tooltips Popovers Progress bars diff --git a/src/OverlayTrigger.js b/src/OverlayTrigger.js index b708faf0bd..bb22fd88ae 100644 --- a/src/OverlayTrigger.js +++ b/src/OverlayTrigger.js @@ -1,11 +1,13 @@ import React, { cloneElement } from 'react'; -import OverlayMixin from './OverlayMixin'; -import RootCloseWrapper from './RootCloseWrapper'; - import createChainedFunction from './utils/createChainedFunction'; import createContextWrapper from './utils/createContextWrapper'; -import domUtils from './utils/domUtils'; +import CustomPropTypes from './utils/CustomPropTypes'; +import Overlay from './Overlay'; +import position from './utils/overlayPositionUtils'; + +import deprecationWarning from './utils/deprecationWarning'; +import warning from 'react/lib/warning'; /** * Check if value one is inside or equal to the of value @@ -22,52 +24,84 @@ function isOneOf(one, of) { } const OverlayTrigger = React.createClass({ - mixins: [OverlayMixin], propTypes: { + /** + * The event or action that toggles the overlay visibility. + */ trigger: React.PropTypes.oneOfType([ React.PropTypes.oneOf(['manual', 'click', 'hover', 'focus']), React.PropTypes.arrayOf(React.PropTypes.oneOf(['click', 'hover', 'focus'])) ]), - placement: React.PropTypes.oneOf(['top', 'right', 'bottom', 'left']), + + /** + * A millisecond delay amount to show and hide the Overlay once triggered + */ delay: React.PropTypes.number, + /** + * A millisecond delay amount before showing the Overlay once triggered. + */ delayShow: React.PropTypes.number, + /** + * A millisecond delay amount before hiding the Overlay once triggered. + */ delayHide: React.PropTypes.number, + + /** + * The initial visibility state of the Overlay, for more nuanced visibility controll consider + * using the Overlay component directly. + */ defaultOverlayShown: React.PropTypes.bool, + + /** + * An element or text to overlay next to the target. + */ overlay: React.PropTypes.node.isRequired, + /** + * @private + */ onBlur: React.PropTypes.func, + /** + * @private + */ onClick: React.PropTypes.func, + /** + * @private + */ onFocus: React.PropTypes.func, + /** + * @private + */ onMouseEnter: React.PropTypes.func, + /** + * @private + */ onMouseLeave: React.PropTypes.func, + + container: CustomPropTypes.mountable, + containerPadding: React.PropTypes.number, + placement: React.PropTypes.oneOf(['top', 'right', 'bottom', 'left']), + rootClose: React.PropTypes.bool }, getDefaultProps() { return { - placement: 'right', - trigger: ['hover', 'focus'], - containerPadding: 0 + trigger: ['hover', 'focus'] }; }, getInitialState() { return { isOverlayShown: this.props.defaultOverlayShown == null ? - false : this.props.defaultOverlayShown, - overlayLeft: null, - overlayTop: null, - arrowOffsetLeft: null, - arrowOffsetTop: null + false : this.props.defaultOverlayShown }; }, show() { this.setState({ isOverlayShown: true - }, function() { - this.updateOverlayPosition(); }); }, @@ -81,77 +115,90 @@ const OverlayTrigger = React.createClass({ if (this.state.isOverlayShown) { this.hide(); } else { - this.show(); + this.show(); } }, - renderOverlay() { - if (!this.state.isOverlayShown) { - return ; - } + componentDidMount(){ + this._mountNode = document.createElement('div'); + React.render(this._overlay, this._mountNode); + }, - const overlay = cloneElement( - this.props.overlay, - { - onRequestHide: this.hide, - placement: this.props.placement, - positionLeft: this.state.overlayLeft, - positionTop: this.state.overlayTop, - arrowOffsetLeft: this.state.arrowOffsetLeft, - arrowOffsetTop: this.state.arrowOffsetTop - } - ); + componentWillUnmount() { + React.unmountComponentAtNode(this._mountNode); + this._mountNode = null; + clearTimeout(this._hoverDelay); + }, - if (this.props.rootClose) { - return ( - - {overlay} - - ); - } else { - return overlay; - } + componentDidUpdate(){ + React.render(this._overlay, this._mountNode); + }, + + getOverlay(){ + let props = { + show: this.state.isOverlayShown, + onHide: this.hide, + rootClose: this.props.rootClose, + target: ()=> React.findDOMNode(this), + placement: this.props.placement, + container: this.props.container, + containerPadding: this.props.containerPadding + }; + + let overlay = cloneElement(this.props.overlay, { + placement: props.placement, + container: props.container + }); + + return ( + + { overlay } + + ); }, render() { - const child = React.Children.only(this.props.children); - if (this.props.trigger === 'manual') { - return child; - } + const trigger = React.Children.only(this.props.children); - const props = {}; + const props = { + 'aria-describedby': this.props.overlay.props.id + }; - props.onClick = createChainedFunction(child.props.onClick, this.props.onClick); - if (isOneOf('click', this.props.trigger)) { - props.onClick = createChainedFunction(this.toggle, props.onClick); - } + // create in render otherwise owner is lost... + this._overlay = this.getOverlay(); - if (isOneOf('hover', this.props.trigger)) { - props.onMouseEnter = createChainedFunction(this.handleDelayedShow, this.props.onMouseEnter); - props.onMouseLeave = createChainedFunction(this.handleDelayedHide, this.props.onMouseLeave); - } + if (this.props.trigger !== 'manual') { + + props.onClick = createChainedFunction(trigger.props.onClick, this.props.onClick); - if (isOneOf('focus', this.props.trigger)) { - props.onFocus = createChainedFunction(this.handleDelayedShow, this.props.onFocus); - props.onBlur = createChainedFunction(this.handleDelayedHide, this.props.onBlur); + if (isOneOf('click', this.props.trigger)) { + props.onClick = createChainedFunction(this.toggle, props.onClick); + } + + if (isOneOf('hover', this.props.trigger)) { + warning(!(this.props.trigger === 'hover'), + '[react-bootstrap] Specifying only the `"hover"` trigger limits the visibilty of the overlay to just mouse users. ' + + 'Consider also including the `"focus"` trigger so that touch and keyboard only users can see the overlay as well.'); + + props.onMouseOver = createChainedFunction(this.handleDelayedShow, this.props.onMouseOver); + props.onMouseOut = createChainedFunction(this.handleDelayedHide, this.props.onMouseOut); + } + + if (isOneOf('focus', this.props.trigger)) { + props.onFocus = createChainedFunction(this.handleDelayedShow, this.props.onFocus); + props.onBlur = createChainedFunction(this.handleDelayedHide, this.props.onBlur); + } + } + else { + deprecationWarning('"manual" trigger type', ' the Overlay component'); } return cloneElement( - child, + trigger, props ); }, - componentWillUnmount() { - clearTimeout(this._hoverDelay); - }, - - componentDidMount() { - if (this.props.defaultOverlayShown) { - this.updateOverlayPosition(); - } - }, - handleDelayedShow() { if (this._hoverDelay != null) { clearTimeout(this._hoverDelay); @@ -167,10 +214,10 @@ const OverlayTrigger = React.createClass({ return; } - this._hoverDelay = setTimeout(function() { + this._hoverDelay = setTimeout(() => { this._hoverDelay = null; this.show(); - }.bind(this), delay); + }, delay); }, handleDelayedHide() { @@ -188,133 +235,38 @@ const OverlayTrigger = React.createClass({ return; } - this._hoverDelay = setTimeout(function() { + this._hoverDelay = setTimeout(() => { this._hoverDelay = null; this.hide(); - }.bind(this), delay); - }, - - updateOverlayPosition() { - if (!this.isMounted()) { - return; - } - - this.setState(this.calcOverlayPosition()); + }, delay); }, + // deprecated Methods calcOverlayPosition() { - const childOffset = this.getPosition(); - - const overlayNode = this.getOverlayDOMNode(); - const overlayHeight = overlayNode.offsetHeight; - const overlayWidth = overlayNode.offsetWidth; - - const placement = this.props.placement; - let overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop; - - if (placement === 'left' || placement === 'right') { - overlayTop = childOffset.top + (childOffset.height - overlayHeight) / 2; - - if (placement === 'left') { - overlayLeft = childOffset.left - overlayWidth; - } else { - overlayLeft = childOffset.left + childOffset.width; - } - - const topDelta = this._getTopDelta(overlayTop, overlayHeight); - overlayTop += topDelta; - arrowOffsetTop = 50 * (1 - 2 * topDelta / overlayHeight) + '%'; - arrowOffsetLeft = null; - } else if (placement === 'top' || placement === 'bottom') { - overlayLeft = childOffset.left + (childOffset.width - overlayWidth) / 2; - - if (placement === 'top') { - overlayTop = childOffset.top - overlayHeight; - } else { - overlayTop = childOffset.top + childOffset.height; - } - - const leftDelta = this._getLeftDelta(overlayLeft, overlayWidth); - overlayLeft += leftDelta; - arrowOffsetLeft = 50 * (1 - 2 * leftDelta / overlayWidth) + '%'; - arrowOffsetTop = null; - } else { - throw new Error( - 'calcOverlayPosition(): No such placement of "' + - this.props.placement + '" found.' - ); - } - - return {overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop}; - }, + let overlay = this.props.overlay; - _getTopDelta(top, overlayHeight) { - const containerDimensions = this._getContainerDimensions(); - const containerScroll = containerDimensions.scroll; - const containerHeight = containerDimensions.height; + deprecationWarning('OverlayTrigger.calcOverlayPosition()', 'utils/overlayPositionUtils'); - const padding = this.props.containerPadding; - const topEdgeOffset = top - padding - containerScroll; - const bottomEdgeOffset = top + padding - containerScroll + overlayHeight; - - if (topEdgeOffset < 0) { - return -topEdgeOffset; - } else if (bottomEdgeOffset > containerHeight) { - return containerHeight - bottomEdgeOffset; - } else { - return 0; - } - }, - - _getLeftDelta(left, overlayWidth) { - const containerDimensions = this._getContainerDimensions(); - const containerWidth = containerDimensions.width; - - const padding = this.props.containerPadding; - const leftEdgeOffset = left - padding; - const rightEdgeOffset = left + padding + overlayWidth; - - if (leftEdgeOffset < 0) { - return -leftEdgeOffset; - } else if (rightEdgeOffset > containerWidth) { - return containerWidth - rightEdgeOffset; - } else { - return 0; - } - }, - - _getContainerDimensions() { - const containerNode = this.getContainerDOMNode(); - let width, height, scroll; - - if (containerNode.tagName === 'BODY') { - width = window.innerWidth; - height = window.innerHeight; - scroll = - domUtils.ownerDocument(containerNode).documentElement.scrollTop || - containerNode.scrollTop; - } else { - width = containerNode.offsetWidth; - height = containerNode.offsetHeight; - scroll = containerNode.scrollTop; - } - - return {width, height, scroll}; + return position.calcOverlayPosition( + overlay.props.placement || this.props.placement + , React.findDOMNode(overlay) + , React.findDOMNode(this) + , React.findDOMNode(overlay.props.container || this.props.container) + , overlay.props.containerPadding || this.props.containerPadding + ); }, getPosition() { - const node = React.findDOMNode(this); - const container = this.getContainerDOMNode(); + deprecationWarning('OverlayTrigger.getPosition()', 'utils/overlayPositionUtils'); - const offset = container.tagName === 'BODY' ? - domUtils.getOffset(node) : domUtils.getPosition(node, container); + let overlay = this.props.overlay; - return { - ...offset, - height: node.offsetHeight, - width: node.offsetWidth - }; + return position.getPosition( + React.findDOMNode(this) + , React.findDOMNode(overlay.props.container || this.props.container) + ); } + }); /** diff --git a/test/OverlayMixinSpec.js b/test/OverlayMixinSpec.js index d1a52fd282..66818d48f5 100644 --- a/test/OverlayMixinSpec.js +++ b/test/OverlayMixinSpec.js @@ -1,6 +1,7 @@ import React from 'react'; import ReactTestUtils from 'react/lib/ReactTestUtils'; import OverlayMixin from '../src/OverlayMixin'; +import { shouldWarn } from './helpers'; describe('OverlayMixin', function () { let instance; @@ -17,10 +18,15 @@ describe('OverlayMixin', function () { } }); + afterEach(function() { if (instance && ReactTestUtils.isCompositeComponent(instance) && instance.isMounted()) { React.unmountComponentAtNode(React.findDOMNode(instance)); } + + if ( console.warn.called ) { + shouldWarn('Overlay mixin is deprecated'); + } }); it('Should render overlay into container (DOMNode)', function() { diff --git a/test/OverlayTriggerSpec.js b/test/OverlayTriggerSpec.js index 756cb4a46d..1d871151d8 100644 --- a/test/OverlayTriggerSpec.js +++ b/test/OverlayTriggerSpec.js @@ -79,103 +79,6 @@ describe('OverlayTrigger', function() { contextSpy.calledWith('value').should.be.true; }); - describe('#calcOverlayPosition()', function() { - [ - { - placement: 'left', - noOffset: [50, 300, null, '50%'], - offsetBefore: [-200, 150, null, '0%'], - offsetAfter: [300, 450, null, '100%'] - }, - { - placement: 'top', - noOffset: [200, 150, '50%', null], - offsetBefore: [50, -100, '0%', null], - offsetAfter: [350, 400, '100%', null] - }, - { - placement: 'bottom', - noOffset: [200, 450, '50%', null], - offsetBefore: [50, 200, '0%', null], - offsetAfter: [350, 700, '100%', null] - }, - { - placement: 'right', - noOffset: [350, 300, null, '50%'], - offsetBefore: [100, 150, null, '0%'], - offsetAfter: [600, 450, null, '100%'] - } - ].forEach(function(testCase) { - describe(`placement = ${testCase.placement}`, function() { - let instance; - - beforeEach(function() { - instance = ReactTestUtils.renderIntoDocument( - test} - > - - - ); - - instance.getOverlayDOMNode = sinon.stub().returns({ - offsetHeight: 200, offsetWidth: 200 - }); - instance._getContainerDimensions = sinon.stub().returns({ - width: 600, height: 600, scroll: 100 - }); - }); - - function checkPosition(expected) { - const [ - overlayLeft, - overlayTop, - arrowOffsetLeft, - arrowOffsetTop - ] = expected; - - it('Should calculate the correct position', function() { - instance.calcOverlayPosition().should.eql( - {overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop} - ); - }); - } - - describe('no viewport offset', function() { - beforeEach(function() { - instance.getPosition = sinon.stub().returns({ - left: 250, top: 350, width: 100, height: 100 - }); - }); - - checkPosition(testCase.noOffset); - }); - - describe('viewport offset before', function() { - beforeEach(function() { - instance.getPosition = sinon.stub().returns({ - left: 0, top: 100, width: 100, height: 100 - }); - }); - - checkPosition(testCase.offsetBefore); - }); - - describe('viewport offset after', function() { - beforeEach(function() { - instance.getPosition = sinon.stub().returns({ - left: 500, top: 600, width: 100, height: 100 - }); - }); - - checkPosition(testCase.offsetAfter); - }); - }); - }); - }); - describe('overlay types', function() { [ { @@ -227,8 +130,8 @@ describe('OverlayTrigger', function() { test} trigger='click' rootClose={testCase.rootClose} - > - + > + ); const overlayTrigger = React.findDOMNode(instance);