├── server.js ├── examples ├── default.js ├── main.css ├── images │ ├── favicon.png │ ├── header.png │ ├── logo.svg │ └── logo-wide.svg ├── .eslintrc ├── render.js ├── main.js ├── styles │ ├── _example.scss │ ├── _footer.scss │ ├── _variables.scss │ ├── main.scss │ └── _base.scss ├── components │ ├── examples │ │ ├── index.js │ │ ├── Basic.js │ │ ├── Markdown.js │ │ ├── Static.js │ │ ├── WithComponents.js │ │ ├── Simple.js │ │ ├── StrictMode.js │ │ ├── BasicConfirm.js │ │ ├── Confirm.js │ │ ├── ReduxModal.js │ │ ├── Long.js │ │ └── NestedLong.js │ ├── PackageHeader.js │ ├── Example.js │ ├── Serverside.js │ ├── App.js │ └── Prerendered.js ├── files │ └── example.md ├── server.js └── index.html ├── .eslintignore ├── CHANGELOG.md ├── test ├── .eslintrc ├── enzyme.js ├── index.js ├── util.js ├── test-ModalBody.js ├── test-exports.js ├── test-Backdrop.js ├── test-ModalDialog.js ├── test-Modal.js └── test-ModalHeader.js ├── .npmignore ├── .gitignore ├── .editorconfig ├── .prettierrc ├── src ├── index.js ├── components │ ├── dom │ │ ├── ModalBody.js │ │ ├── Backdrop.js │ │ ├── ModalDialog.js │ │ └── ModalHeader.js │ └── Modal.js ├── toggle-class.js ├── utils │ └── scrollbarSize.js └── styles │ └── default.scss ├── LICENSE ├── .travis.yml ├── .babelrc ├── collect.js ├── .eslintrc ├── webpack.config.js ├── webpack.config.prod.js ├── CONTRIBUTING.md ├── package.json └── README.md /server.js: -------------------------------------------------------------------------------- 1 | require('./examples/server'); 2 | -------------------------------------------------------------------------------- /examples/default.js: -------------------------------------------------------------------------------- 1 | require('../src/styles/default.scss'); 2 | -------------------------------------------------------------------------------- /examples/main.css: -------------------------------------------------------------------------------- 1 | /* dummy file, replaced automatically */ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | webpack.config.js 3 | webpack.config.prod.js 4 | collect.js 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See [the github releases](https://github.com/thorgate/tg-modal/releases) 2 | -------------------------------------------------------------------------------- /examples/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-modal/HEAD/examples/images/favicon.png -------------------------------------------------------------------------------- /examples/images/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-modal/HEAD/examples/images/header.png -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "react/no-find-dom-node": 0, 4 | "no-console": 0 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | logs 3 | *.log 4 | .tmp 5 | .nyc_output 6 | .idea 7 | node_modules 8 | tmp 9 | coverage 10 | npm-debug.log 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | logs 3 | *.log 4 | .tmp 5 | .nyc_output 6 | .idea 7 | node_modules 8 | dist 9 | es 10 | tmp 11 | coverage 12 | npm-debug.log 13 | -------------------------------------------------------------------------------- /test/enzyme.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | // Configure enzyme 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | // Sham raf 2 | if (!global.requestAnimationFrame) { 3 | global.requestAnimationFrame = (cb) => { 4 | return setTimeout(cb, 0); 5 | }; 6 | 7 | global.cancelAnimationFrame = (t) => { 8 | return clearTimeout(t); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "trailingComma": "all", 5 | "tabWidth": 4, 6 | "printWidth": 120, 7 | "semi": true, 8 | "singleQuote": true, 9 | "jsxBracketSameLine": false, 10 | "proseWrap": "always" 11 | } 12 | -------------------------------------------------------------------------------- /examples/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/no-extraneous-dependencies": ["error", {"devDependencies": true, "optionalDependencies": false, "peerDependencies": false}], 4 | "max-classes-per-file": 0, 5 | "react/forbid-prop-types": 0, 6 | "react/no-multi-comp": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/render.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import Prerendered from './components/Prerendered'; 5 | 6 | // Load styles 7 | require('./styles/main.scss'); 8 | require('../src/styles/default.scss'); 9 | 10 | ReactDOM.hydrate(, document.getElementById('content')); 11 | -------------------------------------------------------------------------------- /examples/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './components/App'; 5 | 6 | import packageCfg from '../package.json'; 7 | 8 | // Load styles 9 | require('./styles/main.scss'); 10 | require('../src/styles/default.scss'); 11 | 12 | ReactDOM.render(, document.getElementById('content')); 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Modal from './components/Modal'; 2 | 3 | import Backdrop from './components/dom/Backdrop'; 4 | import ModalBody from './components/dom/ModalBody'; 5 | import ModalDialog from './components/dom/ModalDialog'; 6 | import ModalHeader from './components/dom/ModalHeader'; 7 | 8 | Modal.Backdrop = Backdrop; 9 | Modal.Body = ModalBody; 10 | Modal.Dialog = ModalDialog; 11 | Modal.Header = ModalHeader; 12 | 13 | export default Modal; 14 | -------------------------------------------------------------------------------- /src/components/dom/ModalBody.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ModalBody = ({ children, ...rest }) =>
{children}
; 5 | 6 | ModalBody.displayName = 'Modal.Body'; 7 | 8 | ModalBody.propTypes = { 9 | children: PropTypes.node, 10 | className: PropTypes.string, 11 | }; 12 | 13 | ModalBody.defaultProps = { 14 | children: null, 15 | className: 'tg-modal-body', 16 | }; 17 | 18 | export default ModalBody; 19 | -------------------------------------------------------------------------------- /examples/styles/_example.scss: -------------------------------------------------------------------------------- 1 | .example-block { 2 | text-align: center; 3 | padding-bottom: 80px; 4 | border-bottom: 1px solid rgba(#d8d8d8, 0.35); 5 | 6 | h2 { 7 | margin-top: 0; 8 | } 9 | 10 | &:last-child { 11 | border-bottom: none; 12 | } 13 | 14 | + .example-block { 15 | padding-top: 80px; 16 | } 17 | 18 | .btn-group { 19 | margin: 0 auto; 20 | max-width: 450px; 21 | 22 | .btn + .btn { 23 | margin-left: 15px; 24 | } 25 | } 26 | 27 | .response { 28 | color: $paragraph-color; 29 | margin-top: 10px; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import TestUtils from 'react-dom/test-utils'; 5 | 6 | /* eslint-disable import/prefer-default-export */ 7 | export function buildContainer(component, props) { 8 | const elem = React.createElement(component, props); 9 | const container = TestUtils.renderIntoDocument(elem); 10 | 11 | return TestUtils.findRenderedComponentWithType(container, component); 12 | } 13 | 14 | export function testRenderFunctional(component, props, testID) { 15 | const elem = React.createElement(component, props); 16 | const { getByTestId } = render(elem); 17 | 18 | return getByTestId(testID); 19 | } 20 | -------------------------------------------------------------------------------- /examples/components/examples/index.js: -------------------------------------------------------------------------------- 1 | import Simple from './Simple'; 2 | import Basic from './Basic'; 3 | import Static from './Static'; 4 | import Confirm from './Confirm'; 5 | import BasicConfirm from './BasicConfirm'; 6 | import Long from './Long'; 7 | import NestedLong from './NestedLong'; 8 | import Markdown from './Markdown'; 9 | import WithComponents from './WithComponents'; 10 | import ReduxModal from './ReduxModal'; 11 | import StrictMode from './StrictMode'; 12 | 13 | export { 14 | Simple, 15 | Basic, 16 | Static, 17 | Confirm, 18 | BasicConfirm, 19 | Long, 20 | NestedLong, 21 | Markdown, 22 | WithComponents, 23 | ReduxModal, 24 | StrictMode, 25 | }; 26 | -------------------------------------------------------------------------------- /examples/files/example.md: -------------------------------------------------------------------------------- 1 | # Markdown 2 | 3 | This content is rendered using [react-markdown][2] which 4 | uses [CommonMark][3] to render markdown with [React][4]. 5 | 6 | [1]: https://en.wikipedia.org/wiki/Markdown#Example 7 | [2]: https://github.com/rexxars/react-markdown 8 | [3]: http://commonmark.org/ 9 | [4]: http://facebook.github.io/react/ 10 | 11 | ---- 12 | 13 | ## Markdown source: 14 | 15 | ``` 16 | # Markdown 17 | 18 | This content is rendered using [react-markdown][2] which 19 | uses [CommonMark][3] to render markdown with [React][4]. 20 | 21 | [1]: https://en.wikipedia.org/wiki/Markdown#Example 22 | [2]: https://github.com/rexxars/react-markdown 23 | [3]: http://commonmark.org/ 24 | [4]: http://facebook.github.io/react/ 25 | ``` 26 | -------------------------------------------------------------------------------- /examples/styles/_footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | position: absolute; 3 | bottom: 0; 4 | width: 100%; 5 | height: 100px; 6 | padding: 30px 0; 7 | line-height: 40px; 8 | 9 | background-color: $tg-orange; 10 | color: $tg-white; 11 | 12 | font-size: 14px; 13 | font-weight: bold; 14 | 15 | a { 16 | color: #fff; 17 | text-decoration: none; 18 | 19 | + a { 20 | margin-left: 30px; 21 | } 22 | } 23 | 24 | @media (max-width: 767px) { 25 | height: auto; 26 | 27 | .col-xs-6 { 28 | float: none; 29 | width: auto; 30 | 31 | + .col-xs-6 { 32 | text-align: left; 33 | padding-left: 150px; 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/toggle-class.js: -------------------------------------------------------------------------------- 1 | function toggleClass(el, className, state) { 2 | if (el.classList) { 3 | if (typeof state === 'undefined') { 4 | el.classList.toggle(className); 5 | } else if (state) { 6 | el.classList.add(className); 7 | } else { 8 | el.classList.remove(className); 9 | } 10 | } else { 11 | const classes = el.className.split(' '); 12 | const existingIndex = classes.indexOf(className); 13 | 14 | if (typeof state === 'undefined') { 15 | state = existingIndex >= 0; 16 | } 17 | 18 | if (state) { 19 | classes.push(className); 20 | } else { 21 | classes.splice(existingIndex, 1); 22 | } 23 | 24 | el.className = classes.join(' '); 25 | } 26 | 27 | return el; 28 | } 29 | 30 | export default toggleClass; 31 | -------------------------------------------------------------------------------- /src/utils/scrollbarSize.js: -------------------------------------------------------------------------------- 1 | let size; 2 | 3 | // Original source available at: https://github.com/react-bootstrap/dom-helpers/blob/master/src/util/scrollbarSize.js 4 | 5 | function getScrollbarSize(recalc) { 6 | if (!size || recalc) { 7 | if (typeof document !== 'undefined') { 8 | const scrollDiv = document.createElement('div'); 9 | 10 | scrollDiv.style.position = 'absolute'; 11 | scrollDiv.style.top = '-9999px'; 12 | scrollDiv.style.width = '50px'; 13 | scrollDiv.style.height = '50px'; 14 | scrollDiv.style.overflow = 'scroll'; 15 | 16 | document.body.appendChild(scrollDiv); 17 | size = scrollDiv.offsetWidth - scrollDiv.clientWidth; 18 | document.body.removeChild(scrollDiv); 19 | } 20 | } 21 | 22 | return size; 23 | } 24 | 25 | export default getScrollbarSize; 26 | -------------------------------------------------------------------------------- /examples/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $font-family-base: 'Helvetica Neue', Arial, Helvetica, sans-serif; 2 | $content-font-family: 'Lato', $font-family-base; 3 | $headings-font-family: 'Muli', $font-family-base; 4 | $hack-font-family: 'Hack', monospace; 5 | 6 | $tg-orange: #ef6036; 7 | $tg-white: #fff; 8 | 9 | $jumbotron-bg: #ef6036; 10 | $jumbotron-text: #fff; 11 | $jumbotron-text-muted: #eee; 12 | 13 | $btn-primary-background: $tg-orange; 14 | $btn-primary-border: $tg-orange; 15 | $btn-primary-color: $tg-white; 16 | 17 | $btn-secondary-background: transparent; 18 | $btn-secondary-border: #c2c2c2; 19 | $btn-secondary-color: #c2c2c2; 20 | 21 | $heading-color: #587582; 22 | $paragraph-color: #8c969b; 23 | 24 | $separator-color: #FF0000; 25 | -------------------------------------------------------------------------------- /examples/components/PackageHeader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class PackageHeader extends Component { 5 | render() { 6 | const { name, description, version } = this.props; 7 | 8 | return ( 9 |
10 |
11 |
12 |

{name}

13 |

{description}

14 |

15 | Currently {version} 16 |

17 |
18 |
19 | ); 20 | } 21 | } 22 | 23 | PackageHeader.propTypes = { 24 | name: PropTypes.string.isRequired, 25 | description: PropTypes.string.isRequired, 26 | version: PropTypes.string.isRequired, 27 | }; 28 | 29 | export default PackageHeader; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is released under the MIT license: 2 | 3 | Copyright (c) 2015-present Thorgate info@thorgate.eu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.)) 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "10" 5 | - "11" 6 | - "12" 7 | - "node" 8 | 9 | before_script: 10 | - yarn build 11 | 12 | after_success: 13 | - yarn coverage 14 | - test $TRAVIS_NODE_VERSION = 12 && yarn coveralls 15 | 16 | notifications: 17 | email: false 18 | 19 | deploy: 20 | provider: npm 21 | skip_cleanup: true 22 | email: hi@thorgate.eu 23 | api_key: 24 | secure: > 25 | lU4A8xmAANiiaNwfPCDVA1QKAyPxOG8l/NPdA0eaOVxdJSyuVVcQbJehxmrnnshZze7usCnzc02PGVjN467GWTF3SMwbJ 26 | l9SyToXgn0mgNLjB3s+bfaGjEwwBJtLQD4bBwTnC7qZKBXjqvGjBPZXpQfs7tr5toGkb9CDxG3tLrrdjGhzcF5sKprSmb 27 | 7n7vp9EbD9U04kiYnGUOuGInb0qALEOZjJKmX4xQplotH2NUsgt5NG7+FQ59Fa/kyqOvAOXMCnYK1ulNjHxagkqDHasCK 28 | VS6QMEJJvYCwYObFKem598Pjf+w6ev2LuNQO+ILDs1enHRb8XEjLsLNVDWt+NzA0Gaw6ABjkQy3zYIGVu0zVNygx24vOX 29 | QB8yuM+4WEJkxMpLCVLGziYm3vY3VZoUiFSDK1TmTKmOIiJFBfqHnb14Q+eYU5OmuU4O/C0W0mL1NaxSACbJZEzRmqaS8 30 | QlWMfZ+YWOuZI5zYAFu7DUwwCn97RWQ95CyWe8TreHXD8qOZaJ2zteibUrDiKCSMCVMmcUhHemTs5alRiATn46j+b2ngf 31 | 3qBCaAjM24DE8a0yb8K6WVityNiy/0peUi7SfqZoVZ7whkbdGGpmrAHRzsDwxKvxVismy5PxUtWuQfreh8Udy+JB1huKx 32 | gTrPnUhYcBAADOitN1OmORx6/e9hLY1k= 33 | on: 34 | tags: true 35 | node: "12" 36 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": { 4 | "presets": [ 5 | ["@babel/preset-env", { 6 | "modules": "commonjs" 7 | }], 8 | "@babel/preset-react" 9 | ], 10 | "plugins": [ 11 | "@babel/transform-runtime", 12 | "@babel/plugin-proposal-class-properties", 13 | ] 14 | }, 15 | "es": { 16 | "presets": [ 17 | ["@babel/preset-env", { 18 | "loose": false, 19 | "modules": false 20 | }], 21 | "@babel/preset-react" 22 | ], 23 | "plugins": [ 24 | "@babel/transform-runtime", 25 | "@babel/plugin-proposal-class-properties", 26 | ] 27 | }, 28 | "test": { 29 | "presets": [ 30 | "@babel/preset-env", 31 | "@babel/preset-react" 32 | ], 33 | "plugins": [ 34 | "@babel/plugin-proposal-class-properties", 35 | ["istanbul", { 36 | "exclude": [ 37 | "**/test/*.js" 38 | ] 39 | }] 40 | ] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/dom/Backdrop.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Backdrop extends Component { 5 | onCancel = (e) => { 6 | /* istanbul ignore else */ 7 | if (e && e.preventDefault) { 8 | e.preventDefault(); 9 | } 10 | 11 | const { isStatic, onCancel } = this.props; 12 | 13 | if (!isStatic) { 14 | onCancel(e, null); 15 | } 16 | }; 17 | 18 | onKeyPress = (e) => { 19 | const { keyCode } = e; 20 | 21 | if (keyCode === 13) { 22 | this.onCancel(e); 23 | } 24 | }; 25 | 26 | render() { 27 | // remove warning for unused vars / missing proptype definition 28 | /* eslint no-unused-vars: 0, react/prop-types: 0 */ 29 | const { isStatic, onCancel, ...rest } = this.props; 30 | 31 | return
; 32 | } 33 | } 34 | 35 | Backdrop.propTypes = { 36 | isStatic: PropTypes.bool, 37 | onCancel: PropTypes.func, 38 | className: PropTypes.string, 39 | }; 40 | 41 | Backdrop.defaultProps = { 42 | className: 'tg-modal-backdrop', 43 | isStatic: false, 44 | onCancel: null, 45 | }; 46 | 47 | export default Backdrop; 48 | -------------------------------------------------------------------------------- /test/test-ModalBody.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | 3 | import ModalBody from '../src/components/dom/ModalBody'; 4 | 5 | import { testRenderFunctional } from './util'; 6 | 7 | beforeEach(() => { 8 | ModalBody.defaultProps['data-testid'] = 'Modal.Body'; 9 | }); 10 | 11 | describe('ModalBody', () => { 12 | it('renders correctly', () => { 13 | const container = testRenderFunctional(ModalBody, {}, 'Modal.Body'); 14 | 15 | // Its a div 16 | assert.equal(container.nodeName, 'DIV'); 17 | 18 | // It has the correct default class 19 | assert.ok(container.classList.contains('tg-modal-body')); 20 | }); 21 | 22 | it('className works', () => { 23 | const container = testRenderFunctional(ModalBody, { className: 'foo' }, 'Modal.Body'); 24 | 25 | // Its a div 26 | assert.equal(container.nodeName, 'DIV'); 27 | 28 | // It has the correct default class 29 | assert.ok(container.classList.contains('foo')); 30 | }); 31 | 32 | it('children work', () => { 33 | const container = testRenderFunctional(ModalBody, { children: 'hello world' }, 'Modal.Body'); 34 | 35 | // Its a div 36 | assert.equal(container.nodeName, 'DIV'); 37 | 38 | // It has correct contents 39 | assert.equal(container.textContent, 'hello world'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /collect.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var fs = require('fs'); 3 | 4 | 5 | /** 6 | * Takes a source file, and does some replacements to 7 | * make the code examples be more useful 8 | * 9 | * @param {string} contents Original file contents 10 | * @return {string} Modified file contents 11 | */ 12 | function transformFile(contents) { 13 | // Show prettier import paths 14 | contents = contents.replace('../../../src/components/Modal', 'tg-modal'); 15 | 16 | return contents; 17 | } 18 | 19 | /** 20 | * Collect all files from examples/components/examples and 21 | * return them as {fName: "Modified source", ...}. 22 | * 23 | * This is used to automatically generate `view code` values 24 | * when rendering the examples page. 25 | * 26 | * @return {Object} Example files as {fName: "Modified source", ...}. 27 | */ 28 | function collectExampleSource() { 29 | var baseDir = path.join(__dirname, 'examples', 'components', 'examples'); 30 | var files = fs.readdirSync(baseDir); 31 | var result = {}; 32 | 33 | files.forEach(function (fileName) { 34 | if (/\.jsx?$/.test(fileName)) { 35 | result[fileName.replace(/\.jsx?$/, '')] = transformFile(fs.readFileSync(path.join(baseDir, fileName), {encoding: 'utf-8'})); 36 | } 37 | }); 38 | 39 | return result; 40 | } 41 | 42 | module.exports = collectExampleSource; 43 | -------------------------------------------------------------------------------- /examples/components/examples/Basic.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src/components/Modal'; 5 | 6 | const BasicModalExample = ({ initialOpen, toggleCode }) => { 7 | const [isOpen, setIsOpen] = useState(initialOpen); 8 | 9 | const toggleModal = (e) => { 10 | if (e && e.preventDefault) { 11 | e.preventDefault(); 12 | } 13 | 14 | setIsOpen((prevOpen) => !prevOpen); 15 | }; 16 | 17 | return ( 18 |
19 |
20 | 23 | 26 |
27 | 28 | 29 |

I’m a basic modal

30 |
31 |
32 | ); 33 | } 34 | 35 | BasicModalExample.propTypes = { 36 | initialOpen: PropTypes.bool, 37 | toggleCode: PropTypes.func.isRequired, 38 | }; 39 | 40 | BasicModalExample.defaultProps = { 41 | initialOpen: false, 42 | }; 43 | 44 | export default BasicModalExample; 45 | -------------------------------------------------------------------------------- /test/test-exports.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | 4 | import { assert } from 'chai'; 5 | import { Component } from 'react'; 6 | 7 | import Modal from '../src'; 8 | 9 | function assertValidComponent(a, key, displayName, Base = Component) { 10 | assert.isDefined(a, `${key} should be defined`); 11 | assert.isFunction(a, `${key} should be a function`); 12 | 13 | if (Base !== null) { 14 | assert(a.prototype instanceof Base, `${key} should be a Component`); 15 | } 16 | 17 | assert(a && a.displayName === displayName, `${key} should be ${displayName}`); 18 | } 19 | 20 | describe('Exports work', () => { 21 | it('default export is correct', () => { 22 | assertValidComponent(Modal, 'default export', 'Modal'); 23 | }); 24 | 25 | it('Default styles are in dist folder', () => { 26 | assert(fs.existsSync(path.join(__dirname, '..', 'dist', 'default.css')), 'default.css should exist in dist/'); 27 | assert(fs.existsSync(path.join(__dirname, '..', 'dist', 'default.scss')), 'default.scss should exist in dist/'); 28 | }); 29 | 30 | it('default.Body is Modal.Body', () => { 31 | assertValidComponent(Modal.Body, 'default.Body', 'Modal.Body', null); 32 | }); 33 | 34 | it('default.Header is Modal.Header', () => { 35 | assertValidComponent(Modal.Header, 'default.Header', 'Modal.Header'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /examples/components/examples/Markdown.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ReactMarkdown from 'react-markdown'; 5 | 6 | import theMarkdown from '../../files/example.md'; 7 | 8 | import Modal from '../../../src/components/Modal'; 9 | 10 | const MarkdownModalExample = ({ toggleCode }) => { 11 | const [isOpen, setIsOpen] = useState(false); 12 | 13 | const toggleModal = (e) => { 14 | if (e && e.preventDefault) { 15 | e.preventDefault(); 16 | } 17 | 18 | setIsOpen((prevOpen) => !prevOpen); 19 | }; 20 | 21 | return ( 22 |
23 |
24 | 27 | 30 |
31 | 32 | 33 |
34 | 35 |
36 |
37 |
38 | ); 39 | } 40 | 41 | MarkdownModalExample.propTypes = { 42 | toggleCode: PropTypes.func.isRequired, 43 | }; 44 | 45 | export default MarkdownModalExample; 46 | -------------------------------------------------------------------------------- /examples/server.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import Koa from 'koa'; 5 | import React from 'react'; 6 | import ReactDOMServer from 'react-dom/server'; 7 | 8 | import Prerendered from './components/Prerendered'; 9 | 10 | const app = new Koa(); 11 | const contents = fs.readFileSync(path.join(__dirname, 'index.html'), { encoding: 'utf-8' }); 12 | 13 | const cssPath = (file) => `//localhost:8081/${file}`; 14 | 15 | function template(bodyClasses, rendered) { 16 | return contents 17 | .replace('bundle.main.js', cssPath('render.main.js')) 18 | .replace('bundle.main.css', cssPath('bundle.main.css')) 19 | .replace('
', `
${rendered}
`) 20 | .replace('', ``); 21 | } 22 | 23 | app.use((ctx, next) => { 24 | let bodyProps = {}; 25 | const setBodyProps = (value) => { 26 | bodyProps = value; 27 | }; 28 | 29 | const rendered = ReactDOMServer.renderToString(); 30 | 31 | ctx.status = 200; 32 | ctx.body = template(bodyProps.className, rendered); 33 | 34 | return next(); 35 | }); 36 | 37 | app.listen(3000, () => { 38 | console.log(''); 39 | console.log('Make sure you have webpack running (yarn start) and then open http://127.0.0.1:3000/'); 40 | console.log(' You should see an opened modal in the page (opened already in server-side HTML)'); 41 | console.log(''); 42 | }); 43 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["eslint-config-airbnb", "plugin:prettier/recommended"], 4 | "plugins": ["react", "prettier"], 5 | "env": { 6 | "browser": true, 7 | "node": true, 8 | "mocha": true 9 | }, 10 | "globals": { 11 | "chai": true, 12 | "EXAMPLE_SRC": true 13 | }, 14 | "rules": { 15 | "prettier/prettier": "error", 16 | "react/jsx-uses-react": 1, 17 | "react/jsx-uses-vars": 1, 18 | "react/no-did-mount-set-state": 1, 19 | "react/no-did-update-set-state": 1, 20 | "react/no-multi-comp": 2, 21 | "react/prop-types": 2, 22 | "react/react-in-jsx-scope": 2, 23 | "react/self-closing-comp": 1, 24 | "react/state-in-constructor": 0, 25 | "react/jsx-indent": [2, 4], 26 | "react/jsx-indent-props": [2, 4], 27 | "react/prefer-stateless-function": 0, 28 | "react/jsx-filename-extension": [0, { "extensions": [".js", ".jsx"] }], 29 | "react/jsx-no-bind": [2, { 30 | ignoreRefs: true, 31 | allowArrowFunctions: false, 32 | allowBind: false, 33 | }], 34 | "react/jsx-one-expression-per-line": 0, 35 | "react/jsx-props-no-spreading": 0, 36 | "class-methods-use-this": 0, 37 | "no-underscore-dangle": 0, 38 | "func-names": 0, 39 | "no-else-return": 0, 40 | "no-param-reassign": 0, 41 | "no-reserved-keys": 0, 42 | "no-console": 1 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/components/examples/Static.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src'; 5 | 6 | const StaticModalExample = ({ toggleCode }) => { 7 | const [isOpen, setIsOpen] = useState(false); 8 | 9 | const toggleModal = (e) => { 10 | if (e && e.preventDefault) { 11 | e.preventDefault(); 12 | } 13 | 14 | setIsOpen((prevOpen) => !prevOpen); 15 | }; 16 | 17 | return ( 18 |
19 |
20 | 23 | 26 |
27 | 28 | 29 | 30 |

Mauris non tempor quam, et lacinia.

31 |
32 | 33 |
34 | 37 |
38 |
39 |
40 | ); 41 | } 42 | 43 | StaticModalExample.propTypes = { 44 | toggleCode: PropTypes.func.isRequired, 45 | }; 46 | 47 | export default StaticModalExample; 48 | -------------------------------------------------------------------------------- /examples/components/Example.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Example extends Component { 5 | state = { 6 | showCode: false, 7 | }; 8 | 9 | toggleCode = (e) => { 10 | e.preventDefault(); 11 | 12 | this.setState((prevState) => ({ 13 | showCode: !prevState.showCode, 14 | })); 15 | }; 16 | 17 | renderCode() { 18 | const { showCode } = this.state; 19 | 20 | if (!showCode) { 21 | return null; 22 | } 23 | 24 | const { src } = this.props; 25 | 26 | return ( 27 |
28 |
{src}
29 |
30 | ); 31 | } 32 | 33 | render() { 34 | const { title, component, children } = this.props; 35 | 36 | const modal = React.createElement(component, { 37 | toggleCode: this.toggleCode, 38 | }); 39 | 40 | return ( 41 |
42 |

{title}

43 |

{children}

44 | 45 |
{modal}
46 | 47 | {this.renderCode()} 48 |
49 | ); 50 | } 51 | } 52 | 53 | Example.propTypes = { 54 | title: PropTypes.string.isRequired, 55 | component: PropTypes.func.isRequired, 56 | src: PropTypes.string.isRequired, 57 | children: PropTypes.node, 58 | }; 59 | 60 | Example.defaultProps = { 61 | children: null, 62 | }; 63 | 64 | export default Example; 65 | -------------------------------------------------------------------------------- /examples/components/Serverside.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { useCallback, createContext, useState } from 'react'; 3 | 4 | import Modal from '../../src/components/Modal'; 5 | 6 | export const ModalContext = createContext(); 7 | 8 | // Wrap the Modal component and hook into its open/close event 9 | const ServerSideModal = ({ onToggle, ...rest }) => { 10 | const ctx = useContext(ModalContext); 11 | 12 | const wrappedOnToggle = useCallback((isOpen, bodyProps) => { 13 | // Pass action to our global state handler 14 | if (ctx && ctx.setBodyProps) { 15 | ctx.setBodyProps(bodyProps); 16 | } 17 | 18 | if (onToggle) { 19 | onToggle(isOpen, bodyProps); 20 | } 21 | }, [onToggle]); 22 | 23 | return ( 24 | 25 | ); 26 | 27 | }; 28 | 29 | export const ModalManager = ({ children, setBodyProps: propsSetBodyProps }) => { 30 | const [bodyProps, setBodyProps] = useState({ props: {} }); 31 | 32 | const wrappedSetBodyProps = useCallback((newBodyProps) => { 33 | setBodyProps({ props: newBodyProps }); 34 | 35 | // Pass props to outside as well 36 | if (propsSetBodyProps) { 37 | propsSetBodyProps(newBodyProps); 38 | } 39 | }, [propsSetBodyProps]); 40 | 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | }; 47 | 48 | export default ServerSideModal; 49 | -------------------------------------------------------------------------------- /test/test-Backdrop.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import TestUtils from 'react-dom/test-utils'; 3 | 4 | import { assert } from 'chai'; 5 | import sinon from 'sinon'; 6 | 7 | import Backdrop from '../src/components/dom/Backdrop'; 8 | 9 | import { buildContainer } from './util'; 10 | 11 | describe('Modal Backdrop', () => { 12 | it('renders correctly', () => { 13 | const container = ReactDOM.findDOMNode(buildContainer(Backdrop, {})); 14 | 15 | // Its a div 16 | assert.equal(container.nodeName, 'DIV'); 17 | 18 | // It has the correct default class 19 | assert.ok(container.classList.contains('tg-modal-backdrop')); 20 | }); 21 | 22 | it('onCancel is called after click', () => { 23 | const spy = sinon.spy(); 24 | 25 | const container = ReactDOM.findDOMNode(buildContainer(Backdrop, { onCancel: spy })); 26 | 27 | // test spy was not called yet 28 | assert.equal(spy.callCount, 0); 29 | 30 | // Trigger click 31 | TestUtils.Simulate.click(container); 32 | 33 | // test spy was called once 34 | assert.equal(spy.callCount, 1); 35 | }); 36 | 37 | it('static wont call onCancel', () => { 38 | const spy = sinon.spy(); 39 | const container = ReactDOM.findDOMNode(buildContainer(Backdrop, { onCancel: spy, isStatic: true })); 40 | 41 | // test spy was not called yet 42 | assert.equal(spy.callCount, 0); 43 | 44 | // Trigger click 45 | TestUtils.Simulate.click(container); 46 | 47 | // test spy was not called since this is static 48 | assert.equal(spy.callCount, 0); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /examples/components/examples/WithComponents.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src/components/Modal'; 5 | 6 | const WithComponentsModalExample = ({ toggleCode }) => { 7 | const [isOpen, setIsOpen] = useState(false); 8 | 9 | const toggleModal = (e) => { 10 | if (e && e.preventDefault) { 11 | e.preventDefault(); 12 | } 13 | 14 | setIsOpen((prevOpen) => !prevOpen); 15 | }; 16 | 17 | return ( 18 |
19 |
20 | 23 | 26 |
27 | 28 | 29 | 30 | Header component! 31 | 32 | 33 |

I’m a modal with custom classes for Dialog, Header and Body.

34 |
35 |
36 |
37 | ); 38 | } 39 | 40 | WithComponentsModalExample.propTypes = { 41 | initialOpen: PropTypes.bool, 42 | toggleCode: PropTypes.func.isRequired, 43 | }; 44 | 45 | WithComponentsModalExample.defaultProps = { 46 | initialOpen: false, 47 | }; 48 | 49 | export default WithComponentsModalExample; 50 | -------------------------------------------------------------------------------- /examples/components/examples/Simple.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src/components/Modal'; 5 | 6 | const SimpleModalExample = ({ toggleCode }) => { 7 | const [isOpen, setIsOpen] = useState(false); 8 | 9 | const toggleModal = (e) => { 10 | if (e && e.preventDefault) { 11 | e.preventDefault(); 12 | } 13 | 14 | setIsOpen((prevOpen) => !prevOpen); 15 | }; 16 | 17 | return ( 18 |
19 |
20 | 23 | 26 |
27 | 28 | 29 |

30 | Viral deep v squid chia, letterpress wayfarers artisan meggings tote bag four loko keffiyeh 31 | hoodie cronut four dollar toast flannel. 32 |

33 | 34 |

35 | Pinterest 8-bit DIY pug cold-pressed Carles, typewriter photo booth deep v quinoa four dollar 36 | toast trust fund freegan. Food truck Godard semiotics, YOLO mixtape asymmetrical selfies 37 | Thundercats 8-bit. 38 |

39 |
40 |
41 | ); 42 | } 43 | 44 | SimpleModalExample.propTypes = { 45 | toggleCode: PropTypes.func.isRequired, 46 | }; 47 | 48 | export default SimpleModalExample; 49 | -------------------------------------------------------------------------------- /src/components/dom/ModalDialog.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class ModalDialog extends Component { 5 | onCancel = (e) => { 6 | /* istanbul ignore else */ 7 | if (e && e.preventDefault) { 8 | e.preventDefault(); 9 | } 10 | 11 | const { onCancel } = this.props; 12 | 13 | onCancel(e, null); 14 | }; 15 | 16 | stopPropagate = (e) => { 17 | e.stopPropagation(); 18 | }; 19 | 20 | onKeyPress = (e) => { 21 | const { keyCode } = e; 22 | 23 | if (keyCode === 13) { 24 | this.onCancel(e); 25 | } 26 | }; 27 | 28 | render() { 29 | const { children, isBasic, className, modalClassName, nodeRef } = this.props; 30 | 31 | return ( 32 |
40 |
41 |
42 | {children} 43 |
44 |
45 |
46 | ); 47 | } 48 | } 49 | 50 | ModalDialog.propTypes = { 51 | children: PropTypes.node, 52 | className: PropTypes.string, 53 | modalClassName: PropTypes.string, 54 | 55 | onCancel: PropTypes.func.isRequired, 56 | nodeRef: PropTypes.object, // eslint-disable-line react/forbid-prop-types 57 | isBasic: PropTypes.bool, 58 | }; 59 | 60 | ModalDialog.defaultProps = { 61 | children: null, 62 | nodeRef: null, 63 | className: 'tg-modal-dialog', 64 | isBasic: false, 65 | modalClassName: '', 66 | }; 67 | 68 | export default ModalDialog; 69 | -------------------------------------------------------------------------------- /src/components/dom/ModalHeader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class ModalHeader extends Component { 5 | constructor(props) { 6 | super(props); 7 | 8 | // warn if something is wrong with props 9 | if (props.addClose && !props.onCancel) { 10 | // eslint-disable-next-line no-console 11 | console.warn(`${ModalHeader.displayName}: addClose is defined but onCancel is missing!`); 12 | } 13 | } 14 | 15 | onCancel = (e) => { 16 | /* istanbul ignore else */ 17 | if (e && e.preventDefault) { 18 | e.preventDefault(); 19 | } 20 | 21 | const { isStatic, onCancel } = this.props; 22 | 23 | if (!isStatic && onCancel) { 24 | onCancel(e, null); 25 | } 26 | }; 27 | 28 | render() { 29 | const { children, addClose, className } = this.props; 30 | 31 | if (typeof children !== 'string') { 32 | return children; 33 | } 34 | 35 | const closeBtn = addClose ? ( 36 | 39 | ) : null; 40 | 41 | return ( 42 |
43 |

{children}

44 | {closeBtn} 45 |
46 | ); 47 | } 48 | } 49 | 50 | ModalHeader.displayName = 'Modal.Header'; 51 | 52 | ModalHeader.propTypes = { 53 | children: PropTypes.node, 54 | className: PropTypes.string, 55 | isStatic: PropTypes.bool, 56 | addClose: PropTypes.bool, 57 | onCancel: PropTypes.func, 58 | }; 59 | 60 | ModalHeader.defaultProps = { 61 | addClose: true, 62 | children: null, 63 | className: 'tg-modal-header', 64 | isStatic: false, 65 | onCancel: null, 66 | }; 67 | 68 | export default ModalHeader; 69 | -------------------------------------------------------------------------------- /examples/components/examples/StrictMode.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src/components/Modal'; 5 | 6 | class BasicModalExample extends Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.state = { 11 | isOpen: props.initialOpen, 12 | }; 13 | } 14 | 15 | toggleModal = (e) => { 16 | if (e && e.preventDefault) { 17 | e.preventDefault(); 18 | } 19 | 20 | this.setState((prevState) => ({ 21 | isOpen: !prevState.isOpen, 22 | })); 23 | }; 24 | 25 | render() { 26 | const { toggleCode } = this.props; 27 | const { isOpen } = this.state; 28 | 29 | // Note: Currently warns when modal is shown 30 | // issue ref: https://github.com/reactjs/react-transition-group/issues/429 31 | // we also use UNSAFE_componentWillMount to allow SSR rendering 32 | 33 | return ( 34 |
35 | 36 |
37 | 40 | 43 |
44 | 45 | 46 |

I’m a basic modal

47 |
48 |
49 |
50 | ); 51 | } 52 | } 53 | 54 | BasicModalExample.propTypes = { 55 | initialOpen: PropTypes.bool, 56 | toggleCode: PropTypes.func.isRequired, 57 | }; 58 | 59 | BasicModalExample.defaultProps = { 60 | initialOpen: false, 61 | }; 62 | 63 | export default BasicModalExample; 64 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tg-modal 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 | Fork me on GitHub 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/components/examples/BasicConfirm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src/components/Modal'; 5 | 6 | const BasicConfirmModalExample = ({ toggleCode }) => { 7 | const [isOpen, setIsOpen] = useState(false); 8 | const [answer, setAnswer] = useState(null); 9 | 10 | const showModal = () => { 11 | setIsOpen(true); 12 | }; 13 | 14 | const onCancel = () => { 15 | setIsOpen(false); 16 | setAnswer('DO NOT WANT'); 17 | }; 18 | 19 | const onConfirm = () => { 20 | setIsOpen(false); 21 | setAnswer('GIMME'); 22 | }; 23 | 24 | return ( 25 |
26 |
27 | 30 | 33 |
34 | 35 |
36 | You answered: {answer || '-'} 37 |
38 | 39 | 40 | Do you want cookies? 41 | 42 |

43 | You can also use enter or escape to accept or decline. 44 |

45 |
46 |
47 | 50 | 53 |
54 |
55 |
56 | ); 57 | } 58 | 59 | BasicConfirmModalExample.propTypes = { 60 | toggleCode: PropTypes.func.isRequired, 61 | }; 62 | 63 | export default BasicConfirmModalExample; 64 | -------------------------------------------------------------------------------- /examples/components/examples/Confirm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src/components/Modal'; 5 | 6 | 7 | const ConfirmModalExample = ({ toggleCode }) => { 8 | const [isOpen, setIsOpen] = useState(false); 9 | const [answer, setAnswer] = useState(null); 10 | 11 | const showModal = () => { 12 | setIsOpen(true); 13 | }; 14 | 15 | const onCancel = () => { 16 | setIsOpen(false); 17 | setAnswer('DO NOT WANT'); 18 | }; 19 | 20 | const onConfirm = () => { 21 | setIsOpen(false); 22 | setAnswer('GIMME'); 23 | }; 24 | 25 | return ( 26 |
27 |
28 | 31 | 34 |
35 | 36 |
37 | You answered: {answer || '-'} 38 |
39 | 40 | 41 | Do you want cookies? 42 | 43 |

44 | You can also use enter or escape to accept or decline. 45 |

46 |
47 |
48 | 51 | 54 |
55 |
56 |
57 | ); 58 | } 59 | 60 | ConfirmModalExample.propTypes = { 61 | initialOpen: PropTypes.bool, 62 | toggleCode: PropTypes.func.isRequired, 63 | }; 64 | 65 | ConfirmModalExample.defaultProps = { 66 | initialOpen: false, 67 | }; 68 | 69 | export default ConfirmModalExample; 70 | -------------------------------------------------------------------------------- /examples/components/examples/ReduxModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect, Provider } from 'react-redux'; 4 | import { createStore } from 'redux'; 5 | 6 | import Modal from '../../../src/components/Modal'; 7 | 8 | // redux actions and types 9 | const OPEN_MODAL = 'redux-example/OPEN_MODAL'; 10 | const CLOSE_MODAL = 'redux-example/CLOSE_MODAL'; 11 | 12 | const openModal = () => ({ type: OPEN_MODAL }); 13 | const closeModal = () => ({ type: CLOSE_MODAL }); 14 | 15 | const ReduxModal = ({ isOpen, onOpen, onClose, toggleCode }) => ( 16 |
17 |
18 | 21 | 24 |
25 | 26 | 27 |

I’m a Redux controlled modal

28 |
29 |
30 | ); 31 | 32 | ReduxModal.propTypes = { 33 | isOpen: PropTypes.bool.isRequired, 34 | onOpen: PropTypes.func.isRequired, 35 | onClose: PropTypes.func.isRequired, 36 | toggleCode: PropTypes.func.isRequired, 37 | }; 38 | 39 | // state is single reducer to display modal, so we return it 40 | const mapStateToProps = (state) => ({ 41 | isOpen: state, 42 | }); 43 | 44 | // map modal open and close actions 45 | const mapDispatchToProps = (dispatch) => ({ 46 | onOpen: () => dispatch(openModal()), 47 | onClose: () => dispatch(closeModal()), 48 | }); 49 | 50 | const ReduxModalConnector = connect(mapStateToProps, mapDispatchToProps)(ReduxModal); 51 | 52 | function reducer(state = false, action) { 53 | switch (action.type) { 54 | case OPEN_MODAL: 55 | return true; 56 | 57 | case CLOSE_MODAL: 58 | return false; 59 | 60 | default: 61 | return state; 62 | } 63 | } 64 | 65 | const store = createStore(reducer); 66 | 67 | const ReduxApp = ({ toggleCode }) => ( 68 | 69 | 70 | 71 | ); 72 | 73 | ReduxApp.propTypes = { 74 | toggleCode: PropTypes.func.isRequired, 75 | }; 76 | 77 | export default ReduxApp; 78 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var autoprefixer = require('autoprefixer'); 3 | var webpack = require('webpack'); 4 | var MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | 6 | var collectExampleSource = require('./collect'); 7 | 8 | module.exports = { 9 | mode: 'development', 10 | entry: { 11 | bundle: './examples/main', 12 | render: './examples/render' 13 | }, 14 | devServer: { 15 | contentBase: './examples/', 16 | host: '0.0.0.0', 17 | // Enable when using ngrok 18 | // disableHostCheck: true, 19 | port: 8081, 20 | hot: true 21 | }, 22 | devtool: "source-map", 23 | output: { 24 | path: path.join(__dirname, 'examples'), 25 | filename: '[name].main.js', 26 | }, 27 | plugins: [ 28 | new webpack.IgnorePlugin(/un~$/), 29 | new webpack.DefinePlugin({ 30 | EXAMPLE_SRC: JSON.stringify(collectExampleSource()) 31 | }), 32 | ], 33 | resolve: { 34 | extensions: ['.js'] 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.jsx?$/, 40 | exclude: /(node_modules|bower_components)/, 41 | loaders: ['babel-loader'] 42 | }, 43 | { 44 | test: /\.md/, 45 | loader: 'raw-loader' 46 | }, 47 | { 48 | test: /\.png|\.jpg|\.svg/, 49 | loader: 'url-loader' 50 | }, 51 | { 52 | test: /\.(css|scss)$/, 53 | use: [ 54 | 'style-loader', 55 | { 56 | loader: 'css-loader', 57 | options: { 58 | sourceMap: true, 59 | modules: 'global', 60 | importLoaders: 2 61 | }, 62 | }, { 63 | loader: "postcss-loader", 64 | options: { 65 | plugins: function() { 66 | return [autoprefixer] 67 | }, 68 | }, 69 | }, { 70 | loader: "resolve-url-loader", 71 | }, { 72 | loader: "sass-loader", 73 | options: { 74 | sourceMap: true, 75 | sassOptions: { 76 | outputStyle: 'expanded', 77 | }, 78 | } 79 | }, 80 | ], 81 | } 82 | ] 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var autoprefixer = require('autoprefixer'); 4 | var MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | 6 | var collectExampleSource = require('./collect'); 7 | 8 | var config = { 9 | mode: 'production', 10 | entry: { 11 | main: './examples/main', 12 | 'default': './examples/default' 13 | }, 14 | output: { 15 | path: path.resolve('./dist/examples'), 16 | filename: 'bundle.[name].js' 17 | }, 18 | plugins: [ 19 | new webpack.IgnorePlugin(/un~$/), 20 | new webpack.DefinePlugin({ 21 | EXAMPLE_SRC: JSON.stringify(collectExampleSource()) 22 | }), 23 | new MiniCssExtractPlugin({ 24 | filename: '[name].css', 25 | chunkFilename: '[id].css', 26 | }), 27 | ], 28 | resolve: { 29 | extensions: ['.js'] 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.jsx?$/, 35 | exclude: /(node_modules|bower_components)/, 36 | loaders: ['babel-loader'] 37 | }, 38 | { 39 | test: /\.md/, 40 | loader: 'raw-loader' 41 | }, 42 | { 43 | test: /\.png|\.jpg|\.svg/, 44 | loader: 'url-loader' 45 | }, 46 | { 47 | test: /\.(css|scss)$/, 48 | use: [ 49 | { 50 | loader: MiniCssExtractPlugin.loader, 51 | options: { 52 | hmr: false 53 | }, 54 | }, 55 | { 56 | loader: 'css-loader', 57 | options: { 58 | sourceMap: true, 59 | modules: { 60 | mode: 'global' 61 | }, 62 | importLoaders: 2 63 | }, 64 | }, { 65 | loader: "postcss-loader", 66 | options: { 67 | plugins: function() { 68 | return [autoprefixer] 69 | }, 70 | }, 71 | }, { 72 | loader: "resolve-url-loader", 73 | }, { 74 | loader: "sass-loader", 75 | options: { 76 | sourceMap: true, 77 | sassOptions: { 78 | outputStyle: 'expanded', 79 | }, 80 | } 81 | }, 82 | ], 83 | } 84 | ], 85 | } 86 | }; 87 | 88 | module.exports = config; 89 | -------------------------------------------------------------------------------- /examples/styles/main.scss: -------------------------------------------------------------------------------- 1 | // styles.scss 2 | @import "variables"; 3 | 4 | @import "base"; 5 | @import "example"; 6 | @import "footer"; 7 | 8 | 9 | .container--main { 10 | padding-bottom: 30px; 11 | } 12 | 13 | .logo-square { 14 | background: url('../images/logo.svg') no-repeat center center; 15 | height: 63px; 16 | width: 63px; 17 | } 18 | 19 | .logo-wide { 20 | float: left; 21 | display: block; 22 | background: url('../images/logo-wide.svg') no-repeat center center; 23 | width: 120px; 24 | height: 40px; 25 | 26 | + span { 27 | margin-left: 15px; 28 | } 29 | } 30 | 31 | .jumbotron { 32 | background: $jumbotron-bg url('../images/header.png') no-repeat center top; 33 | background-size: cover; 34 | min-height: 400px; 35 | text-align: center; 36 | padding: 84px 0; 37 | 38 | &, h1, h2, h3 { 39 | color: $jumbotron-text; 40 | } 41 | 42 | .logo-square { 43 | margin: 0 auto; 44 | } 45 | 46 | h1 { 47 | font-size: 20px; 48 | font-weight: 400; 49 | margin-top: 16px; 50 | } 51 | 52 | h2 { 53 | font-size: 50px; 54 | margin-top: 33px; 55 | } 56 | 57 | h3 { 58 | font-size: 14px; 59 | margin-top: 25px; 60 | opacity: 0.53; 61 | } 62 | } 63 | 64 | .link-block { 65 | padding-top: 110px; 66 | 67 | .example-block { 68 | > h4 { 69 | font-size: 45px; 70 | line-height: 54px; 71 | margin-bottom: 5px; 72 | } 73 | 74 | > p { 75 | margin-bottom: 24px; 76 | } 77 | } 78 | } 79 | 80 | .container { 81 | .tg-modal { 82 | text-align: center; 83 | 84 | .tg-modal-dialog .tg-modal-content { 85 | border-radius: 5px; 86 | 87 | .tg-modal-header { 88 | padding-bottom: 0; 89 | 90 | .tg-modal-title { 91 | font-size: 45px; 92 | line-height: 54px; 93 | margin-bottom: 20px; 94 | } 95 | 96 | .tg-modal-close { 97 | font-family: $headings-font-family; 98 | font-size: 24px; 99 | border-radius: 5px; 100 | } 101 | } 102 | 103 | .tg-modal-body { 104 | padding-left: 50px; 105 | padding-right: 50px; 106 | } 107 | 108 | .tg-modal-footer { 109 | padding-top: 0; 110 | } 111 | } 112 | 113 | &.tg-modal-basic { 114 | &, h1, p { 115 | color: #fff; 116 | } 117 | } 118 | } 119 | } 120 | 121 | .long { 122 | .tg-modal-body { 123 | &, p { 124 | color: #798489; 125 | font-family: $content-font-family; 126 | font-size: 14px; 127 | font-weight: 400; 128 | line-height: 20px; 129 | } 130 | 131 | p { 132 | margin-bottom: 16px; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /examples/images/logo-wide.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/test-ModalDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import TestUtils from 'react-dom/test-utils'; 4 | 5 | import { assert } from 'chai'; 6 | import sinon from 'sinon'; 7 | 8 | import ModalDialog from '../src/components/dom/ModalDialog'; 9 | 10 | import { buildContainer } from './util'; 11 | 12 | const onCancel = () => null; 13 | 14 | describe('ModalDialog', () => { 15 | it('renders correctly', () => { 16 | const container = ReactDOM.findDOMNode(buildContainer(ModalDialog, { onCancel })); 17 | 18 | // Its a div 19 | assert.equal(container.nodeName, 'DIV'); 20 | 21 | assert.ok(container.classList.contains('tg-modal')); 22 | 23 | // It has the correct default class 24 | assert.equal(container.querySelectorAll('.tg-modal-dialog').length, 1); 25 | 26 | // Run stopPropagate 27 | assert.equal(container.querySelectorAll('.tg-modal-content').length, 1); 28 | TestUtils.Simulate.click(container.querySelector('.tg-modal-content')); 29 | }); 30 | 31 | it('isBasic works', () => { 32 | const container = ReactDOM.findDOMNode(buildContainer(ModalDialog, { onCancel, isBasic: true })); 33 | 34 | // Its a div 35 | assert.equal(container.nodeName, 'DIV'); 36 | 37 | // It has modal-basic 38 | assert.ok(container.classList.contains('tg-modal-basic')); 39 | 40 | // It has the correct default class 41 | assert.ok(container.classList.contains('tg-modal')); 42 | }); 43 | 44 | it('onCancel is called after click', () => { 45 | const spy = sinon.spy(); 46 | 47 | const container = ReactDOM.findDOMNode(buildContainer(ModalDialog, { onCancel: spy })); 48 | 49 | // test spy was not called yet 50 | assert.equal(spy.callCount, 0); 51 | 52 | // Trigger click 53 | TestUtils.Simulate.click(container); 54 | 55 | // test spy was called once 56 | assert.equal(spy.callCount, 1); 57 | }); 58 | 59 | it('stopPropagate is called after click', () => { 60 | const spy = sinon.spy(); 61 | 62 | class TempModalDialog extends ModalDialog { 63 | stopPropagate = () => { 64 | spy(); 65 | }; 66 | } 67 | 68 | const container = ReactDOM.findDOMNode(buildContainer(TempModalDialog, { onCancel })); 69 | 70 | // test spy was not called yet 71 | assert.equal(spy.callCount, 0); 72 | 73 | // Close button is rendered into the container 74 | assert.equal(container.querySelectorAll('.tg-modal-content').length, 1); 75 | 76 | // Trigger click 77 | TestUtils.Simulate.click(container.querySelector('.tg-modal-content')); 78 | 79 | // test spy was called once 80 | assert.equal(spy.callCount, 1); 81 | 82 | // test wrapper click 83 | TestUtils.Simulate.click(container); 84 | 85 | // test spy wasn't called 86 | assert.equal(spy.callCount, 1); 87 | }); 88 | 89 | it('custom children work', () => { 90 | const container = ReactDOM.findDOMNode( 91 | buildContainer(ModalDialog, { onCancel, children: Sup }), 92 | ); 93 | 94 | assert.equal(container.querySelectorAll('.child').length, 1); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Following the guidelines detailed here help you communicate that you respect the time of the core developers managing and developing this project. In return, they will reciprocate that respect in the form of addressing your issue, reviewing changes, and helping you finalize your pull requests. 4 | 5 | How to contribute: 6 | 7 | - Create issues 8 | - Add new features with PRs 9 | 10 | ## Using the issue tracker 11 | 12 | The issue tracker is the preferred channel for bug reports, feature requests and submitting pull requests, but please respect the following restrictions: 13 | 14 | Please do not derail or troll issues. Keep the discussion on topic and respect the opinions of others. 15 | 16 | ## Bug reports 17 | 18 | A bug is a demonstrable problem that is caused by the code in the repository. Good bug reports are extremely helpful - thank you! 19 | 20 | Guidelines for bug reports: 21 | 22 | 1. Use the GitHub issue search — check if the issue has already been reported. 23 | 2. Check if the issue has been fixed — try to reproduce it using the latest master or next branch in the repository. 24 | 3. Include your environment information — please answer the following: 25 | - What browser are you using and what version? 26 | - What version of tg-modal are you using? 27 | 4. Include the replication steps/code — what steps/code reproduces the issue. What do you expect to happen instead? Include a screenshot if the issue is visual. 28 | 29 | ## Feature requests 30 | 31 | If you find yourself wishing for a feature that doesn't exist in tg-modal then feel free to create an issue about it in the issue tracker. The issue should describe the feature you need, why you need it and how it should work. Once you have created an issue wait for a sign-off from one of the maintainers before starting work on it. This makes sure your changes align with the future of the project. 32 | 33 | ## Contributing code changes 34 | 35 | All new features an changes to existing features should be covered by tests. 36 | 37 | ## Common tasks 38 | 39 | ### Releasing a new version 40 | 41 | 1. Make sure that the `version` field under `package.json` is updated 42 | 2. Create a new git tag with the same version and push it to repository 43 | - Alternatively you can also create the tag via [Github](https://github.com/thorgate/tg-modal/releases/new) 44 | 3. Travis will now automatically build and publish the new version to npm 45 | 4. Make sure to add release notes under [Github releases](https://github.com/thorgate/tg-modal/releases) 46 | 47 | Once the deployment to npm is completed you need to update [example pages](https://thorgate.github.io/tg-modal/). To do this you need to ensure your working directory is clean. Then you can check out the gh-pages branch with: 48 | 49 | Note: Make sure your origin remote is set to git@github.com:thorgate/tg-modal.git 50 | 51 | ``` 52 | git checkout gh-pages 53 | ``` 54 | 55 | Then you can run a script which downloads the latest release and creates a folder for it (like 0.9.0): 56 | 57 | ``` 58 | ./scripts/update.sh 59 | ``` 60 | 61 | Finally you can stage, commit and push the changes: 62 | 63 | ``` 64 | git add -p 0.9.0/ latest 65 | git commit -m 'Add docs for 0.9.0' 66 | git push origin gh-pages 67 | ``` 68 | 69 | They should appear shortly under https://thorgate.github.io/tg-modal/ 70 | 71 | ### Testing SSR example 72 | 73 | 1. Start webpack `yarn start` 74 | 2. In another terminal start the server `yarn server` 75 | 3. Open http://127.0.0.1:3000/ 76 | 4. The modal should be open during initial page load (and even when JS is disabled) 77 | -------------------------------------------------------------------------------- /test/test-Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // import ReactDOM from 'react-dom'; 3 | 4 | import { expect } from 'chai'; 5 | import { shallow } from 'enzyme'; 6 | 7 | import Modal from '../src'; 8 | 9 | const onCancel = () => null; 10 | 11 | describe('Modal', () => { 12 | it('Renders with correct classes (isOpen=false)', () => { 13 | const node = shallow(); 14 | 15 | expect(node).to.matchSnapshot(); 16 | }); 17 | 18 | it('Renders with correct classes (isOpen=true)', () => { 19 | const node = shallow(); 20 | 21 | expect(node).to.matchSnapshot(); 22 | }); 23 | 24 | it('Renders with correct classes (wrapperClassName)', () => { 25 | const node = shallow(); 26 | 27 | expect(node).to.matchSnapshot(); 28 | expect(node.find('.wrap-god')).to.have.length(1); 29 | }); 30 | 31 | it('Renders with correct classes (className)', () => { 32 | const node = shallow(); 33 | 34 | expect(node).to.matchSnapshot(); 35 | expect( 36 | node 37 | .find(Modal.Dialog) 38 | .shallow() 39 | .find('.goodest-boy'), 40 | ).to.have.length(1); 41 | }); 42 | 43 | it('Renders with correct classes (dialogClassName)', () => { 44 | const node = shallow(); 45 | 46 | expect(node).to.matchSnapshot(); 47 | expect(node.find('.goodest-boy')).to.have.length(1); 48 | }); 49 | 50 | it('Renders with string title', () => { 51 | const node = shallow(); 52 | 53 | expect(node).to.matchSnapshot(); 54 | 55 | const headerNode = node.find(Modal.Header).shallow(); 56 | expect(headerNode.find('.tg-modal-title').text()).to.equal('sum title'); 57 | }); 58 | 59 | it('Renders children', () => { 60 | const node = shallow( 61 | 62 | am child 63 | , 64 | ); 65 | 66 | expect(node).to.matchSnapshot(); 67 | 68 | const bodyNode = node.find(Modal.Body).shallow(); 69 | expect(bodyNode.find('.tg-modal-body').text()).to.equal('am child'); 70 | }); 71 | 72 | it('Renders children even if null', () => { 73 | const node = shallow( 74 | 75 | {[null, false, 'hello']} 76 | , 77 | ); 78 | 79 | expect(node).to.matchSnapshot(); 80 | 81 | const bodyNode = node.find(Modal.Body).shallow(); 82 | expect(bodyNode.find('.tg-modal-body').text()).to.equal('hello'); 83 | }); 84 | 85 | it('Adds event listener when open', () => { 86 | let boundEventListener = false; 87 | global.document.addEventListener = () => { 88 | boundEventListener = true; 89 | }; 90 | 91 | shallow(); 92 | expect(boundEventListener).to.equal(true); 93 | }); 94 | 95 | it('Does not add event listener when closed', () => { 96 | let boundEventListener = false; 97 | global.document.addEventListener = () => { 98 | boundEventListener = true; 99 | }; 100 | 101 | shallow(); 102 | expect(boundEventListener).to.equal(false); 103 | }); 104 | 105 | it('Adds event listener when closed if keyboard is passed in', () => { 106 | let boundEventListener = false; 107 | global.document.addEventListener = () => { 108 | boundEventListener = true; 109 | }; 110 | 111 | shallow(); 112 | expect(boundEventListener).to.equal(true); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /examples/styles/_base.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | .hidden { 6 | display: none; 7 | } 8 | 9 | html { 10 | position: relative; 11 | min-height: 100%; 12 | 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | body { 18 | margin: 0 0 100px 0; 19 | padding: 0; 20 | 21 | font-family: $content-font-family; 22 | font-size: 14px; 23 | line-height: 1.42857; 24 | color: #333333; 25 | background-color: #fff; 26 | } 27 | 28 | h1, h2, h3, h4, h5, h6 { 29 | color: $heading-color; 30 | font-weight: 300; 31 | font-family: $headings-font-family; 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | p { 37 | color: $paragraph-color; 38 | font-size: 17px; 39 | line-height: 21px; 40 | margin: 0 0 10px; 41 | } 42 | 43 | a { 44 | color: $tg-orange; 45 | text-decoration: none; 46 | 47 | &:hover { 48 | text-decoration: underline; 49 | } 50 | } 51 | 52 | .container { 53 | max-width: 1000px; 54 | margin-right: auto; 55 | margin-left: auto; 56 | padding-left: 10px; 57 | padding-right: 10px; 58 | } 59 | 60 | .row { 61 | &:after { 62 | content : "\0020"; 63 | display : block; 64 | height : 0; 65 | clear : both; 66 | overflow : hidden; 67 | visibility : hidden; 68 | } 69 | 70 | margin-left: 0 -15px; 71 | 72 | .col-xs-6 { 73 | position: relative; 74 | min-height: 1px; 75 | padding: 0 15px; 76 | float: left; 77 | width: 50%; 78 | } 79 | } 80 | 81 | .text-left { 82 | text-align: left; 83 | } 84 | 85 | .text-right { 86 | text-align: right; 87 | } 88 | 89 | .btn { 90 | display: inline-block; 91 | margin-bottom: 0; 92 | font-weight: normal; 93 | text-align: center; 94 | vertical-align: middle; 95 | touch-action: manipulation; 96 | cursor: pointer; 97 | background-image: none; 98 | border: 2px solid transparent; 99 | white-space: nowrap; 100 | padding: 12px 12px; 101 | font-size: 14px; 102 | line-height: 1.42857; 103 | border-radius: 30px; 104 | user-select: none; 105 | text-decoration: none; 106 | 107 | text-transform: uppercase; 108 | min-width: 110px; 109 | font-weight: bold; 110 | 111 | transition: all cubic-bezier(0.23, 1, 0.32, 1) 450ms; 112 | 113 | &:focus, &:active { 114 | outline: none; 115 | } 116 | 117 | &::-moz-focus-inner { 118 | border: 0; 119 | } 120 | 121 | &:hover { 122 | text-decoration: none; 123 | } 124 | 125 | &.disabled { 126 | opacity: 0.7; 127 | cursor: not-allowed; 128 | } 129 | } 130 | 131 | .btn-primary { 132 | color: #fff; 133 | background-color: $btn-primary-background; 134 | border-color: $btn-primary-border; 135 | 136 | &:hover { 137 | background-color: #ef6c34; 138 | border-color: #ef6c34; 139 | } 140 | } 141 | 142 | .btn-secondary { 143 | color: $btn-secondary-color; 144 | background-color: $btn-secondary-background; 145 | border-color: $btn-secondary-border; 146 | 147 | &:hover { 148 | background-color: rgba(#000, 0.06); 149 | } 150 | } 151 | 152 | .markdown-wrapper { 153 | h1, h2, h3, h4, h5, h6 { 154 | margin-bottom: 15px; 155 | } 156 | 157 | p { 158 | margin-bottom: 10px; 159 | } 160 | 161 | ul, ol { 162 | margin: 0 0 10px 0; 163 | } 164 | } 165 | 166 | .container { 167 | .markdown-wrapper { 168 | .tg-modal-body { 169 | text-align: left; 170 | } 171 | } 172 | } 173 | 174 | .code-block { 175 | color: #505050; 176 | background-color: #fafafa; 177 | padding: 34px; 178 | padding-right: 0; 179 | margin-top: 34px; 180 | text-align: left; 181 | overflow-x: auto; 182 | 183 | &, pre { 184 | font-family: $hack-font-family; 185 | font-size: 13px; 186 | font-weight: 400; 187 | line-height: 18px; 188 | } 189 | } 190 | 191 | 192 | .tg-modal-body { 193 | hr { 194 | margin: 16px 0; 195 | border: 0 none transparent; 196 | background-color: rgba(#d8d8d8, 0.35); 197 | height: 1px; 198 | } 199 | 200 | pre { 201 | color: #505050; 202 | background-color: #fafafa; 203 | padding: 34px; 204 | padding-right: 0; 205 | text-align: left; 206 | overflow-x: auto; 207 | 208 | > code { 209 | font-family: $hack-font-family; 210 | font-size: 13px; 211 | font-weight: 400; 212 | line-height: 18px; 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tg-modal", 3 | "version": "0.9.1", 4 | "description": "Universal controlled modals for React", 5 | "main": "./dist/index.js", 6 | "jsnext:main": "./es/index.js", 7 | "module": "es/index.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/thorgate/tg-modal.git" 11 | }, 12 | "files": [ 13 | "dist/", 14 | "es/" 15 | ], 16 | "keywords": [ 17 | "react", 18 | "modal", 19 | "isomorphic", 20 | "universal" 21 | ], 22 | "author": "Thorgate ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/thorgate/tg-modal/issues" 26 | }, 27 | "homepage": "https://github.com/thorgate/tg-modal#readme", 28 | "devDependencies": { 29 | "@babel/cli": "^7.7.7", 30 | "@babel/core": "^7.7.7", 31 | "@babel/node": "^7.7.7", 32 | "@babel/plugin-proposal-class-properties": "^7.7.4", 33 | "@babel/plugin-proposal-object-rest-spread": "^7.7.7", 34 | "@babel/plugin-transform-runtime": "^7.7.6", 35 | "@babel/preset-env": "^7.7.7", 36 | "@babel/preset-react": "^7.7.4", 37 | "@babel/register": "^7.7.7", 38 | "@testing-library/react": "^9.4.0", 39 | "autoprefixer": "^9.7.3", 40 | "babel-eslint": "^10.0.1", 41 | "babel-loader": "^8.0.6", 42 | "babel-plugin-istanbul": "^6.0.0", 43 | "chai": "^4.2.0", 44 | "classnames": "^2.2.6", 45 | "coveralls": ">=3.0.9", 46 | "cross-env": "^7.0.0", 47 | "css-loader": "^3.4.0", 48 | "enzyme": "^3.11.0", 49 | "enzyme-adapter-react-16": "^1.15.2", 50 | "eslint": "^6.8.0", 51 | "eslint-config-airbnb": "^18.0.1", 52 | "eslint-config-prettier": "^6.7.0", 53 | "eslint-plugin-import": "^2.19.1", 54 | "eslint-plugin-jsx-a11y": "^6.2.1", 55 | "eslint-plugin-prettier": "^3.1.2", 56 | "eslint-plugin-react": "^7.16.0", 57 | "fbjs-scripts": "^1.2.0", 58 | "file-loader": "^5.0.2", 59 | "is": "*", 60 | "istanbul": "*", 61 | "jsdom": "16.1.0", 62 | "jsdom-global": "3.0.2", 63 | "json-loader": "^0.5.7", 64 | "koa": "^2.11.0", 65 | "mini-css-extract-plugin": "^0.9.0", 66 | "mocha": "^4.0.1", 67 | "mocha-snapshots": "^4.2.0", 68 | "node-sass": "^4.13.0", 69 | "nyc": "^15.0.0", 70 | "postcss-loader": "^3.0.0", 71 | "prettier": "2.0.3", 72 | "prop-types": "^15.7.2", 73 | "raw-loader": "^4.0.0", 74 | "react": "^16.12.0", 75 | "react-dom": "^16.12.0", 76 | "react-markdown": "4.2.2", 77 | "react-redux": "^7.1.3", 78 | "redux": "^4.0.1", 79 | "resolve-url-loader": "^3.1.1", 80 | "rimraf": "*", 81 | "sass-loader": "^8.0.0", 82 | "sinon": "^8.0.1", 83 | "style-loader": "^1.0.0", 84 | "superagent": "*", 85 | "url-loader": "^4.0.0", 86 | "watch": "*", 87 | "webpack": "^4.41.4", 88 | "webpack-cli": "^3.3.10", 89 | "webpack-dev-server": "^3.10.1", 90 | "websocket-driver": "0.7.1" 91 | }, 92 | "peerDependencies": { 93 | "@babel/runtime": ">=7.7.7", 94 | "prop-types": "^15.0.0-0", 95 | "react": "^16.3.0-0" 96 | }, 97 | "dependencies": { 98 | "body-scroll-lock": "^3.0.0", 99 | "react-transition-group": "^4.4.0" 100 | }, 101 | "resolutions": { 102 | "lodash": ">=4.17.14", 103 | "mixin-deep": ">=1.3.2", 104 | "set-value": ">=2.0.1" 105 | }, 106 | "scripts": { 107 | "clean": "rimraf dist", 108 | "lint": "eslint src test examples", 109 | "watch": "watch 'yarn build' src test", 110 | "pretest": "yarn check-engine", 111 | "prebuild": "yarn check-engine", 112 | "build": "yarn build:umd && yarn build:es && yarn build-examples", 113 | "build:umd": "cross-env BABEL_ENV=commonjs $(yarn bin)/babel src -d dist", 114 | "build:es": "cross-env BABEL_ENV=es $(yarn bin)/babel src -d es", 115 | "postbuild": "yarn test -s 500; yarn coverage", 116 | "test": "cross-env BABEL_ENV=test $(yarn bin)/nyc --require @babel/register --require jsdom-global/register --require ./test/index.js --require ./test/enzyme.js --require mocha-snapshots mocha -u exports test/test-*.js", 117 | "coverage": "nyc report && nyc report --reporter=html", 118 | "coveralls": "nyc report --reporter=text-lcov | coveralls", 119 | "postcoveralls": "rimraf ./coverage", 120 | "start": "cross-env BABEL_ENV=test $(yarn bin)/webpack-dev-server", 121 | "server": "cross-env BABEL_ENV=test babel-node --harmony server.js", 122 | "build-examples": "cross-env BABEL_ENV=commonjs NODE_ENV=production $(yarn bin)/webpack --config=webpack.config.prod.js", 123 | "postbuild-examples": "cp examples/index.html dist/examples && cp src/styles/default.scss dist/ && cp dist/examples/default.css dist && cp examples/images/favicon.png dist/examples", 124 | "check-engine": "node node_modules/fbjs-scripts/node/check-dev-engines.js package.json" 125 | }, 126 | "devEngines": { 127 | "node": "6.x || 7.x || 8.x || 10.x || 11.x || 12.x || 13.x || 14.x" 128 | }, 129 | "nyc": { 130 | "sourceMap": false, 131 | "instrument": false 132 | }, 133 | "greenkeeper": { 134 | "ignore": [ 135 | "mocha" 136 | ] 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tg-modal 2 | 3 | ## Introduction 4 | 5 | tg-modal is a react component for Modals. It aims to provide a standalone 6 | `Modal` without the need of adding a big UI library to your dependencies. 7 | 8 | [![NPM version][npm-image]][npm-url] 9 | [![Build Status][travis-image]][travis-url] 10 | [![Coverage][coveralls-image]][coveralls-url] 11 | [![Dependency Status][depstat-image]][depstat-url] 12 | [![Greenkeeper badge][greenkeeper-image]][greenkeeper-url] 13 | [![Downloads][download-badge]][npm-url] 14 | 15 | ## Installation 16 | 17 | ```sh 18 | npm install tg-modal 19 | ``` 20 | 21 | ### Import 22 | 23 | ```js 24 | import Modal from 'tg-modal'; 25 | ``` 26 | 27 | ### Styles 28 | 29 | To get the actual modal working (it might be invisible without styles), one should import 30 | default styles to your own assets. The default styles are available as: 31 | 32 | > CSS: `node_modules/dist/default.css` 33 | > SCSS: `tg-modal/dist/default.scss` 34 | 35 | ### Custom styles 36 | 37 | To use your own styles, the current recommendation is importing the default styles, 38 | and customizing them. 39 | 40 | ## Usage 41 | 42 | Assuming you have included the style-sheet, you can render a simple modal like this: 43 | 44 | ```js 45 | // Import the modal 46 | import Modal from 'tg-modal'; 47 | 48 | 49 | Modal body... 50 | 51 | ``` 52 | 53 | This will render a static modal, which cannot be hidden by the user. 54 | 55 | ### PropTypes 56 | 57 | #### Modal 58 | 59 | | Property | Type | Description 60 | |---------------------|--------|------------ 61 | | onCancel | func | Called when the user cancels the modal (Close button, backdrop click or `ESC` pressed). `function (event, keyboard) {}` 62 | | onConfirm | func | Called after confirming the modal (Currently only by pressing `ENTER`) `function () {}` | 63 | | isOpen | bool | Should the modal be visible 64 | | title | node | When set, `Modal` will render this as child of `Modal.Header` element. 65 | | isStatic | bool | Is the modal Static (backdrop click won't trigger `onCancel`) 66 | | isBasic | bool | Is the modal Basic (backdrop only, best for confirms) 67 | | keyboard | bool? | Should the modal listen to keyboard events (`ENTER` or `ESCAPE` press) [default: *isOpen*] 68 | | autoWrap | bool | If true, children will be wrapped inside `Modal.Body` [default: false] 69 | | onToggle | func | Function called after the modal is toggled. `function (isOpen, props) { }` 70 | | transitionName | string | Name of animation to use for open/close (to see how to define custom ones, see default styles) [default: tg-modal-fade] 71 | | transitionDuration | int | Duration of the transition in milliseconds [default: 300] 72 | | className | string | Extra classnames to use for modal [default: ``] 73 | | dialogClassName | string | Classname to use for `ModalDialog` [default: tg-modal-dialog] 74 | | wrapperClassName | string | Extra classnames to use for modal wrapper [default: ``] 75 | 76 | Props not specified here are considered internal, and are prone to change. 77 | 78 | #### Modal.Header 79 | 80 | | Property | Type | Description 81 | |---------------------|-------------|-------------- 82 | | children | node | Contents 83 | | className | string | Class name to add to the wrapper div [default: tg-modal-header] 84 | | isStatic | bool | If true, the close button won't trigger `onCancel` 85 | | addClose | bool | Show the close button [default: true] 86 | | onCancel | func | Callback to trigger when the close button is clicked 87 | 88 | #### Modal.Body 89 | 90 | | Property | Type | Description 91 | |---------------------|-------------|-------------------------------- 92 | | children | node | Contents 93 | | className | string | Class name to add to the wrapper div [default: tg-modal-body] 94 | 95 | ### Examples 96 | 97 | Examples are available [here][public-url]. 98 | 99 | ## Troubleshooting 100 | 101 | If you encounter a problem, please [file an issue](https://github.com/thorgate/tg-modal/issues). 102 | 103 | ## License 104 | 105 | MIT © [Thorgate](http://github.com/thorgate) 106 | 107 | [npm-url]: https://npmjs.org/package/tg-modal 108 | [npm-image]: https://img.shields.io/npm/v/tg-modal.svg?style=flat-square 109 | 110 | [travis-url]: https://travis-ci.org/thorgate/tg-modal 111 | [travis-image]: https://img.shields.io/travis/thorgate/tg-modal.svg?style=flat-square 112 | 113 | [coveralls-url]: https://coveralls.io/github/thorgate/tg-modal?branch=master 114 | [coveralls-image]: https://coveralls.io/repos/github/thorgate/tg-modal/badge.svg?branch=master 115 | 116 | [depstat-url]: https://david-dm.org/thorgate/tg-modal 117 | [depstat-image]: https://david-dm.org/thorgate/tg-modal.svg?style=flat-square 118 | 119 | [greenkeeper-image]: https://badges.greenkeeper.io/thorgate/tg-modal.svg 120 | [greenkeeper-url]: https://greenkeeper.io/ 121 | 122 | [download-badge]: https://img.shields.io/npm/dm/tg-modal.svg?style=flat-square 123 | 124 | [public-url]: https://thorgate.github.io/tg-modal 125 | -------------------------------------------------------------------------------- /src/styles/default.scss: -------------------------------------------------------------------------------- 1 | // default.scss 2 | 3 | $modal-z-index: 1050; 4 | 5 | .tg-modal, .tg-modal-backdrop { 6 | transform-style: preserve-3d; 7 | backface-visibility: hidden; 8 | transform: translate3d(0,0,0) 9 | } 10 | 11 | .tg-modal { 12 | position: fixed; 13 | top: 0; 14 | right: 0; 15 | bottom: 0; 16 | left: 0; 17 | z-index: $modal-z-index; 18 | overflow: hidden; 19 | -webkit-overflow-scrolling: touch; 20 | outline: 0; 21 | 22 | transform-origin: 50% 25%; 23 | 24 | .tg-modal-dialog { 25 | position: relative; 26 | width: auto; 27 | margin: 10px; 28 | z-index: $modal-z-index + 10; 29 | 30 | @media (min-width: 768px) { 31 | width: 600px; 32 | margin: 30px auto; 33 | } 34 | 35 | .tg-modal-content { 36 | background: #fff; 37 | padding: 0; 38 | 39 | .tg-modal-header { 40 | padding: 1.8rem 1.5rem; 41 | position: relative; 42 | 43 | .tg-modal-title { 44 | padding: 0; 45 | margin: 0; 46 | 47 | font-size: 1.5em; 48 | line-height: 3rem; 49 | font-weight: 500; 50 | } 51 | 52 | .tg-modal-close { 53 | cursor: pointer; 54 | position: absolute; 55 | top: 1.8rem; 56 | right: 1.5rem; 57 | z-index: 1; 58 | opacity: .6; 59 | color: rgb(60, 60, 80); 60 | width: 3rem; 61 | height: 3rem; 62 | 63 | font-size: 1.25em; 64 | padding: 0; 65 | 66 | background: transparent; 67 | border: 0px none transparent; 68 | 69 | transition: background 300ms ease; 70 | 71 | &:focus, 72 | &:hover { 73 | background-color: rgba(#000, 0.1); 74 | outline: none; 75 | } 76 | } 77 | } 78 | 79 | .tg-modal-body { 80 | padding: 1.8rem 1.5rem; 81 | position: relative; 82 | 83 | img { 84 | max-width: 100%; 85 | height: auto; 86 | } 87 | } 88 | 89 | .tg-modal-footer { 90 | padding: 1.8rem 1.5rem; 91 | position: relative; 92 | 93 | a + a, 94 | button + button { 95 | margin-left: 1rem; 96 | } 97 | } 98 | 99 | .tg-modal-header + .tg-modal-body { 100 | padding-top: 0; 101 | } 102 | } 103 | } 104 | 105 | &.tg-modal-basic { 106 | .tg-modal-dialog { 107 | top: 40%; 108 | transform: translateY(-50%); 109 | position: absolute; 110 | left: 0; 111 | right: 0; 112 | 113 | .tg-modal-content { 114 | background: transparent; 115 | color: #fff; 116 | text-align: center; 117 | 118 | .tg-modal-header { 119 | .tg-modal-close { 120 | display: none; 121 | } 122 | } 123 | } 124 | 125 | .tg-modal-footer { 126 | text-align: center; 127 | } 128 | } 129 | } 130 | } 131 | 132 | .tg-modal-backdrop { 133 | position: fixed; 134 | top: -100%; 135 | right: 0; 136 | bottom: 0; 137 | left: 0; 138 | z-index: $modal-z-index; 139 | 140 | background: rgba(#000, 0.85); 141 | } 142 | 143 | .tg-modal-open .tg-modal { 144 | overflow-x: hidden; 145 | overflow-y: auto; 146 | label: scroll-box; 147 | -webkit-overflow-scrolling: touch; 148 | } 149 | 150 | .tg-modal-wrapper.tg-modal-animating { 151 | .tg-modal { 152 | overflow: hidden; 153 | user-select: none; 154 | } 155 | } 156 | 157 | @keyframes tg-modal-fade-in { 158 | from {opacity: 0;} 159 | to {opacity: 1;} 160 | } 161 | 162 | @keyframes tg-modal-fade-out { 163 | from {opacity: 1;} 164 | to {opacity: 0;} 165 | } 166 | 167 | @keyframes tg-modal-scale-in { 168 | from { 169 | opacity: 0; 170 | transform: scale(0.8); 171 | } 172 | to { 173 | opacity: 1; 174 | transform: scale(1); 175 | } 176 | } 177 | 178 | @keyframes tg-modal-scale-out { 179 | from { 180 | opacity: 1; 181 | transform: scale(1); 182 | } 183 | to { 184 | opacity: 0; 185 | transform: scale(0.8); 186 | } 187 | } 188 | 189 | .tg-modal-fade-enter { 190 | &.tg-modal { 191 | animation: tg-modal-scale-in 300ms ease; 192 | } 193 | 194 | &.tg-modal-backdrop { 195 | animation: tg-modal-fade-in 300ms ease; 196 | } 197 | } 198 | 199 | .tg-modal-fade-exit { 200 | &.tg-modal { 201 | animation: tg-modal-scale-out 300ms ease; 202 | animation-fill-mode: forwards; 203 | } 204 | 205 | &.tg-modal-backdrop { 206 | animation: tg-modal-fade-out 300ms ease; 207 | animation-fill-mode: forwards; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /examples/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Example from './Example'; 5 | import PackageHeader from './PackageHeader'; 6 | 7 | import * as examples from './examples'; 8 | 9 | // Load styles 10 | require('../styles/main.scss'); 11 | 12 | // The example app 13 | class App extends Component { 14 | render() { 15 | const { packageCfg } = this.props; 16 | const { name, description, version } = packageCfg; 17 | 18 | return ( 19 |
20 | 21 | 22 |
23 |
24 | 25 | Modal with one paragraph of text. 26 | 27 | 28 | 29 | Modals can also reduce their complexity. 30 | 31 | 32 | 33 | Modals can be static. A static modal does not have keyboard events. They also do not 34 | have a close button in their header. 35 | 36 | 37 | 38 | Modals can be used for user actions/confirmation boxes. 39 | 40 | 41 | 46 | Also works in basic/static mode. 47 | 48 | 49 | 50 | Modals with content that exceeds the viewport will be scrollable. 51 | 52 | 53 | 58 | Nested modals with content that exceeds the viewport are also scrollable. 59 | 60 | 61 | 62 | Modals render react components. This example uses react-remarkable to display markdown. 63 | 64 | 65 | 70 | Modals can be customized with Modal.Header and Modal.Body components. 71 | 72 | 73 | 78 | Modal with controlling via Redux. 79 | 80 | 81 | 82 | Modal wrapped inside React.StrictMode. 83 | 84 |
85 |
86 | 87 | 108 |
109 | ); 110 | } 111 | } 112 | 113 | App.propTypes = { 114 | packageCfg: PropTypes.object.isRequired, 115 | }; 116 | 117 | export default App; 118 | -------------------------------------------------------------------------------- /test/test-ModalHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import TestUtils from 'react-dom/test-utils'; 4 | 5 | import { assert } from 'chai'; 6 | import sinon from 'sinon'; 7 | 8 | import ModalHeader from '../src/components/dom/ModalHeader'; 9 | 10 | import { buildContainer } from './util'; 11 | 12 | describe('ModalHeader', () => { 13 | it('renders correctly', () => { 14 | const spyConsoleWarn = sinon.spy(); 15 | console.warn = spyConsoleWarn; 16 | 17 | const container = ReactDOM.findDOMNode(buildContainer(ModalHeader, { children: 'Hello world' })); 18 | 19 | // test spy was not called yet 20 | assert.equal(spyConsoleWarn.callCount, 1); 21 | 22 | // Its a div 23 | assert.equal(container.nodeName, 'DIV'); 24 | 25 | // It has the correct default class 26 | assert.ok(container.classList.contains('tg-modal-header')); 27 | 28 | // H1 is rendered into the container 29 | assert.equal(container.querySelectorAll('h1.tg-modal-title').length, 1); 30 | 31 | // H1 value is correct 32 | assert.equal(container.querySelector('h1.tg-modal-title').textContent, 'Hello world'); 33 | 34 | // Close button is rendered into the container 35 | assert.equal(container.querySelectorAll('button.tg-modal-close').length, 1); 36 | }); 37 | 38 | it('raw children work', () => { 39 | const spyConsoleWarn = sinon.spy(); 40 | console.warn = spyConsoleWarn; 41 | 42 | const container = ReactDOM.findDOMNode(buildContainer(ModalHeader, { children: Sup })); 43 | 44 | // test spy was not called yet 45 | assert.equal(spyConsoleWarn.callCount, 1); 46 | 47 | assert.equal(container.nodeName, 'SPAN'); 48 | assert.equal(container.textContent, 'Sup'); 49 | }); 50 | 51 | it('className works', () => { 52 | const spyConsoleWarn = sinon.spy(); 53 | console.warn = spyConsoleWarn; 54 | 55 | const container = ReactDOM.findDOMNode( 56 | buildContainer(ModalHeader, { className: 'foo', children: 'Hello world' }), 57 | ); 58 | 59 | // test spy was not called yet 60 | assert.equal(spyConsoleWarn.callCount, 1); 61 | 62 | // Its a div 63 | assert.equal(container.nodeName, 'DIV'); 64 | 65 | // It has the correct default class 66 | assert.ok(container.classList.contains('foo')); 67 | }); 68 | 69 | it('addClose=false does not add close button', () => { 70 | const container = ReactDOM.findDOMNode( 71 | buildContainer(ModalHeader, { addClose: false, children: 'Hello world' }), 72 | ); 73 | 74 | // Close button is not rendered into the container 75 | assert.equal(container.querySelectorAll('button.tg-modal-close').length, 0); 76 | }); 77 | 78 | it('onCancel is called after click', () => { 79 | const spy = sinon.spy(); 80 | 81 | const container = ReactDOM.findDOMNode(buildContainer(ModalHeader, { children: 'Hello world', onCancel: spy })); 82 | 83 | // test spy was not called yet 84 | assert.equal(spy.callCount, 0); 85 | 86 | // Close button is rendered into the container 87 | assert.equal(container.querySelectorAll('button.tg-modal-close').length, 1); 88 | 89 | // Trigger click 90 | TestUtils.Simulate.click(container.querySelector('button.tg-modal-close')); 91 | 92 | // test spy was called once 93 | assert.equal(spy.callCount, 1); 94 | }); 95 | 96 | it('onCancel is not called when isStatic is true', () => { 97 | const spy = sinon.spy(); 98 | 99 | const container = ReactDOM.findDOMNode( 100 | buildContainer(ModalHeader, { children: 'Hello world', onCancel: spy, isStatic: true }), 101 | ); 102 | 103 | // test spy was not called yet 104 | assert.equal(spy.callCount, 0); 105 | 106 | // Close button is rendered into the container 107 | assert.equal(container.querySelectorAll('button.tg-modal-close').length, 1); 108 | 109 | // Trigger click 110 | TestUtils.Simulate.click(container.querySelector('button.tg-modal-close')); 111 | 112 | // test spy was not called 113 | assert.equal(spy.callCount, 0); 114 | }); 115 | 116 | it('addClose is defined and onCancel is not defined', () => { 117 | const spyConsoleWarn = sinon.spy(); 118 | console.warn = spyConsoleWarn; 119 | 120 | const container = ReactDOM.findDOMNode( 121 | buildContainer(ModalHeader, { children: 'Hello world', addClose: true }), 122 | ); 123 | 124 | // test spy was not called yet 125 | assert.equal(spyConsoleWarn.callCount, 1); 126 | 127 | // Close button is rendered into the container 128 | assert.equal(container.querySelectorAll('button.tg-modal-close').length, 1); 129 | 130 | // Trigger click 131 | TestUtils.Simulate.click(container.querySelector('button.tg-modal-close')); 132 | }); 133 | 134 | it('click handler works w/o onCancel', () => { 135 | const container = ReactDOM.findDOMNode(buildContainer(ModalHeader, { children: 'Hello world' })); 136 | 137 | // Close button is rendered into the container 138 | assert.equal(container.querySelectorAll('button.tg-modal-close').length, 1); 139 | 140 | // Trigger click 141 | TestUtils.Simulate.click(container.querySelector('button.tg-modal-close')); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /examples/components/examples/Long.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal from '../../../src/components/Modal'; 5 | 6 | const LongModalExample = ({ toggleCode }) => { 7 | const [isOpen, setIsOpen] = useState(false); 8 | 9 | const toggleModal = (e) => { 10 | if (e && e.preventDefault) { 11 | e.preventDefault(); 12 | } 13 | 14 | setIsOpen((prevOpen) => !prevOpen); 15 | }; 16 | 17 | return ( 18 |
19 |
20 | 23 | 26 |
27 | 28 | 29 |

30 | Viral deep v squid chia, letterpress wayfarers artisan meggings tote bag four loko keffiyeh 31 | hoodie cronut four dollar toast flannel. 32 |

33 | 34 |

35 | Pinterest 8-bit DIY pug cold-pressed Carles, typewriter photo booth deep v quinoa four dollar 36 | toast trust fund freegan. Food truck Godard semiotics, YOLO mixtape asymmetrical selfies 37 | Thundercats 8-bit. 38 |

39 | 40 |

41 | Helvetica banh mi Wes Anderson aesthetic, stumptown keytar ugh beard VHS you probably have not 42 | heard of them Carles Neutra bespoke pour-over Odd Future. Crucifix banjo Tumblr 3 wolf moon, 43 | readymade tilde master cleanse. Neutra fingerstache seitan, cornhole hoodie Pinterest messenger 44 | bag food truck authentic cold-pressed wayfarers narwhal pug blog yr. Heirloom vinyl kitsch, jean 45 | shorts banjo cold-pressed slow-carb skateboard keffiyeh next level farm-to-table pickled 46 | whatever. Whatever Brooklyn trust fund, migas skateboard Marfa typewriter Tumblr pork belly 47 | farm-to-table. Fashion axe paleo selfies, put a bird on it small batch artisan salvia lomo 48 | messenger bag polaroid synth leggings drinking vinegar letterpress organic. Slow-carb church-key 49 | chambray disrupt, Portland you probably have not heard of them Schlitz literally PBR&B trust 50 | fund fap. 51 |

52 | 53 |

54 | Fashion axe bitters chillwave, try-hard four loko retro pour-over raw denim cronut meh kale 55 | chips chambray. Beard drinking vinegar retro, quinoa 3 wolf moon artisan sustainable. DIY 56 | Bushwick hashtag Schlitz, church-key synth tousled freegan typewriter Banksy. Authentic disrupt 57 | YOLO, ugh selfies health goth iPhone chillwave. Direct trade single-origin coffee iPhone Marfa. 58 | Organic 8-bit butcher normcore, salvia Odd Future twee cray. Gentrify fixie Tumblr raw denim 59 | craft beer. 60 |

61 | 62 |

63 | Literally Williamsburg butcher, small batch drinking vinegar bicycle rights messenger bag. 64 | Readymade 3 wolf moon blog ennui mumblecore selvage sartorial plaid, tousled fap paleo. You 65 | probably have not heard of them YOLO actually, leggings four dollar toast street art kale chips 66 | Kickstarter 8-bit messenger bag. Artisan Truffaut actually beard, authentic vegan pour-over 67 | tattooed Marfa tote bag narwhal try-hard. Blog gluten-free single-origin coffee crucifix pug, 68 | Pitchfork you probably have not heard of them distillery master cleanse meggings cardigan. 69 | Brooklyn artisan whatever food truck, Carles tilde pug tofu pour-over put a bird on it Banksy 70 | migas you probably have not heard of them mlkshk flannel. Hoodie mlkshk four dollar toast 71 | sriracha, Brooklyn Bushwick pug 8-bit. 72 |

73 | 74 |

75 | Blog typewriter you probably have not heard of them locavore, letterpress twee authentic. Yr 76 | distillery post-ironic, ennui irony American Apparel literally Tumblr. Kickstarter +1 chillwave 77 | sartorial distillery, normcore Carles single-origin coffee American Apparel flannel. Authentic 78 | pour-over stumptown forage, cray direct trade literally Pinterest locavore 3 wolf moon organic 79 | slow-carb vegan. Mlkshk dreamcatcher try-hard, butcher Blue Bottle cred PBR typewriter bespoke. 80 | Listicle ennui pork belly sriracha, Bushwick meggings letterpress DIY butcher McSweeney 81 | slow-carb forage direct trade. Carles jean shorts VHS, chambray hashtag PBR Echo Park authentic 82 | retro listicle ugh raw denim skateboard literally. 83 |

84 | 85 |

86 | Banksy hoodie pop-up, Pitchfork skateboard DIY typewriter selvage dreamcatcher. Art party hoodie 87 | XOXO, typewriter slow-carb ugh Odd Future lo-fi mumblecore PBR&B letterpress stumptown 88 | Brooklyn normcore viral. You probably have not heard of them Neutra plaid Shoreditch ethical, 89 | kitsch fashion axe. IPhone deep v Intelligentsia, +1 squid Pinterest Williamsburg gentrify 90 | selvage Bushwick chambray master cleanse mixtape Godard polaroid. Stumptown listicle butcher 91 | Echo Park PBR&B Brooklyn typewriter Bushwick locavore, pickled lo-fi. +1 fingerstache 92 | bicycle rights trust fund, blog try-hard banh mi disrupt Bushwick. High Life migas quinoa cray, 93 | roof party wolf chambray ennui bicycle rights viral Wes Anderson chia butcher. 94 |

95 |
96 |
97 | ); 98 | } 99 | 100 | LongModalExample.propTypes = { 101 | toggleCode: PropTypes.func.isRequired, 102 | }; 103 | 104 | export default LongModalExample; 105 | -------------------------------------------------------------------------------- /examples/components/Prerendered.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import Modal, { ModalManager } from './Serverside'; 5 | 6 | 7 | const Prerendered = ({ initialOpen, setBodyProps }) => { 8 | const [isOpen, setIsOpen] = useState(initialOpen); 9 | 10 | const toggleModal = (e) => { 11 | if (e && e.preventDefault) { 12 | e.preventDefault(); 13 | } 14 | 15 | setIsOpen((prevOpen) => !prevOpen); 16 | }; 17 | 18 | return ( 19 | 20 |
21 | 24 | 25 | 26 |

27 | Viral deep v squid chia, letterpress wayfarers artisan meggings tote bag four loko keffiyeh 28 | hoodie cronut four dollar toast flannel. 29 |

30 | 31 |

32 | Pinterest 8-bit DIY pug cold-pressed Carles, typewriter photo booth deep v quinoa four dollar 33 | toast trust fund freegan. Food truck Godard semiotics, YOLO mixtape asymmetrical selfies 34 | Thundercats 8-bit. 35 |

36 | 37 |

38 | Helvetica banh mi Wes Anderson aesthetic, stumptown keytar ugh beard VHS you probably haven not 39 | heard of them Carles Neutra bespoke pour-over Odd Future. Crucifix banjo Tumblr 3 wolf moon, 40 | readymade tilde master cleanse. Neutra fingerstache seitan, cornhole hoodie Pinterest messenger 41 | bag food truck authentic cold-pressed wayfarers narwhal pug blog yr. Heirloom vinyl kitsch, jean 42 | shorts banjo cold-pressed slow-carb skateboard keffiyeh next level farm-to-table pickled 43 | whatever. Whatever Brooklyn trust fund, migas skateboard Marfa typewriter Tumblr pork belly 44 | farm-to-table. Fashion axe paleo selfies, put a bird on it small batch artisan salvia lomo 45 | messenger bag polaroid synth leggings drinking vinegar letterpress organic. Slow-carb church-key 46 | chambray disrupt, Portland you probably haven not heard of them Schlitz literally PBR&B 47 | trust fund fap. 48 |

49 | 50 |

51 | Fashion axe bitters chillwave, try-hard four loko retro pour-over raw denim cronut meh kale 52 | chips chambray. Beard drinking vinegar retro, quinoa 3 wolf moon artisan sustainable. DIY 53 | Bushwick hashtag Schlitz, church-key synth tousled freegan typewriter Banksy. Authentic disrupt 54 | YOLO, ugh selfies health goth iPhone chillwave. Direct trade single-origin coffee iPhone Marfa. 55 | Organic 8-bit butcher normcore, salvia Odd Future twee cray. Gentrify fixie Tumblr raw denim 56 | craft beer. 57 |

58 | 59 |

60 | Literally Williamsburg butcher, small batch drinking vinegar bicycle rights messenger bag. 61 | Readymade 3 wolf moon blog ennui mumblecore selvage sartorial plaid, tousled fap paleo. You 62 | probably haven not heard of them YOLO actually, leggings four dollar toast street art kale chips 63 | Kickstarter 8-bit messenger bag. Artisan Truffaut actually beard, authentic vegan pour-over 64 | tattooed Marfa tote bag narwhal try-hard. Blog gluten-free single-origin coffee crucifix pug, 65 | Pitchfork you probably haven not heard of them distillery master cleanse meggings cardigan. 66 | Brooklyn artisan whatever food truck, Carles tilde pug tofu pour-over put a bird on it Banksy 67 | migas you probably haven not heard of them mlkshk flannel. Hoodie mlkshk four dollar toast 68 | sriracha, Brooklyn Bushwick pug 8-bit. 69 |

70 | 71 |

72 | Blog typewriter you probably haven not heard of them locavore, letterpress twee authentic. Yr 73 | distillery post-ironic, ennui irony American Apparel literally Tumblr. Kickstarter +1 chillwave 74 | sartorial distillery, normcore Carles single-origin coffee American Apparel flannel. Authentic 75 | pour-over stumptown forage, cray direct trade literally Pinterest locavore 3 wolf moon organic 76 | slow-carb vegan. Mlkshk dreamcatcher try-hard, butcher Blue Bottle cred PBR typewriter bespoke. 77 | Listicle ennui pork belly sriracha, Bushwick meggings letterpress DIY butcher McSweeney is 78 | slow-carb forage direct trade. Carles jean shorts VHS, chambray hashtag PBR Echo Park authentic 79 | retro listicle ugh raw denim skateboard literally. 80 |

81 | 82 |

83 | Banksy hoodie pop-up, Pitchfork skateboard DIY typewriter selvage dreamcatcher. Art party hoodie 84 | XOXO, typewriter slow-carb ugh Odd Future lo-fi mumblecore PBR&B letterpress stumptown 85 | Brooklyn normcore viral. You probably haven not heard of them Neutra plaid Shoreditch ethical, 86 | kitsch fashion axe. IPhone deep v Intelligentsia, +1 squid Pinterest Williamsburg gentrify 87 | selvage Bushwick chambray master cleanse mixtape Godard polaroid. Stumptown listicle butcher 88 | Echo Park PBR&B Brooklyn typewriter Bushwick locavore, pickled lo-fi. +1 fingerstache 89 | bicycle rights trust fund, blog try-hard banh mi disrupt Bushwick. High Life migas quinoa cray, 90 | roof party wolf chambray ennui bicycle rights viral Wes Anderson chia butcher. 91 |

92 |
93 |
94 |
95 | ); 96 | }; 97 | 98 | Prerendered.propTypes = { 99 | initialOpen: PropTypes.bool, 100 | setBodyProps: PropTypes.func, 101 | }; 102 | 103 | Prerendered.defaultProps = { 104 | initialOpen: false, 105 | }; 106 | 107 | export default Prerendered; 108 | -------------------------------------------------------------------------------- /examples/components/examples/NestedLong.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import classNames from 'classnames'; 5 | 6 | import Modal from '../../../src/components/Modal'; 7 | 8 | class LongNestedModalExample extends Component { 9 | state = { 10 | openModalCount: 0, 11 | }; 12 | 13 | addModal = () => { 14 | this.setState((prevState) => { 15 | const { maxModals } = this.props; 16 | 17 | return { 18 | openModalCount: Math.min(prevState.openModalCount + 1, maxModals), 19 | }; 20 | }); 21 | }; 22 | 23 | removeModal = () => { 24 | this.setState((prevState) => ({ 25 | openModalCount: Math.max(prevState.openModalCount - 1, 0), 26 | })); 27 | }; 28 | 29 | renderLongModal = (index) => { 30 | const { maxModals } = this.props; 31 | const { openModalCount } = this.state; 32 | 33 | // Only show this modal if the index of it is less than count of open modals - 1 34 | const isOpen = index <= openModalCount - 1; 35 | const isAddDisabled = openModalCount >= maxModals; 36 | 37 | return ( 38 | 45 |
46 | 53 |
54 | 55 |

56 | Pinterest 8-bit DIY pug cold-pressed Carles, typewriter photo booth deep v quinoa four dollar toast 57 | trust fund freegan. Food truck Godard semiotics, YOLO mixtape asymmetrical selfies Thundercats 58 | 8-bit. 59 |

60 | 61 |

62 | Helvetica banh mi Wes Anderson aesthetic, stumptown keytar ugh beard VHS you probably have not heard 63 | of them Carles Neutra bespoke pour-over Odd Future. Crucifix banjo Tumblr 3 wolf moon, readymade 64 | tilde master cleanse. Neutra fingerstache seitan, cornhole hoodie Pinterest messenger bag food truck 65 | authentic cold-pressed wayfarers narwhal pug blog yr. Heirloom vinyl kitsch, jean shorts banjo 66 | cold-pressed slow-carb skateboard keffiyeh next level farm-to-table pickled whatever. Whatever 67 | Brooklyn trust fund, migas skateboard Marfa typewriter Tumblr pork belly farm-to-table. Fashion axe 68 | paleo selfies, put a bird on it small batch artisan salvia lomo messenger bag polaroid synth 69 | leggings drinking vinegar letterpress organic. Slow-carb church-key chambray disrupt, Portland you 70 | probably have not heard of them Schlitz literally PBR&B trust fund fap. 71 |

72 | 73 |

74 | Fashion axe bitters chillwave, try-hard four loko retro pour-over raw denim cronut meh kale chips 75 | chambray. Beard drinking vinegar retro, quinoa 3 wolf moon artisan sustainable. DIY Bushwick hashtag 76 | Schlitz, church-key synth tousled freegan typewriter Banksy. Authentic disrupt YOLO, ugh selfies 77 | health goth iPhone chillwave. Direct trade single-origin coffee iPhone Marfa. Organic 8-bit butcher 78 | normcore, salvia Odd Future twee cray. Gentrify fixie Tumblr raw denim craft beer. 79 |

80 | 81 |

82 | Literally Williamsburg butcher, small batch drinking vinegar bicycle rights messenger bag. Readymade 83 | 3 wolf moon blog ennui mumblecore selvage sartorial plaid, tousled fap paleo. You probably have not 84 | heard of them YOLO actually, leggings four dollar toast street art kale chips Kickstarter 8-bit 85 | messenger bag. Artisan Truffaut actually beard, authentic vegan pour-over tattooed Marfa tote bag 86 | narwhal try-hard. Blog gluten-free single-origin coffee crucifix pug, Pitchfork you probably have 87 | not heard of them distillery master cleanse meggings cardigan. Brooklyn artisan whatever food truck, 88 | Carles tilde pug tofu pour-over put a bird on it Banksy migas you probably have not heard of them 89 | mlkshk flannel. Hoodie mlkshk four dollar toast sriracha, Brooklyn Bushwick pug 8-bit. 90 |

91 | 92 |

93 | Blog typewriter you probably have not heard of them locavore, letterpress twee authentic. Yr 94 | distillery post-ironic, ennui irony American Apparel literally Tumblr. Kickstarter +1 chillwave 95 | sartorial distillery, normcore Carles single-origin coffee American Apparel flannel. Authentic 96 | pour-over stumptown forage, cray direct trade literally Pinterest locavore 3 wolf moon organic 97 | slow-carb vegan. Mlkshk dreamcatcher try-hard, butcher Blue Bottle cred PBR typewriter bespoke. 98 | Listicle ennui pork belly sriracha, Bushwick meggings letterpress DIY butcher McSweeney slow-carb 99 | forage direct trade. Carles jean shorts VHS, chambray hashtag PBR Echo Park authentic retro listicle 100 | ugh raw denim skateboard literally. 101 |

102 | 103 |

104 | Banksy hoodie pop-up, Pitchfork skateboard DIY typewriter selvage dreamcatcher. Art party hoodie 105 | XOXO, typewriter slow-carb ugh Odd Future lo-fi mumblecore PBR&B letterpress stumptown Brooklyn 106 | normcore viral. You probably have not heard of them Neutra plaid Shoreditch ethical, kitsch fashion 107 | axe. IPhone deep v Intelligentsia, +1 squid Pinterest Williamsburg gentrify selvage Bushwick 108 | chambray master cleanse mixtape Godard polaroid. Stumptown listicle butcher Echo Park PBR&B 109 | Brooklyn typewriter Bushwick locavore, pickled lo-fi. +1 fingerstache bicycle rights trust fund, 110 | blog try-hard banh mi disrupt Bushwick. High Life migas quinoa cray, roof party wolf chambray ennui 111 | bicycle rights viral Wes Anderson chia butcher. 112 |

113 |
114 | ); 115 | }; 116 | 117 | render() { 118 | const { maxModals, toggleCode } = this.props; 119 | 120 | return ( 121 |
122 |
123 | 126 | 129 |
130 | 131 | {[...Array(maxModals).keys()].map((i) => this.renderLongModal(i))} 132 |
133 | ); 134 | } 135 | } 136 | 137 | LongNestedModalExample.propTypes = { 138 | toggleCode: PropTypes.func.isRequired, 139 | maxModals: PropTypes.number, 140 | }; 141 | 142 | LongNestedModalExample.defaultProps = { 143 | maxModals: 8, 144 | }; 145 | 146 | export default LongNestedModalExample; 147 | -------------------------------------------------------------------------------- /src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { clearAllBodyScrollLocks, disableBodyScroll } from 'body-scroll-lock'; 3 | import { TransitionGroup, CSSTransition } from 'react-transition-group'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import getScrollbarSize from '../utils/scrollbarSize'; 7 | import toggleClass from '../toggle-class'; 8 | 9 | import ModalDialog from './dom/ModalDialog'; 10 | import ModalHeader from './dom/ModalHeader'; 11 | import ModalBody from './dom/ModalBody'; 12 | import Backdrop from './dom/Backdrop'; 13 | 14 | // This keeps track of how many modals that are open so that the 15 | // container class and container padding for the scrollbar is correctly set. 16 | let numberOfModalsOpen = 0; 17 | 18 | const keyCodes = { 19 | ESCAPE: 27, 20 | ENTER: 13, 21 | }; 22 | 23 | const shouldBindKeyboard = ({ isOpen, keyboard }) => (keyboard === null ? isOpen : keyboard); 24 | 25 | const getToggleProps = (isOpen) => { 26 | return { 27 | scrollbarSize: typeof document !== 'undefined' ? getScrollbarSize() : null, 28 | className: isOpen ? 'tg-modal-open' : '', 29 | }; 30 | }; 31 | 32 | class Modal extends Component { 33 | constructor(props) { 34 | super(props); 35 | 36 | this.state = { 37 | animating: false, 38 | }; 39 | 40 | this.node = React.createRef(); 41 | 42 | // validate children props and warn if something is wrong 43 | React.Children.forEach(props.children, (child) => { 44 | if (child && child.type === ModalHeader) { 45 | if (child.props.addClose && !child.props.onCancel) { 46 | // eslint-disable-next-line no-console 47 | console.warn(`${ModalHeader.displayName}: addClose is defined but onCancel is missing!`); 48 | } 49 | } 50 | }); 51 | } 52 | 53 | // Add support for SSR - since `componentDidMount` is not called in SSR anymore 54 | // In the future we can probably do something with Suspense here. 55 | UNSAFE_componentWillMount() { 56 | if (typeof document === 'undefined') { 57 | const { isOpen } = this.props; 58 | 59 | this.onToggle(isOpen, getToggleProps(isOpen)); 60 | } 61 | } 62 | 63 | componentDidMount() { 64 | const { isOpen } = this.props; 65 | 66 | this.onToggle(isOpen, getToggleProps(isOpen)); 67 | 68 | if (typeof document !== 'undefined') { 69 | if (shouldBindKeyboard(this.props)) { 70 | this.bindKeyboard(); 71 | } 72 | } 73 | } 74 | 75 | componentDidUpdate(prevProps, prevState) { 76 | const { isOpen } = this.props; 77 | 78 | if (prevProps.isOpen !== isOpen) { 79 | this.setState({ 80 | animating: true, 81 | }); 82 | } 83 | 84 | const { animating } = this.state; 85 | 86 | if (!prevState.animating && animating) { 87 | this.onToggle(isOpen, getToggleProps(isOpen)); 88 | } 89 | 90 | const wasBound = shouldBindKeyboard(prevProps); 91 | const shouldBind = shouldBindKeyboard(this.props); 92 | if (wasBound !== shouldBind) { 93 | if (shouldBind) { 94 | this.bindKeyboard(); 95 | } else { 96 | this.unbindKeyboard(); 97 | } 98 | } 99 | } 100 | 101 | componentWillUnmount() { 102 | const { isOpen } = this.props; 103 | 104 | if (isOpen) { 105 | this.onToggle(false, getToggleProps(isOpen)); 106 | } 107 | 108 | if (typeof document !== 'undefined') { 109 | this.unbindKeyboard(); 110 | } 111 | } 112 | 113 | onToggle(state, props) { 114 | const { onToggle } = this.props; 115 | 116 | if (onToggle) { 117 | onToggle(state, props); 118 | } 119 | 120 | // Add body class and padding to scrollbar. 121 | if (typeof document !== 'undefined') { 122 | const { body } = document; 123 | 124 | // Increment modal count when opening. 125 | if (state) { 126 | numberOfModalsOpen += 1; 127 | } 128 | 129 | // Add toggle body class and update body padding if there is only one modal open. 130 | if (numberOfModalsOpen === 1) { 131 | // Toggle open class. 132 | toggleClass(body, 'tg-modal-open', state); 133 | 134 | const { bodyScrollLock } = this.props; 135 | 136 | if (state) { 137 | if (bodyScrollLock && this.node.current) { 138 | disableBodyScroll(this.node.current, { 139 | reserveScrollBarGap: true, 140 | }); 141 | } 142 | } else if (bodyScrollLock && this.node.current) { 143 | clearAllBodyScrollLocks(); 144 | } 145 | } 146 | 147 | // Decrement modal count when closing. 148 | if (!state) { 149 | numberOfModalsOpen = Math.max(numberOfModalsOpen - 1, 0); 150 | } 151 | } 152 | } 153 | 154 | onCancel = (e, extra) => { 155 | // Don't do anything while animating 156 | const { animating } = this.state; 157 | 158 | if (animating) { 159 | return; 160 | } 161 | 162 | const { isOpen, isStatic, onCancel } = this.props; 163 | 164 | if (isOpen && !isStatic && onCancel) { 165 | onCancel(e, extra); 166 | } 167 | }; 168 | 169 | getAnimatorProps() { 170 | const { transitionName, transitionDuration } = this.props; 171 | const { animating } = this.state; 172 | 173 | return { 174 | classNames: transitionName, 175 | timeout: transitionDuration, 176 | onEntered: this.clearAnimating, 177 | onExited: this.clearAnimating, 178 | unmountOnExit: true, 179 | in: animating, 180 | }; 181 | } 182 | 183 | getAnimatorGroupProps() { 184 | const { wrapperClassName } = this.props; 185 | const { animating } = this.state; 186 | 187 | return { 188 | component: 'div', 189 | className: `tg-modal-wrapper ${wrapperClassName} ${animating ? 'tg-modal-animating' : ''}`.trim(), 190 | }; 191 | } 192 | 193 | clearAnimating = () => { 194 | this.setState({ 195 | animating: false, 196 | }); 197 | }; 198 | 199 | handleKeys = (e) => { 200 | // Handle escape press 201 | if (e.which === keyCodes.ESCAPE) { 202 | this.onCancel(e, true); 203 | } else if (e.which === keyCodes.ENTER) { 204 | // Don't do anything while animating 205 | const { animating } = this.state; 206 | 207 | if (!animating) { 208 | const { onConfirm } = this.props; 209 | 210 | if (onConfirm) { 211 | e.preventDefault(); 212 | 213 | onConfirm(); 214 | } 215 | } 216 | } 217 | }; 218 | 219 | bindKeyboard() { 220 | // Ensure we don't bind twice 221 | this.unbindKeyboard(); 222 | 223 | if (typeof document !== 'undefined') { 224 | this._keyHandler = this.handleKeys; 225 | 226 | document.addEventListener('keyup', this._keyHandler, false); 227 | } 228 | } 229 | 230 | unbindKeyboard() { 231 | if (typeof document !== 'undefined') { 232 | if (this._keyHandler) { 233 | document.removeEventListener('keyup', this._keyHandler, false); 234 | this._keyHandler = null; 235 | } 236 | } 237 | } 238 | 239 | renderChild = (child) => { 240 | if (!child) { 241 | return child; 242 | } 243 | 244 | const { onCancel } = this.props; 245 | const { addClose, headerOnCancel } = child.props || {}; 246 | 247 | if (child.type === ModalHeader && addClose && !headerOnCancel) { 248 | return React.cloneElement(child, { 249 | ...child.props, 250 | onCancel, 251 | }); 252 | } 253 | 254 | return child; 255 | }; 256 | 257 | renderModalBody() { 258 | const { autoWrap, children } = this.props; 259 | 260 | const nodes = React.Children.map(children, this.renderChild); 261 | 262 | if (autoWrap) { 263 | return {nodes}; 264 | } 265 | 266 | return nodes; 267 | } 268 | 269 | renderModalHeader() { 270 | const { title, isStatic } = this.props; 271 | 272 | if (!title) { 273 | // No title: Return nothing 274 | return null; 275 | } else if (typeof title === 'string') { 276 | // Title is a string, return a ModalHeader 277 | return ( 278 | 279 | {title} 280 | 281 | ); 282 | } else { 283 | // Fall back to rendering title directly (events should be handled by parent) 284 | return title; 285 | } 286 | } 287 | 288 | renderTransition = (element) => { 289 | const { TransitionClass } = this.props; 290 | return ( 291 | 292 | {React.cloneElement(element)} 293 | 294 | ); 295 | }; 296 | 297 | renderModal() { 298 | const { isOpen, isBasic, isStatic, dialogClassName, className } = this.props; 299 | 300 | if (!isOpen) { 301 | return []; 302 | } 303 | 304 | return [ 305 | this.renderTransition(), 306 | this.renderTransition( 307 | 315 | {this.renderModalHeader()} 316 | {this.renderModalBody()} 317 | , 318 | ), 319 | ]; 320 | } 321 | 322 | render() { 323 | const { TransitionGroupClass } = this.props; 324 | return {this.renderModal()}; 325 | } 326 | } 327 | 328 | Modal.displayName = 'Modal'; 329 | 330 | Modal.propTypes = { 331 | isOpen: PropTypes.bool.isRequired, 332 | onCancel: PropTypes.func.isRequired, 333 | onConfirm: PropTypes.func, 334 | 335 | children: PropTypes.node, 336 | 337 | isStatic: PropTypes.bool, 338 | isBasic: PropTypes.bool, 339 | autoWrap: PropTypes.bool, 340 | className: PropTypes.string, 341 | dialogClassName: PropTypes.string, 342 | wrapperClassName: PropTypes.string, 343 | 344 | transitionName: PropTypes.string, 345 | transitionDuration: PropTypes.number, 346 | 347 | title: PropTypes.node, 348 | 349 | TransitionClass: PropTypes.func, 350 | TransitionGroupClass: PropTypes.func, 351 | 352 | style: PropTypes.object, // eslint-disable-line react/forbid-prop-types 353 | 354 | // Enable/disable keyboard events. 355 | // When null, the default, it will behave as having same value as isOpen 356 | keyboard: PropTypes.bool, 357 | 358 | // Enable/disable body scroll locking 359 | bodyScrollLock: PropTypes.bool, 360 | 361 | // This is internally used 362 | onToggle: PropTypes.func, 363 | }; 364 | 365 | Modal.defaultProps = { 366 | autoWrap: false, 367 | children: null, 368 | className: '', 369 | dialogClassName: 'tg-modal-dialog', 370 | wrapperClassName: '', 371 | 372 | isStatic: false, 373 | isBasic: false, 374 | 375 | bodyScrollLock: true, 376 | 377 | transitionName: 'tg-modal-fade', 378 | transitionDuration: 300, 379 | 380 | keyboard: null, 381 | 382 | onToggle: null, 383 | onConfirm: null, 384 | 385 | title: null, 386 | 387 | style: null, 388 | 389 | TransitionClass: CSSTransition, 390 | TransitionGroupClass: TransitionGroup, 391 | }; 392 | 393 | export default Modal; 394 | --------------------------------------------------------------------------------