├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.js ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── main.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets ├── index.less ├── list.less ├── panel.less └── select.less ├── bunfig.toml ├── docs ├── CHANGELOG.md ├── demo │ ├── adjust-overflow.md │ ├── animation.md │ ├── change-on-select.md │ ├── custom-arrow-icon.md │ ├── custom-field-name.md │ ├── debug.md │ ├── default-expand-single-option.md │ ├── defaultValue.md │ ├── disabled.md │ ├── dynamic-options.md │ ├── hover.md │ ├── multiple-search.md │ ├── multiple.md │ ├── option-render.md │ ├── panel.md │ ├── popup-render.md │ ├── rc-form.md │ ├── search.md │ ├── simple.md │ ├── text-trigger.md │ ├── value.md │ └── visible.md └── index.md ├── examples ├── adjust-overflow.tsx ├── animation.tsx ├── change-on-select.tsx ├── custom-arrow-icon.tsx ├── custom-field-name.tsx ├── debug.tsx ├── default-expand-single-option.tsx ├── defaultValue.tsx ├── disabled.tsx ├── dynamic-options.tsx ├── hover.tsx ├── multiple-search.tsx ├── multiple.tsx ├── option-render.tsx ├── panel.tsx ├── popup-render.tsx ├── rc-form.tsx ├── search.tsx ├── simple.tsx ├── text-trigger.tsx ├── utils.tsx ├── value.tsx └── visible.tsx ├── index.js ├── jest.config.js ├── package.json ├── src ├── Cascader.tsx ├── OptionList │ ├── CacheContent.tsx │ ├── Checkbox.tsx │ ├── Column.tsx │ ├── List.tsx │ ├── index.tsx │ ├── useActive.ts │ └── useKeyboard.ts ├── Panel.tsx ├── context.ts ├── hooks │ ├── useDisplayValues.ts │ ├── useEntities.ts │ ├── useMissingValues.ts │ ├── useOptions.ts │ ├── useSearchConfig.ts │ ├── useSearchOptions.ts │ ├── useSelect.ts │ └── useValues.ts ├── index.tsx └── utils │ ├── commonUtil.ts │ ├── treeUtil.ts │ └── warningPropsUtil.ts ├── tests ├── Panel.spec.tsx ├── __mocks__ │ └── @rc-component │ │ └── trigger.js ├── __snapshots__ │ ├── index.spec.tsx.snap │ └── search.spec.tsx.snap ├── checkable.spec.tsx ├── demoOptions.ts ├── enzyme.ts ├── fieldNames.spec.tsx ├── index.spec.tsx ├── keyboard.spec.tsx ├── loadData.spec.tsx ├── private.spec.tsx ├── search.limit.spec.tsx ├── search.spec.tsx ├── selector.spec.tsx ├── semantic.spec.tsx └── setup.js ├── tsconfig.json └── update-example.js /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | alias: { 6 | 'rc-cascader$': path.resolve('src'), 7 | 'rc-cascader/es': path.resolve('src'), 8 | }, 9 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 10 | themeConfig: { 11 | name: 'Cascader', 12 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /.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 | 'import/no-extraneous-dependencies': 0, 15 | 'no-shadow': 0 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ant-design # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: ant-design # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: np 11 | versions: 12 | - 7.2.0 13 | - 7.3.0 14 | - 7.4.0 15 | - dependency-name: "@types/react" 16 | versions: 17 | - 17.0.0 18 | - 17.0.1 19 | - 17.0.2 20 | - 17.0.3 21 | - dependency-name: "@types/jest" 22 | versions: 23 | - 26.0.20 24 | - 26.0.21 25 | - 26.0.22 26 | - dependency-name: "@types/react-dom" 27 | versions: 28 | - 17.0.0 29 | - 17.0.1 30 | - 17.0.2 31 | - dependency-name: typescript 32 | versions: 33 | - 4.1.3 34 | - 4.1.4 35 | - 4.1.5 36 | - 4.2.2 37 | - 4.2.3 38 | -------------------------------------------------------------------------------- /.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: "2 15 * * 3" 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 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test-npm.yml@main 6 | secrets: inherit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | *.iml 3 | *.log 4 | .idea 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | node_modules 22 | .cache 23 | *.css 24 | build 25 | lib 26 | es 27 | yarn.lock 28 | package-lock.json 29 | coverage 30 | # umi 31 | .umi 32 | .umi-production 33 | .umi-test 34 | .env.local 35 | .doc 36 | 37 | # dumi 38 | .dumi/tmp 39 | .dumi/tmp-production 40 | dist 41 | .vscode 42 | 43 | bun.lockb -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100, 8 | "arrowParens": "avoid" 9 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## 0.17.0 / 2018/12/14 4 | 5 | - should close popup on double click when changeOnSelect is set 6 | 7 | ## 0.16.0 / 2018/08/23 8 | 9 | - Add loadingIcon. 10 | 11 | ## 0.15.0 / 2018/08/10 12 | 13 | - Add expandIcon. 14 | 15 | ## 0.14.0 / 2018/07/03 16 | 17 | - Fix typo `filedNames` to `fieldNames`. [ant-design/ant-design#10896](https://github.com/ant-design/ant-design/issues/10896) 18 | 19 | ## 0.13.0 / 2018/04/27 20 | 21 | - Allow to custom the field name of options. [#23](https://github.com/react-component/cascader/pull/23) 22 | 23 | ## 0.12.0 24 | 25 | - Add es build 26 | - support React 16 27 | 28 | ## 0.11.0 / 2017-01-17 29 | 30 | - Add Keyboard support 31 | 32 | ## 0.10.0 / 2016-08-20 33 | 34 | - Add `dropdownMenuColumnStyle` 35 | - Fix `changeOnSelect` when load data dynamicly 36 | 37 | ## 0.9.0 / 2016-02-19 38 | 39 | - support `popupPlacement` 40 | 41 | ## 0.8.0 / 2016-01-26 42 | 43 | - Add prop `changeOnSelect` 44 | 45 | ## 0.7.0 / 2016-01-25 46 | 47 | - Support disabled item 48 | 49 | ## 0.6.1 / 2016-01-18 50 | 51 | - Hide popup menu when there is no options, fix #4 52 | 53 | ## 0.6.0 / 2016-01-05 54 | 55 | - Add prop `disabled`. 56 | 57 | ## 0.5.1 / 2015-12-31 58 | 59 | - Always scroll to show active menu item 60 | 61 | ## 0.5.0 / 2015-12-30 62 | 63 | - Remove `onSelect` 64 | - Add `loadData` for dynamicly changing options 65 | 66 | ## 0.4.0 / 2015-12-29 67 | 68 | - Add prop `popupVisible`. 69 | - `onVisibleChange` => `onPopupVisibleChange`. 70 | 71 | ## 0.3.0 / 2015-12-28 72 | 73 | - Fix value and defaultValue. 74 | - Add Test Cases. 75 | 76 | ## 0.2.0 / 2015-12-25 77 | 78 | - Add prop `expandTrigger`. 79 | 80 | ## 0.1.0 / 2015-12-25 81 | 82 | First release. 83 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-present alipay.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-cascader 2 | 3 | React Cascader Component. 4 | 5 | [![NPM version][npm-image]][npm-url] 6 | [![npm download][download-image]][download-url] 7 | [![build status][github-actions-image]][github-actions-url] 8 | [![Codecov][codecov-image]][codecov-url] 9 | [![bundle size][bundlephobia-image]][bundlephobia-url] 10 | [![dumi][dumi-image]][dumi-url] 11 | 12 | [npm-image]: http://img.shields.io/npm/v/rc-cascader.svg?style=flat-square 13 | [npm-url]: http://npmjs.org/package/rc-cascader 14 | [travis-image]: https://img.shields.io/travis/react-component/cascader/master?style=flat-square 15 | [travis-url]: https://travis-ci.com/react-component/cascader 16 | [github-actions-image]: https://github.com/react-component/cascader/workflows/CI/badge.svg 17 | [github-actions-url]: https://github.com/react-component/cascader/actions 18 | [codecov-image]: https://img.shields.io/codecov/c/github/react-component/cascader/master.svg?style=flat-square 19 | [codecov-url]: https://app.codecov.io/gh/react-component/cascader 20 | [david-url]: https://david-dm.org/react-component/cascader 21 | [david-image]: https://david-dm.org/react-component/cascader/status.svg?style=flat-square 22 | [david-dev-url]: https://david-dm.org/react-component/cascader?type=dev 23 | [david-dev-image]: https://david-dm.org/react-component/cascader/dev-status.svg?style=flat-square 24 | [download-image]: https://img.shields.io/npm/dm/rc-cascader.svg?style=flat-square 25 | [download-url]: https://npmjs.org/package/rc-cascader 26 | [bundlephobia-url]: https://bundlephobia.com/package/rc-cascader 27 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-cascader 28 | [dumi-url]: https://github.com/umijs/dumi 29 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square 30 | 31 | ## Browser Support 32 | 33 | | [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | [Electron](http://godban.github.io/browsers-support-badges/)
Electron | 34 | | --- | --- | --- | --- | --- | 35 | | IE11, Edge | last 2 versions | last 2 versions | last 2 versions | last 2 versions | 36 | 37 | ## Screenshots 38 | 39 | 40 | 41 | ## Example 42 | 43 | https://cascader-react-component.vercel.app 44 | 45 | ## Install 46 | 47 | [![rc-cascader](https://nodei.co/npm/rc-cascader.png)](https://npmjs.org/package/rc-cascader) 48 | 49 | ```bash 50 | $ npm install rc-cascader --save 51 | ``` 52 | 53 | ## Usage 54 | 55 | ```js 56 | import React from 'react'; 57 | import Cascader from '@rc-component/cascader'; 58 | 59 | const options = [{ 60 | 'label': '福建', 61 | 'value': 'fj', 62 | 'children': [{ 63 | 'label': '福州', 64 | 'value': 'fuzhou', 65 | 'children': [{ 66 | 'label': '马尾', 67 | 'value': 'mawei', 68 | }], 69 | }, { 70 | 'label': '泉州', 71 | 'value': 'quanzhou', 72 | }], 73 | }, { 74 | 'label': '浙江', 75 | 'value': 'zj', 76 | 'children': [{ 77 | 'label': '杭州', 78 | 'value': 'hangzhou', 79 | 'children': [{ 80 | 'label': '余杭', 81 | 'value': 'yuhang', 82 | }], 83 | }], 84 | }, { 85 | 'label': '北京', 86 | 'value': 'bj', 87 | 'children': [{ 88 | 'label': '朝阳区', 89 | 'value': 'chaoyang', 90 | }, { 91 | 'label': '海淀区', 92 | 'value': 'haidian', 93 | }], 94 | }]; 95 | 96 | React.render( 97 | 98 | ... 99 | 100 | , container); 101 | ``` 102 | 103 | ## API 104 | 105 | ### props 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 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 |
nametypedefaultdescription
autoClearSearchValuebooleantrueWhether the current search will be cleared on selecting an item. Only applies when checkable
optionsObjectThe data options of cascade
valueArrayselected value
defaultValueArrayinitial selected value
onChangeFunction(value, selectedOptions)callback when finishing cascader select
changeOnSelectBooleanfalsechange value on each selection
loadDataFunction(selectedOptions)callback when click any option, use for loading more options
expandTriggerString'click'expand current item when click or hover
openBooleanvisibility of popup overlay
onPopupVisibleChangeFunction(visible)callback when popup overlay's visibility changed
transitionNameStringtransition className like "slide-up"
prefixClsStringrc-cascaderprefix className of popup overlay
popupClassNameStringadditional className of popup overlay
popupPlacementStringbottomLeftuse preset popup align config from builtinPlacements:bottomRight topRight bottomLeft topLeft
getPopupContainerfunction(trigger:Node):Node() => document.bodycontainer which popup select menu rendered into
dropdownMenuColumnStyleObjectstyle object for each cascader pop menu
fieldNamesObject{ label: 'label', value: 'value', children: 'children' }custom field name for label and value and children
expandIconReactNode>specific the default expand icon
loadingIconReactNode>specific the default loading icon
hidePopupOnSelectBoolean>truehide popup on select
239 | 240 | ### option 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 |
nametypedefaultdescription
labelStringoption text to display
valueStringoption value as react key
disabledBooleandisabled option
childrenArraychildren options
278 | 279 | ## Development 280 | 281 | ```bash 282 | $ npm install 283 | $ npm start 284 | ``` 285 | 286 | ## Test Case 287 | 288 | ```bash 289 | $ npm test 290 | ``` 291 | 292 | ## Coverage 293 | 294 | ```bash 295 | $ npm run coverage 296 | ``` 297 | 298 | ## License 299 | 300 | rc-cascader is released under the MIT license. 301 | 302 | ## 🤝 Contributing 303 | 304 | 305 | Contribution Leaderboard 306 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @import "./select.less"; 2 | @import "./list.less"; 3 | @import "./panel.less"; -------------------------------------------------------------------------------- /assets/list.less: -------------------------------------------------------------------------------- 1 | @select-prefix: ~'rc-cascader'; 2 | 3 | .@{select-prefix} { 4 | &-dropdown { 5 | min-height: auto; 6 | } 7 | 8 | &-menus { 9 | display: flex; 10 | flex-wrap: nowrap; 11 | } 12 | 13 | &-menu { 14 | flex: none; 15 | margin: 0; 16 | padding: 0; 17 | list-style: none; 18 | border-left: 1px solid blue; 19 | height: 180px; 20 | min-width: 100px; 21 | overflow: auto; 22 | 23 | &:first-child { 24 | border-left: 0; 25 | } 26 | 27 | &-item { 28 | display: flex; 29 | flex-wrap: nowrap; 30 | padding-right: 20px; 31 | position: relative; 32 | 33 | &:hover { 34 | background: rgba(0, 0, 255, 0.1); 35 | } 36 | 37 | &-selected { 38 | background: rgba(0, 0, 255, 0.05); 39 | } 40 | 41 | &-active { 42 | background: rgba(0, 255, 0, 0.1); 43 | } 44 | 45 | &-disabled { 46 | opacity: 0.5; 47 | } 48 | 49 | 50 | 51 | &-content { 52 | flex: auto; 53 | } 54 | 55 | &-expand-icon { 56 | position: absolute; 57 | right: 4px; 58 | top: 50%; 59 | transform: translateY(-50%); 60 | } 61 | } 62 | } 63 | 64 | &-checkbox { 65 | position: relative; 66 | display: block; 67 | flex: none; 68 | width: 20px; 69 | height: 20px; 70 | border: 1px solid blue; 71 | 72 | &::after { 73 | position: absolute; 74 | top: 50%; 75 | left: 50%; 76 | transform: translate(-50%, -50%); 77 | content: ''; 78 | } 79 | 80 | &-checked::after { 81 | content: '✔️'; 82 | } 83 | 84 | &-indeterminate::after { 85 | content: '➖'; 86 | } 87 | } 88 | 89 | // ====================== RTL ====================== 90 | &-rtl { 91 | direction: rtl; 92 | 93 | .@{select-prefix}-menu { 94 | flex: none; 95 | margin: 0; 96 | padding: 0; 97 | list-style: none; 98 | border-left: none; 99 | border-right: 1px solid blue; 100 | 101 | &:first-child { 102 | border-right: 0; 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /assets/panel.less: -------------------------------------------------------------------------------- 1 | @import (reference) './index.less'; 2 | 3 | .@{select-prefix} { 4 | &-panel { 5 | border: 1px solid green; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /assets/select.less: -------------------------------------------------------------------------------- 1 | @import '~@rc-component/select/assets/index'; 2 | 3 | @select-prefix: ~'rc-cascader'; -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/demo/adjust-overflow.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: adjust-overflow 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/animation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: animation 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/change-on-select.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: change-on-select 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/custom-arrow-icon.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: custom-arrow-icon 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/custom-field-name.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: custom-field-name 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/debug.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: debug 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/default-expand-single-option.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: default-expand-single-option 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/defaultValue.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: defaultValue 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/disabled.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: disabled 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/dynamic-options.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: dynamic-options 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/hover.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: hover 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/multiple-search.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: multiple-search 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/option-render.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: option-render 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/panel.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Panel 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/popup-render.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: popup-render 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/rc-form.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: rc-form 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/search.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: search 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/simple.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: animated 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/text-trigger.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: text-trigger 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/value.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: value 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/visible.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: visible 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-cascader 4 | description: React cascader Component 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /examples/adjust-overflow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import type { BuildInPlacements } from '@rc-component/trigger/lib/interface'; 3 | import '../assets/index.less'; 4 | import type { CascaderProps } from '../src'; 5 | import Cascader from '../src'; 6 | import type { Option2 } from './utils'; 7 | 8 | const addressOptions = [ 9 | { 10 | label: '福建', 11 | value: 'fj', 12 | children: [ 13 | { 14 | label: '福州', 15 | value: 'fuzhou', 16 | children: [ 17 | { 18 | label: '马尾', 19 | value: 'mawei', 20 | }, 21 | ], 22 | }, 23 | { 24 | label: '泉州', 25 | value: 'quanzhou', 26 | }, 27 | ], 28 | }, 29 | { 30 | label: '浙江', 31 | value: 'zj', 32 | children: [ 33 | { 34 | label: '杭州', 35 | value: 'hangzhou', 36 | children: [ 37 | { 38 | label: '余杭', 39 | value: 'yuhang', 40 | }, 41 | ], 42 | }, 43 | ], 44 | }, 45 | { 46 | label: '北京', 47 | value: 'bj', 48 | children: [ 49 | { 50 | label: '朝阳区', 51 | value: 'chaoyang', 52 | }, 53 | { 54 | label: '海淀区', 55 | value: 'haidian', 56 | disabled: true, 57 | }, 58 | ], 59 | }, 60 | ]; 61 | 62 | const MyCascader = ({ builtinPlacements }: { builtinPlacements?: BuildInPlacements }) => { 63 | const [inputValue, setInputValue] = useState(''); 64 | 65 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 66 | console.log(value, selectedOptions); 67 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 68 | }; 69 | 70 | return ( 71 | 72 | 77 | 78 | ); 79 | }; 80 | 81 | const placements = { 82 | bottomLeft: { 83 | points: ['tl', 'bl'], 84 | offset: [0, 4], 85 | overflow: { 86 | adjustY: 1, 87 | }, 88 | }, 89 | topLeft: { 90 | points: ['bl', 'tl'], 91 | offset: [0, -4], 92 | overflow: { 93 | adjustY: 1, 94 | }, 95 | }, 96 | bottomRight: { 97 | points: ['tr', 'br'], 98 | offset: [0, 4], 99 | overflow: { 100 | adjustY: 1, 101 | }, 102 | }, 103 | topRight: { 104 | points: ['br', 'tr'], 105 | offset: [0, -4], 106 | overflow: { 107 | adjustY: 1, 108 | }, 109 | }, 110 | }; 111 | 112 | function Demo() { 113 | return ( 114 |
115 | 116 |
117 |
118 | 119 |
120 | ); 121 | } 122 | 123 | export default Demo; 124 | -------------------------------------------------------------------------------- /examples/animation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | children: [ 12 | { 13 | label: '福州', 14 | value: 'fuzhou', 15 | children: [ 16 | { 17 | label: '马尾', 18 | value: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | label: '泉州', 24 | value: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | label: '浙江', 30 | value: 'zj', 31 | children: [ 32 | { 33 | label: '杭州', 34 | value: 'hangzhou', 35 | children: [ 36 | { 37 | label: '余杭', 38 | value: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | label: '北京', 46 | value: 'bj', 47 | children: [ 48 | { 49 | label: '朝阳区', 50 | value: 'chaoyang', 51 | }, 52 | { 53 | label: '海淀区', 54 | value: 'haidian', 55 | }, 56 | ], 57 | }, 58 | ]; 59 | 60 | const Demo = () => { 61 | const [inputValue, setInputValue] = useState(''); 62 | 63 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 64 | console.log(value, selectedOptions); 65 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 66 | }; 67 | 68 | return ( 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default Demo; 76 | -------------------------------------------------------------------------------- /examples/change-on-select.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const options = [ 8 | { 9 | label: 'Women Clothing', 10 | value: 'Women Clothing', 11 | children: [ 12 | { 13 | label: 'Women Tops, Blouses & Tee', 14 | value: 'Women Tops, Blouses & Tee', 15 | children: [ 16 | { 17 | label: 'Women T-Shirts', 18 | value: 'Women T-Shirts', 19 | }, 20 | { 21 | label: 'Women Tops', 22 | value: 'Women Tops', 23 | }, 24 | { 25 | label: 'Women Tank Tops & Camis', 26 | value: 'Women Tank Tops & Camis', 27 | }, 28 | { 29 | label: 'Women Blouses', 30 | value: 'Women Blouses', 31 | }, 32 | ], 33 | }, 34 | { 35 | label: 'Women Suits', 36 | value: 'Women Suits', 37 | children: [ 38 | { 39 | label: 'Women Suit Pants', 40 | value: 'Women Suit Pants', 41 | }, 42 | { 43 | label: 'Women Suit Sets', 44 | value: 'Women Suit Sets', 45 | }, 46 | { 47 | label: 'Women Blazers', 48 | value: 'Women Blazers', 49 | }, 50 | ], 51 | }, 52 | { 53 | label: 'Women Co-ords', 54 | value: 'Women Co-ords', 55 | children: [ 56 | { 57 | label: 'Two-piece Outfits', 58 | value: 'Two-piece Outfits', 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | ]; 65 | 66 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 67 | console.log(value, selectedOptions); 68 | }; 69 | 70 | const Demo = () => ( 71 | console.log('loadData')} 78 | /> 79 | ); 80 | 81 | export default Demo; 82 | -------------------------------------------------------------------------------- /examples/custom-arrow-icon.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions: CascaderProps['options'] = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | children: [ 12 | { 13 | label: '福州', 14 | value: 'fuzhou', 15 | children: [ 16 | { 17 | label: '马尾', 18 | value: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | label: '泉州', 24 | value: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | label: '浙江', 30 | value: 'zj', 31 | children: [ 32 | { 33 | label: '杭州', 34 | value: 'hangzhou', 35 | children: [ 36 | { 37 | label: '余杭', 38 | value: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | label: '北京', 46 | value: 'bj', 47 | children: [ 48 | { 49 | label: '朝阳区', 50 | value: 'chaoyang', 51 | }, 52 | { 53 | label: '海淀区', 54 | value: 'haidian', 55 | disabled: true, 56 | }, 57 | ], 58 | }, 59 | ]; 60 | 61 | const svgPath = 62 | 'M869 487.8L491.2 159.9c-2.9-2.5-6.6-' + 63 | '3.9-10.5-3.9h-88.5c-7.4 0-10.8 9.2-5.2 14l350.2' + 64 | ' 304H152c-4.4 0-8 3.6-8 8v60c0 4.4 ' + 65 | '3.6 8 8 8h585.1L386.9 854c-5.6 4.9-2.2 14 5.2' + 66 | ' 14h91.5c1.9 0 3.8-0.7 5.2-2L869 536.2c14.7-12.8' + 67 | ' 14.7-35.6 0-48.4z'; 68 | 69 | const loadingPath = 70 | 'M511.4 124C290.5 124.3 112 303 112' + 71 | ' 523.9c0 128 60.2 242 153.8 315.2l-37.5 48c-4.1 5.3-' + 72 | '0.3 13 6.3 12.9l167-0.8c5.2 0 9-4.9 7.7-9.9L369.8 72' + 73 | '7c-1.6-6.5-10-8.3-14.1-3L315 776.1c-10.2-8-20-16.7-2' + 74 | '9.3-26-29.4-29.4-52.5-63.6-68.6-101.7C200.4 609 192 ' + 75 | '567.1 192 523.9s8.4-85.1 25.1-124.5c16.1-38.1 39.2-7' + 76 | '2.3 68.6-101.7 29.4-29.4 63.6-52.5 101.7-68.6C426.9 ' + 77 | '212.4 468.8 204 512 204s85.1 8.4 124.5 25.1c38.1 16.' + 78 | '1 72.3 39.2 101.7 68.6 29.4 29.4 52.5 63.6 68.6 101.' + 79 | '7 16.7 39.4 25.1 81.3 25.1 124.5s-8.4 85.1-25.1 124.' + 80 | '5c-16.1 38.1-39.2 72.3-68.6 101.7-7.5 7.5-15.3 14.5-' + 81 | '23.4 21.2-3.4 2.8-3.9 7.7-1.2 11.1l39.4 50.5c2.8 3.5' + 82 | ' 7.9 4.1 11.4 1.3C854.5 760.8 912 649.1 912 523.9c0-' + 83 | '221.1-179.4-400.2-400.6-399.9z'; 84 | 85 | const Demo = () => { 86 | const [inputValue, setInputValue] = useState(''); 87 | const [dynamicInputValue, setDynamicInputValue] = useState(''); 88 | const [options, setOptions] = useState([ 89 | { 90 | label: '福建', 91 | isLeaf: false, 92 | value: 'fj', 93 | }, 94 | { 95 | label: '浙江', 96 | isLeaf: false, 97 | value: 'zj', 98 | }, 99 | ]); 100 | 101 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 102 | console.log(value, selectedOptions); 103 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 104 | }; 105 | 106 | const onChangeDynamic: CascaderProps['onChange'] = (value, selectedOptions) => { 107 | console.log(value, selectedOptions); 108 | setDynamicInputValue(selectedOptions.map(o => o.label).join(', ')); 109 | }; 110 | 111 | const expandIcon = ( 112 | 113 | 123 | 124 | 125 | 126 | ); 127 | 128 | const loadingIcon = ( 129 | 136 | 146 | 147 | 148 | 149 | ); 150 | 151 | const loadData: CascaderProps['loadData'] = selectedOptions => { 152 | const targetOption = selectedOptions[selectedOptions.length - 1]; 153 | targetOption.loading = true; 154 | // 动态加载下级数据 155 | setTimeout(() => { 156 | targetOption.loading = false; 157 | targetOption.children = [ 158 | { 159 | label: `${targetOption.label}动态加载1`, 160 | value: 'dynamic1', 161 | }, 162 | { 163 | label: `${targetOption.label}动态加载2`, 164 | value: 'dynamic2', 165 | }, 166 | ]; 167 | setOptions([...options]); 168 | }, 1500); 169 | }; 170 | 171 | return ( 172 |
173 | 179 | 180 | 181 | 189 | 190 | 191 |
192 | ); 193 | }; 194 | 195 | export default Demo; 196 | -------------------------------------------------------------------------------- /examples/custom-field-name.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option } from './utils'; 6 | 7 | const addressOptions: Option[] = [ 8 | { 9 | name: '福建', 10 | code: 'fj', 11 | nodes: [ 12 | { 13 | name: '福州', 14 | code: 'fuzhou', 15 | nodes: [ 16 | { 17 | name: '马尾', 18 | code: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | name: '泉州', 24 | code: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | name: '浙江', 30 | code: 'zj', 31 | nodes: [ 32 | { 33 | name: '杭州', 34 | code: 'hangzhou', 35 | nodes: [ 36 | { 37 | name: '余杭', 38 | code: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | name: '北京', 46 | code: 'bj', 47 | nodes: [ 48 | { 49 | name: '朝阳区', 50 | code: 'chaoyang', 51 | }, 52 | { 53 | name: '海淀区', 54 | code: 'haidian', 55 | disabled: true, 56 | }, 57 | ], 58 | }, 59 | ]; 60 | 61 | const Demo = () => { 62 | const [inputValue, setInputValue] = useState(''); 63 | 64 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 65 | console.log(value, selectedOptions); 66 | setInputValue(selectedOptions.map(o => o.name).join(', ')); 67 | }; 68 | 69 | return ( 70 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | export default Demo; 81 | -------------------------------------------------------------------------------- /examples/debug.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../assets/index.less'; 3 | import Cascader from '../src'; 4 | import type { Option2 } from './utils'; 5 | 6 | const addressOptions: Option2[] = [ 7 | // ...new Array(20).fill(null).map((_, i) => ({ label: String(i), value: `99${i}` })), 8 | { 9 | label: 空孩子, 10 | value: 'empty', 11 | children: [], 12 | }, 13 | { 14 | label: '福建', 15 | value: 'fj', 16 | title: '测试标题', 17 | children: [ 18 | { 19 | label: '福州', 20 | value: 'fuzhou', 21 | disabled: true, 22 | children: [ 23 | { 24 | label: '马尾', 25 | value: 'mawei', 26 | }, 27 | ], 28 | }, 29 | { 30 | label: '泉州', 31 | value: 'quanzhou', 32 | }, 33 | ], 34 | }, 35 | { 36 | label: '浙江', 37 | value: 'zj', 38 | children: [ 39 | { 40 | label: '杭州', 41 | value: 'hangzhou', 42 | children: [ 43 | { 44 | label: '禁用', 45 | value: 'disabled', 46 | disabled: true, 47 | }, 48 | { 49 | label: '余杭', 50 | value: 'yuhang', 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | { 57 | label: '北京', 58 | value: 'bj', 59 | children: [ 60 | { 61 | label: '朝阳区', 62 | value: 'chaoyang', 63 | }, 64 | { 65 | label: '海淀区', 66 | value: 'haidian', 67 | disabled: true, 68 | }, 69 | { 70 | label: 'TEST', 71 | value: 'test', 72 | }, 73 | ], 74 | }, 75 | { 76 | label: '顶层禁用', 77 | value: 'disabled', 78 | disabled: true, 79 | children: [ 80 | { 81 | label: '看不见', 82 | value: 'invisible', 83 | }, 84 | ], 85 | }, 86 | // ...new Array(20).fill(null).map((_, i) => ({ label: String(i), value: i })), 87 | ]; 88 | 89 | const Demo = () => { 90 | const [multiple, setMultiple] = React.useState(true); 91 | 92 | const onChange = (value: string[], selectedOptions: Option2[]) => { 93 | console.log('[DEBUG] onChange - value:', value); 94 | console.log('[DEBUG] onChange - selectedOptions:', selectedOptions); 95 | }; 96 | 97 | return ( 98 | <> 99 | 109 | {multiple ? ( 110 | 111 | ) : ( 112 | 121 | )} 122 | 123 | ); 124 | }; 125 | 126 | export default Demo; 127 | -------------------------------------------------------------------------------- /examples/default-expand-single-option.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | import React, { useState } from 'react'; 3 | import '../assets/index.less'; 4 | import type { CascaderProps } from '../src'; 5 | import Cascader from '../src'; 6 | import type { Option2 } from './utils'; 7 | 8 | const options = [ 9 | { 10 | value: 'zhejiang', 11 | label: '浙江', 12 | children: [ 13 | { 14 | value: 'hangzhou', 15 | label: '杭州', 16 | children: [ 17 | { 18 | value: 'xihu', 19 | label: '西湖', 20 | }, 21 | ], 22 | }, 23 | ], 24 | }, 25 | { 26 | value: 'jiangsu', 27 | label: '江苏', 28 | children: [ 29 | { 30 | value: 'nanjing', 31 | label: '南京', 32 | children: [ 33 | { 34 | value: 'zhonghuamen', 35 | label: '中华门', 36 | }, 37 | ], 38 | }, 39 | ], 40 | }, 41 | ]; 42 | 43 | const App = () => { 44 | const [inputValue, setInputValue] = useState(''); 45 | 46 | const [value, setValue] = useState([]); 47 | 48 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 49 | const lastSelected = selectedOptions[selectedOptions.length - 1]; 50 | if (lastSelected.children && lastSelected.children.length === 1) { 51 | value.push(lastSelected.children[0].value as string); 52 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 53 | setValue(value); 54 | return; 55 | } 56 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 57 | setValue(value); 58 | }; 59 | 60 | return ( 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | export default App; 68 | -------------------------------------------------------------------------------- /examples/defaultValue.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | children: [ 12 | { 13 | label: '福州', 14 | value: 'fuzhou', 15 | children: [ 16 | { 17 | label: '马尾', 18 | value: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | label: '泉州', 24 | value: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | label: '占位1', 30 | value: 'zw1', 31 | }, 32 | { 33 | label: '占位2', 34 | value: 'zw2', 35 | }, 36 | { 37 | label: '占位3', 38 | value: 'zw3', 39 | }, 40 | { 41 | label: '占位4', 42 | value: 'zw4', 43 | }, 44 | { 45 | label: '占位5', 46 | value: 'zw5', 47 | }, 48 | { 49 | label: '浙江', 50 | value: 'zj', 51 | children: [ 52 | { 53 | label: '杭州', 54 | value: 'hangzhou', 55 | children: [ 56 | { 57 | label: '余杭', 58 | value: 'yuhang', 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | { 65 | label: '北京', 66 | value: 'bj', 67 | children: [ 68 | { 69 | label: '朝阳区', 70 | value: 'chaoyang', 71 | }, 72 | { 73 | label: '海淀区', 74 | value: 'haidian', 75 | }, 76 | ], 77 | }, 78 | ]; 79 | 80 | const defaultOptions = [ 81 | { 82 | label: '浙江', 83 | value: 'zj', 84 | }, 85 | { 86 | label: '杭州', 87 | value: 'hangzhou', 88 | }, 89 | { 90 | label: '余杭', 91 | value: 'yuhang', 92 | }, 93 | ]; 94 | 95 | const Demo = () => { 96 | const [inputValue, setInputValue] = useState(defaultOptions.map(o => o.label).join(', ')); 97 | 98 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 99 | console.log(value, selectedOptions); 100 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 101 | }; 102 | 103 | const defaultValue = defaultOptions.map(o => o.value); 104 | return ( 105 | 106 | 107 | 108 | ); 109 | }; 110 | 111 | export default Demo; 112 | -------------------------------------------------------------------------------- /examples/disabled.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import Cascader from '../src'; 4 | 5 | const addressOptions = [ 6 | { 7 | label: '福建', 8 | value: 'fj', 9 | children: [ 10 | { 11 | label: '福州', 12 | value: 'fuzhou', 13 | children: [ 14 | { 15 | label: '马尾', 16 | value: 'mawei', 17 | }, 18 | ], 19 | }, 20 | { 21 | label: '泉州', 22 | value: 'quanzhou', 23 | }, 24 | ], 25 | }, 26 | { 27 | label: '浙江', 28 | value: 'zj', 29 | children: [ 30 | { 31 | label: '杭州', 32 | value: 'hangzhou', 33 | children: [ 34 | { 35 | label: '余杭', 36 | value: 'yuhang', 37 | }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | { 43 | label: '北京', 44 | value: 'bj', 45 | children: [ 46 | { 47 | label: '朝阳区', 48 | value: 'chaoyang', 49 | }, 50 | { 51 | label: '海淀区', 52 | value: 'haidian', 53 | }, 54 | ], 55 | }, 56 | ]; 57 | 58 | const Demo = () => { 59 | const [inputValue] = useState(''); 60 | 61 | return ( 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default Demo; 69 | -------------------------------------------------------------------------------- /examples/dynamic-options.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | isLeaf: false, 11 | value: 'fj', 12 | }, 13 | { 14 | label: '浙江', 15 | isLeaf: false, 16 | value: 'zj', 17 | }, 18 | ]; 19 | 20 | const Demo = () => { 21 | const [inputValue, setInputValue] = useState(''); 22 | const [options, setOptions] = useState(addressOptions); 23 | 24 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 25 | console.log('OnChange:', value, selectedOptions); 26 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 27 | }; 28 | 29 | const loadData: CascaderProps['loadData'] = selectedOptions => { 30 | console.log('onLoad:', selectedOptions); 31 | const targetOption = selectedOptions[selectedOptions.length - 1]; 32 | targetOption.loading = true; 33 | // 动态加载下级数据 34 | setTimeout(() => { 35 | targetOption.loading = false; 36 | targetOption.children = [ 37 | { 38 | label: `${targetOption.label}动态加载1`, 39 | value: 'dynamic1', 40 | isLeaf: false, 41 | }, 42 | { 43 | label: `${targetOption.label}动态加载2`, 44 | value: 'dynamic2', 45 | }, 46 | ]; 47 | setOptions([...options]); 48 | }, 500); 49 | }; 50 | 51 | return ( 52 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default Demo; 65 | -------------------------------------------------------------------------------- /examples/hover.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | children: [ 12 | { 13 | label: '福州', 14 | value: 'fuzhou', 15 | children: [ 16 | { 17 | label: '马尾', 18 | value: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | label: '泉州', 24 | value: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | label: '浙江', 30 | value: 'zj', 31 | children: [ 32 | { 33 | label: '杭州', 34 | value: 'hangzhou', 35 | children: [ 36 | { 37 | label: '余杭', 38 | value: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | label: '北京', 46 | value: 'bj', 47 | children: [ 48 | { 49 | label: '朝阳区', 50 | value: 'chaoyang', 51 | }, 52 | { 53 | label: '海淀区', 54 | value: 'haidian', 55 | }, 56 | ], 57 | }, 58 | { 59 | label: '台湾', 60 | value: 'tw', 61 | children: [ 62 | { 63 | label: '台北', 64 | value: 'taipei', 65 | children: [ 66 | { 67 | label: '中正区', 68 | value: 'zhongzheng', 69 | }, 70 | ], 71 | }, 72 | { 73 | label: '高雄', 74 | value: 'gaoxiong', 75 | }, 76 | ], 77 | }, 78 | { 79 | label: '香港', 80 | value: 'xg', 81 | }, 82 | ]; 83 | 84 | const Demo = () => { 85 | const [inputValue, setInputValue] = useState(''); 86 | 87 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 88 | console.log(value, selectedOptions); 89 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 90 | }; 91 | 92 | return ( 93 |
94 |

Hover to expand children

95 | 96 | 97 | 98 |
99 | ); 100 | }; 101 | 102 | export default Demo; 103 | -------------------------------------------------------------------------------- /examples/multiple-search.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../assets/index.less'; 3 | import Cascader from '../src'; 4 | 5 | const options = [ 6 | { 7 | value: 'zhejiang', 8 | label: 'Zhejiang', 9 | children: [ 10 | { 11 | value: 'hangzhou', 12 | label: 'Hangzhou', 13 | children: [ 14 | { 15 | value: 'xihu', 16 | label: 'West Lake', 17 | }, 18 | { 19 | value: 'xiasha', 20 | label: 'Xia Sha', 21 | }, 22 | ], 23 | }, 24 | ], 25 | }, 26 | { 27 | value: 'jiangsu', 28 | label: 'Jiangsu', 29 | children: [ 30 | { 31 | value: 'nanjing', 32 | label: 'Nanjing', 33 | children: [ 34 | { 35 | value: 'zhonghuamen', 36 | label: 'Zhong Hua men', 37 | }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | ]; 43 | 44 | const Demo = () => { 45 | return ; 46 | }; 47 | 48 | export default Demo; 49 | -------------------------------------------------------------------------------- /examples/multiple.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | import React, { useState } from 'react'; 3 | import '../assets/index.less'; 4 | import type { CascaderProps } from '../src'; 5 | import Cascader from '../src'; 6 | import type { Option2 } from './utils'; 7 | 8 | const { SHOW_CHILD } = Cascader; 9 | 10 | const optionLists = [ 11 | { 12 | value: 'zhejiang', 13 | label: 'Zhejiang', 14 | isLeaf: false, 15 | disableCheckbox: true, 16 | }, 17 | { 18 | value: 'jiangsu', 19 | label: 'Jiangsu', 20 | isLeaf: false, 21 | disableCheckbox: false, 22 | }, 23 | ]; 24 | 25 | const Demo = () => { 26 | const [options, setOptions] = React.useState(optionLists); 27 | const [value, setValue] = useState([]); 28 | 29 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 30 | console.log(value, selectedOptions); 31 | setValue(value); 32 | }; 33 | 34 | const loadData: CascaderProps['loadData'] = selectedOptions => { 35 | const targetOption = selectedOptions[selectedOptions.length - 1]; 36 | targetOption.loading = true; 37 | 38 | // load options lazily 39 | setTimeout(() => { 40 | targetOption.loading = false; 41 | targetOption.children = [ 42 | { 43 | label: `${targetOption.label} Dynamic 1`, 44 | value: 'dynamic1', 45 | disableCheckbox: false, 46 | }, 47 | { 48 | label: `${targetOption.label} Dynamic 2`, 49 | value: 'dynamic2', 50 | disableCheckbox: true, 51 | }, 52 | ]; 53 | setOptions([...options]); 54 | }, 1000); 55 | }; 56 | 57 | // 直接选中一级选项,但是此时二级选项没有全部选中 58 | return ( 59 | 68 | ); 69 | }; 70 | 71 | export default Demo; 72 | -------------------------------------------------------------------------------- /examples/option-render.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../assets/index.less'; 3 | import Cascader from '../src'; 4 | 5 | const addressOptions = [ 6 | { 7 | label: '福建', 8 | title: '福建-fj', 9 | value: 'fj', 10 | children: [ 11 | { 12 | label: '福州', 13 | value: 'fuzhou', 14 | children: [ 15 | { 16 | label: '马尾', 17 | value: 'mawei', 18 | }, 19 | ], 20 | }, 21 | { 22 | label: '泉州', 23 | value: 'quanzhou', 24 | }, 25 | ], 26 | }, 27 | { 28 | label: '浙江', 29 | value: 'zj', 30 | title: '浙江-zj', 31 | children: [ 32 | { 33 | label: '杭州', 34 | value: 'hangzhou', 35 | children: [ 36 | { 37 | label: '余杭', 38 | value: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | label: '北京', 46 | value: 'bj', 47 | title: '北京-bj', 48 | children: [ 49 | { 50 | label: '朝阳区', 51 | value: 'chaoyang', 52 | }, 53 | { 54 | label: '海淀区', 55 | value: 'haidian', 56 | }, 57 | ], 58 | }, 59 | ]; 60 | 61 | const Demo = () => { 62 | return ( 63 |
64 | { 67 | return ; 68 | }} 69 | /> 70 |
71 | ); 72 | }; 73 | 74 | export default Demo; 75 | -------------------------------------------------------------------------------- /examples/panel.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | import React from 'react'; 3 | import '../assets/index.less'; 4 | import Cascader from '../src'; 5 | 6 | const addressOptions = [ 7 | { 8 | label: '福建', 9 | value: 'fj', 10 | children: [ 11 | { 12 | label: '福州', 13 | value: 'fuzhou', 14 | children: [ 15 | { 16 | label: '马尾', 17 | value: 'mawei', 18 | }, 19 | ], 20 | }, 21 | { 22 | label: '泉州', 23 | value: 'quanzhou', 24 | }, 25 | ], 26 | }, 27 | { 28 | label: '浙江', 29 | value: 'zj', 30 | children: [ 31 | { 32 | label: '杭州', 33 | value: 'hangzhou', 34 | children: [ 35 | { 36 | label: '余杭', 37 | value: 'yuhang', 38 | }, 39 | ], 40 | }, 41 | ], 42 | }, 43 | { 44 | label: '北京', 45 | value: 'bj', 46 | children: [ 47 | { 48 | label: '朝阳区', 49 | value: 'chaoyang', 50 | }, 51 | { 52 | label: '海淀区', 53 | value: 'haidian', 54 | }, 55 | ], 56 | }, 57 | ]; 58 | 59 | export default () => { 60 | const [value, setValue] = React.useState([]); 61 | 62 | const [value2, setValue2] = React.useState([]); 63 | 64 | const [disabled, setDisabled] = React.useState(false); 65 | 66 | return ( 67 | <> 68 |

Panel

69 | 76 | 83 | { 87 | console.log('Change:', nextValue); 88 | setValue(nextValue); 89 | }} 90 | disabled={disabled} 91 | /> 92 | 93 | { 98 | console.log('Change:', nextValue); 99 | setValue2(nextValue); 100 | }} 101 | disabled={disabled} 102 | /> 103 | 104 | 105 | 106 | 107 | 108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /examples/popup-render.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | children: [ 12 | { 13 | label: '福州', 14 | value: 'fuzhou', 15 | children: [ 16 | { 17 | label: '马尾', 18 | value: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | label: '泉州', 24 | value: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | label: '浙江', 30 | value: 'zj', 31 | children: [ 32 | { 33 | label: '杭州', 34 | value: 'hangzhou', 35 | children: [ 36 | { 37 | label: '余杭', 38 | value: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | label: '北京', 46 | value: 'bj', 47 | children: [ 48 | { 49 | label: '朝阳区', 50 | value: 'chaoyang', 51 | }, 52 | { 53 | label: '海淀区', 54 | value: 'haidian', 55 | disabled: true, 56 | }, 57 | ], 58 | }, 59 | ]; 60 | 61 | const Demo = () => { 62 | const [inputValue, setInputValue] = useState(''); 63 | 64 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 65 | console.log(value, selectedOptions); 66 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 67 | }; 68 | 69 | return ( 70 | ( 74 |
75 | {menus} 76 |
77 | Hey, popupRender, Long popupRender, Long popupRender 78 |
79 | )} 80 | > 81 | 82 |
83 | ); 84 | }; 85 | 86 | export default Demo; 87 | -------------------------------------------------------------------------------- /examples/rc-form.tsx: -------------------------------------------------------------------------------- 1 | import arrayTreeFilter from 'array-tree-filter'; 2 | import Form, { Field } from 'rc-field-form'; 3 | import '../assets/index.less'; 4 | import type { CascaderProps } from '../src'; 5 | import Cascader from '../src'; 6 | import React from 'react'; 7 | import type { Option2 } from './utils'; 8 | 9 | const addressOptions = [ 10 | { 11 | label: '福建', 12 | value: 'fj', 13 | children: [ 14 | { 15 | label: '福州', 16 | value: 'fuzhou', 17 | children: [ 18 | { 19 | label: '马尾', 20 | value: 'mawei', 21 | }, 22 | ], 23 | }, 24 | { 25 | label: '泉州', 26 | value: 'quanzhou', 27 | }, 28 | ], 29 | }, 30 | { 31 | label: '浙江', 32 | value: 'zj', 33 | children: [ 34 | { 35 | label: '杭州', 36 | value: 'hangzhou', 37 | children: [ 38 | { 39 | label: '余杭', 40 | value: 'yuhang', 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | { 47 | label: '北京', 48 | value: 'bj', 49 | children: [ 50 | { 51 | label: '朝阳区', 52 | value: 'chaoyang', 53 | }, 54 | { 55 | label: '海淀区', 56 | value: 'haidian', 57 | }, 58 | ], 59 | }, 60 | ]; 61 | 62 | const CascaderInput = (props: any) => { 63 | const onChange: CascaderProps['onChange'] = value => { 64 | if (props.onChange) { 65 | props.onChange(value); 66 | } 67 | }; 68 | 69 | const getLabel = () => { 70 | const value = props.value || []; 71 | return arrayTreeFilter(props.options, (o: any, level) => o.value === value[level]) 72 | .map(o => o.label) 73 | .join(', '); 74 | }; 75 | 76 | return ( 77 | 78 | 79 | 80 | ); 81 | }; 82 | 83 | const Demo = () => { 84 | return ( 85 |
86 |
{ 88 | console.error('values', values); 89 | }} 90 | initialValues={{ address: [] }} 91 | > 92 |

93 | 94 | 95 | 96 | 97 | {(_, __, { getFieldError }) => { 98 | const hasErrors = getFieldError('address'); 99 | return

{hasErrors ? hasErrors.join(' ') : null}
; 100 | }} 101 | 102 |

103 |

104 | 105 |

106 |
107 |
108 | ); 109 | }; 110 | 111 | export default Demo; 112 | -------------------------------------------------------------------------------- /examples/search.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../assets/index.less'; 3 | import Cascader from '../src'; 4 | 5 | const testClassNames = { 6 | prefix: 'test-prefix', 7 | suffix: 'test-suffix', 8 | input: 'test-input', 9 | popup: { 10 | list: 'test-popup-list', 11 | listItem: 'test-popup-list-item', 12 | }, 13 | }; 14 | const testStyles = { 15 | popup: { 16 | list: { background: 'red' }, 17 | listItem: { color: 'yellow' }, 18 | }, 19 | }; 20 | const addressOptions = [ 21 | { 22 | label: '福建', 23 | value: 'fj', 24 | children: [ 25 | { 26 | label: '福州', 27 | value: 'fuzhou', 28 | children: [ 29 | { 30 | label: '马尾-mw', 31 | value: 'mawei', 32 | }, 33 | ], 34 | }, 35 | { 36 | label: '泉州-qz', 37 | value: 'quanzhou', 38 | }, 39 | ], 40 | }, 41 | { 42 | label: '浙江', 43 | value: 'zj', 44 | children: [ 45 | { 46 | label: '杭州', 47 | value: 'hangzhou', 48 | children: [ 49 | { 50 | label: '余杭', 51 | value: 'yuhang', 52 | }, 53 | { 54 | label: '福州', 55 | value: 'fuzhou', 56 | children: [ 57 | { 58 | label: '马尾', 59 | value: 'mawei', 60 | }, 61 | ], 62 | }, 63 | ], 64 | }, 65 | ], 66 | }, 67 | { 68 | label: '北京', 69 | value: 'bj', 70 | children: [ 71 | { 72 | label: '朝阳区', 73 | value: 'chaoyang', 74 | }, 75 | { 76 | label: '海淀区', 77 | value: 'haidian', 78 | }, 79 | ], 80 | }, 81 | ]; 82 | 83 | const Demo = () => { 84 | return ( 85 | 'icon'} 88 | classNames={testClassNames} 89 | styles={testStyles} 90 | options={addressOptions} 91 | showSearch 92 | style={{ width: 300 }} 93 | animation="slide-up" 94 | notFoundContent="Empty Content!" 95 | /> 96 | ); 97 | }; 98 | 99 | export default Demo; 100 | -------------------------------------------------------------------------------- /examples/simple.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | "aria-label": '福建', 12 | "aria-labelledby": 'fj', 13 | "data-type": 'fj', 14 | children: [ 15 | { 16 | label: '福州', 17 | value: 'fuzhou', 18 | children: [ 19 | { 20 | label: '马尾', 21 | value: 'mawei', 22 | }, 23 | ], 24 | }, 25 | { 26 | label: '泉州', 27 | value: 'quanzhou', 28 | }, 29 | ], 30 | }, 31 | { 32 | label: '浙江', 33 | value: 'zj', 34 | children: [ 35 | { 36 | label: '杭州', 37 | value: 'hangzhou', 38 | children: [ 39 | { 40 | label: '余杭', 41 | value: 'yuhang', 42 | }, 43 | ], 44 | }, 45 | ], 46 | }, 47 | { 48 | label: '北京', 49 | value: 'bj', 50 | children: [ 51 | { 52 | label: '朝阳区', 53 | value: 'chaoyang', 54 | }, 55 | { 56 | label: '海淀区', 57 | value: 'haidian', 58 | disabled: true, 59 | }, 60 | ], 61 | }, 62 | ]; 63 | 64 | const Demo = () => { 65 | const [inputValue, setInputValue] = useState(''); 66 | 67 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 68 | console.log(value, selectedOptions); 69 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 70 | }; 71 | 72 | return ( 73 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default Demo; 80 | -------------------------------------------------------------------------------- /examples/text-trigger.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import '../assets/index.less'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import type { Option2 } from './utils'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | children: [ 12 | { 13 | label: '福州', 14 | value: 'fuzhou', 15 | children: [ 16 | { 17 | label: '马尾', 18 | value: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | label: '泉州', 24 | value: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | label: '浙江', 30 | value: 'zj', 31 | children: [ 32 | { 33 | label: '杭州', 34 | value: 'hangzhou', 35 | children: [ 36 | { 37 | label: '余杭', 38 | value: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | label: '北京', 46 | value: 'bj', 47 | children: [ 48 | { 49 | label: '朝阳区', 50 | value: 'chaoyang', 51 | }, 52 | { 53 | label: '海淀区', 54 | value: 'haidian', 55 | }, 56 | ], 57 | }, 58 | ]; 59 | 60 | const Demo = () => { 61 | const [inputValue, setInputValue] = useState(''); 62 | 63 | const onChange: CascaderProps['onChange'] = (value, selectedOptions) => { 64 | console.log(value, selectedOptions); 65 | setInputValue(selectedOptions.map(o => o.label).join(', ')); 66 | }; 67 | 68 | return ( 69 | 70 | {inputValue} 71 | 72 | 切换地区 73 | 74 | 75 | ); 76 | }; 77 | 78 | export default Demo; 79 | -------------------------------------------------------------------------------- /examples/utils.tsx: -------------------------------------------------------------------------------- 1 | export interface Option { 2 | code?: string; 3 | name?: string; 4 | nodes?: Option[]; 5 | disabled?: boolean; 6 | } 7 | 8 | export interface Option2 { 9 | value?: string; 10 | label?: React.ReactNode; 11 | title?: React.ReactNode; 12 | disabled?: boolean; 13 | disableCheckbox?: boolean; 14 | isLeaf?: boolean; 15 | loading?: boolean; 16 | children?: Option2[]; 17 | } 18 | -------------------------------------------------------------------------------- /examples/value.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | import arrayTreeFilter from 'array-tree-filter'; 3 | import React, { useState } from 'react'; 4 | import '../assets/index.less'; 5 | import type { CascaderProps } from '../src'; 6 | import Cascader from '../src'; 7 | import type { Option2 } from './utils'; 8 | 9 | const addressOptions = [ 10 | { 11 | label: '福建', 12 | value: 'fj', 13 | children: [ 14 | { 15 | label: '福州', 16 | value: 'fuzhou', 17 | children: [ 18 | { 19 | label: '马尾', 20 | value: 'mawei', 21 | }, 22 | ], 23 | }, 24 | { 25 | label: '泉州', 26 | value: 'quanzhou', 27 | }, 28 | ], 29 | }, 30 | { 31 | label: '浙江', 32 | value: 'zj', 33 | children: [ 34 | { 35 | label: '杭州', 36 | value: 'hangzhou', 37 | children: [ 38 | { 39 | label: '余杭', 40 | value: 'yuhang', 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | { 47 | label: '北京', 48 | value: 'bj', 49 | children: [ 50 | { 51 | label: '朝阳区', 52 | value: 'chaoyang', 53 | }, 54 | { 55 | label: '海淀区', 56 | value: 'haidian', 57 | }, 58 | ], 59 | }, 60 | ]; 61 | 62 | const Demo = () => { 63 | const [value, setValue] = useState([]); 64 | const onChange: CascaderProps['onChange'] = value => { 65 | console.log(value); 66 | setValue(value); 67 | }; 68 | 69 | const handleSetValue = () => { 70 | setValue(['bj', 'chaoyang']); 71 | }; 72 | 73 | const getLabel = () => { 74 | return arrayTreeFilter(addressOptions, (o, level) => o.value === value[level]) 75 | .map(o => o.label) 76 | .join(', '); 77 | }; 78 | 79 | return ( 80 |
81 | 82 | 83 | 84 | 85 |
86 | ); 87 | }; 88 | 89 | export default Demo; 90 | -------------------------------------------------------------------------------- /examples/visible.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-shadow */ 2 | import arrayTreeFilter from 'array-tree-filter'; 3 | import React, { useState } from 'react'; 4 | import '../assets/index.less'; 5 | import Cascader from '../src'; 6 | 7 | const addressOptions = [ 8 | { 9 | label: '福建', 10 | value: 'fj', 11 | children: [ 12 | { 13 | label: '福州', 14 | value: 'fuzhou', 15 | children: [ 16 | { 17 | label: '马尾', 18 | value: 'mawei', 19 | }, 20 | ], 21 | }, 22 | { 23 | label: '泉州', 24 | value: 'quanzhou', 25 | }, 26 | ], 27 | }, 28 | { 29 | label: '浙江', 30 | value: 'zj', 31 | children: [ 32 | { 33 | label: '杭州', 34 | value: 'hangzhou', 35 | children: [ 36 | { 37 | label: '余杭', 38 | value: 'yuhang', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | label: '北京', 46 | value: 'bj', 47 | children: [ 48 | { 49 | label: '朝阳区', 50 | value: 'chaoyang', 51 | }, 52 | { 53 | label: '海淀区', 54 | value: 'haidian', 55 | }, 56 | ], 57 | }, 58 | ]; 59 | 60 | const Demo = () => { 61 | const [value, setValue] = useState([]); 62 | const [open, setOpen] = useState(false); 63 | 64 | const getLabel = () => { 65 | return arrayTreeFilter(addressOptions, (o, level) => o.value === value[level]) 66 | .map(o => o.label) 67 | .join(', '); 68 | }; 69 | 70 | return ( 71 | setOpen(open)} 76 | onChange={value => setValue(value)} 77 | > 78 | 79 | 80 | ); 81 | }; 82 | 83 | export default Demo; 84 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import Cascader from './src'; 2 | 3 | export default Cascader; 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['./tests/setup.js'], 3 | snapshotSerializers: [require.resolve('enzyme-to-json/serializer')], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/cascader", 3 | "version": "1.3.0", 4 | "description": "cascade select ui component for react", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-cascader", 9 | "react-select", 10 | "select", 11 | "cascade", 12 | "cascader" 13 | ], 14 | "homepage": "https://github.com/react-component/cascader", 15 | "bugs": { 16 | "url": "https://github.com/react-component/cascader/issues" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/react-component/cascader.git" 21 | }, 22 | "license": "MIT", 23 | "author": "afc163@gmail.com", 24 | "main": "./lib/index", 25 | "module": "./es/index", 26 | "files": [ 27 | "lib", 28 | "es", 29 | "assets/*.css", 30 | "assets/*.less" 31 | ], 32 | "scripts": { 33 | "build": "dumi build", 34 | "compile": "father build", 35 | "coverage": "father test --coverage", 36 | "tsc": "bunx tsc --noEmit", 37 | "deploy": "UMI_ENV=gh npm run build && gh-pages -d dist", 38 | "lint": "eslint src/ examples/ --ext .tsx,.ts,.jsx,.jsx", 39 | "now-build": "npm run build", 40 | "prepublishOnly": "npm run compile && rc-np", 41 | "lint:tsc": "tsc -p tsconfig.json --noEmit", 42 | "start": "dumi dev", 43 | "test": "rc-test" 44 | }, 45 | "dependencies": { 46 | "@rc-component/select": "~1.0.0", 47 | "@rc-component/tree": "~1.0.0", 48 | "@rc-component/util": "^1.2.1", 49 | "classnames": "^2.3.1" 50 | }, 51 | "devDependencies": { 52 | "@rc-component/father-plugin": "^2.0.2", 53 | "@rc-component/np": "^1.0.3", 54 | "@rc-component/trigger": "^3.0.0", 55 | "@testing-library/react": "^12.1.5", 56 | "@types/classnames": "^2.2.6", 57 | "@types/enzyme": "^3.1.15", 58 | "@types/jest": "^29.4.0", 59 | "@types/react": "^19.0.0", 60 | "@types/react-dom": "^19.0.0", 61 | "@types/warning": "^3.0.0", 62 | "@umijs/fabric": "^4.0.0", 63 | "array-tree-filter": "^3.0.2", 64 | "cheerio": "1.0.0-rc.12", 65 | "core-js": "^3.40.0", 66 | "cross-env": "^7.0.0", 67 | "dumi": "^2.1.10", 68 | "enzyme": "^3.3.0", 69 | "enzyme-adapter-react-16": "^1.15.6", 70 | "enzyme-to-json": "^3.2.1", 71 | "eslint": "^8.54.0", 72 | "eslint-plugin-jest": "^28.8.3", 73 | "eslint-plugin-unicorn": "^56.0.1", 74 | "father": "^4.0.0", 75 | "gh-pages": "^6.1.1", 76 | "glob": "^7.1.6", 77 | "less": "^4.2.0", 78 | "prettier": "^3.1.0", 79 | "rc-field-form": "^1.44.0", 80 | "rc-test": "^7.1.2", 81 | "react": "^16.0.0", 82 | "react-dom": "^16.0.0", 83 | "typescript": "^5.3.2" 84 | }, 85 | "peerDependencies": { 86 | "react": ">=16.9.0", 87 | "react-dom": ">=16.9.0" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/OptionList/CacheContent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface CacheContentProps { 4 | children?: React.ReactNode; 5 | open?: boolean; 6 | } 7 | 8 | const CacheContent = React.memo( 9 | ({ children }: CacheContentProps) => children as React.ReactElement, 10 | (_, next) => !next.open, 11 | ); 12 | 13 | if (process.env.NODE_ENV !== 'production') { 14 | CacheContent.displayName = 'CacheContent'; 15 | } 16 | 17 | export default CacheContent; 18 | -------------------------------------------------------------------------------- /src/OptionList/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import classNames from 'classnames'; 3 | import CascaderContext from '../context'; 4 | 5 | export interface CheckboxProps { 6 | prefixCls: string; 7 | checked?: boolean; 8 | halfChecked?: boolean; 9 | disabled?: boolean; 10 | onClick?: React.MouseEventHandler; 11 | disableCheckbox?: boolean; 12 | } 13 | 14 | export default function Checkbox({ 15 | prefixCls, 16 | checked, 17 | halfChecked, 18 | disabled, 19 | onClick, 20 | disableCheckbox, 21 | }: CheckboxProps) { 22 | const { checkable } = React.useContext(CascaderContext); 23 | 24 | const customCheckbox = typeof checkable !== 'boolean' ? checkable : null; 25 | 26 | return ( 27 | 35 | {customCheckbox} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/OptionList/Column.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import * as React from 'react'; 3 | import pickAttrs from 'rc-util/lib/pickAttrs'; 4 | import type { DefaultOptionType, SingleValueType } from '../Cascader'; 5 | import CascaderContext from '../context'; 6 | import { SEARCH_MARK } from '../hooks/useSearchOptions'; 7 | import { isLeaf, toPathKey } from '../utils/commonUtil'; 8 | import Checkbox from './Checkbox'; 9 | 10 | export const FIX_LABEL = '__cascader_fix_label__'; 11 | 12 | export interface ColumnProps { 13 | prefixCls: string; 14 | multiple?: boolean; 15 | options: OptionType[]; 16 | /** Current Column opened item key */ 17 | activeValue?: React.Key; 18 | /** The value path before current column */ 19 | prevValuePath: React.Key[]; 20 | onToggleOpen: (open: boolean) => void; 21 | onSelect: (valuePath: SingleValueType, leaf: boolean) => void; 22 | onActive: (valuePath: SingleValueType) => void; 23 | checkedSet: Set; 24 | halfCheckedSet: Set; 25 | loadingKeys: React.Key[]; 26 | isSelectable: (option: DefaultOptionType) => boolean; 27 | disabled?: boolean; 28 | } 29 | 30 | export default function Column({ 31 | prefixCls, 32 | multiple, 33 | options, 34 | activeValue, 35 | prevValuePath, 36 | onToggleOpen, 37 | onSelect, 38 | onActive, 39 | checkedSet, 40 | halfCheckedSet, 41 | loadingKeys, 42 | isSelectable, 43 | disabled: propsDisabled, 44 | }: ColumnProps) { 45 | const menuPrefixCls = `${prefixCls}-menu`; 46 | const menuItemPrefixCls = `${prefixCls}-menu-item`; 47 | const menuRef = React.useRef(null); 48 | 49 | const { 50 | fieldNames, 51 | changeOnSelect, 52 | expandTrigger, 53 | expandIcon, 54 | loadingIcon, 55 | popupMenuColumnStyle, 56 | optionRender, 57 | classNames, 58 | styles, 59 | } = React.useContext(CascaderContext); 60 | 61 | const hoverOpen = expandTrigger === 'hover'; 62 | 63 | const isOptionDisabled = (disabled?: boolean) => propsDisabled || disabled; 64 | 65 | // ============================ Option ============================ 66 | const optionInfoList = React.useMemo( 67 | () => 68 | options.map(option => { 69 | const { disabled, disableCheckbox } = option; 70 | const searchOptions: Record[] = option[SEARCH_MARK]; 71 | const label = option[FIX_LABEL] ?? option[fieldNames.label]; 72 | const value = option[fieldNames.value]; 73 | 74 | const isMergedLeaf = isLeaf(option, fieldNames); 75 | 76 | // Get real value of option. Search option is different way. 77 | const fullPath = searchOptions 78 | ? searchOptions.map(opt => opt[fieldNames.value]) 79 | : [...prevValuePath, value]; 80 | const fullPathKey = toPathKey(fullPath); 81 | 82 | const isLoading = loadingKeys.includes(fullPathKey); 83 | 84 | // >>>>> checked 85 | const checked = checkedSet.has(fullPathKey); 86 | 87 | // >>>>> halfChecked 88 | const halfChecked = halfCheckedSet.has(fullPathKey); 89 | 90 | return { 91 | disabled, 92 | label, 93 | value, 94 | isLeaf: isMergedLeaf, 95 | isLoading, 96 | checked, 97 | halfChecked, 98 | option, 99 | disableCheckbox, 100 | fullPath, 101 | fullPathKey, 102 | }; 103 | }), 104 | [options, checkedSet, fieldNames, halfCheckedSet, loadingKeys, prevValuePath], 105 | ); 106 | 107 | React.useEffect(() => { 108 | if (menuRef.current) { 109 | const selector = `.${menuItemPrefixCls}-active`; 110 | const activeElement = menuRef.current.querySelector(selector); 111 | 112 | if (activeElement) { 113 | activeElement.scrollIntoView({ 114 | block: 'nearest', 115 | inline: 'nearest', 116 | }); 117 | } 118 | } 119 | }, [activeValue, menuItemPrefixCls]); 120 | 121 | // ============================ Render ============================ 122 | return ( 123 |
    129 | {optionInfoList.map( 130 | ({ 131 | disabled, 132 | label, 133 | value, 134 | isLeaf: isMergedLeaf, 135 | isLoading, 136 | checked, 137 | halfChecked, 138 | option, 139 | fullPath, 140 | fullPathKey, 141 | disableCheckbox, 142 | }) => { 143 | const ariaProps = pickAttrs(option, { 144 | aria: true, 145 | data: true 146 | }); 147 | // >>>>> Open 148 | const triggerOpenPath = () => { 149 | if (isOptionDisabled(disabled)) { 150 | return; 151 | } 152 | const nextValueCells = [...fullPath]; 153 | if (hoverOpen && isMergedLeaf) { 154 | nextValueCells.pop(); 155 | } 156 | onActive(nextValueCells); 157 | }; 158 | 159 | // >>>>> Selection 160 | const triggerSelect = () => { 161 | if (isSelectable(option) && !isOptionDisabled(disabled)) { 162 | onSelect(fullPath, isMergedLeaf); 163 | } 164 | }; 165 | 166 | // >>>>> Title 167 | let title: string | undefined; 168 | if (typeof option.title === 'string') { 169 | title = option.title; 170 | } else if (typeof label === 'string') { 171 | title = label; 172 | } 173 | 174 | // >>>>> Render 175 | return ( 176 |
  • { 192 | triggerOpenPath(); 193 | if (disableCheckbox) { 194 | return; 195 | } 196 | if (!multiple || isMergedLeaf) { 197 | triggerSelect(); 198 | } 199 | }} 200 | onDoubleClick={() => { 201 | if (changeOnSelect) { 202 | onToggleOpen(false); 203 | } 204 | }} 205 | onMouseEnter={() => { 206 | if (hoverOpen) { 207 | triggerOpenPath(); 208 | } 209 | }} 210 | onMouseDown={e => { 211 | // Prevent selector from blurring 212 | e.preventDefault(); 213 | }} 214 | > 215 | {multiple && ( 216 | ) => { 223 | if (disableCheckbox) { 224 | return; 225 | } 226 | e.stopPropagation(); 227 | triggerSelect(); 228 | }} 229 | /> 230 | )} 231 |
    232 | {optionRender ? optionRender(option) : label} 233 |
    234 | {!isLoading && expandIcon && !isMergedLeaf && ( 235 |
    {expandIcon}
    236 | )} 237 | {isLoading && loadingIcon && ( 238 |
    {loadingIcon}
    239 | )} 240 |
  • 241 | ); 242 | }, 243 | )} 244 |
245 | ); 246 | } 247 | -------------------------------------------------------------------------------- /src/OptionList/List.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable default-case */ 2 | import classNames from 'classnames'; 3 | import type { useBaseProps } from '@rc-component/select'; 4 | import type { RefOptionListProps } from '@rc-component/select/lib/OptionList'; 5 | import * as React from 'react'; 6 | import type { DefaultOptionType, LegacyKey, SingleValueType } from '../Cascader'; 7 | import CascaderContext from '../context'; 8 | import { 9 | getFullPathKeys, 10 | isLeaf, 11 | scrollIntoParentView, 12 | toPathKey, 13 | toPathKeys, 14 | toPathValueStr, 15 | } from '../utils/commonUtil'; 16 | import { toPathOptions } from '../utils/treeUtil'; 17 | import CacheContent from './CacheContent'; 18 | import Column, { FIX_LABEL } from './Column'; 19 | import useActive from './useActive'; 20 | import useKeyboard from './useKeyboard'; 21 | 22 | export type RawOptionListProps = Pick< 23 | ReturnType, 24 | | 'prefixCls' 25 | | 'multiple' 26 | | 'searchValue' 27 | | 'toggleOpen' 28 | | 'notFoundContent' 29 | | 'direction' 30 | | 'open' 31 | | 'disabled' 32 | >; 33 | 34 | const RawOptionList = React.forwardRef((props, ref) => { 35 | const { 36 | prefixCls, 37 | multiple, 38 | searchValue, 39 | toggleOpen, 40 | notFoundContent, 41 | direction, 42 | open, 43 | disabled, 44 | } = props; 45 | 46 | const containerRef = React.useRef(null); 47 | const rtl = direction === 'rtl'; 48 | 49 | const { 50 | options, 51 | values, 52 | halfValues, 53 | fieldNames, 54 | changeOnSelect, 55 | onSelect, 56 | searchOptions, 57 | popupPrefixCls, 58 | loadData, 59 | expandTrigger, 60 | } = React.useContext(CascaderContext); 61 | 62 | const mergedPrefixCls = popupPrefixCls || prefixCls; 63 | 64 | // ========================= loadData ========================= 65 | const [loadingKeys, setLoadingKeys] = React.useState([]); 66 | 67 | const internalLoadData = (valueCells: LegacyKey[]) => { 68 | // Do not load when search 69 | if (!loadData || searchValue) { 70 | return; 71 | } 72 | 73 | const optionList = toPathOptions(valueCells, options, fieldNames); 74 | const rawOptions = optionList.map(({ option }) => option); 75 | const lastOption = rawOptions[rawOptions.length - 1]; 76 | 77 | if (lastOption && !isLeaf(lastOption, fieldNames)) { 78 | const pathKey = toPathKey(valueCells); 79 | 80 | setLoadingKeys(keys => [...keys, pathKey]); 81 | 82 | loadData(rawOptions); 83 | } 84 | }; 85 | 86 | // zombieJ: This is bad. We should make this same as `rc-tree` to use Promise instead. 87 | React.useEffect(() => { 88 | if (loadingKeys.length) { 89 | loadingKeys.forEach(loadingKey => { 90 | const valueStrCells = toPathValueStr(loadingKey as string); 91 | const optionList = toPathOptions(valueStrCells, options, fieldNames, true).map( 92 | ({ option }) => option, 93 | ); 94 | const lastOption = optionList[optionList.length - 1]; 95 | 96 | if (!lastOption || lastOption[fieldNames.children] || isLeaf(lastOption, fieldNames)) { 97 | setLoadingKeys(keys => keys.filter(key => key !== loadingKey)); 98 | } 99 | }); 100 | } 101 | }, [options, loadingKeys, fieldNames]); 102 | 103 | // ========================== Values ========================== 104 | const checkedSet = React.useMemo(() => new Set(toPathKeys(values)), [values]); 105 | const halfCheckedSet = React.useMemo(() => new Set(toPathKeys(halfValues)), [halfValues]); 106 | 107 | // ====================== Accessibility ======================= 108 | const [activeValueCells, setActiveValueCells] = useActive(multiple, open); 109 | 110 | // =========================== Path =========================== 111 | const onPathOpen = (nextValueCells: LegacyKey[]) => { 112 | setActiveValueCells(nextValueCells); 113 | 114 | // Trigger loadData 115 | internalLoadData(nextValueCells); 116 | }; 117 | 118 | const isSelectable = (option: DefaultOptionType) => { 119 | if (disabled) { 120 | return false; 121 | } 122 | 123 | const { disabled: optionDisabled } = option; 124 | const isMergedLeaf = isLeaf(option, fieldNames); 125 | 126 | return !optionDisabled && (isMergedLeaf || changeOnSelect || multiple); 127 | }; 128 | 129 | const onPathSelect = (valuePath: SingleValueType, leaf: boolean, fromKeyboard = false) => { 130 | onSelect(valuePath); 131 | 132 | if (!multiple && (leaf || (changeOnSelect && (expandTrigger === 'hover' || fromKeyboard)))) { 133 | toggleOpen(false); 134 | } 135 | }; 136 | 137 | // ========================== Option ========================== 138 | const mergedOptions = React.useMemo(() => { 139 | if (searchValue) { 140 | return searchOptions; 141 | } 142 | 143 | return options; 144 | }, [searchValue, searchOptions, options]); 145 | 146 | // ========================== Column ========================== 147 | const optionColumns = React.useMemo(() => { 148 | const optionList = [{ options: mergedOptions }]; 149 | let currentList = mergedOptions; 150 | 151 | const fullPathKeys = getFullPathKeys(currentList, fieldNames); 152 | 153 | for (let i = 0; i < activeValueCells.length; i += 1) { 154 | const activeValueCell = activeValueCells[i]; 155 | const currentOption = currentList.find( 156 | (option, index) => 157 | (fullPathKeys[index] ? toPathKey(fullPathKeys[index]) : option[fieldNames.value]) === 158 | activeValueCell, 159 | ); 160 | 161 | const subOptions = currentOption?.[fieldNames.children]; 162 | if (!subOptions?.length) { 163 | break; 164 | } 165 | 166 | currentList = subOptions; 167 | optionList.push({ options: subOptions }); 168 | } 169 | 170 | return optionList; 171 | }, [mergedOptions, activeValueCells, fieldNames]); 172 | 173 | // ========================= Keyboard ========================= 174 | const onKeyboardSelect = (selectValueCells: SingleValueType, option: DefaultOptionType) => { 175 | if (isSelectable(option)) { 176 | onPathSelect(selectValueCells, isLeaf(option, fieldNames), true); 177 | } 178 | }; 179 | 180 | useKeyboard(ref, mergedOptions, fieldNames, activeValueCells, onPathOpen, onKeyboardSelect, { 181 | direction, 182 | searchValue, 183 | toggleOpen, 184 | open, 185 | }); 186 | 187 | // >>>>> Active Scroll 188 | React.useEffect(() => { 189 | if (searchValue) { 190 | return; 191 | } 192 | for (let i = 0; i < activeValueCells.length; i += 1) { 193 | const cellPath = activeValueCells.slice(0, i + 1); 194 | const cellKeyPath = toPathKey(cellPath); 195 | const ele = containerRef.current?.querySelector( 196 | `li[data-path-key="${cellKeyPath.replace(/\\{0,2}"/g, '\\"')}"]`, // matches unescaped double quotes 197 | ); 198 | if (ele) { 199 | scrollIntoParentView(ele); 200 | } 201 | } 202 | }, [activeValueCells, searchValue]); 203 | 204 | // ========================== Render ========================== 205 | // >>>>> Empty 206 | const isEmpty = !optionColumns[0]?.options?.length; 207 | 208 | const emptyList: DefaultOptionType[] = [ 209 | { 210 | [fieldNames.value as 'value']: '__EMPTY__', 211 | [FIX_LABEL as 'label']: notFoundContent, 212 | disabled: true, 213 | }, 214 | ]; 215 | 216 | const columnProps = { 217 | ...props, 218 | multiple: !isEmpty && multiple, 219 | onSelect: onPathSelect, 220 | onActive: onPathOpen, 221 | onToggleOpen: toggleOpen, 222 | checkedSet, 223 | halfCheckedSet, 224 | loadingKeys, 225 | isSelectable, 226 | }; 227 | 228 | // >>>>> Columns 229 | const mergedOptionColumns = isEmpty ? [{ options: emptyList }] : optionColumns; 230 | 231 | const columnNodes: React.ReactElement[] = mergedOptionColumns.map((col, index) => { 232 | const prevValuePath = activeValueCells.slice(0, index); 233 | const activeValue = activeValueCells[index]; 234 | 235 | return ( 236 | 244 | ); 245 | }); 246 | 247 | // >>>>> Render 248 | return ( 249 | 250 |
257 | {columnNodes} 258 |
259 |
260 | ); 261 | }); 262 | 263 | if (process.env.NODE_ENV !== 'production') { 264 | RawOptionList.displayName = 'RawOptionList'; 265 | } 266 | 267 | export default RawOptionList; 268 | -------------------------------------------------------------------------------- /src/OptionList/index.tsx: -------------------------------------------------------------------------------- 1 | import { useBaseProps } from '@rc-component/select'; 2 | import type { RefOptionListProps } from '@rc-component/select/lib/OptionList'; 3 | import * as React from 'react'; 4 | import RawOptionList from './List'; 5 | 6 | const RefOptionList = React.forwardRef((props, ref) => { 7 | const baseProps = useBaseProps(); 8 | 9 | // >>>>> Render 10 | return ; 11 | }); 12 | 13 | export default RefOptionList; 14 | -------------------------------------------------------------------------------- /src/OptionList/useActive.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import CascaderContext from '../context'; 3 | import { LegacyKey } from '@/Cascader'; 4 | 5 | /** 6 | * Control the active open options path. 7 | */ 8 | const useActive = ( 9 | multiple?: boolean, 10 | open?: boolean, 11 | ): [LegacyKey[], (activeValueCells: LegacyKey[]) => void] => { 12 | const { values } = React.useContext(CascaderContext); 13 | 14 | const firstValueCells = values[0]; 15 | 16 | // Record current dropdown active options 17 | // This also control the open status 18 | const [activeValueCells, setActiveValueCells] = React.useState([]); 19 | 20 | React.useEffect( 21 | () => { 22 | if (!multiple) { 23 | setActiveValueCells(firstValueCells || []); 24 | } 25 | }, 26 | /* eslint-disable react-hooks/exhaustive-deps */ 27 | [open, firstValueCells], 28 | /* eslint-enable react-hooks/exhaustive-deps */ 29 | ); 30 | 31 | return [activeValueCells, setActiveValueCells]; 32 | }; 33 | 34 | export default useActive; 35 | -------------------------------------------------------------------------------- /src/OptionList/useKeyboard.ts: -------------------------------------------------------------------------------- 1 | import type { RefOptionListProps } from '@rc-component/select/lib/OptionList'; 2 | import KeyCode from '@rc-component/util/lib/KeyCode'; 3 | import * as React from 'react'; 4 | import type { DefaultOptionType, InternalFieldNames, LegacyKey, SingleValueType } from '../Cascader'; 5 | import { SEARCH_MARK } from '../hooks/useSearchOptions'; 6 | import { getFullPathKeys, toPathKey } from '../utils/commonUtil'; 7 | 8 | export default ( 9 | ref: React.Ref, 10 | options: DefaultOptionType[], 11 | fieldNames: InternalFieldNames, 12 | activeValueCells: LegacyKey[], 13 | setActiveValueCells: (activeValueCells: LegacyKey[]) => void, 14 | onKeyBoardSelect: (valueCells: SingleValueType, option: DefaultOptionType) => void, 15 | contextProps: { 16 | direction?: 'ltr' | 'rtl'; 17 | searchValue: string; 18 | toggleOpen: (open?: boolean) => void; 19 | open?: boolean; 20 | }, 21 | ) => { 22 | const { direction, searchValue, toggleOpen, open } = contextProps; 23 | const rtl = direction === 'rtl'; 24 | 25 | const [validActiveValueCells, lastActiveIndex, lastActiveOptions, fullPathKeys] = 26 | React.useMemo(() => { 27 | let activeIndex = -1; 28 | let currentOptions = options; 29 | 30 | const mergedActiveIndexes: number[] = []; 31 | const mergedActiveValueCells: LegacyKey[] = []; 32 | 33 | const len = activeValueCells.length; 34 | 35 | const pathKeys = getFullPathKeys(options, fieldNames); 36 | 37 | // Fill validate active value cells and index 38 | for (let i = 0; i < len && currentOptions; i += 1) { 39 | // Mark the active index for current options 40 | const nextActiveIndex = currentOptions.findIndex( 41 | (option, index) => 42 | (pathKeys[index] ? toPathKey(pathKeys[index]) : option[fieldNames.value]) === 43 | activeValueCells[i], 44 | ); 45 | 46 | if (nextActiveIndex === -1) { 47 | break; 48 | } 49 | 50 | activeIndex = nextActiveIndex; 51 | mergedActiveIndexes.push(activeIndex); 52 | mergedActiveValueCells.push(activeValueCells[i]); 53 | 54 | currentOptions = currentOptions[activeIndex][fieldNames.children]; 55 | } 56 | 57 | // Fill last active options 58 | let activeOptions = options; 59 | for (let i = 0; i < mergedActiveIndexes.length - 1; i += 1) { 60 | activeOptions = activeOptions[mergedActiveIndexes[i]][fieldNames.children]; 61 | } 62 | 63 | return [mergedActiveValueCells, activeIndex, activeOptions, pathKeys]; 64 | }, [activeValueCells, fieldNames, options]); 65 | 66 | // Update active value cells and scroll to target element 67 | const internalSetActiveValueCells = (next: LegacyKey[]) => { 68 | setActiveValueCells(next); 69 | }; 70 | 71 | // Same options offset 72 | const offsetActiveOption = (offset: number) => { 73 | const len = lastActiveOptions.length; 74 | 75 | let currentIndex = lastActiveIndex; 76 | if (currentIndex === -1 && offset < 0) { 77 | currentIndex = len; 78 | } 79 | 80 | for (let i = 0; i < len; i += 1) { 81 | currentIndex = (currentIndex + offset + len) % len; 82 | const option = lastActiveOptions[currentIndex]; 83 | if (option && !option.disabled) { 84 | const nextActiveCells = validActiveValueCells 85 | .slice(0, -1) 86 | .concat( 87 | fullPathKeys[currentIndex] 88 | ? toPathKey(fullPathKeys[currentIndex]) 89 | : option[fieldNames.value], 90 | ); 91 | internalSetActiveValueCells(nextActiveCells); 92 | return; 93 | } 94 | } 95 | }; 96 | 97 | // Different options offset 98 | const prevColumn = () => { 99 | if (validActiveValueCells.length > 1) { 100 | const nextActiveCells = validActiveValueCells.slice(0, -1); 101 | internalSetActiveValueCells(nextActiveCells); 102 | } else { 103 | toggleOpen(false); 104 | } 105 | }; 106 | 107 | const nextColumn = () => { 108 | const nextOptions: DefaultOptionType[] = 109 | lastActiveOptions[lastActiveIndex]?.[fieldNames.children] || []; 110 | 111 | const nextOption = nextOptions.find(option => !option.disabled); 112 | 113 | if (nextOption) { 114 | const nextActiveCells = [...validActiveValueCells, nextOption[fieldNames.value]]; 115 | internalSetActiveValueCells(nextActiveCells); 116 | } 117 | }; 118 | 119 | React.useImperativeHandle(ref, () => ({ 120 | // scrollTo: treeRef.current?.scrollTo, 121 | onKeyDown: event => { 122 | const { which } = event; 123 | 124 | switch (which) { 125 | // >>> Arrow keys 126 | case KeyCode.UP: 127 | case KeyCode.DOWN: { 128 | let offset = 0; 129 | if (which === KeyCode.UP) { 130 | offset = -1; 131 | } else if (which === KeyCode.DOWN) { 132 | offset = 1; 133 | } 134 | 135 | if (offset !== 0) { 136 | offsetActiveOption(offset); 137 | } 138 | 139 | break; 140 | } 141 | 142 | case KeyCode.LEFT: { 143 | if (searchValue) { 144 | break; 145 | } 146 | if (rtl) { 147 | nextColumn(); 148 | } else { 149 | prevColumn(); 150 | } 151 | break; 152 | } 153 | 154 | case KeyCode.RIGHT: { 155 | if (searchValue) { 156 | break; 157 | } 158 | if (rtl) { 159 | prevColumn(); 160 | } else { 161 | nextColumn(); 162 | } 163 | break; 164 | } 165 | 166 | case KeyCode.BACKSPACE: { 167 | if (!searchValue) { 168 | prevColumn(); 169 | } 170 | break; 171 | } 172 | 173 | // >>> Select 174 | case KeyCode.ENTER: { 175 | if (validActiveValueCells.length) { 176 | const option = lastActiveOptions[lastActiveIndex]; 177 | 178 | // Search option should revert back of origin options 179 | const originOptions: DefaultOptionType[] = option?.[SEARCH_MARK] || []; 180 | if (originOptions.length) { 181 | onKeyBoardSelect( 182 | originOptions.map(opt => opt[fieldNames.value]), 183 | originOptions[originOptions.length - 1], 184 | ); 185 | } else { 186 | onKeyBoardSelect(validActiveValueCells, lastActiveOptions[lastActiveIndex]); 187 | } 188 | } 189 | break; 190 | } 191 | 192 | // >>> Close 193 | case KeyCode.ESC: { 194 | toggleOpen(false); 195 | 196 | if (open) { 197 | event.stopPropagation(); 198 | } 199 | } 200 | } 201 | }, 202 | onKeyUp: () => {}, 203 | })); 204 | }; 205 | -------------------------------------------------------------------------------- /src/Panel.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useEvent, useMergedState } from '@rc-component/util'; 3 | import * as React from 'react'; 4 | import type { 5 | CascaderProps, 6 | DefaultOptionType, 7 | InternalCascaderProps, 8 | InternalValueType, 9 | SingleValueType, 10 | } from './Cascader'; 11 | import type { CascaderContextProps } from './context'; 12 | import CascaderContext from './context'; 13 | import useMissingValues from './hooks/useMissingValues'; 14 | import useOptions from './hooks/useOptions'; 15 | import useSelect from './hooks/useSelect'; 16 | import useValues from './hooks/useValues'; 17 | import RawOptionList from './OptionList/List'; 18 | import { fillFieldNames, toRawValues } from './utils/commonUtil'; 19 | import { toPathOptions } from './utils/treeUtil'; 20 | 21 | export type PickType = 22 | | 'value' 23 | | 'defaultValue' 24 | | 'changeOnSelect' 25 | | 'onChange' 26 | | 'options' 27 | | 'prefixCls' 28 | | 'checkable' 29 | | 'fieldNames' 30 | | 'showCheckedStrategy' 31 | | 'loadData' 32 | | 'expandTrigger' 33 | | 'expandIcon' 34 | | 'loadingIcon' 35 | | 'className' 36 | | 'style' 37 | | 'direction' 38 | | 'notFoundContent' 39 | | 'disabled'; 40 | 41 | export type PanelProps< 42 | OptionType extends DefaultOptionType = DefaultOptionType, 43 | ValueField extends keyof OptionType = keyof OptionType, 44 | Multiple extends boolean | React.ReactNode = false, 45 | > = Pick, PickType>; 46 | 47 | function noop() {} 48 | 49 | export default function Panel< 50 | OptionType extends DefaultOptionType = DefaultOptionType, 51 | ValueField extends keyof OptionType = keyof OptionType, 52 | Multiple extends boolean | React.ReactNode = false, 53 | >(props: PanelProps) { 54 | const { 55 | prefixCls = 'rc-cascader', 56 | style, 57 | className, 58 | options, 59 | checkable, 60 | defaultValue, 61 | value, 62 | fieldNames, 63 | changeOnSelect, 64 | onChange, 65 | showCheckedStrategy, 66 | loadData, 67 | expandTrigger, 68 | expandIcon = '>', 69 | loadingIcon, 70 | direction, 71 | notFoundContent = 'Not Found', 72 | disabled, 73 | } = props as Pick; 74 | 75 | // ======================== Multiple ======================== 76 | const multiple = !!checkable; 77 | 78 | // ========================= Values ========================= 79 | const [rawValues, setRawValues] = useMergedState< 80 | InternalValueType | undefined, 81 | SingleValueType[] 82 | >(defaultValue, { value, postState: toRawValues }); 83 | 84 | // ========================= FieldNames ========================= 85 | const mergedFieldNames = React.useMemo( 86 | () => fillFieldNames(fieldNames), 87 | /* eslint-disable react-hooks/exhaustive-deps */ 88 | [JSON.stringify(fieldNames)], 89 | /* eslint-enable react-hooks/exhaustive-deps */ 90 | ); 91 | 92 | // =========================== Option =========================== 93 | const [mergedOptions, getPathKeyEntities, getValueByKeyPath] = useOptions( 94 | mergedFieldNames, 95 | options, 96 | ); 97 | 98 | // ========================= Values ========================= 99 | const getMissingValues = useMissingValues(mergedOptions, mergedFieldNames); 100 | 101 | // Fill `rawValues` with checked conduction values 102 | const [checkedValues, halfCheckedValues, missingCheckedValues] = useValues( 103 | multiple, 104 | rawValues, 105 | getPathKeyEntities, 106 | getValueByKeyPath, 107 | getMissingValues, 108 | ); 109 | 110 | // =========================== Change =========================== 111 | const triggerChange = useEvent((nextValues: InternalValueType) => { 112 | setRawValues(nextValues); 113 | 114 | // Save perf if no need trigger event 115 | if (onChange) { 116 | const nextRawValues = toRawValues(nextValues); 117 | 118 | const valueOptions = nextRawValues.map(valueCells => 119 | toPathOptions(valueCells, mergedOptions, mergedFieldNames).map(valueOpt => valueOpt.option), 120 | ); 121 | 122 | const triggerValues = multiple ? nextRawValues : nextRawValues[0]; 123 | const triggerOptions = multiple ? valueOptions : valueOptions[0]; 124 | 125 | onChange(triggerValues, triggerOptions); 126 | } 127 | }); 128 | 129 | // =========================== Select =========================== 130 | const handleSelection = useSelect( 131 | multiple, 132 | triggerChange, 133 | checkedValues, 134 | halfCheckedValues, 135 | missingCheckedValues, 136 | getPathKeyEntities, 137 | getValueByKeyPath, 138 | showCheckedStrategy, 139 | ); 140 | 141 | const onInternalSelect = useEvent((valuePath: SingleValueType) => { 142 | handleSelection(valuePath); 143 | }); 144 | 145 | // ======================== Context ========================= 146 | const cascaderContext = React.useMemo( 147 | () => ({ 148 | options: mergedOptions, 149 | fieldNames: mergedFieldNames, 150 | values: checkedValues, 151 | halfValues: halfCheckedValues, 152 | changeOnSelect, 153 | onSelect: onInternalSelect, 154 | checkable, 155 | searchOptions: [], 156 | popupPrefixCls: undefined, 157 | loadData, 158 | expandTrigger, 159 | expandIcon, 160 | loadingIcon, 161 | popupMenuColumnStyle: undefined, 162 | }), 163 | [ 164 | mergedOptions, 165 | mergedFieldNames, 166 | checkedValues, 167 | halfCheckedValues, 168 | changeOnSelect, 169 | onInternalSelect, 170 | checkable, 171 | loadData, 172 | expandTrigger, 173 | expandIcon, 174 | loadingIcon, 175 | ], 176 | ); 177 | 178 | // ========================= Render ========================= 179 | const panelPrefixCls = `${prefixCls}-panel`; 180 | const isEmpty = !mergedOptions.length; 181 | 182 | return ( 183 | 184 |
195 | {isEmpty ? ( 196 | notFoundContent 197 | ) : ( 198 | 207 | )} 208 |
209 |
210 | ); 211 | } 212 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { 3 | CascaderProps, 4 | InternalFieldNames, 5 | DefaultOptionType, 6 | SingleValueType, 7 | } from './Cascader'; 8 | 9 | export interface CascaderContextProps { 10 | options: NonNullable; 11 | fieldNames: InternalFieldNames; 12 | values: SingleValueType[]; 13 | halfValues: SingleValueType[]; 14 | changeOnSelect?: boolean; 15 | onSelect: (valuePath: SingleValueType) => void; 16 | checkable?: boolean | React.ReactNode; 17 | searchOptions: DefaultOptionType[]; 18 | popupPrefixCls?: string; 19 | loadData?: (selectOptions: DefaultOptionType[]) => void; 20 | expandTrigger?: 'hover' | 'click'; 21 | expandIcon?: React.ReactNode; 22 | loadingIcon?: React.ReactNode; 23 | popupMenuColumnStyle?: React.CSSProperties; 24 | optionRender?: CascaderProps['optionRender']; 25 | classNames?: CascaderProps['classNames']; 26 | styles?: CascaderProps['styles']; 27 | } 28 | 29 | const CascaderContext = React.createContext({} as CascaderContextProps); 30 | 31 | export default CascaderContext; 32 | -------------------------------------------------------------------------------- /src/hooks/useDisplayValues.ts: -------------------------------------------------------------------------------- 1 | import { toPathOptions } from '../utils/treeUtil'; 2 | import * as React from 'react'; 3 | import type { 4 | DefaultOptionType, 5 | SingleValueType, 6 | CascaderProps, 7 | InternalFieldNames, 8 | } from '../Cascader'; 9 | import { toPathKey } from '../utils/commonUtil'; 10 | 11 | export default ( 12 | rawValues: SingleValueType[], 13 | options: DefaultOptionType[], 14 | fieldNames: InternalFieldNames, 15 | multiple: boolean, 16 | displayRender: CascaderProps['displayRender'], 17 | ) => { 18 | return React.useMemo(() => { 19 | const mergedDisplayRender = 20 | displayRender || 21 | // Default displayRender 22 | (labels => { 23 | const mergedLabels: React.ReactNode[] = multiple ? labels.slice(-1) : labels; 24 | const SPLIT = ' / '; 25 | 26 | if (mergedLabels.every(label => ['string', 'number'].includes(typeof label))) { 27 | return mergedLabels.join(SPLIT); 28 | } 29 | 30 | // If exist non-string value, use ReactNode instead 31 | return mergedLabels.reduce((list: React.ReactNode[], label, index) => { 32 | const keyedLabel = React.isValidElement(label) 33 | ? React.cloneElement(label, { key: index }) 34 | : label; 35 | 36 | if (index === 0) { 37 | return [keyedLabel]; 38 | } 39 | return [...list, SPLIT, keyedLabel]; 40 | }, []); 41 | }); 42 | 43 | return rawValues.map(valueCells => { 44 | const valueOptions = toPathOptions(valueCells, options, fieldNames); 45 | 46 | const label = mergedDisplayRender( 47 | valueOptions.map(({ option, value }) => option?.[fieldNames.label] ?? value), 48 | valueOptions.map(({ option }) => option), 49 | ); 50 | 51 | const value = toPathKey(valueCells); 52 | 53 | return { 54 | label, 55 | value, 56 | key: value, 57 | valueCells, 58 | disabled: valueOptions[valueOptions.length - 1]?.option?.disabled, 59 | }; 60 | }); 61 | }, [rawValues, options, fieldNames, displayRender, multiple]); 62 | }; 63 | -------------------------------------------------------------------------------- /src/hooks/useEntities.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { convertDataToEntities } from '@rc-component/tree/lib/utils/treeUtil'; 3 | import type { DefaultOptionType, InternalFieldNames } from '../Cascader'; 4 | import type { DataEntity, DataNode } from '@rc-component/tree/lib/interface'; 5 | import { VALUE_SPLIT } from '../utils/commonUtil'; 6 | 7 | export interface OptionsInfo { 8 | keyEntities: Record; 9 | pathKeyEntities: Record; 10 | } 11 | 12 | export type GetEntities = () => OptionsInfo['pathKeyEntities']; 13 | 14 | /** Lazy parse options data into conduct-able info to avoid perf issue in single mode */ 15 | export default (options: DefaultOptionType[], fieldNames: InternalFieldNames) => { 16 | const cacheRef = React.useRef<{ 17 | options: DefaultOptionType[]; 18 | info: OptionsInfo; 19 | }>({ 20 | options: [], 21 | info: { keyEntities: {}, pathKeyEntities: {} }, 22 | }); 23 | 24 | const getEntities: GetEntities = React.useCallback(() => { 25 | if (cacheRef.current.options !== options) { 26 | cacheRef.current.options = options; 27 | cacheRef.current.info = convertDataToEntities(options as DataNode[], { 28 | fieldNames: fieldNames as any, 29 | initWrapper: wrapper => ({ 30 | ...wrapper, 31 | pathKeyEntities: {}, 32 | }), 33 | processEntity: (entity, wrapper) => { 34 | const pathKey = (entity.nodes as DefaultOptionType[]) 35 | .map(node => node[fieldNames.value]) 36 | .join(VALUE_SPLIT); 37 | 38 | (wrapper as unknown as OptionsInfo).pathKeyEntities[pathKey] = entity; 39 | 40 | // Overwrite origin key. 41 | // this is very hack but we need let conduct logic work with connect path 42 | entity.key = pathKey; 43 | }, 44 | }) as any; 45 | } 46 | 47 | return cacheRef.current.info.pathKeyEntities; 48 | }, [fieldNames, options]); 49 | 50 | return getEntities; 51 | }; 52 | -------------------------------------------------------------------------------- /src/hooks/useMissingValues.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { DefaultOptionType, InternalFieldNames, SingleValueType } from '../Cascader'; 3 | import { toPathOptions } from '../utils/treeUtil'; 4 | 5 | export type GetMissValues = ReturnType; 6 | 7 | export default function useMissingValues( 8 | options: DefaultOptionType[], 9 | fieldNames: InternalFieldNames, 10 | ) { 11 | return React.useCallback( 12 | (rawValues: SingleValueType[]): [SingleValueType[], SingleValueType[]] => { 13 | const missingValues: SingleValueType[] = []; 14 | const existsValues: SingleValueType[] = []; 15 | 16 | rawValues.forEach(valueCell => { 17 | const pathOptions = toPathOptions(valueCell, options, fieldNames); 18 | if (pathOptions.every(opt => opt.option)) { 19 | existsValues.push(valueCell); 20 | } else { 21 | missingValues.push(valueCell); 22 | } 23 | }); 24 | 25 | return [existsValues, missingValues]; 26 | }, 27 | [options, fieldNames], 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useOptions.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { DefaultOptionType } from '..'; 3 | import type { InternalFieldNames, SingleValueType, LegacyKey } from '../Cascader'; 4 | import useEntities, { type GetEntities } from './useEntities'; 5 | 6 | export default function useOptions( 7 | mergedFieldNames: InternalFieldNames, 8 | options?: DefaultOptionType[], 9 | ): [ 10 | mergedOptions: DefaultOptionType[], 11 | getPathKeyEntities: GetEntities, 12 | getValueByKeyPath: (pathKeys: LegacyKey[]) => SingleValueType[], 13 | ] { 14 | const mergedOptions = React.useMemo(() => options || [], [options]); 15 | 16 | // Only used in multiple mode, this fn will not call in single mode 17 | const getPathKeyEntities = useEntities(mergedOptions, mergedFieldNames); 18 | 19 | /** Convert path key back to value format */ 20 | const getValueByKeyPath = React.useCallback( 21 | (pathKeys: LegacyKey[]): SingleValueType[] => { 22 | const keyPathEntities = getPathKeyEntities(); 23 | 24 | return pathKeys.map(pathKey => { 25 | const { nodes } = keyPathEntities[pathKey]; 26 | 27 | return nodes.map(node => (node as Record)[mergedFieldNames.value]); 28 | }); 29 | }, 30 | [getPathKeyEntities, mergedFieldNames], 31 | ); 32 | 33 | return [mergedOptions, getPathKeyEntities, getValueByKeyPath]; 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks/useSearchConfig.ts: -------------------------------------------------------------------------------- 1 | import warning from '@rc-component/util/lib/warning'; 2 | import * as React from 'react'; 3 | import type { CascaderProps, ShowSearchType } from '../Cascader'; 4 | 5 | // Convert `showSearch` to unique config 6 | export default function useSearchConfig(showSearch?: CascaderProps['showSearch']) { 7 | return React.useMemo<[boolean, ShowSearchType]>(() => { 8 | if (!showSearch) { 9 | return [false, {}]; 10 | } 11 | 12 | let searchConfig: ShowSearchType = { 13 | matchInputWidth: true, 14 | limit: 50, 15 | }; 16 | 17 | if (showSearch && typeof showSearch === 'object') { 18 | searchConfig = { 19 | ...searchConfig, 20 | ...showSearch, 21 | }; 22 | } 23 | 24 | if ((searchConfig.limit as number) <= 0) { 25 | searchConfig.limit = false; 26 | 27 | if (process.env.NODE_ENV !== 'production') { 28 | warning(false, "'limit' of showSearch should be positive number or false."); 29 | } 30 | } 31 | 32 | return [true, searchConfig]; 33 | }, [showSearch]); 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks/useSearchOptions.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { DefaultOptionType, InternalFieldNames, ShowSearchType } from '../Cascader'; 3 | 4 | export const SEARCH_MARK = '__rc_cascader_search_mark__'; 5 | 6 | const defaultFilter: ShowSearchType['filter'] = (search, options, { label = '' }) => 7 | options.some(opt => String(opt[label]).toLowerCase().includes(search.toLowerCase())); 8 | 9 | const defaultRender: ShowSearchType['render'] = (inputValue, path, prefixCls, fieldNames) => 10 | path.map(opt => opt[fieldNames.label as string]).join(' / '); 11 | 12 | const useSearchOptions = ( 13 | search: string, 14 | options: DefaultOptionType[], 15 | fieldNames: InternalFieldNames, 16 | prefixCls: string, 17 | config: ShowSearchType, 18 | enableHalfPath?: boolean, 19 | ) => { 20 | const { filter = defaultFilter, render = defaultRender, limit = 50, sort } = config; 21 | 22 | return React.useMemo(() => { 23 | const filteredOptions: DefaultOptionType[] = []; 24 | if (!search) { 25 | return []; 26 | } 27 | 28 | function dig( 29 | list: DefaultOptionType[], 30 | pathOptions: DefaultOptionType[], 31 | parentDisabled = false, 32 | ) { 33 | list.forEach(option => { 34 | // Perf saving when `sort` is disabled and `limit` is provided 35 | if (!sort && limit !== false && limit > 0 && filteredOptions.length >= limit) { 36 | return; 37 | } 38 | 39 | const connectedPathOptions = [...pathOptions, option]; 40 | const children = option[fieldNames.children]; 41 | 42 | const mergedDisabled = parentDisabled || option.disabled; 43 | 44 | // If current option is filterable 45 | if ( 46 | // If is leaf option 47 | !children || 48 | children.length === 0 || 49 | // If is changeOnSelect or multiple 50 | enableHalfPath 51 | ) { 52 | if (filter(search, connectedPathOptions, { label: fieldNames.label })) { 53 | filteredOptions.push({ 54 | ...option, 55 | disabled: mergedDisabled, 56 | [fieldNames.label as 'label']: render( 57 | search, 58 | connectedPathOptions, 59 | prefixCls, 60 | fieldNames, 61 | ), 62 | [SEARCH_MARK]: connectedPathOptions, 63 | [fieldNames.children]: undefined, 64 | }); 65 | } 66 | } 67 | 68 | if (children) { 69 | dig( 70 | option[fieldNames.children] as DefaultOptionType[], 71 | connectedPathOptions, 72 | mergedDisabled, 73 | ); 74 | } 75 | }); 76 | } 77 | 78 | dig(options, []); 79 | 80 | // Do sort 81 | if (sort) { 82 | filteredOptions.sort((a, b) => { 83 | return sort(a[SEARCH_MARK], b[SEARCH_MARK], search, fieldNames); 84 | }); 85 | } 86 | 87 | return limit !== false && limit > 0 88 | ? filteredOptions.slice(0, limit as number) 89 | : filteredOptions; 90 | }, [search, options, fieldNames, prefixCls, render, enableHalfPath, filter, sort, limit]); 91 | }; 92 | 93 | export default useSearchOptions; 94 | -------------------------------------------------------------------------------- /src/hooks/useSelect.ts: -------------------------------------------------------------------------------- 1 | import { conductCheck } from '@rc-component/tree/lib/utils/conductUtil'; 2 | import type { 3 | InternalValueType, 4 | LegacyKey, 5 | ShowCheckedStrategy, 6 | SingleValueType, 7 | } from '../Cascader'; 8 | import { toPathKey, toPathKeys } from '../utils/commonUtil'; 9 | import { formatStrategyValues } from '../utils/treeUtil'; 10 | import type { GetEntities } from './useEntities'; 11 | 12 | export default function useSelect( 13 | multiple: boolean, 14 | triggerChange: (nextValues: InternalValueType) => void, 15 | checkedValues: SingleValueType[], 16 | halfCheckedValues: SingleValueType[], 17 | missingCheckedValues: SingleValueType[], 18 | getPathKeyEntities: GetEntities, 19 | getValueByKeyPath: (pathKeys: LegacyKey[]) => SingleValueType[], 20 | showCheckedStrategy?: ShowCheckedStrategy, 21 | ) { 22 | return (valuePath: SingleValueType) => { 23 | if (!multiple) { 24 | triggerChange(valuePath); 25 | } else { 26 | // Prepare conduct required info 27 | const pathKey = toPathKey(valuePath); 28 | const checkedPathKeys = toPathKeys(checkedValues); 29 | const halfCheckedPathKeys = toPathKeys(halfCheckedValues); 30 | 31 | const existInChecked = checkedPathKeys.includes(pathKey); 32 | const existInMissing = missingCheckedValues.some( 33 | valueCells => toPathKey(valueCells) === pathKey, 34 | ); 35 | 36 | // Do update 37 | let nextCheckedValues = checkedValues; 38 | let nextMissingValues = missingCheckedValues; 39 | 40 | if (existInMissing && !existInChecked) { 41 | // Missing value only do filter 42 | nextMissingValues = missingCheckedValues.filter( 43 | valueCells => toPathKey(valueCells) !== pathKey, 44 | ); 45 | } else { 46 | // Update checked key first 47 | const nextRawCheckedKeys = existInChecked 48 | ? checkedPathKeys.filter(key => key !== pathKey) 49 | : [...checkedPathKeys, pathKey]; 50 | 51 | const pathKeyEntities = getPathKeyEntities(); 52 | 53 | // Conduction by selected or not 54 | let checkedKeys: LegacyKey[]; 55 | if (existInChecked) { 56 | ({ checkedKeys } = conductCheck( 57 | nextRawCheckedKeys, 58 | { checked: false, halfCheckedKeys: halfCheckedPathKeys }, 59 | pathKeyEntities, 60 | ) as { checkedKeys: LegacyKey[] }); 61 | } else { 62 | ({ checkedKeys } = conductCheck(nextRawCheckedKeys, true, pathKeyEntities) as { 63 | checkedKeys: LegacyKey[]; 64 | }); 65 | } 66 | 67 | // Roll up to parent level keys 68 | const deDuplicatedKeys = formatStrategyValues( 69 | checkedKeys, 70 | getPathKeyEntities, 71 | showCheckedStrategy, 72 | ); 73 | nextCheckedValues = getValueByKeyPath(deDuplicatedKeys); 74 | } 75 | 76 | triggerChange([...nextMissingValues, ...nextCheckedValues]); 77 | } 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/hooks/useValues.ts: -------------------------------------------------------------------------------- 1 | import type { DataEntity } from '@rc-component/tree/lib/interface'; 2 | import { conductCheck } from '@rc-component/tree/lib/utils/conductUtil'; 3 | import * as React from 'react'; 4 | import type { LegacyKey, SingleValueType } from '../Cascader'; 5 | import { toPathKeys } from '../utils/commonUtil'; 6 | import type { GetMissValues } from './useMissingValues'; 7 | 8 | export default function useValues( 9 | multiple: boolean, 10 | rawValues: SingleValueType[], 11 | getPathKeyEntities: () => Record, 12 | getValueByKeyPath: (pathKeys: LegacyKey[]) => SingleValueType[], 13 | getMissingValues: GetMissValues, 14 | ): [ 15 | checkedValues: SingleValueType[], 16 | halfCheckedValues: SingleValueType[], 17 | missingCheckedValues: SingleValueType[], 18 | ] { 19 | // Fill `rawValues` with checked conduction values 20 | return React.useMemo(() => { 21 | const [existValues, missingValues] = getMissingValues(rawValues); 22 | 23 | if (!multiple || !rawValues.length) { 24 | return [existValues, [], missingValues]; 25 | } 26 | 27 | const keyPathValues = toPathKeys(existValues); 28 | const keyPathEntities = getPathKeyEntities(); 29 | 30 | const { checkedKeys, halfCheckedKeys } = conductCheck(keyPathValues, true, keyPathEntities) as { 31 | checkedKeys: LegacyKey[]; 32 | halfCheckedKeys: LegacyKey[]; 33 | }; 34 | 35 | // Convert key back to value cells 36 | return [getValueByKeyPath(checkedKeys), getValueByKeyPath(halfCheckedKeys), missingValues]; 37 | }, [multiple, rawValues, getPathKeyEntities, getValueByKeyPath, getMissingValues]); 38 | } 39 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import Cascader from './Cascader'; 2 | import Panel from './Panel'; 3 | 4 | export type { 5 | BaseOptionType, 6 | DefaultOptionType, 7 | CascaderProps, 8 | FieldNames, 9 | ShowSearchType, 10 | CascaderRef, 11 | } from './Cascader'; 12 | export { Panel }; 13 | 14 | export default Cascader; 15 | -------------------------------------------------------------------------------- /src/utils/commonUtil.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DefaultOptionType, 3 | FieldNames, 4 | InternalFieldNames, 5 | InternalValueType, 6 | SingleValueType, 7 | } from '../Cascader'; 8 | import { SEARCH_MARK } from '../hooks/useSearchOptions'; 9 | 10 | export const VALUE_SPLIT = '__RC_CASCADER_SPLIT__'; 11 | export const SHOW_PARENT = 'SHOW_PARENT'; 12 | export const SHOW_CHILD = 'SHOW_CHILD'; 13 | 14 | /** 15 | * Will convert value to string, and join with `VALUE_SPLIT` 16 | */ 17 | export function toPathKey(value: SingleValueType) { 18 | return value.join(VALUE_SPLIT); 19 | } 20 | 21 | /** 22 | * Batch convert value to string, and join with `VALUE_SPLIT` 23 | */ 24 | export function toPathKeys(value: SingleValueType[]) { 25 | return value.map(toPathKey); 26 | } 27 | 28 | export function toPathValueStr(pathKey: string) { 29 | return pathKey.split(VALUE_SPLIT); 30 | } 31 | 32 | export function fillFieldNames(fieldNames?: FieldNames): InternalFieldNames { 33 | const { label, value, children } = fieldNames || {}; 34 | const val = value || 'value'; 35 | return { 36 | label: label || 'label', 37 | value: val, 38 | key: val as string, 39 | children: children || 'children', 40 | }; 41 | } 42 | 43 | export function isLeaf(option: DefaultOptionType, fieldNames: FieldNames) { 44 | return option.isLeaf ?? !option[fieldNames.children as string]?.length; 45 | } 46 | 47 | export function scrollIntoParentView(element: HTMLElement) { 48 | const parent = element.parentElement; 49 | if (!parent) { 50 | return; 51 | } 52 | 53 | const elementToParent = element.offsetTop - parent.offsetTop; // offsetParent may not be parent. 54 | if (elementToParent - parent.scrollTop < 0) { 55 | parent.scrollTo({ top: elementToParent }); 56 | } else if (elementToParent + element.offsetHeight - parent.scrollTop > parent.offsetHeight) { 57 | parent.scrollTo({ top: elementToParent + element.offsetHeight - parent.offsetHeight }); 58 | } 59 | } 60 | 61 | export function getFullPathKeys(options: DefaultOptionType[], fieldNames: FieldNames) { 62 | return options.map(item => 63 | item[SEARCH_MARK]?.map((opt: Record) => opt[fieldNames.value as string]), 64 | ); 65 | } 66 | 67 | function isMultipleValue(value: InternalValueType): value is SingleValueType[] { 68 | return Array.isArray(value) && Array.isArray(value[0]); 69 | } 70 | 71 | export function toRawValues(value?: InternalValueType): SingleValueType[] { 72 | if (!value) { 73 | return []; 74 | } 75 | 76 | if (isMultipleValue(value)) { 77 | return value; 78 | } 79 | 80 | return (value.length === 0 ? [] : [value]).map(val => (Array.isArray(val) ? val : [val])); 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/treeUtil.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SingleValueType, 3 | DefaultOptionType, 4 | InternalFieldNames, 5 | ShowCheckedStrategy, 6 | LegacyKey, 7 | } from '../Cascader'; 8 | import type { GetEntities } from '../hooks/useEntities'; 9 | import { SHOW_CHILD } from './commonUtil'; 10 | 11 | export function formatStrategyValues( 12 | pathKeys: LegacyKey[], 13 | getKeyPathEntities: GetEntities, 14 | showCheckedStrategy?: ShowCheckedStrategy, 15 | ) { 16 | const valueSet = new Set(pathKeys); 17 | const keyPathEntities = getKeyPathEntities(); 18 | 19 | return pathKeys.filter(key => { 20 | const entity = keyPathEntities[key]; 21 | const parent = entity ? entity.parent : null; 22 | const children = entity ? entity.children : null; 23 | 24 | if (entity && entity.node.disabled) { 25 | return true; 26 | } 27 | 28 | return showCheckedStrategy === SHOW_CHILD 29 | ? !(children && children.some(child => child.key && valueSet.has(child.key as LegacyKey))) 30 | : !(parent && !parent.node.disabled && valueSet.has(parent.key as LegacyKey)); 31 | }); 32 | } 33 | 34 | export function toPathOptions( 35 | valueCells: SingleValueType, 36 | options: DefaultOptionType[], 37 | fieldNames: InternalFieldNames, 38 | // Used for loadingKeys which saved loaded keys as string 39 | stringMode = false, 40 | ) { 41 | let currentList = options; 42 | const valueOptions: { 43 | value: SingleValueType[number]; 44 | index: number; 45 | option: DefaultOptionType; 46 | }[] = []; 47 | 48 | for (let i = 0; i < valueCells.length; i += 1) { 49 | const valueCell = valueCells[i]; 50 | const foundIndex = currentList?.findIndex(option => { 51 | const val = option[fieldNames.value]; 52 | return stringMode ? String(val) === String(valueCell) : val === valueCell; 53 | }); 54 | const foundOption = foundIndex !== -1 ? currentList?.[foundIndex] : null; 55 | 56 | valueOptions.push({ 57 | value: foundOption?.[fieldNames.value] ?? valueCell, 58 | index: foundIndex, 59 | option: foundOption as DefaultOptionType, 60 | }); 61 | 62 | currentList = foundOption?.[fieldNames.children]; 63 | } 64 | 65 | return valueOptions; 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/warningPropsUtil.ts: -------------------------------------------------------------------------------- 1 | import warning from '@rc-component/util/lib/warning'; 2 | import type { DefaultOptionType, FieldNames } from '../Cascader'; 3 | 4 | // value in Cascader options should not be null 5 | export function warningNullOptions(options: DefaultOptionType[], fieldNames: FieldNames) { 6 | if (options) { 7 | const recursiveOptions = (optionsList: DefaultOptionType[]) => { 8 | for (let i = 0; i < optionsList.length; i++) { 9 | const option = optionsList[i]; 10 | 11 | if (option[fieldNames?.value as string] === null) { 12 | warning(false, '`value` in Cascader options should not be `null`.'); 13 | return true; 14 | } 15 | 16 | if ( 17 | Array.isArray(option[fieldNames?.children as string]) && 18 | recursiveOptions(option[fieldNames?.children as string]) 19 | ) { 20 | return true; 21 | } 22 | } 23 | }; 24 | 25 | recursiveOptions(options); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Panel.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Cascader, { type CascaderProps } from '../src'; 4 | 5 | describe('Cascader.Panel', () => { 6 | beforeEach(() => { 7 | jest.useFakeTimers(); 8 | }); 9 | 10 | afterEach(() => { 11 | jest.useRealTimers(); 12 | }); 13 | 14 | const options: CascaderProps['options'] = [ 15 | { 16 | label: 'Light', 17 | value: 'light', 18 | }, 19 | { 20 | label: 'Bamboo', 21 | value: 'bamboo', 22 | children: [ 23 | { 24 | label: 'Little', 25 | value: 'little', 26 | }, 27 | ], 28 | }, 29 | ]; 30 | 31 | it('basic', () => { 32 | const onChange = jest.fn(); 33 | const { container } = render(); 34 | 35 | expect(container.querySelector('.rc-cascader-panel')).toBeTruthy(); 36 | expect(container.querySelectorAll('.rc-cascader-menu')).toHaveLength(1); 37 | 38 | // Click first column 39 | fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[1]); 40 | expect(container.querySelectorAll('.rc-cascader-menu')).toHaveLength(2); 41 | 42 | // Click second column 43 | fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[2]); 44 | expect(onChange).toHaveBeenCalledWith(['bamboo', 'little'], expect.anything()); 45 | }); 46 | 47 | it('multiple', () => { 48 | const onChange = jest.fn(); 49 | const { container } = render( 50 | , 51 | ); 52 | 53 | // Click first column - light 54 | fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[0]); 55 | expect(onChange).toHaveBeenCalledWith([['light']], expect.anything()); 56 | onChange.mockReset(); 57 | 58 | // Click first column - bamboo (no trigger onChange) 59 | fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[1]); 60 | expect(onChange).not.toHaveBeenCalled(); 61 | 62 | // Click second column - little 63 | fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item')[2]); 64 | expect(onChange).toHaveBeenCalledWith([['light'], ['bamboo']], expect.anything()); 65 | }); 66 | 67 | it('multiple with showCheckedStrategy', () => { 68 | const onChange = jest.fn(); 69 | const { container } = render( 70 | , 76 | ); 77 | 78 | fireEvent.click(container.querySelectorAll('.rc-cascader-checkbox')[1]); 79 | expect(onChange).toHaveBeenCalledWith([['bamboo', 'little']], expect.anything()); 80 | }); 81 | 82 | it('rtl', () => { 83 | const { container } = render(); 84 | 85 | expect(container.querySelector('.rc-cascader-panel-rtl')).toBeTruthy(); 86 | }); 87 | 88 | it('notFoundContent', () => { 89 | const { container } = render(); 90 | 91 | expect(container.querySelector('.rc-cascader-panel-empty')?.textContent).toEqual('Hello World'); 92 | }); 93 | 94 | it('control', () => { 95 | const { container } = render(); 96 | 97 | const checkedLi = container.querySelector('[aria-checked="true"]'); 98 | expect(checkedLi?.textContent).toEqual('Little'); 99 | }); 100 | 101 | it('disabled', () => { 102 | const onChange = jest.fn(); 103 | const { container } = render( 104 | , 105 | ); 106 | 107 | expect(container.querySelector('.rc-cascader-menu-item-disabled')).toBeTruthy(); 108 | 109 | const selectOption = container.querySelector('.rc-cascader-menu-item')!; 110 | fireEvent.click(selectOption); 111 | expect(onChange).not.toHaveBeenCalled(); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /tests/__mocks__/@rc-component/trigger.js: -------------------------------------------------------------------------------- 1 | import Trigger from '@rc-component/trigger/lib/mock'; 2 | 3 | export default Trigger; 4 | -------------------------------------------------------------------------------- /tests/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Cascader.Basic should not show title when title is falsy 1`] = ` 4 | 61 | `; 62 | 63 | exports[`Cascader.Basic should render custom popup correctly 1`] = ` 64 |
68 |
69 |
72 |
75 | 134 |
135 |
136 | 139 | Hello, popupRender 140 | 141 |
142 |
143 |
144 | `; 145 | -------------------------------------------------------------------------------- /tests/__snapshots__/search.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Cascader.Search should correct render Cascader with same field name of label and value 1`] = ` 4 | 79 | `; 80 | -------------------------------------------------------------------------------- /tests/checkable.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Cascader from '../src'; 4 | import { addressOptions } from './demoOptions'; 5 | import { mount } from './enzyme'; 6 | 7 | describe('Cascader.Checkable', () => { 8 | const options = [ 9 | { 10 | label: 'Light', 11 | value: 'light', 12 | }, 13 | { 14 | label: 'Bamboo', 15 | value: 'bamboo', 16 | children: [ 17 | { 18 | label: 'Little', 19 | value: 'little', 20 | children: [ 21 | { 22 | label: 'Toy Fish', 23 | value: 'fish', 24 | }, 25 | { 26 | label: 'Toy Cards', 27 | value: 'cards', 28 | }, 29 | ], 30 | }, 31 | ], 32 | }, 33 | ]; 34 | 35 | it('customize', () => { 36 | const onChange = jest.fn(); 37 | const wrapper = mount(); 38 | 39 | expect(wrapper.exists('.rc-cascader-checkbox')).toBeTruthy(); 40 | expect(wrapper.exists('.rc-cascader-checkbox-checked')).toBeFalsy(); 41 | expect(wrapper.exists('.rc-cascader-checkbox-indeterminate')).toBeFalsy(); 42 | 43 | // Check light 44 | wrapper.find('.rc-cascader-checkbox').first().simulate('click'); 45 | expect(wrapper.exists('.rc-cascader-checkbox-checked')).toBeTruthy(); 46 | expect(onChange).toHaveBeenCalledWith( 47 | [['light']], 48 | [[expect.objectContaining({ value: 'light' })]], 49 | ); 50 | 51 | onChange.mockReset(); 52 | 53 | // Open bamboo > little 54 | wrapper.clickOption(0, 1); 55 | wrapper.clickOption(1, 0); 56 | 57 | // Check cards 58 | wrapper.clickOption(2, 1); 59 | expect(wrapper.find('.rc-cascader-checkbox-indeterminate')).toHaveLength(2); 60 | expect(wrapper.exists('.rc-cascader-checkbox-indeterminate')).toBeTruthy(); 61 | expect(onChange).toHaveBeenCalledWith( 62 | [ 63 | // Light 64 | ['light'], 65 | // Cards 66 | ['bamboo', 'little', 'cards'], 67 | ], 68 | [ 69 | // Light 70 | [expect.objectContaining({ value: 'light' })], 71 | // Cards 72 | [ 73 | expect.objectContaining({ value: 'bamboo' }), 74 | expect.objectContaining({ value: 'little' }), 75 | expect.objectContaining({ value: 'cards' }), 76 | ], 77 | ], 78 | ); 79 | 80 | // Check fish 81 | wrapper.clickOption(2, 0); 82 | expect(wrapper.find('.rc-cascader-checkbox-indeterminate')).toHaveLength(0); 83 | expect(wrapper.find('.rc-cascader-checkbox-checked')).toHaveLength(5); 84 | expect(onChange).toHaveBeenCalledWith( 85 | [ 86 | // Light 87 | ['light'], 88 | // Bamboo 89 | ['bamboo'], 90 | ], 91 | [ 92 | // Light 93 | [expect.objectContaining({ value: 'light' })], 94 | // Cards 95 | [expect.objectContaining({ value: 'bamboo' })], 96 | ], 97 | ); 98 | }); 99 | it('click checkbox invoke one onChange', () => { 100 | const onChange = jest.fn(); 101 | const wrapper = mount(); 102 | 103 | expect(wrapper.exists('.rc-cascader-checkbox')).toBeTruthy(); 104 | expect(wrapper.exists('.rc-cascader-checkbox-checked')).toBeFalsy(); 105 | expect(wrapper.exists('.rc-cascader-checkbox-indeterminate')).toBeFalsy(); 106 | 107 | // Check checkbox 108 | wrapper.find('.rc-cascader-checkbox').first().simulate('click'); 109 | expect(wrapper.exists('.rc-cascader-checkbox-checked')).toBeTruthy(); 110 | expect(onChange).toHaveBeenCalledTimes(1); 111 | }); 112 | 113 | it('merge checked options', () => { 114 | const onChange = jest.fn(); 115 | 116 | const wrapper = mount( 117 | , 138 | ); 139 | 140 | // Open parent 141 | wrapper.find('.rc-cascader-menu-item-content').first().simulate('click'); 142 | 143 | // Check child1 144 | wrapper.find('span.rc-cascader-checkbox').at(1).simulate('click'); 145 | expect(onChange).toHaveBeenCalledWith([['parent', 'child1']], expect.anything()); 146 | 147 | // Check child2 148 | onChange.mockReset(); 149 | wrapper.find('span.rc-cascader-checkbox').at(2).simulate('click'); 150 | expect(onChange).toHaveBeenCalledWith([['parent']], expect.anything()); 151 | 152 | // Uncheck child1 153 | onChange.mockReset(); 154 | wrapper.find('span.rc-cascader-checkbox').at(1).simulate('click'); 155 | expect(onChange).toHaveBeenCalledWith([['parent', 'child2']], expect.anything()); 156 | }); 157 | 158 | // https://github.com/ant-design/ant-design/issues/33302 159 | it('should not display checkbox when children is empty', () => { 160 | const wrapper = mount( 161 | 162 | 163 | , 164 | ); 165 | wrapper.find('input').simulate('click'); 166 | const menus = wrapper.find('.rc-cascader-menu'); 167 | expect(menus.find('.rc-cascader-checkbox').length).toBe(0); 168 | }); 169 | 170 | it('should work with custom checkable', () => { 171 | const wrapper = mount( 172 | 0} 174 | open 175 | options={addressOptions} 176 | />, 177 | ); 178 | expect(wrapper.find('.my-custom-checkbox')).toHaveLength(3); 179 | }); 180 | 181 | it('should be correct expression with disableCheckbox', () => { 182 | const wrapper = mount( 183 | , 206 | ); 207 | 208 | // disabled className 209 | wrapper.find('.rc-cascader-menu-item').simulate('click'); 210 | expect(wrapper.find('.rc-cascader-menu-item')).toHaveLength(4); 211 | expect(wrapper.find('.rc-cascader-checkbox-disabled')).toHaveLength(1); 212 | 213 | // click disableCkeckbox 214 | wrapper.find('.rc-cascader-menu-item').at(1).simulate('click'); 215 | expect(wrapper.find('.rc-cascader-checkbox-checked')).toHaveLength(0); 216 | 217 | // click disableMenuItem 218 | wrapper.find('.rc-cascader-checkbox-disabled').simulate('click'); 219 | expect(wrapper.find('.rc-cascader-checkbox-checked')).toHaveLength(0); 220 | 221 | // Check all children except disableCheckbox When the parent checkbox is checked 222 | expect(wrapper.find('.rc-cascader-checkbox')).toHaveLength(4); 223 | wrapper.find('.rc-cascader-checkbox').first().simulate('click'); 224 | expect(wrapper.find('.rc-cascader-checkbox-checked')).toHaveLength(3); 225 | }); 226 | 227 | it('should not merge disabled options', () => { 228 | const onChange = jest.fn(); 229 | 230 | render( 231 | , 254 | ); 255 | 256 | fireEvent.click( 257 | document.querySelector('[data-path-key="China"] .rc-cascader-checkbox') as HTMLElement, 258 | ); 259 | 260 | expect(onChange).toHaveBeenCalledWith([['China', 'beijing'], ['China']], expect.anything()); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /tests/demoOptions.ts: -------------------------------------------------------------------------------- 1 | import type { BaseOptionType } from '../src'; 2 | 3 | export const optionsForActiveMenuItems: BaseOptionType[] = [ 4 | { 5 | value: '1', 6 | label: '1', 7 | children: [ 8 | { 9 | value: '1', 10 | label: '1', 11 | }, 12 | { 13 | value: '2', 14 | label: '2', 15 | }, 16 | ], 17 | }, 18 | { 19 | value: '2', 20 | label: '2', 21 | children: [ 22 | { 23 | value: '1', 24 | label: '1', 25 | }, 26 | { 27 | value: '2', 28 | label: '2', 29 | }, 30 | { 31 | value: '3', 32 | label: '3', 33 | }, 34 | ], 35 | }, 36 | ]; 37 | 38 | export const addressOptions: BaseOptionType[] = [ 39 | { 40 | label: '福建', 41 | value: 'fj', 42 | children: [ 43 | { 44 | label: '福州', 45 | value: 'fuzhou', 46 | children: [ 47 | { 48 | label: '马尾', 49 | value: 'mawei', 50 | }, 51 | ], 52 | }, 53 | { 54 | label: '泉州', 55 | value: 'quanzhou', 56 | }, 57 | ], 58 | }, 59 | { 60 | label: '浙江', 61 | value: 'zj', 62 | children: [ 63 | { 64 | label: '杭州', 65 | value: 'hangzhou', 66 | children: [ 67 | { 68 | label: '余杭', 69 | value: 'yuhang', 70 | }, 71 | ], 72 | }, 73 | { 74 | label: '福州', 75 | value: 'fuzhou', 76 | children: [ 77 | { 78 | label: '马尾', 79 | value: 'mawei', 80 | }, 81 | ], 82 | }, 83 | ], 84 | }, 85 | { 86 | label: '北京', 87 | value: 'bj', 88 | children: [ 89 | { 90 | label: '朝阳区', 91 | value: 'chaoyang', 92 | }, 93 | { 94 | label: '海淀区', 95 | value: 'haidian', 96 | }, 97 | ], 98 | }, 99 | ]; 100 | 101 | export const addressOptionsForFieldNames = [ 102 | { 103 | name: '福建', 104 | code: 'fj', 105 | nodes: [ 106 | { 107 | name: '福州', 108 | code: 'fuzhou', 109 | nodes: [ 110 | { 111 | name: '马尾', 112 | code: 'mawei', 113 | }, 114 | ], 115 | }, 116 | { 117 | name: '泉州', 118 | code: 'quanzhou', 119 | }, 120 | ], 121 | }, 122 | { 123 | name: '浙江', 124 | code: 'zj', 125 | nodes: [ 126 | { 127 | name: '杭州', 128 | code: 'hangzhou', 129 | nodes: [ 130 | { 131 | name: '余杭', 132 | code: 'yuhang', 133 | }, 134 | ], 135 | }, 136 | ], 137 | }, 138 | { 139 | name: '北京', 140 | code: 'bj', 141 | nodes: [ 142 | { 143 | name: '朝阳区', 144 | code: 'chaoyang', 145 | }, 146 | { 147 | name: '海淀区', 148 | code: 'haidian', 149 | }, 150 | ], 151 | }, 152 | ]; 153 | 154 | // Uneven 155 | export const addressOptionsForUneven = [ 156 | ...addressOptions, 157 | { 158 | label: '台湾', 159 | value: 'tw', 160 | children: [ 161 | { 162 | label: '台北', 163 | value: 'taipei', 164 | children: [ 165 | { 166 | label: '中正区', 167 | value: 'zhongzheng', 168 | }, 169 | ], 170 | }, 171 | { 172 | label: '高雄', 173 | value: 'gaoxiong', 174 | }, 175 | ], 176 | }, 177 | { 178 | label: '香港', 179 | value: 'xg', 180 | }, 181 | ]; 182 | -------------------------------------------------------------------------------- /tests/enzyme.ts: -------------------------------------------------------------------------------- 1 | import { mount as originMount } from 'enzyme'; 2 | import type { ReactWrapper as OriginReactWrapper } from 'enzyme'; 3 | 4 | type ReplaceReturnType any, TNewReturn> = ( 5 | ...a: Parameters 6 | ) => TNewReturn; 7 | 8 | export interface ReactWrapper extends OriginReactWrapper { 9 | isOpen: () => boolean; 10 | clickOption: ( 11 | menuIndex: number, 12 | itemIndex: number, 13 | type?: 'click' | 'doubleClick' | 'mouseEnter', 14 | ) => ReactWrapper; 15 | } 16 | 17 | type Mount = ReplaceReturnType; 18 | 19 | export const mount = originMount as Mount; 20 | -------------------------------------------------------------------------------- /tests/fieldNames.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from './enzyme'; 3 | import Cascader from '../src'; 4 | 5 | describe('Cascader.FieldNames', () => { 6 | const options = [ 7 | { 8 | customTitle: 'Light', 9 | customValue: 'light', 10 | }, 11 | { 12 | customTitle: 'Bamboo', 13 | customValue: 'bamboo', 14 | customChildren: [ 15 | { 16 | customTitle: 'Little', 17 | customValue: 'little', 18 | customChildren: [ 19 | { 20 | customTitle: 'Toy', 21 | customValue: 'toy', 22 | }, 23 | ], 24 | }, 25 | ], 26 | }, 27 | ]; 28 | 29 | const fieldNames = { 30 | label: 'customTitle', 31 | value: 'customValue', 32 | children: 'customChildren', 33 | } as const; 34 | 35 | it('customize', () => { 36 | const onChange = jest.fn(); 37 | const wrapper = mount( 38 | , 39 | ); 40 | 41 | // Open 42 | wrapper.find('.rc-cascader-selector').simulate('mousedown'); 43 | expect(wrapper.isOpen()).toBeTruthy(); 44 | 45 | // Check values 46 | expect(wrapper.find('.rc-cascader-menu')).toHaveLength(1); 47 | expect(wrapper.find('.rc-cascader-menu').at(0).find('.rc-cascader-menu-item')).toHaveLength(2); 48 | 49 | // Click Bamboo 50 | wrapper.clickOption(0, 1); 51 | expect(wrapper.find('.rc-cascader-menu')).toHaveLength(2); 52 | expect(wrapper.find('.rc-cascader-menu').at(1).find('.rc-cascader-menu-item')).toHaveLength(1); 53 | 54 | // Click Little & Toy 55 | wrapper.clickOption(1, 0); 56 | wrapper.clickOption(2, 0); 57 | 58 | expect(onChange).toHaveBeenCalledWith( 59 | ['bamboo', 'little', 'toy'], 60 | [ 61 | expect.objectContaining({ customTitle: 'Bamboo', customValue: 'bamboo' }), 62 | expect.objectContaining({ customTitle: 'Little', customValue: 'little' }), 63 | expect.objectContaining({ customTitle: 'Toy', customValue: 'toy' }), 64 | ], 65 | ); 66 | }); 67 | 68 | it('defaultValue', () => { 69 | const wrapper = mount( 70 | , 77 | ); 78 | 79 | expect(wrapper.find('.rc-cascader-selection-item').text()).toEqual('Bamboo / Little / Toy'); 80 | 81 | expect(wrapper.find('.rc-cascader-menu')).toHaveLength(3); 82 | expect(wrapper.find('.rc-cascader-menu-item-active')).toHaveLength(3); 83 | expect(wrapper.find('.rc-cascader-menu-item-active').at(0).text()).toEqual('Bamboo'); 84 | expect(wrapper.find('.rc-cascader-menu-item-active').at(1).text()).toEqual('Little'); 85 | expect(wrapper.find('.rc-cascader-menu-item-active').at(2).text()).toEqual('Toy'); 86 | }); 87 | 88 | it('displayRender', () => { 89 | const wrapper = mount( 90 | 95 | `${labels.join('->')} & ${selectOptions?.map(opt => opt.customValue).join('>>')}` 96 | } 97 | />, 98 | ); 99 | 100 | expect(wrapper.find('.rc-cascader-selection-item').text()).toEqual( 101 | 'Bamboo->Little->Toy & bamboo>>little>>toy', 102 | ); 103 | }); 104 | 105 | it('same title & value should show correct title', () => { 106 | const wrapper = mount( 107 | , 116 | ); 117 | 118 | expect(wrapper.find('.rc-cascader-menu-item').last().text()).toEqual('little'); 119 | }); 120 | 121 | it('empty should correct when label same as value', () => { 122 | const wrapper = mount( 123 | , 132 | ); 133 | 134 | expect(wrapper.find('.rc-cascader-menu-item').last().text()).toEqual('Not Found'); 135 | }); 136 | 137 | it('`null` is a value in fieldNames options should throw a warning', () => { 138 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => null); 139 | mount( 140 | , 161 | ); 162 | 163 | expect(errorSpy).toHaveBeenCalledWith( 164 | 'Warning: `value` in Cascader options should not be `null`.', 165 | ); 166 | errorSpy.mockReset(); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /tests/keyboard.spec.tsx: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import KeyCode from '@rc-component/util/lib/KeyCode'; 3 | import type { CascaderProps } from '../src'; 4 | import Cascader from '../src'; 5 | import { addressOptions } from './demoOptions'; 6 | import React from 'react'; 7 | 8 | describe('Cascader.Keyboard', () => { 9 | let wrapper: any; 10 | let selectedValue: any; 11 | let selectedOptions: any; 12 | let menus; 13 | const onChange: CascaderProps['onChange'] = (value, options) => { 14 | selectedValue = value; 15 | selectedOptions = options; 16 | }; 17 | 18 | beforeEach(() => { 19 | wrapper = mount(); 20 | }); 21 | 22 | afterEach(() => { 23 | selectedValue = null; 24 | selectedOptions = null; 25 | menus = null; 26 | }); 27 | 28 | [ 29 | // Space 30 | ['space', KeyCode.SPACE], 31 | // Enter 32 | ['enter', KeyCode.ENTER], 33 | ].forEach(([name, which]) => { 34 | it(`${name} to open`, () => { 35 | wrapper.find('input').simulate('keyDown', { which }); 36 | expect(wrapper.isOpen()).toBeTruthy(); 37 | 38 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ESC }); 39 | expect(wrapper.isOpen()).toBeFalsy(); 40 | }); 41 | }); 42 | 43 | it('should have keyboard support', () => { 44 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 45 | menus = wrapper.find('.rc-cascader-menu'); 46 | expect(wrapper.isOpen()).toBeTruthy(); 47 | expect(menus.length).toBe(1); 48 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 49 | menus = wrapper.find('.rc-cascader-menu'); 50 | expect(menus.length).toBe(2); 51 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 52 | menus = wrapper.find('.rc-cascader-menu'); 53 | expect(menus.length).toBe(3); 54 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 55 | menus = wrapper.find('.rc-cascader-menu'); 56 | expect(menus.length).toBe(3); 57 | wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); 58 | menus = wrapper.find('.rc-cascader-menu'); 59 | expect(menus.length).toBe(3); 60 | wrapper.find('input').simulate('keyDown', { which: KeyCode.QUESTION_MARK }); 61 | menus = wrapper.find('.rc-cascader-menu'); 62 | expect(menus.length).toBe(3); 63 | wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); 64 | menus = wrapper.find('.rc-cascader-menu'); 65 | expect(menus.length).toBe(2); 66 | expect(wrapper.find('.rc-cascader-menu-item-active').at(0).text()).toBe( 67 | addressOptions[0].label, 68 | ); 69 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 70 | menus = wrapper.find('.rc-cascader-menu'); 71 | expect(menus.length).toBe(2); 72 | expect(wrapper.find('.rc-cascader-menu-item-active').at(0).text()).toBe( 73 | addressOptions[1].label, 74 | ); 75 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 76 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 77 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 78 | expect(wrapper.isOpen()).toBeFalsy(); 79 | expect(selectedValue).toEqual(['zj', 'hangzhou', 'yuhang']); 80 | expect(selectedOptions).toEqual([ 81 | addressOptions[1], 82 | addressOptions[1]?.children?.[0], 83 | addressOptions[1]?.children?.[0]?.children?.[0], 84 | ]); 85 | }); 86 | 87 | it('enter on search', () => { 88 | wrapper.find('input').simulate('change', { target: { value: '余杭' } }); 89 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 90 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 91 | 92 | expect(selectedValue).toEqual(['zj', 'hangzhou', 'yuhang']); 93 | expect(selectedOptions).toEqual([ 94 | addressOptions[1], 95 | addressOptions[1]?.children?.[0], 96 | addressOptions[1]?.children?.[0]?.children?.[0], 97 | ]); 98 | }); 99 | it('enter on search when has same sub key', () => { 100 | wrapper.find('input').simulate('change', { target: { value: '福' } }); 101 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 102 | expect(wrapper.find('.rc-cascader-menu-item-active').length).toBe(1); 103 | expect( 104 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 105 | ).toEqual('福建 / 福州 / 马尾'); 106 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 107 | expect(wrapper.find('.rc-cascader-menu-item-active').length).toBe(1); 108 | expect( 109 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 110 | ).toEqual('福建 / 泉州'); 111 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 112 | expect(wrapper.find('.rc-cascader-menu-item-active').length).toBe(1); 113 | expect( 114 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 115 | ).toEqual('浙江 / 福州 / 马尾'); 116 | }); 117 | 118 | it('rtl', () => { 119 | wrapper = mount(); 120 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 121 | expect(wrapper.isOpen()).toBeTruthy(); 122 | 123 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 124 | expect( 125 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 126 | ).toEqual('福建'); 127 | 128 | wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); 129 | expect( 130 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 131 | ).toEqual('福州'); 132 | 133 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 134 | expect( 135 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 136 | ).toEqual('福建'); 137 | 138 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 139 | expect(wrapper.isOpen()).toBeFalsy(); 140 | }); 141 | 142 | describe('up', () => { 143 | it('Select last enabled', () => { 144 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 145 | expect(wrapper.isOpen()).toBeTruthy(); 146 | 147 | wrapper.find('input').simulate('keyDown', { which: KeyCode.UP }); 148 | expect( 149 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 150 | ).toEqual('北京'); 151 | }); 152 | 153 | it('ignore disabled item', () => { 154 | wrapper = mount( 155 | , 176 | ); 177 | 178 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 179 | wrapper.find('input').simulate('keyDown', { which: KeyCode.UP }); 180 | expect( 181 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content').last().text(), 182 | ).toEqual('Little'); 183 | }); 184 | }); 185 | 186 | it('should have close menu when press some keys', () => { 187 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 188 | expect(wrapper.isOpen()).toBeTruthy(); 189 | wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); 190 | expect(wrapper.isOpen()).toBeFalsy(); 191 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 192 | expect(wrapper.isOpen()).toBeTruthy(); 193 | wrapper.find('input').simulate('keyDown', { which: KeyCode.BACKSPACE }); 194 | expect(wrapper.isOpen()).toBeFalsy(); 195 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 196 | expect(wrapper.isOpen()).toBeTruthy(); 197 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 198 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ESC }); 199 | expect(wrapper.isOpen()).toBeFalsy(); 200 | }); 201 | 202 | it('should call the Cascader onKeyDown callback in all cases', () => { 203 | const onKeyDown = jest.fn(); 204 | 205 | wrapper = mount( 206 | , 207 | ); 208 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 209 | expect(wrapper.isOpen()).toBeTruthy(); 210 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ESC }); 211 | expect(wrapper.isOpen()).toBeFalsy(); 212 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 213 | 214 | expect(onKeyDown).toHaveBeenCalledTimes(3); 215 | }); 216 | 217 | it('changeOnSelect', () => { 218 | wrapper = mount(); 219 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 220 | expect(wrapper.isOpen()).toBeTruthy(); 221 | 222 | // 0-0 223 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 224 | 225 | // 0-0-0 226 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 227 | 228 | // Select 229 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 230 | expect(wrapper.isOpen()).toBeFalsy(); 231 | expect(selectedValue).toEqual(['fj', 'fuzhou']); 232 | }); 233 | 234 | it('all disabled should not crash', () => { 235 | wrapper = mount( 236 | ({ ...opt, disabled: true }))} 238 | onChange={onChange} 239 | changeOnSelect 240 | />, 241 | ); 242 | for (let i = 0; i < 10; i += 1) { 243 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 244 | } 245 | expect( 246 | wrapper.find('.rc-cascader-menu-item-active .rc-cascader-menu-item-content'), 247 | ).toHaveLength(0); 248 | }); 249 | 250 | it('should not switch column when press left/right key in search input', () => { 251 | wrapper = mount(); 252 | wrapper.find('input').simulate('change', { 253 | target: { 254 | value: '123', 255 | }, 256 | }); 257 | wrapper.find('input').simulate('keyDown', { which: KeyCode.LEFT }); 258 | expect(wrapper.isOpen()).toBeTruthy(); 259 | wrapper.find('input').simulate('keyDown', { which: KeyCode.RIGHT }); 260 | expect(wrapper.isOpen()).toBeTruthy(); 261 | }); 262 | 263 | // TODO: This is strange that we need check on this 264 | it.skip('should not handle keyDown events when children specify the onKeyDown', () => { 265 | wrapper = mount( 266 | 267 | {}} /> 268 | , 269 | ); 270 | 271 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 272 | expect(wrapper.isOpen()).toBeFalsy(); 273 | }); 274 | }); 275 | -------------------------------------------------------------------------------- /tests/loadData.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act } from 'react-dom/test-utils'; 3 | import { mount } from './enzyme'; 4 | import type { CascaderProps } from '../src'; 5 | import Cascader from '../src'; 6 | 7 | describe('Cascader.LoadData', () => { 8 | beforeEach(() => { 9 | jest.useFakeTimers(); 10 | }); 11 | 12 | afterEach(() => { 13 | jest.useRealTimers(); 14 | }); 15 | 16 | it('basic load', () => { 17 | const loadData = jest.fn(); 18 | const wrapper = mount( 19 | } 21 | options={[ 22 | { 23 | label: 'Bamboo', 24 | value: 'bamboo', 25 | isLeaf: false, 26 | }, 27 | ]} 28 | loadData={loadData} 29 | open 30 | />, 31 | ); 32 | 33 | wrapper.find('.rc-cascader-menu-item-content').first().simulate('click'); 34 | expect(wrapper.exists('.loading-icon')).toBeTruthy(); 35 | expect(loadData).toHaveBeenCalledWith([ 36 | expect.objectContaining({ 37 | value: 'bamboo', 38 | }), 39 | ]); 40 | 41 | expect(wrapper.exists('.rc-cascader-menu-item-loading')).toBeTruthy(); 42 | expect(wrapper.exists('.rc-cascader-menu-item-loading-icon')).toBeTruthy(); 43 | 44 | // Fill data 45 | wrapper.setProps({ 46 | options: [ 47 | { 48 | label: 'Bamboo', 49 | value: 'bamboo', 50 | isLeaf: false, 51 | children: [], 52 | }, 53 | ], 54 | }); 55 | wrapper.update(); 56 | expect(wrapper.exists('.loading-icon')).toBeFalsy(); 57 | }); 58 | 59 | it('not load leaf', () => { 60 | const loadData = jest.fn(); 61 | const onValueChange = jest.fn(); 62 | const wrapper = mount( 63 | , 74 | ); 75 | 76 | wrapper.clickOption(0, 0); 77 | expect(onValueChange).toHaveBeenCalled(); 78 | expect(loadData).not.toHaveBeenCalled(); 79 | }); 80 | 81 | // https://github.com/ant-design/ant-design/issues/9084 82 | it('should trigger loadData when expandTrigger is hover', () => { 83 | const options = [ 84 | { 85 | value: 'zhejiang', 86 | label: 'Zhejiang', 87 | isLeaf: false, 88 | }, 89 | { 90 | value: 'jiangsu', 91 | label: 'Jiangsu', 92 | isLeaf: false, 93 | }, 94 | ]; 95 | const loadData = jest.fn(); 96 | const wrapper = mount( 97 | 98 | 99 | , 100 | ); 101 | wrapper.find('input').simulate('click'); 102 | const menus = wrapper.find('.rc-cascader-menu'); 103 | const menu1Items = menus.at(0).find('.rc-cascader-menu-item'); 104 | menu1Items.at(0).simulate('mouseEnter'); 105 | jest.runAllTimers(); 106 | expect(loadData).toHaveBeenCalled(); 107 | }); 108 | 109 | it('change isLeaf back to true should not loop loading', async () => { 110 | const Demo = () => { 111 | const [options, setOptions] = React.useState([ 112 | { value: 'zhejiang', label: 'Zhejiang', isLeaf: false }, 113 | ]); 114 | 115 | const loadData = () => { 116 | Promise.resolve().then(() => { 117 | act(() => { 118 | setOptions([ 119 | { 120 | value: 'zhejiang', 121 | label: 'Zhejiang', 122 | isLeaf: true, 123 | }, 124 | ]); 125 | }); 126 | }); 127 | }; 128 | 129 | return ; 130 | }; 131 | 132 | const wrapper = mount(); 133 | wrapper.find('.rc-cascader-menu-item-content').first().simulate('click'); 134 | expect(wrapper.exists('.rc-cascader-menu-item-loading')).toBeTruthy(); 135 | 136 | for (let i = 0; i < 3; i += 1) { 137 | await Promise.resolve(); 138 | } 139 | wrapper.update(); 140 | 141 | expect(wrapper.exists('.rc-cascader-menu-item-loading')).toBeFalsy(); 142 | }); 143 | 144 | it('nest load should not crash', async () => { 145 | const Demo = () => { 146 | const [options, setOptions] = React.useState([{ label: 'top', value: 'top', isLeaf: false }]); 147 | 148 | const loadData: CascaderProps['loadData'] = selectedOptions => { 149 | Promise.resolve().then(() => { 150 | act(() => { 151 | selectedOptions[selectedOptions.length - 1].children = [ 152 | { 153 | label: 'child', 154 | value: 'child', 155 | isLeaf: false, 156 | }, 157 | ]; 158 | setOptions(list => [...list]); 159 | }); 160 | }); 161 | }; 162 | 163 | return ; 164 | }; 165 | 166 | const wrapper = mount(); 167 | 168 | // First column click 169 | wrapper.find('.rc-cascader-menu-item-content').last().simulate('click'); 170 | for (let i = 0; i < 3; i += 1) { 171 | await Promise.resolve(); 172 | } 173 | wrapper.update(); 174 | 175 | // Second column click 176 | wrapper.find('.rc-cascader-menu-item-content').last().simulate('click'); 177 | for (let i = 0; i < 3; i += 1) { 178 | await Promise.resolve(); 179 | } 180 | wrapper.update(); 181 | 182 | expect(wrapper.find('ul.rc-cascader-menu')).toHaveLength(3); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /tests/private.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Cascader from '../src'; 4 | 5 | describe('Cascader.Private', () => { 6 | it('popupPrefixCls', () => { 7 | const { container } = render( 8 | , 26 | ); 27 | 28 | expect(container.querySelector('.bamboo-dropdown')).toBeTruthy(); 29 | expect(container.querySelector('.little-menus')).toBeTruthy(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/search.limit.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Cascader from '../src'; 3 | import type { ReactWrapper } from './enzyme'; 4 | import { mount } from './enzyme'; 5 | 6 | describe('Cascader.Search', () => { 7 | function doSearch(wrapper: ReactWrapper, search: string) { 8 | wrapper.find('input').simulate('change', { 9 | target: { 10 | value: search, 11 | }, 12 | }); 13 | } 14 | const options = [ 15 | { 16 | children: [] as any[], 17 | isParent: true, 18 | label: 'Asia', 19 | value: 'Asia', 20 | }, 21 | ]; 22 | for (let i = 0; i < 100; i++) { 23 | options[0].children.push({ 24 | label: 'label' + i, 25 | value: 'value' + i, 26 | }); 27 | } 28 | 29 | it('limit', () => { 30 | const wrapper = mount( 31 | , 38 | ); 39 | 40 | doSearch(wrapper, 'as'); 41 | const itemList = wrapper.find('div.rc-cascader-menu-item-content'); 42 | expect(itemList).toHaveLength(100); 43 | }); 44 | 45 | it('limit', () => { 46 | const wrapper = mount( 47 | , 54 | ); 55 | 56 | doSearch(wrapper, 'as'); 57 | const itemList = wrapper.find('div.rc-cascader-menu-item-content'); 58 | expect(itemList).toHaveLength(100); 59 | }); 60 | 61 | it('limit', () => { 62 | const wrapper = mount( 63 | , 70 | ); 71 | 72 | doSearch(wrapper, 'as'); 73 | const itemList = wrapper.find('div.rc-cascader-menu-item-content'); 74 | expect(itemList).toHaveLength(20); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/search.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react'; 2 | import KeyCode from '@rc-component/util/lib/KeyCode'; 3 | import { resetWarned } from '@rc-component/util/lib/warning'; 4 | import React from 'react'; 5 | import Cascader from '../src'; 6 | import { optionsForActiveMenuItems } from './demoOptions'; 7 | import type { ReactWrapper } from './enzyme'; 8 | import { mount } from './enzyme'; 9 | 10 | describe('Cascader.Search', () => { 11 | function doSearch(wrapper: ReactWrapper, search: string) { 12 | wrapper.find('input').simulate('change', { 13 | target: { 14 | value: search, 15 | }, 16 | }); 17 | } 18 | 19 | const options = [ 20 | { 21 | label: 'Label Light', 22 | value: 'light', 23 | }, 24 | { 25 | label: 'Label Bamboo', 26 | value: 'bamboo', 27 | children: [ 28 | { 29 | label: 'Label Little', 30 | value: 'little', 31 | children: [ 32 | { 33 | label: 'Toy Fish', 34 | value: 'fish', 35 | // Leave a empty children here. But cascader should think this is a leaf node. 36 | children: [], 37 | }, 38 | { 39 | label: 'Toy Cards', 40 | value: 'cards', 41 | }, 42 | ], 43 | }, 44 | ], 45 | }, 46 | ]; 47 | 48 | it('default search', () => { 49 | const onSearch = jest.fn(); 50 | const onChange = jest.fn(); 51 | const wrapper = mount( 52 | , 53 | ); 54 | 55 | // Leaf 56 | doSearch(wrapper, 'toy'); 57 | let itemList = wrapper.find('div.rc-cascader-menu-item-content'); 58 | expect(itemList).toHaveLength(2); 59 | expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); 60 | expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); 61 | expect(onSearch).toHaveBeenCalledWith('toy'); 62 | 63 | // Parent 64 | doSearch(wrapper, 'Label Little'); 65 | itemList = wrapper.find('div.rc-cascader-menu-item-content'); 66 | expect(itemList).toHaveLength(2); 67 | expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); 68 | expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); 69 | expect(onSearch).toHaveBeenCalledWith('Label Little'); 70 | 71 | // Change 72 | wrapper.clickOption(0, 0); 73 | expect(onChange).toHaveBeenCalledWith(['bamboo', 'little', 'fish'], expect.anything()); 74 | }); 75 | 76 | it('changeOnSelect', () => { 77 | const onChange = jest.fn(); 78 | const wrapper = mount( 79 | , 80 | ); 81 | 82 | // Leaf 83 | doSearch(wrapper, 'Label Little'); 84 | const itemList = wrapper.find('div.rc-cascader-menu-item-content'); 85 | expect(itemList).toHaveLength(3); 86 | expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little'); 87 | expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); 88 | expect(itemList.at(2).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); 89 | 90 | // Should not expandable 91 | expect(wrapper.exists('.rc-cascader-menu-item-expand-icon')).toBeFalsy(); 92 | 93 | // Trigger onChange 94 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 95 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 96 | expect(onChange).toHaveBeenCalledWith(['bamboo', 'little'], expect.anything()); 97 | }); 98 | 99 | it('sort', () => { 100 | const wrapper = mount( 101 | { 106 | const finalA = pathA[pathA.length - 1]; 107 | const finalB = pathB[pathB.length - 1]; 108 | 109 | // this value is string 110 | if ((finalA.value as any) < (finalB.value as any)) { 111 | return -1; 112 | } 113 | return 1; 114 | }, 115 | }} 116 | />, 117 | ); 118 | 119 | doSearch(wrapper, 'toy'); 120 | const itemList = wrapper.find('div.rc-cascader-menu-item-content'); 121 | expect(itemList).toHaveLength(2); 122 | expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Cards'); 123 | expect(itemList.at(1).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); 124 | }); 125 | 126 | it('limit', () => { 127 | const wrapper = mount( 128 | , 135 | ); 136 | 137 | doSearch(wrapper, 'toy'); 138 | const itemList = wrapper.find('div.rc-cascader-menu-item-content'); 139 | expect(itemList).toHaveLength(1); 140 | expect(itemList.at(0).text()).toEqual('Label Bamboo / Label Little / Toy Fish'); 141 | }); 142 | 143 | it('render', () => { 144 | const wrapper = mount( 145 | 150 | `${prefixCls}-${inputValue}-${optList.map(opt => opt.value).join('~')}`, 151 | }} 152 | />, 153 | ); 154 | 155 | doSearch(wrapper, 'toy'); 156 | const itemList = wrapper.find('div.rc-cascader-menu-item-content'); 157 | expect(itemList).toHaveLength(2); 158 | expect(itemList.at(0).text()).toEqual('rc-cascader-toy-bamboo~little~fish'); 159 | expect(itemList.at(1).text()).toEqual('rc-cascader-toy-bamboo~little~cards'); 160 | }); 161 | 162 | it('not crash when empty', () => { 163 | const onChange = jest.fn(); 164 | const wrapper = mount(); 165 | doSearch(wrapper, 'toy'); 166 | 167 | // Selection empty 168 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 169 | expect(onChange).not.toHaveBeenCalled(); 170 | 171 | wrapper.find('input').simulate('keyDown', { which: KeyCode.DOWN }); 172 | wrapper.find('input').simulate('keyDown', { which: KeyCode.ENTER }); 173 | expect(onChange).toHaveBeenCalled(); 174 | 175 | // Content empty 176 | doSearch(wrapper, 'not exist'); 177 | expect(wrapper.exists('.rc-cascader-menu-empty')).toBeTruthy(); 178 | }); 179 | 180 | it('warning of negative limit', () => { 181 | resetWarned(); 182 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 183 | 184 | const wrapper = mount(); 185 | 186 | expect(errorSpy).toHaveBeenCalledWith( 187 | "Warning: 'limit' of showSearch should be positive number or false.", 188 | ); 189 | 190 | doSearch(wrapper, 'toy'); 191 | expect(wrapper.find('div.rc-cascader-menu-item-content')).toHaveLength(2); 192 | 193 | errorSpy.mockRestore(); 194 | }); 195 | 196 | it('onChange should be triggered when click option with changeOnSelect + multiple', () => { 197 | const onChange = jest.fn(); 198 | const wrapper = mount( 199 | , 200 | ); 201 | doSearch(wrapper, 'toy'); 202 | wrapper.find('.rc-cascader-menu-item').first().simulate('click'); 203 | wrapper.find('.rc-cascader-menu-item').first().simulate('mousedown'); 204 | expect(onChange).toHaveBeenCalledWith([['bamboo', 'little', 'fish']], expect.anything()); 205 | 206 | doSearch(wrapper, 'light'); 207 | wrapper.find('.rc-cascader-menu-item').first().simulate('click'); 208 | wrapper.find('.rc-cascader-menu-item').first().simulate('mousedown'); 209 | expect(onChange).toHaveBeenCalledWith( 210 | [['bamboo', 'little', 'fish'], ['light']], 211 | expect.anything(), 212 | ); 213 | }); 214 | 215 | it('onChange should be triggered when click option with multiple', () => { 216 | const onChange = jest.fn(); 217 | const wrapper = mount(); 218 | doSearch(wrapper, 'toy'); 219 | wrapper.find('.rc-cascader-menu-item').first().simulate('click'); 220 | wrapper.find('.rc-cascader-menu-item').first().simulate('mousedown'); 221 | expect(onChange).toHaveBeenCalledWith([['bamboo', 'little', 'fish']], expect.anything()); 222 | 223 | doSearch(wrapper, 'light'); 224 | wrapper.find('.rc-cascader-menu-item').first().simulate('click'); 225 | wrapper.find('.rc-cascader-menu-item').first().simulate('mousedown'); 226 | expect(onChange).toHaveBeenCalledWith( 227 | [['bamboo', 'little', 'fish'], ['light']], 228 | expect.anything(), 229 | ); 230 | }); 231 | 232 | it('should not crash when exist options with same value on different levels', () => { 233 | const wrapper = mount(); 234 | 235 | doSearch(wrapper, '1'); 236 | wrapper.find('.rc-cascader-menu-item').first().simulate('click'); 237 | doSearch(wrapper, '1'); 238 | }); 239 | 240 | it('should correct render Cascader with same field name of label and value', () => { 241 | const customOptions = [ 242 | { 243 | name: 'Zhejiang', 244 | children: [ 245 | { 246 | name: 'Hangzhou', 247 | children: [ 248 | { 249 | name: 'West Lake', 250 | }, 251 | { 252 | name: 'Xia Sha', 253 | disabled: true, 254 | }, 255 | ], 256 | }, 257 | ], 258 | }, 259 | ]; 260 | const wrapper = mount( 261 | 266 | path.some(option => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1), 267 | }} 268 | />, 269 | ); 270 | wrapper.find('input').simulate('change', { target: { value: 'z' } }); 271 | expect(wrapper.render()).toMatchSnapshot(); 272 | }); 273 | 274 | // https://github.com/ant-design/ant-design/issues/41810 275 | it('not back to options when selected', () => { 276 | const { container } = render(); 277 | 278 | // Search 279 | fireEvent.change(container.querySelector('input') as HTMLElement, { 280 | target: { 281 | value: 'bamboo', 282 | }, 283 | }); 284 | 285 | // Click 286 | fireEvent.click(document.querySelector('.rc-cascader-menu-item-content') as HTMLElement); 287 | expect(document.querySelector('.rc-cascader-dropdown-hidden')).toBeTruthy(); 288 | expect(document.querySelector('.rc-cascader-menu-item-content')?.textContent).toBe( 289 | 'Label Bamboo / Label Little / Toy Fish', 290 | ); 291 | }); 292 | 293 | it('autoClearSearchValue={false} should be worked', () => { 294 | const wrapper = mount( 295 | , 296 | ); 297 | 298 | // Search 299 | wrapper.find('input').simulate('change', { target: { value: 'bamboo' } }); 300 | 301 | // Click 302 | wrapper.find('.rc-cascader-checkbox').first().simulate('click'); 303 | expect(wrapper.find('input').prop('value')).toEqual('bamboo'); 304 | }); 305 | 306 | it('disabled path should not search', () => { 307 | const { container } = render( 308 | , 325 | ); 326 | 327 | expect(container.querySelectorAll('.rc-cascader-menu-item')).toHaveLength(1); 328 | expect(container.querySelectorAll('.rc-cascader-menu-item-disabled')).toHaveLength(1); 329 | expect(container.querySelector('.rc-cascader-menu-item-disabled')?.textContent).toEqual( 330 | 'bamboo / little', 331 | ); 332 | }); 333 | it('Should optionRender work', () => { 334 | const { container, rerender } = render( 335 | `${option.label} - test`} 339 | />, 340 | ); 341 | expect(container.querySelector('.rc-cascader-menu-item-content')?.innerHTML).toEqual( 342 | 'bamboo - test', 343 | ); 344 | rerender( 345 | JSON.stringify(option)} 349 | />, 350 | ); 351 | expect(container.querySelector('.rc-cascader-menu-item-content')?.innerHTML).toEqual( 352 | '{"label":"bamboo","disabled":true,"value":"bamboo"}', 353 | ); 354 | }); 355 | }); 356 | -------------------------------------------------------------------------------- /tests/selector.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import { mount } from './enzyme'; 4 | import Cascader from '../src'; 5 | import { addressOptions } from './demoOptions'; 6 | 7 | // Mock `useActive` hook 8 | jest.mock('../src/OptionList/useActive', () => (multiple: boolean, open: boolean) => { 9 | // Pass to origin hooks 10 | const originHook = jest.requireActual('../src/OptionList/useActive').default; 11 | const [activeValueCells, setActiveValueCells] = originHook(multiple, open); 12 | 13 | (global as any).activeValueCells = activeValueCells; 14 | 15 | return [activeValueCells, setActiveValueCells]; 16 | }); 17 | 18 | describe('Cascader.Selector', () => { 19 | describe('clear all', () => { 20 | it('single', () => { 21 | const onChange = jest.fn(); 22 | const { container } = render( 23 | , 24 | ); 25 | 26 | fireEvent.mouseDown(container.querySelector('.rc-cascader-clear-icon') as HTMLElement); 27 | expect(onChange).toHaveBeenCalledWith(undefined, undefined); 28 | }); 29 | 30 | it('Should clear activeCells', () => { 31 | const onChange = jest.fn(); 32 | 33 | const { container } = render( 34 | , 41 | ); 42 | 43 | // Open and select 44 | fireEvent.mouseDown(container.querySelector('.rc-cascader-selector') as HTMLElement); 45 | expect(container.querySelector('.rc-cascader-open')).toBeTruthy(); 46 | 47 | fireEvent.click(container.querySelector('.rc-cascader-menu-item-content') as HTMLElement); 48 | fireEvent.click(container.querySelectorAll('.rc-cascader-menu-item-content')[1]); 49 | expect(container.querySelector('.rc-cascader-open')).toBeFalsy(); 50 | 51 | // Clear 52 | fireEvent.mouseDown(container.querySelector('.rc-cascader-clear-icon') as HTMLElement); 53 | expect((global as any).activeValueCells).toEqual([]); 54 | }); 55 | 56 | it('multiple', () => { 57 | const onChange = jest.fn(); 58 | const wrapper = mount( 59 | , 60 | ); 61 | 62 | wrapper.find('.rc-cascader-clear-icon').simulate('mouseDown'); 63 | expect(onChange).toHaveBeenCalledWith([], []); 64 | }); 65 | }); 66 | 67 | it('remove selector', () => { 68 | const onChange = jest.fn(); 69 | const wrapper = mount( 70 | , 71 | ); 72 | 73 | wrapper.find('.rc-cascader-selection-item-remove-icon').first().simulate('click'); 74 | expect(onChange).toHaveBeenCalledWith([['exist']], expect.anything()); 75 | }); 76 | 77 | it('avoid reuse', () => { 78 | const Tag: React.FC = ({ children, onClose }) => { 79 | const [visible, setVisible] = useState(true); 80 | return ( 81 | 90 | ); 91 | }; 92 | 93 | const wrapper = mount( 94 | { 104 | wrapper.setProps({ 105 | value: values, 106 | }); 107 | }} 108 | tagRender={({ label, onClose }) => ( 109 | 110 | {label} 111 | 112 | )} 113 | checkable 114 | />, 115 | ); 116 | 117 | for (let i = 5; i > 0; i--) { 118 | const buttons = wrapper.find('button'); 119 | expect(buttons.length).toBe(i); 120 | buttons.first().simulate('click'); 121 | wrapper.update(); 122 | expect(wrapper.find('.reuse').length).toBe(0); 123 | } 124 | }); 125 | 126 | it('when selected modify options', () => { 127 | const wrapper = mount(); 128 | 129 | // First column click 130 | wrapper.find('.rc-cascader-menu-item-content').first().simulate('click'); 131 | wrapper.update(); 132 | 133 | // Second column click 134 | wrapper.find('.rc-cascader-menu-item-content').last().simulate('click'); 135 | wrapper.update(); 136 | 137 | wrapper.setProps({ 138 | options: [{ label: '福建', value: 'fj', isLeaf: false }], 139 | }); 140 | 141 | wrapper.update(); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /tests/semantic.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import React from 'react'; 4 | import Cascader from '../src'; 5 | 6 | describe('Cascader.Search', () => { 7 | it('Should support semantic', () => { 8 | const testClassNames = { 9 | prefix: 'test-prefix', 10 | suffix: 'test-suffix', 11 | input: 'test-input', 12 | popup: { 13 | list: 'test-popup-list', 14 | listItem: 'test-popup-list-item', 15 | }, 16 | }; 17 | const testStyles = { 18 | prefix: { color: 'green' }, 19 | suffix: { color: 'blue' }, 20 | input: { color: 'purple' }, 21 | popup: { 22 | list: { background: 'red' }, 23 | listItem: { color: 'yellow' }, 24 | }, 25 | }; 26 | const { container } = render( 27 | 'icon'} 32 | open 33 | options={[{ label: 'bamboo', value: 'bamboo' }]} 34 | optionRender={option => `${option.label} - test`} 35 | />, 36 | ); 37 | const input = container.querySelector('.rc-cascader-selection-search-input'); 38 | const prefix = container.querySelector('.rc-cascader-prefix'); 39 | const suffix = container.querySelector('.rc-cascader-arrow'); 40 | const list = container.querySelector('.rc-cascader-menu'); 41 | const listItem = container.querySelector('.rc-cascader-menu-item'); 42 | expect(input).toHaveStyle(testStyles.input); 43 | expect(prefix).toHaveStyle(testStyles.prefix); 44 | expect(suffix).toHaveStyle(testStyles.suffix); 45 | expect(list).toHaveStyle(testStyles.popup.list); 46 | expect(listItem).toHaveStyle(testStyles.popup.listItem); 47 | expect(input?.className).toContain(testClassNames.input); 48 | expect(prefix?.className).toContain(testClassNames.prefix); 49 | expect(suffix?.className).toContain(testClassNames.suffix); 50 | expect(list?.className).toContain(testClassNames.popup.list); 51 | expect(listItem?.className).toContain(testClassNames.popup.listItem); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = 2 | global.requestAnimationFrame || 3 | function requestAnimationFrame(cb) { 4 | return setTimeout(cb, 0); 5 | }; 6 | 7 | const Enzyme = require('enzyme'); 8 | const Adapter = require('enzyme-adapter-react-16'); 9 | 10 | Enzyme.configure({ adapter: new Adapter() }); 11 | 12 | Object.assign(Enzyme.ReactWrapper.prototype, { 13 | isOpen() { 14 | return !!this.find('Trigger').props().popupVisible; 15 | }, 16 | findOption(menuIndex, itemIndex) { 17 | const menu = this.find('ul.rc-cascader-menu').at(menuIndex); 18 | const itemList = menu.find('li.rc-cascader-menu-item'); 19 | 20 | return itemList.at(itemIndex); 21 | }, 22 | clickOption(menuIndex, itemIndex, type = 'click') { 23 | this.findOption(menuIndex, itemIndex).simulate(type); 24 | 25 | return this; 26 | }, 27 | }); 28 | 29 | window.HTMLElement.prototype.scrollIntoView = jest.fn(); 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "baseUrl": "./", 6 | "jsx": "react", 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "paths": { 12 | "@/*": ["src/*"], 13 | "@@/*": ["src/.umi/*"], 14 | "@rc-component/cascader": ["src/index.ts"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /update-example.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('./examples/*.jsx'); 11 | 12 | paths.forEach(path => { 13 | const name = path.split('/').pop().split('.')[0]; 14 | fs.writeFile( 15 | `./docs/demo/${name}.md`, 16 | `## ${name} 17 | 18 | 19 | `, 20 | 'utf8', 21 | function(error) { 22 | if(error){ 23 | console.log(error); 24 | return false; 25 | } 26 | console.log(`${name} 更新成功~`); 27 | } 28 | ) 29 | }); 30 | --------------------------------------------------------------------------------