├── .babelrc.js ├── .eslintrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ └── deploy-docs.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── codecov.yml ├── karma.conf.js ├── package.json ├── renovate.json ├── src ├── Dropdown.tsx ├── DropdownContext.ts ├── DropdownMenu.tsx ├── DropdownToggle.tsx ├── Modal.tsx ├── ModalManager.ts ├── Overlay.tsx ├── Portal.tsx ├── index.ts ├── isOverflowing.ts ├── manageAriaHidden.ts ├── mergeOptionsWithPopperConfig.ts ├── ownerDocument.ts ├── popper.ts ├── safeFindDOMNode.ts ├── types.d.ts ├── usePopper.ts ├── useRootClose.ts └── useWaitForDOMRef.ts ├── test ├── .eslintrc ├── DropdownSpec.js ├── ModalManagerSpec.js ├── ModalSpec.js ├── PortalSpec.js ├── WaitForContainerSpec.js ├── helpers.js ├── index.js ├── usePopperSpec.js └── useRootCloseSpec.js ├── tsconfig.json ├── www ├── .eslintrc ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── gatsby-ssr.js ├── package.json ├── src │ ├── @docpocalypse │ │ └── gatsby-theme │ │ │ ├── components │ │ │ └── SideNavigation.tsx │ │ │ └── syntax-theme.js │ ├── code-examples │ │ ├── .eslintrc │ │ ├── .prettierrc │ │ ├── Dropdown.js │ │ ├── Modal.js │ │ ├── Overlay.js │ │ ├── Portal.js │ │ ├── Transition.js │ │ └── useRootClose.js │ ├── css.js │ ├── examples │ │ ├── .prettierrc │ │ ├── Dropdown.mdx │ │ ├── Modal.mdx │ │ ├── Overlay.mdx │ │ ├── Portal.mdx │ │ ├── useDropdownMenu.mdx │ │ ├── useDropdownToggle.mdx │ │ └── useRootClose.mdx │ ├── injectCss.js │ ├── pages │ │ ├── index.mdx │ │ └── transitions.mdx │ └── styles.css ├── tailwind.config.js ├── tsconfig.json └── yarn.lock └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => ({ 2 | presets: [ 3 | [ 4 | '@babel/env', 5 | { 6 | loose: true, 7 | modules: api.env() === 'esm' ? false : 'commonjs', 8 | targets: { 9 | browsers: ['last 4 versions', 'not ie <= 8'], 10 | }, 11 | }, 12 | ], 13 | '@babel/react', 14 | '@babel/preset-typescript', 15 | ], 16 | plugins: [ 17 | ['@babel/plugin-proposal-class-properties', { loose: true }], 18 | ['@babel/plugin-transform-runtime', { useESModules: api.env() === 'esm' }], 19 | api.env() !== 'esm' && 'add-module-exports', 20 | ].filter(Boolean), 21 | 22 | env: { 23 | test: { 24 | plugins: ['istanbul'], 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@react-bootstrap", "4catalyzer-typescript", "prettier"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "jsx-a11y/no-autofocus": "off", 6 | "prettier/prettier": "error", 7 | "import/extensions": "off", 8 | "@typescript-eslint/no-empty-function": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.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: '' 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Reproducible Example 23 | 24 | Minimal example by using [CodeSandbox](https://codesandbox.io/s/github/react-bootstrap/code-sandbox-examples/tree/master/basic). 25 | 26 | ## Expected behavior 27 | 28 | A clear and concise description of what you expected to happen. 29 | 30 | ## Screenshots 31 | 32 | If applicable, add screenshots to help explain your problem. 33 | 34 | ## Environment (please complete the following information) 35 | 36 | - Operating System: [e.g. macOS] 37 | - Browser, Version [e.g. Chrome 74] 38 | - react-overlays Version [e.g. 2.0.0] 39 | 40 | ## Additional context 41 | 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## Is your feature request related to a problem? Please describe 10 | 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | 15 | A clear and concise description of what you want to happen. 16 | 17 | ## Describe alternatives you've considered 18 | 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | ## Additional context 22 | 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | jobs: 10 | ci: 11 | name: CI 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | browser: [ChromeHeadless, FirefoxHeadless] 16 | os: [ubuntu-latest, windows-latest, macOS-latest] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | - name: Setup Node.js environment 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: 12.x 24 | - name: Update Brew (macOS) 25 | if: matrix.os == 'macOS-latest' 26 | run: brew update 27 | - name: Install Chrome (macOS) 28 | if: matrix.os == 'macOS-latest' && matrix.browser == 'ChromeHeadless' 29 | run: brew install --cask google-chrome 30 | - name: Install Firefox (macOS) 31 | if: matrix.os == 'macOS-latest' && matrix.browser == 'FirefoxHeadless' 32 | run: brew install --cask firefox 33 | - name: Install Dependencies 34 | run: yarn bootstrap 35 | - name: Run Tests 36 | run: yarn test 37 | env: 38 | BROWSER: ${{ matrix.browser }} 39 | - name: Codecov 40 | uses: codecov/codecov-action@v1 41 | - name: Build Project 42 | run: yarn build 43 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | on: 3 | release: 4 | jobs: 5 | build_docs: 6 | name: Build and Deploy Documentation 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checking out Repository 10 | uses: actions/checkout@v2 11 | - name: Setup Node.js 12 | uses: actions/setup-node@v2 13 | with: 14 | node-version: 12.x 15 | - name: Install Dependencies 16 | run: yarn bootstrap 17 | - name: Build and Deploy Production Files 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | run: yarn deploy-docs 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | .cache/ 3 | www/public 4 | 5 | # Logs 6 | logs 7 | *.log 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 31 | node_modules 32 | 33 | # Build artifacts 34 | /examples/static 35 | /lib 36 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [5.2.1](https://github.com/react-bootstrap/react-overlays/compare/v5.2.0...v5.2.1) (2022-09-16) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **usePopper:** fix exported Modifier type ([#1013](https://github.com/react-bootstrap/react-overlays/issues/1013)) ([96b3425](https://github.com/react-bootstrap/react-overlays/commit/96b34251baf534d146a6cf84bb3f20dda451db5b)) 7 | 8 | 9 | 10 | 11 | 12 | # [5.2.0](https://github.com/react-bootstrap/react-overlays/compare/v5.1.2...v5.2.0) (2022-06-02) 13 | 14 | 15 | ### Features 16 | 17 | * **useRootClose:** add support for open shadow roots ([#1004](https://github.com/react-bootstrap/react-overlays/issues/1004)) ([3b7fb53](https://github.com/react-bootstrap/react-overlays/commit/3b7fb53d4b2141bbffa476608dfa1c0f694fa429)) 18 | 19 | 20 | 21 | 22 | 23 | ## [5.1.2](https://github.com/react-bootstrap/react-overlays/compare/v5.1.1...v5.1.2) (2022-05-10) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * update @restart/hooks to 0.4.7 ([#999](https://github.com/react-bootstrap/react-overlays/issues/999)) ([82d9bf9](https://github.com/react-bootstrap/react-overlays/commit/82d9bf9ffe6c6055d4a1f31c74822f82bb24b3b2)) 29 | 30 | 31 | 32 | 33 | 34 | ## [5.1.1](https://github.com/react-bootstrap/react-overlays/compare/v5.1.0...v5.1.1) (2021-07-11) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * **types:** fixed TransitionCallbacks interface to reflect react-transition-group args ([#962](https://github.com/react-bootstrap/react-overlays/issues/962)) ([282161c](https://github.com/react-bootstrap/react-overlays/commit/282161c74ddc129668cc7e536cce13e36ecf62de)) 40 | 41 | 42 | 43 | 44 | 45 | # [5.1.0](https://github.com/react-bootstrap/react-overlays/compare/v5.0.0...v5.1.0) (2021-06-25) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * **Dropdown:** add checking if ref exists on dropdown ([#958](https://github.com/react-bootstrap/react-overlays/issues/958)) ([b0363c0](https://github.com/react-bootstrap/react-overlays/commit/b0363c0b2350a842f13e5ab38e1e33490e1c5d70)) 51 | * **Dropdown:** avoid calling onToggle when tabbing if menu ref not set ([#959](https://github.com/react-bootstrap/react-overlays/issues/959)) ([1c23c7d](https://github.com/react-bootstrap/react-overlays/commit/1c23c7d03a4bb3edaed55acc6abecd995b67ae0a)) 52 | * allow internal tabbing in dropdown menu ([#939](https://github.com/react-bootstrap/react-overlays/issues/939)) ([30fb517](https://github.com/react-bootstrap/react-overlays/commit/30fb51753e7f763029fb75ddd4bc7333de8bc4e0)) 53 | 54 | 55 | ### Features 56 | 57 | * **Modal:** split modal types for ease of export ([#956](https://github.com/react-bootstrap/react-overlays/issues/956)) ([849ab56](https://github.com/react-bootstrap/react-overlays/commit/849ab56b181d16215ce850ea1e13c3859c40fba3)) 58 | 59 | 60 | 61 | 62 | 63 | ## [5.0.1](https://github.com/react-bootstrap/react-overlays/compare/v5.0.0...v5.0.1) (2021-04-21) 64 | 65 | 66 | ### Bug Fixes 67 | 68 | * allow internal tabbing in dropdown menu ([#939](https://github.com/react-bootstrap/react-overlays/issues/939)) ([30fb517](https://github.com/react-bootstrap/react-overlays/commit/30fb51753e7f763029fb75ddd4bc7333de8bc4e0)) 69 | 70 | 71 | 72 | 73 | 74 | # [5.0.0](https://github.com/react-bootstrap/react-overlays/compare/v4.1.1...v5.0.0) (2021-03-01) 75 | 76 | 77 | * feat!: Make DropdownAPI consistent and fix keyboard handling (#843) ([3ed2d85](https://github.com/react-bootstrap/react-overlays/commit/3ed2d853180db7325ab090ebdd4b2ef1096d20b4)), closes [#843](https://github.com/react-bootstrap/react-overlays/issues/843) 78 | 79 | 80 | ### BREAKING CHANGES 81 | 82 | * Dropdown does not inject props or accept a children render function (it just works) 83 | 84 | 85 | 86 | 87 | 88 | ## [4.1.1](https://github.com/react-bootstrap/react-overlays/compare/v4.1.0...v4.1.1) (2020-10-29) 89 | 90 | 91 | ### Bug Fixes 92 | 93 | * **Popper:** Prevent duplicate ids in aria-describedby ([#883](https://github.com/react-bootstrap/react-overlays/issues/883)) ([48bb128](https://github.com/react-bootstrap/react-overlays/commit/48bb1285916b6c0b49e02f07bcf1a603b77de15e)) 94 | * root-close firing immediately in react 17 ([#880](https://github.com/react-bootstrap/react-overlays/issues/880)) ([fa8c878](https://github.com/react-bootstrap/react-overlays/commit/fa8c878daba92ff7852aa8245d08df31d9ed17ab)) 95 | 96 | 97 | 98 | 99 | 100 | # [4.1.0](https://github.com/react-bootstrap/react-overlays/compare/v4.0.0...v4.1.0) (2020-07-20) 101 | 102 | 103 | ### Bug Fixes 104 | 105 | * **deps:** bump lodash from 4.17.15 to 4.17.19 ([#840](https://github.com/react-bootstrap/react-overlays/issues/840)) ([e718175](https://github.com/react-bootstrap/react-overlays/commit/e718175eb4ea35d8bbe656d19ec0769bb2363653)) 106 | 107 | 108 | ### Features 109 | 110 | * add aria-describedby modifier for tooltips ([#842](https://github.com/react-bootstrap/react-overlays/issues/842)) ([941a5dc](https://github.com/react-bootstrap/react-overlays/commit/941a5dca181c36db891bffd5abdbd7c83759a704)) 111 | 112 | 113 | 114 | 115 | 116 | # [4.0.0](https://github.com/react-bootstrap/react-overlays/compare/v3.2.0...v4.0.0) (2020-07-10) 117 | 118 | 119 | ### Features 120 | 121 | * improve popper integration ([#837](https://github.com/react-bootstrap/react-overlays/issues/837)) ([362128f](https://github.com/react-bootstrap/react-overlays/commit/362128f66baab0bfbc611204e5de5c86fc264fb0)) 122 | 123 | 124 | ### BREAKING CHANGES 125 | 126 | * popperConfig longer accept object forms of modifiers, pass an array instead 127 | * overlay and dropdown menu injected values are different 128 | * overlay no longer triggers an update when placement change due to auto or flip placements 129 | 130 | * address feedback 131 | 132 | 133 | 134 | 135 | 136 | # [3.2.0](https://github.com/react-bootstrap/react-overlays/compare/v3.1.3...v3.2.0) (2020-05-14) 137 | 138 | 139 | ### Bug Fixes 140 | 141 | * Adding missing useEventCallback ([#804](https://github.com/react-bootstrap/react-overlays/issues/804)) ([cb7e2ab](https://github.com/react-bootstrap/react-overlays/commit/cb7e2ab299f450ef342fb572cafa355c0108eb26)) 142 | 143 | 144 | ### Features 145 | 146 | * **Modal:** Prevent onHide when keyboard event defaultPrevented is true ([#816](https://github.com/react-bootstrap/react-overlays/issues/816)) ([b4ffcec](https://github.com/react-bootstrap/react-overlays/commit/b4ffcecc7a1c25af9d2525a5704b2320e0509fb8)) 147 | 148 | 149 | 150 | 151 | 152 | ## [3.1.3](https://github.com/react-bootstrap/react-overlays/compare/v3.1.2...v3.1.3) (2020-04-22) 153 | 154 | 155 | ### Bug Fixes 156 | 157 | * **types:** fix Modal prop types ([#802](https://github.com/react-bootstrap/react-overlays/issues/802)) ([caa0531](https://github.com/react-bootstrap/react-overlays/commit/caa05316151953eb839a6fe75cb4a36064f76720)) 158 | 159 | 160 | 161 | 162 | 163 | ## [3.1.2](https://github.com/react-bootstrap/react-overlays/compare/v3.1.1...v3.1.2) (2020-04-20) 164 | 165 | 166 | ### Bug Fixes 167 | 168 | * esm imports in cjs build ([933b159](https://github.com/react-bootstrap/react-overlays/commit/933b159bb5e3e7fe99636b49c488ec249f7bd8dd)) 169 | 170 | 171 | 172 | 173 | 174 | ## [3.1.1](https://github.com/react-bootstrap/react-overlays/compare/v3.1.0...v3.1.1) (2020-04-20) 175 | 176 | 177 | ### Bug Fixes 178 | 179 | * **types:** onHide is optional ([#800](https://github.com/react-bootstrap/react-overlays/issues/800)) ([bdc10d1](https://github.com/react-bootstrap/react-overlays/commit/bdc10d12d2020ef9430a07cf959018fa4d2ce926)) 180 | 181 | 182 | 183 | 184 | 185 | # [3.1.0](https://github.com/react-bootstrap/react-overlays/compare/v3.0.1...v3.1.0) (2020-04-20) 186 | 187 | 188 | ### Bug Fixes 189 | 190 | * build output ([#799](https://github.com/react-bootstrap/react-overlays/issues/799)) ([8bf269e](https://github.com/react-bootstrap/react-overlays/commit/8bf269e42e8a3df249dd8cbff736874b6a23f35c)) 191 | 192 | 193 | ### Features 194 | 195 | * typescript and docs ([#794](https://github.com/react-bootstrap/react-overlays/issues/794)) ([9c9b441](https://github.com/react-bootstrap/react-overlays/commit/9c9b44144e3c2899b77335546c7484bbf5469b3e)) 196 | 197 | 198 | 199 | 200 | 201 | ## [3.0.1](https://github.com/react-bootstrap/react-overlays/compare/v3.0.0...v3.0.1) (2020-03-16) 202 | 203 | 204 | ### Bug Fixes 205 | 206 | * popper imports for common js ([#781](https://github.com/react-bootstrap/react-overlays/issues/781)) ([c29dc76](https://github.com/react-bootstrap/react-overlays/commit/c29dc76f66c7eb74f5123ca9d5f531918ea7134a)) 207 | * typo ([#777](https://github.com/react-bootstrap/react-overlays/issues/777)) ([edd278b](https://github.com/react-bootstrap/react-overlays/commit/edd278b585106729cfef2ed556cc4a9d97077b0d)) 208 | 209 | 210 | 211 | 212 | 213 | # [3.0.0](https://github.com/react-bootstrap/react-overlays/compare/v2.1.1...v3.0.0) (2020-02-19) 214 | 215 | 216 | ### Features 217 | 218 | * upgrade popper to v2 ([#766](https://github.com/react-bootstrap/react-overlays/issues/766)) ([02c8e6d](https://github.com/react-bootstrap/react-overlays/commit/02c8e6dc3c2a692de1a87b7c985f82a08a4e8594)) 219 | 220 | 221 | ### BREAKING CHANGES 222 | 223 | * popper upgrade to v2, slightly different modifiers format now 224 | 225 | 226 | 227 | 228 | 229 | ## [2.1.1](https://github.com/react-bootstrap/react-overlays/compare/v2.1.0...v2.1.1) (2020-02-03) 230 | 231 | 232 | ### Bug Fixes 233 | 234 | * Compute scrollbar size dynamically on need ([#297](https://github.com/react-bootstrap/react-overlays/issues/297)) ([e022e9c](https://github.com/react-bootstrap/react-overlays/commit/e022e9c2b66fb7a0f8ef0c172a1b12c986dbcc75)) 235 | 236 | 237 | 238 | 239 | 240 | # [2.1.0](https://github.com/react-bootstrap/react-overlays/compare/v2.0.0...v2.1.0) (2019-10-11) 241 | 242 | 243 | ### Bug Fixes 244 | 245 | * **github:** correct name of bug report template ([30ab079](https://github.com/react-bootstrap/react-overlays/commit/30ab079)) 246 | 247 | 248 | ### Features 249 | 250 | * **github:** add issue templates ([4fc4e52](https://github.com/react-bootstrap/react-overlays/commit/4fc4e52)) 251 | 252 | 253 | 254 | 255 | 256 | # [2.0.0](https://github.com/react-bootstrap/react-overlays/compare/v2.0.0-1...v2.0.0) (2019-10-03) 257 | 258 | > No changes from 2.0.0-1 259 | 260 | # [2.0.0-1](https://github.com/react-bootstrap/react-overlays/compare/v2.0.0-0...v2.0.0-1) (2019-09-18) 261 | 262 | ### Bug Fixes 263 | 264 | - mismatch between Modal.dialog and hideSiblings parameters ([#389](https://github.com/react-bootstrap/react-overlays/issues/389)) ([202c96b](https://github.com/react-bootstrap/react-overlays/commit/202c96ba970c87394f245fcef914e94bd8a59a8b)) 265 | - **ci:** add git attributes that prevents formatter from failing ([b343193](https://github.com/react-bootstrap/react-overlays/commit/b343193fb30e70cc276ec979e75aafc3001c24b2)) 266 | - **ci:** add safeguard for running CodeCov on non-linux container ([b29b594](https://github.com/react-bootstrap/react-overlays/commit/b29b5941fca3a367f68e0ae044b4e9a2e5b1022e)) 267 | - **ci:** container action can only be run on linux containers fix ([c0be34e](https://github.com/react-bootstrap/react-overlays/commit/c0be34e1560384d4c10b390999840fe86e2c8e14)) 268 | - **ci:** merge upload test coverage step into main testing job ([0671fa8](https://github.com/react-bootstrap/react-overlays/commit/0671fa828b1e8eeeecb0c098883b4e7339b93a17)) 269 | - **ci:** missing import due to not installing parent package ([93059c8](https://github.com/react-bootstrap/react-overlays/commit/93059c89b090dc710c544d03d678d01d1c89205a)) 270 | - **ci:** potential fix for linting issue on windows ci build ([a54f2b6](https://github.com/react-bootstrap/react-overlays/commit/a54f2b6931c6b7499bdc599208de57ca636014bb)) 271 | - **ci:** remove macOS CI build ([b2f7225](https://github.com/react-bootstrap/react-overlays/commit/b2f7225cb74f1b0fd3e1a3a77ba016ffa615d539)) 272 | - **ci:** run upload test results as a separate job ([d601e7e](https://github.com/react-bootstrap/react-overlays/commit/d601e7eb590cc16b0187b6380949df305b1a210b)) 273 | - **docs:** compilation issues when building production files ([#412](https://github.com/react-bootstrap/react-overlays/issues/412)) ([3867655](https://github.com/react-bootstrap/react-overlays/commit/3867655aa5f61c053cdc7c682cdf8e893af6b232)) 274 | - **readme:** correct badge link ([85ad01e](https://github.com/react-bootstrap/react-overlays/commit/85ad01e6550cdcd4da179a497bdcf3ab26f2d80a)) 275 | - **readme:** incorrect naming for github actions badge ([e4b47b8](https://github.com/react-bootstrap/react-overlays/commit/e4b47b83fb57bd23b993c5f3c7efc33a1eb955a7)) 276 | 277 | ### Features 278 | 279 | - **ci:** only trigger test build on pushes and prs to master ([723f91e](https://github.com/react-bootstrap/react-overlays/commit/723f91eff173da54a4668663ed1aba8ee885389e)) 280 | 281 | # [2.0.0-0](https://github.com/react-bootstrap/react-overlays/compare/v1.2.0...v2.0.0-0) (2019-08-17) 282 | 283 | ### Bug Fixes 284 | 285 | - cleanup ([d0d95b2](https://github.com/react-bootstrap/react-overlays/commit/d0d95b20037e0d6b93eba603e9d78980671bc3c3)) 286 | - disabled mocha/no-mocha-arrows ([a5ed2eb](https://github.com/react-bootstrap/react-overlays/commit/a5ed2ebfdceb52d1d0363e6609984726f697a561)) 287 | - eslint ([1ac8079](https://github.com/react-bootstrap/react-overlays/commit/1ac8079813d2ca1ce8569e99bd5cc0eba3e464f7)) 288 | - Make sure menu ref is set before using contains ([#291](https://github.com/react-bootstrap/react-overlays/issues/291)) ([8caa423](https://github.com/react-bootstrap/react-overlays/commit/8caa4236ac6d016676130d4a3258e77a3b04bd9b)) 289 | - ModalManager typo ([1a25b5a](https://github.com/react-bootstrap/react-overlays/commit/1a25b5a3cbdcea887949ca05e430b68944407502)) 290 | 291 | ### Features 292 | 293 | - Hooks and API simplifications ([#288](https://github.com/react-bootstrap/react-overlays/issues/288)) ([9f98306](https://github.com/react-bootstrap/react-overlays/commit/9f9830696178605b4e729bf15cf949140bf8c197)) 294 | - Remove affix support ([#287](https://github.com/react-bootstrap/react-overlays/issues/287)) ([cae4df8](https://github.com/react-bootstrap/react-overlays/commit/cae4df8b8762d491abbcd868df9b1b44f6bb9c5c)) 295 | - usePopper ([#299](https://github.com/react-bootstrap/react-overlays/issues/299)) ([bb5c51f](https://github.com/react-bootstrap/react-overlays/commit/bb5c51f2993aae28a41d7bdb3ef80a96ce1f4be3)) 296 | 297 | ### BREAKING CHANGES 298 | 299 | - removes RootCloseWrapper for useRootClose 300 | 301 | # [1.2.0](https://github.com/react-bootstrap/react-overlays/compare/v1.1.2...v1.2.0) (2019-03-07) 302 | 303 | ### Features 304 | 305 | - allow configurable menu focus behavior ([#286](https://github.com/react-bootstrap/react-overlays/issues/286)) ([76a63d4](https://github.com/react-bootstrap/react-overlays/commit/76a63d4)) 306 | 307 | ## [1.1.2](https://github.com/react-bootstrap/react-overlays/compare/v1.1.1...v1.1.2) (2019-02-13) 308 | 309 | ### Bug Fixes 310 | 311 | - modal focus ([#277](https://github.com/react-bootstrap/react-overlays/issues/277)) ([d348903](https://github.com/react-bootstrap/react-overlays/commit/d348903)) 312 | 313 | ## [1.1.1](https://github.com/react-bootstrap/react-overlays/compare/v1.1.0...v1.1.1) (2019-01-04) 314 | 315 | ### Bug Fixes 316 | 317 | - release config ([31a379a](https://github.com/react-bootstrap/react-overlays/commit/31a379a)) 318 | 319 | # [1.1.0](https://github.com/react-bootstrap/react-overlays/compare/v1.0.0...v1.1.0) (2019-01-03) 320 | 321 | ### Bug Fixes 322 | 323 | - add uncontrollable to deps ([#276](https://github.com/react-bootstrap/react-overlays/issues/276)) ([e07cf96](https://github.com/react-bootstrap/react-overlays/commit/e07cf96)), closes [#262](https://github.com/react-bootstrap/react-overlays/issues/262) 324 | 325 | ### Features 326 | 327 | - add RootCloseWrapper's disabled support to Overlay ([#273](https://github.com/react-bootstrap/react-overlays/issues/273)) ([2e316d1](https://github.com/react-bootstrap/react-overlays/commit/2e316d1)) 328 | 329 | ## [v0.8.3] 330 | 331 | > 2017-10-24 332 | 333 | - **Bugfix:** Support React v16 portal API ([#208]) 334 | - **Bugfix:** Only call `onRendered` in `` on initial render ([#218]) 335 | - **Bugfix:** Use more robust method of getting `` dialog element ([#220]) 336 | - **Bugfix:** Remove broken `getOverlayDOMNode` from `` ([#222]) 337 | 338 | [v0.8.3]: https://github.com/react-bootstrap/react-overlays/compare/v0.8.2...v0.8.3 339 | [#208]: https://github.com/react-bootstrap/react-overlays/pull/208 340 | [#218]: https://github.com/react-bootstrap/react-overlays/pull/218 341 | [#220]: https://github.com/react-bootstrap/react-overlays/pull/220 342 | [#222]: https://github.com/react-bootstrap/react-overlays/pull/222 343 | 344 | ## [v0.8.2] 345 | 346 | > 2017-10-06 347 | 348 | - **Bugfix:** Fix detecting escape keyboard event on IE ([#211]) 349 | 350 | [v0.8.2]: https://github.com/react-bootstrap/react-overlays/compare/v0.8.1...v0.8.2 351 | [#211]: https://github.com/react-bootstrap/react-overlays/pull/211 352 | 353 | ## [v0.8.1] 354 | 355 | > 2017-09-13 356 | 357 | - **Bugfix:** Use `keydown` instead of incorrect `keyup` for `` close keyboard event ([#195]) 358 | 359 | [v0.8.1]: https://github.com/react-bootstrap/react-overlays/compare/v0.8.0...v0.8.1 360 | [#195]: https://github.com/react-bootstrap/react-overlays/pull/195 361 | 362 | ## [v0.8.0] 363 | 364 | > 2017-07-03 365 | 366 | - **Feature:** Remove `` and depend on react-transition-group@2.0.0 ([#184]) 367 | 368 | [v0.8.0]: https://github.com/react-bootstrap/react-overlays/compare/v0.7.0...v0.8.0 369 | [#184]: https://github.com/react-bootstrap/react-overlays/pull/184 370 | 371 | ## [v0.7.0] 372 | 373 | > 2017-04-22 374 | 375 | - **Chore:** Update dependencies to avoid React deprecation warnings 376 | - **Chore:** Use function refs ([#159]) 377 | 378 | [v0.7.0]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.12...v0.7.0 379 | [#159]: https://github.com/react-bootstrap/react-overlays/pull/159 380 | 381 | ## [v0.6.12] 382 | 383 | > 2017-03-06 384 | 385 | - **Feature:** Add `mountOnEnter` to `` ([#144]) 386 | - **Feature:** Add `restoreFocus` to `` ([#145]) 387 | 388 | [v0.6.12]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.11...v0.6.12 389 | [#144]: https://github.com/react-bootstrap/react-overlays/pull/144 390 | [#145]: https://github.com/react-bootstrap/react-overlays/pull/145 391 | 392 | ## [v0.6.11] 393 | 394 | > 2017-02-13 395 | 396 | - **Feature:** Allow accessibility attributes on `` root element ([#114]) 397 | - **Feature:** Expose triggering event in `onRootClose` callback ([#142]) 398 | 399 | [v0.6.11]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.10...v0.6.11 400 | [#114]: https://github.com/react-bootstrap/react-overlays/pull/114 401 | [#142]: https://github.com/react-bootstrap/react-overlays/pull/142 402 | 403 | ## [v0.6.10] 404 | 405 | > 2016-10-03 406 | 407 | - **Bugfix:** Don't fire `onRootClose` in capture phase to avoid race conditions with React events ([#118]) 408 | 409 | [v0.6.10]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.9...v0.6.10 410 | [#118]: https://github.com/react-bootstrap/react-overlays/pull/118 411 | 412 | ## [v0.6.9] 413 | 414 | > 2016-10-01 415 | 416 | - **Bugfix:** Don't spuriously trigger `onRootClose` when React event handler unmounts event target ([#117]) 417 | 418 | [v0.6.9]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.8...v0.6.9 419 | [#117]: https://github.com/react-bootstrap/react-overlays/pull/117 420 | 421 | ## [v0.6.8] 422 | 423 | > 2016-09-30 424 | 425 | - **Feature:** Remove wrapping DOM element in `` ([#116]) 426 | - **Bugfix:** Do not bind listeners for `` when `disabled` is set ([#116]) 427 | 428 | [v0.6.8]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.7...v0.6.8 429 | [#116]: https://github.com/react-bootstrap/react-overlays/pull/116 430 | 431 | ## [v0.6.7] 432 | 433 | > 2016-09-29 434 | 435 | - **Feature:** Allow opt-out of `` container styling ([#113]) 436 | - **Feature:** Add `renderBackdrop` to `` ([#113]) 437 | 438 | [v0.6.7]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.6...v0.6.7 439 | [#113]: https://github.com/react-bootstrap/react-overlays/pull/113 440 | 441 | ## [v0.6.6] 442 | 443 | > 2016-08-01 444 | 445 | - **Bugfix:** Don't trigger PropTypes warning ([#105]) 446 | 447 | [v0.6.6]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.5...v0.6.6 448 | [#105]: https://github.com/react-bootstrap/react-overlays/pull/105 449 | 450 | ## [v0.6.5] 451 | 452 | > 2016-07-13 453 | 454 | - **Bugfix:** Make `target` on `` work like `container` ([#102]) 455 | 456 | [v0.6.5]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.4...v0.6.5 457 | [#102]: https://github.com/react-bootstrap/react-overlays/pull/102 458 | 459 | ## [v0.6.4] 460 | 461 | > 2016-07-11 462 | 463 | - **Feature:** Add `disabled` prop to `` ([#93]) 464 | - **Feature:** Add `event` prop to `` to control mouse event that triggers root close behavior ([#95]) 465 | - **Bugfix:** Fix restoring focus on closing `` ([#82]) 466 | - **Bugfix:** Do not pass unknown props to children ([#99]) 467 | - **Chore:** Upgrade to Babel 6 ([#100]) 468 | 469 | [v0.6.4]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.3...v0.6.4 470 | [#82]: https://github.com/react-bootstrap/react-overlays/pull/82 471 | [#93]: https://github.com/react-bootstrap/react-overlays/pull/93 472 | [#95]: https://github.com/react-bootstrap/react-overlays/pull/95 473 | [#99]: https://github.com/react-bootstrap/react-overlays/pull/99 474 | [#100]: https://github.com/react-bootstrap/react-overlays/pull/100 475 | 476 | ## [v0.6.3] 477 | 478 | > 2016-04-07 479 | 480 | - **Minor:** Update React peer dependency ([#76]) 481 | 482 | [v0.6.3]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.2...v0.6.3 483 | [#76]: https://github.com/react-bootstrap/react-overlays/pull/76 484 | 485 | ## [v0.6.2] 486 | 487 | > 2016-04-03 488 | 489 | - **Bugfix:** Fix unmounting `` when parent is unmounted ([#74]) 490 | 491 | [v0.6.2]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.1...v0.6.2 492 | [#74]: https://github.com/react-bootstrap/react-overlays/pull/74 493 | 494 | ## [v0.6.1] 495 | 496 | > 2016-03-28 497 | 498 | - **Bugfix:** Flush new props to DOM before initiating transitions ([#60]) 499 | - **Bugfix:** Update `` container node when `container` prop changes ([#66]) 500 | - **Bugfix:** Don't invoke close in `` on right clicks ([#69]) 501 | 502 | [v0.6.1]: https://github.com/react-bootstrap/react-overlays/compare/v0.6.0...v0.6.1 503 | [#60]: https://github.com/react-bootstrap/react-overlays/pull/60 504 | [#66]: https://github.com/react-bootstrap/react-overlays/pull/66 505 | [#69]: https://github.com/react-bootstrap/react-overlays/pull/69 506 | 507 | ## v0.6.0 - Fri, 15 Jan 2016 16:15:50 GMT 508 | 509 | - [c0b5890](../../commit/c0b5890) [fixed] Don't forward own props from 510 | - [742c3c1](../../commit/742c3c1) [fixed] Modal does not fire show callback 511 | 512 | ## v0.5.4 - Tue, 17 Nov 2015 20:03:06 GMT 513 | 514 | - [4eabbfc](../../commit/4eabbfc) [added] affix state callbacks 515 | 516 | ## v0.5.3 - Mon, 16 Nov 2015 19:52:03 GMT 517 | 518 | - [d064667](../../commit/d064667) [fixed] AutoAffix nnot passing width or updating 519 | 520 | ## v0.5.2 - Mon, 16 Nov 2015 17:32:27 GMT 521 | 522 | - [823d0f8](../../commit/823d0f8) [fixed] fix missing warning dep 523 | - [1857449](../../commit/1857449) [changed] Friendlier default for AutoAffix 524 | - [f633476](../../commit/f633476) [fixed] clean up modal styles if unmounted during exit transition 525 | 526 | ## v0.5.1 - Mon, 02 Nov 2015 16:07:44 GMT 527 | 528 | - [e965152](../../commit/e965152) [added] Affix and AutoAffix 529 | 530 | ## v0.5.0 - Wed, 07 Oct 2015 19:40:23 GMT 531 | 532 | - [044100b](../../commit/044100b) [added] React 0.14 support 533 | - [edd316a](../../commit/edd316a) [added] aria-hidden, by default, to modal container siblings. 534 | 535 | ## v0.4.4 - Mon, 24 Aug 2015 18:34:19 GMT 536 | 537 | ## v0.4.3 - Sun, 23 Aug 2015 22:54:52 GMT 538 | 539 | - [4f7823e](../../commit/4f7823e) [changed] focus target of the modal to its content 540 | 541 | ## v0.4.2 - Mon, 10 Aug 2015 19:04:31 GMT 542 | 543 | ## v0.4.1 - Tue, 04 Aug 2015 23:48:08 GMT 544 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 react-bootstrap 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | > **HEADS UP!** this repo is deprecated and not receiving any more updates. 3 | > 4 | > Don't worry though, that's because it's been succeeded by https://github.com/react-restart/ui which is built off of react-overlays by the same 5 | team. 6 | 7 | 8 | 9 | # react-overlays 10 | 11 | [![CI status][ci-badge]][actions] 12 | [![Deploy docs status][deploy-docs-badge]][actions] 13 | [![Codecov][codecov-badge]][codecov] 14 | [![Netlify Status](https://api.netlify.com/api/v1/badges/e86fa356-4480-409e-9c24-52ea0660a923/deploy-status)](https://app.netlify.com/sites/react-overlays/deploys) 15 | 16 | Utilities for creating robust overlay components. 17 | 18 | ## Documentation 19 | 20 | https://react-bootstrap.github.io/react-overlays 21 | 22 | ## Installation 23 | 24 | ```sh 25 | npm install --save react-overlays 26 | ``` 27 | 28 | ## Notes 29 | 30 | All of these utilities have been abstracted out of [React-Bootstrap](https://github.com/react-bootstrap/react-bootstrap) in order to provide better access to the generic implementations of these commonly-needed components. The included components are building blocks for creating more polished components. Everything is bring-your-own-styles, CSS or otherwise. 31 | 32 | If you are looking for more complete overlays, modals, or tooltips – something you can use out-of-the-box – check out React-Bootstrap, which is built using these components. 33 | 34 | [actions]: https://github.com/react-bootstrap/react-overlays/actions 35 | [codecov]: https://codecov.io/gh/react-bootstrap/react-overlays 36 | [codecov-badge]: https://codecov.io/gh/react-bootstrap/react-overlays/branch/master/graph/badge.svg 37 | [ci-badge]: https://github.com/react-bootstrap/react-overlays/workflows/CI/badge.svg 38 | [deploy-docs-badge]: https://github.com/react-bootstrap/react-overlays/workflows/Deploy%20Documentation/badge.svg 39 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const { rules, plugins } = require('webpack-atoms'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = (config) => { 5 | const { env } = process; 6 | 7 | config.set({ 8 | frameworks: ['mocha', 'webpack', 'sinon-chai'], 9 | 10 | files: ['test/index.js'], 11 | 12 | preprocessors: { 13 | 'test/index.js': ['webpack', 'sourcemap'], 14 | }, 15 | 16 | webpack: { 17 | mode: 'development', 18 | module: { 19 | rules: [ 20 | { 21 | ...rules.js(), 22 | test: /\.[jt]sx?$/, 23 | }, 24 | ], 25 | }, 26 | resolve: { 27 | symlinks: false, 28 | extensions: ['.mjs', '.js', '.ts', '.tsx', '.json'], 29 | fallback: { 30 | util: require.resolve('util/'), 31 | // for Enzyme/Cheerio 32 | stream: require.resolve('stream-browserify'), 33 | }, 34 | }, 35 | plugins: [ 36 | plugins.define({ 37 | 'process.env.NODE_ENV': JSON.stringify('test'), 38 | __DEV__: true, 39 | }), 40 | new webpack.ProvidePlugin({ 41 | process: 'process/browser', 42 | }), 43 | ], 44 | devtool: 'inline-cheap-module-source-map', 45 | }, 46 | 47 | reporters: ['mocha', 'coverage'], 48 | 49 | mochaReporter: { 50 | output: 'autowatch', 51 | }, 52 | 53 | coverageReporter: { 54 | type: 'lcov', 55 | dir: 'coverage', 56 | }, 57 | 58 | customLaunchers: { 59 | ChromeCi: { 60 | base: 'Chrome', 61 | flags: ['--no-sandbox'], 62 | }, 63 | }, 64 | 65 | browsers: env.BROWSER ? env.BROWSER.split(',') : ['Chrome'], 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-overlays", 3 | "version": "5.2.1", 4 | "description": "Utilities for creating robust overlay components", 5 | "author": { 6 | "name": "Jason Quense", 7 | "email": "monastic.panic@gmail.com" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/react-bootstrap/react-overlays.git" 12 | }, 13 | "license": "MIT", 14 | "main": "lib/cjs/index.js", 15 | "module": "lib/esm/index.js", 16 | "sideEffects": false, 17 | "files": [ 18 | "lib" 19 | ], 20 | "keywords": [ 21 | "react-overlays", 22 | "react-component", 23 | "react", 24 | "overlay", 25 | "react-component", 26 | "tooltip", 27 | "bootstrap", 28 | "popover", 29 | "modal" 30 | ], 31 | "scripts": { 32 | "bootstrap": "yarn --network-timeout 100000 && yarn --cwd www --network-timeout 100000", 33 | "build": "rimraf lib && 4c build src && yarn build:popper && yarn build:pick", 34 | "build:pick": "cherry-pick --cwd=lib --input-dir=../src --cjs-dir=cjs --esm-dir=esm", 35 | "build:popper": "rollup src/popper.ts --file lib/cjs/popper.js --format cjs --name popper --plugin @rollup/plugin-node-resolve", 36 | "deploy-docs": "yarn --cwd www build --prefix-paths && gh-pages -d www/public", 37 | "lint": "eslint www/*.js www/src src test *.js --ext .js,.ts,.tsx", 38 | "prepublishOnly": "yarn build", 39 | "release": "rollout", 40 | "start": "yarn --cwd www start", 41 | "tdd": "cross-env NODE_ENV=test karma start", 42 | "test": "yarn lint && yarn testonly", 43 | "testonly": "yarn tdd --single-run", 44 | "prepare": "husky install" 45 | }, 46 | "lint-staged": { 47 | "*.js,*.tsx": "eslint --fix --ext .js,.ts,.tsx" 48 | }, 49 | "prettier": { 50 | "singleQuote": true, 51 | "trailingComma": "all" 52 | }, 53 | "publishConfig": { 54 | "directory": "lib" 55 | }, 56 | "release": { 57 | "conventionalCommits": true 58 | }, 59 | "dependencies": { 60 | "@babel/runtime": "^7.13.8", 61 | "@popperjs/core": "^2.11.6", 62 | "@restart/hooks": "^0.4.7", 63 | "@types/warning": "^3.0.0", 64 | "dom-helpers": "^5.2.0", 65 | "prop-types": "^15.7.2", 66 | "uncontrollable": "^7.2.1", 67 | "warning": "^4.0.3" 68 | }, 69 | "peerDependencies": { 70 | "react": ">=16.3.0", 71 | "react-dom": ">=16.3.0" 72 | }, 73 | "devDependencies": { 74 | "@4c/cli": "^2.2.1", 75 | "@4c/rollout": "^2.2.0", 76 | "@4c/tsconfig": "^0.3.1", 77 | "@babel/cli": "^7.13.10", 78 | "@babel/core": "^7.13.10", 79 | "@babel/plugin-proposal-class-properties": "^7.13.0", 80 | "@babel/plugin-transform-runtime": "^7.13.10", 81 | "@babel/polyfill": "^7.12.1", 82 | "@babel/preset-env": "^7.13.12", 83 | "@babel/preset-react": "^7.12.13", 84 | "@babel/preset-typescript": "^7.13.0", 85 | "@react-bootstrap/eslint-config": "^1.3.2", 86 | "@rollup/plugin-node-resolve": "^11.2.0", 87 | "@types/classnames": "^2.2.11", 88 | "@types/react": "^17.0.3", 89 | "@types/react-dom": "^17.0.2", 90 | "@typescript-eslint/eslint-plugin": "^4.19.0", 91 | "@typescript-eslint/parser": "^4.19.0", 92 | "babel-eslint": "^10.1.0", 93 | "babel-plugin-add-module-exports": "^1.0.4", 94 | "babel-plugin-istanbul": "^6.0.0", 95 | "chai": "^4.3.4", 96 | "cherry-pick": "^0.5.0", 97 | "cross-env": "^7.0.3", 98 | "enzyme": "^3.11.0", 99 | "enzyme-adapter-react-16": "^1.15.6", 100 | "eslint": "^7.22.0", 101 | "eslint-config-4catalyzer-typescript": "^3.0.3", 102 | "eslint-config-prettier": "^8.1.0", 103 | "eslint-plugin-import": "^2.22.1", 104 | "eslint-plugin-jsx-a11y": "^6.4.1", 105 | "eslint-plugin-mocha": "^8.1.0", 106 | "eslint-plugin-prettier": "^3.3.1", 107 | "eslint-plugin-react": "^7.22.0", 108 | "eslint-plugin-react-hooks": "^4.2.0", 109 | "gh-pages": "^3.1.0", 110 | "husky": "^5.2.0", 111 | "jquery": "^3.6.0", 112 | "karma": "^6.2.0", 113 | "karma-chrome-launcher": "^3.1.0", 114 | "karma-coverage": "^2.0.3", 115 | "karma-firefox-launcher": "^2.1.0", 116 | "karma-mocha": "^2.0.1", 117 | "karma-mocha-reporter": "^2.2.5", 118 | "karma-sinon-chai": "^2.0.2", 119 | "karma-sourcemap-loader": "^0.3.8", 120 | "karma-webpack": "5.0.0", 121 | "lint-staged": "^10.5.4", 122 | "mocha": "^8.3.2", 123 | "prettier": "^2.2.1", 124 | "process": "^0.11.10", 125 | "react": "^16.14.0", 126 | "react-dom": "^16.14.0", 127 | "react-transition-group": "^4.4.1", 128 | "rimraf": "^3.0.2", 129 | "rollup": "^2.42.3", 130 | "simulant": "^0.2.2", 131 | "sinon": "^9.2.4", 132 | "sinon-chai": "^3.5.0", 133 | "stream-browserify": "^3.0.0", 134 | "typescript": "^4.2.3", 135 | "util": "^0.12.3", 136 | "webpack": "^5.27.2", 137 | "webpack-atoms": "^16.0.1", 138 | "webpack-cli": "^4.5.0" 139 | }, 140 | "bugs": { 141 | "url": "https://github.com/react-bootstrap/react-overlays/issues" 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>4Catalyzer/renovate-config:library"] 3 | } 4 | -------------------------------------------------------------------------------- /src/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import matches from 'dom-helpers/matches'; 2 | import qsa from 'dom-helpers/querySelectorAll'; 3 | import addEventListener from 'dom-helpers/addEventListener'; 4 | import React, { useCallback, useRef, useEffect, useMemo } from 'react'; 5 | import PropTypes from 'prop-types'; 6 | import { useUncontrolledProp } from 'uncontrollable'; 7 | import usePrevious from '@restart/hooks/usePrevious'; 8 | import useForceUpdate from '@restart/hooks/useForceUpdate'; 9 | import useGlobalListener from '@restart/hooks/useGlobalListener'; 10 | import useEventCallback from '@restart/hooks/useEventCallback'; 11 | 12 | import DropdownContext, { DropDirection } from './DropdownContext'; 13 | import DropdownMenu from './DropdownMenu'; 14 | import DropdownToggle from './DropdownToggle'; 15 | 16 | const propTypes = { 17 | /** 18 | * A render prop that returns the root dropdown element. The `props` 19 | * argument should spread through to an element containing _both_ the 20 | * menu and toggle in order to handle keyboard events for focus management. 21 | * 22 | * @type {Function ({ 23 | * props: { 24 | * onKeyDown: (SyntheticEvent) => void, 25 | * }, 26 | * }) => React.Element} 27 | */ 28 | children: PropTypes.node, 29 | 30 | /** 31 | * Determines the direction and location of the Menu in relation to it's Toggle. 32 | */ 33 | drop: PropTypes.oneOf(['up', 'left', 'right', 'down']), 34 | 35 | /** 36 | * Controls the focus behavior for when the Dropdown is opened. Set to 37 | * `true` to always focus the first menu item, `keyboard` to focus only when 38 | * navigating via the keyboard, or `false` to disable completely 39 | * 40 | * The Default behavior is `false` **unless** the Menu has a `role="menu"` 41 | * where it will default to `keyboard` to match the recommended [ARIA Authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/#menubutton). 42 | */ 43 | focusFirstItemOnShow: PropTypes.oneOf([false, true, 'keyboard']), 44 | 45 | /** 46 | * A css slector string that will return __focusable__ menu items. 47 | * Selectors should be relative to the menu component: 48 | * e.g. ` > li:not('.disabled')` 49 | */ 50 | itemSelector: PropTypes.string, 51 | 52 | /** 53 | * Align the menu to the 'end' side of the placement side of the Dropdown toggle. The default placement is `top-start` or `bottom-start`. 54 | */ 55 | alignEnd: PropTypes.bool, 56 | 57 | /** 58 | * Whether or not the Dropdown is visible. 59 | * 60 | * @controllable onToggle 61 | */ 62 | show: PropTypes.bool, 63 | 64 | /** 65 | * Sets the initial show position of the Dropdown. 66 | */ 67 | defaultShow: PropTypes.bool, 68 | 69 | /** 70 | * A callback fired when the Dropdown wishes to change visibility. Called with the requested 71 | * `show` value, the DOM event, and the source that fired it: `'click'`,`'keydown'`,`'rootClose'`, or `'select'`. 72 | * 73 | * ```ts static 74 | * function( 75 | * isOpen: boolean, 76 | * event: SyntheticEvent, 77 | * ): void 78 | * ``` 79 | * 80 | * @controllable show 81 | */ 82 | onToggle: PropTypes.func, 83 | }; 84 | 85 | export interface DropdownInjectedProps { 86 | onKeyDown: React.KeyboardEventHandler; 87 | } 88 | 89 | export interface DropdownProps { 90 | drop?: DropDirection; 91 | alignEnd?: boolean; 92 | defaultShow?: boolean; 93 | show?: boolean; 94 | onToggle: (nextShow: boolean, event?: React.SyntheticEvent | Event) => void; 95 | itemSelector?: string; 96 | focusFirstItemOnShow?: false | true | 'keyboard'; 97 | children: React.ReactNode; 98 | } 99 | 100 | function useRefWithUpdate() { 101 | const forceUpdate = useForceUpdate(); 102 | const ref = useRef(null); 103 | const attachRef = useCallback( 104 | (element: null | HTMLElement) => { 105 | ref.current = element; 106 | // ensure that a menu set triggers an update for consumers 107 | forceUpdate(); 108 | }, 109 | [forceUpdate], 110 | ); 111 | return [ref, attachRef] as const; 112 | } 113 | 114 | /** 115 | * @displayName Dropdown 116 | * @public 117 | */ 118 | function Dropdown({ 119 | drop, 120 | alignEnd, 121 | defaultShow, 122 | show: rawShow, 123 | onToggle: rawOnToggle, 124 | itemSelector = '* > *', 125 | focusFirstItemOnShow, 126 | children, 127 | }: DropdownProps) { 128 | const [show, onToggle] = useUncontrolledProp( 129 | rawShow, 130 | defaultShow!, 131 | rawOnToggle, 132 | ); 133 | 134 | // We use normal refs instead of useCallbackRef in order to populate the 135 | // the value as quickly as possible, otherwise the effect to focus the element 136 | // may run before the state value is set 137 | const [menuRef, setMenu] = useRefWithUpdate(); 138 | const menuElement = menuRef.current; 139 | 140 | const [toggleRef, setToggle] = useRefWithUpdate(); 141 | const toggleElement = toggleRef.current; 142 | 143 | const lastShow = usePrevious(show); 144 | const lastSourceEvent = useRef(null); 145 | const focusInDropdown = useRef(false); 146 | 147 | const toggle = useCallback( 148 | (nextShow: boolean, event?: Event | React.SyntheticEvent) => { 149 | onToggle(nextShow, event); 150 | }, 151 | [onToggle], 152 | ); 153 | 154 | const context = useMemo( 155 | () => ({ 156 | toggle, 157 | drop, 158 | show, 159 | alignEnd, 160 | menuElement, 161 | toggleElement, 162 | setMenu, 163 | setToggle, 164 | }), 165 | [ 166 | toggle, 167 | drop, 168 | show, 169 | alignEnd, 170 | menuElement, 171 | toggleElement, 172 | setMenu, 173 | setToggle, 174 | ], 175 | ); 176 | 177 | if (menuElement && lastShow && !show) { 178 | focusInDropdown.current = menuElement.contains(document.activeElement); 179 | } 180 | 181 | const focusToggle = useEventCallback(() => { 182 | if (toggleElement && toggleElement.focus) { 183 | toggleElement.focus(); 184 | } 185 | }); 186 | 187 | const maybeFocusFirst = useEventCallback(() => { 188 | const type = lastSourceEvent.current; 189 | let focusType = focusFirstItemOnShow; 190 | 191 | if (focusType == null) { 192 | focusType = 193 | menuRef.current && matches(menuRef.current, '[role=menu]') 194 | ? 'keyboard' 195 | : false; 196 | } 197 | 198 | if ( 199 | focusType === false || 200 | (focusType === 'keyboard' && !/^key.+$/.test(type!)) 201 | ) { 202 | return; 203 | } 204 | 205 | const first = qsa(menuRef.current!, itemSelector)[0]; 206 | if (first && first.focus) first.focus(); 207 | }); 208 | 209 | useEffect(() => { 210 | if (show) maybeFocusFirst(); 211 | else if (focusInDropdown.current) { 212 | focusInDropdown.current = false; 213 | focusToggle(); 214 | } 215 | // only `show` should be changing 216 | }, [show, focusInDropdown, focusToggle, maybeFocusFirst]); 217 | 218 | useEffect(() => { 219 | lastSourceEvent.current = null; 220 | }); 221 | 222 | const getNextFocusedChild = (current: HTMLElement, offset: number) => { 223 | if (!menuRef.current) return null; 224 | 225 | const items = qsa(menuRef.current, itemSelector); 226 | 227 | let index = items.indexOf(current) + offset; 228 | index = Math.max(0, Math.min(index, items.length)); 229 | 230 | return items[index]; 231 | }; 232 | 233 | useGlobalListener('keydown', (event: KeyboardEvent) => { 234 | const { key } = event; 235 | const target = event.target as HTMLElement; 236 | 237 | const fromMenu = menuRef.current?.contains(target); 238 | const fromToggle = toggleRef.current?.contains(target); 239 | 240 | // Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400 241 | // in inscrutability 242 | const isInput = /input|textarea/i.test(target.tagName); 243 | if (isInput && (key === ' ' || (key !== 'Escape' && fromMenu))) { 244 | return; 245 | } 246 | 247 | if (!fromMenu && !fromToggle) { 248 | return; 249 | } 250 | 251 | if (!menuRef.current && key === 'Tab') { 252 | return; 253 | } 254 | 255 | lastSourceEvent.current = event.type; 256 | 257 | switch (key) { 258 | case 'ArrowUp': { 259 | const next = getNextFocusedChild(target, -1); 260 | if (next && next.focus) next.focus(); 261 | event.preventDefault(); 262 | 263 | return; 264 | } 265 | case 'ArrowDown': 266 | event.preventDefault(); 267 | if (!show) { 268 | onToggle(true, event); 269 | } else { 270 | const next = getNextFocusedChild(target, 1); 271 | if (next && next.focus) next.focus(); 272 | } 273 | return; 274 | case 'Tab': 275 | // on keydown the target is the element being tabbed FROM, we need that 276 | // to know if this event is relevant to this dropdown (e.g. in this menu). 277 | // On `keyup` the target is the element being tagged TO which we use to check 278 | // if focus has left the menu 279 | addEventListener( 280 | document as any, 281 | 'keyup', 282 | (e) => { 283 | if ( 284 | (e.key === 'Tab' && !e.target) || 285 | !menuRef.current?.contains(e.target as HTMLElement) 286 | ) { 287 | onToggle(false, event); 288 | } 289 | }, 290 | { once: true }, 291 | ); 292 | break; 293 | case 'Escape': 294 | event.preventDefault(); 295 | event.stopPropagation(); 296 | 297 | onToggle(false, event); 298 | break; 299 | default: 300 | } 301 | }); 302 | 303 | return ( 304 | 305 | {children} 306 | 307 | ); 308 | } 309 | 310 | Dropdown.displayName = 'ReactOverlaysDropdown'; 311 | 312 | Dropdown.propTypes = propTypes; 313 | 314 | Dropdown.Menu = DropdownMenu; 315 | Dropdown.Toggle = DropdownToggle; 316 | 317 | export default Dropdown; 318 | -------------------------------------------------------------------------------- /src/DropdownContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type DropDirection = 'up' | 'down' | 'left' | 'right'; 4 | 5 | export type DropdownContextValue = { 6 | toggle: (nextShow: boolean, event?: React.SyntheticEvent | Event) => void; 7 | menuElement: HTMLElement | null; 8 | toggleElement: HTMLElement | null; 9 | setMenu: (ref: HTMLElement | null) => void; 10 | setToggle: (ref: HTMLElement | null) => void; 11 | 12 | show: boolean; 13 | alignEnd?: boolean; 14 | drop?: DropDirection; 15 | }; 16 | 17 | const DropdownContext = React.createContext(null); 18 | 19 | export default DropdownContext; 20 | -------------------------------------------------------------------------------- /src/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useContext, useRef } from 'react'; 3 | import useCallbackRef from '@restart/hooks/useCallbackRef'; 4 | import DropdownContext, { DropdownContextValue } from './DropdownContext'; 5 | import usePopper, { 6 | UsePopperOptions, 7 | Placement, 8 | Offset, 9 | UsePopperState, 10 | } from './usePopper'; 11 | import useRootClose, { RootCloseOptions } from './useRootClose'; 12 | import mergeOptionsWithPopperConfig from './mergeOptionsWithPopperConfig'; 13 | 14 | export interface UseDropdownMenuOptions { 15 | flip?: boolean; 16 | show?: boolean; 17 | fixed?: boolean; 18 | alignEnd?: boolean; 19 | usePopper?: boolean; 20 | offset?: Offset; 21 | rootCloseEvent?: RootCloseOptions['clickTrigger']; 22 | popperConfig?: Omit; 23 | } 24 | 25 | export type UserDropdownMenuProps = Record & { 26 | ref: React.RefCallback; 27 | style?: React.CSSProperties; 28 | 'aria-labelledby'?: string; 29 | }; 30 | 31 | export type UserDropdownMenuArrowProps = Record & { 32 | ref: React.RefCallback; 33 | style: React.CSSProperties; 34 | }; 35 | 36 | export interface UseDropdownMenuMetadata { 37 | show: boolean; 38 | alignEnd?: boolean; 39 | hasShown: boolean; 40 | toggle?: DropdownContextValue['toggle']; 41 | popper: UsePopperState | null; 42 | arrowProps: Partial; 43 | } 44 | 45 | const noop: any = () => {}; 46 | 47 | /** 48 | * @memberOf Dropdown 49 | * @param {object} options 50 | * @param {boolean} options.flip Automatically adjust the menu `drop` position based on viewport edge detection 51 | * @param {[number, number]} options.offset Define an offset distance between the Menu and the Toggle 52 | * @param {boolean} options.show Display the menu manually, ignored in the context of a `Dropdown` 53 | * @param {boolean} options.usePopper opt in/out of using PopperJS to position menus. When disabled you must position it yourself. 54 | * @param {string} options.rootCloseEvent The pointer event to listen for when determining "clicks outside" the menu for triggering a close. 55 | * @param {object} options.popperConfig Options passed to the [`usePopper`](/api/usePopper) hook. 56 | */ 57 | export function useDropdownMenu(options: UseDropdownMenuOptions = {}) { 58 | const context = useContext(DropdownContext); 59 | 60 | const [arrowElement, attachArrowRef] = useCallbackRef(); 61 | 62 | const hasShownRef = useRef(false); 63 | 64 | const { 65 | flip, 66 | offset, 67 | rootCloseEvent, 68 | fixed = false, 69 | popperConfig = {}, 70 | usePopper: shouldUsePopper = !!context, 71 | } = options; 72 | 73 | const show = context?.show == null ? !!options.show : context.show; 74 | const alignEnd = 75 | context?.alignEnd == null ? options.alignEnd : context.alignEnd; 76 | 77 | if (show && !hasShownRef.current) { 78 | hasShownRef.current = true; 79 | } 80 | 81 | const handleClose = (e: React.SyntheticEvent | Event) => { 82 | context?.toggle(false, e); 83 | }; 84 | 85 | const { drop, setMenu, menuElement, toggleElement } = context || {}; 86 | 87 | let placement: Placement = alignEnd ? 'bottom-end' : 'bottom-start'; 88 | if (drop === 'up') placement = alignEnd ? 'top-end' : 'top-start'; 89 | else if (drop === 'right') placement = alignEnd ? 'right-end' : 'right-start'; 90 | else if (drop === 'left') placement = alignEnd ? 'left-end' : 'left-start'; 91 | 92 | const popper = usePopper( 93 | toggleElement, 94 | menuElement, 95 | mergeOptionsWithPopperConfig({ 96 | placement, 97 | enabled: !!(shouldUsePopper && show), 98 | enableEvents: show, 99 | offset, 100 | flip, 101 | fixed, 102 | arrowElement, 103 | popperConfig, 104 | }), 105 | ); 106 | 107 | const menuProps: UserDropdownMenuProps = { 108 | ref: setMenu || noop, 109 | 'aria-labelledby': toggleElement?.id, 110 | ...popper.attributes.popper, 111 | style: popper.styles.popper as any, 112 | }; 113 | 114 | const metadata: UseDropdownMenuMetadata = { 115 | show, 116 | alignEnd, 117 | hasShown: hasShownRef.current, 118 | toggle: context?.toggle, 119 | popper: shouldUsePopper ? popper : null, 120 | arrowProps: shouldUsePopper 121 | ? { 122 | ref: attachArrowRef, 123 | ...popper.attributes.arrow, 124 | style: popper.styles.arrow as any, 125 | } 126 | : {}, 127 | }; 128 | 129 | useRootClose(menuElement, handleClose, { 130 | clickTrigger: rootCloseEvent, 131 | disabled: !show, 132 | }); 133 | 134 | return [menuProps, metadata] as const; 135 | } 136 | 137 | const propTypes = { 138 | /** 139 | * A render prop that returns a Menu element. The `props` 140 | * argument should spread through to **a component that can accept a ref**. 141 | * 142 | * @type {Function ({ 143 | * show: boolean, 144 | * alignEnd: boolean, 145 | * close: (?SyntheticEvent) => void, 146 | * placement: Placement, 147 | * update: () => void, 148 | * forceUpdate: () => void, 149 | * props: { 150 | * ref: (?HTMLElement) => void, 151 | * style: { [string]: string | number }, 152 | * aria-labelledby: ?string 153 | * }, 154 | * arrowProps: { 155 | * ref: (?HTMLElement) => void, 156 | * style: { [string]: string | number }, 157 | * }, 158 | * }) => React.Element} 159 | */ 160 | children: PropTypes.func.isRequired, 161 | 162 | /** 163 | * Controls the visible state of the menu, generally this is 164 | * provided by the parent `Dropdown` component, 165 | * but may also be specified as a prop directly. 166 | */ 167 | show: PropTypes.bool, 168 | 169 | /** 170 | * Aligns the dropdown menu to the 'end' of it's placement position. 171 | * Generally this is provided by the parent `Dropdown` component, 172 | * but may also be specified as a prop directly. 173 | */ 174 | alignEnd: PropTypes.bool, 175 | 176 | /** 177 | * Enables the Popper.js `flip` modifier, allowing the Dropdown to 178 | * automatically adjust it's placement in case of overlap with the viewport or toggle. 179 | * Refer to the [flip docs](https://popper.js.org/popper-documentation.html#modifiers..flip.enabled) for more info 180 | */ 181 | flip: PropTypes.bool, 182 | 183 | usePopper: PropTypes.oneOf([true, false]), 184 | 185 | /** 186 | * A set of popper options and props passed directly to react-popper's Popper component. 187 | */ 188 | popperConfig: PropTypes.object, 189 | 190 | /** 191 | * Override the default event used by RootCloseWrapper. 192 | */ 193 | rootCloseEvent: PropTypes.string, 194 | }; 195 | 196 | const defaultProps = { 197 | usePopper: true, 198 | }; 199 | 200 | export interface DropdownMenuProps extends UseDropdownMenuOptions { 201 | children: ( 202 | props: UserDropdownMenuProps, 203 | meta: UseDropdownMenuMetadata, 204 | ) => React.ReactNode; 205 | } 206 | 207 | /** 208 | * Also exported as `` from `Dropdown`. 209 | * 210 | * @displayName DropdownMenu 211 | * @memberOf Dropdown 212 | */ 213 | function DropdownMenu({ children, ...options }: DropdownMenuProps) { 214 | const [props, meta] = useDropdownMenu(options); 215 | 216 | return <>{meta.hasShown ? children(props, meta) : null}; 217 | } 218 | 219 | DropdownMenu.displayName = 'ReactOverlaysDropdownMenu'; 220 | 221 | DropdownMenu.propTypes = propTypes; 222 | DropdownMenu.defaultProps = defaultProps; 223 | 224 | /** @component */ 225 | export default DropdownMenu; 226 | -------------------------------------------------------------------------------- /src/DropdownToggle.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useContext, useCallback } from 'react'; 3 | import DropdownContext, { DropdownContextValue } from './DropdownContext'; 4 | 5 | export interface UseDropdownToggleProps { 6 | ref: DropdownContextValue['setToggle']; 7 | onClick: React.MouseEventHandler; 8 | 'aria-haspopup': boolean; 9 | 'aria-expanded': boolean; 10 | } 11 | 12 | export interface UseDropdownToggleMetadata { 13 | show: DropdownContextValue['show']; 14 | toggle: DropdownContextValue['toggle']; 15 | } 16 | 17 | const noop = () => {}; 18 | 19 | /** 20 | * Wires up Dropdown toggle functionality, returning a set a props to attach 21 | * to the element that functions as the dropdown toggle (generally a button). 22 | * 23 | * @memberOf Dropdown 24 | */ 25 | export function useDropdownToggle(): [ 26 | UseDropdownToggleProps, 27 | UseDropdownToggleMetadata, 28 | ] { 29 | const { show = false, toggle = noop, setToggle } = 30 | useContext(DropdownContext) || {}; 31 | const handleClick = useCallback( 32 | (e) => { 33 | toggle(!show, e); 34 | }, 35 | [show, toggle], 36 | ); 37 | 38 | return [ 39 | { 40 | ref: setToggle || noop, 41 | onClick: handleClick, 42 | 'aria-haspopup': true, 43 | 'aria-expanded': !!show, 44 | }, 45 | { show, toggle }, 46 | ]; 47 | } 48 | 49 | const propTypes = { 50 | /** 51 | * A render prop that returns a Toggle element. The `props` 52 | * argument should spread through to **a component that can accept a ref**. Use 53 | * the `onToggle` argument to toggle the menu open or closed 54 | * 55 | * @type {Function ({ 56 | * show: boolean, 57 | * toggle: (show: boolean) => void, 58 | * props: { 59 | * ref: (?HTMLElement) => void, 60 | * aria-haspopup: true 61 | * aria-expanded: boolean 62 | * }, 63 | * }) => React.Element} 64 | */ 65 | children: PropTypes.func.isRequired, 66 | }; 67 | 68 | export interface DropdownToggleProps { 69 | children: ( 70 | props: UseDropdownToggleProps, 71 | meta: UseDropdownToggleMetadata, 72 | ) => React.ReactNode; 73 | } 74 | 75 | /** 76 | * Also exported as `` from `Dropdown`. 77 | * 78 | * @displayName DropdownToggle 79 | * @memberOf Dropdown 80 | */ 81 | function DropdownToggle({ children }: DropdownToggleProps) { 82 | const [props, meta] = useDropdownToggle(); 83 | 84 | return <>{children(props, meta)}; 85 | } 86 | 87 | DropdownToggle.displayName = 'ReactOverlaysDropdownToggle'; 88 | DropdownToggle.propTypes = propTypes; 89 | 90 | /** @component */ 91 | export default DropdownToggle; 92 | -------------------------------------------------------------------------------- /src/Modal.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-use-before-define, react/prop-types */ 2 | 3 | import activeElement from 'dom-helpers/activeElement'; 4 | import contains from 'dom-helpers/contains'; 5 | import canUseDOM from 'dom-helpers/canUseDOM'; 6 | import listen from 'dom-helpers/listen'; 7 | import PropTypes from 'prop-types'; 8 | import React, { 9 | useState, 10 | useRef, 11 | useCallback, 12 | useImperativeHandle, 13 | forwardRef, 14 | useEffect, 15 | } from 'react'; 16 | import ReactDOM from 'react-dom'; 17 | import useMounted from '@restart/hooks/useMounted'; 18 | import useWillUnmount from '@restart/hooks/useWillUnmount'; 19 | 20 | import usePrevious from '@restart/hooks/usePrevious'; 21 | import useEventCallback from '@restart/hooks/useEventCallback'; 22 | import ModalManager from './ModalManager'; 23 | import useWaitForDOMRef, { DOMContainer } from './useWaitForDOMRef'; 24 | import { TransitionCallbacks } from './types'; 25 | 26 | let manager: ModalManager; 27 | 28 | export type ModalTransitionComponent = React.ComponentType< 29 | { 30 | in: boolean; 31 | appear?: boolean; 32 | unmountOnExit?: boolean; 33 | } & TransitionCallbacks 34 | >; 35 | 36 | export interface RenderModalDialogProps { 37 | style: React.CSSProperties | undefined; 38 | className: string | undefined; 39 | tabIndex: number; 40 | role: string; 41 | ref: React.RefCallback; 42 | 'aria-modal': boolean | undefined; 43 | } 44 | 45 | export interface RenderModalBackdropProps { 46 | ref: React.RefCallback; 47 | onClick: (event: React.SyntheticEvent) => void; 48 | } 49 | 50 | /* 51 | Modal props are split into a version with and without index signature so that you can fully use them in another projects 52 | This is due to Typescript not playing well with index singatures e.g. when using Omit 53 | */ 54 | export interface BaseModalProps extends TransitionCallbacks { 55 | children?: React.ReactElement; 56 | role?: string; 57 | style?: React.CSSProperties; 58 | className?: string; 59 | 60 | show?: boolean; 61 | container?: DOMContainer; 62 | onShow?: () => void; 63 | onHide?: () => void; 64 | manager?: ModalManager; 65 | backdrop?: true | false | 'static'; 66 | 67 | renderDialog?: (props: RenderModalDialogProps) => React.ReactNode; 68 | renderBackdrop?: (props: RenderModalBackdropProps) => React.ReactNode; 69 | 70 | onEscapeKeyDown?: (e: KeyboardEvent) => void; 71 | onBackdropClick?: (e: React.SyntheticEvent) => void; 72 | containerClassName?: string; 73 | keyboard?: boolean; 74 | transition?: ModalTransitionComponent; 75 | backdropTransition?: ModalTransitionComponent; 76 | autoFocus?: boolean; 77 | enforceFocus?: boolean; 78 | restoreFocus?: boolean; 79 | restoreFocusOptions?: { 80 | preventScroll: boolean; 81 | }; 82 | } 83 | 84 | export interface ModalProps extends BaseModalProps { 85 | [other: string]: any; 86 | } 87 | 88 | function getManager() { 89 | if (!manager) manager = new ModalManager(); 90 | return manager; 91 | } 92 | 93 | function useModalManager(provided?: ModalManager) { 94 | const modalManager = provided || getManager(); 95 | 96 | const modal = useRef({ 97 | dialog: (null as any) as HTMLElement, 98 | backdrop: (null as any) as HTMLElement, 99 | }); 100 | 101 | return Object.assign(modal.current, { 102 | add: (container: HTMLElement, className?: string) => 103 | modalManager.add(modal.current, container, className), 104 | 105 | remove: () => modalManager.remove(modal.current), 106 | 107 | isTopModal: () => modalManager.isTopModal(modal.current), 108 | 109 | setDialogRef: useCallback((ref: HTMLElement | null) => { 110 | modal.current.dialog = ref!; 111 | }, []), 112 | 113 | setBackdropRef: useCallback((ref: HTMLElement | null) => { 114 | modal.current.backdrop = ref!; 115 | }, []), 116 | }); 117 | } 118 | 119 | export interface ModalHandle { 120 | dialog: HTMLElement | null; 121 | backdrop: HTMLElement | null; 122 | } 123 | 124 | const Modal: React.ForwardRefExoticComponent< 125 | ModalProps & React.RefAttributes 126 | > = forwardRef( 127 | ( 128 | { 129 | show = false, 130 | role = 'dialog', 131 | className, 132 | style, 133 | children, 134 | backdrop = true, 135 | keyboard = true, 136 | onBackdropClick, 137 | onEscapeKeyDown, 138 | transition, 139 | backdropTransition, 140 | autoFocus = true, 141 | enforceFocus = true, 142 | restoreFocus = true, 143 | restoreFocusOptions, 144 | renderDialog, 145 | renderBackdrop = (props: RenderModalBackdropProps) =>
, 146 | manager: providedManager, 147 | container: containerRef, 148 | containerClassName, 149 | onShow, 150 | onHide = () => {}, 151 | 152 | onExit, 153 | onExited, 154 | onExiting, 155 | onEnter, 156 | onEntering, 157 | onEntered, 158 | 159 | ...rest 160 | }: ModalProps, 161 | ref: React.Ref, 162 | ) => { 163 | const container = useWaitForDOMRef(containerRef); 164 | const modal = useModalManager(providedManager); 165 | 166 | const isMounted = useMounted(); 167 | const prevShow = usePrevious(show); 168 | const [exited, setExited] = useState(!show); 169 | const lastFocusRef = useRef(null); 170 | 171 | useImperativeHandle(ref, () => modal, [modal]); 172 | 173 | if (canUseDOM && !prevShow && show) { 174 | lastFocusRef.current = activeElement() as HTMLElement; 175 | } 176 | 177 | if (!transition && !show && !exited) { 178 | setExited(true); 179 | } else if (show && exited) { 180 | setExited(false); 181 | } 182 | 183 | const handleShow = useEventCallback(() => { 184 | modal.add(container!, containerClassName); 185 | 186 | removeKeydownListenerRef.current = listen( 187 | document as any, 188 | 'keydown', 189 | handleDocumentKeyDown, 190 | ); 191 | 192 | removeFocusListenerRef.current = listen( 193 | document as any, 194 | 'focus', 195 | // the timeout is necessary b/c this will run before the new modal is mounted 196 | // and so steals focus from it 197 | () => setTimeout(handleEnforceFocus), 198 | true, 199 | ); 200 | 201 | if (onShow) { 202 | onShow(); 203 | } 204 | 205 | // autofocus after onShow to not trigger a focus event for previous 206 | // modals before this one is shown. 207 | if (autoFocus) { 208 | const currentActiveElement = activeElement(document) as HTMLElement; 209 | 210 | if ( 211 | modal.dialog && 212 | currentActiveElement && 213 | !contains(modal.dialog, currentActiveElement) 214 | ) { 215 | lastFocusRef.current = currentActiveElement; 216 | modal.dialog.focus(); 217 | } 218 | } 219 | }); 220 | 221 | const handleHide = useEventCallback(() => { 222 | modal.remove(); 223 | 224 | removeKeydownListenerRef.current?.(); 225 | removeFocusListenerRef.current?.(); 226 | 227 | if (restoreFocus) { 228 | // Support: <=IE11 doesn't support `focus()` on svg elements (RB: #917) 229 | lastFocusRef.current?.focus?.(restoreFocusOptions); 230 | lastFocusRef.current = null; 231 | } 232 | }); 233 | 234 | // TODO: try and combine these effects: https://github.com/react-bootstrap/react-overlays/pull/794#discussion_r409954120 235 | 236 | // Show logic when: 237 | // - show is `true` _and_ `container` has resolved 238 | useEffect(() => { 239 | if (!show || !container) return; 240 | 241 | handleShow(); 242 | }, [show, container, /* should never change: */ handleShow]); 243 | 244 | // Hide cleanup logic when: 245 | // - `exited` switches to true 246 | // - component unmounts; 247 | useEffect(() => { 248 | if (!exited) return; 249 | 250 | handleHide(); 251 | }, [exited, handleHide]); 252 | 253 | useWillUnmount(() => { 254 | handleHide(); 255 | }); 256 | 257 | // -------------------------------- 258 | 259 | const handleEnforceFocus = useEventCallback(() => { 260 | if (!enforceFocus || !isMounted() || !modal.isTopModal()) { 261 | return; 262 | } 263 | 264 | const currentActiveElement = activeElement(); 265 | 266 | if ( 267 | modal.dialog && 268 | currentActiveElement && 269 | !contains(modal.dialog, currentActiveElement) 270 | ) { 271 | modal.dialog.focus(); 272 | } 273 | }); 274 | 275 | const handleBackdropClick = useEventCallback((e: React.SyntheticEvent) => { 276 | if (e.target !== e.currentTarget) { 277 | return; 278 | } 279 | 280 | onBackdropClick?.(e); 281 | 282 | if (backdrop === true) { 283 | onHide(); 284 | } 285 | }); 286 | 287 | const handleDocumentKeyDown = useEventCallback((e: KeyboardEvent) => { 288 | if (keyboard && e.keyCode === 27 && modal.isTopModal()) { 289 | onEscapeKeyDown?.(e); 290 | 291 | if (!e.defaultPrevented) { 292 | onHide(); 293 | } 294 | } 295 | }); 296 | 297 | const removeFocusListenerRef = useRef | null>(); 298 | const removeKeydownListenerRef = useRef | null>(); 299 | 300 | const handleHidden: TransitionCallbacks['onExited'] = (...args) => { 301 | setExited(true); 302 | onExited?.(...args); 303 | }; 304 | 305 | const Transition = transition; 306 | if (!container || !(show || (Transition && !exited))) { 307 | return null; 308 | } 309 | 310 | const dialogProps = { 311 | role, 312 | ref: modal.setDialogRef, 313 | // apparently only works on the dialog role element 314 | 'aria-modal': role === 'dialog' ? true : undefined, 315 | ...rest, 316 | style, 317 | className, 318 | tabIndex: -1, 319 | }; 320 | 321 | let dialog = renderDialog ? ( 322 | renderDialog(dialogProps) 323 | ) : ( 324 |
325 | {React.cloneElement(children!, { role: 'document' })} 326 |
327 | ); 328 | 329 | if (Transition) { 330 | dialog = ( 331 | 342 | {dialog} 343 | 344 | ); 345 | } 346 | 347 | let backdropElement = null; 348 | if (backdrop) { 349 | const BackdropTransition = backdropTransition; 350 | 351 | backdropElement = renderBackdrop({ 352 | ref: modal.setBackdropRef, 353 | onClick: handleBackdropClick, 354 | }); 355 | 356 | if (BackdropTransition) { 357 | backdropElement = ( 358 | 359 | {backdropElement} 360 | 361 | ); 362 | } 363 | } 364 | 365 | return ( 366 | <> 367 | {ReactDOM.createPortal( 368 | <> 369 | {backdropElement} 370 | {dialog} 371 | , 372 | container, 373 | )} 374 | 375 | ); 376 | }, 377 | ); 378 | 379 | const propTypes = { 380 | /** 381 | * Set the visibility of the Modal 382 | */ 383 | show: PropTypes.bool, 384 | 385 | /** 386 | * A DOM element, a `ref` to an element, or function that returns either. The Modal is appended to it's `container` element. 387 | * 388 | * For the sake of assistive technologies, the container should usually be the document body, so that the rest of the 389 | * page content can be placed behind a virtual backdrop as well as a visual one. 390 | */ 391 | container: PropTypes.any, 392 | 393 | /** 394 | * A callback fired when the Modal is opening. 395 | */ 396 | onShow: PropTypes.func, 397 | 398 | /** 399 | * A callback fired when either the backdrop is clicked, or the escape key is pressed. 400 | * 401 | * The `onHide` callback only signals intent from the Modal, 402 | * you must actually set the `show` prop to `false` for the Modal to close. 403 | */ 404 | onHide: PropTypes.func, 405 | 406 | /** 407 | * Include a backdrop component. 408 | */ 409 | backdrop: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['static'])]), 410 | 411 | /** 412 | * A function that returns the dialog component. Useful for custom 413 | * rendering. **Note:** the component should make sure to apply the provided ref. 414 | * 415 | * ```js static 416 | * renderDialog={props => } 417 | * ``` 418 | */ 419 | renderDialog: PropTypes.func, 420 | 421 | /** 422 | * A function that returns a backdrop component. Useful for custom 423 | * backdrop rendering. 424 | * 425 | * ```js 426 | * renderBackdrop={props => } 427 | * ``` 428 | */ 429 | renderBackdrop: PropTypes.func, 430 | 431 | /** 432 | * A callback fired when the escape key, if specified in `keyboard`, is pressed. 433 | * 434 | * If preventDefault() is called on the keyboard event, closing the modal will be cancelled. 435 | */ 436 | onEscapeKeyDown: PropTypes.func, 437 | 438 | /** 439 | * A callback fired when the backdrop, if specified, is clicked. 440 | */ 441 | onBackdropClick: PropTypes.func, 442 | 443 | /** 444 | * A css class or set of classes applied to the modal container when the modal is open, 445 | * and removed when it is closed. 446 | */ 447 | containerClassName: PropTypes.string, 448 | 449 | /** 450 | * Close the modal when escape key is pressed 451 | */ 452 | keyboard: PropTypes.bool, 453 | 454 | /** 455 | * A `react-transition-group@2.0.0` `` component used 456 | * to control animations for the dialog component. 457 | */ 458 | transition: PropTypes.elementType, 459 | 460 | /** 461 | * A `react-transition-group@2.0.0` `` component used 462 | * to control animations for the backdrop components. 463 | */ 464 | backdropTransition: PropTypes.elementType, 465 | 466 | /** 467 | * When `true` The modal will automatically shift focus to itself when it opens, and 468 | * replace it to the last focused element when it closes. This also 469 | * works correctly with any Modal children that have the `autoFocus` prop. 470 | * 471 | * Generally this should never be set to `false` as it makes the Modal less 472 | * accessible to assistive technologies, like screen readers. 473 | */ 474 | autoFocus: PropTypes.bool, 475 | 476 | /** 477 | * When `true` The modal will prevent focus from leaving the Modal while open. 478 | * 479 | * Generally this should never be set to `false` as it makes the Modal less 480 | * accessible to assistive technologies, like screen readers. 481 | */ 482 | enforceFocus: PropTypes.bool, 483 | 484 | /** 485 | * When `true` The modal will restore focus to previously focused element once 486 | * modal is hidden 487 | */ 488 | restoreFocus: PropTypes.bool, 489 | 490 | /** 491 | * Options passed to focus function when `restoreFocus` is set to `true` 492 | * 493 | * @link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#Parameters 494 | */ 495 | restoreFocusOptions: PropTypes.shape({ 496 | preventScroll: PropTypes.bool, 497 | }), 498 | 499 | /** 500 | * Callback fired before the Modal transitions in 501 | */ 502 | onEnter: PropTypes.func, 503 | 504 | /** 505 | * Callback fired as the Modal begins to transition in 506 | */ 507 | onEntering: PropTypes.func, 508 | 509 | /** 510 | * Callback fired after the Modal finishes transitioning in 511 | */ 512 | onEntered: PropTypes.func, 513 | 514 | /** 515 | * Callback fired right before the Modal transitions out 516 | */ 517 | onExit: PropTypes.func, 518 | 519 | /** 520 | * Callback fired as the Modal begins to transition out 521 | */ 522 | onExiting: PropTypes.func, 523 | 524 | /** 525 | * Callback fired after the Modal finishes transitioning out 526 | */ 527 | onExited: PropTypes.func, 528 | 529 | /** 530 | * A ModalManager instance used to track and manage the state of open 531 | * Modals. Useful when customizing how modals interact within a container 532 | */ 533 | manager: PropTypes.instanceOf(ModalManager), 534 | }; 535 | 536 | Modal.displayName = 'Modal'; 537 | Modal.propTypes = propTypes as any; 538 | 539 | export default Object.assign(Modal, { 540 | Manager: ModalManager, 541 | }); 542 | -------------------------------------------------------------------------------- /src/ModalManager.ts: -------------------------------------------------------------------------------- 1 | import addClass from 'dom-helpers/addClass'; 2 | import removeClass from 'dom-helpers/removeClass'; 3 | import css from 'dom-helpers/css'; 4 | import getScrollbarSize from 'dom-helpers/scrollbarSize'; 5 | 6 | import isOverflowing from './isOverflowing'; 7 | import { ariaHidden, hideSiblings, showSiblings } from './manageAriaHidden'; 8 | 9 | function findIndexOf(arr: T[], cb: (item: T, idx: number) => boolean) { 10 | let idx = -1; 11 | arr.some((d, i) => { 12 | if (cb(d, i)) { 13 | idx = i; 14 | return true; 15 | } 16 | return false; 17 | }); 18 | return idx; 19 | } 20 | 21 | export interface ModalInstance { 22 | dialog: Element; 23 | backdrop: Element; 24 | } 25 | 26 | export type ContainerState = Record & { 27 | isOverflowing?: boolean; 28 | style?: Partial; 29 | modals: ModalInstance[]; 30 | }; 31 | /** 32 | * Proper state management for containers and the modals in those containers. 33 | * 34 | * @internal Used by the Modal to ensure proper styling of containers. 35 | */ 36 | class ModalManager { 37 | readonly hideSiblingNodes: boolean; 38 | 39 | readonly handleContainerOverflow: boolean; 40 | 41 | readonly modals: ModalInstance[]; 42 | 43 | readonly containers: HTMLElement[]; 44 | 45 | readonly data: ContainerState[]; 46 | 47 | readonly scrollbarSize: number; 48 | 49 | constructor({ 50 | hideSiblingNodes = true, 51 | handleContainerOverflow = true, 52 | } = {}) { 53 | this.hideSiblingNodes = hideSiblingNodes; 54 | this.handleContainerOverflow = handleContainerOverflow; 55 | this.modals = []; 56 | this.containers = []; 57 | this.data = []; 58 | this.scrollbarSize = getScrollbarSize(); 59 | } 60 | 61 | isContainerOverflowing(modal: ModalInstance) { 62 | const data = this.data[this.containerIndexFromModal(modal)]; 63 | return data && data.overflowing; 64 | } 65 | 66 | containerIndexFromModal(modal: ModalInstance) { 67 | return findIndexOf(this.data, (d) => d.modals.indexOf(modal) !== -1); 68 | } 69 | 70 | setContainerStyle(containerState: ContainerState, container: HTMLElement) { 71 | const style: Partial = { overflow: 'hidden' }; 72 | 73 | // we are only interested in the actual `style` here 74 | // because we will override it 75 | containerState.style = { 76 | overflow: container.style.overflow, 77 | paddingRight: container.style.paddingRight, 78 | }; 79 | 80 | if (containerState.overflowing) { 81 | // use computed style, here to get the real padding 82 | // to add our scrollbar width 83 | style.paddingRight = `${ 84 | parseInt(css(container, 'paddingRight') || '0', 10) + this.scrollbarSize 85 | }px`; 86 | } 87 | 88 | css(container, style as any); 89 | } 90 | 91 | removeContainerStyle(containerState: ContainerState, container: HTMLElement) { 92 | Object.assign(container.style, containerState.style); 93 | } 94 | 95 | add(modal: ModalInstance, container: HTMLElement, className?: string) { 96 | let modalIdx = this.modals.indexOf(modal); 97 | const containerIdx = this.containers.indexOf(container); 98 | 99 | if (modalIdx !== -1) { 100 | return modalIdx; 101 | } 102 | 103 | modalIdx = this.modals.length; 104 | this.modals.push(modal); 105 | 106 | if (this.hideSiblingNodes) { 107 | hideSiblings(container, modal); 108 | } 109 | 110 | if (containerIdx !== -1) { 111 | this.data[containerIdx].modals.push(modal); 112 | return modalIdx; 113 | } 114 | 115 | const data = { 116 | modals: [modal], 117 | // right now only the first modal of a container will have its classes applied 118 | classes: className ? className.split(/\s+/) : [], 119 | overflowing: isOverflowing(container), 120 | }; 121 | 122 | if (this.handleContainerOverflow) { 123 | this.setContainerStyle(data, container); 124 | } 125 | 126 | data.classes.forEach(addClass.bind(null, container)); 127 | 128 | this.containers.push(container); 129 | this.data.push(data); 130 | 131 | return modalIdx; 132 | } 133 | 134 | remove(modal: ModalInstance) { 135 | const modalIdx = this.modals.indexOf(modal); 136 | 137 | if (modalIdx === -1) { 138 | return; 139 | } 140 | 141 | const containerIdx = this.containerIndexFromModal(modal); 142 | const data = this.data[containerIdx]; 143 | const container = this.containers[containerIdx]; 144 | 145 | data.modals.splice(data.modals.indexOf(modal), 1); 146 | 147 | this.modals.splice(modalIdx, 1); 148 | 149 | // if that was the last modal in a container, 150 | // clean up the container 151 | if (data.modals.length === 0) { 152 | data.classes.forEach(removeClass.bind(null, container)); 153 | 154 | if (this.handleContainerOverflow) { 155 | this.removeContainerStyle(data, container); 156 | } 157 | 158 | if (this.hideSiblingNodes) { 159 | showSiblings(container, modal); 160 | } 161 | this.containers.splice(containerIdx, 1); 162 | this.data.splice(containerIdx, 1); 163 | } else if (this.hideSiblingNodes) { 164 | // otherwise make sure the next top modal is visible to a SR 165 | const { backdrop, dialog } = data.modals[data.modals.length - 1]; 166 | ariaHidden(false, dialog); 167 | ariaHidden(false, backdrop); 168 | } 169 | } 170 | 171 | isTopModal(modal: ModalInstance) { 172 | return ( 173 | !!this.modals.length && this.modals[this.modals.length - 1] === modal 174 | ); 175 | } 176 | } 177 | 178 | export default ModalManager; 179 | -------------------------------------------------------------------------------- /src/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useState } from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import useCallbackRef from '@restart/hooks/useCallbackRef'; 5 | import useMergedRefs from '@restart/hooks/useMergedRefs'; 6 | import { placements } from './popper'; 7 | import usePopper, { 8 | Placement, 9 | UsePopperOptions, 10 | Offset, 11 | State, 12 | } from './usePopper'; 13 | import useRootClose, { RootCloseOptions } from './useRootClose'; 14 | import useWaitForDOMRef, { DOMContainer } from './useWaitForDOMRef'; 15 | import { TransitionCallbacks } from './types'; 16 | import mergeOptionsWithPopperConfig from './mergeOptionsWithPopperConfig'; 17 | 18 | export interface OverlayProps extends TransitionCallbacks { 19 | flip?: boolean; 20 | placement?: Placement; 21 | offset?: Offset; 22 | containerPadding?: number; 23 | popperConfig?: Omit; 24 | container?: DOMContainer; 25 | target: DOMContainer; 26 | show?: boolean; 27 | transition?: React.ComponentType< 28 | { in?: boolean; appear?: boolean } & TransitionCallbacks 29 | >; 30 | onHide?: (e: Event) => void; 31 | rootClose?: boolean; 32 | rootCloseDisabled?: boolean; 33 | rootCloseEvent?: RootCloseOptions['clickTrigger']; 34 | children: (value: { 35 | show: boolean; 36 | placement: Placement; 37 | update: () => void; 38 | forceUpdate: () => void; 39 | state?: State; 40 | props: Record & { 41 | ref: React.RefCallback; 42 | style: React.CSSProperties; 43 | 'aria-labelledby'?: string; 44 | }; 45 | arrowProps: Record & { 46 | ref: React.RefCallback; 47 | style: React.CSSProperties; 48 | }; 49 | }) => React.ReactNode; 50 | } 51 | 52 | /** 53 | * Built on top of `Popper.js`, the overlay component is 54 | * great for custom tooltip overlays. 55 | */ 56 | const Overlay = React.forwardRef( 57 | (props, outerRef) => { 58 | const { 59 | flip, 60 | offset, 61 | placement, 62 | containerPadding = 5, 63 | popperConfig = {}, 64 | transition: Transition, 65 | } = props; 66 | 67 | const [rootElement, attachRef] = useCallbackRef(); 68 | const [arrowElement, attachArrowRef] = useCallbackRef(); 69 | const mergedRef = useMergedRefs(attachRef, outerRef); 70 | 71 | const container = useWaitForDOMRef(props.container); 72 | const target = useWaitForDOMRef(props.target); 73 | 74 | const [exited, setExited] = useState(!props.show); 75 | 76 | const { styles, attributes, ...popper } = usePopper( 77 | target, 78 | rootElement, 79 | mergeOptionsWithPopperConfig({ 80 | placement, 81 | enableEvents: !!props.show, 82 | containerPadding: containerPadding || 5, 83 | flip, 84 | offset, 85 | arrowElement, 86 | popperConfig, 87 | }), 88 | ); 89 | 90 | if (props.show) { 91 | if (exited) setExited(false); 92 | } else if (!props.transition && !exited) { 93 | setExited(true); 94 | } 95 | 96 | const handleHidden: TransitionCallbacks['onExited'] = (...args) => { 97 | setExited(true); 98 | 99 | if (props.onExited) { 100 | props.onExited(...args); 101 | } 102 | }; 103 | 104 | // Don't un-render the overlay while it's transitioning out. 105 | const mountOverlay = props.show || (Transition && !exited); 106 | 107 | useRootClose(rootElement, props.onHide!, { 108 | disabled: !props.rootClose || props.rootCloseDisabled, 109 | clickTrigger: props.rootCloseEvent, 110 | }); 111 | 112 | if (!mountOverlay) { 113 | // Don't bother showing anything if we don't have to. 114 | return null; 115 | } 116 | 117 | let child = props.children({ 118 | ...popper, 119 | show: !!props.show, 120 | props: { 121 | ...attributes.popper, 122 | style: styles.popper as any, 123 | ref: mergedRef, 124 | }, 125 | arrowProps: { 126 | ...attributes.arrow, 127 | style: styles.arrow as any, 128 | ref: attachArrowRef, 129 | }, 130 | }); 131 | 132 | if (Transition) { 133 | const { onExit, onExiting, onEnter, onEntering, onEntered } = props; 134 | 135 | child = ( 136 | 146 | {child} 147 | 148 | ); 149 | } 150 | 151 | return container ? ReactDOM.createPortal(child, container) : null; 152 | }, 153 | ); 154 | 155 | Overlay.displayName = 'Overlay'; 156 | 157 | Overlay.propTypes = { 158 | /** 159 | * Set the visibility of the Overlay 160 | */ 161 | show: PropTypes.bool, 162 | 163 | /** Specify where the overlay element is positioned in relation to the target element */ 164 | placement: PropTypes.oneOf(placements), 165 | 166 | /** 167 | * A DOM Element, Ref to an element, or function that returns either. The `target` element is where 168 | * the overlay is positioned relative to. 169 | */ 170 | target: PropTypes.any, 171 | 172 | /** 173 | * A DOM Element, Ref to an element, or function that returns either. The `container` will have the Portal children 174 | * appended to it. 175 | */ 176 | container: PropTypes.any, 177 | 178 | /** 179 | * Enables the Popper.js `flip` modifier, allowing the Overlay to 180 | * automatically adjust it's placement in case of overlap with the viewport or toggle. 181 | * Refer to the [flip docs](https://popper.js.org/popper-documentation.html#modifiers..flip.enabled) for more info 182 | */ 183 | flip: PropTypes.bool, 184 | 185 | /** 186 | * A render prop that returns an element to overlay and position. See 187 | * the [react-popper documentation](https://github.com/FezVrasta/react-popper#children) for more info. 188 | * 189 | * @type {Function ({ 190 | * show: boolean, 191 | * placement: Placement, 192 | * update: () => void, 193 | * forceUpdate: () => void, 194 | * props: { 195 | * ref: (?HTMLElement) => void, 196 | * style: { [string]: string | number }, 197 | * aria-labelledby: ?string 198 | * [string]: string | number, 199 | * }, 200 | * arrowProps: { 201 | * ref: (?HTMLElement) => void, 202 | * style: { [string]: string | number }, 203 | * [string]: string | number, 204 | * }, 205 | * }) => React.Element} 206 | */ 207 | children: PropTypes.func.isRequired, 208 | 209 | /** 210 | * Control how much space there is between the edge of the boundary element and overlay. 211 | * A convenience shortcut to setting `popperConfig.modfiers.preventOverflow.padding` 212 | */ 213 | containerPadding: PropTypes.number, 214 | 215 | /** 216 | * A set of popper options and props passed directly to react-popper's Popper component. 217 | */ 218 | popperConfig: PropTypes.object, 219 | 220 | /** 221 | * Specify whether the overlay should trigger `onHide` when the user clicks outside the overlay 222 | */ 223 | rootClose: PropTypes.bool, 224 | 225 | /** 226 | * Specify event for toggling overlay 227 | */ 228 | rootCloseEvent: PropTypes.oneOf(['click', 'mousedown']), 229 | 230 | /** 231 | * Specify disabled for disable RootCloseWrapper 232 | */ 233 | rootCloseDisabled: PropTypes.bool, 234 | /** 235 | * A Callback fired by the Overlay when it wishes to be hidden. 236 | * 237 | * __required__ when `rootClose` is `true`. 238 | * 239 | * @type func 240 | */ 241 | onHide(props, ...args) { 242 | if (props.rootClose) { 243 | return PropTypes.func.isRequired(props, ...args); 244 | } 245 | 246 | return PropTypes.func(props, ...args); 247 | }, 248 | 249 | /** 250 | * A `react-transition-group@2.0.0` `` component 251 | * used to animate the overlay as it changes visibility. 252 | */ 253 | // @ts-ignore 254 | transition: PropTypes.elementType, 255 | 256 | /** 257 | * Callback fired before the Overlay transitions in 258 | */ 259 | onEnter: PropTypes.func, 260 | 261 | /** 262 | * Callback fired as the Overlay begins to transition in 263 | */ 264 | onEntering: PropTypes.func, 265 | 266 | /** 267 | * Callback fired after the Overlay finishes transitioning in 268 | */ 269 | onEntered: PropTypes.func, 270 | 271 | /** 272 | * Callback fired right before the Overlay transitions out 273 | */ 274 | onExit: PropTypes.func, 275 | 276 | /** 277 | * Callback fired as the Overlay begins to transition out 278 | */ 279 | onExiting: PropTypes.func, 280 | 281 | /** 282 | * Callback fired after the Overlay finishes transitioning out 283 | */ 284 | onExited: PropTypes.func, 285 | }; 286 | 287 | export default Overlay; 288 | -------------------------------------------------------------------------------- /src/Portal.tsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import React from 'react'; 5 | import useWaitForDOMRef, { DOMContainer } from './useWaitForDOMRef'; 6 | 7 | const propTypes = { 8 | /** 9 | * A DOM element, Ref to an element, or function that returns either. The `container` will have the Portal children 10 | * appended to it. 11 | */ 12 | container: PropTypes.any, 13 | 14 | onRendered: PropTypes.func, 15 | }; 16 | 17 | export interface PortalProps { 18 | children: React.ReactElement; 19 | container: DOMContainer; 20 | onRendered?: (element: any) => void; 21 | } 22 | 23 | /** 24 | * @public 25 | */ 26 | const Portal = ({ container, children, onRendered }: PortalProps) => { 27 | const resolvedContainer = useWaitForDOMRef(container, onRendered); 28 | 29 | return resolvedContainer ? ( 30 | <>{ReactDOM.createPortal(children, resolvedContainer)} 31 | ) : null; 32 | }; 33 | 34 | Portal.displayName = 'Portal'; 35 | Portal.propTypes = propTypes; 36 | 37 | export default Portal; 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Dropdown from './Dropdown'; 2 | import { useDropdownMenu } from './DropdownMenu'; 3 | import { useDropdownToggle } from './DropdownToggle'; 4 | import Modal from './Modal'; 5 | import Overlay from './Overlay'; 6 | import Portal from './Portal'; 7 | import useRootClose from './useRootClose'; 8 | 9 | export { 10 | Dropdown, 11 | useDropdownMenu, 12 | useDropdownToggle, 13 | Modal, 14 | Overlay, 15 | Portal, 16 | useRootClose, 17 | }; 18 | -------------------------------------------------------------------------------- /src/isOverflowing.ts: -------------------------------------------------------------------------------- 1 | import isWindow from 'dom-helpers/isWindow'; 2 | import ownerDocument from 'dom-helpers/ownerDocument'; 3 | 4 | function isBody(node: Element): node is HTMLBodyElement { 5 | return node && node.tagName.toLowerCase() === 'body'; 6 | } 7 | 8 | function bodyIsOverflowing(node: Element | Document | Window) { 9 | const doc = isWindow(node) ? ownerDocument() : ownerDocument(node as Element); 10 | const win = isWindow(node) || doc.defaultView!; 11 | 12 | return doc.body.clientWidth < win.innerWidth; 13 | } 14 | 15 | export default function isOverflowing(container: Element) { 16 | const win = isWindow(container); 17 | return win || isBody(container) 18 | ? bodyIsOverflowing(container) 19 | : container.scrollHeight > container.clientHeight; 20 | } 21 | -------------------------------------------------------------------------------- /src/manageAriaHidden.ts: -------------------------------------------------------------------------------- 1 | const BLACKLIST = ['template', 'script', 'style']; 2 | 3 | const isHidable = ({ nodeType, tagName }: Element) => 4 | nodeType === 1 && BLACKLIST.indexOf(tagName.toLowerCase()) === -1; 5 | 6 | const siblings = ( 7 | container: Element, 8 | exclude: Element[], 9 | cb: (el: Element) => any, 10 | ) => { 11 | [].forEach.call(container.children, (node) => { 12 | if (exclude.indexOf(node) === -1 && isHidable(node)) { 13 | cb(node); 14 | } 15 | }); 16 | }; 17 | 18 | export function ariaHidden(hide: boolean, node: Element | null | undefined) { 19 | if (!node) return; 20 | if (hide) { 21 | node.setAttribute('aria-hidden', 'true'); 22 | } else { 23 | node.removeAttribute('aria-hidden'); 24 | } 25 | } 26 | 27 | interface SiblingExclusions { 28 | dialog: Element; 29 | backdrop: Element; 30 | } 31 | export function hideSiblings( 32 | container: Element, 33 | { dialog, backdrop }: SiblingExclusions, 34 | ) { 35 | siblings(container, [dialog, backdrop], (node) => ariaHidden(true, node)); 36 | } 37 | 38 | export function showSiblings( 39 | container: Element, 40 | { dialog, backdrop }: SiblingExclusions, 41 | ) { 42 | siblings(container, [dialog, backdrop], (node) => ariaHidden(false, node)); 43 | } 44 | -------------------------------------------------------------------------------- /src/mergeOptionsWithPopperConfig.ts: -------------------------------------------------------------------------------- 1 | import { UsePopperOptions, Offset, Placement, Modifiers } from './usePopper'; 2 | 3 | export type Config = { 4 | flip?: boolean; 5 | fixed?: boolean; 6 | alignEnd?: boolean; 7 | enabled?: boolean; 8 | containerPadding?: number; 9 | arrowElement?: Element | null; 10 | enableEvents?: boolean; 11 | offset?: Offset; 12 | placement?: Placement; 13 | popperConfig?: UsePopperOptions; 14 | }; 15 | 16 | export function toModifierMap(modifiers: Modifiers | undefined) { 17 | const result: Modifiers = {}; 18 | 19 | if (!Array.isArray(modifiers)) { 20 | return modifiers || result; 21 | } 22 | 23 | // eslint-disable-next-line no-unused-expressions 24 | modifiers?.forEach((m) => { 25 | result[m.name!] = m; 26 | }); 27 | return result; 28 | } 29 | 30 | export function toModifierArray(map: Modifiers | undefined = {}) { 31 | if (Array.isArray(map)) return map; 32 | return Object.keys(map).map((k) => { 33 | map[k].name = k; 34 | return map[k]; 35 | }); 36 | } 37 | 38 | export default function mergeOptionsWithPopperConfig({ 39 | enabled, 40 | enableEvents, 41 | placement, 42 | flip, 43 | offset, 44 | fixed, 45 | containerPadding, 46 | arrowElement, 47 | popperConfig = {}, 48 | }: Config): UsePopperOptions { 49 | const modifiers = toModifierMap(popperConfig.modifiers); 50 | 51 | return { 52 | ...popperConfig, 53 | placement, 54 | enabled, 55 | strategy: fixed ? 'fixed' : popperConfig.strategy, 56 | modifiers: toModifierArray({ 57 | ...modifiers, 58 | eventListeners: { 59 | enabled: enableEvents, 60 | }, 61 | preventOverflow: { 62 | ...modifiers.preventOverflow, 63 | options: containerPadding 64 | ? { 65 | padding: containerPadding, 66 | ...modifiers.preventOverflow?.options, 67 | } 68 | : modifiers.preventOverflow?.options, 69 | }, 70 | offset: { 71 | options: { 72 | offset, 73 | ...modifiers.offset?.options, 74 | }, 75 | }, 76 | arrow: { 77 | ...modifiers.arrow, 78 | enabled: !!arrowElement, 79 | options: { 80 | ...modifiers.arrow?.options, 81 | element: arrowElement, 82 | }, 83 | }, 84 | flip: { 85 | enabled: !!flip, 86 | ...modifiers.flip, 87 | }, 88 | }), 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /src/ownerDocument.ts: -------------------------------------------------------------------------------- 1 | import ownerDocument from 'dom-helpers/ownerDocument'; 2 | import safeFindDOMNode from './safeFindDOMNode'; 3 | 4 | export default ( 5 | componentOrElement: React.ComponentClass | Element | null | undefined, 6 | ) => ownerDocument(safeFindDOMNode(componentOrElement) as any); 7 | -------------------------------------------------------------------------------- /src/popper.ts: -------------------------------------------------------------------------------- 1 | import arrow from '@popperjs/core/lib/modifiers/arrow'; 2 | import computeStyles from '@popperjs/core/lib/modifiers/computeStyles'; 3 | import eventListeners from '@popperjs/core/lib/modifiers/eventListeners'; 4 | import flip from '@popperjs/core/lib/modifiers/flip'; 5 | import hide from '@popperjs/core/lib/modifiers/hide'; 6 | import offset from '@popperjs/core/lib/modifiers/offset'; 7 | import popperOffsets from '@popperjs/core/lib/modifiers/popperOffsets'; 8 | import preventOverflow from '@popperjs/core/lib/modifiers/preventOverflow'; 9 | import { placements } from '@popperjs/core/lib/enums'; 10 | import { popperGenerator } from '@popperjs/core/lib/popper-base'; 11 | 12 | // For the common JS build we will turn this file into a bundle with no imports. 13 | // This is b/c the Popper lib is all esm files, and would break in a common js only environment 14 | export const createPopper = popperGenerator({ 15 | defaultModifiers: [ 16 | hide, 17 | popperOffsets, 18 | computeStyles, 19 | eventListeners, 20 | offset, 21 | flip, 22 | preventOverflow, 23 | arrow, 24 | ], 25 | }); 26 | 27 | export { placements }; 28 | -------------------------------------------------------------------------------- /src/safeFindDOMNode.ts: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | 3 | export default function safeFindDOMNode( 4 | componentOrElement: React.ComponentClass | Element | null | undefined, 5 | ) { 6 | if (componentOrElement && 'setState' in componentOrElement) { 7 | return ReactDOM.findDOMNode(componentOrElement); 8 | } 9 | return (componentOrElement ?? null) as Element | Text | null; 10 | } 11 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | export interface TransitionCallbacks { 2 | onEnter?(node: HTMLElement, isAppearing: boolean): any; 3 | onEntered?(node: HTMLElement, isAppearing: boolean): any; 4 | onEntering?(node: HTMLElement, isAppearing: boolean): any; 5 | onExit?(node: HTMLElement): any; 6 | onExited?(node: HTMLElement): any; 7 | onExiting?(node: HTMLElement): any; 8 | } 9 | -------------------------------------------------------------------------------- /src/usePopper.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 2 | import useSafeState from '@restart/hooks/useSafeState'; 3 | import * as Popper from '@popperjs/core'; 4 | import { createPopper } from './popper'; 5 | 6 | const initialPopperStyles = ( 7 | position: string, 8 | ): Partial => ({ 9 | position, 10 | top: '0', 11 | left: '0', 12 | opacity: '0', 13 | pointerEvents: 'none', 14 | }); 15 | 16 | const disabledApplyStylesModifier = { name: 'applyStyles', enabled: false }; 17 | 18 | // In order to satisfy the current usage of options, including undefined 19 | type OptionsWithUndefined< 20 | T extends Popper.Obj | undefined 21 | > = T extends Popper.Obj ? T : Popper.Obj; 22 | 23 | // until docjs supports type exports... 24 | export type Modifier< 25 | Name, 26 | Options extends Popper.Obj | undefined 27 | > = Popper.Modifier>; 28 | 29 | export type Options = Popper.Options; 30 | export type Instance = Popper.Instance; 31 | export type Placement = Popper.Placement; 32 | export type VirtualElement = Popper.VirtualElement; 33 | export type State = Popper.State; 34 | 35 | export type OffsetValue = [ 36 | number | null | undefined, 37 | number | null | undefined, 38 | ]; 39 | export type OffsetFunction = (details: { 40 | popper: Popper.Rect; 41 | reference: Popper.Rect; 42 | placement: Placement; 43 | }) => OffsetValue; 44 | 45 | export type Offset = OffsetFunction | OffsetValue; 46 | 47 | export type ModifierMap = Record>>; 48 | export type Modifiers = 49 | | Popper.Options['modifiers'] 50 | | Record>>; 51 | 52 | export type UsePopperOptions = Omit< 53 | Options, 54 | 'modifiers' | 'placement' | 'strategy' 55 | > & { 56 | enabled?: boolean; 57 | placement?: Options['placement']; 58 | strategy?: Options['strategy']; 59 | modifiers?: Options['modifiers']; 60 | }; 61 | 62 | export interface UsePopperState { 63 | placement: Placement; 64 | update: () => void; 65 | forceUpdate: () => void; 66 | attributes: Record>; 67 | styles: Record>; 68 | state?: State; 69 | } 70 | 71 | const ariaDescribedByModifier: Modifier<'ariaDescribedBy', undefined> = { 72 | name: 'ariaDescribedBy', 73 | enabled: true, 74 | phase: 'afterWrite', 75 | effect: ({ state }) => { 76 | return () => { 77 | const { reference, popper } = state.elements; 78 | if ('removeAttribute' in reference) { 79 | const ids = (reference.getAttribute('aria-describedby') || '') 80 | .split(',') 81 | .filter((id) => id.trim() !== popper.id); 82 | 83 | if (!ids.length) reference.removeAttribute('aria-describedby'); 84 | else reference.setAttribute('aria-describedby', ids.join(',')); 85 | } 86 | }; 87 | }, 88 | fn: ({ state }) => { 89 | const { popper, reference } = state.elements; 90 | 91 | const role = popper.getAttribute('role')?.toLowerCase(); 92 | 93 | if (popper.id && role === 'tooltip' && 'setAttribute' in reference) { 94 | const ids = reference.getAttribute('aria-describedby'); 95 | if (ids && ids.split(',').indexOf(popper.id) !== -1) { 96 | return; 97 | } 98 | 99 | reference.setAttribute( 100 | 'aria-describedby', 101 | ids ? `${ids},${popper.id}` : popper.id, 102 | ); 103 | } 104 | }, 105 | }; 106 | 107 | const EMPTY_MODIFIERS = [] as any; 108 | /** 109 | * Position an element relative some reference element using Popper.js 110 | * 111 | * @param referenceElement 112 | * @param popperElement 113 | * @param {object} options 114 | * @param {object=} options.modifiers Popper.js modifiers 115 | * @param {boolean=} options.enabled toggle the popper functionality on/off 116 | * @param {string=} options.placement The popper element placement relative to the reference element 117 | * @param {string=} options.strategy the positioning strategy 118 | * @param {boolean=} options.eventsEnabled have Popper listen on window resize events to reposition the element 119 | * @param {function=} options.onCreate called when the popper is created 120 | * @param {function=} options.onUpdate called when the popper is updated 121 | * 122 | * @returns {UsePopperState} The popper state 123 | */ 124 | function usePopper( 125 | referenceElement: VirtualElement | null | undefined, 126 | popperElement: HTMLElement | null | undefined, 127 | { 128 | enabled = true, 129 | placement = 'bottom', 130 | strategy = 'absolute', 131 | modifiers = EMPTY_MODIFIERS, 132 | ...config 133 | }: UsePopperOptions = {}, 134 | ): UsePopperState { 135 | const popperInstanceRef = useRef(); 136 | 137 | const update = useCallback(() => { 138 | popperInstanceRef.current?.update(); 139 | }, []); 140 | 141 | const forceUpdate = useCallback(() => { 142 | popperInstanceRef.current?.forceUpdate(); 143 | }, []); 144 | 145 | const [popperState, setState] = useSafeState( 146 | useState({ 147 | placement, 148 | update, 149 | forceUpdate, 150 | attributes: {}, 151 | styles: { 152 | popper: initialPopperStyles(strategy), 153 | arrow: {}, 154 | }, 155 | }), 156 | ); 157 | 158 | const updateModifier = useMemo>( 159 | () => ({ 160 | name: 'updateStateModifier', 161 | enabled: true, 162 | phase: 'write', 163 | requires: ['computeStyles'], 164 | fn: ({ state }) => { 165 | const styles: UsePopperState['styles'] = {}; 166 | const attributes: UsePopperState['attributes'] = {}; 167 | 168 | Object.keys(state.elements).forEach((element) => { 169 | styles[element] = state.styles[element]; 170 | attributes[element] = state.attributes[element]; 171 | }); 172 | 173 | setState({ 174 | state, 175 | styles, 176 | attributes, 177 | update, 178 | forceUpdate, 179 | placement: state.placement, 180 | }); 181 | }, 182 | }), 183 | [update, forceUpdate, setState], 184 | ); 185 | 186 | useEffect(() => { 187 | if (!popperInstanceRef.current || !enabled) return; 188 | 189 | popperInstanceRef.current.setOptions({ 190 | placement, 191 | strategy, 192 | modifiers: [...modifiers, updateModifier, disabledApplyStylesModifier], 193 | }); 194 | // intentionally NOT re-running on new modifiers 195 | // eslint-disable-next-line react-hooks/exhaustive-deps 196 | }, [strategy, placement, updateModifier, enabled]); 197 | 198 | useEffect(() => { 199 | if (!enabled || referenceElement == null || popperElement == null) { 200 | return undefined; 201 | } 202 | 203 | popperInstanceRef.current = createPopper(referenceElement, popperElement, { 204 | ...config, 205 | placement, 206 | strategy, 207 | modifiers: [...modifiers, ariaDescribedByModifier, updateModifier], 208 | }); 209 | 210 | return () => { 211 | if (popperInstanceRef.current != null) { 212 | popperInstanceRef.current.destroy(); 213 | popperInstanceRef.current = undefined; 214 | 215 | setState((s) => ({ 216 | ...s, 217 | attributes: {}, 218 | styles: { popper: initialPopperStyles(strategy) }, 219 | })); 220 | } 221 | }; 222 | // This is only run once to _create_ the popper 223 | // eslint-disable-next-line react-hooks/exhaustive-deps 224 | }, [enabled, referenceElement, popperElement]); 225 | 226 | return popperState; 227 | } 228 | 229 | export default usePopper; 230 | -------------------------------------------------------------------------------- /src/useRootClose.ts: -------------------------------------------------------------------------------- 1 | import contains from 'dom-helpers/contains'; 2 | import listen from 'dom-helpers/listen'; 3 | import { useCallback, useEffect, useRef } from 'react'; 4 | 5 | import useEventCallback from '@restart/hooks/useEventCallback'; 6 | import warning from 'warning'; 7 | 8 | import ownerDocument from './ownerDocument'; 9 | 10 | const escapeKeyCode = 27; 11 | const noop = () => {}; 12 | 13 | export type MouseEvents = { 14 | [K in keyof GlobalEventHandlersEventMap]: GlobalEventHandlersEventMap[K] extends MouseEvent 15 | ? K 16 | : never; 17 | }[keyof GlobalEventHandlersEventMap]; 18 | 19 | function isLeftClickEvent(event: MouseEvent) { 20 | return event.button === 0; 21 | } 22 | 23 | function isModifiedEvent(event: MouseEvent) { 24 | return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); 25 | } 26 | 27 | const getRefTarget = ( 28 | ref: React.RefObject | Element | null | undefined, 29 | ) => ref && ('current' in ref ? ref.current : ref); 30 | 31 | export interface RootCloseOptions { 32 | disabled?: boolean; 33 | clickTrigger?: MouseEvents; 34 | } 35 | /** 36 | * The `useRootClose` hook registers your callback on the document 37 | * when rendered. Powers the `` component. This is used achieve modal 38 | * style behavior where your callback is triggered when the user tries to 39 | * interact with the rest of the document or hits the `esc` key. 40 | * 41 | * @param {Ref| HTMLElement} ref The element boundary 42 | * @param {function} onRootClose 43 | * @param {object=} options 44 | * @param {boolean=} options.disabled 45 | * @param {string=} options.clickTrigger The DOM event name (click, mousedown, etc) to attach listeners on 46 | */ 47 | function useRootClose( 48 | ref: React.RefObject | Element | null | undefined, 49 | onRootClose: (e: Event) => void, 50 | { disabled, clickTrigger = 'click' }: RootCloseOptions = {}, 51 | ) { 52 | const preventMouseRootCloseRef = useRef(false); 53 | const onClose = onRootClose || noop; 54 | 55 | const handleMouseCapture = useCallback( 56 | (e) => { 57 | const currentTarget = getRefTarget(ref); 58 | 59 | warning( 60 | !!currentTarget, 61 | 'RootClose captured a close event but does not have a ref to compare it to. ' + 62 | 'useRootClose(), should be passed a ref that resolves to a DOM node', 63 | ); 64 | 65 | preventMouseRootCloseRef.current = 66 | !currentTarget || 67 | isModifiedEvent(e) || 68 | !isLeftClickEvent(e) || 69 | !!contains(currentTarget, e.composedPath?.()[0] ?? e.target); 70 | }, 71 | [ref], 72 | ); 73 | 74 | const handleMouse = useEventCallback((e: MouseEvent) => { 75 | if (!preventMouseRootCloseRef.current) { 76 | onClose(e); 77 | } 78 | }); 79 | 80 | const handleKeyUp = useEventCallback((e: KeyboardEvent) => { 81 | if (e.keyCode === escapeKeyCode) { 82 | onClose(e); 83 | } 84 | }); 85 | 86 | useEffect(() => { 87 | if (disabled || ref == null) return undefined; 88 | 89 | // Store the current event to avoid triggering handlers immediately 90 | // https://github.com/facebook/react/issues/20074 91 | let currentEvent = window.event; 92 | 93 | const doc = ownerDocument(getRefTarget(ref)); 94 | 95 | // Use capture for this listener so it fires before React's listener, to 96 | // avoid false positives in the contains() check below if the target DOM 97 | // element is removed in the React mouse callback. 98 | const removeMouseCaptureListener = listen( 99 | doc as any, 100 | clickTrigger, 101 | handleMouseCapture, 102 | true, 103 | ); 104 | 105 | const removeMouseListener = listen(doc as any, clickTrigger, (e) => { 106 | // skip if this event is the same as the one running when we added the handlers 107 | if (e === currentEvent) { 108 | currentEvent = undefined; 109 | return; 110 | } 111 | handleMouse(e); 112 | }); 113 | 114 | const removeKeyupListener = listen(doc as any, 'keyup', (e) => { 115 | // skip if this event is the same as the one running when we added the handlers 116 | if (e === currentEvent) { 117 | currentEvent = undefined; 118 | return; 119 | } 120 | handleKeyUp(e); 121 | }); 122 | 123 | let mobileSafariHackListeners = [] as Array<() => void>; 124 | if ('ontouchstart' in doc.documentElement) { 125 | mobileSafariHackListeners = [].slice 126 | .call(doc.body.children) 127 | .map((el) => listen(el, 'mousemove', noop)); 128 | } 129 | 130 | return () => { 131 | removeMouseCaptureListener(); 132 | removeMouseListener(); 133 | removeKeyupListener(); 134 | mobileSafariHackListeners.forEach((remove) => remove()); 135 | }; 136 | }, [ 137 | ref, 138 | disabled, 139 | clickTrigger, 140 | handleMouseCapture, 141 | handleMouse, 142 | handleKeyUp, 143 | ]); 144 | } 145 | 146 | export default useRootClose; 147 | -------------------------------------------------------------------------------- /src/useWaitForDOMRef.ts: -------------------------------------------------------------------------------- 1 | import ownerDocument from 'dom-helpers/ownerDocument'; 2 | import { useState, useEffect } from 'react'; 3 | 4 | export type DOMContainer = 5 | | T 6 | | React.RefObject 7 | | null 8 | | (() => T | React.RefObject | null); 9 | 10 | export const resolveContainerRef = ( 11 | ref: DOMContainer | undefined, 12 | ): T | HTMLBodyElement | null => { 13 | if (typeof document === 'undefined') return null; 14 | if (ref == null) return ownerDocument().body as HTMLBodyElement; 15 | if (typeof ref === 'function') ref = ref(); 16 | 17 | if (ref && 'current' in ref) ref = ref.current; 18 | if (ref?.nodeType) return ref || null; 19 | 20 | return null; 21 | }; 22 | 23 | export default function useWaitForDOMRef( 24 | ref: DOMContainer | undefined, 25 | onResolved?: (element: T | HTMLBodyElement) => void, 26 | ) { 27 | const [resolvedRef, setRef] = useState(() => resolveContainerRef(ref)); 28 | 29 | if (!resolvedRef) { 30 | const earlyRef = resolveContainerRef(ref); 31 | if (earlyRef) setRef(earlyRef); 32 | } 33 | 34 | useEffect(() => { 35 | if (onResolved && resolvedRef) { 36 | onResolved(resolvedRef); 37 | } 38 | }, [onResolved, resolvedRef]); 39 | 40 | useEffect(() => { 41 | const nextRef = resolveContainerRef(ref); 42 | if (nextRef !== resolvedRef) { 43 | setRef(nextRef); 44 | } 45 | }, [ref, resolvedRef]); 46 | 47 | return resolvedRef; 48 | } 49 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:mocha/recommended"], 3 | "env": { 4 | "mocha": true 5 | }, 6 | "globals": { 7 | "assert": true, 8 | "expect": true, 9 | "sinon": true 10 | }, 11 | "plugins": ["mocha"], 12 | "rules": { 13 | "no-script-url": 0, 14 | "no-unused-expressions": 0, 15 | "padded-blocks": 0, 16 | "react/no-multi-comp": 0, 17 | "react/prop-types": 0, 18 | "mocha/no-exclusive-tests": 2, 19 | "mocha/no-mocha-arrows": 0, 20 | "no-unused-vars": [2, { "varsIgnorePattern": "^_$" }] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/DropdownSpec.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { act } from 'react-dom/test-utils'; 5 | import simulant from 'simulant'; 6 | import Dropdown from '../src/Dropdown'; 7 | 8 | describe('', () => { 9 | const Menu = ({ 10 | usePopper, 11 | rootCloseEvent, 12 | renderSpy, 13 | popperConfig, 14 | ...props 15 | }) => ( 16 | 22 | {(menuProps, meta) => { 23 | const { show, toggle } = meta; 24 | renderSpy && renderSpy(meta); 25 | 26 | return ( 27 |
toggle(false)} 33 | style={{ display: show ? 'flex' : 'none' }} 34 | /> 35 | ); 36 | }} 37 | 38 | ); 39 | 40 | const Toggle = (props) => ( 41 | 42 | {(toggleProps) => ( 43 | 61 | 62 | 63 | 64 | 65 | 66 | )} 67 | 68 | ); 69 | 70 | let focusableContainer; 71 | 72 | beforeEach(() => { 73 | focusableContainer = document.createElement('div'); 74 | document.body.appendChild(focusableContainer); 75 | }); 76 | 77 | afterEach(() => { 78 | ReactDOM.unmountComponentAtNode(focusableContainer); 79 | document.body.removeChild(focusableContainer); 80 | }); 81 | 82 | it('renders toggle with Dropdown.Toggle', () => { 83 | const buttonNode = mount() 84 | .assertSingle('button.toggle') 85 | .getDOMNode(); 86 | 87 | buttonNode.textContent.should.match(/Child Title/); 88 | 89 | buttonNode.getAttribute('aria-haspopup').should.equal('true'); 90 | buttonNode.getAttribute('aria-expanded').should.equal('false'); 91 | buttonNode.getAttribute('id').should.be.ok; 92 | }); 93 | 94 | it('forwards alignEnd to menu', () => { 95 | const renderSpy = sinon.spy((meta) => { 96 | meta.alignEnd.should.equal(true); 97 | }); 98 | 99 | mount( 100 | , 101 | ); 102 | 103 | renderSpy.should.have.been.called; 104 | }); 105 | 106 | // NOTE: The onClick event handler is invoked for both the Enter and Space 107 | // keys as well since the component is a button. I cannot figure out how to 108 | // get ReactTestUtils to simulate such though. 109 | it('toggles open/closed when clicked', () => { 110 | const wrapper = mount(); 111 | 112 | wrapper.assertNone('.show'); 113 | wrapper.assertNone('ReactOverlaysDropdownMenu > *'); 114 | wrapper.assertSingle('button[aria-expanded=false]').simulate('click'); 115 | 116 | wrapper.assertSingle('ReactOverlaysDropdown'); 117 | 118 | wrapper.assertSingle('div[data-show=true]'); 119 | 120 | wrapper.assertSingle('button[aria-expanded=true]').simulate('click'); 121 | 122 | wrapper.assertNone('.show'); 123 | 124 | wrapper.assertSingle('button[aria-expanded=false]'); 125 | }); 126 | 127 | it('closes when clicked outside', () => { 128 | const closeSpy = sinon.spy(); 129 | const wrapper = mount(); 130 | 131 | wrapper.find('.toggle').simulate('click'); 132 | 133 | act(() => { 134 | // Use native events as the click doesn't have to be in the React portion 135 | simulant.fire(document.body, 'click'); 136 | }); 137 | 138 | closeSpy.should.have.been.calledTwice; 139 | closeSpy.lastCall.args[0].should.equal(false); 140 | }); 141 | 142 | it('closes when mousedown outside if rootCloseEvent set', () => { 143 | const closeSpy = sinon.spy(); 144 | 145 | const wrapper = mount( 146 | 147 |
148 | Child Title, 149 | 150 | 151 | 152 | 153 |
154 |
, 155 | ); 156 | 157 | act(() => { 158 | wrapper.find('.toggle').simulate('click'); 159 | }); 160 | 161 | // Use native events as the click doesn't have to be in the React portion 162 | const event = new MouseEvent('mousedown'); 163 | document.dispatchEvent(event); 164 | 165 | closeSpy.should.have.been.calledTwice; 166 | closeSpy.lastCall.args[0].should.equal(false); 167 | }); 168 | 169 | it('when focused and closed toggles open when the key "down" is pressed', () => { 170 | const wrapper = mount(, { attachTo: focusableContainer }); 171 | 172 | simulant.fire(wrapper.find('.toggle').getDOMNode(), 'keydown', { 173 | key: 'ArrowDown', 174 | }); 175 | 176 | wrapper.update().assertSingle('ReactOverlaysDropdownMenu div'); 177 | }); 178 | 179 | it('closes when item is clicked', () => { 180 | const onToggle = sinon.spy(); 181 | 182 | const wrapper = mount().setProps({ 183 | show: true, 184 | onToggle, 185 | }); 186 | 187 | wrapper.assertSingle('ReactOverlaysDropdown[show=true]'); 188 | 189 | wrapper.find('button').last().simulate('click'); 190 | 191 | onToggle.should.have.been.calledWith(false); 192 | }); 193 | 194 | it('does not close when onToggle is controlled', () => { 195 | const onToggle = sinon.spy(); 196 | 197 | const wrapper = mount(); 198 | 199 | wrapper.find('.toggle').simulate('click'); 200 | wrapper.find('.menu > button').first().simulate('click'); 201 | 202 | onToggle.should.have.been.calledWith(false); 203 | wrapper.find('ReactOverlaysDropdown').prop('show').should.equal(true); 204 | }); 205 | 206 | it('has aria-labelledby same id as toggle button', () => { 207 | const wrapper = mount(); 208 | 209 | wrapper 210 | .find('.toggle') 211 | .getDOMNode() 212 | .getAttribute('id') 213 | .should.equal( 214 | wrapper.find('.menu').getDOMNode().getAttribute('aria-labelledby'), 215 | ); 216 | }); 217 | 218 | describe('focusable state', () => { 219 | it('when focus should not be moved to first item when focusFirstItemOnShow is `false`', () => { 220 | const wrapper = mount( 221 | 222 |
223 | Child Title, 224 | 225 | 226 | 227 |
228 |
, 229 | { attachTo: focusableContainer }, 230 | ); 231 | 232 | wrapper.find('.toggle').getDOMNode().focus(); 233 | 234 | wrapper.find('.toggle').simulate('click'); 235 | 236 | document.activeElement.should.equal(wrapper.find('.toggle').getDOMNode()); 237 | }); 238 | 239 | it('when focused and closed sets focus on first menu item when the key "down" is pressed for role="menu"', () => { 240 | const wrapper = mount( 241 | 242 |
243 | Child Title, 244 | 245 | 246 | 247 | 248 |
249 |
, 250 | { attachTo: focusableContainer }, 251 | ); 252 | 253 | const toggle = wrapper.find('.toggle').getDOMNode(); 254 | toggle.focus(); 255 | 256 | simulant.fire(toggle, 'keydown', { 257 | key: 'ArrowDown', 258 | }); 259 | 260 | document.activeElement.should.equal( 261 | wrapper.update().find('.menu > button').first().getDOMNode(), 262 | ); 263 | }); 264 | 265 | it('when focused and closed sets focus on first menu item when the focusFirstItemOnShow is true', () => { 266 | const wrapper = mount( 267 | 268 |
269 | Child Title, 270 | 271 | 272 | 273 | 274 |
275 |
, 276 | { attachTo: focusableContainer }, 277 | ); 278 | 279 | wrapper.find('.toggle').getDOMNode().focus(); 280 | 281 | wrapper.find('.toggle').simulate('click'); 282 | 283 | document.activeElement.should.equal( 284 | wrapper.find('.menu > button').first().getDOMNode(), 285 | ); 286 | }); 287 | 288 | it('when open and the key "Escape" is pressed the menu is closed and focus is returned to the button', () => { 289 | const wrapper = mount(, { 290 | attachTo: focusableContainer, 291 | }); 292 | 293 | const firstItem = wrapper.find('.menu > button').first().getDOMNode(); 294 | 295 | firstItem.focus(); 296 | document.activeElement.should.equal(firstItem); 297 | 298 | act(() => { 299 | simulant.fire(firstItem, 'keydown', { 300 | key: 'Escape', 301 | }); 302 | }); 303 | 304 | console.log(document.activeElement); 305 | document.activeElement.should.equal( 306 | wrapper.update().find('.toggle').getDOMNode(), 307 | ); 308 | }); 309 | 310 | it('when open and the key "tab" is pressed the menu is closed and focus is progress to the next focusable element', () => { 311 | const wrapper = mount( 312 |
313 | 314 | 315 |
, 316 | { attachTo: focusableContainer }, 317 | ); 318 | 319 | const toggle = wrapper.find('.toggle').getDOMNode(); 320 | 321 | toggle.focus(); 322 | 323 | simulant.fire(toggle, 'keydown', { 324 | key: 'Tab', 325 | }); 326 | 327 | simulant.fire(document, 'keyup', { 328 | key: 'Tab', 329 | }); 330 | toggle.getAttribute('aria-expanded').should.equal('false'); 331 | 332 | // simulating a tab event doesn't actually shift focus. 333 | // at least that seems to be the case according to SO. 334 | // hence no assert on the input having focus. 335 | }); 336 | }); 337 | 338 | it('should not call onToggle if the menu is not open and "tab" is pressed', () => { 339 | const onToggleSpy = sinon.spy(); 340 | const wrapper = mount(, { 341 | attachTo: focusableContainer, 342 | }); 343 | 344 | const toggle = wrapper.find('.toggle').getDOMNode(); 345 | toggle.focus(); 346 | 347 | simulant.fire(toggle, 'keydown', { 348 | key: 'Tab', 349 | }); 350 | 351 | simulant.fire(document, 'keyup', { 352 | key: 'Tab', 353 | }); 354 | 355 | onToggleSpy.should.not.be.called; 356 | }); 357 | 358 | describe('popper config', () => { 359 | it('can add modifiers', (done) => { 360 | const spy = sinon.spy(); 361 | const popper = { 362 | modifiers: [ 363 | { 364 | name: 'test', 365 | enabled: true, 366 | phase: 'write', 367 | fn: spy, 368 | }, 369 | ], 370 | }; 371 | 372 | mount( 373 | 374 |
375 | Child Title 376 | 377 | 378 | 379 | 380 |
381 |
, 382 | ); 383 | 384 | setTimeout(() => { 385 | spy.should.have.been.calledOnce; 386 | done(); 387 | }); 388 | }); 389 | }); 390 | }); 391 | -------------------------------------------------------------------------------- /test/ModalManagerSpec.js: -------------------------------------------------------------------------------- 1 | import css from 'dom-helpers/css'; 2 | import getScrollbarSize from 'dom-helpers/scrollbarSize'; 3 | 4 | import ModalManager from '../src/ModalManager'; 5 | 6 | import { injectCss } from './helpers'; 7 | 8 | const createModal = () => ({ dialog: null, backdrop: null }); 9 | 10 | describe('ModalManager', () => { 11 | let container, manager; 12 | 13 | beforeEach(() => { 14 | manager = new ModalManager(); 15 | container = document.createElement('div'); 16 | container.setAttribute('id', 'container'); 17 | document.body.appendChild(container); 18 | }); 19 | 20 | afterEach(() => { 21 | document.body.removeChild(container); 22 | container = null; 23 | manager = null; 24 | }); 25 | 26 | it('should add Modal', () => { 27 | let modal = createModal(); 28 | 29 | manager.add(modal, container); 30 | 31 | expect(manager.modals.length).to.equal(1); 32 | expect(manager.modals[0]).to.equal(modal); 33 | 34 | expect(manager.containers[0]).to.equal(container); 35 | expect(manager.data[0]).to.eql({ 36 | modals: [modal], 37 | classes: [], 38 | overflowing: false, 39 | style: { 40 | overflow: '', 41 | paddingRight: '', 42 | }, 43 | }); 44 | }); 45 | 46 | it('should not add a modal twice', () => { 47 | let modal = createModal(); 48 | manager.add(modal, container); 49 | manager.add(modal, container); 50 | 51 | expect(manager.modals.length).to.equal(1); 52 | expect(manager.containers.length).to.equal(1); 53 | expect(manager.data[0].modals.length).to.equal(1); 54 | }); 55 | 56 | it('should not add a container twice', () => { 57 | let modalA = createModal(); 58 | let modalB = createModal(); 59 | 60 | manager.add(modalA, container); 61 | manager.add(modalB, container); 62 | 63 | expect(manager.modals.length).to.equal(2); 64 | expect(manager.containers.length).to.equal(1); 65 | expect(manager.data[0].modals.length).to.equal(2); 66 | }); 67 | 68 | it('should remove modal', () => { 69 | let modalA = createModal(); 70 | let modalB = createModal(); 71 | 72 | manager.add(modalA, container); 73 | manager.add(modalB, container); 74 | 75 | manager.remove(modalA); 76 | 77 | expect(manager.modals.length).to.equal(1); 78 | expect(manager.containers.length).to.equal(1); 79 | expect(manager.data[0].modals.length).to.equal(1); 80 | }); 81 | 82 | it('should remove container when there are no more modals associated with it', () => { 83 | let modalA = createModal(); 84 | let modalB = createModal(); 85 | 86 | manager.add(modalA, container); 87 | manager.add(modalB, container); 88 | 89 | expect(manager.data[0].modals.length).to.equal(2); 90 | 91 | manager.remove(modalA); 92 | manager.remove(modalB); 93 | 94 | expect(manager.modals.length).to.equal(0); 95 | expect(manager.containers.length).to.equal(0); 96 | expect(manager.data.length).to.equal(0); 97 | }); 98 | 99 | describe('container aria-hidden', () => { 100 | let app; 101 | 102 | beforeEach(() => { 103 | app = document.createElement('div'); 104 | app.setAttribute('id', 'app-root'); 105 | container.appendChild(app); 106 | }); 107 | 108 | it('should add aria-hidden to container siblings', () => { 109 | manager.add(createModal(), container); 110 | 111 | expect(app.getAttribute('aria-hidden')).to.equal('true'); 112 | }); 113 | 114 | it('should not add aria-hidden to modal', () => { 115 | let modal = createModal(); 116 | let mount = document.createElement('div'); 117 | 118 | modal.dialog = mount; 119 | container.appendChild(mount); 120 | manager.add(modal, container); 121 | 122 | expect(modal.dialog.getAttribute('aria-hidden')).to.equal(null); 123 | }); 124 | 125 | it('should add aria-hidden to previous modals', () => { 126 | let modalA = createModal(); 127 | let mount = document.createElement('div'); 128 | 129 | modalA.dialog = mount; 130 | container.appendChild(mount); 131 | 132 | manager.add(modalA, container); 133 | manager.add(createModal(), container); 134 | 135 | expect(app.getAttribute('aria-hidden')).to.equal('true'); 136 | expect(mount.getAttribute('aria-hidden')).to.equal('true'); 137 | }); 138 | 139 | it('should remove aria-hidden on americas next top modal', () => { 140 | let modalA = createModal(); 141 | let modalB = createModal(); 142 | let mount = document.createElement('div'); 143 | 144 | modalA.dialog = mount; 145 | container.appendChild(mount); 146 | 147 | manager.add(modalA, container); 148 | manager.add(modalB, container); 149 | 150 | expect(mount.getAttribute('aria-hidden')).to.equal('true'); 151 | 152 | manager.remove(modalB, container); 153 | 154 | expect(mount.getAttribute('aria-hidden')).to.equal(null); 155 | }); 156 | 157 | it('should remove aria-hidden on siblings', () => { 158 | let modal = createModal(); 159 | 160 | manager.add(modal, container); 161 | 162 | expect(app.getAttribute('aria-hidden')).to.equal('true'); 163 | 164 | manager.remove(modal, container); 165 | 166 | expect(app.getAttribute('aria-hidden')).to.equal(null); 167 | }); 168 | }); 169 | 170 | describe('container styles', () => { 171 | beforeEach(() => { 172 | container.appendChild(document.createElement('div')); 173 | injectCss(` 174 | #container { 175 | padding-right: 20px; 176 | overflow: scroll; 177 | height: 300px; 178 | } 179 | 180 | #container > div { 181 | height: 1000px; 182 | } 183 | `); 184 | }); 185 | 186 | afterEach(() => injectCss.reset()); 187 | 188 | it('should set container overflow to hidden ', () => { 189 | let modal = createModal(); 190 | 191 | expect(container.style.overflow).to.equal(''); 192 | 193 | manager.add(modal, container); 194 | 195 | expect(container.style.overflow).to.equal('hidden'); 196 | }); 197 | 198 | it('should respect handleContainerOverflow', () => { 199 | let modal = createModal(); 200 | 201 | expect(container.style.overflow).to.equal(''); 202 | 203 | new ModalManager({ handleContainerOverflow: false }).add( 204 | modal, 205 | container, 206 | ); 207 | 208 | expect(container.style.overflow).to.equal(''); 209 | }); 210 | 211 | it('should set add to existing container padding', () => { 212 | let modal = createModal(); 213 | manager.add(modal, container); 214 | 215 | expect(container.style.paddingRight).to.equal( 216 | `${getScrollbarSize() + 20}px`, 217 | ); 218 | }); 219 | 220 | it('should add container classes ', () => { 221 | let modal = createModal(); 222 | 223 | expect(container.className).to.equal(''); 224 | 225 | manager.add(modal, container, 'test test-other'); 226 | 227 | expect(container.className).to.equal('test test-other'); 228 | }); 229 | 230 | it('should restore container overflow style', () => { 231 | let modal = createModal(); 232 | 233 | container.style.overflow = 'scroll'; 234 | 235 | expect(container.style.overflow).to.equal('scroll'); 236 | 237 | manager.add(modal, container); 238 | manager.remove(modal); 239 | 240 | expect(container.style.overflow).to.equal('scroll'); 241 | }); 242 | 243 | it('should reset overflow style to the computed one', () => { 244 | let modal = createModal(); 245 | 246 | expect(css(container, 'overflow')).to.equal('scroll'); 247 | 248 | manager.add(modal, container); 249 | manager.remove(modal); 250 | 251 | expect(container.style.overflow).to.equal(''); 252 | expect(css(container, 'overflow')).to.equal('scroll'); 253 | }); 254 | 255 | it('should only remove styles when there are no associated modals', () => { 256 | let modalA = createModal(); 257 | let modalB = createModal(); 258 | 259 | expect(container.style.overflow).to.equal(''); 260 | 261 | manager.add(modalA, container); 262 | manager.add(modalB, container); 263 | 264 | manager.remove(modalB); 265 | 266 | expect(container.style.overflow).to.equal('hidden'); 267 | 268 | manager.remove(modalA); 269 | 270 | expect(container.style.overflow).to.equal(''); 271 | expect(container.style.paddingRight).to.equal(''); 272 | }); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /test/ModalSpec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | import jQuery from 'jquery'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { act } from 'react-dom/test-utils'; 6 | import Transition from 'react-transition-group/Transition'; 7 | import simulant from 'simulant'; 8 | 9 | import { mount } from 'enzyme'; 10 | 11 | import Modal from '../src/Modal'; 12 | 13 | const $ = (componentOrNode) => jQuery(ReactDOM.findDOMNode(componentOrNode)); 14 | 15 | describe('', () => { 16 | let attachTo; 17 | let wrapper; 18 | 19 | const mountWithRef = (el, options) => { 20 | const ref = React.createRef(); 21 | const Why = (props) => React.cloneElement(el, { ...props, ref }); 22 | wrapper = mount(, options); 23 | return ref; 24 | }; 25 | 26 | beforeEach(() => { 27 | attachTo = document.createElement('div'); 28 | document.body.appendChild(attachTo); 29 | }); 30 | 31 | afterEach(() => { 32 | if (wrapper) { 33 | wrapper.unmount(); 34 | wrapper = null; 35 | } 36 | attachTo.remove(); 37 | }); 38 | 39 | it('should render the modal content', () => { 40 | const ref = mountWithRef( 41 | 42 | Message 43 | , 44 | { attachTo }, 45 | ); 46 | 47 | expect(ref.current.dialog.querySelectorAll('strong')).to.have.lengthOf(1); 48 | }); 49 | 50 | it('should disable scrolling on the modal container while open', (done) => { 51 | const modal = React.createRef(); 52 | 53 | class Container extends React.Component { 54 | ref = React.createRef(); 55 | 56 | state = { 57 | modalOpen: true, 58 | }; 59 | 60 | handleCloseModal = () => { 61 | this.setState({ modalOpen: false }); 62 | }; 63 | 64 | render() { 65 | return ( 66 |
67 | 73 | Message 74 | 75 |
76 | ); 77 | } 78 | } 79 | 80 | wrapper = mount(, { attachTo }); 81 | 82 | setTimeout(() => { 83 | const container = wrapper.instance().ref.current; 84 | 85 | let backdrop = modal.current.backdrop; 86 | 87 | expect($(container).css('overflow')).to.equal('hidden'); 88 | 89 | backdrop.click(); 90 | 91 | expect($(container).css('overflow')).to.not.equal('hidden'); 92 | 93 | done(); 94 | }); 95 | }); 96 | 97 | it('should add and remove container classes', () => { 98 | const modal = React.createRef(); 99 | 100 | class Container extends React.Component { 101 | state = { modalOpen: true }; 102 | 103 | ref = React.createRef(); 104 | 105 | handleCloseModal = () => { 106 | this.setState({ modalOpen: false }); 107 | }; 108 | 109 | render() { 110 | return ( 111 |
112 | 119 | Message 120 | 121 |
122 | ); 123 | } 124 | } 125 | 126 | wrapper = mount(, { attachTo }); 127 | 128 | const container = wrapper.instance().ref.current; 129 | 130 | let backdrop = modal.current.backdrop; 131 | 132 | expect($(container).hasClass('test test2')).to.be.true; 133 | 134 | backdrop.click(); 135 | 136 | expect($(container).hasClass('test test2')).to.be.false; 137 | }); 138 | 139 | it('should fire backdrop click callback', () => { 140 | let onClickSpy = sinon.spy(); 141 | let ref = mountWithRef( 142 | 143 | Message 144 | , 145 | { attachTo }, 146 | ); 147 | 148 | let backdrop = ref.current.backdrop; 149 | 150 | backdrop.click(); 151 | 152 | expect(onClickSpy).to.have.been.calledOnce; 153 | }); 154 | 155 | it('should close the modal when the backdrop is clicked', (done) => { 156 | let doneOp = () => { 157 | done(); 158 | }; 159 | let ref = mountWithRef( 160 | 161 | Message 162 | , 163 | { attachTo }, 164 | ); 165 | 166 | let backdrop = ref.current.backdrop; 167 | 168 | backdrop.click(); 169 | }); 170 | 171 | it('should not close the modal when the "static" backdrop is clicked', () => { 172 | let onHideSpy = sinon.spy(); 173 | 174 | let ref = mountWithRef( 175 | 176 | Message 177 | , 178 | { attachTo }, 179 | ); 180 | 181 | let { backdrop } = ref.current; 182 | 183 | backdrop.click(); 184 | 185 | expect(onHideSpy).to.not.have.been.called; 186 | }); 187 | 188 | it('should close the modal when the esc key is pressed', (done) => { 189 | let doneOp = () => { 190 | done(); 191 | }; 192 | 193 | let ref = mountWithRef( 194 | 195 | Message 196 | , 197 | { attachTo }, 198 | ); 199 | 200 | let { backdrop } = ref.current; 201 | 202 | simulant.fire(backdrop, 'keydown', { keyCode: 27 }); 203 | }); 204 | 205 | it('should not trigger onHide if e.preventDefault() called', () => { 206 | const onHideSpy = sinon.spy(); 207 | const onEscapeKeyDown = (e) => { 208 | e.preventDefault(); 209 | }; 210 | 211 | let ref = mountWithRef( 212 | 213 | Message 214 | , 215 | { attachTo }, 216 | ); 217 | 218 | let { backdrop } = ref.current; 219 | 220 | simulant.fire(backdrop, 'keydown', { keyCode: 27 }); 221 | expect(onHideSpy).to.not.have.been.called; 222 | }); 223 | 224 | it('should add role to child', () => { 225 | let dialog; 226 | wrapper = mount( 227 | 228 | { 230 | dialog = r; 231 | }} 232 | > 233 | Message 234 | 235 | , 236 | { attachTo }, 237 | ); 238 | 239 | expect(dialog.getAttribute('role')).to.equal('document'); 240 | }); 241 | 242 | it('should allow custom rendering', () => { 243 | let dialog; 244 | wrapper = mount( 245 | ( 248 | { 252 | dialog = r; 253 | }} 254 | > 255 | Message 256 | 257 | )} 258 | />, 259 | { attachTo }, 260 | ); 261 | 262 | expect(dialog.getAttribute('role')).to.equal('group'); 263 | }); 264 | 265 | it('should unbind listeners when unmounted', () => { 266 | wrapper = mount( 267 |
268 | 269 | Foo bar 270 | 271 |
, 272 | { attachTo }, 273 | ); 274 | 275 | assert.ok(document.body.classList.contains('modal-open')); 276 | 277 | mount(
, { attachTo }); 278 | 279 | assert.ok(!document.body.classList.contains('modal-open')); 280 | }); 281 | 282 | it('should pass transition callbacks to Transition', (done) => { 283 | let count = 0; 284 | let increment = () => count++; 285 | 286 | wrapper = mount( 287 | } 290 | onExit={increment} 291 | onExiting={increment} 292 | onExited={() => { 293 | increment(); 294 | expect(count).to.equal(6); 295 | done(); 296 | }} 297 | onEnter={increment} 298 | onEntering={increment} 299 | onEntered={() => { 300 | increment(); 301 | wrapper.setProps({ show: false }); 302 | }} 303 | > 304 | Message 305 | , 306 | { attachTo }, 307 | ); 308 | }); 309 | 310 | it('should fire show callback on mount', () => { 311 | let onShowSpy = sinon.spy(); 312 | 313 | mount( 314 | 315 | Message 316 | , 317 | { attachTo }, 318 | ); 319 | 320 | expect(onShowSpy).to.have.been.calledOnce; 321 | }); 322 | 323 | it('should fire show callback on update', () => { 324 | let onShowSpy = sinon.spy(); 325 | wrapper = mount( 326 | 327 | Message 328 | , 329 | { attachTo }, 330 | ); 331 | 332 | wrapper.setProps({ show: true }); 333 | 334 | expect(onShowSpy).to.have.been.calledOnce; 335 | }); 336 | 337 | it('should fire onEscapeKeyDown callback on escape close', () => { 338 | let onEscapeSpy = sinon.spy(); 339 | 340 | let ref = mountWithRef( 341 | 342 | Message 343 | , 344 | { attachTo }, 345 | ); 346 | 347 | wrapper.setProps({ show: true }); 348 | 349 | act(() => { 350 | simulant.fire(ref.current.backdrop, 'keydown', { keyCode: 27 }); 351 | }); 352 | 353 | expect(onEscapeSpy).to.have.been.calledOnce; 354 | }); 355 | 356 | it('should accept role on the Modal', () => { 357 | const ref = mountWithRef( 358 | 359 | Message 360 | , 361 | { attachTo }, 362 | ); 363 | 364 | expect(ref.current.dialog.getAttribute('role')).to.equal('alertdialog'); 365 | }); 366 | 367 | it('should accept the `aria-describedby` property on the Modal', () => { 368 | const ref = mountWithRef( 369 | 370 | Message 371 | , 372 | { attachTo }, 373 | ); 374 | 375 | expect(ref.current.dialog.getAttribute('aria-describedby')).to.equal( 376 | 'modal-description', 377 | ); 378 | }); 379 | 380 | describe('Focused state', () => { 381 | let focusableContainer = null; 382 | 383 | beforeEach(() => { 384 | focusableContainer = document.createElement('div'); 385 | focusableContainer.tabIndex = 0; 386 | focusableContainer.className = 'focus-container'; 387 | document.body.appendChild(focusableContainer); 388 | focusableContainer.focus(); 389 | }); 390 | 391 | afterEach(() => { 392 | ReactDOM.unmountComponentAtNode(focusableContainer); 393 | document.body.removeChild(focusableContainer); 394 | }); 395 | 396 | it('should focus on the Modal when it is opened', () => { 397 | expect(document.activeElement).to.equal(focusableContainer); 398 | 399 | wrapper = mount( 400 | 401 | Message 402 | , 403 | { attachTo: focusableContainer }, 404 | ); 405 | 406 | document.activeElement.className.should.contain('modal'); 407 | 408 | wrapper.setProps({ show: false }); 409 | 410 | expect(document.activeElement).to.equal(focusableContainer); 411 | }); 412 | 413 | it('should not focus on the Modal when autoFocus is false', () => { 414 | mount( 415 | 416 | Message 417 | , 418 | { attachTo: focusableContainer }, 419 | ); 420 | 421 | expect(document.activeElement).to.equal(focusableContainer); 422 | }); 423 | 424 | it('should not focus Modal when child has focus', () => { 425 | expect(document.activeElement).to.equal(focusableContainer); 426 | 427 | mount( 428 | 429 |
430 | 431 |
432 |
, 433 | { attachTo: focusableContainer }, 434 | ); 435 | 436 | let input = document.getElementsByTagName('input')[0]; 437 | 438 | expect(document.activeElement).to.equal(input); 439 | }); 440 | 441 | it('should return focus to the modal', (done) => { 442 | expect(document.activeElement).to.equal(focusableContainer); 443 | 444 | mount( 445 | 446 |
447 | 448 |
449 |
, 450 | { attachTo: focusableContainer }, 451 | ); 452 | 453 | focusableContainer.focus(); 454 | // focus reset runs in a timeout 455 | setTimeout(() => { 456 | document.activeElement.className.should.contain('modal'); 457 | done(); 458 | }, 50); 459 | }); 460 | 461 | it('should not attempt to focus nonexistent children', () => { 462 | // eslint-disable-next-line no-unused-vars 463 | const Dialog = React.forwardRef((_, __) => null); 464 | 465 | mount( 466 | 467 | 468 | , 469 | { attachTo: focusableContainer }, 470 | ); 471 | }); 472 | }); 473 | }); 474 | -------------------------------------------------------------------------------- /test/PortalSpec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { act } from 'react-dom/test-utils'; 4 | import { mount } from 'enzyme'; 5 | 6 | import Portal from '../src/Portal'; 7 | 8 | describe('Portal', () => { 9 | it('should render overlay into container (document)', () => { 10 | class Container extends React.Component { 11 | componentDidMount() { 12 | expect(this.div).to.exist; 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 |
{ 20 | this.div = c; 21 | }} 22 | id="test1" 23 | /> 24 | 25 | ); 26 | } 27 | } 28 | 29 | mount(); 30 | 31 | expect(document.querySelectorAll('#test1')).to.have.lengthOf(1); 32 | }); 33 | 34 | it('should render overlay into container (DOMNode)', () => { 35 | const container = document.createElement('div'); 36 | 37 | class Container extends React.Component { 38 | componentDidMount() { 39 | expect(this.div).to.exist; 40 | } 41 | 42 | render() { 43 | return ( 44 | 45 |
{ 47 | this.div = c; 48 | }} 49 | id="test1" 50 | /> 51 | 52 | ); 53 | } 54 | } 55 | 56 | mount(); 57 | 58 | expect(container.querySelectorAll('#test1')).to.have.lengthOf(1); 59 | }); 60 | 61 | it('should render overlay into container (ReactComponent)', () => { 62 | class Container extends React.Component { 63 | container = React.createRef(); 64 | 65 | componentDidMount() { 66 | expect(this.div).to.not.exist; 67 | } 68 | 69 | render() { 70 | return ( 71 |
72 | 73 |
{ 75 | this.div = c; 76 | }} 77 | id="test1" 78 | /> 79 | 80 |
81 | ); 82 | } 83 | } 84 | 85 | let instance; 86 | act(() => { 87 | instance = mount().instance(); 88 | }); 89 | 90 | expect(instance.div).to.exist; 91 | expect( 92 | ReactDOM.findDOMNode(instance).querySelectorAll('#test1'), 93 | ).to.have.lengthOf(1); 94 | }); 95 | 96 | it('should not fail to render a null overlay', () => { 97 | class Container extends React.Component { 98 | container = React.createRef(); 99 | 100 | render() { 101 | return ( 102 |
103 | 104 |
105 | ); 106 | } 107 | } 108 | 109 | const nodes = mount().getDOMNode().childNodes; 110 | 111 | expect(nodes).to.be.empty; 112 | }); 113 | 114 | it('should unmount when parent unmounts', () => { 115 | class Parent extends React.Component { 116 | state = { show: true }; 117 | 118 | render() { 119 | return
{(this.state.show && ) || null}
; 120 | } 121 | } 122 | 123 | class Child extends React.Component { 124 | render() { 125 | return ( 126 |
127 |
{ 129 | this.container = c; 130 | }} 131 | /> 132 | this.container}> 133 |
134 | 135 |
136 | ); 137 | } 138 | } 139 | 140 | const instance = mount(); 141 | 142 | instance.setState({ show: false }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /test/WaitForContainerSpec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-shadow */ 2 | import React, { useRef } from 'react'; 3 | import { act } from 'react-dom/test-utils'; 4 | import { mount } from 'enzyme'; 5 | 6 | import useWaitForDOMRef from '../src/useWaitForDOMRef'; 7 | 8 | describe('useWaitForDOMRef', () => { 9 | it('should resolve on first render if possible (element)', () => { 10 | let renderCount = 0; 11 | const container = document.createElement('div'); 12 | 13 | function Test({ container, onResolved }) { 14 | useWaitForDOMRef(container, onResolved); 15 | renderCount++; 16 | return null; 17 | } 18 | 19 | const onResolved = sinon.spy((resolved) => { 20 | expect(resolved).to.equal(container); 21 | }); 22 | 23 | act(() => { 24 | mount(); 25 | }); 26 | 27 | renderCount.should.equal(1); 28 | onResolved.should.have.been.calledOnce; 29 | }); 30 | 31 | it('should resolve on first render if possible (ref)', () => { 32 | let renderCount = 0; 33 | const container = React.createRef(); 34 | container.current = document.createElement('div'); 35 | 36 | function Test({ container, onResolved }) { 37 | useWaitForDOMRef(container, onResolved); 38 | renderCount++; 39 | return null; 40 | } 41 | 42 | const onResolved = sinon.spy((resolved) => { 43 | expect(resolved).to.equal(container.current); 44 | }); 45 | 46 | act(() => { 47 | mount(); 48 | }); 49 | 50 | renderCount.should.equal(1); 51 | onResolved.should.have.been.calledOnce; 52 | }); 53 | 54 | it('should resolve on first render if possible (function)', () => { 55 | const div = document.createElement('div'); 56 | const container = () => div; 57 | let renderCount = 0; 58 | 59 | function Test({ container, onResolved }) { 60 | useWaitForDOMRef(container, onResolved); 61 | renderCount++; 62 | return null; 63 | } 64 | 65 | const onResolved = sinon.spy((resolved) => { 66 | expect(resolved).to.equal(div); 67 | }); 68 | 69 | act(() => { 70 | mount(); 71 | }); 72 | renderCount.should.equal(1); 73 | onResolved.should.have.been.calledOnce; 74 | }); 75 | 76 | it('should resolve after if required', () => { 77 | let renderCount = 0; 78 | 79 | function Test({ container, onResolved }) { 80 | useWaitForDOMRef(container, onResolved); 81 | renderCount++; 82 | return null; 83 | } 84 | 85 | const onResolved = sinon.spy((resolved) => { 86 | expect(resolved.tagName).to.equal('DIV'); 87 | }); 88 | 89 | function Wrapper() { 90 | const container = useRef(null); 91 | 92 | return ( 93 | <> 94 | 95 |
96 | 97 | ); 98 | } 99 | act(() => { 100 | mount().update(); 101 | }); 102 | renderCount.should.equal(2); 103 | onResolved.should.have.been.calledOnce; 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | export function shouldWarn(about) { 2 | console.error.expected.push(about); 3 | } 4 | 5 | let style; 6 | let seen = []; 7 | 8 | export function injectCss(rules) { 9 | if (seen.indexOf(rules) !== -1) { 10 | return; 11 | } 12 | 13 | style = 14 | style || 15 | (function iife() { 16 | let _style = document.createElement('style'); 17 | _style.appendChild(document.createTextNode('')); 18 | document.head.appendChild(_style); 19 | return _style; 20 | })(); 21 | 22 | seen.push(rules); 23 | style.innerHTML += `\n${rules}`; 24 | } 25 | 26 | injectCss.reset = () => { 27 | if (style) { 28 | document.head.removeChild(style); 29 | } 30 | style = null; 31 | seen = []; 32 | }; 33 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable mocha/no-hooks-for-single-case, mocha/no-top-level-hooks */ 2 | 3 | import '@babel/polyfill'; 4 | 5 | import { format } from 'util'; 6 | import chai from 'chai'; 7 | import Enzyme, { ReactWrapper, ShallowWrapper } from 'enzyme'; 8 | import Adapter from 'enzyme-adapter-react-16'; 9 | import sinonChai from 'sinon-chai'; 10 | 11 | Enzyme.configure({ adapter: new Adapter() }); 12 | 13 | function assertLength(length) { 14 | return function $assertLength(selector) { 15 | let result = this.find(selector); 16 | expect(result).to.have.length(length); 17 | return result; 18 | }; 19 | } 20 | 21 | function print() { 22 | return this.tap((f) => console.log(f.debug())); 23 | } 24 | 25 | ReactWrapper.prototype.assertSingle = assertLength(1); 26 | ShallowWrapper.prototype.assertSingle = assertLength(1); 27 | 28 | ReactWrapper.prototype.assertNone = assertLength(0); 29 | ShallowWrapper.prototype.assertNone = assertLength(0); 30 | 31 | ReactWrapper.prototype.print = print; 32 | ShallowWrapper.prototype.print = print; 33 | 34 | chai.should(); 35 | chai.use(sinonChai); 36 | 37 | global.expect = chai.expect; 38 | global.assert = chai.assert; 39 | 40 | beforeEach(() => { 41 | sinon.stub(console, 'error').callsFake((...args) => { 42 | const msg = format(...args); 43 | let expected = false; 44 | 45 | console.error.expected.forEach((about) => { 46 | if (msg.indexOf(about) !== -1) { 47 | console.error.warned[about] = true; 48 | expected = true; 49 | } 50 | }); 51 | 52 | if (expected) { 53 | return; 54 | } 55 | 56 | console.error.threw = true; 57 | throw new Error(msg); 58 | }); 59 | 60 | console.error.expected = []; 61 | console.error.warned = Object.create(null); 62 | console.error.threw = false; 63 | }); 64 | 65 | afterEach(() => { 66 | if (!console.error.threw && console.error.expected.length) { 67 | expect(console.error.warned).to.have.keys(console.error.expected); 68 | } 69 | 70 | console.error.restore(); 71 | }); 72 | 73 | describe('Process environment for tests', () => { 74 | it('Should be development for React console warnings', () => { 75 | assert.notEqual(process.env.NODE_ENV, 'production'); 76 | }); 77 | }); 78 | 79 | // Ensure all files in src folder are loaded for proper code coverage analysis 80 | const srcContext = require.context('../src', true, /.*\.js$/); 81 | srcContext.keys().forEach(srcContext); 82 | 83 | const testsContext = require.context('.', true, /Spec$/); 84 | testsContext.keys().forEach(testsContext); 85 | -------------------------------------------------------------------------------- /test/usePopperSpec.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import React from 'react'; 3 | import usePopper from '../src/usePopper'; 4 | 5 | describe('usePopper', () => { 6 | function renderHook(fn, initialProps) { 7 | let result = { current: null }; 8 | 9 | function Wrapper(props) { 10 | result.current = fn(props); 11 | return null; 12 | } 13 | 14 | result.mount = mount(); 15 | result.update = (props) => result.mount.setProps(props); 16 | 17 | return result; 18 | } 19 | 20 | const elements = {}; 21 | beforeEach(() => { 22 | elements.mount = document.createElement('div'); 23 | elements.reference = document.createElement('div'); 24 | elements.popper = document.createElement('div'); 25 | 26 | elements.mount.appendChild(elements.reference); 27 | elements.mount.appendChild(elements.popper); 28 | document.body.appendChild(elements.mount); 29 | }); 30 | 31 | afterEach(() => { 32 | elements.mount.parentNode.removeChild(elements.mount); 33 | }); 34 | 35 | it('should return state', (done) => { 36 | const result = renderHook(() => 37 | usePopper(elements.reference, elements.popper, { 38 | eventsEnabled: true, 39 | }), 40 | ); 41 | 42 | setTimeout(() => { 43 | expect(result.current.update).to.be.a('function'); 44 | expect(result.current.forceUpdate).to.be.a('function'); 45 | expect(result.current.styles).to.have.any.key('popper'); 46 | expect(result.current.attributes).to.have.any.key('popper'); 47 | done(); 48 | }); 49 | }); 50 | 51 | it('should add aria-describedBy for tooltips', (done) => { 52 | elements.popper.setAttribute('role', 'tooltip'); 53 | elements.popper.setAttribute('id', 'example123'); 54 | 55 | const result = renderHook(() => 56 | usePopper(elements.reference, elements.popper), 57 | ); 58 | 59 | setTimeout(() => { 60 | expect( 61 | document.querySelector('[aria-describedby="example123"]'), 62 | ).to.equal(elements.reference); 63 | 64 | result.mount.unmount(); 65 | 66 | expect( 67 | document.querySelector('[aria-describedby="example123"]'), 68 | ).to.equal(null); 69 | 70 | done(); 71 | }); 72 | }); 73 | 74 | it('should add to existing describedBy', (done) => { 75 | elements.popper.setAttribute('role', 'tooltip'); 76 | elements.popper.setAttribute('id', 'example123'); 77 | elements.reference.setAttribute('aria-describedby', 'foo, bar , baz '); 78 | 79 | const result = renderHook(() => 80 | usePopper(elements.reference, elements.popper), 81 | ); 82 | 83 | setTimeout(() => { 84 | expect( 85 | document.querySelector( 86 | '[aria-describedby="foo, bar , baz ,example123"]', 87 | ), 88 | ).to.equal(elements.reference); 89 | 90 | result.mount.unmount(); 91 | 92 | expect( 93 | document.querySelector('[aria-describedby="foo, bar , baz "]'), 94 | ).to.equal(elements.reference); 95 | 96 | done(); 97 | }); 98 | }); 99 | 100 | it('should not aria-describedBy any other role', (done) => { 101 | renderHook(() => usePopper(elements.reference, elements.popper)); 102 | 103 | setTimeout(() => { 104 | expect( 105 | document.querySelector('[aria-describedby="example123"]'), 106 | ).to.equal(null); 107 | 108 | done(); 109 | }); 110 | }); 111 | 112 | it('should not add add duplicates to aria-describedby', (done) => { 113 | elements.popper.setAttribute('role', 'tooltip'); 114 | elements.popper.setAttribute('id', 'example123'); 115 | elements.reference.setAttribute('aria-describedby', 'foo'); 116 | 117 | const result = renderHook(() => 118 | usePopper(elements.reference, elements.popper), 119 | ); 120 | 121 | window.dispatchEvent(new Event('resize')); 122 | 123 | setTimeout(() => { 124 | expect( 125 | document.querySelector('[aria-describedby="foo,example123"]'), 126 | ).to.equal(elements.reference); 127 | 128 | result.mount.unmount(); 129 | 130 | expect(document.querySelector('[aria-describedby="foo"]')).to.equal( 131 | elements.reference, 132 | ); 133 | 134 | done(); 135 | }); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/useRootCloseSpec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-use-before-define */ 2 | import React, { useRef } from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import simulant from 'simulant'; 5 | import { mount } from 'enzyme'; 6 | 7 | import useRootClose from '../src/useRootClose'; 8 | 9 | const escapeKeyCode = 27; 10 | const configs = [ 11 | { 12 | description: '', 13 | useShadowRoot: false, 14 | }, 15 | { 16 | description: 'with shadow root', 17 | useShadowRoot: true, 18 | }, 19 | ]; 20 | // Wrap simulant's created event to add composed: true, which is the default 21 | // for most events. 22 | const fire = (node, event, params) => { 23 | const simulatedEvent = simulant(event, params); 24 | const fixedEvent = new simulatedEvent.constructor(simulatedEvent.type, { 25 | bubbles: simulatedEvent.bubbles, 26 | button: simulatedEvent.button, 27 | cancelable: simulatedEvent.cancelable, 28 | composed: true, 29 | }); 30 | fixedEvent.keyCode = simulatedEvent.keyCode; 31 | node.dispatchEvent(fixedEvent); 32 | return fixedEvent; 33 | }; 34 | 35 | configs.map((config) => 36 | // eslint-disable-next-line mocha/no-setup-in-describe 37 | describe(`useRootClose ${config.description}`, () => { 38 | let attachTo, renderRoot, myDiv; 39 | 40 | beforeEach(() => { 41 | renderRoot = document.createElement('div'); 42 | if (config.useShadowRoot) { 43 | renderRoot.attachShadow({ mode: 'open' }); 44 | } 45 | document.body.appendChild(renderRoot); 46 | attachTo = config.useShadowRoot ? renderRoot.shadowRoot : renderRoot; 47 | myDiv = () => attachTo.querySelector('#my-div'); 48 | }); 49 | 50 | afterEach(() => { 51 | ReactDOM.unmountComponentAtNode(renderRoot); 52 | document.body.removeChild(renderRoot); 53 | }); 54 | 55 | describe('using default event', () => { 56 | // eslint-disable-next-line mocha/no-setup-in-describe 57 | shouldCloseOn(undefined, 'click'); 58 | }); 59 | 60 | describe('using click event', () => { 61 | // eslint-disable-next-line mocha/no-setup-in-describe 62 | shouldCloseOn('click', 'click'); 63 | }); 64 | 65 | describe('using mousedown event', () => { 66 | // eslint-disable-next-line mocha/no-setup-in-describe 67 | shouldCloseOn('mousedown', 'mousedown'); 68 | }); 69 | 70 | function shouldCloseOn(clickTrigger, eventName) { 71 | function Wrapper({ onRootClose, disabled }) { 72 | const ref = useRef(); 73 | useRootClose(ref, onRootClose, { 74 | disabled, 75 | clickTrigger, 76 | }); 77 | 78 | return ( 79 |
80 | hello there 81 |
82 | ); 83 | } 84 | 85 | it('should close when clicked outside', () => { 86 | let spy = sinon.spy(); 87 | 88 | mount(, { attachTo }); 89 | 90 | fire(myDiv(), eventName); 91 | 92 | expect(spy).to.not.have.been.called; 93 | 94 | fire(document.body, eventName); 95 | 96 | expect(spy).to.have.been.calledOnce; 97 | 98 | expect(spy.getCall(0).args[0].type).to.be.oneOf(['click', 'mousedown']); 99 | }); 100 | 101 | it('should not close when right-clicked outside', () => { 102 | let spy = sinon.spy(); 103 | mount(, { attachTo }); 104 | 105 | fire(myDiv(), eventName, { button: 1 }); 106 | 107 | expect(spy).to.not.have.been.called; 108 | 109 | fire(document.body, eventName, { button: 1 }); 110 | 111 | expect(spy).to.not.have.been.called; 112 | }); 113 | 114 | it('should not close when disabled', () => { 115 | let spy = sinon.spy(); 116 | mount(, { attachTo }); 117 | 118 | fire(myDiv(), eventName); 119 | 120 | expect(spy).to.not.have.been.called; 121 | 122 | fire(document.body, eventName); 123 | 124 | expect(spy).to.not.have.been.called; 125 | }); 126 | 127 | it('should close when inside another RootCloseWrapper', () => { 128 | let outerSpy = sinon.spy(); 129 | let innerSpy = sinon.spy(); 130 | 131 | function Inner() { 132 | const ref = useRef(); 133 | useRootClose(ref, innerSpy, { clickTrigger }); 134 | 135 | return ( 136 |
137 | hello there 138 |
139 | ); 140 | } 141 | 142 | function Outer() { 143 | const ref = useRef(); 144 | useRootClose(ref, outerSpy, { clickTrigger }); 145 | 146 | return ( 147 |
148 |
hello there
149 | 150 |
151 | ); 152 | } 153 | 154 | mount(, { attachTo }); 155 | 156 | fire(myDiv(), eventName); 157 | 158 | expect(outerSpy).to.have.not.been.called; 159 | expect(innerSpy).to.have.been.calledOnce; 160 | 161 | expect(innerSpy.getCall(0).args[0].type).to.be.oneOf([ 162 | 'click', 163 | 'mousedown', 164 | ]); 165 | }); 166 | } 167 | 168 | describe('using keyup event', () => { 169 | function Wrapper({ children, onRootClose, event: clickTrigger }) { 170 | const ref = useRef(); 171 | useRootClose(ref, onRootClose, { clickTrigger }); 172 | 173 | return ( 174 |
175 | {children} 176 |
177 | ); 178 | } 179 | 180 | it('should close when escape keyup', () => { 181 | let spy = sinon.spy(); 182 | mount( 183 | 184 |
hello there
185 |
, 186 | ); 187 | 188 | expect(spy).to.not.have.been.called; 189 | 190 | fire(document.body, 'keyup', { keyCode: escapeKeyCode }); 191 | 192 | expect(spy).to.have.been.calledOnce; 193 | 194 | expect(spy.getCall(0).args.length).to.be.equal(1); 195 | expect(spy.getCall(0).args[0].keyCode).to.be.equal(escapeKeyCode); 196 | expect(spy.getCall(0).args[0].type).to.be.equal('keyup'); 197 | }); 198 | 199 | it('should close when inside another RootCloseWrapper', () => { 200 | let outerSpy = sinon.spy(); 201 | let innerSpy = sinon.spy(); 202 | 203 | mount( 204 | 205 |
206 |
hello there
207 | 208 |
hello there
209 |
210 |
211 |
, 212 | ); 213 | 214 | fire(document.body, 'keyup', { keyCode: escapeKeyCode }); 215 | 216 | // TODO: Update to match expectations. 217 | // expect(outerSpy).to.have.not.been.called; 218 | expect(innerSpy).to.have.been.calledOnce; 219 | 220 | expect(innerSpy.getCall(0).args.length).to.be.equal(1); 221 | expect(innerSpy.getCall(0).args[0].keyCode).to.be.equal(escapeKeyCode); 222 | expect(innerSpy.getCall(0).args[0].type).to.be.equal('keyup'); 223 | }); 224 | }); 225 | }), 226 | ); 227 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@4c/tsconfig/web.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /www/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "import/prefer-default-export": "off", 4 | "react/prop-types": "off", 5 | "react/no-danger": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /www/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import './src/styles.css'; 2 | 3 | export const onClientEntry = () => {}; 4 | -------------------------------------------------------------------------------- /www/gatsby-config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | pathPrefix: '/react-overlays', 5 | siteMetadata: { 6 | title: 'React Overlays', 7 | author: 'Jason Quense', 8 | browsers: [ 9 | 'last 4 Chrome versions', 10 | 'last 4 Firefox versions', 11 | 'last 2 Edge versions', 12 | 'last 2 Safari versions', 13 | ], 14 | }, 15 | plugins: [ 16 | { 17 | resolve: '@docpocalypse/gatsby-theme', 18 | options: { 19 | sources: [path.resolve(__dirname, '../src')], 20 | 21 | getImportName(docNode, _) { 22 | return `import ${docNode.name} from '${docNode.packageName}/${docNode.fileName}'`; 23 | }, 24 | 25 | propsLayout: 'list', 26 | tailwindConfig: require.resolve('./tailwind.config'), 27 | exampleCodeScope: { 28 | css: require.resolve('./src/css'), 29 | styled: '@emotion/styled', 30 | injectCss: require.resolve('./src/injectCss'), 31 | ReactDOM: 'react-dom', 32 | }, 33 | }, 34 | }, 35 | 'gatsby-plugin-sass', 36 | { 37 | resolve: 'gatsby-plugin-astroturf', 38 | options: { extension: '.module.scss' }, 39 | }, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /www/gatsby-node.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | exports.onCreateWebpackConfig = ({ actions }) => { 4 | actions.setWebpackConfig({ 5 | devtool: 'source-map', 6 | resolve: { 7 | symlinks: false, 8 | alias: { 9 | react: require.resolve('react'), 10 | 'react-dom': require.resolve('react-dom'), 11 | 'react-overlays': path.resolve(__dirname, '../src'), 12 | }, 13 | }, 14 | }); 15 | }; 16 | 17 | exports.onCreateBabelConfig = ({ actions }) => { 18 | actions.setBabelOptions({ 19 | options: { 20 | babelrc: true, 21 | envName: 'docs', 22 | }, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /www/gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | require('./src/styles.css'); 3 | 4 | exports.onRenderBody = ({ setHeadComponents }) => { 5 | setHeadComponents( 6 | <> 7 | 11 | , 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "www", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "start": "gatsby develop", 9 | "build": "gatsby build" 10 | }, 11 | "devDependencies": { 12 | "@docpocalypse/gatsby-theme": "^0.11.30", 13 | "@emotion/core": "^10.1.1", 14 | "@emotion/styled": "^10.0.27", 15 | "astroturf": "^0.10.5", 16 | "bootstrap": "^4.6.0", 17 | "dom-helpers": "^5.2.0", 18 | "emotion": "^10.0.27", 19 | "gatsby": "^2.32.11", 20 | "gatsby-plugin-astroturf": "^0.2.1", 21 | "gatsby-plugin-sass": "^2.8.0", 22 | "lodash": "^4.17.21", 23 | "prop-types": "^15.7.2", 24 | "react": "^16.14.0", 25 | "react-dom": "^16.14.0", 26 | "react-transition-group": "^4.4.1", 27 | "sass": "^1.32.8" 28 | }, 29 | "resolutions": { 30 | "ast-types": "^0.14.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /www/src/@docpocalypse/gatsby-theme/components/SideNavigation.tsx: -------------------------------------------------------------------------------- 1 | import SideNavigation from '@docpocalypse/gatsby-theme/src/components/SideNavigation'; 2 | import { useStaticQuery, graphql } from 'gatsby'; 3 | import sortBy from 'lodash/sortBy'; 4 | import React from 'react'; 5 | import groupBy from 'lodash/groupBy'; 6 | 7 | export default function DocSideNavigation({ className }) { 8 | const { api } = useStaticQuery(graphql` 9 | query { 10 | api: allDocpocalypse { 11 | nodes { 12 | name 13 | tags { 14 | name 15 | value 16 | } 17 | } 18 | } 19 | } 20 | `); 21 | 22 | const members = groupBy( 23 | api.nodes, 24 | (doc) => doc.tags.find((t) => t.name === 'memberOf')?.value || 'none', 25 | ); 26 | 27 | return ( 28 | 29 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /www/src/@docpocalypse/gatsby-theme/syntax-theme.js: -------------------------------------------------------------------------------- 1 | import syntaxTheme from '@docpocalypse/gatsby-theme/syntax-themes/github'; 2 | 3 | export default syntaxTheme; 4 | -------------------------------------------------------------------------------- /www/src/code-examples/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-alert": "off", 4 | "no-shadow": "off", 5 | "no-unused-expressions": "off", 6 | "no-unused-vars": "off", 7 | "react/jsx-no-undef": "off", 8 | "react/no-multi-comp": "off", 9 | "react/prop-types": "off" 10 | }, 11 | "globals": { 12 | "render": false, 13 | "React": false, 14 | "ReactDOM": false, 15 | "injectCss": false, 16 | "useRef": false, 17 | "useEffect": false, 18 | "useState": false, 19 | "useReducer": false, 20 | 21 | "Modal": false, 22 | "Overlay": false, 23 | "Dropdown": false, 24 | "useDropdownMenu": false, 25 | "useDropdownToggle": false, 26 | "Portal": false, 27 | "useRootClose": false, 28 | "css": false, 29 | "styled": false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /www/src/code-examples/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /www/src/code-examples/Dropdown.js: -------------------------------------------------------------------------------- 1 | const MenuContainer = styled('ButtonToolbar')` 2 | display: ${(p) => (p.show ? 'flex' : 'none')}; 3 | min-width: 150px; 4 | position: absolute; 5 | flex-direction: column; 6 | border: 1px solid #e5e5e5; 7 | background-color: white; 8 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 9 | padding: 20px; 10 | `; 11 | 12 | const Menu = ({ role }) => { 13 | const { show, onClose, props } = useDropdownMenu({ flip: true }); 14 | return ( 15 | 16 | 19 | 22 | 23 | ); 24 | }; 25 | 26 | const Toggle = ({ id, children }) => { 27 | const [props, { show, toggle }] = useDropdownToggle(); 28 | return ( 29 | 38 | ); 39 | }; 40 | 41 | const DropdownButton = ({ show, onToggle, drop, alignEnd, title, role }) => ( 42 | 49 | {({ props }) => ( 50 |
51 | {title} 52 | 53 |
54 | )} 55 |
56 | ); 57 | 58 | const ButtonToolbar = styled('div')` 59 | & > * + * { 60 | margin-left: 12px; 61 | } 62 | `; 63 | 64 | function DropdownExample() { 65 | const [show, setShow] = useState(false); 66 | 67 | return ( 68 | 69 | setShow(nextShow)} 72 | title={`${show ? 'Close' : 'Open'} Dropdown`} 73 | /> 74 | 75 | 76 | 77 | 78 | 79 | ); 80 | } 81 | 82 | render(); 83 | -------------------------------------------------------------------------------- /www/src/code-examples/Modal.js: -------------------------------------------------------------------------------- 1 | let rand = () => Math.floor(Math.random() * 20) - 10; 2 | 3 | const Backdrop = styled('div')` 4 | position: fixed; 5 | z-index: 1040; 6 | top: 0; 7 | bottom: 0; 8 | left: 0; 9 | right: 0; 10 | background-color: #000; 11 | opacity: 0.5; 12 | `; 13 | 14 | // we use some pseudo random coords so nested modals 15 | // don't sit right on top of each other. 16 | const RandomlyPositionedModal = styled(Modal)` 17 | position: fixed; 18 | width: 400px; 19 | z-index: 1040; 20 | top: ${() => 50 + rand()}%; 21 | left: ${() => 50 + rand()}%; 22 | border: 1px solid #e5e5e5; 23 | background-color: white; 24 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 25 | padding: 20px; 26 | `; 27 | 28 | function ModalExample() { 29 | const [show, setShow] = useState(false); 30 | 31 | const renderBackdrop = (props) => ; 32 | 33 | return ( 34 |
35 | 42 |

Click to get the full Modal experience!

43 | 44 | setShow(false)} 47 | renderBackdrop={renderBackdrop} 48 | aria-labelledby="modal-label" 49 | > 50 |
51 | 52 |

53 | Duis mollis, est non commodo luctus, nisi erat porttitor ligula. 54 |

55 | 56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | render(); 63 | -------------------------------------------------------------------------------- /www/src/code-examples/Overlay.js: -------------------------------------------------------------------------------- 1 | // Styles mostly from Bootstrap. 2 | 3 | const Tooltip = styled('div')` 4 | position: absolute; 5 | padding: 0 5px; 6 | 7 | ${(p) => { 8 | switch (p.placement) { 9 | case 'left': 10 | return css` 11 | margin-left: -3px; 12 | padding: 0 5px; 13 | `; 14 | case 'right': 15 | return css` 16 | margin-left: 3px; 17 | padding: 0 5px; 18 | `; 19 | case 'top': 20 | return css` 21 | margin-top: -3px; 22 | padding: 5px 0; 23 | `; 24 | case 'bottom': 25 | return css` 26 | margin-bottom: 3px; 27 | padding: 5px 0; 28 | `; 29 | default: 30 | return ''; 31 | } 32 | }} 33 | `; 34 | 35 | const Arrow = styled('div')` 36 | position: absolute; 37 | width: 0; 38 | height: 0; 39 | border-style: solid; 40 | opacity: 0.75; 41 | 42 | ${(p) => { 43 | switch (p.placement) { 44 | case 'left': 45 | return css` 46 | right: 0; 47 | border-width: 5px 0 5px 5px; 48 | border-color: transparent transparent transparent #000; 49 | `; 50 | case 'right': 51 | return css` 52 | left: 0; 53 | border-width: 5px 5px 5px 0; 54 | border-color: transparent #232323 transparent transparent; 55 | `; 56 | case 'top': 57 | return css` 58 | bottom: 0; 59 | border-width: 5px 5px 0; 60 | border-color: #232323 transparent transparent transparent; 61 | `; 62 | case 'bottom': 63 | return css` 64 | top: 0; 65 | border-width: 0 5px 5px; 66 | border-color: transparent transparent #232323 transparent; 67 | `; 68 | default: 69 | return ''; 70 | } 71 | }} 72 | `; 73 | 74 | const Body = styled('div')` 75 | padding: 3px 8px; 76 | color: #fff; 77 | text-align: center; 78 | border-radius: 3px; 79 | background-color: #000; 80 | opacity: 0.75; 81 | `; 82 | 83 | const PLACEMENTS = ['left', 'top', 'right', 'bottom']; 84 | 85 | const initialSstate = { 86 | show: false, 87 | placement: null, 88 | }; 89 | 90 | function reducer(state, [type, payload]) { 91 | switch (type) { 92 | case 'placement': 93 | return { show: !!payload, placement: payload }; 94 | case 'hide': 95 | return { ...state, show: false, placement: null }; 96 | default: 97 | return state; 98 | } 99 | } 100 | 101 | function OverlayExample() { 102 | const [{ show, placement }, dispatch] = useReducer(reducer, initialSstate); 103 | const triggerRef = useRef(null); 104 | const containerRef = useRef(null); 105 | 106 | const handleClick = () => { 107 | const nextPlacement = PLACEMENTS[PLACEMENTS.indexOf(placement) + 1]; 108 | 109 | dispatch(['placement', nextPlacement]); 110 | }; 111 | 112 | return ( 113 |
114 | 123 |

Keep clicking to see the placement change.

124 | 125 | dispatch('hide')} 129 | placement={placement} 130 | container={containerRef} 131 | target={triggerRef} 132 | > 133 | {({ props, arrowProps, placement }) => ( 134 | 135 | 140 | 141 | I’m placed to the {placement} 142 | 143 | 144 | )} 145 | 146 |
147 | ); 148 | } 149 | 150 | render(); 151 | -------------------------------------------------------------------------------- /www/src/code-examples/Portal.js: -------------------------------------------------------------------------------- 1 | function PortalExample() { 2 | const [show, setShow] = useState(false); 3 | const containerRef = useRef(null); 4 | 5 | let child = But I actually render here!; 6 | 7 | return ( 8 |
9 | 16 |
17 | It looks like I will render in here. 18 | 19 | {show && child} 20 |
21 | 22 |
23 |
24 | ); 25 | } 26 | 27 | render(); 28 | -------------------------------------------------------------------------------- /www/src/code-examples/Transition.js: -------------------------------------------------------------------------------- 1 | const FADE_DURATION = 200; 2 | 3 | injectCss(` 4 | .fade { 5 | opacity: 0; 6 | transition: opacity ${FADE_DURATION}ms linear; 7 | } 8 | 9 | .show { 10 | opacity: 1; 11 | } 12 | 13 | .transition-example-modal { 14 | position: fixed; 15 | z-index: 1040; 16 | top: 0; bottom: 0; left: 0; right: 0; 17 | } 18 | 19 | .transition-example-backdrop { 20 | position: fixed; 21 | top: 0; bottom: 0; left: 0; right: 0; 22 | background-color: #000; 23 | } 24 | 25 | .transition-example-backdrop.fade.in { 26 | opacity: 0.5; 27 | } 28 | 29 | .transition-example-dialog { 30 | position: absolute; 31 | width: 400; 32 | top: 50%; left: 50%; 33 | transform: translate(-50%, -50%); 34 | border: 1px solid #e5e5e5; 35 | background-color: white; 36 | box-shadow: 0 5px 15px rgba(0, 0, 0, .5); 37 | padding: 20px; 38 | } 39 | `); 40 | 41 | const fadeStyles = { 42 | entering: 'show', 43 | entered: 'show', 44 | }; 45 | 46 | const Fade = ({ children, ...props }) => ( 47 | 48 | {(status, innerProps) => 49 | React.cloneElement(children, { 50 | ...innerProps, 51 | className: `fade ${fadeStyles[status]} ${children.props.className}`, 52 | }) 53 | } 54 | 55 | ); 56 | 57 | class TransitionExample extends React.Component { 58 | constructor(...args) { 59 | super(...args); 60 | 61 | this.state = { showModal: false }; 62 | this.toggleModal = () => { 63 | this.setState((state) => ({ showModal: !state.showModal })); 64 | }; 65 | 66 | this.toggleTooltip = () => { 67 | this.setState((state) => ({ showTooltip: !state.showTooltip })); 68 | }; 69 | 70 | this.tooltipRef = React.createRef(); 71 | } 72 | 73 | render() { 74 | return ( 75 |
76 | 83 | 84 | 92 | 93 | this.tooltipRef.current} 99 | > 100 | {({ props: { ref, style } }) => ( 101 |
102 | Hello there 103 |
104 | )} 105 |
106 | 107 | 115 |
116 | 117 |

118 | Anim pariatur cliche reprehenderit, enim eiusmod high life 119 | accusamus terry richardson ad squid. Nihil anim keffiyeh 120 | helvetica, craft beer labore wes anderson cred nesciunt sapiente 121 | ea proident. 122 |

123 |
124 |
125 |
126 | ); 127 | } 128 | } 129 | 130 | render(); 131 | -------------------------------------------------------------------------------- /www/src/code-examples/useRootClose.js: -------------------------------------------------------------------------------- 1 | function RootCloseWrapperExample() { 2 | const [show, setShow] = useState(false); 3 | const ref = useRef(); 4 | const handleRootClose = () => setShow(false); 5 | 6 | useRootClose(ref, handleRootClose, { 7 | disabled: !show, 8 | }); 9 | 10 | return ( 11 |
12 | 19 | 20 | {show && ( 21 |
22 |
23 | Click anywhere to dismiss me! 24 |
25 |
26 | )} 27 |
28 | ); 29 | } 30 | 31 | render(); 32 | -------------------------------------------------------------------------------- /www/src/css.js: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/core'; 2 | 3 | export default css; 4 | -------------------------------------------------------------------------------- /www/src/examples/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 60 3 | } 4 | -------------------------------------------------------------------------------- /www/src/examples/Dropdown.mdx: -------------------------------------------------------------------------------- 1 | `Dropdown` is set of structural components for building, accessible dropdown menus with close-on-click, 2 | keyboard navigation, and correct focus handling. As with all the react-overlay's 3 | components it's BYOS (Bring Your Own Styles). Dropdown is primarily 4 | built from three base components, you should compose to build your Dropdowns. 5 | 6 | - `Dropdown`: wraps the menu and toggle, and handles keyboard navigation 7 | - `Dropdown.Toggle`: generally a button that triggers the menu opening 8 | - `Dropdown.Menu`: the overlaid, menu, positioned to the toggle with PopperJS 9 | 10 | ```jsx 11 | import { 12 | useDropdownMenu, 13 | useDropdownToggle, 14 | Dropdown, 15 | } from "react-overlays"; 16 | 17 | const Menu = ({ role }) => { 18 | const [props, { toggle, show }] = useDropdownMenu({ 19 | flip: true, 20 | offset: [0, 8], 21 | }); 22 | const display = show ? "flex" : "none"; 23 | return ( 24 |
29 | 36 | 43 |
44 | ); 45 | }; 46 | 47 | const Toggle = ({ id, children }) => { 48 | const [props, { show, toggle }] = useDropdownToggle(); 49 | return ( 50 | 58 | ); 59 | }; 60 | 61 | const DropdownButton = ({ 62 | show, 63 | onToggle, 64 | drop, 65 | alignEnd, 66 | title, 67 | role, 68 | }) => ( 69 | 76 | 77 | {title} 78 | 79 | 80 | 81 | ); 82 | 83 | const ButtonToolbar = styled("div")` 84 | & > * + * { 85 | margin-left: 12px; 86 | } 87 | `; 88 | 89 | function DropdownExample() { 90 | const [show, setShow] = useState(false); 91 | 92 | return ( 93 | 94 | setShow(nextShow)} 97 | title={`${show ? "Close" : "Open"} Dropdown`} 98 | /> 99 | 100 | 101 | 102 | 103 | 104 | ); 105 | } 106 | 107 | ; 108 | ``` 109 | 110 | ## Different containers 111 | 112 | Dropdowns use `PopperJS` by default to position Menu's to Toggles. PopperJS is a 113 | powerful positioning library that lets you easily construct Dropdown markup to suit 114 | your app's needs. 115 | 116 | The example here positions the Menu to the `document` body via a Portal. 117 | 118 | ```js 119 | import { Dropdown } from "react-overlays"; 120 | 121 | 122 | 123 | {(props) => ( 124 | 127 | )} 128 | 129 | 130 | {(props, { show }) => 131 | ReactDOM.createPortal( 132 |
138 |

I am rendered into the document body

139 |
, 140 | document.body 141 | ) 142 | } 143 |
144 |
; 145 | ``` 146 | -------------------------------------------------------------------------------- /www/src/examples/Modal.mdx: -------------------------------------------------------------------------------- 1 | Love them or hate them, `` provides a solid foundation for creating dialogs, lightboxes, or whatever else. 2 | The Modal component renders its `children` node in front of a backdrop component. 3 | 4 | The Modal offers a few helpful features over using just a `` component and some styles: 5 | 6 | - Manages dialog stacking when one-at-a-time just isn't enough. 7 | - Creates a backdrop, for disabling interaction below the modal. 8 | - It properly manages focus; moving to the modal content, and keeping it there until the modal is closed. 9 | - It disables scrolling of the page content while open. 10 | - Adds the appropriate ARIA roles are automatically. 11 | - Easily-pluggable animations via a `` component. 12 | 13 | Note that, in the same way the backdrop element prevents users from clicking or interacting 14 | with the page content underneath the Modal, screen readers also need to be signaled to not to 15 | interact with page content while the Modal is open. To do this, we use a common technique of applying 16 | the `aria-hidden='true'` attribute to the non-Modal elements in the Modal `container`. This means that for 17 | a Modal to be truly modal, it should have a `container` that is _outside_ your app's 18 | React hierarchy (such as the default: document.body). 19 | 20 | ```jsx renderAsComponent 21 | import { Modal } from "react-overlays"; 22 | 23 | let rand = () => Math.floor(Math.random() * 20) - 10; 24 | 25 | const Backdrop = styled("div")` 26 | position: fixed; 27 | z-index: 1040; 28 | top: 0; 29 | bottom: 0; 30 | left: 0; 31 | right: 0; 32 | background-color: #000; 33 | opacity: 0.5; 34 | `; 35 | 36 | // we use some pseudo random coords so nested modals 37 | // don't sit right on top of each other. 38 | const RandomlyPositionedModal = styled(Modal)` 39 | position: fixed; 40 | width: 400px; 41 | z-index: 1040; 42 | top: ${() => 50 + rand()}%; 43 | left: ${() => 50 + rand()}%; 44 | border: 1px solid #e5e5e5; 45 | background-color: white; 46 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5); 47 | padding: 20px; 48 | `; 49 | 50 | function ModalExample() { 51 | const [show, setShow] = useState(false); 52 | 53 | const renderBackdrop = (props) => ; 54 | 55 | return ( 56 |
57 | 64 |

Click to get the full Modal experience!

65 | 66 | setShow(false)} 69 | renderBackdrop={renderBackdrop} 70 | aria-labelledby="modal-label" 71 | > 72 |
73 | 74 |

75 | Duis mollis, est non commodo luctus, nisi erat 76 | porttitor ligula. 77 |

78 | 79 |
80 |
81 |
82 | ); 83 | } 84 | 85 | ; 86 | ``` 87 | -------------------------------------------------------------------------------- /www/src/examples/Overlay.mdx: -------------------------------------------------------------------------------- 1 | A powerful and flexible overlay component for showing things over, and next to, other things. 2 | 3 | ```jsx 4 | import { Overlay } from "react-overlays"; 5 | 6 | const Tooltip = styled("div")` 7 | position: absolute; 8 | `; 9 | 10 | const Arrow = styled("div")` 11 | position: absolute; 12 | width: 10px; 13 | height: 10px; 14 | z-index: -1; 15 | 16 | &::before { 17 | content: ""; 18 | position: absolute; 19 | transform: rotate(45deg); 20 | background: #000; 21 | width: 10px; 22 | height: 10px; 23 | top: 0; 24 | left: 0; 25 | } 26 | 27 | ${(p) => 28 | ({ 29 | left: "right: -4px;", 30 | right: "left: -4px;", 31 | top: "bottom: -4px;", 32 | bottom: "top: -4px;", 33 | }[p.placement])} 34 | `; 35 | 36 | const Body = styled("div")` 37 | padding: 3px 8px; 38 | color: #fff; 39 | text-align: center; 40 | border-radius: 3px; 41 | background-color: #000; 42 | `; 43 | 44 | const PLACEMENTS = ["left", "top", "right", "bottom"]; 45 | 46 | const initialSstate = { 47 | show: false, 48 | placement: null, 49 | }; 50 | 51 | function reducer(state, [type, payload]) { 52 | switch (type) { 53 | case "placement": 54 | return { show: !!payload, placement: payload }; 55 | case "hide": 56 | return { ...state, show: false, placement: null }; 57 | default: 58 | return state; 59 | } 60 | } 61 | 62 | function OverlayExample() { 63 | const [{ show, placement }, dispatch] = useReducer( 64 | reducer, 65 | initialSstate 66 | ); 67 | const triggerRef = useRef(null); 68 | const containerRef = useRef(null); 69 | 70 | const handleClick = () => { 71 | const nextPlacement = 72 | PLACEMENTS[PLACEMENTS.indexOf(placement) + 1]; 73 | 74 | dispatch(["placement", nextPlacement]); 75 | }; 76 | 77 | return ( 78 |
82 | 91 |

Keep clicking to see the placement change.

92 | 93 | dispatch("hide")} 98 | placement={placement} 99 | container={containerRef} 100 | target={triggerRef} 101 | > 102 | {({ props, arrowProps, placement }) => ( 103 | 104 | 109 | 110 | I’m placed to the{" "} 111 | {placement} 112 | 113 | 114 | )} 115 | 116 |
117 | ); 118 | } 119 | 120 | ; 121 | ``` 122 | -------------------------------------------------------------------------------- /www/src/examples/Portal.mdx: -------------------------------------------------------------------------------- 1 | The `` component renders its children into a new "subtree" outside of current component hierarchy. 2 | You can think of it as a declarative `appendChild()`, or jQuery's `$().appendTo()`. 3 | The children of `` component will be appended to the `container` specified. 4 | 5 | The component is a light wrapper around `React.createPortal` with some conveniences around 6 | specifying and waiting for the container element. 7 | 8 | ```js renderAsComponent 9 | import { Portal } from "react-overlays"; 10 | 11 | const [show, setShow] = useState(false); 12 | const containerRef = useRef(null); 13 | 14 | let child = But I actually render here!; 15 | 16 |
17 | 24 |
25 | It looks like I will render in here. 26 | 27 | 28 | {show && child} 29 | 30 |
31 | 32 |
36 |
; 37 | ``` 38 | -------------------------------------------------------------------------------- /www/src/examples/useDropdownMenu.mdx: -------------------------------------------------------------------------------- 1 | Create custom dropdown menus without the extra component with `useDropdownMenu`. 2 | Make sure to spread through the returned props. If creating a navigation menu 3 | (as described [here](https://www.w3.org/TR/wai-aria-practices/#menu)), make sure 4 | to provide the `role='menu'` prop to automatically get correct keyboard focus handling. 5 | 6 | ```jsx showImports 7 | import { useDropdownMenu } from "react-overlays"; 8 | 9 | const MenuItem = ({ children, href }) => ( 10 |
  • 11 | 15 | {children} 16 | 17 |
  • 18 | ); 19 | 20 | const NavMenu = () => { 21 | const { props } = useDropdownMenu(); 22 | 23 | return ( 24 |
      29 | Home 30 | Docs 31 | About 32 |
    33 | ); 34 | }; 35 | 36 | ; 37 | ``` 38 | -------------------------------------------------------------------------------- /www/src/examples/useDropdownToggle.mdx: -------------------------------------------------------------------------------- 1 | Create custom dropdown toggles without the extra component with `useDropdownToggle`. 2 | Make sure to spread through the returned props. Accessible `Toggle`s should 3 | also provide an `id` to the HTML element that is rendered. 4 | 5 | ```jsx showImports 6 | import { useDropdownToggle } from "react-overlays"; 7 | 8 | const Toggle = ({ id, children }) => { 9 | const [props, { show, toggle }] = useDropdownToggle(); 10 | 11 | return ( 12 | 21 | ); 22 | }; 23 | 24 | ; 25 | ``` 26 | -------------------------------------------------------------------------------- /www/src/examples/useRootClose.mdx: -------------------------------------------------------------------------------- 1 | ```js renderAsComponent 2 | import { useRootClose } from "react-overlays"; 3 | 4 | const ref = useRef(); 5 | const [show, setShow] = useState(false); 6 | const handleRootClose = () => setShow(false); 7 | 8 | useRootClose(ref, handleRootClose, { 9 | disabled: !show, 10 | }); 11 | 12 | return ( 13 |
    14 | 21 | 22 | {show && ( 23 |
    27 | Click anywhere to dismiss me! 28 |
    29 | )} 30 |
    31 | ); 32 | ``` 33 | -------------------------------------------------------------------------------- /www/src/injectCss.js: -------------------------------------------------------------------------------- 1 | let style; 2 | const seen = []; 3 | 4 | export default function injectCss(rules) { 5 | if (seen.indexOf(rules) !== -1) { 6 | return; 7 | } 8 | 9 | style = 10 | style || 11 | (() => { 12 | let _style = document.createElement('style'); 13 | _style.appendChild(document.createTextNode('')); 14 | document.head.appendChild(_style); 15 | return _style; 16 | })(); 17 | 18 | seen.push(rules); 19 | style.innerHTML += `\n${rules}`; 20 | } 21 | -------------------------------------------------------------------------------- /www/src/pages/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | --- 4 | 5 | # Getting Started 6 | 7 | ## Installation 8 | 9 | ```sh 10 | npm install react-overlays 11 | ``` 12 | 13 | Or with yarn 14 | 15 | ```sh 16 | yarn add react-overlays 17 | ``` 18 | 19 | ## Usage 20 | 21 | Components can be imported from the main package or directly like: 22 | 23 | ```js 24 | import Dropdown from 'react-overlays/Dropdown'; 25 | ``` 26 | 27 | ## Styling 28 | 29 | React Overlays is a toolkit for creating functional overlays, tooltips, modals, and dropdowns. 30 | It is not a UI framework but is meant to be incorporated _into_ frameworks. To make those integrations 31 | possible, React Overlays is style-agnostic and **does not** come with any CSS. 32 | You should provide your own styles, and the documentation provides some simple examples 33 | for how to do that. For more complex integrations, check out [React Bootstrap](https://github.com/react-bootstrap/react-bootstrap). 34 | 35 | There are a few places where inline `style`s are applied, however. They are functionally 36 | required and very minimal. Specifically `PopperJs` injects it's own styles in order 37 | to position overlays and dropdowns, and `Modal` applies `overflow: hidden` to the 38 | document body. These can technically be overridden but it's very unlikely to be 39 | required. 40 | -------------------------------------------------------------------------------- /www/src/pages/transitions.mdx: -------------------------------------------------------------------------------- 1 | # Animation and Transitions 2 | 3 | Animation of components is handled by `transition` props. If 4 | a component accepts a `transition` prop you can provide 5 | a [react-transition-group@2.0.0](https://github.com/reactjs/react-transition-group) 6 | compatible`Transition` component and it will work.Feel free to use `CSSTransition` specifically, or roll your 7 | own like the below example. 8 | 9 | ```js live 10 | import { Modal, Overlay } from 'react-overlays'; 11 | import Transition from 'react-transition-group/Transition'; 12 | 13 | const FADE_DURATION = 200; 14 | 15 | injectCss(` 16 | .fade { 17 | opacity: 0; 18 | transition: opacity ${FADE_DURATION}ms linear; 19 | } 20 | 21 | .show { 22 | opacity: 1; 23 | } 24 | 25 | 26 | .backdrop.fade.show { 27 | opacity: 0.5; 28 | } 29 | 30 | .dialog { 31 | position: absolute; 32 | width: 400; 33 | top: 50%; left: 50%; 34 | transform: translate(-50%, -50%); 35 | border: 1px solid #e5e5e5; 36 | background-color: white; 37 | box-shadow: 0 5px 15px rgba(0, 0, 0, .5); 38 | padding: 20px; 39 | } 40 | `); 41 | 42 | const fadeStyles = { 43 | entering: 'show', 44 | entered: 'show', 45 | }; 46 | 47 | const Fade = ({ children, ...props }) => ( 48 | 49 | {(status, innerProps) => 50 | React.cloneElement(children, { 51 | ...innerProps, 52 | className: `fade ${fadeStyles[status]} ${children.props.className}`, 53 | }) 54 | } 55 | 56 | ); 57 | 58 | function TransitionExample() { 59 | const [showModal, setShowModal] = useState(false); 60 | const [showTooltip, setShowTooltip] = useState(false); 61 | const [tooltipRef, attachRef] = useState(null); 62 | 63 | return ( 64 |
    65 | 72 | 73 | 81 | 82 | 93 | {({ props: { ref, style } }) => ( 94 |
    99 | Hello there 100 |
    101 | )} 102 |
    103 | 104 | setShowModal(false)} 107 | transition={Fade} 108 | backdropTransition={Fade} 109 | renderBackdrop={(props) => ( 110 |
    111 | )} 112 | renderDialog={(props) => ( 113 |
    117 |
    118 | 119 |

    120 | Anim pariatur cliche reprehenderit, enim eiusmod high life 121 | accusamus terry richardson ad squid. Nihil anim keffiyeh 122 | helvetica, craft beer labore wes anderson cred nesciunt sapiente 123 | ea proident. 124 |

    125 | 132 |
    133 |
    134 | )} 135 | /> 136 |
    137 | ); 138 | } 139 | 140 | ; 141 | ``` 142 | -------------------------------------------------------------------------------- /www/src/styles.css: -------------------------------------------------------------------------------- 1 | .btn { 2 | @apply border border-primary text-primary rounded px-8 mt-4 appearance-none text-center whitespace-no-wrap; 3 | height: 40px; 4 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, 5 | border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 6 | } 7 | 8 | .btn:focus { 9 | @apply outline-none shadow-outline; 10 | } 11 | 12 | .btn:hover { 13 | @apply bg-primary text-white; 14 | } 15 | .btn:active { 16 | @apply bg-brand-600 text-white; 17 | } 18 | -------------------------------------------------------------------------------- /www/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const brand = { 2 | 100: '#EEDBF5', 3 | 200: '#DBB2EB', 4 | 300: '#C88AE0', 5 | 400: '#B562D5', 6 | 500: '#A13ACB', 7 | 600: '#832CA5', 8 | 700: '#63217D', 9 | 800: '#431655', 10 | 900: '#230C2C', 11 | }; 12 | 13 | module.exports = { 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand, 18 | primary: brand['500'], 19 | accent: brand['800'], 20 | subtle: brand['100'], 21 | }, 22 | CodeBlock: { 23 | '@apply text-sm': true, 24 | }, 25 | LiveCode: { 26 | '& .editor': { 27 | '@apply text-sm': true, 28 | }, 29 | }, 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /www/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@4c/tsconfig/web.json", 3 | "compilerOptions": { 4 | "rootDir": "..", 5 | "noImplicitAny": false 6 | }, 7 | "include": ["src", "../src"] 8 | } 9 | --------------------------------------------------------------------------------