├── .circleci
└── config.yml
├── .eslintignore
├── .eslintrc.json
├── .github
└── ISSUE_TEMPLATE
│ └── bug_report.md
├── .gitignore
├── README.md
├── examples
├── index.html
└── index.js
├── jest.config.js
├── license.md
├── package.json
├── tsconfig.json
├── usePortal.gif
├── usePortal.test.ts
├── usePortal.ts
└── yarn.lock
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | test:
4 | docker:
5 | - image: circleci/node:8.10
6 | environment:
7 | - NODE_ENV: test
8 | working_directory: ~/react-useportal
9 | steps:
10 | - checkout
11 | - restore_cache:
12 | key: react-useportal-yarn-{{ checksum "yarn.lock" }}
13 | - run:
14 | name: Yarn Install
15 | command: |
16 | yarn install
17 | - save_cache:
18 | key: react-useportal-yarn-{{ checksum "yarn.lock" }}
19 | paths:
20 | - ~/react-useportal/node_modules
21 | - run:
22 | name: Run JS Tests
23 | command: yarn test
24 | workflows:
25 | version: 2
26 | build_and_test:
27 | jobs:
28 | - test
29 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "plugins": [
4 | "@typescript-eslint",
5 | "react",
6 | "react-hooks",
7 | "jest",
8 | "jest-formatting"
9 | ],
10 | "parserOptions": {
11 | "ecmaVersion": 2018,
12 | "sourceType": "module",
13 | "ecmaFeatures": {
14 | "jsx": true
15 | }
16 | },
17 | "extends": [
18 | "plugin:@typescript-eslint/recommended",
19 | "plugin:react/recommended",
20 | "plugin:jest/recommended",
21 | "prettier/@typescript-eslint",
22 | "plugin:prettier/recommended"
23 | ],
24 | "rules": {
25 | "@typescript-eslint/member-delimiter-style": [
26 | "error",
27 | {
28 | "multiline": {
29 | "delimiter": "none",
30 | "requireLast": false
31 | },
32 | "singleline": {
33 | "delimiter": "comma",
34 | "requireLast": false
35 | }
36 | }
37 | ],
38 | "@typescript-eslint/explicit-member-accessibility": ["off"],
39 | "@typescript-eslint/explicit-function-return-type": ["off"],
40 | "@typescript-eslint/no-explicit-any": ["off"],
41 | "react-hooks/rules-of-hooks": "error",
42 | "react-hooks/exhaustive-deps": "warn",
43 | "jest/consistent-test-it": [
44 | "error",
45 | {
46 | "fn": "it"
47 | }
48 | ],
49 | "jest-formatting/padding-before-test-blocks": 2,
50 | "jest-formatting/padding-before-describe-blocks": 2,
51 | "react/prop-types": 0,
52 | "prefer-const": "warn",
53 | "no-var": "warn",
54 | "prettier/prettier": [
55 | "error",
56 | {
57 | "printWidth": 80,
58 | "singleQuote": true,
59 | "semi": false,
60 | "tabWidth": 2,
61 | "trailingComma": "all"
62 | }
63 | ]
64 | },
65 | "settings": {
66 | "react": {
67 | "version": "detect"
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: alex-cory
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **⚠️ Make a Codesandbox ⚠️**
14 | Please do this to easily reproduce the bug.
15 |
16 | **To Reproduce**
17 | Steps to reproduce the behavior:
18 | 1. Go to '...'
19 | 2. Click on '....'
20 | 3. Scroll down to '....'
21 | 4. See error
22 |
23 | **Expected behavior**
24 | A clear and concise description of what you expected to happen.
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 |
10 | # Runtime data
11 | pids
12 | *.pid
13 | *.seed
14 | *.pid.lock
15 |
16 | # Directory for instrumented libs generated by jscoverage/JSCover
17 | lib-cov
18 |
19 | # Coverage directory used by tools like istanbul
20 | coverage
21 |
22 | # nyc test coverage
23 | .nyc_output
24 |
25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
26 | .grunt
27 |
28 | # Bower dependency directory (https://bower.io/)
29 | bower_components
30 |
31 | # node-waf configuration
32 | .lock-wscript
33 |
34 | # Compiled binary addons (https://nodejs.org/api/addons.html)
35 | build/Release
36 |
37 | # Dependency directories
38 | node_modules/
39 | jspm_packages/
40 |
41 | # TypeScript v1 declaration files
42 | typings/
43 |
44 | # Optional npm cache directory
45 | .npm
46 |
47 | # Optional eslint cache
48 | .eslintcache
49 |
50 | # Optional REPL history
51 | .node_repl_history
52 |
53 | # Output of 'npm pack'
54 | *.tgz
55 |
56 | # Yarn Integrity file
57 | .yarn-integrity
58 |
59 | # dotenv environment variables file
60 | .env
61 |
62 | # next.js build output
63 | .next
64 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
65 |
66 | # dependencies
67 | /node_modules
68 | /.pnp
69 | .pnp.js
70 |
71 | # testing
72 | /coverage
73 |
74 | # production
75 | /build
76 |
77 | # misc
78 | .DS_Store
79 | .env.local
80 | .env.development.local
81 | .env.test.local
82 | .env.production.local
83 |
84 | npm-debug.log*
85 | yarn-debug.log*
86 | yarn-error.log*
87 |
88 | # Parcel
89 | .cache
90 |
91 | # dependencies
92 | /node_modules
93 | /.pnp
94 | .pnp.js
95 |
96 | # testing
97 | /coverage
98 |
99 | # production
100 | /dist
101 |
102 | # misc
103 | .DS_Store
104 | .env.local
105 | .env.development.local
106 | .env.test.local
107 | .env.production.local
108 |
109 | npm-debug.log*
110 | yarn-debug.log*
111 | yarn-error.log*
112 |
113 | # Using Yarn
114 | package-lock.json
115 |
116 | # Gitkeep files
117 | !.gitkeep
118 |
119 | # IDE config
120 | .idea
121 | *.iml
122 | /venv
123 | .vscode
124 |
125 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
usePortal
3 |
4 | 🌀 React hook for using Portals
5 |
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 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | Need to make dropdowns, lightboxes/modals/dialogs, global message notifications, or tooltips in React? React Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component ([react docs](https://reactjs.org/docs/portals.html)).
38 |
39 | This hook is also isomorphic, meaning it works with SSR (server side rendering).
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | Features
48 | --------
49 | - SSR (server side rendering) support
50 | - TypeScript support
51 | - 1 dependency ([use-ssr](https://github.com/alex-cory/use-ssr))
52 | - Built in state
53 |
54 | ### Examples
55 | - [SSR Example - Next.js - codesandbox container](https://codesandbox.io/s/useportal-in-nextjs-codesandbox-container-9rm5o) (sometimes buggy, if so try [this example](https://codesandbox.io/s/useportal-in-nextjs-ux9nb))
56 | - [Modal Example (useModal) - create-react-app](https://codesandbox.io/s/w6jp7z4pkk)
57 | - [Dropdown Example (useDropdown) - Next.js](https://codesandbox.io/s/useportal-usedropdown-587fo)
58 | - [Tooltip Example (useTooltip) - Next.js](https://codesandbox.io/s/useportal-usedropdown-dgesf)
59 |
60 |
61 | Installation
62 | ------------
63 |
64 | ```shell
65 | yarn add react-useportal or npm i -S react-useportal
66 | ```
67 |
68 | Usage
69 | -----
70 |
71 | ### Stateless
72 | ```jsx
73 | import usePortal from 'react-useportal'
74 |
75 | const App = () => {
76 | const { Portal } = usePortal()
77 |
78 | return (
79 |
80 | This text is portaled at the end of document.body!
81 |
82 | )
83 | }
84 |
85 | const App = () => {
86 | const { Portal } = usePortal({
87 | bindTo: document && document.getElementById('san-francisco')
88 | })
89 |
90 | return (
91 |
92 | This text is portaled into San Francisco!
93 |
94 | )
95 | }
96 | ```
97 |
98 | ### With State
99 | ```jsx
100 | import usePortal from 'react-useportal'
101 |
102 | const App = () => {
103 | var { openPortal, closePortal, isOpen, Portal } = usePortal()
104 |
105 | // want to use array destructuring? You can do that too
106 | var [openPortal, closePortal, isOpen, Portal] = usePortal()
107 |
108 | return (
109 | <>
110 |
111 | Open Portal
112 |
113 | {isOpen && (
114 |
115 |
116 | This Portal handles its own state.{' '}
117 | Close me! , hit ESC or
118 | click outside of me.
119 |
120 |
121 | )}
122 | >
123 | )
124 | }
125 | ```
126 |
127 | ### Need Animations?
128 | ```jsx
129 | import usePortal from 'react-useportal'
130 |
131 | const App = () => {
132 | const { openPortal, closePortal, isOpen, Portal } = usePortal()
133 | return (
134 | <>
135 |
136 | Open Portal
137 |
138 |
139 |
140 | This Portal handles its own state.{' '}
141 | Close me! , hit ESC or
142 | click outside of me.
143 |
144 |
145 | >
146 | )
147 | }
148 | ```
149 |
150 | ### Customizing the Portal directly
151 | By using `onOpen`, `onClose` or any other event handler, you can modify the `Portal` and return it. See [useDropdown](https://codesandbox.io/s/useportal-usedropdown-587fo) for a working example. If opening the portal from a click event it's important that you pass the `event` object to `openPortal` and `togglePortal` otherwise you will need to attach a `ref` to the clicked element (if you want to be able to open the portal without passing an event you will need to set `programmaticallyOpen` to `true`).
152 |
153 | ```jsx
154 | const useModal = () => {
155 | const { isOpen, openPortal, togglePortal, closePortal, Portal } = usePortal({
156 | onOpen({ portal }) {
157 | portal.current.style.cssText = `
158 | /* add your css here for the Portal */
159 | position: fixed;
160 | left: 50%;
161 | top: 50%;
162 | transform: translate(-50%,-50%);
163 | z-index: 1000;
164 | `
165 | }
166 | })
167 |
168 | return {
169 | Modal: Portal,
170 | openModal: openPortal,
171 | toggleModal: togglePortal,
172 | closeModal: closePortal,
173 | isOpen
174 | }
175 | }
176 |
177 | const App = () => {
178 | const { openModal, closeModal, isOpen, Modal } = useModal()
179 |
180 | return <>
181 | openModal(e)}>Open Modal
182 | {isOpen && (
183 |
184 | This will dynamically center to the middle of the screen regardless of the size of what you put in here
185 |
186 | )}
187 | >
188 | }
189 | ```
190 |
191 | **Make sure you are passing the html synthetic event to the `openPortal` and `togglePortal` . i.e. `onClick={e => openPortal(e)}`**
192 |
193 | ### Usage with a `ref`
194 | If for some reason, you don't want to pass around the `event` to `openPortal` or `togglePortal` and you're not using `programmaticallyOpen`, you can use a `ref` like this.
195 | ```jsx
196 | import usePortal from 'react-useportal'
197 |
198 | const App = () => {
199 | var { ref, openPortal, closePortal, isOpen, Portal } = usePortal()
200 |
201 | return (
202 | <>
203 | {/* see below how I don't have to pass the event if I use the ref */}
204 | openPortal()}>
205 | Open Portal
206 |
207 | {isOpen && (
208 |
209 |
210 | This Portal handles its own state.{' '}
211 | Close me! , hit ESC or
212 | click outside of me.
213 |
214 |
215 | )}
216 | >
217 | )
218 | }
219 | ```
220 |
221 | Options
222 | -----
223 | | Option | Description |
224 | | --------------------- | ---------------------------------------------------------------------------------------- |
225 | | `closeOnOutsideClick` | This will close the portal when not clicking within the portal. Default is `true` |
226 | | `closeOnEsc` | This will allow you to hit ESC and it will close the modal. Default is `true` |
227 | | `bindTo` | This is the DOM node you want to attach the portal to. By default it attaches to `document.body` |
228 | | `isOpen` | This will be the default for the portal. Default is `false` |
229 | | `onOpen` | This is used to call something when the portal is opened and to modify the css of the portal directly |
230 | | `onClose` | This is used to call something when the portal is closed and to modify the css of the portal directly |
231 | | `onPortalClick` | This is fired whenever clicking on the `Portal` |
232 | | html event handlers (i.e. `onClick`) | These can be used instead of `onOpen` to modify the css of the portal directly. [`onMouseEnter` and `onMouseLeave` example](https://codesandbox.io/s/useportal-usedropdown-dgesf) |
233 | | `programmaticallyOpen` | This option allows you to open or toggle the portal without passing in an event. Default is `false` |
234 |
235 | ### Option Usage
236 |
237 | ```js
238 | const {
239 | openPortal,
240 | closePortal,
241 | togglePortal,
242 | isOpen,
243 | Portal,
244 | // if you don't pass an event to openPortal, closePortal, or togglePortal and you're not using programmaticallyOpen, you will need
245 | // to put this on the element you want to interact with/click
246 | ref,
247 | // if for some reason you want to interact directly with the portal, you can with this ref
248 | portalRef,
249 | } = usePortal({
250 | closeOnOutsideClick: true,
251 | closeOnEsc: true,
252 | bindTo, // attach the portal to this node in the DOM
253 | isOpen: false,
254 | // `event` has all the fields that a normal `event` would have such as `event.target.value`, etc.
255 | // with the additional `portal` and `targetEl` added to it as seen in the examples below
256 | onOpen: (event) => {
257 | // can access: event.portal, event.targetEl, event.event, event.target, etc.
258 | },
259 | // `onClose` will not have an `event` unless you pass an `event` to `closePortal`
260 | onClose({ portal, targetEl, event }) {},
261 | // `targetEl` is the element that you either are attaching a `ref` to
262 | // or that you are putting `openPortal` or `togglePortal` or `closePortal` on
263 | onPortalClick({ portal, targetEl, event }) {},
264 | // in addition, any event handler such as onClick, onMouseOver, etc will be handled the same
265 | onClick({ portal, targetEl, event }) {}
266 | })
267 | ```
268 | Todos
269 | ------
270 | - [ ] React Native support. [1](https://github.com/zenyr/react-native-portal) [2](https://github.com/cloudflare/react-gateway) [3](https://medium.com/@naorzruk/portals-in-react-native-22797ba8aa1b) [4](https://stackoverflow.com/questions/46505378/can-we-have-react-16-portal-functionality-react-native) [5](https://github.com/callstack/react-native-paper/blob/master/src/components/Portal/PortalManager.tsx) Probably going to have to add a `Provider`...
271 | - [ ] add correct typescript return types
272 | - [ ] add support for popup windows [resource 1](https://javascript.info/popup-windows) [resource 2](https://hackernoon.com/using-a-react-16-portal-to-do-something-cool-2a2d627b0202). Maybe something like
273 | ```jsx
274 | const { openPortal, closePortal, isOpen, Portal } = usePortal({
275 | popup: ['', '', 'width=600,height=400,left=200,top=200']
276 | })
277 | // window.open('', '', 'width=600,height=400,left=200,top=200')
278 | ```
279 | - [ ] tests (priority)
280 | - [ ] maybe have a ` ` then you can change the order of the array destructuring syntax
281 | - [ ] fix code so maintainability is A
282 | - [ ] set up code climate test coverage
283 | - [ ] optimize badges [see awesome badge list](https://github.com/boennemann/badges)
284 | - [ ] add code climate test coverage badge
285 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/examples/index.js:
--------------------------------------------------------------------------------
1 | import React , { useEffect, useState } from 'react'
2 | import { render } from 'react-dom'
3 | import usePortal from '../usePortal'
4 |
5 | const Exmaple1 = () => {
6 | const { openPortal, closePortal, isOpen, Portal } = usePortal()
7 |
8 | return (
9 | <>
10 | Example 1
11 | Open Portal
12 | {isOpen && (
13 |
14 |
15 | Cool
16 | Close Portal
17 |
18 |
19 | )}
20 | >
21 | )
22 | }
23 |
24 | const Example2 = () => {
25 | const {
26 | openPortal: openFirstPortal,
27 | closePortal: closeFirstPortal,
28 | isOpen: isFirstOpen,
29 | Portal: FirstPortal
30 | } = usePortal();
31 |
32 | const [
33 | openSecondPortal,
34 | closeSecondPortal,
35 | isSecondOpen,
36 | SecondPortal
37 | ] = usePortal();
38 |
39 | return (
40 | <>
41 | Example 2
42 | Open First
43 | {isFirstOpen && (
44 |
45 | I'm First.
46 | {
48 | openSecondPortal(e);
49 | closeFirstPortal();
50 | }}
51 | >
52 | Close Me and Open Second
53 |
54 |
55 | )}
56 | {isSecondOpen && (
57 |
58 | I'm SecondClose Me
59 |
60 | )}
61 | >
62 | );
63 | }
64 |
65 | const Example3 = () => {
66 | const { openPortal, closePortal, isOpen, Portal } = usePortal({
67 | programmaticallyOpen: true,
68 | onClose: () => setOpen(false)
69 | })
70 | const [open, setOpen] = useState(false);
71 |
72 | useEffect(() => {
73 | if (open) {
74 | openPortal()
75 | } else {
76 | closePortal()
77 | }
78 | }, [closePortal, open, openPortal, setOpen])
79 |
80 | return (
81 | <>
82 | Example 3
83 | setOpen(true)}>Programmatically Open Portal
84 | {isOpen && (
85 |
86 |
87 | This portal was opened via a state change
88 | setOpen(false)}>Programmatically Close Portal
89 |
90 |
91 | )}
92 | >
93 | )
94 | }
95 |
96 | // this should attach via `bind` so whatever you "bind" it to, you can click
97 | // and it will apear near where you click. Need to figure out how to handle
98 | // this though. THIS IS NOT IMPLEMENTED, JUST POTENTIAL SYNTAX
99 | // const Example3 = () => {
100 | // const { togglePortal, closePortal, isOpen, Portal, bind } = usePortal({
101 | // style(portal, clickedElement) {
102 | // const { x, y, height, width } = clickedElement.getBoundingClientRect()
103 | // portal.style.cssText = `
104 | // position: absolute;
105 | // left: ${x}px;
106 | // top: ${y + height + 8}px;
107 | // background: blue;
108 | // width: ${width}px;
109 | // `
110 | // return portal
111 | // },
112 | // })
113 | // return Example 3
114 | // }
115 |
116 | function App() {
117 | return (
118 | <>
119 |
120 |
121 |
122 | >
123 | )
124 | }
125 |
126 | render( , document.getElementById('root'))
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 |
3 | module.exports = {
4 | rootDir: process.cwd(),
5 | coverageDirectory: '/.coverage',
6 | globals: {
7 | __DEV__: true,
8 | },
9 | collectCoverageFrom: [
10 | 'src/**/*.{js,jsx,ts,tsx}',
11 | '!src/**/*.d.ts',
12 | '!src/**/*.test.*',
13 | '!src/test/**/*.*',
14 | ],
15 | // setupFilesAfterEnv: [path.join(__dirname, './setupTests.ts')],
16 | testMatch: [
17 | '/**/?(*.)(spec|test).ts?(x)',
18 | ],
19 | testEnvironment: 'node',
20 | testURL: 'http://localhost',
21 | transform: {
22 | '^.+\\.(ts|tsx)$': 'ts-jest',
23 | },
24 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'],
25 | // testPathIgnorePatterns: ['/src/__tests__/test-utils.tsx'],
26 | moduleNameMapper: {
27 | '^react-native$': 'react-native-web',
28 | },
29 | moduleFileExtensions: [
30 | 'web.js',
31 | 'js',
32 | 'json',
33 | 'web.jsx',
34 | 'jsx',
35 | 'ts',
36 | 'tsx',
37 | 'feature',
38 | 'csv',
39 | ],
40 | }
41 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Alex Cory
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.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-useportal",
3 | "version": "1.0.12",
4 | "homepage": "https://codesandbox.io/s/w6jp7z4pkk",
5 | "main": "dist/usePortal.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/alex-cory/react-useportal.git"
9 | },
10 | "description": "🌀 React hook for Portals",
11 | "author": "Alex Cory ",
12 | "license": "MIT",
13 | "private": false,
14 | "scripts": {
15 | "dev": "rm -rf dist && parcel examples/index.html --open",
16 | "prepublishOnly": "yarn build # runs before publish",
17 | "build": "rm -rf dist && ./node_modules/.bin/tsc --module CommonJS",
18 | "build:watch": "rm -rf dist && ./node_modules/.bin/tsc -w --module CommonJS",
19 | "test:browser": "yarn tsc && jest --env=jsdom",
20 | "test:browser:watch": "yarn tsc && jest --watch --env=jsdom",
21 | "test:server": "yarn tsc && jest --env=node",
22 | "test:server:watch": "yarn tsc && jest --watch --env=node",
23 | "test:watch": "yarn test:browser:watch && yarn test:server:watch",
24 | "test": "yarn test:browser && yarn test:server",
25 | "clean": "npm prune; yarn cache clean; rm -rf ./node_modules package-lock.json yarn.lock; yarn",
26 | "lint": "eslint ./**/*.{ts,tsx}",
27 | "lint:fix": "npm run lint -- --fix",
28 | "lint:watch": "watch 'yarn lint'"
29 | },
30 | "peerDependencies": {
31 | "react": "^16.8.6 || ^17.0.0 || ^18.0.0",
32 | "react-dom": "^16.8.6 || ^17.0.0 || ^18.0.0"
33 | },
34 | "dependencies": {
35 | "use-ssr": "^1.0.25"
36 | },
37 | "devDependencies": {
38 | "@testing-library/react-hooks": "^3.0.0",
39 | "@types/jest": "^24.0.18",
40 | "@types/react": "^16.9.2",
41 | "@types/react-dom": "^16.8.4",
42 | "@typescript-eslint/eslint-plugin": "^2.2.0",
43 | "@typescript-eslint/parser": "^2.2.0",
44 | "eslint": "^6.3.0",
45 | "eslint-plugin-jest": "^23.0.0",
46 | "eslint-plugin-prettier": "^3.1.0",
47 | "eslint-plugin-react": "^7.14.3",
48 | "jest": "^24.7.1",
49 | "parcel-bundler": "^1.12.3",
50 | "prettier": "^1.18.2",
51 | "react": "^16.8.6",
52 | "react-dom": "^16.8.6",
53 | "react-test-renderer": "^16.8.6",
54 | "react-testing-library": "^8.0.0",
55 | "ts-jest": "^24.0.0",
56 | "typescript": "^3.4.5"
57 | },
58 | "files": [
59 | "dist"
60 | ],
61 | "keywords": [
62 | "react",
63 | "hook",
64 | "use",
65 | "portal",
66 | "react-hook",
67 | "react-component",
68 | "modal",
69 | "lightbox",
70 | "tooltip",
71 | "notification",
72 | "react-portal",
73 | "react-useportal",
74 | "react-use-portal",
75 | "transportation",
76 | "react portal hook"
77 | ]
78 | }
79 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [ "es2017", "dom" ],
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "target": "es5",
7 | "strict": true,
8 | "outDir": "dist",
9 | "declaration": true,
10 | "sourceMap": true,
11 | "inlineSources": true,
12 | "jsx": "react",
13 | "esModuleInterop": true,
14 | "types": [
15 | "jest"
16 | ]
17 | },
18 | "include": [
19 | "usePortal.ts",
20 | ],
21 | "exclude": ["node_modules"]
22 | }
23 |
--------------------------------------------------------------------------------
/usePortal.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamthesiz/react-useportal/64dc7dcb0ecd6a9a381fcf4c7ccb871f5b171ebf/usePortal.gif
--------------------------------------------------------------------------------
/usePortal.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react-hooks'
2 | import usePortal, { errorMessage1 } from './usePortal'
3 |
4 | describe('usePortal', () => {
5 | it('should not be open', () => {
6 | const { result } = renderHook(() => usePortal())
7 | const { isOpen } = result.current
8 | expect(isOpen).toBe(false)
9 | })
10 |
11 | it('should error if no event is passed and no ref is set', () => {
12 | const { result } = renderHook(() => usePortal())
13 | try {
14 | result.current.openPortal()
15 | } catch(err) {
16 | expect(err.message).toBe(errorMessage1)
17 | }
18 | })
19 |
20 | it('does not error if programmatically opening the portal', () => {
21 | const { result } = renderHook(() => usePortal({ programmaticallyOpen: true }))
22 | act(() => {
23 | expect(result.current.openPortal).not.toThrow()
24 | });
25 | })
26 | })
--------------------------------------------------------------------------------
/usePortal.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect, useCallback, useMemo, ReactNode, DOMAttributes, SyntheticEvent, MutableRefObject, MouseEvent } from 'react'
2 | import { createPortal, findDOMNode } from 'react-dom'
3 | import useSSR from 'use-ssr'
4 |
5 | type HTMLElRef = MutableRefObject
6 | type CustomEvent = {
7 | event?: SyntheticEvent
8 | portal: HTMLElRef
9 | targetEl: HTMLElRef
10 | } & SyntheticEvent
11 |
12 | type CustomEventHandler = (customEvent: CustomEvent) => void
13 | type CustomEventHandlers = {
14 | [K in keyof DOMAttributes]?: CustomEventHandler
15 | }
16 |
17 | type EventListenerMap = { [K in keyof DOMAttributes]: keyof GlobalEventHandlersEventMap }
18 | type EventListenersRef = MutableRefObject<{
19 | [K in keyof DOMAttributes]?: (event: SyntheticEvent) => void
20 | }>
21 |
22 | export type UsePortalOptions = {
23 | closeOnOutsideClick?: boolean
24 | closeOnEsc?: boolean
25 | bindTo?: HTMLElement // attach the portal to this node in the DOM
26 | isOpen?: boolean
27 | onOpen?: CustomEventHandler
28 | onClose?: CustomEventHandler
29 | onPortalClick?: CustomEventHandler
30 | programmaticallyOpen?: boolean
31 | } & CustomEventHandlers
32 |
33 | type UsePortalObjectReturn = {} // TODO
34 | type UsePortalArrayReturn = [] // TODO
35 |
36 | export const errorMessage1 = 'You must either add a `ref` to the element you are interacting with or pass an `event` to openPortal(e) or togglePortal(e) when the `programmaticallyOpen` option is not set to `true`.'
37 |
38 | export default function usePortal({
39 | closeOnOutsideClick = true,
40 | closeOnEsc = true,
41 | bindTo, // attach the portal to this node in the DOM
42 | isOpen: defaultIsOpen = false,
43 | onOpen,
44 | onClose,
45 | onPortalClick,
46 | programmaticallyOpen = false,
47 | ...eventHandlers
48 | }: UsePortalOptions = {}): any {
49 | const { isServer, isBrowser } = useSSR()
50 | const [isOpen, makeOpen] = useState(defaultIsOpen)
51 | // we use this ref because `isOpen` is stale for handleOutsideMouseClick
52 | const open = useRef(isOpen)
53 |
54 | const setOpen = useCallback((v: boolean) => {
55 | // workaround to not have stale `isOpen` in the handleOutsideMouseClick
56 | open.current = v
57 | makeOpen(v)
58 | }, [])
59 |
60 | const targetEl = useRef() as HTMLElRef // this is the element you are clicking/hovering/whatever, to trigger opening the portal
61 | const portal = useRef(isBrowser ? document.createElement('div') : null) as HTMLElRef
62 |
63 | useEffect(() => {
64 | if (isBrowser && !portal.current) portal.current = document.createElement('div')
65 | }, [isBrowser, portal])
66 |
67 | const elToMountTo = useMemo(() => {
68 | if (isServer) return
69 | return (bindTo && findDOMNode(bindTo)) || document.body
70 | }, [isServer, bindTo])
71 |
72 | const createCustomEvent = (e: any) => {
73 | if (!e) return { portal, targetEl, event: e }
74 | const event = e || {}
75 | if (event.persist) event.persist()
76 | event.portal = portal
77 | event.targetEl = targetEl
78 | event.event = e
79 | const { currentTarget } = e
80 | if (!targetEl.current && currentTarget && currentTarget !== document) targetEl.current = event.currentTarget
81 | return event
82 | }
83 |
84 | // this should handle all eventHandlers like onClick, onMouseOver, etc. passed into the config
85 | const customEventHandlers: CustomEventHandlers = Object
86 | .entries(eventHandlers)
87 | .reduce((acc, [handlerName, eventHandler]) => {
88 | acc[handlerName] = (event?: SyntheticEvent) => {
89 | if (isServer) return
90 | eventHandler(createCustomEvent(event))
91 | }
92 | return acc
93 | }, {})
94 |
95 | const openPortal = useCallback((e: any) => {
96 | if (isServer) return
97 | const customEvent = createCustomEvent(e)
98 | // for some reason, when we don't have the event argument, there
99 | // is a weird race condition. Would like to see if we can remove
100 | // setTimeout, but for now this works
101 | if (targetEl.current == null && !programmaticallyOpen) {
102 | setTimeout(() => setOpen(true), 0)
103 | throw Error(errorMessage1)
104 | }
105 | if (onOpen) onOpen(customEvent)
106 | setOpen(true)
107 | }, [isServer, portal, setOpen, targetEl, onOpen])
108 |
109 | const closePortal = useCallback((e: any) => {
110 | if (isServer) return
111 | const customEvent = createCustomEvent(e)
112 | if (onClose && open.current) onClose(customEvent)
113 | if (open.current) setOpen(false)
114 | }, [isServer, onClose, setOpen])
115 |
116 | const togglePortal = useCallback((e: SyntheticEvent): void =>
117 | open.current ? closePortal(e) : openPortal(e),
118 | [closePortal, openPortal]
119 | )
120 |
121 | const handleKeydown = useCallback((e: KeyboardEvent): void =>
122 | (e.key === 'Escape' && closeOnEsc) ? closePortal(e) : undefined,
123 | [closeOnEsc, closePortal]
124 | )
125 |
126 | const handleOutsideMouseClick = useCallback((e: MouseEvent): void => {
127 | const containsTarget = (target: HTMLElRef) => target.current.contains(e.target as HTMLElement)
128 | // There might not be a targetEl if the portal was opened programmatically.
129 | if (containsTarget(portal) || (e as any).button !== 0 || !open.current || (targetEl.current && containsTarget(targetEl))) return
130 | if (closeOnOutsideClick) closePortal(e)
131 | }, [isServer, closePortal, closeOnOutsideClick, portal])
132 |
133 | const handleMouseDown = useCallback((e: MouseEvent): void => {
134 | if (isServer || !(portal.current instanceof HTMLElement)) return
135 | const customEvent = createCustomEvent(e)
136 | if (portal.current.contains(customEvent.target as HTMLElement) && onPortalClick) onPortalClick(customEvent)
137 | handleOutsideMouseClick(e)
138 | }, [handleOutsideMouseClick])
139 |
140 | // used to remove the event listeners on unmount
141 | const eventListeners = useRef({}) as EventListenersRef
142 |
143 | useEffect(() => {
144 | if (isServer) return
145 | if (!(elToMountTo instanceof HTMLElement) || !(portal.current instanceof HTMLElement)) return
146 |
147 | // TODO: eventually will need to figure out a better solution for this.
148 | // Surely we can find a way to map onScroll/onWheel -> scroll/wheel better,
149 | // but for all other event handlers. For now this works.
150 | const eventHandlerMap: EventListenerMap = {
151 | onScroll: 'scroll',
152 | onWheel: 'wheel',
153 | }
154 | const node = portal.current
155 | elToMountTo.appendChild(portal.current)
156 | // handles all special case handlers. Currently only onScroll and onWheel
157 | Object.entries(eventHandlerMap).forEach(([handlerName /* onScroll */, eventListenerName /* scroll */]) => {
158 | if (!eventHandlers[handlerName as keyof EventListenerMap]) return
159 | eventListeners.current[handlerName as keyof EventListenerMap] = (e: any) => (eventHandlers[handlerName as keyof EventListenerMap] as any)(createCustomEvent(e))
160 | document.addEventListener(eventListenerName as keyof GlobalEventHandlersEventMap, eventListeners.current[handlerName as keyof EventListenerMap] as any)
161 | })
162 | document.addEventListener('keydown', handleKeydown)
163 | document.addEventListener('mousedown', handleMouseDown as any)
164 |
165 | return () => {
166 | // handles all special case handlers. Currently only onScroll and onWheel
167 | Object.entries(eventHandlerMap).forEach(([handlerName, eventListenerName]) => {
168 | if (!eventHandlers[handlerName as keyof EventListenerMap]) return
169 | document.removeEventListener(eventListenerName as keyof GlobalEventHandlersEventMap, eventListeners.current[handlerName as keyof EventListenerMap] as any)
170 | delete eventListeners.current[handlerName as keyof EventListenerMap]
171 | })
172 | document.removeEventListener('keydown', handleKeydown)
173 | document.removeEventListener('mousedown', handleMouseDown as any)
174 | elToMountTo.removeChild(node)
175 | }
176 | }, [isServer, handleOutsideMouseClick, handleKeydown, elToMountTo, portal])
177 |
178 | const Portal = useCallback(({ children }: { children: ReactNode }) => {
179 | if (portal.current != null) return createPortal(children, portal.current)
180 | return null
181 | }, [portal])
182 |
183 | return Object.assign(
184 | [openPortal, closePortal, open.current, Portal, togglePortal, targetEl, portal],
185 | {
186 | isOpen: open.current,
187 | openPortal,
188 | ref: targetEl,
189 | closePortal,
190 | togglePortal,
191 | Portal,
192 | portalRef: portal,
193 | ...customEventHandlers,
194 | bind: { // used if you want to spread all html attributes onto the target element
195 | ref: targetEl,
196 | ...customEventHandlers
197 | }
198 | }
199 | )
200 | }
201 |
--------------------------------------------------------------------------------