├── .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 | 
27 |
28 | ## Example
29 |
30 | online example: http://react-component.github.io/dropdown/examples/
31 |
32 | ## install
33 |
34 | [](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 | name |
51 | type |
52 | default |
53 | description |
54 |
55 |
56 |
57 |
58 | overlayClassName |
59 | String |
60 | |
61 | additional css class of root dom node |
62 |
63 |
64 | openClassName |
65 | String |
66 | `${prefixCls}-open` |
67 | className of trigger when dropdown is opened |
68 |
69 |
70 | prefixCls |
71 | String |
72 | rc-dropdown |
73 | prefix class name |
74 |
75 |
76 | transitionName |
77 | String |
78 | |
79 | dropdown menu's animation css class name |
80 |
81 |
82 | animation |
83 | String |
84 | |
85 | part of dropdown menu's animation css class name |
86 |
87 |
88 | placement |
89 | String |
90 | bottomLeft |
91 | Position of menu item. There are: top, topCenter, topRight, bottomLeft, bottom, bottomRight |
92 |
93 |
94 | onVisibleChange |
95 | Function |
96 | |
97 | call when visible is changed |
98 |
99 |
100 | visible |
101 | boolean |
102 | |
103 | whether tooltip is visible |
104 |
105 |
106 | defaultVisible |
107 | boolean |
108 | |
109 | whether tooltip is visible initially |
110 |
111 |
112 | overlay |
113 | rc-menu |
114 | |
115 | rc-menu element |
116 |
117 |
118 | onOverlayClick |
119 | function(e) |
120 | |
121 | call when overlay is clicked |
122 |
123 |
124 | minOverlayWidthMatchTrigger |
125 | boolean |
126 | true (false when set alignPoint) |
127 | whether overlay's width must not be less than trigger's |
128 |
129 |
130 | getPopupContainer |
131 | Function(menuDOMNode): HTMLElement |
132 | () => document.body |
133 | Where to render the DOM node of dropdown |
134 |
135 |
136 |
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 |
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 |
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 | ,
20 | ,
21 | ];
22 |
23 | if (this.state.longList) {
24 | menuItems.push();
25 | }
26 | const menu = ;
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
547 | }
548 | >
549 |
550 | ,
551 | );
552 | fireEvent.click(container.querySelector('button'));
553 | fireEvent.click(baseElement.querySelectorAll('li')[0]);
554 |
555 | jest.runAllTimers();
556 | jest.useRealTimers();
557 | });
558 |
559 | it('should support autoFocus', async () => {
560 | jest.useFakeTimers();
561 |
562 | const overlay = (
563 |
569 | );
570 | const { container } = render(
571 |
572 |
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 |
--------------------------------------------------------------------------------