diff --git a/docs/examples/ModalStatic.js b/docs/examples/ModalStatic.js new file mode 100644 index 0000000000..e073616bc2 --- /dev/null +++ b/docs/examples/ModalStatic.js @@ -0,0 +1,22 @@ + +const modalInstance = ( +
+ + + Modal title + + + + One fine body... + + + + + + + + +
+); + +React.render(modalInstance, mountNode); diff --git a/docs/src/ComponentsPage.js b/docs/src/ComponentsPage.js index 7ccafb5a9d..667fa7d37b 100644 --- a/docs/src/ComponentsPage.js +++ b/docs/src/ComponentsPage.js @@ -263,6 +263,10 @@ const ComponentsPage = React.createClass({

Modals Modal

+

Static Markup

+

A modal dialog component

+ +

Basic example

diff --git a/docs/src/Samples.js b/docs/src/Samples.js index a3ceda963a..fc58d7ed58 100644 --- a/docs/src/Samples.js +++ b/docs/src/Samples.js @@ -34,6 +34,7 @@ export default { PanelGroupUncontrolled: require('fs').readFileSync(__dirname + '/../examples/PanelGroupUncontrolled.js', 'utf8'), PanelGroupAccordion: require('fs').readFileSync(__dirname + '/../examples/PanelGroupAccordion.js', 'utf8'), Modal: require('fs').readFileSync(__dirname + '/../examples/Modal.js', 'utf8'), + ModalStatic: require('fs').readFileSync(__dirname + '/../examples/ModalStatic.js', 'utf8'), ModalContained: require('fs').readFileSync(__dirname + '/../examples/ModalContained.js', 'utf8'), ModalDefaultSizing: require('fs').readFileSync(__dirname + '/../examples/ModalDefaultSizing.js', 'utf8'), ModalCustomSizing: require('fs').readFileSync(__dirname + '/../examples/ModalCustomSizing.js', 'utf8'), diff --git a/src/Modal.js b/src/Modal.js index 417e53fda4..c98041b5e8 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -1,15 +1,13 @@ /*eslint-disable react/prop-types */ import React, { cloneElement } from 'react'; - import classNames from 'classnames'; -import createChainedFunction from './utils/createChainedFunction'; -import BootstrapMixin from './BootstrapMixin'; import domUtils from './utils/domUtils'; import EventListener from './utils/EventListener'; +import createChainedFunction from './utils/createChainedFunction'; import Portal from './Portal'; import Fade from './Fade'; - +import Dialog from './ModalDialog'; import Body from './ModalBody'; import Header from './ModalHeader'; import Title from './ModalTitle'; @@ -93,12 +91,11 @@ function getScrollbarSize(){ return scrollbarSize; } - -const ModalMarkup = React.createClass({ - - mixins: [ BootstrapMixin ], - +const Modal = React.createClass({ propTypes: { + ...Portal.propTypes, + ...Dialog.propTypes, + /** * Include a backdrop component. Specify 'static' for a backdrop that doesn't trigger an "onHide" when clicked. */ @@ -112,17 +109,6 @@ const ModalMarkup = React.createClass({ * Open and close the Modal with a slide and fade animation. */ animation: React.PropTypes.bool, - /** - * A Callback fired when the header closeButton or non-static backdrop is clicked. - * @type {function} - * @required - */ - onHide: React.PropTypes.func.isRequired, - - /** - * A css class to apply to the Modal dialog DOM node. - */ - dialogClassName: React.PropTypes.string, /** * When `true` The modal will automatically shift focus to itself when it opens, and replace it to the last focused element when it closes. @@ -138,62 +124,77 @@ const ModalMarkup = React.createClass({ enforceFocus: React.PropTypes.bool }, - getDefaultProps() { + getDefaultProps(){ return { bsClass: 'modal', + show: false, + animation: true, backdrop: true, keyboard: true, - animation: true, - closeButton: true, - autoFocus: true, enforceFocus: true }; }, getInitialState(){ - return { }; + return {exited: !this.props.show}; }, render() { - let state = this.state; - let modalStyle = { ...state.dialogStyles, display: 'block'}; - let dialogClasses = this.getBsClassSet(); + let { children, animation, backdrop, ...props } = this.props; + let { onExit, onExiting, onEnter, onEntering, onEntered } = props; - delete dialogClasses.modal; - dialogClasses['modal-dialog'] = true; + let show = !!props.show; - let classes = { - modal: true, - in: this.props.show && !this.props.animation - }; + const mountModal = show || (animation && !this.state.exited); + if (!mountModal) { + return null; + } let modal = ( -

-
-
- { this.renderContent() } -
-
-
+ + { this.renderContent() } + ); - return this.props.backdrop ? - this.renderBackdrop(modal, state.backdropStyles) : modal; + if ( animation ) { + modal = ( + + { modal } + + ); + } + + if (backdrop) { + modal = this.renderBackdrop(modal); + } + + return ( + + { modal } + + ); }, renderContent() { return React.Children.map(this.props.children, child => { // TODO: use context in 0.14 - if (child.type.__isModalHeader) { + if (child && child.type && child.type.__isModalHeader) { return cloneElement(child, { onHide: createChainedFunction(this.props.onHide, child.props.onHide) }); @@ -203,18 +204,18 @@ const ModalMarkup = React.createClass({ }, renderBackdrop(modal) { - let { animation } = this.props; - let duration = Modal.BACKDROP_TRANSITION_DURATION; //eslint-disable-line no-use-before-define + let { animation, bsClass } = this.props; + let duration = Modal.BACKDROP_TRANSITION_DURATION; let backdrop = (
); return ( -
+
{ animation ? {backdrop} : backdrop @@ -224,19 +225,49 @@ const ModalMarkup = React.createClass({ ); }, - iosClickHack() { - // IOS only allows click events to be delegated to the document on elements - // it considers 'clickable' - anchors, buttons, etc. We fake a click handler on the - // DOM nodes themselves. Remove if handled by React: https://github.com/facebook/react/issues/1169 - React.findDOMNode(this.refs.modal).onclick = function () {}; - React.findDOMNode(this.refs.backdrop).onclick = function () {}; + _setDialogRef(ref){ + this.refs.dialog = ref; + + //maintains backwards compat with older component breakdown + if (!this.props.backdrop) { + this.refs.modal = ref; + } }, - componentWillMount(){ - this.checkForFocus(); + componentWillReceiveProps(nextProps) { + if (nextProps.show) { + this.setState({exited: false}); + } else if (!nextProps.animation) { + // Otherwise let handleHidden take care of marking exited. + this.setState({exited: true}); + } + }, + + componentWillUpdate(nextProps){ + if (nextProps.show) { + this.checkForFocus(); + } }, componentDidMount() { + if ( this.props.show ){ + this.onShow(); + } + }, + + componentDidUpdate(prevProps) { + let { animation } = this.props; + + if ( prevProps.show && !this.props.show && !animation) { + //otherwise handleHidden will call this. + this.onHide(); + } + else if ( !prevProps.show && this.props.show ) { + this.onShow(); + } + }, + + onShow() { const doc = domUtils.ownerDocument(this); const win = domUtils.ownerWindow(this); @@ -244,7 +275,7 @@ const ModalMarkup = React.createClass({ EventListener.listen(doc, 'keyup', this.handleDocumentKeyUp); this._onWindowResizeListener = - EventListener.listen(win, 'resize', this.handleWindowResize); + EventListener.listen(win, 'resize', this.handleWindowResize); if (this.props.enforceFocus) { this._onFocusinListener = onFocus(this, this.enforceFocus); @@ -270,19 +301,7 @@ const ModalMarkup = React.createClass({ , () => this.focusModalContent()); }, - componentDidUpdate(prevProps) { - if (this.props.backdrop && this.props.backdrop !== prevProps.backdrop) { - this.iosClickHack(); - this.setState(this._getStyles()); //eslint-disable-line react/no-did-update-set-state - } - - if (this.props.container !== prevProps.container) { - let container = getContainer(this); - this._containerIsOverflowing = container.scrollHeight > containerClientHeight(container, this); - } - }, - - componentWillUnmount() { + onHide() { this._onDocumentKeyupListener.remove(); this._onWindowResizeListener.remove(); @@ -299,6 +318,16 @@ const ModalMarkup = React.createClass({ this.restoreLastFocus(); }, + handleHidden(...args) { + this.setState({ exited: true }); + + this.onHide(); + + if (this.props.onExited) { + this.props.onExited(...args); + } + }, + handleBackdropClick(e) { if (e.target !== e.currentTarget) { return; @@ -327,19 +356,18 @@ const ModalMarkup = React.createClass({ }, focusModalContent () { - let modalContent = React.findDOMNode(this.refs.modal); + let modalContent = React.findDOMNode(this.refs.dialog); let current = domUtils.activeElement(this); let focusInModal = current && domUtils.contains(modalContent, current); - if (this.props.autoFocus && !focusInModal) { + if (modalContent && this.props.autoFocus && !focusInModal) { this.lastFocus = current; - modalContent.focus(); } }, restoreLastFocus () { - if (this.lastFocus) { + if (this.lastFocus && this.lastFocus.focus) { this.lastFocus.focus(); this.lastFocus = null; } @@ -351,13 +379,21 @@ const ModalMarkup = React.createClass({ } let active = domUtils.activeElement(this); - let modal = React.findDOMNode(this.refs.modal); + let modal = React.findDOMNode(this.refs.dialog); - if (modal !== active && !domUtils.contains(modal, active)){ + if (modal && modal !== active && !domUtils.contains(modal, active)){ modal.focus(); } }, + iosClickHack() { + // IOS only allows click events to be delegated to the document on elements + // it considers 'clickable' - anchors, buttons, etc. We fake a click handler on the + // DOM nodes themselves. Remove if handled by React: https://github.com/facebook/react/issues/1169 + React.findDOMNode(this.refs.modal).onclick = function () {}; + React.findDOMNode(this.refs.backdrop).onclick = function () {}; + }, + _getStyles() { if ( !domUtils.canUseDom ) { return {}; } @@ -374,51 +410,7 @@ const ModalMarkup = React.createClass({ } }; } -}); -const Modal = React.createClass({ - propTypes: { - ...Portal.propTypes, - ...ModalMarkup.propTypes - }, - - getDefaultProps(){ - return { - show: false, - animation: true - }; - }, - - render() { - let { children, ...props } = this.props; - - let show = !!props.show; - - let modal = ( - - { children } - - ); - - return ( - - { props.animation - ? ( - - { modal } - - ) - : show && modal - } - - - ); - } }); Modal.Body = Body; @@ -426,6 +418,8 @@ Modal.Header = Header; Modal.Title = Title; Modal.Footer = Footer; +Modal.Dialog = Dialog; + Modal.TRANSITION_DURATION = 300; Modal.BACKDROP_TRANSITION_DURATION = 150; diff --git a/src/ModalDialog.js b/src/ModalDialog.js new file mode 100644 index 0000000000..e23ba9954f --- /dev/null +++ b/src/ModalDialog.js @@ -0,0 +1,60 @@ +/*eslint-disable react/prop-types */ +import React from 'react'; +import classNames from 'classnames'; +import BootstrapMixin from './BootstrapMixin'; + +const ModalDialog = React.createClass({ + + mixins: [ BootstrapMixin ], + + propTypes: { + + /** + * A Callback fired when the header closeButton or non-static backdrop is clicked. + * @type {function} + * @required + */ + onHide: React.PropTypes.func.isRequired, + + /** + * A css class to apply to the Modal dialog DOM node. + */ + dialogClassName: React.PropTypes.string + + }, + + getDefaultProps() { + return { + bsClass: 'modal', + closeButton: true + }; + }, + + render() { + let modalStyle = { display: 'block'}; + let bsClass = this.props.bsClass; + let dialogClasses = this.getBsClassSet(); + + delete dialogClasses.modal; + dialogClasses[`${bsClass}-dialog`] = true; + + return ( +
+
+
+ { this.props.children } +
+
+
+ ); + } +}); + +export default ModalDialog; diff --git a/test/ModalSpec.js b/test/ModalSpec.js index 66bb5be9e2..4f88fb5e4e 100644 --- a/test/ModalSpec.js +++ b/test/ModalSpec.js @@ -1,7 +1,7 @@ import React from 'react'; import ReactTestUtils from 'react/lib/ReactTestUtils'; import Modal from '../src/Modal'; -import { render } from './helpers'; +import { render, shouldWarn } from './helpers'; describe('Modal', function () { let mountPoint; @@ -52,6 +52,7 @@ describe('Modal', function () { ); } }); + let instance = render( , mountPoint); @@ -60,7 +61,7 @@ describe('Modal', function () { assert.ok(React.findDOMNode(instance).className.match(/\bmodal-open\b/)); - let backdrop = React.findDOMNode(modal.refs.modal).getElementsByClassName('modal-backdrop')[0]; + let backdrop = React.findDOMNode(modal.refs.backdrop); ReactTestUtils.Simulate.click(backdrop); @@ -74,18 +75,17 @@ describe('Modal', function () { it('Should close the modal when the backdrop is clicked', function (done) { let doneOp = function () { done(); }; let instance = render( - + Message , mountPoint); - let backdrop = React.findDOMNode(instance.refs.modal) - .getElementsByClassName('modal-backdrop')[0]; + let backdrop = React.findDOMNode(instance.refs.backdrop); ReactTestUtils.Simulate.click(backdrop); }); - it('Should close the modal when the modal background is clicked', function (done) { + it('Should close the modal when the modal dialog is clicked', function (done) { let doneOp = function () { done(); }; let instance = render( @@ -94,10 +94,9 @@ describe('Modal', function () { , mountPoint); - let backdrop = React.findDOMNode(instance.refs.modal) - .getElementsByClassName('modal')[0]; + let dialog = React.findDOMNode(instance.refs.dialog); - ReactTestUtils.Simulate.click(backdrop); + ReactTestUtils.Simulate.click(dialog); }); it('Should close the modal when the modal close button is clicked', function (done) { @@ -116,6 +115,27 @@ describe('Modal', function () { ReactTestUtils.Simulate.click(button); }); + it('Should use bsClass on the dialog', function () { + let noOp = function () {}; + let instance = render( + + Message + + , mountPoint); + + let dialog = React.findDOMNode(instance.refs.dialog); + let backdrop = React.findDOMNode(instance.refs.backdrop); + + assert.ok(dialog.className.match(/\bmymodal\b/)); + assert.ok(dialog.children[0].className.match(/\bmymodal-dialog\b/)); + assert.ok(dialog.children[0].children[0].className.match(/\bmymodal-content\b/)); + + assert.ok(backdrop.className.match(/\bmymodal-backdrop\b/)); + + + shouldWarn("Invalid prop 'bsClass' of value 'mymodal'"); + }); + it('Should pass bsSize to the dialog', function () { let noOp = function () {}; let instance = render( @@ -140,6 +160,32 @@ describe('Modal', function () { assert.match(dialog.props.className, /\btestCss\b/); }); + it('Should pass transition callbacks to Transition', function (done) { + let count = 0; + let increment = ()=> count++; + + let instance = render( + {}} + onExit={increment} + onExiting={increment} + onExited={()=> { + increment(); + expect(count).to.equal(6); + done(); + }} + onEnter={increment} + onEntering={increment} + onEntered={()=> { + increment(); + instance.setProps({ show: false }); + }} + > + Message + + , mountPoint); + }); + describe('Focused state', function () { let focusableContainer = null;