├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.ts ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── .travis.yml ├── HISTORY.md ├── LICENSE ├── README.md ├── assets └── index.less ├── docs ├── demo │ ├── arrow.md │ ├── context-menu.md │ ├── dropdown-menu-width.md │ ├── multiple.md │ ├── overlay-callback.md │ └── simple.md ├── examples │ ├── arrow.jsx │ ├── context-menu.jsx │ ├── dropdown-menu-width.jsx │ ├── multiple.jsx │ ├── overlay-callback.jsx │ └── simple.jsx └── index.md ├── index.js ├── now.json ├── package.json ├── script └── update-content.js ├── src ├── Dropdown.tsx ├── Overlay.tsx ├── hooks │ └── useAccessibility.ts ├── index.tsx └── placements.ts ├── tests ├── __mocks__ │ └── @rc-component │ │ └── trigger.tsx ├── __snapshots__ │ └── basic.test.tsx.snap ├── basic.test.tsx ├── point.test.tsx └── utils.js └── tsconfig.json /.dumirc.ts: -------------------------------------------------------------------------------- 1 | // more config: https://d.umijs.org/config 2 | import { defineConfig } from 'dumi'; 3 | 4 | export default defineConfig({ 5 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 6 | themeConfig: { 7 | name: 'rc-dropdown', 8 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 9 | }, 10 | outputPath: '.docs', 11 | exportStatic: {}, 12 | styles: [ 13 | ` 14 | section.dumi-default-header-left { 15 | width: 240px; 16 | } 17 | `, 18 | ], 19 | }); 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*.{js,css}] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const base = require('@umijs/fabric/dist/eslint'); 2 | 3 | module.exports = { 4 | ...base, 5 | rules: { 6 | ...base.rules, 7 | 'no-template-curly-in-string': 0, 8 | 'prefer-promise-reject-errors': 0, 9 | 'react/no-array-index-key': 0, 10 | 'react/sort-comp': 0, 11 | '@typescript-eslint/no-explicit-any': 0, 12 | 'jsx-a11y/label-has-associated-control': 0, 13 | 'jsx-a11y/label-has-for': 0, 14 | 'no-shadow': 0 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "father"; 2 | 3 | export default defineConfig({ 4 | plugins: ["@rc-component/father-plugin"], 5 | }); 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | CI: 7 | uses: react-component/rc-test/.github/workflows/test.yml@main 8 | secrets: inherit 9 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "38 3 * * 6" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.log 3 | .idea/ 4 | .ipr 5 | .iws 6 | *~ 7 | ~* 8 | *.diff 9 | *.patch 10 | *.bak 11 | .DS_Store 12 | Thumbs.db 13 | .project 14 | .*proj 15 | .svn/ 16 | *.swp 17 | *.swo 18 | *.pyc 19 | *.pyo 20 | .build 21 | node_modules 22 | .cache 23 | dist 24 | assets/**/*.css 25 | build 26 | lib 27 | es 28 | coverage 29 | yarn.lock 30 | package-lock.json 31 | .vscode 32 | 33 | # dumi 34 | .dumi/tmp 35 | .dumi/tmp-test 36 | .dumi/tmp-production 37 | .docs 38 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "proseWrap": "never" 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 10 5 | 6 | script: 7 | - | 8 | if [ "$TEST_TYPE" = test ]; then 9 | npm run coverage && \ 10 | bash <(curl -s https://codecov.io/bash) 11 | else 12 | npm run $TEST_TYPE 13 | fi 14 | env: 15 | matrix: 16 | - TEST_TYPE=lint 17 | - TEST_TYPE=test 18 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | ---- 3 | 4 | ## 2.4.0 / 2018-12-28 5 | 6 | - `overlay` support function render 7 | 8 | ## 2.3.0 / 2018-12-21 9 | 10 | - add `openClassName` 11 | 12 | ## 2.2.0 / 2018-06-06 13 | 14 | - add `alignPoint` to support mosue point align 15 | 16 | ## 1.5.0 / 2016-07-27 17 | 18 | - Add `onOverlayClick`. 19 | 20 | - 21 | 22 | ## 1.4.5 / 2016-03-02 23 | 24 | - if exists getPopupContainer it will be passed to Trigger component 25 | 26 | ## 1.4.0 / 2015-10-26 27 | 28 | - update for react 0.14 29 | 30 | ## 1.2.0 / 2015-06-07 31 | 32 | - remove closeOnSelect, use visible prop to control 33 | 34 | ## 0.8.0 / 2015-06-07 35 | 36 | Already available 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-dropdown 2 | 3 | react dropdown component 4 | 5 | [![NPM version][npm-image]][npm-url] [![build status][travis-image]][travis-url] [![Test coverage][coveralls-image]][coveralls-url] [![Dependencies][david-image]][david-url] [![DevDependencies][david-dev-image]][david-dev-url] [![npm download][download-image]][download-url] [![bundle size][bundlephobia-image]][bundlephobia-url] [![dumi][dumi-image]][dumi-url] 6 | 7 | [npm-image]: http://img.shields.io/npm/v/rc-dropdown.svg?style=flat-square 8 | [npm-url]: http://npmjs.org/package/rc-dropdown 9 | [travis-image]: https://img.shields.io/travis/react-component/dropdown.svg?style=flat-square 10 | [travis-url]: https://travis-ci.org/react-component/dropdown 11 | [coveralls-image]: https://img.shields.io/coveralls/react-component/dropdown.svg?style=flat-square 12 | [coveralls-url]: https://coveralls.io/r/react-component/dropdown?branch=master 13 | [david-url]: https://david-dm.org/react-component/dropdown 14 | [david-image]: https://david-dm.org/react-component/dropdown/status.svg?style=flat-square 15 | [david-dev-url]: https://david-dm.org/react-component/dropdown?type=dev 16 | [david-dev-image]: https://david-dm.org/react-component/dropdown/dev-status.svg?style=flat-square 17 | [download-image]: https://img.shields.io/npm/dm/rc-dropdown.svg?style=flat-square 18 | [download-url]: https://npmjs.org/package/rc-dropdown 19 | [bundlephobia-url]: https://bundlephobia.com/result?p=rc-dropdown 20 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-dropdown 21 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square 22 | [dumi-url]: https://github.com/umijs/dumi 23 | 24 | ## Screenshot 25 | 26 | ![](https://t.alipayobjects.com/images/rmsweb/T1bWpgXgBaXXXXXXXX.png) 27 | 28 | ## Example 29 | 30 | online example: http://react-component.github.io/dropdown/examples/ 31 | 32 | ## install 33 | 34 | [![rc-dropdown](https://nodei.co/npm/rc-dropdown.png)](https://npmjs.org/package/rc-dropdown) 35 | 36 | ## Usage 37 | 38 | ```js 39 | var Dropdown = require('rc-dropdown'); 40 | // use dropdown 41 | ``` 42 | 43 | ## API 44 | 45 | ### props 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |           67 |           68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |           127 |           128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 |
nametypedefaultdescription
overlayClassNameStringadditional css class of root dom node
openClassNameString`${prefixCls}-open`className of trigger when dropdown is opened
prefixClsStringrc-dropdownprefix class name
transitionNameStringdropdown menu's animation css class name
animationStringpart of dropdown menu's animation css class name
placementStringbottomLeftPosition of menu item. There are: top, topCenter, topRight, bottomLeft, bottom, bottomRight
onVisibleChangeFunctioncall when visible is changed
visiblebooleanwhether tooltip is visible
defaultVisiblebooleanwhether tooltip is visible initially
overlayrc-menurc-menu element
onOverlayClickfunction(e)call when overlay is clicked
minOverlayWidthMatchTriggerbooleantrue (false when set alignPoint)whether overlay's width must not be less than trigger's
getPopupContainerFunction(menuDOMNode): HTMLElement() => document.bodyWhere to render the DOM node of dropdown
137 | 138 | Note: Additional props are passed into the underlying [rc-trigger](https://github.com/react-component/trigger) component. This can be useful for example, to display the dropdown in a separate [portal](https://reactjs.org/docs/portals.html)-driven window via the `getDocument()` rc-trigger prop. 139 | 140 | ## Development 141 | 142 | ```bash 143 | npm install 144 | npm start 145 | ``` 146 | 147 | ## Test Case 148 | 149 | ```bash 150 | npm test 151 | npm run chrome-test 152 | ``` 153 | 154 | ## Coverage 155 | 156 | ```bash 157 | npm run coverage 158 | ``` 159 | 160 | open coverage/ dir 161 | 162 | ## License 163 | 164 | rc-dropdown is released under the MIT license. 165 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @dropdownPrefixCls: rc-dropdown; 2 | 3 | @dropdown-arrow-width: 8px; 4 | @dropdown-distance: @dropdown-arrow-width + 4; 5 | @dropdown-arrow-color: #373737; 6 | @dropdown-overlay-shadow: 0 1px 5px #ccc; 7 | 8 | @font-face { 9 | font-family: 'anticon'; 10 | src: url('//at.alicdn.com/t/font_1434092639_4910953.eot'); 11 | /* IE9*/ 12 | src: url('//at.alicdn.com/t/font_1434092639_4910953.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('//at.alicdn.com/t/font_1434092639_4910953.woff') format('woff'), /* chrome、firefox */ url('//at.alicdn.com/t/font_1434092639_4910953.ttf') format('truetype'), /* chrome、firefox、opera、Safari, Android, iOS 4.2+*/ url('//at.alicdn.com/t/font_1434092639_4910953.svg#iconfont') format('svg'); 13 | /* iOS 4.1- */ 14 | } 15 | 16 | .@{dropdownPrefixCls} { 17 | position: absolute; 18 | left: -9999px; 19 | top: -9999px; 20 | z-index: 1070; 21 | display: block; 22 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 23 | font-size: 12px; 24 | font-weight: normal; 25 | line-height: 1.5; 26 | 27 | &-hidden { 28 | display: none; 29 | } 30 | 31 | .rc-menu { 32 | outline: none; 33 | position: relative; 34 | list-style-type: none; 35 | padding: 0; 36 | margin: 2px 0 2px; 37 | text-align: left; 38 | background-color: #fff; 39 | border-radius: 3px; 40 | box-shadow: @dropdown-overlay-shadow; 41 | background-clip: padding-box; 42 | border: 1px solid #ccc; 43 | 44 | > li { 45 | margin: 0; 46 | padding: 0; 47 | } 48 | 49 | &:before { 50 | content: ""; 51 | position: absolute; 52 | top: -4px; 53 | left: 0; 54 | width: 100%; 55 | height: 4px; 56 | background: rgb(255, 255, 255); 57 | background: rgba(255, 255, 255, 0.01); 58 | } 59 | 60 | & > &-item { 61 | position: relative; 62 | display: block; 63 | padding: 7px 10px; 64 | clear: both; 65 | font-size: 12px; 66 | font-weight: normal; 67 | color: #666666; 68 | white-space: nowrap; 69 | 70 | &:hover, &-active, &-selected { 71 | background-color: #ebfaff; 72 | } 73 | 74 | &-selected { 75 | position: relative; 76 | &:after { 77 | content: '\e613'; 78 | font-family: 'anticon'; 79 | font-weight: bold; 80 | position: absolute; 81 | top: 6px; 82 | right: 16px; 83 | color: #3CB8F0; 84 | } 85 | } 86 | 87 | &-disabled { 88 | color: #ccc; 89 | cursor: not-allowed; 90 | pointer-events: none; 91 | 92 | &:hover { 93 | color: #ccc; 94 | background-color: #fff; 95 | cursor: not-allowed; 96 | } 97 | } 98 | 99 | &:last-child { 100 | border-bottom-left-radius: 3px; 101 | border-bottom-right-radius: 3px; 102 | } 103 | 104 | &:first-child { 105 | border-top-left-radius: 3px; 106 | border-top-right-radius: 3px; 107 | } 108 | 109 | &-divider { 110 | height: 1px; 111 | margin: 1px 0; 112 | overflow: hidden; 113 | background-color: #e5e5e5; 114 | line-height: 0; 115 | } 116 | } 117 | } 118 | 119 | .effect() { 120 | animation-duration: 0.3s; 121 | animation-fill-mode: both; 122 | transform-origin: 0 0; 123 | display: block !important; 124 | } 125 | 126 | &-slide-up-enter,&-slide-up-appear { 127 | .effect(); 128 | opacity: 0; 129 | animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); 130 | animation-play-state: paused; 131 | } 132 | 133 | &-slide-up-leave { 134 | .effect(); 135 | opacity: 1; 136 | animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34); 137 | animation-play-state: paused; 138 | } 139 | 140 | &-slide-up-enter&-slide-up-enter-active&-placement-bottomLeft, 141 | &-slide-up-appear&-slide-up-appear-active&-placement-bottomLeft, 142 | &-slide-up-enter&-slide-up-enter-active&-placement-bottomCenter, 143 | &-slide-up-appear&-slide-up-appear-active&-placement-bottomCenter, 144 | &-slide-up-enter&-slide-up-enter-active&-placement-bottomRight, 145 | &-slide-up-appear&-slide-up-appear-active&-placement-bottomRight { 146 | animation-name: rcDropdownSlideUpIn; 147 | animation-play-state: running; 148 | } 149 | 150 | &-slide-up-enter&-slide-up-enter-active&-placement-topLeft, 151 | &-slide-up-appear&-slide-up-appear-active&-placement-topLeft, 152 | &-slide-up-enter&-slide-up-enter-active&-placement-topCenter, 153 | &-slide-up-appear&-slide-up-appear-active&-placement-topCenter, 154 | &-slide-up-enter&-slide-up-enter-active&-placement-topRight, 155 | &-slide-up-appear&-slide-up-appear-active&-placement-topRight { 156 | animation-name: rcDropdownSlideDownIn; 157 | animation-play-state: running; 158 | } 159 | 160 | &-slide-up-leave&-slide-up-leave-active&-placement-bottomLeft, 161 | &-slide-up-leave&-slide-up-leave-active&-placement-bottomCenter, 162 | &-slide-up-leave&-slide-up-leave-active&-placement-bottomRight { 163 | animation-name: rcDropdownSlideUpOut; 164 | animation-play-state: running; 165 | } 166 | 167 | &-slide-up-leave&-slide-up-leave-active&-placement-topLeft, 168 | &-slide-up-leave&-slide-up-leave-active&-placement-topCenter, 169 | &-slide-up-leave&-slide-up-leave-active&-placement-topRight { 170 | animation-name: rcDropdownSlideDownOut; 171 | animation-play-state: running; 172 | } 173 | 174 | @keyframes rcDropdownSlideUpIn { 175 | 0% { 176 | opacity: 0; 177 | transform-origin: 0% 0%; 178 | transform: scaleY(0); 179 | } 180 | 100% { 181 | opacity: 1; 182 | transform-origin: 0% 0%; 183 | transform: scaleY(1); 184 | } 185 | } 186 | @keyframes rcDropdownSlideUpOut { 187 | 0% { 188 | opacity: 1; 189 | transform-origin: 0% 0%; 190 | transform: scaleY(1); 191 | } 192 | 100% { 193 | opacity: 0; 194 | transform-origin: 0% 0%; 195 | transform: scaleY(0); 196 | } 197 | } 198 | 199 | @keyframes rcDropdownSlideDownIn { 200 | 0% { 201 | opacity: 0; 202 | transform-origin: 0% 100%; 203 | transform: scaleY(0); 204 | } 205 | 100% { 206 | opacity: 1; 207 | transform-origin: 0% 100%; 208 | transform: scaleY(1); 209 | } 210 | } 211 | @keyframes rcDropdownSlideDownOut { 212 | 0% { 213 | opacity: 1; 214 | transform-origin: 0% 100%; 215 | transform: scaleY(1); 216 | } 217 | 100% { 218 | opacity: 0; 219 | transform-origin: 0% 100%; 220 | transform: scaleY(0); 221 | } 222 | } 223 | } 224 | 225 | // arrows 226 | .@{dropdownPrefixCls}-arrow { 227 | position: absolute; 228 | border-width: @dropdown-arrow-width / 2; 229 | border-color: transparent; 230 | box-shadow: @dropdown-overlay-shadow; 231 | border-style: solid; 232 | transform: rotate(45deg); 233 | } 234 | 235 | .@{dropdownPrefixCls} { 236 | // adjust padding 237 | &-show-arrow&-placement-top, 238 | &-show-arrow&-placement-topLeft, 239 | &-show-arrow&-placement-topRight { 240 | padding-bottom: 6px; 241 | } 242 | 243 | &-show-arrow&-placement-bottom, 244 | &-show-arrow&-placement-bottomLeft, 245 | &-show-arrow&-placement-bottomRight { 246 | padding-top: 6px; 247 | } 248 | 249 | // top-* 250 | &-placement-top &-arrow, 251 | &-placement-topLeft &-arrow, 252 | &-placement-topRight &-arrow { 253 | bottom: @dropdown-distance - @dropdown-arrow-width; 254 | border-top-color: white; 255 | } 256 | 257 | &-placement-top &-arrow { 258 | left: 50%; 259 | } 260 | 261 | &-placement-topLeft &-arrow { 262 | left: 15%; 263 | } 264 | 265 | &-placement-topRight &-arrow { 266 | right: 15%; 267 | } 268 | 269 | // bottom-* 270 | &-placement-bottom &-arrow, 271 | &-placement-bottomLeft &-arrow, 272 | &-placement-bottomRight &-arrow { 273 | top: @dropdown-distance - @dropdown-arrow-width; 274 | border-bottom-color: white; 275 | } 276 | 277 | &-placement-bottom &-arrow { 278 | left: 50%; 279 | } 280 | 281 | &-placement-bottomLeft &-arrow { 282 | left: 15%; 283 | } 284 | 285 | &-placement-bottomRight &-arrow { 286 | right: 15%; 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /docs/demo/arrow.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: arrow 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/context-menu.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: context-menu 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/dropdown-menu-width.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: dropdown-menu-width 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/multiple.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: multiple 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/overlay-callback.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: overlay-callback 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/simple.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: simple 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/examples/arrow.jsx: -------------------------------------------------------------------------------- 1 | import Dropdown from '@rc-component/dropdown'; 2 | import Menu, { Divider, Item as MenuItem } from '@rc-component/menu'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | function onSelect({ key }) { 7 | console.log(`${key} selected`); 8 | } 9 | 10 | function onVisibleChange(visible) { 11 | console.log(visible); 12 | } 13 | 14 | const menu = ( 15 | 16 | disabled 17 | one 18 | 19 | two 20 | 21 | ); 22 | 23 | export default function Arrow() { 24 | return ( 25 |
26 |
27 |
28 | 35 | 36 | 37 |
38 |
39 | 47 | 48 | 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /docs/examples/context-menu.jsx: -------------------------------------------------------------------------------- 1 | import Dropdown from '@rc-component/dropdown'; 2 | import Menu, { Item as MenuItem } from '@rc-component/menu'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | function ContextMenu() { 7 | const menu = ( 8 | 9 | one 10 | two 11 | 12 | ); 13 | 14 | return ( 15 | 21 |
29 | Right click me! 30 |
31 |
32 | ); 33 | } 34 | 35 | export default ContextMenu; 36 | -------------------------------------------------------------------------------- /docs/examples/dropdown-menu-width.jsx: -------------------------------------------------------------------------------- 1 | import Dropdown from '@rc-component/dropdown'; 2 | import Menu, { Item as MenuItem } from '@rc-component/menu'; 3 | import React, { PureComponent } from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | class Example extends PureComponent { 7 | state = { longList: false }; 8 | 9 | short = () => { 10 | this.setState({ longList: false }); 11 | }; 12 | 13 | long = () => { 14 | this.setState({ longList: true }); 15 | }; 16 | 17 | render() { 18 | const menuItems = [ 19 | 1st item, 20 | 2nd item, 21 | ]; 22 | 23 | if (this.state.longList) { 24 | menuItems.push(3rd LONG SUPER LONG item); 25 | } 26 | const menu = {menuItems}; 27 | return ( 28 |
29 | 30 | 31 | 32 | 33 | 34 |
35 | ); 36 | } 37 | } 38 | 39 | export default Example; 40 | -------------------------------------------------------------------------------- /docs/examples/multiple.jsx: -------------------------------------------------------------------------------- 1 | import Dropdown from '@rc-component/dropdown'; 2 | import Menu, { Divider, Item as MenuItem } from '@rc-component/menu'; 3 | import React, { Component } from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | class Test extends Component { 7 | state = { 8 | visible: false, 9 | }; 10 | 11 | onVisibleChange = (visible) => { 12 | console.log('visible', visible); 13 | this.setState({ 14 | visible, 15 | }); 16 | }; 17 | 18 | selected = []; 19 | 20 | saveSelected = ({ selectedKeys }) => { 21 | this.selected = selectedKeys; 22 | }; 23 | 24 | confirm = () => { 25 | console.log(this.selected); 26 | this.setState({ 27 | visible: false, 28 | }); 29 | }; 30 | 31 | render() { 32 | const menu = ( 33 | 39 | one 40 | two 41 | 42 | 43 | 53 | 54 | 55 | ); 56 | 57 | return ( 58 | 66 | 67 | 68 | ); 69 | } 70 | } 71 | 72 | export default Test; 73 | -------------------------------------------------------------------------------- /docs/examples/overlay-callback.jsx: -------------------------------------------------------------------------------- 1 | import Dropdown from '@rc-component/dropdown'; 2 | import Menu, { Divider, Item as MenuItem } from '@rc-component/menu'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | function onSelect({ key }) { 7 | console.log(`${key} selected`); 8 | } 9 | 10 | function onVisibleChange(visible) { 11 | console.log(visible); 12 | } 13 | 14 | const menuCallback = () => ( 15 | 16 | disabled 17 | one 18 | 19 | two 20 | 21 | ); 22 | 23 | export default function OverlayCallback() { 24 | return ( 25 |
26 |
27 |
28 | 34 | 35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /docs/examples/simple.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console,react/button-has-type */ 2 | import Dropdown from '@rc-component/dropdown'; 3 | import Menu, { Divider, Item as MenuItem } from '@rc-component/menu'; 4 | import React from 'react'; 5 | import '../../assets/index.less'; 6 | 7 | function onSelect({ key }) { 8 | console.log(`${key} selected`); 9 | } 10 | 11 | function onVisibleChange(visible) { 12 | console.log(visible); 13 | } 14 | 15 | const menu = ( 16 | 17 | disabled 18 | one 19 | 20 | two 21 | 22 | ); 23 | 24 | export default function Simple() { 25 | return ( 26 |
27 |
28 |
29 | 36 | 37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-dropdown 4 | description: React Dropdown Component 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./src'); 4 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-dropdown", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": ".docs" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/dropdown", 3 | "version": "1.0.0", 4 | "description": "dropdown ui component for react", 5 | "keywords": [ 6 | "react", 7 | "react-dropdown" 8 | ], 9 | "homepage": "http://github.com/react-component/dropdown", 10 | "bugs": { 11 | "url": "http://github.com/react-component/dropdown/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:react-component/dropdown.git" 16 | }, 17 | "license": "MIT", 18 | "maintainers": [ 19 | "yiminghe@gmail.com", 20 | "hualei5280@gmail.com" 21 | ], 22 | "main": "lib/index", 23 | "module": "./es/index", 24 | "files": [ 25 | "lib", 26 | "es", 27 | "assets/*.css" 28 | ], 29 | "scripts": { 30 | "build": "dumi build", 31 | "compile": "father build && lessc assets/index.less assets/index.css", 32 | "coverage": "rc-test --coverage", 33 | "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js", 34 | "now-build": "npm run build", 35 | "prepare": "husky install && dumi setup", 36 | "prepublishOnly": "npm run compile && rc-np", 37 | "start": "dumi dev", 38 | "test": "rc-test" 39 | }, 40 | "lint-staged": { 41 | "**/*.{js,jsx,tsx,ts,md,json}": [ 42 | "prettier --write", 43 | "git add" 44 | ] 45 | }, 46 | "dependencies": { 47 | "@rc-component/trigger": "^3.0.0", 48 | "@rc-component/util": "^1.2.1", 49 | "classnames": "^2.2.6" 50 | }, 51 | "devDependencies": { 52 | "@rc-component/father-plugin": "^2.0.2", 53 | "@rc-component/np": "^1.0.3", 54 | "@rc-component/resize-observer": "^1.0.0", 55 | "@testing-library/jest-dom": "^5.16.5", 56 | "@testing-library/react": "^14.0.0", 57 | "@types/classnames": "^2.2.6", 58 | "@types/jest": "^29.0.0", 59 | "@types/react": "^18.0.0", 60 | "@types/react-dom": "^18.0.0", 61 | "@types/warning": "^3.0.0", 62 | "@umijs/fabric": "^3.0.0", 63 | "cross-env": "^7.0.0", 64 | "dumi": "^2.0.0", 65 | "eslint": "^7.18.0", 66 | "father": "^4.0.0", 67 | "glob": "^10.0.0", 68 | "husky": "^8.0.3", 69 | "jest-environment-jsdom": "^29.5.0", 70 | "jquery": "^3.3.1", 71 | "less": "^4.1.1", 72 | "lint-staged": "^13.2.1", 73 | "prettier": "^2.8.7", 74 | "@rc-component/menu": "^1.0.0", 75 | "rc-test": "^7.0.14", 76 | "react": "^18.0.0", 77 | "react-dom": "^18.0.0", 78 | "regenerator-runtime": "^0.13.9", 79 | "typescript": "^5.0.0" 80 | }, 81 | "peerDependencies": { 82 | "react": ">=16.11.0", 83 | "react-dom": ">=16.11.0" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /script/update-content.js: -------------------------------------------------------------------------------- 1 | /* 2 | 用于 dumi 改造使用, 3 | 可用于将 examples 的文件批量修改为 demo 引入形式, 4 | 其他项目根据具体情况使用。 5 | */ 6 | 7 | const fs = require('fs'); 8 | const glob = require('glob'); 9 | 10 | const paths = glob.sync('./docs/examples/*.jsx'); 11 | 12 | paths.forEach((path) => { 13 | const name = path.split('/').pop().split('.')[0]; 14 | fs.writeFile( 15 | `./docs/demo/${name}.md`, 16 | `--- 17 | title: ${name} 18 | nav: 19 | title: Demo 20 | path: /demo 21 | --- 22 | 23 | 24 | `, 25 | 'utf8', 26 | function (error) { 27 | if (error) { 28 | console.log(error); 29 | return false; 30 | } 31 | console.log(`${name} 更新成功~`); 32 | }, 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /src/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import type { TriggerProps } from '@rc-component/trigger'; 2 | import Trigger from '@rc-component/trigger'; 3 | import type { 4 | ActionType, 5 | AlignType, 6 | AnimationType, 7 | BuildInPlacements, 8 | } from '@rc-component/trigger/lib/interface'; 9 | import { composeRef, getNodeRef, supportRef } from '@rc-component/util/lib/ref'; 10 | import classNames from 'classnames'; 11 | import React from 'react'; 12 | import useAccessibility from './hooks/useAccessibility'; 13 | import Overlay from './Overlay'; 14 | import Placements from './placements'; 15 | 16 | export interface DropdownProps 17 | extends Pick< 18 | TriggerProps, 19 | | 'getPopupContainer' 20 | | 'children' 21 | | 'mouseEnterDelay' 22 | | 'mouseLeaveDelay' 23 | | 'onPopupAlign' 24 | | 'builtinPlacements' 25 | | 'autoDestroy' 26 | > { 27 | minOverlayWidthMatchTrigger?: boolean; 28 | arrow?: boolean; 29 | onVisibleChange?: (visible: boolean) => void; 30 | onOverlayClick?: (e: Event) => void; 31 | prefixCls?: string; 32 | transitionName?: string; 33 | overlayClassName?: string; 34 | openClassName?: string; 35 | animation?: AnimationType; 36 | align?: AlignType; 37 | overlayStyle?: React.CSSProperties; 38 | placement?: keyof typeof Placements; 39 | placements?: BuildInPlacements; 40 | overlay?: (() => React.ReactElement) | React.ReactElement; 41 | trigger?: ActionType | ActionType[]; 42 | alignPoint?: boolean; 43 | showAction?: ActionType[]; 44 | hideAction?: ActionType[]; 45 | visible?: boolean; 46 | autoFocus?: boolean; 47 | } 48 | 49 | function Dropdown(props: DropdownProps, ref) { 50 | const { 51 | arrow = false, 52 | prefixCls = 'rc-dropdown', 53 | transitionName, 54 | animation, 55 | align, 56 | placement = 'bottomLeft', 57 | placements = Placements, 58 | getPopupContainer, 59 | showAction, 60 | hideAction, 61 | overlayClassName, 62 | overlayStyle, 63 | visible, 64 | trigger = ['hover'], 65 | autoFocus, 66 | overlay, 67 | children, 68 | onVisibleChange, 69 | ...otherProps 70 | } = props; 71 | 72 | const [triggerVisible, setTriggerVisible] = React.useState(); 73 | const mergedVisible = 'visible' in props ? visible : triggerVisible; 74 | const mergedMotionName = animation 75 | ? `${prefixCls}-${animation}` 76 | : transitionName; 77 | 78 | const triggerRef = React.useRef(null); 79 | const overlayRef = React.useRef(null); 80 | const childRef = React.useRef(null); 81 | React.useImperativeHandle(ref, () => triggerRef.current); 82 | 83 | const handleVisibleChange = (newVisible: boolean) => { 84 | setTriggerVisible(newVisible); 85 | onVisibleChange?.(newVisible); 86 | }; 87 | 88 | useAccessibility({ 89 | visible: mergedVisible, 90 | triggerRef: childRef, 91 | onVisibleChange: handleVisibleChange, 92 | autoFocus, 93 | overlayRef, 94 | }); 95 | 96 | const onClick = (e) => { 97 | const { onOverlayClick } = props; 98 | setTriggerVisible(false); 99 | 100 | if (onOverlayClick) { 101 | onOverlayClick(e); 102 | } 103 | }; 104 | 105 | const getMenuElement = () => ( 106 | 112 | ); 113 | 114 | const getMenuElementOrLambda = () => { 115 | if (typeof overlay === 'function') { 116 | return getMenuElement; 117 | } 118 | return getMenuElement(); 119 | }; 120 | 121 | const getMinOverlayWidthMatchTrigger = () => { 122 | const { minOverlayWidthMatchTrigger, alignPoint } = props; 123 | if ('minOverlayWidthMatchTrigger' in props) { 124 | return minOverlayWidthMatchTrigger; 125 | } 126 | 127 | return !alignPoint; 128 | }; 129 | 130 | const getOpenClassName = () => { 131 | const { openClassName } = props; 132 | if (openClassName !== undefined) { 133 | return openClassName; 134 | } 135 | return `${prefixCls}-open`; 136 | }; 137 | 138 | const childrenNode = React.cloneElement(children, { 139 | className: classNames( 140 | children.props?.className, 141 | mergedVisible && getOpenClassName(), 142 | ), 143 | ref: supportRef(children) 144 | ? composeRef(childRef, getNodeRef(children)) 145 | : undefined, 146 | }); 147 | 148 | let triggerHideAction = hideAction; 149 | if (!triggerHideAction && trigger.indexOf('contextMenu') !== -1) { 150 | triggerHideAction = ['click']; 151 | } 152 | 153 | return ( 154 | 176 | {childrenNode} 177 | 178 | ); 179 | } 180 | 181 | export default React.forwardRef(Dropdown); 182 | -------------------------------------------------------------------------------- /src/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import { composeRef, getNodeRef, supportRef } from '@rc-component/util/lib/ref'; 2 | import React, { forwardRef, useMemo } from 'react'; 3 | import type { DropdownProps } from './Dropdown'; 4 | 5 | export type OverlayProps = Pick< 6 | DropdownProps, 7 | 'overlay' | 'arrow' | 'prefixCls' 8 | >; 9 | 10 | const Overlay = forwardRef((props, ref) => { 11 | const { overlay, arrow, prefixCls } = props; 12 | 13 | const overlayNode = useMemo(() => { 14 | let overlayElement: React.ReactElement; 15 | if (typeof overlay === 'function') { 16 | overlayElement = overlay(); 17 | } else { 18 | overlayElement = overlay; 19 | } 20 | return overlayElement; 21 | }, [overlay]); 22 | 23 | const composedRef = composeRef(ref, getNodeRef(overlayNode)); 24 | 25 | return ( 26 | <> 27 | {arrow &&
} 28 | {React.cloneElement(overlayNode, { 29 | ref: supportRef(overlayNode) ? composedRef : undefined, 30 | })} 31 | 32 | ); 33 | }); 34 | 35 | export default Overlay; 36 | -------------------------------------------------------------------------------- /src/hooks/useAccessibility.ts: -------------------------------------------------------------------------------- 1 | import KeyCode from '@rc-component/util/lib/KeyCode'; 2 | import raf from '@rc-component/util/lib/raf'; 3 | import * as React from 'react'; 4 | 5 | const { ESC, TAB } = KeyCode; 6 | 7 | interface UseAccessibilityProps { 8 | visible: boolean; 9 | triggerRef: React.RefObject; 10 | onVisibleChange?: (visible: boolean) => void; 11 | autoFocus?: boolean; 12 | overlayRef?: React.RefObject; 13 | } 14 | 15 | export default function useAccessibility({ 16 | visible, 17 | triggerRef, 18 | onVisibleChange, 19 | autoFocus, 20 | overlayRef, 21 | }: UseAccessibilityProps) { 22 | const focusMenuRef = React.useRef(false); 23 | 24 | const handleCloseMenuAndReturnFocus = () => { 25 | if (visible) { 26 | triggerRef.current?.focus?.(); 27 | onVisibleChange?.(false); 28 | } 29 | }; 30 | 31 | const focusMenu = () => { 32 | if (overlayRef.current?.focus) { 33 | overlayRef.current.focus(); 34 | focusMenuRef.current = true; 35 | return true; 36 | } 37 | return false; 38 | }; 39 | 40 | const handleKeyDown = (event) => { 41 | switch (event.keyCode) { 42 | case ESC: 43 | handleCloseMenuAndReturnFocus(); 44 | break; 45 | case TAB: { 46 | let focusResult: boolean = false; 47 | if (!focusMenuRef.current) { 48 | focusResult = focusMenu(); 49 | } 50 | 51 | if (focusResult) { 52 | event.preventDefault(); 53 | } else { 54 | handleCloseMenuAndReturnFocus(); 55 | } 56 | break; 57 | } 58 | } 59 | }; 60 | 61 | React.useEffect(() => { 62 | if (visible) { 63 | window.addEventListener('keydown', handleKeyDown); 64 | if (autoFocus) { 65 | // FIXME: hack with raf 66 | raf(focusMenu, 3); 67 | } 68 | return () => { 69 | window.removeEventListener('keydown', handleKeyDown); 70 | focusMenuRef.current = false; 71 | }; 72 | } 73 | return () => { 74 | focusMenuRef.current = false; 75 | }; 76 | }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps 77 | } 78 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export type { TriggerProps } from '@rc-component/trigger'; 2 | export type { DropdownProps } from './Dropdown'; 3 | export type { OverlayProps } from './Overlay'; 4 | 5 | import Dropdown from './Dropdown'; 6 | 7 | export default Dropdown; 8 | -------------------------------------------------------------------------------- /src/placements.ts: -------------------------------------------------------------------------------- 1 | const autoAdjustOverflow = { 2 | adjustX: 1, 3 | adjustY: 1, 4 | }; 5 | 6 | const targetOffset = [0, 0]; 7 | 8 | const placements = { 9 | topLeft: { 10 | points: ['bl', 'tl'], 11 | overflow: autoAdjustOverflow, 12 | offset: [0, -4], 13 | targetOffset, 14 | }, 15 | top: { 16 | points: ['bc', 'tc'], 17 | overflow: autoAdjustOverflow, 18 | offset: [0, -4], 19 | targetOffset, 20 | }, 21 | topRight: { 22 | points: ['br', 'tr'], 23 | overflow: autoAdjustOverflow, 24 | offset: [0, -4], 25 | targetOffset, 26 | }, 27 | bottomLeft: { 28 | points: ['tl', 'bl'], 29 | overflow: autoAdjustOverflow, 30 | offset: [0, 4], 31 | targetOffset, 32 | }, 33 | bottom: { 34 | points: ['tc', 'bc'], 35 | overflow: autoAdjustOverflow, 36 | offset: [0, 4], 37 | targetOffset, 38 | }, 39 | bottomRight: { 40 | points: ['tr', 'br'], 41 | overflow: autoAdjustOverflow, 42 | offset: [0, 4], 43 | targetOffset, 44 | }, 45 | }; 46 | 47 | export default placements; 48 | -------------------------------------------------------------------------------- /tests/__mocks__/@rc-component/trigger.tsx: -------------------------------------------------------------------------------- 1 | import Trigger from '@rc-component/trigger/lib/mock'; 2 | 3 | export default Trigger; 4 | -------------------------------------------------------------------------------- /tests/__snapshots__/basic.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`dropdown simply works 1`] = ` 4 |
5 | 10 |
14 | 46 | 51 |
52 | `; 53 | -------------------------------------------------------------------------------- /tests/basic.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/button-has-type,react/no-find-dom-node,react/no-render-return-value,object-shorthand,func-names,max-len */ 2 | import type { MenuRef } from '@rc-component/menu'; 3 | import Menu, { Divider, Item as MenuItem } from '@rc-component/menu'; 4 | import { _rs } from '@rc-component/resize-observer'; 5 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 6 | import { act, fireEvent } from '@testing-library/react'; 7 | import type { HTMLAttributes } from 'react'; 8 | import * as React from 'react'; 9 | import { createRef, forwardRef, useImperativeHandle } from 'react'; 10 | import Dropdown from '../src'; 11 | import { render, sleep } from './utils'; 12 | 13 | async function waitForTime() { 14 | for (let i = 0; i < 10; i += 1) { 15 | await act(async () => { 16 | jest.runAllTimers(); 17 | }); 18 | } 19 | } 20 | 21 | async function triggerResize(target: Element) { 22 | act(() => { 23 | _rs([{ target } as ResizeObserverEntry]); 24 | }); 25 | 26 | await waitForTime(); 27 | } 28 | 29 | spyElementPrototypes(HTMLElement, { 30 | offsetParent: { 31 | get: () => document.body, 32 | }, 33 | offsetLeft: { 34 | get: function () { 35 | return parseFloat(window.getComputedStyle(this).marginLeft) || 0; 36 | }, 37 | }, 38 | offsetTop: { 39 | get: function () { 40 | return parseFloat(window.getComputedStyle(this).marginTop) || 0; 41 | }, 42 | }, 43 | offsetHeight: { 44 | get: function () { 45 | return parseFloat(window.getComputedStyle(this).height) || 0; 46 | }, 47 | }, 48 | offsetWidth: { 49 | get: function () { 50 | return parseFloat(window.getComputedStyle(this).width) || 0; 51 | }, 52 | }, 53 | 54 | getBoundingClientRect: () => ({ 55 | width: 100, 56 | height: 100, 57 | }), 58 | }); 59 | 60 | describe('dropdown', () => { 61 | beforeEach(() => { 62 | jest.clearAllTimers(); 63 | }); 64 | 65 | it('default visible', () => { 66 | const { container } = render( 67 | Test
} visible> 68 | 69 | , 70 | ); 71 | expect(container instanceof HTMLDivElement).toBeTruthy(); 72 | expect( 73 | container 74 | .querySelector('.my-button') 75 | ?.classList.contains('rc-dropdown-open'), 76 | ).toBeTruthy(); 77 | }); 78 | 79 | it('supports controlled visible prop', () => { 80 | const onVisibleChange = jest.fn(); 81 | const { container } = render( 82 | Test
} 84 | visible 85 | trigger={['click']} 86 | onVisibleChange={onVisibleChange} 87 | > 88 | 89 | , 90 | ); 91 | expect(container instanceof HTMLDivElement).toBeTruthy(); 92 | expect( 93 | container 94 | .querySelector('.my-button') 95 | ?.classList.contains('rc-dropdown-open'), 96 | ).toBeTruthy(); 97 | 98 | fireEvent.click(container.querySelector('.my-button')); 99 | expect(onVisibleChange).toHaveBeenCalledWith(false); 100 | }); 101 | 102 | it('simply works', async () => { 103 | let clicked; 104 | 105 | function onClick({ key }) { 106 | clicked = key; 107 | } 108 | 109 | const onOverlayClick = jest.fn(); 110 | 111 | const menu = ( 112 | 113 | 114 | one 115 | 116 | 117 | two 118 | 119 | ); 120 | const { container, baseElement } = render( 121 | 126 | 127 | , 128 | ); 129 | expect(container.querySelector('.my-button')).toBeTruthy(); 130 | // should not display until be triggered 131 | expect(baseElement.querySelector('.rc-dropdown')).toBeFalsy(); 132 | 133 | fireEvent.click(container.querySelector('.my-button')); 134 | expect(clicked).toBeUndefined(); 135 | expect( 136 | baseElement 137 | .querySelector('.rc-dropdown') 138 | .classList.contains('rc-dropdown-hidden'), 139 | ).toBeFalsy(); 140 | expect(container).toMatchSnapshot(); 141 | 142 | fireEvent.click(baseElement.querySelector('.my-menuitem')); 143 | expect(clicked).toBe('1'); 144 | expect(onOverlayClick).toHaveBeenCalled(); 145 | expect( 146 | baseElement 147 | .querySelector('.rc-dropdown') 148 | .classList.contains('rc-dropdown-hidden'), 149 | ).toBeTruthy(); 150 | }); 151 | 152 | it('re-align works', async () => { 153 | jest.useFakeTimers(); 154 | 155 | const onPopupAlign = jest.fn(); 156 | 157 | const buttonStyle = { width: 600, height: 20, marginLeft: 100 }; 158 | const menu = ( 159 | 160 | one 161 | 162 | ); 163 | const { container } = render( 164 | 170 | 173 | , 174 | ); 175 | 176 | expect(onPopupAlign).not.toHaveBeenCalled(); 177 | 178 | fireEvent.click(container.querySelector('.my-btn')); 179 | await waitForTime(); 180 | 181 | expect(onPopupAlign).toHaveBeenCalled(); 182 | 183 | jest.useRealTimers(); 184 | }); 185 | 186 | it('Test default minOverlayWidthMatchTrigger', async () => { 187 | jest.useFakeTimers(); 188 | 189 | const overlayWidth = 50; 190 | const overlay =
Test
; 191 | 192 | const { container, baseElement } = render( 193 | 194 | 197 | , 198 | ); 199 | 200 | await triggerResize(container.querySelector('button')); 201 | 202 | expect(baseElement.querySelector('.rc-dropdown')).toHaveStyle({ 203 | minWidth: '100px', 204 | }); 205 | 206 | jest.useRealTimers(); 207 | }); 208 | 209 | it('user pass minOverlayWidthMatchTrigger', async () => { 210 | jest.useFakeTimers(); 211 | 212 | const overlayWidth = 50; 213 | const overlay =
Test
; 214 | 215 | const { container, baseElement } = render( 216 | 222 | 225 | , 226 | ); 227 | 228 | await triggerResize(container.querySelector('button')); 229 | 230 | expect(baseElement.querySelector('.rc-dropdown')).not.toHaveStyle({ 231 | minWidth: '100px', 232 | }); 233 | 234 | jest.useRealTimers(); 235 | }); 236 | 237 | it('should support default openClassName', () => { 238 | const overlay =
Test
; 239 | const { container } = render( 240 | 245 | 248 | , 249 | ); 250 | fireEvent.click(container.querySelector('.my-button')); 251 | expect( 252 | container 253 | .querySelector('.my-button') 254 | .classList.contains('rc-dropdown-open'), 255 | ).toBeTruthy(); 256 | fireEvent.click(container.querySelector('.my-button')); 257 | expect( 258 | container 259 | .querySelector('.my-button') 260 | .classList.contains('rc-dropdown-open'), 261 | ).toBeFalsy(); 262 | }); 263 | 264 | it('should support custom openClassName', async () => { 265 | const overlay =
Test
; 266 | const { container } = render( 267 | 273 | 276 | , 277 | ); 278 | 279 | fireEvent.click(container.querySelector('.my-button')); 280 | expect( 281 | container.querySelector('.my-button').classList.contains('opened'), 282 | ).toBeTruthy(); 283 | fireEvent.click(container.querySelector('.my-button')); 284 | expect( 285 | container.querySelector('.my-button').classList.contains('opened'), 286 | ).toBeFalsy(); 287 | }); 288 | 289 | it('overlay callback', async () => { 290 | const overlay =
Test
; 291 | const { container, baseElement } = render( 292 | overlay}> 293 | 294 | , 295 | ); 296 | 297 | fireEvent.click(container.querySelector('.my-button')); 298 | expect( 299 | baseElement 300 | .querySelector('.rc-dropdown') 301 | .classList.contains('rc-dropdown-hidden'), 302 | ).toBeFalsy(); 303 | }); 304 | 305 | it('should support arrow', async () => { 306 | const overlay =
Test
; 307 | const { container, baseElement } = render( 308 | 309 | 312 | , 313 | ); 314 | 315 | fireEvent.click(container.querySelector('.my-button')); 316 | await sleep(500); 317 | expect( 318 | baseElement 319 | .querySelector('.rc-dropdown') 320 | .classList.contains('rc-dropdown-show-arrow'), 321 | ).toBeTruthy(); 322 | expect( 323 | baseElement 324 | .querySelector('.rc-dropdown') 325 | .firstElementChild.classList.contains('rc-dropdown-arrow'), 326 | ).toBeTruthy(); 327 | }); 328 | 329 | it('Keyboard navigation works', async () => { 330 | jest.useFakeTimers(); 331 | 332 | const overlay = ( 333 | 334 | 335 | one 336 | 337 | two 338 | 339 | ); 340 | const { container, baseElement } = render( 341 | 342 | 343 | , 344 | ); 345 | const trigger = container.querySelector('.my-button'); 346 | 347 | // Open menu; 348 | fireEvent.click(trigger); 349 | await waitForTime(); 350 | expect( 351 | baseElement 352 | .querySelector('.rc-dropdown') 353 | .classList.contains('rc-dropdown-hidden'), 354 | ).toBeFalsy(); 355 | 356 | // Close menu with Esc 357 | fireEvent.keyDown(window, { key: 'Esc', keyCode: 27 }); 358 | await waitForTime(); 359 | expect(document.activeElement.className).toContain('my-button'); 360 | 361 | // Open menu 362 | fireEvent.click(trigger); 363 | await waitForTime(); 364 | expect( 365 | baseElement 366 | .querySelector('.rc-dropdown') 367 | .classList.contains('rc-dropdown-hidden'), 368 | ).toBeFalsy(); 369 | 370 | // Focus menu with Tab 371 | window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab 372 | expect(document.activeElement.className).toContain('menu'); 373 | 374 | // Close menu with Tab 375 | window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab 376 | await waitForTime(); 377 | expect(document.activeElement.className).toContain('my-button'); 378 | 379 | jest.useRealTimers(); 380 | }); 381 | 382 | it('Tab should close menu if overlay cannot be focused', async () => { 383 | jest.useFakeTimers(); 384 | 385 | const Overlay = () =>
test
; 386 | const { container, baseElement } = render( 387 | }> 388 | 389 | , 390 | ); 391 | const trigger = container.querySelector('.my-button'); 392 | 393 | // Open menu; 394 | fireEvent.click(trigger); 395 | await waitForTime(); 396 | expect( 397 | baseElement 398 | .querySelector('.rc-dropdown') 399 | .classList.contains('rc-dropdown-hidden'), 400 | ).toBeFalsy(); 401 | 402 | // Close menu with Esc 403 | fireEvent.keyDown(window, { key: 'Esc', keyCode: 27 }); 404 | await waitForTime(); 405 | expect(document.activeElement.className).toContain('my-button'); 406 | 407 | // Open menu 408 | fireEvent.click(trigger); 409 | await waitForTime(); 410 | expect( 411 | baseElement 412 | .querySelector('.rc-dropdown') 413 | .classList.contains('rc-dropdown-hidden'), 414 | ).toBeFalsy(); 415 | 416 | // Close menu with Tab 417 | window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab 418 | await waitForTime(); 419 | expect(document.activeElement.className).toContain('my-button'); 420 | 421 | jest.useRealTimers(); 422 | }); 423 | 424 | it('keyboard should work if menu is wrapped', async () => { 425 | const overlay = ( 426 |
427 | 428 | 429 | one 430 | 431 | two 432 | 433 |
434 | ); 435 | const { container, baseElement } = render( 436 | 437 | 438 | , 439 | ); 440 | const trigger = container.querySelector('.my-button'); 441 | 442 | // Open menu 443 | fireEvent.click(trigger); 444 | await sleep(200); 445 | expect( 446 | baseElement 447 | .querySelector('.rc-dropdown') 448 | .classList.contains('rc-dropdown-hidden'), 449 | ).toBeFalsy(); 450 | 451 | // Close menu with Esc 452 | window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 })); // Esc 453 | await sleep(200); 454 | expect(document.activeElement.className).toContain('my-button'); 455 | 456 | // Open menu 457 | fireEvent.click(trigger); 458 | await sleep(200); 459 | expect( 460 | baseElement 461 | .querySelector('.rc-dropdown') 462 | .classList.contains('rc-dropdown-hidden'), 463 | ).toBeFalsy(); 464 | 465 | // Focus menu with Tab 466 | window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab 467 | 468 | // Close menu with Tab 469 | window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab 470 | await sleep(200); 471 | expect(document.activeElement.className).toContain('my-button'); 472 | }); 473 | 474 | it('support Menu expandIcon', async () => { 475 | const props = { 476 | overlay: ( 477 | }> 478 | foo 479 | 480 | foo 481 | 482 | 483 | ), 484 | visible: true, 485 | getPopupContainer: (node) => node, 486 | }; 487 | 488 | const { container } = render( 489 | 490 | 491 | , 492 | ); 493 | await sleep(500); 494 | expect(container.querySelector('#customExpandIcon')).toBeTruthy(); 495 | }); 496 | 497 | it('should support customized menuRef', async () => { 498 | const menuRef = createRef(); 499 | const props = { 500 | overlay: ( 501 | 502 | foo 503 | 504 | ), 505 | visible: true, 506 | }; 507 | 508 | render( 509 | 510 | 511 | , 512 | ); 513 | 514 | await sleep(500); 515 | expect(menuRef.current).toBeTruthy(); 516 | }); 517 | 518 | it('should support trigger when child provide nativeElement', async () => { 519 | jest.useFakeTimers(); 520 | const Button = forwardRef>( 521 | (props, ref) => { 522 | const btnRef = createRef(); 523 | useImperativeHandle(ref, () => ({ 524 | foo: () => {}, 525 | nativeElement: btnRef.current, 526 | })); 527 | return ( 528 | 536 | ); 537 | }, 538 | ); 539 | const { container, baseElement } = render( 540 | node} 543 | overlay={ 544 | 545 | foo 546 | 547 | } 548 | > 549 | 573 | , 574 | ); 575 | const trigger = container.querySelector('.my-button'); 576 | 577 | // Open menu 578 | fireEvent.click(trigger); 579 | 580 | await waitForTime(); 581 | 582 | expect( 583 | container 584 | .querySelector('.rc-dropdown') 585 | .classList.contains('rc-dropdown-hidden'), 586 | ).toBeFalsy(); 587 | expect(document.activeElement.className).toContain('menu'); 588 | 589 | // Close menu with Tab 590 | window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab 591 | 592 | await waitForTime(); 593 | 594 | expect(document.activeElement.className).toContain('my-button'); 595 | 596 | jest.useRealTimers(); 597 | }); 598 | 599 | it('children cannot be given ref should not throw', () => { 600 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 601 | const Component = () =>
test
; 602 | 603 | render( 604 | test
}> 605 | 606 | , 607 | ); 608 | expect(errorSpy).not.toHaveBeenCalledWith( 609 | expect.stringContaining( 610 | 'Warning: Function components cannot be given refs', 611 | ), 612 | expect.anything(), 613 | expect.anything(), 614 | ); 615 | }); 616 | }); 617 | -------------------------------------------------------------------------------- /tests/point.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/button-has-type,react/no-render-return-value */ 2 | import { act, fireEvent } from '@testing-library/react'; 3 | import * as React from 'react'; 4 | import Dropdown from '../src'; 5 | import { render } from './utils'; 6 | 7 | // Fix prettier rm this 8 | console.log(!!React); 9 | 10 | async function waitForTime() { 11 | for (let i = 0; i < 10; i += 1) { 12 | await act(async () => { 13 | jest.runAllTimers(); 14 | }); 15 | } 16 | } 17 | 18 | describe('point', () => { 19 | beforeEach(() => { 20 | jest.useFakeTimers(); 21 | }); 22 | 23 | afterEach(() => { 24 | jest.clearAllTimers(); 25 | jest.useRealTimers(); 26 | }); 27 | 28 | it('click show', async () => { 29 | const overlay = ( 30 |
36 | Test 37 |
38 | ); 39 | 40 | const onPopupAlign = jest.fn(); 41 | 42 | const { container } = render( 43 | 53 | 54 | , 55 | ); 56 | 57 | fireEvent.contextMenu(container.querySelector('.my-button')); 58 | await waitForTime(); 59 | 60 | expect(container.querySelector('.rc-dropdown')).toBeTruthy(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { render, act } from '@testing-library/react'; 3 | 4 | const globalTimeout = global.setTimeout; 5 | 6 | export async function sleep(timeout = 0) { 7 | await act(async () => { 8 | await new Promise((resolve) => { 9 | globalTimeout(resolve, timeout); 10 | }); 11 | }); 12 | } 13 | 14 | function customRender(ui, options) { 15 | return render(ui, { wrapper: StrictMode, ...options }); 16 | } 17 | 18 | export { customRender as render }; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "baseUrl": "./", 5 | "declaration": true, 6 | "module": "esnext", 7 | "target": "esnext", 8 | "moduleResolution": "node", 9 | "jsx": "react", 10 | "skipLibCheck": true, 11 | "paths": { 12 | "@@/*": [".dumi/tmp/*"] 13 | } 14 | }, 15 | "include": ["./src", "./tests", "./typings/"], 16 | "typings": "./typings/index.d.ts", 17 | "exclude": [ 18 | "node_modules", 19 | "build", 20 | "scripts", 21 | "acceptance-tests", 22 | "webpack", 23 | "jest", 24 | "src/setupTests.ts", 25 | "tslint:latest", 26 | "tslint-config-prettier" 27 | ] 28 | } 29 | --------------------------------------------------------------------------------