├── .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 |
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 |
--------------------------------------------------------------------------------