├── .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 | undefined 11 | 12 | 13 | 14 | 15 | 16 | undefined 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | undefined 28 | 29 | 30 | Known Vulnerabilities 31 | 32 | 33 | Known Vulnerabilities 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 | 113 | {isOpen && ( 114 | 115 |

116 | This Portal handles its own state.{' '} 117 | , 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 | 138 | 139 |

140 | This Portal handles its own state.{' '} 141 | , 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 | 207 | {isOpen && ( 208 | 209 |

210 | This Portal handles its own state.{' '} 211 | , 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 | 12 | {isOpen && ( 13 | 14 |
15 | Cool 16 | 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 | 43 | {isFirstOpen && ( 44 | 45 | I'm First. 46 | 54 | 55 | )} 56 | {isSecondOpen && ( 57 | 58 | I'm Second 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 | 84 | {isOpen && ( 85 | 86 |
87 | This portal was opened via a state change 88 | 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 | --------------------------------------------------------------------------------