├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── __tests__ └── modal.js ├── babel.config.json ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── index.js └── styles.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | coverage 3 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | __tests__ 3 | .babelrc 4 | webpack.config.js 5 | jest.config.js 6 | jestSetup.js -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 José Neto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A lightweight dialog component for React 2 | 3 | ## Demo 4 | 5 | https://codesandbox.io/s/react-modal-example-3km0w 6 | 7 | ## Install 8 | 9 | ``` 10 | npm i @netojose/react-modal 11 | ``` 12 | 13 | or 14 | 15 | ``` 16 | yarn add @netojose/react-modal 17 | ``` 18 | 19 | ## Basic usage 20 | 21 | ```js 22 | import React, { useState, useCallback } from 'react' 23 | import Modal from '@netojose/react-modal' 24 | 25 | function App() { 26 | const [isOpen, setIsOpen] = useState(false) 27 | const openModal = useCallback(() => setIsOpen(true), []) 28 | const closeModal = useCallback(() => setIsOpen(false), []) 29 | return ( 30 |
31 | 32 | 33 |

This is the modal content

34 | 35 |
36 |
37 | ) 38 | } 39 | 40 | export default App 41 | ``` 42 | 43 | ## API 44 | 45 | | prop | Description | type | default value | required | 46 | | ------------------- | ----------------------------------------------- | -------- | --------------------- | -------- | 47 | | isOpen | Flag to render or not the modal | boolean | false | Yes | 48 | | ariaLabelledby | `aria-labelledby` modal attribute | string | null | No | 49 | | ariaDescribedby | `aria-describedby` modal attribute | string | null | No | 50 | | onAfterOpen | Callback after modal open | function | () => null | No | 51 | | onAfterClose | Callback after modal close | function | () => null | No | 52 | | onRequestClose | Callback when a close modal action is triggered | function | () => null | No | 53 | | closeOnOverlayClick | Flag to request close modal on overlay click | boolean | true | No | 54 | | closeOnEsc | Flag to request close modal on press esc key | boolean | true | No | 55 | | focusAfterRender | Flag to modal should be focused after render | boolean | true | No | 56 | | portalClassName | Portal div class name | string | ReactModal\_\_Portal | No | 57 | | overlayClassName | Overlay div class name | string | ReactModal\_\_Overlay | No | 58 | | modalClassName | Modal div class name | string | ReactModal\_\_Modal | No | 59 | | overlayStyles | Extra overlay styles | object | {} | No | 60 | | modalStyles | Extra modal styles | object | {} | No | 61 | | container | Query selector to append portal | string | body | No | 62 | -------------------------------------------------------------------------------- /__tests__/modal.js: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from '@testing-library/react' 2 | import '@testing-library/jest-dom' 3 | import userEvent from '@testing-library/user-event' 4 | import React from 'react' 5 | import Modal from '../src/index' 6 | 7 | const mockOnAfterOpen = jest.fn() 8 | const mockOnAfterClose = jest.fn() 9 | const mockOnRequestClose = jest.fn() 10 | 11 | afterEach(() => { 12 | jest.clearAllMocks() 13 | }) 14 | 15 | test('Not render when isOpen is false', () => { 16 | render(Modal content) 17 | 18 | expect(screen.queryByText('Modal content')).not.toBeInTheDocument() 19 | }) 20 | 21 | test('Check for modal content', () => { 22 | render(Modal content) 23 | 24 | expect(screen.queryByText('Modal content')).toBeInTheDocument() 25 | }) 26 | 27 | test('Check if portal is created', () => { 28 | render(Modal content) 29 | 30 | const overlayElement = screen.getByText('Modal content').parentElement 31 | const portalElement = overlayElement.parentElement 32 | 33 | expect(portalElement).toBeInTheDocument() 34 | expect(portalElement).toHaveClass('ReactModal__Portal') 35 | }) 36 | 37 | test('Check if portal is removed', () => { 38 | const { unmount } = render(Modal content) 39 | 40 | const overlayElement = screen.getByText('Modal content').parentElement 41 | const portalElement = overlayElement.parentElement 42 | 43 | expect(portalElement).toBeInTheDocument() 44 | expect(portalElement).toHaveClass('ReactModal__Portal') 45 | 46 | unmount() 47 | 48 | expect(screen.queryByText('Modal content')).not.toBeInTheDocument() 49 | }) 50 | 51 | test('Check for onAfterOpen callback', () => { 52 | const openModal = (isOpen) => ( 53 | 54 | Modal content 55 | 56 | ) 57 | const { rerender } = render(openModal(false)) 58 | 59 | expect(mockOnAfterOpen.mock.calls.length).toBe(0) 60 | 61 | rerender(openModal(true)) 62 | 63 | expect(mockOnAfterOpen.mock.calls.length).toBe(1) 64 | 65 | rerender(openModal(false)) 66 | 67 | expect(mockOnAfterOpen.mock.calls.length).toBe(1) 68 | }) 69 | 70 | test('Check for onAfterClose callback', () => { 71 | const openModal = (isOpen) => ( 72 | 73 | Modal content 74 | 75 | ) 76 | const { rerender } = render(openModal(false)) 77 | 78 | expect(mockOnAfterClose.mock.calls.length).toBe(0) 79 | 80 | rerender(openModal(true)) 81 | 82 | expect(mockOnAfterClose.mock.calls.length).toBe(0) 83 | 84 | rerender(openModal(false)) 85 | 86 | expect(mockOnAfterClose.mock.calls.length).toBe(1) 87 | }) 88 | 89 | test('Check for onRequestClose callback', async () => { 90 | const openModal = (isOpen) => Modal content 91 | const { rerender } = render(openModal(false)) 92 | 93 | rerender(openModal(true)) 94 | 95 | await act(async () => { 96 | await userEvent.keyboard('{Escape}') 97 | }) 98 | 99 | rerender( 100 | 101 | Modal content 102 | 103 | ) 104 | 105 | expect(mockOnRequestClose.mock.calls.length).toBe(0) 106 | 107 | await act(async () => { 108 | await userEvent.keyboard('{Escape}') 109 | }) 110 | 111 | expect(mockOnRequestClose.mock.calls.length).toBe(1) 112 | }) 113 | 114 | test('Check onRequestClose on click overlay', async () => { 115 | const openModal = (isOpen) => ( 116 | 117 | Modal content 118 | 119 | ) 120 | const { rerender } = render(openModal(false)) 121 | 122 | rerender(openModal(true)) 123 | 124 | expect(mockOnRequestClose.mock.calls.length).toBe(0) 125 | 126 | const overlayElement = screen.getByText('Modal content').parentElement 127 | 128 | await act(async () => { 129 | await userEvent.click(overlayElement) 130 | }) 131 | 132 | expect(mockOnRequestClose.mock.calls.length).toBe(1) 133 | 134 | rerender( 135 | 140 | Modal content 141 | 142 | ) 143 | 144 | await act(async () => { 145 | await userEvent.click(overlayElement) 146 | }) 147 | 148 | expect(mockOnRequestClose.mock.calls.length).toBe(1) 149 | }) 150 | 151 | test('Check onRequestClose on click overlay', async () => { 152 | const openModal = (isOpen) => ( 153 | 154 | Modal content 155 | 156 | ) 157 | const { rerender } = render(openModal(false)) 158 | 159 | rerender(openModal(true)) 160 | 161 | expect(mockOnRequestClose.mock.calls.length).toBe(0) 162 | 163 | const overlayElement = screen.getByText('Modal content').parentElement 164 | 165 | await act(async () => { 166 | await userEvent.click(overlayElement) 167 | }) 168 | 169 | expect(mockOnRequestClose.mock.calls.length).toBe(1) 170 | 171 | rerender( 172 | 177 | Modal content 178 | 179 | ) 180 | 181 | await act(async () => { 182 | await userEvent.click(overlayElement) 183 | }) 184 | 185 | expect(mockOnRequestClose.mock.calls.length).toBe(1) 186 | }) 187 | 188 | test('Check onRequestClose on click overlay', async () => { 189 | const openModal = (isOpen) => ( 190 | 191 | Modal content 192 | 193 | ) 194 | const { rerender } = render(openModal(false)) 195 | 196 | rerender(openModal(true)) 197 | 198 | expect(mockOnRequestClose.mock.calls.length).toBe(0) 199 | 200 | const overlayElement = screen.getByText('Modal content').parentElement 201 | 202 | await act(async () => { 203 | await userEvent.click(overlayElement) 204 | }) 205 | 206 | expect(mockOnRequestClose.mock.calls.length).toBe(1) 207 | 208 | rerender( 209 | 214 | Modal content 215 | 216 | ) 217 | 218 | await act(async () => { 219 | await userEvent.click(overlayElement) 220 | }) 221 | 222 | expect(mockOnRequestClose.mock.calls.length).toBe(1) 223 | }) 224 | 225 | test('Check autofocus after open modal', () => { 226 | const openModal = (isOpen) => ( 227 | 228 | 229 | 230 | ) 231 | const noFocusAfterRenderOpenModal = (isOpen) => ( 232 | 233 | 234 | 235 | ) 236 | const { rerender } = render(openModal(false)) 237 | 238 | rerender(openModal(true)) 239 | 240 | const inputElement = screen.getByRole('textbox', { name: '' }) 241 | 242 | expect(inputElement).toHaveFocus() 243 | 244 | rerender(noFocusAfterRenderOpenModal(false)) 245 | 246 | rerender(noFocusAfterRenderOpenModal(true)) 247 | 248 | expect(inputElement).not.toHaveFocus() 249 | }) 250 | 251 | test('Not allow tab navigation while modal is opened', async () => { 252 | const openModal = (isOpen) => ( 253 | 254 | 255 | 256 | ) 257 | const { rerender } = render(openModal(false)) 258 | 259 | rerender(openModal(true)) 260 | 261 | const inputElement = screen.getByRole('textbox', { name: '' }) 262 | 263 | expect(inputElement).not.toHaveFocus() 264 | 265 | await act(async () => { 266 | await userEvent.tab() 267 | }) 268 | 269 | expect(inputElement).toHaveFocus() 270 | }) 271 | 272 | test('Not allow tab navigation while modal is opened even when modal has no focusable element', async () => { 273 | const openModal = (isOpen) => ( 274 | 275 | Hello 276 | 277 | ) 278 | const { rerender } = render(openModal(false)) 279 | 280 | rerender(openModal(true)) 281 | 282 | const element = screen.getByText('Hello') 283 | 284 | expect(element).not.toHaveFocus() 285 | 286 | await act(async () => { 287 | await userEvent.tab() 288 | }) 289 | 290 | expect(element).not.toHaveFocus() 291 | }) 292 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env"], 3 | "plugins": ["@babel/plugin-transform-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | testEnvironment: "jsdom", 3 | }; 4 | 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@netojose/react-modal", 3 | "version": "1.0.4", 4 | "description": "A lightweight dialog component for React", 5 | "main": "./dist/index.js", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/netojose/react-modal.git" 10 | }, 11 | "scripts": { 12 | "test": "jest", 13 | "test:coverage": "jest --coverage", 14 | "start": "webpack --watch", 15 | "build": "webpack" 16 | }, 17 | "homepage": "https://github.com/netojose/react-modal", 18 | "bugs": "https://github.com/netojose/react-modal/issues", 19 | "author": { 20 | "name": "José neto", 21 | "email": "sputinykster@gmail.com" 22 | }, 23 | "peerDependencies": { 24 | "prop-types": "^15.6.0", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.5.0", 30 | "@babel/plugin-transform-class-properties": "^7.22.5", 31 | "@babel/preset-env": "^7.5.0", 32 | "@babel/preset-react": "^7.0.0", 33 | "@testing-library/jest-dom": "^6.1.4", 34 | "@testing-library/react": "^14.0.0", 35 | "@testing-library/user-event": "^13.5.0", 36 | "babel-jest": "^29.7.0", 37 | "babel-loader": "^9.1.3", 38 | "jest": "^29.7.0", 39 | "jest-environment-jsdom": "^29.7.0", 40 | "prop-types": "^15.6.0", 41 | "react": "^18.2.0", 42 | "react-dom": "^18.2.0", 43 | "webpack": "^5.89.0", 44 | "webpack-cli": "^5.1.4" 45 | }, 46 | "tags": [ 47 | "react", 48 | "modal", 49 | "dialog" 50 | ], 51 | "keywords": [ 52 | "react", 53 | "react-component", 54 | "modal", 55 | "dialog" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, createRef } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import propTypes from 'prop-types' 4 | 5 | import { overlay as overlayCSS, modal as modalCSS } from './styles' 6 | 7 | class Modal extends PureComponent { 8 | constructor(props) { 9 | super(props) 10 | this.portalNode = document.createElement('div') 11 | this.portalNode.className = props.portalClassName 12 | this.modalRef = createRef() 13 | } 14 | componentWillUnmount() { 15 | this._removePortal() 16 | } 17 | 18 | componentDidUpdate(prevProps) { 19 | const { isOpen, onAfterOpen, onAfterClose } = this.props 20 | const { isOpen: prevIsOpen } = prevProps 21 | 22 | if (isOpen && !prevIsOpen) { 23 | this._setFocus() 24 | document.addEventListener('keydown', this._watchKeyboard) 25 | onAfterOpen() 26 | } else if (!isOpen && prevIsOpen) { 27 | this._removePortal() 28 | onAfterClose() 29 | } 30 | } 31 | 32 | _removePortal = () => { 33 | const { parentNode } = this.portalNode 34 | if (parentNode) { 35 | parentNode.removeChild(this.portalNode) 36 | } 37 | document.removeEventListener('keydown', this._watchKeyboard) 38 | } 39 | 40 | _watchKeyboard = e => { 41 | e = e || window.event 42 | const KEY_TAB = 9 43 | const KEY_ESC = 27 44 | const { closeOnEsc } = this.props 45 | 46 | if (e.keyCode === KEY_TAB) { 47 | this._handleTabNavigation(e) 48 | return 49 | } 50 | 51 | if (e.keyCode === KEY_ESC && closeOnEsc) { 52 | this.props.onRequestClose() 53 | } 54 | } 55 | 56 | _handleOverlayClick = e => { 57 | const { closeOnOverlayClick } = this.props 58 | if (closeOnOverlayClick && e.target === e.currentTarget) { 59 | this.props.onRequestClose() 60 | } 61 | } 62 | 63 | _setFocus = () => { 64 | if (!this.props.focusAfterRender) { 65 | return 66 | } 67 | const focusable = this._getFocusable() 68 | if (focusable.length > 0) { 69 | focusable[0].focus() 70 | } 71 | } 72 | 73 | _getFocusable = () => 74 | this.modalRef.current.querySelectorAll( 75 | 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]' 76 | ) 77 | 78 | _handleTabNavigation = e => { 79 | const focusable = this._getFocusable() 80 | 81 | if (focusable.length < 1) { 82 | e.preventDefault() 83 | return 84 | } 85 | 86 | const firstEl = focusable[0] 87 | const lastEl = focusable[focusable.length - 1] 88 | const currEl = document.activeElement 89 | 90 | if (e.shiftKey && currEl === firstEl) { 91 | e.preventDefault() 92 | lastEl.focus() 93 | return 94 | } 95 | 96 | if (!e.shiftKey && currEl === lastEl) { 97 | e.preventDefault() 98 | firstEl.focus() 99 | return 100 | } 101 | 102 | const isValidEl = Array.from(focusable).find(el => el === currEl) 103 | if (!isValidEl) { 104 | e.preventDefault() 105 | firstEl.focus() 106 | } 107 | } 108 | 109 | render() { 110 | const { 111 | children, 112 | isOpen, 113 | ariaLabelledby, 114 | ariaDescribedby, 115 | overlayClassName, 116 | modalClassName, 117 | overlayStyles, 118 | modalStyles, 119 | container 120 | } = this.props 121 | 122 | if (!isOpen) { 123 | return null 124 | } 125 | 126 | const containerNode = document.querySelector(container) 127 | if (!containerNode.contains(this.portalNode)) { 128 | containerNode.appendChild(this.portalNode) 129 | } 130 | 131 | const styleOverlay = { ...overlayCSS, ...overlayStyles } 132 | const styleModal = { ...modalCSS, ...modalStyles } 133 | 134 | return ReactDOM.createPortal( 135 |
140 |
148 | {children} 149 |
150 |
, 151 | this.portalNode 152 | ) 153 | } 154 | } 155 | 156 | Modal.defaultProps = { 157 | children: null, 158 | isOpen: false, 159 | focusAfterRender: true, 160 | ariaLabelledby: null, 161 | ariaDescribedby: null, 162 | onAfterOpen: () => null, 163 | onAfterClose: () => null, 164 | onRequestClose: () => null, 165 | closeOnOverlayClick: true, 166 | closeOnEsc: true, 167 | portalClassName: 'ReactModal__Portal', 168 | overlayClassName: 'ReactModal__Overlay', 169 | modalClassName: 'ReactModal__Modal', 170 | overlayStyles: {}, 171 | modalStyles: {}, 172 | container: 'body' 173 | } 174 | 175 | Modal.propTypes = { 176 | children: propTypes.node, 177 | isOpen: propTypes.bool, 178 | focusAfterRender: propTypes.bool, 179 | ariaLabelledby: propTypes.string, 180 | ariaDescribedby: propTypes.string, 181 | onAfterOpen: propTypes.func, 182 | onAfterClose: propTypes.func, 183 | onRequestClose: propTypes.func, 184 | closeOnOverlayClick: propTypes.bool, 185 | closeOnEsc: propTypes.bool, 186 | portalClassName: propTypes.string, 187 | overlayClassName: propTypes.string, 188 | modalClassName: propTypes.string, 189 | overlayStyles: propTypes.object, 190 | modalStyles: propTypes.object, 191 | container: propTypes.string 192 | } 193 | 194 | export default Modal 195 | -------------------------------------------------------------------------------- /src/styles.js: -------------------------------------------------------------------------------- 1 | export const overlay = { 2 | position: 'fixed', 3 | top: 0, 4 | left: 0, 5 | right: 0, 6 | bottom: 0, 7 | backgroundColor: 'rgba(255, 255, 255, 0.75)' 8 | } 9 | 10 | export const modal = { 11 | position: 'absolute', 12 | top: '40px', 13 | left: '40px', 14 | right: '40px', 15 | bottom: '40px', 16 | border: '1px solid #ccc', 17 | background: '#fff', 18 | overflow: 'auto', 19 | WebkitOverflowScrolling: 'touch', 20 | borderRadius: '4px', 21 | outline: 'none', 22 | padding: '20px' 23 | } 24 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | output: { 6 | path: path.resolve(__dirname, 'dist'), 7 | filename: 'index.js', 8 | libraryTarget: 'commonjs2' 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | include: path.resolve(__dirname, 'src'), 15 | exclude: /(node_modules|dist)/, 16 | use: 'babel-loader' 17 | } 18 | ] 19 | }, 20 | mode: 'production', 21 | externals: { 22 | react: 'commonjs react', 23 | 'react-dom': 'commonjs react-dom' 24 | } 25 | } 26 | --------------------------------------------------------------------------------