diff --git a/.travis.yml b/.travis.yml index 1553c991f..85f704247 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,9 +8,12 @@ addons: firefox: "latest" before_install: - - export CHROME_BIN=chromium-browser + - export CHROME_BIN=/usr/bin/google-chrome - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start + - sudo apt-get install -y libappindicator1 fonts-liberation + - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + - sudo dpkg -i google-chrome*.deb after_script: - npm run coverage diff --git a/example/js/Layout.jsx b/example/js/Layout.jsx index 1bc39bdd4..86276833c 100644 --- a/example/js/Layout.jsx +++ b/example/js/Layout.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ButtonsExample from './ButtonsExample'; import DropdownsExample from './DropdownsExample'; +import ModalExample from './ModalExample'; import TetherExample from './TetherExample'; import TooltipExample from './TooltipExample'; import LabelsExample from './LabelsExample'; @@ -19,6 +20,7 @@ class Layout extends React.Component { + diff --git a/example/js/ModalExample.jsx b/example/js/ModalExample.jsx new file mode 100644 index 000000000..e3604f6ec --- /dev/null +++ b/example/js/ModalExample.jsx @@ -0,0 +1,45 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ + +import React from 'react'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'lib/index'; + +class ModalExample extends React.Component { + constructor(props) { + super(props); + this.state = { + modal: false + }; + + this.toggle = this.toggle.bind(this); + } + + toggle() { + this.setState({ + modal: !this.state.modal + }); + } + + render() { + return ( +
+

Modals

+
+

+ +

+ + Modal title + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + + + + + +
+ ); + } +} + +export default ModalExample; diff --git a/lib/Fade.jsx b/lib/Fade.jsx new file mode 100644 index 000000000..a52872548 --- /dev/null +++ b/lib/Fade.jsx @@ -0,0 +1,59 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + className: PropTypes.string +}; + +const defaultProps = {}; + +class Fade extends React.Component { + constructor(props) { + super(props); + + this.state = { + fadeIn: false + }; + + this.fade = this.fade.bind(this); + this.fadeIn = this.fadeIn.bind(this); + this.fadeOut = this.fadeOut.bind(this); + } + + fade(fade, cb, delay) { + this.setState({ + fadeIn: fade + }); + + if (cb) { + setTimeout(cb, delay); + } + } + + fadeIn(cb, delay) { + this.fade(true, cb, delay); + } + + fadeOut(cb, delay) { + this.fade(false, cb, delay); + } + + render() { + const classes = classNames( + this.props.className, + 'fade', + { + in: this.state.fadeIn + } + ); + + return ( +
+ ); + } +} + +Fade.propTypes = propTypes; +Fade.defaultProps = defaultProps; + +export default Fade; diff --git a/lib/Modal.jsx b/lib/Modal.jsx new file mode 100644 index 000000000..3643238dc --- /dev/null +++ b/lib/Modal.jsx @@ -0,0 +1,158 @@ +import React, { PropTypes } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import Fade from './Fade'; + +const propTypes = { + isOpen: PropTypes.bool, + size: PropTypes.string, + toggle: PropTypes.func.isRequired, + onEnter: PropTypes.func, + onExit: PropTypes.func +}; + +const defaultProps = { + isOpen: false +}; + +class Modal extends React.Component { + constructor(props) { + super(props); + + this.handleProps = this.handleProps.bind(this); + this.handleBackdropClick = this.handleBackdropClick.bind(this); + this.handleEscape = this.handleEscape.bind(this); + this.destroy = this.destroy.bind(this); + this.onEnter = this.onEnter.bind(this); + this.onExit = this.onExit.bind(this); + } + + componentDidMount() { + if (this.props.isOpen) { + this.show(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isOpen !== prevProps.isOpen) { + this.handleProps(); + } + } + + componentWillUnmount() { + this.hide(); + } + + onEnter() { + this._modal.fadeIn(); + if (this.props.onEnter) { + this.props.onEnter(); + } + } + + onExit() { + this.destroy(); + if (this.props.onExit) { + this.props.onExit(); + } + } + + handleEscape(e) { + if (e.keyCode === 27) { + this.props.toggle(); + } + } + + handleBackdropClick(e) { + const container = ReactDOM.findDOMNode(this._dialog); + + if (e.target && !container.contains(e.target)) { + this.props.toggle(); + } + } + + handleProps() { + if (this.props.isOpen) { + this.show(); + } else { + this.hide(); + } + } + + destroy() { + let classes = document.body.className.replace('modal-open', ''); + this.removeEvents(); + + if (this._element) { + ReactDOM.unmountComponentAtNode(this._element); + document.body.removeChild(this._element); + this._element = null; + } + + document.body.className = classNames(classes).trim(); + } + + removeEvents() { + document.removeEventListener('click', this.handleBackdropClick, false); + document.removeEventListener('keyup', this.handleEscape, false); + } + + hide() { + this.removeEvents(); + + if (this._modal) { + this._modal.fadeOut(); + } + if (this._backdrop) { + this._backdrop.fadeOut(this.onExit, 250); + } + } + + show() { + const classes = document.body.className; + this._element = document.createElement('div'); + this._element.setAttribute('tabindex', '-1'); + + document.body.appendChild(this._element); + document.addEventListener('click', this.handleBackdropClick, false); + document.addEventListener('keyup', this.handleEscape, false); + + document.body.className = classNames( + classes, + 'modal-open' + ); + + ReactDOM.unstable_renderSubtreeIntoContainer( + this, + this.renderChildren(), + this._element + ); + + this._element.focus(); + this._backdrop.fadeIn(this.onEnter, 100); + } + + renderChildren() { + return ( +
+ this._modal = c }> +
this._dialog = c }> +
+ { this.props.children } +
+
+
+ this._backdrop = c }/> +
+ ); + } + + render() { + return null; + } +} + +Modal.propTypes = propTypes; +Modal.defaultProps = defaultProps; + +export default Modal; diff --git a/lib/ModalBody.jsx b/lib/ModalBody.jsx new file mode 100644 index 000000000..2fb2f83c6 --- /dev/null +++ b/lib/ModalBody.jsx @@ -0,0 +1,21 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + className: PropTypes.string +}; + +const ModalBody = (props) => { + const classes = classNames( + props.className, + 'modal-body' + ); + + return ( +
+ ); +}; + +ModalBody.propTypes = propTypes; + +export default ModalBody; diff --git a/lib/ModalFooter.jsx b/lib/ModalFooter.jsx new file mode 100644 index 000000000..fd9a05ab3 --- /dev/null +++ b/lib/ModalFooter.jsx @@ -0,0 +1,21 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + className: PropTypes.string +}; + +const ModalFooter = (props) => { + const classes = classNames( + props.className, + 'modal-footer' + ); + + return ( +
+ ); +}; + +ModalFooter.propTypes = propTypes; + +export default ModalFooter; diff --git a/lib/ModalHeader.jsx b/lib/ModalHeader.jsx new file mode 100644 index 000000000..96cabf9cb --- /dev/null +++ b/lib/ModalHeader.jsx @@ -0,0 +1,46 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + toggle: PropTypes.func +}; + +const defaultProps = {}; + +class ModalHeader extends React.Component { + render() { + let closeButton; + const { + className, + children, + toggle, + ...props } = this.props; + + const classes = classNames( + className, + 'modal-header' + ); + + if (toggle) { + closeButton = ( + + ); + } + + return ( +
+ { closeButton } +

+ { children } +

+
+ ); + } +} + +ModalHeader.propTypes = propTypes; +ModalHeader.defaultProps = defaultProps; + +export default ModalHeader; diff --git a/lib/index.js b/lib/index.js index 7f8c84c7f..8763a8f4b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,10 +6,15 @@ import Dropdown from './Dropdown'; import DropdownItem from './DropdownItem'; import DropdownMenu from './DropdownMenu'; import DropdownToggle from './DropdownToggle'; +import Fade from './Fade'; import Label from './Label'; import Popover from './Popover'; import PopoverTitle from './PopoverTitle'; import PopoverContent from './PopoverContent'; +import Modal from './Modal'; +import ModalHeader from './ModalHeader'; +import ModalBody from './ModalBody'; +import ModalFooter from './ModalFooter'; import TetherContent from './TetherContent'; import Tooltip from './Tooltip'; @@ -22,10 +27,15 @@ export { DropdownItem, DropdownMenu, DropdownToggle, + Fade, Label, Popover, PopoverContent, PopoverTitle, + Modal, + ModalHeader, + ModalBody, + ModalFooter, TetherContent, Tooltip }; diff --git a/test/Fade.spec.js b/test/Fade.spec.js new file mode 100644 index 000000000..6920b031f --- /dev/null +++ b/test/Fade.spec.js @@ -0,0 +1,64 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { shallow, mount } from 'enzyme'; +import { Fade } from '../lib'; + +describe('Fade', () => { + it('should render with "fade" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('fade')).toBe(true); + expect(wrapper.hasClass('in')).toBe(false); + expect(wrapper.text()).toBe('Yo!'); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('fade')).toBe(true); + expect(wrapper.hasClass('in')).toBe(false); + }); + + it('should toggle "in" class with fadeIn & fadeOut', () => { + const wrapper = mount(Yo!); + const instance = wrapper.instance(); + + expect(wrapper.hasClass('in')).toBe(false); + + instance.fadeIn(); + // hasClass does not pick up updates + // maybe related https://github.com/airbnb/enzyme/issues/134 + expect(ReactDOM.findDOMNode(instance).className).toBe('fade in'); + + instance.fadeOut(); + + expect(ReactDOM.findDOMNode(instance).className).toBe('fade'); + }); + + it('should call fadeIn & fadeOut with delayed callbacks', () => { + jasmine.clock().install(); + + const fadeInCallback = jasmine.createSpy('spy'); + const fadeOutCallback = jasmine.createSpy('spy'); + const wrapper = mount(Yo!); + const instance = wrapper.instance(); + + expect(wrapper.hasClass('in')).toBe(false); + + instance.fadeIn(fadeInCallback, 250); + expect(fadeInCallback).not.toHaveBeenCalled(); + + jasmine.clock().tick(250); + expect(fadeInCallback).toHaveBeenCalled(); + + instance.fadeOut(fadeOutCallback, 250); + expect(fadeOutCallback).not.toHaveBeenCalled(); + + jasmine.clock().tick(250); + expect(fadeOutCallback).toHaveBeenCalled(); + + jasmine.clock().uninstall(); + }); +}); diff --git a/test/Modal.spec.js b/test/Modal.spec.js new file mode 100644 index 000000000..80ceb9293 --- /dev/null +++ b/test/Modal.spec.js @@ -0,0 +1,225 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ +import React from 'react'; +import { mount } from 'enzyme'; +import { Modal } from '../lib'; + +describe('Modal', () => { + let isOpen; + let toggle; + + beforeEach(() => { + isOpen = false; + toggle = () => { isOpen = !isOpen; }; + jasmine.clock().install(); + }); + + afterEach(() => { + // fast forward time for modal to fade out + jasmine.clock().tick(300); + jasmine.clock().uninstall(); + }); + + + it('should render modal when isOpen is true', () => { + isOpen = true; + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(wrapper.children().length).toBe(0); + expect(document.getElementsByClassName('modal').length).toBe(1); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(1); + wrapper.unmount(); + }); + + it('should not render modal when isOpen is false', () => { + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(wrapper.children().length).toBe(0); + expect(document.getElementsByClassName('modal').length).toBe(0); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(0); + wrapper.unmount(); + }); + + it('should toggle modal', () => { + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(isOpen).toBe(false); + expect(document.getElementsByClassName('modal').length).toBe(0); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(0); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + + jasmine.clock().tick(300); + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(1); + wrapper.unmount(); + }); + + it('should call onExit & onEnter', () => { + spyOn(Modal.prototype, 'onEnter').and.callThrough(); + spyOn(Modal.prototype, 'onExit').and.callThrough(); + const onEnter = jasmine.createSpy('spy'); + const onExit = jasmine.createSpy('spy'); + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(isOpen).toBe(false); + expect(onEnter).not.toHaveBeenCalled(); + expect(Modal.prototype.onEnter).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(Modal.prototype.onExit).not.toHaveBeenCalled(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(onEnter).toHaveBeenCalled(); + expect(Modal.prototype.onEnter).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(Modal.prototype.onExit).not.toHaveBeenCalled(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + expect(onExit).toHaveBeenCalled(); + expect(Modal.prototype.onExit).toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('should not call handleProps when isOpen does not change', () => { + spyOn(Modal.prototype, 'handleProps').and.callThrough(); + spyOn(Modal.prototype, 'componentDidUpdate').and.callThrough(); + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(isOpen).toBe(false); + expect(Modal.prototype.handleProps).not.toHaveBeenCalled(); + expect(Modal.prototype.componentDidUpdate).not.toHaveBeenCalled(); + + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + expect(Modal.prototype.handleProps).not.toHaveBeenCalled(); + expect(Modal.prototype.componentDidUpdate).toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('should close modal when escape key pressed', () => { + isOpen = true; + const wrapper = mount( + + Yo! + + ); + const instance = wrapper.instance(); + + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + + instance.handleEscape({keyCode: 13}); + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + + instance.handleEscape({keyCode: 27}); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(document.getElementsByClassName('modal').length).toBe(0); + + wrapper.unmount(); + }); + + it('should close modal when clicking backdrop', () => { + isOpen = true; + const wrapper = mount( + + + + ); + + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + // + document.getElementById('clicker').click(); + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + + document.getElementsByClassName('modal-backdrop')[0].click(); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + + wrapper.unmount(); + }); + + it('should destroy this._element', () => { + isOpen = true; + const wrapper = mount( + + + + ); + const instance = wrapper.instance(); + + jasmine.clock().tick(300); + expect(instance._element).toBeTruthy() + + instance.destroy(); + + expect(instance._element).toBe(null) + + instance.destroy(); + wrapper.unmount(); + }); +}); diff --git a/test/ModalBody.spec.js b/test/ModalBody.spec.js new file mode 100644 index 000000000..fbc17d286 --- /dev/null +++ b/test/ModalBody.spec.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ModalBody } from '../lib'; + +describe('ModalBody', () => { + it('should render with "modal-body" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('modal-body')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('modal-body')).toBe(true); + }); +}); diff --git a/test/ModalFooter.spec.js b/test/ModalFooter.spec.js new file mode 100644 index 000000000..afad998d3 --- /dev/null +++ b/test/ModalFooter.spec.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ModalFooter } from '../lib'; + +describe('ModalFooter', () => { + it('should render with "modal-footer" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('modal-footer')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('modal-footer')).toBe(true); + expect(wrapper.hasClass('other')).toBe(true); + }); +}); diff --git a/test/ModalHeader.spec.js b/test/ModalHeader.spec.js new file mode 100644 index 000000000..9d25869b8 --- /dev/null +++ b/test/ModalHeader.spec.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ModalHeader } from '../lib'; + +describe('ModalHeader', () => { + it('should render with "modal-header" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('modal-header')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('modal-header')).toBe(true); + }); + + it('should render close button', () => { + const wrapper = shallow( {}} className="other">Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('modal-header')).toBe(true); + expect(wrapper.find('button.close').length).toBe(1); + }); +});