diff --git a/.dumirc.ts b/.dumirc.ts new file mode 100644 index 00000000..f140eae2 --- /dev/null +++ b/.dumirc.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'dumi'; + +export default defineConfig({ + favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], + themeConfig: { + name: 'tree-select', + logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', + }, + exportStatic: {}, + base: '/', + publicPath: '/', + styles: [ + ` + .markdown table { + width: auto !important; + } + `, + ], +}); diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..d0a9746f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..d2a5f966 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "41 11 * * 2" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ javascript ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/react-component-ci.yml b/.github/workflows/react-component-ci.yml index 432a3fb3..f860ff10 100644 --- a/.github/workflows/react-component-ci.yml +++ b/.github/workflows/react-component-ci.yml @@ -1,114 +1,6 @@ -name: CI - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - +name: ✅ test +on: [push, pull_request] jobs: - setup: - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@master - - - uses: actions/setup-node@v1 - with: - node-version: '12' - - - name: cache package-lock.json - uses: actions/cache@v2 - with: - path: package-temp-dir - key: lock-${{ github.sha }} - - - name: create package-lock.json - run: npm i --package-lock-only - - - name: hack for singe file - run: | - if [ ! -d "package-temp-dir" ]; then - mkdir package-temp-dir - fi - cp package-lock.json package-temp-dir - - - name: cache node_modules - id: node_modules_cache_id - uses: actions/cache@v2 - with: - path: node_modules - key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} - - - name: install - if: steps.node_modules_cache_id.outputs.cache-hit != 'true' - run: npm ci - - lint: - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@master - - - name: restore cache from package-lock.json - uses: actions/cache@v2 - with: - path: package-temp-dir - key: lock-${{ github.sha }} - - - name: restore cache from node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} - - - name: lint - run: npm run lint - - needs: setup - - compile: - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@master - - - name: restore cache from package-lock.json - uses: actions/cache@v2 - with: - path: package-temp-dir - key: lock-${{ github.sha }} - - - name: restore cache from node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} - - - name: compile - run: npm run compile - - needs: setup - - coverage: - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@master - - - name: restore cache from package-lock.json - uses: actions/cache@v2 - with: - path: package-temp-dir - key: lock-${{ github.sha }} - - - name: restore cache from node_modules - uses: actions/cache@v2 - with: - path: node_modules - key: node_modules-${{ hashFiles('**/package-temp-dir/package-lock.json') }} - - - name: coverage - run: npm test -- --coverage && bash <(curl -s https://codecov.io/bash) - - needs: setup + test: + uses: react-component/rc-test/.github/workflows/test.yml@main + secrets: inherit diff --git a/.gitignore b/.gitignore index 22062024..e0ae8dea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.storybook .doc *.iml *.log @@ -24,13 +23,17 @@ node_modules dist *.css build -lib/* +lib coverage +.vscode yarn.lock package-lock.json -es/* -# umi -.umi -.umi-production -.umi-test +pnpm-lock.yaml +es +# dumi +.dumi/tmp +.dumi/tmp-test +.dumi/tmp-production .env.local + +bun.lockb \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..af5adff9 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +lint-staged \ No newline at end of file diff --git a/.umirc.ts b/.umirc.ts deleted file mode 100644 index d0bc2b08..00000000 --- a/.umirc.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'dumi'; - -export default defineConfig({ - title: 'rc-tree-select', - favicon: - 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', - logo: - 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', - exportStatic: {}, - outputPath: '.doc', - resolve: { - examples: ['none'], - }, - styles: [ - ` - .markdown table { - width: auto !important; - } - `, - ] -}); diff --git a/README.md b/README.md index 71313b88..59489d16 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,38 @@ -# rc-tree-select - -React TreeSelect Component - -[![NPM version][npm-image]][npm-url] [![dumi](https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square)](https://github.com/umijs/dumi) [![build status][github-actions-image]][github-actions-url] [![Test coverage][coveralls-image]][coveralls-url] [![Dependencies][david-image]][david-url] [![DevDependencies][david-dev-image]][david-dev-url] [![npm download][download-image]][download-url] [![bundle size][bundlephobia-image]][bundlephobia-url] - -[npm-image]: http://img.shields.io/npm/v/rc-tree-select.svg?style=flat-square -[npm-url]: http://npmjs.org/package/rc-tree-select -[github-actions-image]: https://github.com/react-component/tree-select/workflows/CI/badge.svg -[github-actions-url]: https://github.com/react-component/tree-select/actions -[circleci-image]: https://img.shields.io/circleci/react-component/tree-select/master?style=flat-square -[circleci-url]: https://circleci.com/gh/react-component/tree-select -[coveralls-image]: https://img.shields.io/coveralls/react-component/tree-select.svg?style=flat-square -[coveralls-url]: https://coveralls.io/r/react-component/tree-select?branch=master +# @rc-component/tree-select + +React TreeSelect Component. + + +[![NPM version][npm-image]][npm-url] +[![npm download][download-image]][download-url] +[![build status][github-actions-image]][github-actions-url] +[![Codecov][codecov-image]][codecov-url] +[![bundle size][bundlephobia-image]][bundlephobia-url] +[![dumi][dumi-image]][dumi-url] + +[npm-image]: http://img.shields.io/npm/v/@rc-component/tree-select.svg?style=flat-square +[npm-url]: http://npmjs.org/package/@rc-component/tree-select +[travis-image]: https://img.shields.io/travis/react-component/tree-select/master?style=flat-square +[travis-url]: https://travis-ci.com/react-component/tree-select +[github-actions-image]: https://github.com/react-component/tree-select/actions/workflows/react-component-ci.yml/badge.svg +[github-actions-url]: https://github.com/react-component/tree-select/actions/workflows/react-component-ci.yml +[codecov-image]: https://img.shields.io/codecov/c/github/react-component/tree-select/master.svg?style=flat-square +[codecov-url]: https://app.codecov.io/gh/react-component/tree-select [david-url]: https://david-dm.org/react-component/tree-select [david-image]: https://david-dm.org/react-component/tree-select/status.svg?style=flat-square [david-dev-url]: https://david-dm.org/react-component/tree-select?type=dev [david-dev-image]: https://david-dm.org/react-component/tree-select/dev-status.svg?style=flat-square -[download-image]: https://img.shields.io/npm/dm/rc-tree-select.svg?style=flat-square -[download-url]: https://npmjs.org/package/rc-tree-select -[bundlephobia-url]: https://bundlephobia.com/result?p=rc-tree-select -[bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-tree-select +[download-image]: https://img.shields.io/npm/dm/@rc-component/tree-select.svg?style=flat-square +[download-url]: https://npmjs.org/package/@rc-component/tree-select +[bundlephobia-url]: https://bundlephobia.com/package/@rc-component/tree-select +[bundlephobia-image]: https://badgen.net/bundlephobia/minzip/@rc-component/tree-select +[dumi-url]: https://github.com/umijs/dumi +[dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square ## Screenshots - ## Development ``` @@ -41,81 +48,83 @@ online example: https://tree-select-react-component.vercel.app/ ## install -[![rc-tree-select](https://nodei.co/npm/rc-tree-select.png)](https://npmjs.org/package/rc-tree-select) +[![@rc-component/tree-select](https://nodei.co/npm/@rc-component/tree-select.png)](https://npmjs.org/package/@rc-component/tree-select) ## API ### TreeSelect props -| name | description | type | default | -|----------|----------------|----------|--------------| -|className | additional css class of root dom node | String | '' | -|prefixCls | prefix class | String | '' | -|animation | dropdown animation name. only support slide-up now | String | '' | -|transitionName | dropdown css animation name | String | '' | -|choiceTransitionName | css animation name for selected items at multiple mode | String | '' | -|dropdownMatchSelectWidth | whether dropdown's with is same with select. Default set `min-width` same as input | bool | true | -|dropdownClassName | additional className applied to dropdown | String | - | -|dropdownStyle | additional style applied to dropdown | Object | {} | -|dropdownPopupAlign | specify alignment for dropdown (alignConfig of [dom-align](https://github.com/yiminghe/dom-align)) | Object | - | -|onDropdownVisibleChange | control dropdown visible | function | `() => { return true; }` | -|notFoundContent | specify content to show when no result matches. | String | 'Not Found' | -|showSearch | whether show search input in single mode | bool | true | -|allowClear | whether allowClear | bool | false | -|maxTagTextLength | max tag text length to show | number | - | -|maxTagCount | max tag count to show | number | - | -|maxTagPlaceholder | placeholder for omitted values | ReactNode/function(omittedValues) | - | -|multiple | whether multiple select (true when enable treeCheckable) | bool | false | -|disabled | whether disabled select | bool | false | -|searchValue | work with `onSearch` to make search value controlled. | string | '' | -|defaultValue | initial selected treeNode(s) | same as value type | - | -|value | current selected treeNode(s). | normal: String/Array. labelInValue: {value:String,label:React.Node}/Array<{value,label}>. treeCheckStrictly(halfChecked default false): {value:String,label:React.Node, halfChecked}/Array<{value,label,halfChecked}>. | - | -|labelInValue| whether to embed label in value, see above value type | Bool | false | -|onChange | called when select treeNode or input value change | function(value, label(null), extra) | - | -|onSelect | called when select treeNode | function(value, node, extra) | - | -|onSearch | called when input changed | function | - | -|onTreeExpand | called when tree node expand | function(expandedKeys) | - | -|showCheckedStrategy | `TreeSelect.SHOW_ALL`: show all checked treeNodes (Include parent treeNode). `TreeSelect.SHOW_PARENT`: show checked treeNodes (Just show parent treeNode). Default just show child. | enum{TreeSelect.SHOW_ALL, TreeSelect.SHOW_PARENT, TreeSelect.SHOW_CHILD } | TreeSelect.SHOW_CHILD | -|treeIcon | show tree icon | bool | false | -|treeLine | show tree line | bool | false | -|treeDefaultExpandAll | default expand all treeNode | bool | false | -|treeDefaultExpandedKeys | default expanded treeNode keys | Array | - | -|treeExpandedKeys | set tree expanded keys | Array | - | -|treeExpandAction | Tree open logic, optional: false \| `click` \| `doubleClick`, same as `expandAction` of `rc-tree` | string \| boolean | `click` | -|treeCheckable | whether tree show checkbox (select callback will not fire) | bool | false | -|treeCheckStrictly | check node precisely, parent and children nodes are not associated| bool | false | -|filterTreeNode | whether filter treeNodes by input value. default filter by treeNode's treeNodeFilterProp prop's value | bool/Function(inputValue:string, treeNode:TreeNode) | Function | -|treeNodeFilterProp | which prop value of treeNode will be used for filter if filterTreeNode return true | String | 'value' | -|treeNodeLabelProp | which prop value of treeNode will render as content of select | String | 'title' | -|treeData | treeNodes data Array, if set it then you need not to construct children TreeNode. (value should be unique across the whole array) | array<{value,label,children, [disabled,selectable]}> | [] | -|treeDataSimpleMode | enable simple mode of treeData.(treeData should be like this: [{id:1, pId:0, value:'1', label:"test1",...},...], `pId` is parent node's id) | bool/object{id:'id', pId:'pId', rootPId:null} | false | -|loadData | load data asynchronously | function(node) | - | -|getPopupContainer | container which popup select menu rendered into | function(trigger:Node):Node | function(){return document.body;} | -|autoClearSearchValue | auto clear search input value when multiple select is selected/deselected | boolean | true | -| inputIcon | specify the select arrow icon | ReactNode \| (props: TreeProps) => ReactNode | - | -| clearIcon | specify the clear icon | ReactNode \| (props: TreeProps) => ReactNode | - | -| removeIcon | specify the remove icon | ReactNode \| (props: TreeProps) => ReactNode | - | -|switcherIcon| specify the switcher icon | ReactNode \| (props: TreeProps) => ReactNode | - | -|virtual| Disable virtual when `false` | false | - | - +| name | description | type | default | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | +| className | additional css class of root dom node | String | '' | +| prefixCls | prefix class | String | '' | +| animation | dropdown animation name. only support slide-up now | String | '' | +| transitionName | dropdown css animation name | String | '' | +| choiceTransitionName | css animation name for selected items at multiple mode | String | '' | +| popupMatchSelectWidth | whether dropdown's with is same with select. Default set `min-width` same as input | bool | true | +| dropdownClassName | additional className applied to dropdown | String | - | +| dropdownStyle | additional style applied to dropdown | Object | {} | +| onPopupVisibleChange | control dropdown visible | function | `() => { return true; }` | +| notFoundContent | specify content to show when no result matches. | String | 'Not Found' | +| showSearch | whether show search input in single mode | bool | true | +| allowClear | whether allowClear | bool | false | +| maxTagTextLength | max tag text length to show | number | - | +| maxTagCount | max tag count to show | number | - | +| maxCount | Limit the maximum number of items that can be selected in multiple mode | number | - | +| maxTagPlaceholder | placeholder for omitted values | ReactNode/function(omittedValues) | - | +| multiple | whether multiple select (true when enable treeCheckable) | bool | false | +| disabled | whether disabled select | bool | false | +| searchValue | work with `onSearch` to make search value controlled. | string | '' | +| defaultValue | initial selected treeNode(s) | same as value type | - | +| value | current selected treeNode(s). | normal: String/Array. labelInValue: {value:String,label:React.Node}/Array<{value,label}>. treeCheckStrictly(halfChecked default false): {value:String,label:React.Node, halfChecked}/Array<{value,label,halfChecked}>. | - | +| labelInValue | whether to embed label in value, see above value type | Bool | false | +| onChange | called when select treeNode or input value change | function(value, label(null), extra) | - | +| onSelect | called when select treeNode | function(value, node, extra) | - | +| onSearch | called when input changed | function | - | +| onTreeExpand | called when tree node expand | function(expandedKeys) | - | +| onPopupScroll | called when popup scroll | function(event) | - | +| showCheckedStrategy | `TreeSelect.SHOW_ALL`: show all checked treeNodes (Include parent treeNode). `TreeSelect.SHOW_PARENT`: show checked treeNodes (Just show parent treeNode). Default just show child. | enum{TreeSelect.SHOW_ALL, TreeSelect.SHOW_PARENT, TreeSelect.SHOW_CHILD } | TreeSelect.SHOW_CHILD | +| treeIcon | show tree icon | bool | false | +| treeLine | show tree line | bool | false | +| treeDefaultExpandAll | default expand all treeNode | bool | false | +| treeDefaultExpandedKeys | default expanded treeNode keys | Array | - | +| treeExpandedKeys | set tree expanded keys | Array | - | +| treeExpandAction | Tree open logic, optional: false \| `click` \| `doubleClick`, same as `expandAction` of `rc-tree` | string \| boolean | `click` | +| treeCheckable | whether tree show checkbox (select callback will not fire) | bool | false | +| treeCheckStrictly | check node precisely, parent and children nodes are not associated | bool | false | +| filterTreeNode | whether filter treeNodes by input value. default filter by treeNode's treeNodeFilterProp prop's value | bool/Function(inputValue:string, treeNode:TreeNode) | Function | +| treeNodeFilterProp | which prop value of treeNode will be used for filter if filterTreeNode return true | String | 'value' | +| treeNodeLabelProp | which prop value of treeNode will render as content of select | String | 'title' | +| treeData | treeNodes data Array, if set it then you need not to construct children TreeNode. (value should be unique across the whole array) | array<{value,label,children, [disabled,selectable]}> | [] | +| treeDataSimpleMode | enable simple mode of treeData.(treeData should be like this: [{id:1, pId:0, value:'1', label:"test1",...},...], `pId` is parent node's id) | bool/object{id:'id', pId:'pId', rootPId:null} | false | +| treeTitleRender | Custom render nodes | (nodeData: OptionType) => ReactNode | +| loadData | load data asynchronously | function(node) | - | +| getPopupContainer | container which popup select menu rendered into | function(trigger:Node):Node | function(){return document.body;} | +| autoClearSearchValue | auto clear search input value when multiple select is selected/deselected | boolean | true | +| suffixIcon | specify the select arrow icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| clearIcon | specify the clear icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| removeIcon | specify the remove icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| switcherIcon | specify the switcher icon | ReactNode \| (props: TreeProps) => ReactNode | - | +| virtual | Disable virtual when `false` | false | - | ### TreeNode props -> note: you'd better to use `treeData` instead of using TreeNode. -| name | description | type | default | -|----------|----------------|----------|--------------| -|disabled | disable treeNode | bool | false | -|key | it's value must be unique across the tree's all TreeNode, you must set it | String | - | -|value | default as treeNodeFilterProp (be unique across the tree's all TreeNode) | String | '' | -|title | tree/subTree's title | String/element | '---' | -|isLeaf | whether it's leaf node | bool | false | +> note: you'd better to use `treeData` instead of using TreeNode. +| name | description | type | default | +| -------- | ------------------------------------------------------------------------- | -------------- | ------- | +| disabled | disable treeNode | bool | false | +| key | it's value must be unique across the tree's all TreeNode, you must set it | String | - | +| value | default as treeNodeFilterProp (be unique across the tree's all TreeNode) | String | '' | +| title | tree/subTree's title | String/element | '---' | +| isLeaf | whether it's leaf node | bool | false | ## note + 1. Optimization tips(when there are large amounts of data, like more than 5000 nodes) - - Do not Expand all nodes. - - Recommend not exist many `TreeSelect` components in a page at the same time. - - Recommend not use `treeCheckable` mode, or use `treeCheckStrictly`. + - Do not Expand all nodes. + - Recommend not exist many `TreeSelect` components in a page at the same time. + - Recommend not use `treeCheckable` mode, or use `treeCheckStrictly`. 2. In `treeCheckable` mode, It has the same effect when click `x`(node in Selection box) or uncheck in the treeNode(in dropdown panel), but the essence is not the same. So, even if both of them trigger `onChange` method, but the parameters (the third parameter) are different. (中文:在`treeCheckable`模式下,已选择节点上的`x`删除操作、和相应 treeNode 节点上 checkbox 的 uncheck 操作,最终效果相同,但本质不一样。前者跟弹出的 tree 组件可以“毫无关系”(例如 dropdown 没展开过,tree 也就没渲染好),而后者是 tree 组件上的节点 uncheck 事件。所以、即便两者都会触发`onChange`方法、但它们的参数(第三个参数)是不同的。) ## Test Case @@ -128,4 +137,4 @@ http://localhost:8000/node_modules/rc-server/node_modules/node-jscover/lib/front ## License -rc-tree-select is released under the MIT license. +@rc-component/tree-select is released under the MIT license. diff --git a/assets/select.less b/assets/select.less index 2e473db4..a71d5489 100644 --- a/assets/select.less +++ b/assets/select.less @@ -1,3 +1,3 @@ -@import '~rc-select/assets/index'; +@import '~@rc-component/select/assets/index'; @select-prefix: ~'rc-tree-select'; \ No newline at end of file diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..80d57b63 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +peer = false \ No newline at end of file diff --git a/docs/demo/basic.md b/docs/demo/basic.md index 806f1426..edb9e624 100644 --- a/docs/demo/basic.md +++ b/docs/demo/basic.md @@ -1,3 +1,8 @@ -## basic +--- +title: basic +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/big-data.md b/docs/demo/big-data.md index d331d114..481dde69 100644 --- a/docs/demo/big-data.md +++ b/docs/demo/big-data.md @@ -1,3 +1,8 @@ -## big-data +--- +title: big-data +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/controlled.md b/docs/demo/controlled.md index 2e201f14..17ec1d3c 100644 --- a/docs/demo/controlled.md +++ b/docs/demo/controlled.md @@ -1,3 +1,8 @@ -## controlled +--- +title: controlled +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/custom-icons.md b/docs/demo/custom-icons.md index e7bbffa1..bd7361e5 100644 --- a/docs/demo/custom-icons.md +++ b/docs/demo/custom-icons.md @@ -1,3 +1,8 @@ -## custom-icons +--- +title: custom-icons +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/debug.md b/docs/demo/debug.md index 6dd26237..380f8042 100644 --- a/docs/demo/debug.md +++ b/docs/demo/debug.md @@ -1,3 +1,8 @@ -## debug +--- +title: debug +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/disable.md b/docs/demo/disable.md index 770e4655..81d38c9e 100644 --- a/docs/demo/disable.md +++ b/docs/demo/disable.md @@ -1,3 +1,8 @@ -## disable +--- +title: disable +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/dynamic.md b/docs/demo/dynamic.md index cf637336..e62d12e1 100644 --- a/docs/demo/dynamic.md +++ b/docs/demo/dynamic.md @@ -1,3 +1,8 @@ -## dynamic +--- +title: dynamic +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/field-names.md b/docs/demo/field-names.md deleted file mode 100644 index 5d2046a4..00000000 --- a/docs/demo/field-names.md +++ /dev/null @@ -1,3 +0,0 @@ -## FieldNames - - diff --git a/docs/demo/fieldNames.md b/docs/demo/fieldNames.md new file mode 100644 index 00000000..1cc8adb2 --- /dev/null +++ b/docs/demo/fieldNames.md @@ -0,0 +1,8 @@ +--- +title: fieldNames +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/demo/filter.md b/docs/demo/filter.md index 27601297..b94591a4 100644 --- a/docs/demo/filter.md +++ b/docs/demo/filter.md @@ -1,3 +1,8 @@ -## filter +--- +title: filter +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/form.md b/docs/demo/form.md index cd353a12..9fb4a38c 100644 --- a/docs/demo/form.md +++ b/docs/demo/form.md @@ -1,3 +1,8 @@ -## form +--- +title: form +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/mutiple-with-maxCount.md b/docs/demo/mutiple-with-maxCount.md new file mode 100644 index 00000000..2df96692 --- /dev/null +++ b/docs/demo/mutiple-with-maxCount.md @@ -0,0 +1,8 @@ +--- +title: mutiple-with-maxCount +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/demo/treeNodeLabelProp.md b/docs/demo/treeNodeLabelProp.md index f36f429b..189d8976 100644 --- a/docs/demo/treeNodeLabelProp.md +++ b/docs/demo/treeNodeLabelProp.md @@ -1,3 +1,8 @@ -## treeNodeLabelProp +--- +title: treeNodeLabelProp +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/demo/width.md b/docs/demo/width.md index f9154146..f70b473e 100644 --- a/docs/demo/width.md +++ b/docs/demo/width.md @@ -1,3 +1,8 @@ -## width +--- +title: width +nav: + title: Demo + path: /demo +--- - + diff --git a/docs/index.md b/docs/index.md index f8685ece..0a6339a8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,7 @@ --- -title: rc-tree-select +hero: + title: rc-tree-select + description: React Tree Select Component --- diff --git a/examples/basic.tsx b/examples/basic.tsx index bc96622e..a4bdd960 100644 --- a/examples/basic.tsx +++ b/examples/basic.tsx @@ -1,8 +1,8 @@ -import '../assets/index.less'; +import Dialog from '@rc-component/dialog'; +import '@rc-component/dialog/assets/index.css'; import React from 'react'; -import 'rc-dialog/assets/index.css'; -import Dialog from 'rc-dialog'; -import TreeSelect, { TreeNode, SHOW_PARENT } from '../src'; +import '../assets/index.less'; +import TreeSelect, { SHOW_PARENT, TreeNode } from '../src'; import { gData } from './utils/dataUtil'; function isLeaf(value) { @@ -124,7 +124,7 @@ class Demo extends React.Component { console.log(args); }; - onDropdownVisibleChange = visible => { + onPopupVisibleChange = visible => { const { value } = this.state; console.log(visible, value); if (Array.isArray(value) && value.length > 1 && value.length < 3) { @@ -155,20 +155,13 @@ class Demo extends React.Component { show dialog {visible ? ( - +
triggerNode.parentNode} style={{ width: 300 }} transitionName="rc-tree-select-dropdown-slide-up" choiceTransitionName="rc-tree-select-selection__choice-zoom" - // dropdownStyle={{ maxHeight: 200, overflow: 'auto', zIndex: 1500 }} placeholder={请下拉选择} showSearch allowClear @@ -189,7 +182,6 @@ class Demo extends React.Component { style={{ width: 300 }} transitionName="rc-tree-select-dropdown-slide-up" choiceTransitionName="rc-tree-select-selection__choice-zoom" - // dropdownStyle={{ maxHeight: 200, overflow: 'auto' }} placeholder={请下拉选择} showSearch allowClear @@ -205,13 +197,16 @@ class Demo extends React.Component { console.log('onChange', val, ...args); this.setState({ value: val }); }} - onDropdownVisibleChange={v => { - console.log('single onDropdownVisibleChange', v); + onPopupVisibleChange={v => { + console.log('single onPopupVisibleChange', v); this.setState({ tsOpen: v, }); }} onSelect={this.onSelect} + onPopupScroll={evt => { + console.log('onPopupScroll:', evt.target); + }} />

single select (just select children)

@@ -219,7 +214,6 @@ class Demo extends React.Component { style={{ width: 300 }} transitionName="rc-tree-select-dropdown-slide-up" choiceTransitionName="rc-tree-select-selection__choice-zoom" - // dropdownStyle={{ maxHeight: 200, overflow: 'auto' }} placeholder={请下拉选择} showSearch allowClear @@ -236,7 +230,6 @@ class Demo extends React.Component { style={{ width: 300 }} transitionName="rc-tree-select-dropdown-slide-up" choiceTransitionName="rc-tree-select-selection__choice-zoom" - // dropdownStyle={{ maxHeight: 200, overflow: 'auto' }} placeholder={请下拉选择} multiple value={multipleValue} @@ -255,12 +248,11 @@ class Demo extends React.Component { transitionName="rc-tree-select-dropdown-slide-up" choiceTransitionName="rc-tree-select-selection__choice-zoom" style={{ width: 300 }} - // dropdownStyle={{ height: 200, overflow: 'auto' }} - dropdownPopupAlign={{ + popupAlign={{ overflow: { adjustY: 0, adjustX: 0 }, offset: [0, 2], }} - onDropdownVisibleChange={this.onDropdownVisibleChange} + onPopupVisibleChange={this.onPopupVisibleChange} placeholder={请下拉选择} treeLine maxTagTextLength={10} @@ -284,7 +276,6 @@ class Demo extends React.Component { style={{ width: 500 }} transitionName="rc-tree-select-dropdown-slide-up" choiceTransitionName="rc-tree-select-selection__choice-zoom" - // dropdownStyle={{ maxHeight: 200, overflow: 'auto' }} placeholder={请下拉选择} showSearch allowClear @@ -300,7 +291,6 @@ class Demo extends React.Component {

use treeDataSimpleMode

请下拉选择} // treeLine maxTagTextLength={10} @@ -324,7 +314,7 @@ class Demo extends React.Component {

Testing in extreme conditions (Boundary conditions test)

use TreeNode Component (not recommend) + +

title render

+ + open + style={{ width: 300 }} + treeData={gData} + treeTitleRender={node => node.label + 'ok'} + />
); } diff --git a/examples/big-data.tsx b/examples/big-data.tsx index d9781a4a..16b3d674 100644 --- a/examples/big-data.tsx +++ b/examples/big-data.tsx @@ -54,7 +54,6 @@ class Demo extends React.Component {

normal check

checkStrictly Conrolled treeExpandedKeys - + { ); }; -const inputIcon = getSvg(bubblePath); +const suffixIcon = getSvg(bubblePath); const clearIcon = getSvg(clearPath); const removeIcon = getSvg(clearPath); const iconProps = { - inputIcon, + suffixIcon, clearIcon, removeIcon, switcherIcon, }; const iconPropsFunction = { - inputIcon: () => inputIcon, - clearIcon: () => clearIcon, - removeIcon: () => removeIcon, + suffixIcon, + clearIcon, + removeIcon, switcherIcon, }; @@ -86,7 +86,7 @@ function Demo() { placeholder={Please Select} transitionName="rc-tree-select-dropdown-slide-up" style={{ width: 300 }} - dropdownStyle={{ maxHeight: 200, overflow: 'auto', zIndex: 1500 }} + popupStyle={{ maxHeight: 200, overflow: 'auto', zIndex: 1500 }} showSearch allowClear {...iconProps} @@ -99,7 +99,7 @@ function Demo() { placeholder={Please Select} transitionName="rc-tree-select-dropdown-slide-up" style={{ width: 300 }} - dropdownStyle={{ maxHeight: 200, overflow: 'auto', zIndex: 1500 }} + popupStyle={{ maxHeight: 200, overflow: 'auto', zIndex: 1500 }} showSearch allowClear {...iconPropsFunction} diff --git a/examples/debug.tsx b/examples/debug.tsx index 9d04d30b..46e2eddb 100644 --- a/examples/debug.tsx +++ b/examples/debug.tsx @@ -1,33 +1,14 @@ -/* eslint-disable */ - import React from 'react'; import TreeSelect from '../src'; import '../assets/index.less'; -const { TreeNode, SHOW_ALL, SHOW_CHILD } = TreeSelect; -const SelectNode = TreeNode; - const treeData = [ { key: '0', value: '0', title: 'label0' }, { key: '1', value: '1', title: 'label1' }, ]; -const children = [ - , - , -]; - const createSelect = props => ; -// export default () => ( -// -// ); - export default () => createSelect({ maxTagCount: 1, diff --git a/examples/dynamic.tsx b/examples/dynamic.tsx index bf3ccbde..26cde9c4 100644 --- a/examples/dynamic.tsx +++ b/examples/dynamic.tsx @@ -30,7 +30,7 @@ class Demo extends React.Component { loadData = treeNode => { console.log('trigger load:', treeNode); - return new Promise(resolve => { + return new Promise(resolve => { setTimeout(() => { let { treeData } = this.state; treeData = treeData.slice(); diff --git a/examples/fieldNames.tsx b/examples/fieldNames.tsx index be636993..028a8ec3 100644 --- a/examples/fieldNames.tsx +++ b/examples/fieldNames.tsx @@ -1,33 +1,63 @@ import '../assets/index.less'; import React from 'react'; -import 'rc-dialog/assets/index.css'; +import '@rc-component/dialog/assets/index.css'; import TreeSelect from '../src'; export default () => { return ( - +
+

basic

+ + +

title render

+ {node.myLabel}} + treeData={[ + { + myLabel: 'Parent', + myValue: 'parent', + myChildren: [ + { + myLabel: 'Sub 1', + myValue: 'sub_1', + }, + { + myLabel: 'Sub 2', + myValue: 'sub_2', + }, + ], + }, + ]} + fieldNames={{ + label: 'myLabel', + value: 'myValue', + children: 'myChildren', + }} + /> +
); }; diff --git a/examples/filter.tsx b/examples/filter.tsx index d473bf99..1cb07caf 100644 --- a/examples/filter.tsx +++ b/examples/filter.tsx @@ -63,11 +63,6 @@ class Demo extends React.Component { style={{ width: 300 }} transitionName="rc-tree-select-dropdown-slide-up" choiceTransitionName="rc-tree-select-selection__choice-zoom" - // dropdownStyle={{ height: 200, overflow: 'auto' }} - dropdownPopupAlign={{ - overflow: { adjustY: 0, adjustX: 0 }, - offset: [0, 2], - }} placeholder={请下拉选择} treeLine maxTagTextLength={10} @@ -82,7 +77,7 @@ class Demo extends React.Component {

use treeDataSimpleMode

请下拉选择} treeLine maxTagTextLength={10} diff --git a/examples/form.tsx b/examples/form.tsx index 0ab0ebd7..0499ea63 100644 --- a/examples/form.tsx +++ b/examples/form.tsx @@ -1,8 +1,8 @@ import React, { Component } from 'react'; -import Select from 'rc-select'; -import Form, { useForm, Field } from 'rc-field-form'; +import Select from '@rc-component/select'; +import Form, { useForm, Field } from '@rc-component/form'; import TreeSelect from '../src'; -import 'rc-select/assets/index.less'; +import '@rc-component/select/assets/index.less'; import '../assets/index.less'; import { gData } from './utils/dataUtil'; @@ -21,7 +21,7 @@ const errorStyle = { }; class TreeSelectInput extends Component<{ - onChange?: Function; + onChange?: (value: string[]) => void; style: React.CSSProperties; }> { onChange = (value, ...args) => { @@ -105,11 +105,7 @@ const Demo = () => { > {(control, { errors }) => (
- +

{errors.join(',')}

@@ -122,18 +118,11 @@ const Demo = () => {
{(control, { errors }) => (
-
); }; const RefOptionList = React.forwardRef(OptionList); -RefOptionList.displayName = 'OptionList'; + +if (process.env.NODE_ENV !== 'production') { + RefOptionList.displayName = 'OptionList'; +} export default RefOptionList; diff --git a/src/TreeSelect.tsx b/src/TreeSelect.tsx index 139c5ed9..b4243e7a 100644 --- a/src/TreeSelect.tsx +++ b/src/TreeSelect.tsx @@ -1,132 +1,85 @@ +import type { BaseSelectPropsWithoutPrivate, BaseSelectRef } from '@rc-component/select'; +import type { BaseSelectSemanticName } from '@rc-component/select/lib/BaseSelect'; +import { BaseSelect } from '@rc-component/select'; +import useId from '@rc-component/util/lib/hooks/useId'; +import type { IconType } from '@rc-component/tree/lib/interface'; +import type { ExpandAction } from '@rc-component/tree/lib/Tree'; +import { conductCheck } from '@rc-component/tree/lib/utils/conductUtil'; +import useControlledState from '@rc-component/util/lib/hooks/useControlledState'; import * as React from 'react'; -import { BaseSelect } from 'rc-select'; -import type { IconType } from 'rc-tree/lib/interface'; -import type { ExpandAction } from 'rc-tree/lib/Tree'; -import type { - BaseSelectRef, - BaseSelectPropsWithoutPrivate, - BaseSelectProps, - SelectProps, -} from 'rc-select'; -import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; -import useId from 'rc-select/lib/hooks/useId'; -import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import useCache from './hooks/useCache'; +import useCheckedKeys from './hooks/useCheckedKeys'; +import useDataEntities from './hooks/useDataEntities'; +import useFilterTreeData from './hooks/useFilterTreeData'; +import useRefFunc from './hooks/useRefFunc'; +import useTreeData from './hooks/useTreeData'; +import LegacyContext from './LegacyContext'; import OptionList from './OptionList'; import TreeNode from './TreeNode'; -import { formatStrategyValues, SHOW_ALL, SHOW_PARENT, SHOW_CHILD } from './utils/strategyUtil'; -import type { CheckedStrategy } from './utils/strategyUtil'; -import TreeSelectContext from './TreeSelectContext'; import type { TreeSelectContextProps } from './TreeSelectContext'; -import LegacyContext from './LegacyContext'; -import useTreeData from './hooks/useTreeData'; -import { toArray, fillFieldNames, isNil } from './utils/valueUtil'; -import useCache from './hooks/useCache'; -import useRefFunc from './hooks/useRefFunc'; -import useDataEntities from './hooks/useDataEntities'; +import TreeSelectContext from './TreeSelectContext'; import { fillAdditionalInfo, fillLegacyProps } from './utils/legacyUtil'; -import useCheckedKeys from './hooks/useCheckedKeys'; -import useFilterTreeData from './hooks/useFilterTreeData'; +import type { CheckedStrategy } from './utils/strategyUtil'; +import { formatStrategyValues, SHOW_ALL, SHOW_CHILD, SHOW_PARENT } from './utils/strategyUtil'; +import { fillFieldNames, isNil, toArray } from './utils/valueUtil'; import warningProps from './utils/warningPropsUtil'; -import warning from 'rc-util/lib/warning'; - -export type OnInternalSelect = (value: RawValueType, info: { selected: boolean }) => void; - -export type RawValueType = string | number; - -export interface LabeledValueType { - key?: React.Key; - value?: RawValueType; - label?: React.ReactNode; - /** Only works on `treeCheckStrictly` */ - halfChecked?: boolean; -} - -export type SelectSource = 'option' | 'selection' | 'input' | 'clear'; - -export type DraftValueType = RawValueType | LabeledValueType | (RawValueType | LabeledValueType)[]; - -/** @deprecated This is only used for legacy compatible. Not works on new code. */ -export interface LegacyCheckedNode { - pos: string; - node: React.ReactElement; - children?: LegacyCheckedNode[]; -} - -export interface ChangeEventExtra { - /** @deprecated Please save prev value by control logic instead */ - preValue: LabeledValueType[]; - triggerValue: RawValueType; - /** @deprecated Use `onSelect` or `onDeselect` instead. */ - selected?: boolean; - /** @deprecated Use `onSelect` or `onDeselect` instead. */ - checked?: boolean; - - // Not sure if exist user still use this. We have to keep but not recommend user to use - /** @deprecated This prop not work as react node anymore. */ - triggerNode: React.ReactElement; - /** @deprecated This prop not work as react node anymore. */ - allCheckedNodes: LegacyCheckedNode[]; -} - -export interface FieldNames { - value?: string; - label?: string; - children?: string; -} - -export interface InternalFieldName extends Omit { - _title: string[]; -} - -export interface SimpleModeConfig { - id?: React.Key; - pId?: React.Key; - rootPId?: React.Key; -} - -export interface BaseOptionType { - disabled?: boolean; - checkable?: boolean; - disableCheckbox?: boolean; - children?: BaseOptionType[]; - [name: string]: any; -} - -export interface DefaultOptionType extends BaseOptionType { - value?: RawValueType; - title?: React.ReactNode; - label?: React.ReactNode; - key?: React.Key; - children?: DefaultOptionType[]; -} - -export interface LegacyDataNode extends DefaultOptionType { - props: any; +import type { + LabeledValueType, + SafeKey, + Key, + DataNode, + SimpleModeConfig, + ChangeEventExtra, + SelectSource, + DefaultValueType, + FieldNames, + LegacyDataNode, +} from './interface'; +import useSearchConfig from './hooks/useSearchConfig'; + +export type SemanticName = BaseSelectSemanticName; +export type PopupSemantic = 'item' | 'itemTitle'; +export interface SearchConfig { + searchValue?: string; + onSearch?: (value: string) => void; + autoClearSearchValue?: boolean; + filterTreeNode?: boolean | ((inputValue: string, treeNode: DataNode) => boolean); + treeNodeFilterProp?: string; } -export interface TreeSelectProps< - ValueType = any, - OptionType extends BaseOptionType = DefaultOptionType -> extends Omit { +export interface TreeSelectProps + extends Omit { prefixCls?: string; id?: string; - + children?: React.ReactNode; + styles?: Partial> & { + popup?: Partial>; + }; + classNames?: Partial> & { + popup?: Partial>; + }; // >>> Value value?: ValueType; defaultValue?: ValueType; onChange?: (value: ValueType, labelList: React.ReactNode[], extra: ChangeEventExtra) => void; // >>> Search + showSearch?: boolean | SearchConfig; + /** @deprecated Use `showSearch.searchValue` instead */ searchValue?: string; - /** @deprecated Use `searchValue` instead */ + /** @deprecated Use `showSearch.searchValue` instead */ inputValue?: string; + /** @deprecated Use `showSearch.onSearch` instead */ onSearch?: (value: string) => void; + /** @deprecated Use `showSearch.autoClearSearchValue` instead */ autoClearSearchValue?: boolean; - filterTreeNode?: boolean | ((inputValue: string, treeNode: DefaultOptionType) => boolean); + /** @deprecated Use `showSearch.filterTreeNode` instead */ + filterTreeNode?: boolean | ((inputValue: string, treeNode: DataNode) => boolean); + /** @deprecated Use `showSearch.treeNodeFilterProp` instead */ treeNodeFilterProp?: string; // >>> Select - onSelect?: SelectProps['onSelect']; - onDeselect?: SelectProps['onDeselect']; + onSelect?: (value: ValueType, option: OptionType) => void; + onDeselect?: (value: ValueType, option: OptionType) => void; // >>> Selector showCheckedStrategy?: CheckedStrategy; @@ -140,26 +93,29 @@ export interface TreeSelectProps< treeCheckable?: boolean | React.ReactNode; treeCheckStrictly?: boolean; labelInValue?: boolean; + maxCount?: number; // >>> Data treeData?: OptionType[]; treeDataSimpleMode?: boolean | SimpleModeConfig; loadData?: (dataNode: LegacyDataNode) => Promise; - treeLoadedKeys?: React.Key[]; - onTreeLoad?: (loadedKeys: React.Key[]) => void; + treeLoadedKeys?: SafeKey[]; + onTreeLoad?: (loadedKeys: SafeKey[]) => void; // >>> Expanded treeDefaultExpandAll?: boolean; - treeExpandedKeys?: React.Key[]; - treeDefaultExpandedKeys?: React.Key[]; - onTreeExpand?: (expandedKeys: React.Key[]) => void; + treeExpandedKeys?: SafeKey[]; + treeDefaultExpandedKeys?: SafeKey[]; + onTreeExpand?: (expandedKeys: SafeKey[]) => void; treeExpandAction?: ExpandAction; // >>> Options virtual?: boolean; listHeight?: number; listItemHeight?: number; - onDropdownVisibleChange?: (open: boolean) => void; + listItemScrollOffset?: number; + onPopupVisibleChange?: (open: boolean) => void; + treeTitleRender?: (node: OptionType) => React.ReactNode; // >>> Tree treeLine?: boolean; @@ -169,7 +125,7 @@ export interface TreeSelectProps< treeMotion?: any; } -function isRawValue(value: RawValueType | LabeledValueType): value is RawValueType { +function isRawValue(value: SafeKey | LabeledValueType): value is SafeKey { return !value || typeof value !== 'object'; } @@ -186,15 +142,15 @@ const TreeSelect = React.forwardRef((props, ref) onDeselect, // Search - searchValue, - inputValue, - onSearch, - autoClearSearchValue = true, - filterTreeNode, - treeNodeFilterProp = 'value', - + showSearch, + searchValue: legacySearchValue, + inputValue: legacyinputValue, + onSearch: legacyOnSearch, + autoClearSearchValue: legacyAutoClearSearchValue, + filterTreeNode: legacyFilterTreeNode, + treeNodeFilterProp: legacytreeNodeFilterProp, // Selector - showCheckedStrategy = SHOW_CHILD, + showCheckedStrategy, treeNodeLabelProp, // Mode @@ -202,6 +158,7 @@ const TreeSelect = React.forwardRef((props, ref) treeCheckable, treeCheckStrictly, labelInValue, + maxCount, // FieldNames fieldNames, @@ -225,8 +182,10 @@ const TreeSelect = React.forwardRef((props, ref) virtual, listHeight = 200, listItemHeight = 20, - onDropdownVisibleChange, - dropdownMatchSelectWidth = true, + listItemScrollOffset = 0, + + onPopupVisibleChange, + popupMatchSelectWidth = true, // Tree treeLine, @@ -234,7 +193,12 @@ const TreeSelect = React.forwardRef((props, ref) showTreeIcon, switcherIcon, treeMotion, + treeTitleRender, + onPopupScroll, + + classNames: treeSelectClassNames, + styles, ...restProps } = props; @@ -244,7 +208,33 @@ const TreeSelect = React.forwardRef((props, ref) const mergedLabelInValue = treeCheckStrictly || labelInValue; const mergedMultiple = mergedCheckable || multiple; - const [internalValue, setInternalValue] = useMergedState(defaultValue, { value }); + const searchProps = { + searchValue: legacySearchValue, + inputValue: legacyinputValue, + onSearch: legacyOnSearch, + autoClearSearchValue: legacyAutoClearSearchValue, + filterTreeNode: legacyFilterTreeNode, + treeNodeFilterProp: legacytreeNodeFilterProp, + }; + const [mergedShowSearch, searchConfig] = useSearchConfig(showSearch, searchProps); + const { + searchValue, + onSearch, + autoClearSearchValue = true, + filterTreeNode, + treeNodeFilterProp = 'value', + } = searchConfig; + + const [internalValue, setInternalValue] = useControlledState(defaultValue, value); + + // `multiple` && `!treeCheckable` should be show all + const mergedShowCheckedStrategy = React.useMemo(() => { + if (!treeCheckable) { + return SHOW_ALL; + } + + return showCheckedStrategy || SHOW_CHILD; + }, [showCheckedStrategy, treeCheckable]); // ========================== Warning =========================== if (process.env.NODE_ENV !== 'production') { @@ -252,7 +242,7 @@ const TreeSelect = React.forwardRef((props, ref) } // ========================= FieldNames ========================= - const mergedFieldNames: InternalFieldName = React.useMemo( + const mergedFieldNames: FieldNames = React.useMemo( () => fillFieldNames(fieldNames), /* eslint-disable react-hooks/exhaustive-deps */ [JSON.stringify(fieldNames)], @@ -260,12 +250,10 @@ const TreeSelect = React.forwardRef((props, ref) ); // =========================== Search =========================== - const [mergedSearchValue, setSearchValue] = useMergedState('', { - value: searchValue !== undefined ? searchValue : inputValue, - postState: search => search || '', - }); + const [internalSearchValue, setSearchValue] = useControlledState('', searchValue); + const mergedSearchValue = internalSearchValue || ''; - const onInternalSearch: BaseSelectProps['onSearch'] = searchText => { + const onInternalSearch = searchText => { setSearchValue(searchText); onSearch?.(searchText); }; @@ -280,7 +268,7 @@ const TreeSelect = React.forwardRef((props, ref) /** Get `missingRawValues` which not exist in the tree yet */ const splitRawValues = React.useCallback( - (newRawValues: RawValueType[]) => { + (newRawValues: SafeKey[]) => { const missingRawValues = []; const existRawValues = []; @@ -307,7 +295,7 @@ const TreeSelect = React.forwardRef((props, ref) // =========================== Label ============================ const getLabel = React.useCallback( - (item: DefaultOptionType) => { + (item: DataNode) => { if (item) { if (treeNodeLabelProp) { return item[treeNodeLabelProp]; @@ -328,7 +316,7 @@ const TreeSelect = React.forwardRef((props, ref) ); // ========================= Wrap Value ========================= - const toLabeledValues = React.useCallback((draftValues: DraftValueType) => { + const toLabeledValues = React.useCallback((draftValues: DefaultValueType) => { const values = toArray(draftValues); return values.map(val => { @@ -340,7 +328,7 @@ const TreeSelect = React.forwardRef((props, ref) }, []); const convert2LabelValues = React.useCallback( - (draftValues: DraftValueType) => { + (draftValues: DefaultValueType) => { const values = toLabeledValues(draftValues); return values.map(item => { @@ -353,14 +341,17 @@ const TreeSelect = React.forwardRef((props, ref) // Fill missing label & status if (entity) { - rawLabel = rawLabel ?? getLabel(entity.node); + rawLabel = treeTitleRender + ? treeTitleRender(entity.node) + : (rawLabel ?? getLabel(entity.node)); rawDisabled = entity.node.disabled; } else if (rawLabel === undefined) { // We try to find in current `labelInValue` value - const labelInValueItem = toLabeledValues(internalValue).find(labeledItem => labeledItem.value === rawValue); + const labelInValueItem = toLabeledValues(internalValue).find( + labeledItem => labeledItem.value === rawValue, + ); rawLabel = labelInValueItem.label; } - return { label: rawLabel, value: rawValue, @@ -373,10 +364,10 @@ const TreeSelect = React.forwardRef((props, ref) ); // =========================== Values =========================== - const rawMixedLabeledValues = React.useMemo(() => toLabeledValues(internalValue), [ - toLabeledValues, - internalValue, - ]); + const rawMixedLabeledValues = React.useMemo( + () => toLabeledValues(internalValue === null ? [] : internalValue), + [toLabeledValues, internalValue], + ); // Split value into full check and half check const [rawLabeledValues, rawHalfLabeledValues] = React.useMemo(() => { @@ -395,9 +386,10 @@ const TreeSelect = React.forwardRef((props, ref) }, [rawMixedLabeledValues]); // const [mergedValues] = useCache(rawLabeledValues); - const rawValues = React.useMemo(() => rawLabeledValues.map(item => item.value), [ - rawLabeledValues, - ]); + const rawValues = React.useMemo( + () => rawLabeledValues.map(item => item.value), + [rawLabeledValues], + ); // Convert value to key. Will fill missed keys for conduct check. const [rawCheckedValues, rawHalfCheckedValues] = useCheckedKeys( @@ -411,8 +403,8 @@ const TreeSelect = React.forwardRef((props, ref) const displayValues = React.useMemo(() => { // Collect keys which need to show const displayKeys = formatStrategyValues( - rawCheckedValues, - showCheckedStrategy, + rawCheckedValues as SafeKey[], + mergedShowCheckedStrategy, keyEntities, mergedFieldNames, ); @@ -423,9 +415,10 @@ const TreeSelect = React.forwardRef((props, ref) // Back fill with origin label const labeledValues = values.map(val => { const targetItem = rawLabeledValues.find(item => item.value === val); + const label = labelInValue ? targetItem?.label : treeTitleRender?.(targetItem); return { value: val, - label: targetItem?.label, + label, }; }); @@ -441,25 +434,49 @@ const TreeSelect = React.forwardRef((props, ref) ...item, label: item.label ?? item.value, })); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ mergedFieldNames, mergedMultiple, rawCheckedValues, rawLabeledValues, convert2LabelValues, - showCheckedStrategy, + mergedShowCheckedStrategy, keyEntities, ]); const [cachedDisplayValues] = useCache(displayValues); + // ========================== MaxCount ========================== + const mergedMaxCount = React.useMemo(() => { + if ( + mergedMultiple && + (mergedShowCheckedStrategy === 'SHOW_CHILD' || treeCheckStrictly || !treeCheckable) + ) { + return maxCount; + } + return null; + }, [maxCount, mergedMultiple, treeCheckStrictly, mergedShowCheckedStrategy, treeCheckable]); + // =========================== Change =========================== const triggerChange = useRefFunc( ( - newRawValues: RawValueType[], - extra: { triggerValue?: RawValueType; selected?: boolean }, + newRawValues: SafeKey[], + extra: { triggerValue?: SafeKey; selected?: boolean }, source: SelectSource, ) => { + const formattedKeyList = formatStrategyValues( + newRawValues, + mergedShowCheckedStrategy, + keyEntities, + mergedFieldNames, + ); + + // Not allow pass with `maxCount` + if (mergedMaxCount && formattedKeyList.length > mergedMaxCount) { + return; + } + const labeledValues = convert2LabelValues(newRawValues); setInternalValue(labeledValues); @@ -470,14 +487,8 @@ const TreeSelect = React.forwardRef((props, ref) // Generate rest parameters is costly, so only do it when necessary if (onChange) { - let eventValues: RawValueType[] = newRawValues; + let eventValues: SafeKey[] = newRawValues; if (treeConduction) { - const formattedKeyList = formatStrategyValues( - newRawValues, - showCheckedStrategy, - keyEntities, - mergedFieldNames, - ); eventValues = formattedKeyList.map(key => { const entity = valueEntities.get(key); return entity ? entity.node[mergedFieldNames.value] : key; @@ -489,7 +500,7 @@ const TreeSelect = React.forwardRef((props, ref) selected: undefined, }; - let returnRawValues: (LabeledValueType | RawValueType)[] = eventValues; + let returnRawValues: (LabeledValueType | SafeKey)[] = eventValues; // We need fill half check back if (treeCheckStrictly) { @@ -544,7 +555,7 @@ const TreeSelect = React.forwardRef((props, ref) // ========================== Options =========================== /** Trigger by option list */ const onOptionSelect = React.useCallback( - (selectedKey: React.Key, { selected, source }: { selected: boolean; source: SelectSource }) => { + (selectedKey: SafeKey, { selected, source }: { selected: boolean; source: SelectSource }) => { const entity = keyEntities[selectedKey]; const node = entity?.node; const selectedValue = node?.[mergedFieldNames.value] ?? selectedKey; @@ -565,7 +576,7 @@ const TreeSelect = React.forwardRef((props, ref) const keyList = existRawValues.map(val => valueEntities.get(val).key); // Conduction by selected or not - let checkedKeys: React.Key[]; + let checkedKeys: Key[]; if (selected) { ({ checkedKeys } = conductCheck(keyList, true, keyEntities)); } else { @@ -579,7 +590,7 @@ const TreeSelect = React.forwardRef((props, ref) // Fill back of keys newRawValues = [ ...missingRawValues, - ...checkedKeys.map(key => keyEntities[key].node[mergedFieldNames.value]), + ...checkedKeys.map(key => keyEntities[key as SafeKey].node[mergedFieldNames.value]), ]; } triggerChange(newRawValues, { selected, triggerValue: selectedValue }, source || 'option'); @@ -605,68 +616,77 @@ const TreeSelect = React.forwardRef((props, ref) onDeselect, rawCheckedValues, rawHalfCheckedValues, + maxCount, ], ); // ========================== Dropdown ========================== - const onInternalDropdownVisibleChange = React.useCallback( + const onInternalPopupVisibleChange = React.useCallback( (open: boolean) => { - if (onDropdownVisibleChange) { - const legacyParam = {}; - - Object.defineProperty(legacyParam, 'documentClickClose', { - get() { - warning(false, 'Second param of `onDropdownVisibleChange` has been removed.'); - return false; - }, - }); - - (onDropdownVisibleChange as any)(open, legacyParam); + if (onPopupVisibleChange) { + onPopupVisibleChange(open); } }, - [onDropdownVisibleChange], + [onPopupVisibleChange], ); // ====================== Display Change ======================== - const onDisplayValuesChange = useRefFunc( - (newValues, info) => { - const newRawValues = newValues.map(item => item.value); + const onDisplayValuesChange = useRefFunc((newValues, info) => { + const newRawValues = newValues.map(item => item.value); - if (info.type === 'clear') { - triggerChange(newRawValues, {}, 'selection'); - return; - } + if (info.type === 'clear') { + triggerChange(newRawValues, {}, 'selection'); + return; + } - // TreeSelect only have multiple mode which means display change only has remove - if (info.values.length) { - onOptionSelect(info.values[0].value, { selected: false, source: 'selection' }); - } - }, - ); + // TreeSelect only have multiple mode which means display change only has remove + if (info.values.length) { + onOptionSelect(info.values[0].value, { selected: false, source: 'selection' }); + } + }); // ========================== Context =========================== - const treeSelectContext = React.useMemo( - () => ({ + const treeSelectContext = React.useMemo(() => { + return { virtual, - dropdownMatchSelectWidth, + popupMatchSelectWidth, listHeight, listItemHeight, + listItemScrollOffset, treeData: filteredTreeData, fieldNames: mergedFieldNames, onSelect: onOptionSelect, treeExpandAction, - }), - [ - virtual, - dropdownMatchSelectWidth, - listHeight, - listItemHeight, - filteredTreeData, - mergedFieldNames, - onOptionSelect, - treeExpandAction, - ], - ); + treeTitleRender, + onPopupScroll, + leftMaxCount: maxCount === undefined ? null : maxCount - cachedDisplayValues.length, + leafCountOnly: + mergedShowCheckedStrategy === 'SHOW_CHILD' && !treeCheckStrictly && !!treeCheckable, + valueEntities, + classNames: treeSelectClassNames, + styles, + }; + }, [ + virtual, + popupMatchSelectWidth, + listHeight, + listItemHeight, + listItemScrollOffset, + filteredTreeData, + mergedFieldNames, + onOptionSelect, + treeExpandAction, + treeTitleRender, + onPopupScroll, + maxCount, + cachedDisplayValues.length, + mergedShowCheckedStrategy, + treeCheckStrictly, + treeCheckable, + valueEntities, + treeSelectClassNames, + styles, + ]); // ======================= Legacy Context ======================= const legacyContext = React.useMemo( @@ -717,6 +737,8 @@ const TreeSelect = React.forwardRef((props, ref) >> MISC id={mergedId} prefixCls={prefixCls} @@ -725,13 +747,15 @@ const TreeSelect = React.forwardRef((props, ref) displayValues={cachedDisplayValues} onDisplayValuesChange={onDisplayValuesChange} // >>> Search + autoClearSearchValue={autoClearSearchValue} + showSearch={mergedShowSearch} searchValue={mergedSearchValue} onSearch={onInternalSearch} // >>> Options OptionList={OptionList} emptyOptions={!mergedTreeData.length} - onDropdownVisibleChange={onInternalDropdownVisibleChange} - dropdownMatchSelectWidth={dropdownMatchSelectWidth} + onPopupVisibleChange={onInternalPopupVisibleChange} + popupMatchSelectWidth={popupMatchSelectWidth} /> @@ -743,9 +767,9 @@ if (process.env.NODE_ENV !== 'production') { TreeSelect.displayName = 'TreeSelect'; } -const GenericTreeSelect = (TreeSelect as unknown) as (< +const GenericTreeSelect = TreeSelect as unknown as (< ValueType = any, - OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType + OptionType extends DataNode = DataNode, >( props: React.PropsWithChildren> & { ref?: React.Ref; diff --git a/src/TreeSelectContext.ts b/src/TreeSelectContext.ts index 0cb40e0f..ab1813ec 100644 --- a/src/TreeSelectContext.ts +++ b/src/TreeSelectContext.ts @@ -1,16 +1,29 @@ import * as React from 'react'; -import type { ExpandAction } from 'rc-tree/lib/Tree'; -import type { DefaultOptionType, InternalFieldName, OnInternalSelect } from './TreeSelect'; +import type { ExpandAction } from '@rc-component/tree/lib/Tree'; +import type { DataNode, FieldNames, Key } from './interface'; +import type useDataEntities from './hooks/useDataEntities'; +import { TreeSelectProps } from './TreeSelect'; export interface TreeSelectContextProps { virtual?: boolean; - dropdownMatchSelectWidth?: boolean | number; + popupMatchSelectWidth?: boolean | number; listHeight: number; listItemHeight: number; - treeData: DefaultOptionType[]; - fieldNames: InternalFieldName; - onSelect: OnInternalSelect; + listItemScrollOffset?: number; + treeData: DataNode[]; + fieldNames: FieldNames; + onSelect: (value: Key, info: { selected: boolean }) => void; treeExpandAction?: ExpandAction; + treeTitleRender?: (node: any) => React.ReactNode; + onPopupScroll?: React.UIEventHandler; + + // For `maxCount` usage + leftMaxCount: number | null; + /** When `true`, only take leaf node as count, or take all as count with `maxCount` limitation */ + leafCountOnly: boolean; + valueEntities: ReturnType['valueEntities']; + classNames: TreeSelectProps['classNames']; + styles: TreeSelectProps['styles']; } const TreeSelectContext = React.createContext(null as any); diff --git a/src/hooks/useCache.ts b/src/hooks/useCache.ts index db939e5f..526b7c54 100644 --- a/src/hooks/useCache.ts +++ b/src/hooks/useCache.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { LabeledValueType, RawValueType } from '../TreeSelect'; +import type { LabeledValueType, SafeKey } from '../interface'; /** * This function will try to call requestIdleCallback if available to save performance. @@ -7,16 +7,16 @@ import type { LabeledValueType, RawValueType } from '../TreeSelect'; */ export default (values: LabeledValueType[]): [LabeledValueType[]] => { const cacheRef = React.useRef({ - valueLabels: new Map(), + valueLabels: new Map(), }); return React.useMemo(() => { const { valueLabels } = cacheRef.current; - const valueLabelsCache = new Map(); + const valueLabelsCache = new Map(); const filledValues = values.map(item => { - const { value } = item; - const mergedLabel = item.label ?? valueLabels.get(value); + const { value, label } = item; + const mergedLabel = label ?? valueLabels.get(value); // Save in cache valueLabelsCache.set(value, mergedLabel); diff --git a/src/hooks/useCheckedKeys.ts b/src/hooks/useCheckedKeys.ts index de9a97d1..1724a184 100644 --- a/src/hooks/useCheckedKeys.ts +++ b/src/hooks/useCheckedKeys.ts @@ -1,27 +1,33 @@ import * as React from 'react'; -import type { DataEntity } from 'rc-tree/lib/interface'; -import { conductCheck } from 'rc-tree/lib/utils/conductUtil'; -import type { LabeledValueType, RawValueType } from '../TreeSelect'; +import type { DataEntity } from '@rc-component/tree/lib/interface'; +import { conductCheck } from '@rc-component/tree/lib/utils/conductUtil'; +import type { LabeledValueType, SafeKey, Key } from '../interface'; -export default ( +const useCheckedKeys = ( rawLabeledValues: LabeledValueType[], rawHalfCheckedValues: LabeledValueType[], treeConduction: boolean, - keyEntities: Record, -) => - React.useMemo(() => { - let checkedKeys: RawValueType[] = rawLabeledValues.map(({ value }) => value); - let halfCheckedKeys: RawValueType[] = rawHalfCheckedValues.map(({ value }) => value); + keyEntities: Record, +) => { + return React.useMemo(() => { + const extractValues = (values: LabeledValueType[]): Key[] => values.map(({ value }) => value); - const missingValues = checkedKeys.filter(key => !keyEntities[key]); + const checkedKeys = extractValues(rawLabeledValues); + const halfCheckedKeys = extractValues(rawHalfCheckedValues); + + const missingValues = checkedKeys.filter(key => !keyEntities[key as SafeKey]); + + let finalCheckedKeys = checkedKeys; + let finalHalfCheckedKeys = halfCheckedKeys; if (treeConduction) { - ({ checkedKeys, halfCheckedKeys } = conductCheck(checkedKeys, true, keyEntities)); + const conductResult = conductCheck(checkedKeys, true, keyEntities); + finalCheckedKeys = conductResult.checkedKeys; + finalHalfCheckedKeys = conductResult.halfCheckedKeys; } - return [ - // Checked keys should fill with missing keys which should de-duplicated - Array.from(new Set([...missingValues, ...checkedKeys])), - // Half checked keys - halfCheckedKeys]; + return [Array.from(new Set([...missingValues, ...finalCheckedKeys])), finalHalfCheckedKeys]; }, [rawLabeledValues, rawHalfCheckedValues, treeConduction, keyEntities]); +}; + +export default useCheckedKeys; diff --git a/src/hooks/useDataEntities.ts b/src/hooks/useDataEntities.ts index d015033c..dd2837bd 100644 --- a/src/hooks/useDataEntities.ts +++ b/src/hooks/useDataEntities.ts @@ -1,13 +1,13 @@ import * as React from 'react'; -import { convertDataToEntities } from 'rc-tree/lib/utils/treeUtil'; -import type { DataEntity } from 'rc-tree/lib/interface'; -import type { FieldNames, RawValueType } from '../TreeSelect'; -import warning from 'rc-util/lib/warning'; +import { convertDataToEntities } from '@rc-component/tree/lib/utils/treeUtil'; +import type { DataEntity } from '@rc-component/tree/lib/interface'; +import type { SafeKey, FieldNames } from '../interface'; +import warning from '@rc-component/util/lib/warning'; import { isNil } from '../utils/valueUtil'; export default (treeData: any, fieldNames: FieldNames) => React.useMemo<{ - valueEntities: Map; + valueEntities: Map; keyEntities: Record; }>(() => { const collection = convertDataToEntities(treeData, { diff --git a/src/hooks/useFilterTreeData.ts b/src/hooks/useFilterTreeData.ts index 1433fa83..29361801 100644 --- a/src/hooks/useFilterTreeData.ts +++ b/src/hooks/useFilterTreeData.ts @@ -1,23 +1,20 @@ import * as React from 'react'; -import type { DefaultOptionType, InternalFieldName, TreeSelectProps } from '../TreeSelect'; +import type { TreeSelectProps } from '../TreeSelect'; +import type { DataNode, FieldNames } from '../interface'; import { fillLegacyProps } from '../utils/legacyUtil'; -type GetFuncType = T extends boolean ? never : T; -type FilterFn = GetFuncType; +type FilterFn = NonNullable; -export default ( - treeData: DefaultOptionType[], +const useFilterTreeData = ( + treeData: DataNode[], searchValue: string, - { - treeNodeFilterProp, - filterTreeNode, - fieldNames, - }: { - fieldNames: InternalFieldName; + options: { + fieldNames: FieldNames; treeNodeFilterProp: string; filterTreeNode: TreeSelectProps['filterTreeNode']; }, ) => { + const { fieldNames, treeNodeFilterProp, filterTreeNode } = options; const { children: fieldChildren } = fieldNames; return React.useMemo(() => { @@ -25,38 +22,30 @@ export default ( return treeData; } - let filterOptionFunc: FilterFn; - if (typeof filterTreeNode === 'function') { - filterOptionFunc = filterTreeNode; - } else { - const upperStr = searchValue.toUpperCase(); - filterOptionFunc = (_, dataNode) => { - const value = dataNode[treeNodeFilterProp]; - - return String(value).toUpperCase().includes(upperStr); - }; - } - - function dig(list: DefaultOptionType[], keepAll: boolean = false) { - return list - .map(dataNode => { - const children = dataNode[fieldChildren]; - - const match = keepAll || filterOptionFunc(searchValue, fillLegacyProps(dataNode)); - const childList = dig(children || [], match); - - if (match || childList.length) { - return { - ...dataNode, - isLeaf: undefined, - [fieldChildren]: childList, - }; - } - return null; - }) - .filter(node => node); - } - - return dig(treeData); + const filterOptionFunc: FilterFn = + typeof filterTreeNode === 'function' + ? filterTreeNode + : (_, dataNode) => + String(dataNode[treeNodeFilterProp]).toUpperCase().includes(searchValue.toUpperCase()); + + const filterTreeNodes = (nodes: DataNode[], keepAll = false): DataNode[] => + nodes.reduce((filtered, node) => { + const children = node[fieldChildren]; + const isMatch = keepAll || filterOptionFunc(searchValue, fillLegacyProps(node)); + const filteredChildren = filterTreeNodes(children || [], isMatch); + + if (isMatch || filteredChildren.length) { + filtered.push({ + ...node, + isLeaf: undefined, + [fieldChildren]: filteredChildren, + }); + } + return filtered; + }, []); + + return filterTreeNodes(treeData); }, [treeData, searchValue, fieldChildren, treeNodeFilterProp, filterTreeNode]); }; + +export default useFilterTreeData; diff --git a/src/hooks/useSearchConfig.ts b/src/hooks/useSearchConfig.ts new file mode 100644 index 00000000..e38e7350 --- /dev/null +++ b/src/hooks/useSearchConfig.ts @@ -0,0 +1,39 @@ +import type { SearchConfig } from '@/TreeSelect'; +import * as React from 'react'; + +// Convert `showSearch` to unique config +export default function useSearchConfig( + showSearch: boolean | SearchConfig, + props: SearchConfig & { inputValue: string }, +) { + const { + searchValue, + inputValue, + onSearch, + autoClearSearchValue, + filterTreeNode, + treeNodeFilterProp, + } = props; + return React.useMemo<[boolean | undefined, SearchConfig]>(() => { + const isObject = typeof showSearch === 'object'; + + const searchConfig: SearchConfig = { + searchValue: searchValue ?? inputValue, + onSearch, + autoClearSearchValue, + filterTreeNode, + treeNodeFilterProp, + ...(isObject ? showSearch : {}), + }; + + return [isObject ? true : showSearch, searchConfig]; + }, [ + showSearch, + searchValue, + inputValue, + onSearch, + autoClearSearchValue, + filterTreeNode, + treeNodeFilterProp, + ]); +} diff --git a/src/hooks/useTreeData.ts b/src/hooks/useTreeData.ts index 26ffbebd..58ea4b10 100644 --- a/src/hooks/useTreeData.ts +++ b/src/hooks/useTreeData.ts @@ -1,63 +1,54 @@ import * as React from 'react'; import type { DataNode, SimpleModeConfig } from '../interface'; import { convertChildrenToData } from '../utils/legacyUtil'; -import type { DefaultOptionType } from '../TreeSelect'; -function parseSimpleTreeData( - treeData: DataNode[], - { id, pId, rootPId }: SimpleModeConfig, -): DataNode[] { - const keyNodes = {}; - const rootNodeList = []; +function buildTreeStructure(nodes: DataNode[], config: SimpleModeConfig): DataNode[] { + const { id, pId, rootPId } = config; + const nodeMap = new Map(); + const rootNodes: DataNode[] = []; - // Fill in the map - const nodeList = treeData.map(node => { - const clone = { ...node }; - const key = clone[id]; - keyNodes[key] = clone; - clone.key = clone.key || key; - return clone; + nodes.forEach(node => { + const nodeKey = node[id]; + const clonedNode = { ...node, key: node.key || nodeKey }; + nodeMap.set(nodeKey, clonedNode); }); - // Connect tree - nodeList.forEach(node => { + nodeMap.forEach(node => { const parentKey = node[pId]; - const parent = keyNodes[parentKey]; + const parent = nodeMap.get(parentKey); - // Fill parent if (parent) { parent.children = parent.children || []; parent.children.push(node); - } - - // Fill root tree node - if (parentKey === rootPId || (!parent && rootPId === null)) { - rootNodeList.push(node); + } else if (parentKey === rootPId || rootPId === null) { + rootNodes.push(node); } }); - return rootNodeList; + return rootNodes; } /** - * Convert `treeData` or `children` into formatted `treeData`. - * Will not re-calculate if `treeData` or `children` not change. + * 将 `treeData` 或 `children` 转换为格式化的 `treeData`。 + * 如果 `treeData` 或 `children` 没有变化,则不会重新计算。 */ export default function useTreeData( treeData: DataNode[], children: React.ReactNode, simpleMode: boolean | SimpleModeConfig, -): DefaultOptionType[] { +): DataNode[] { return React.useMemo(() => { if (treeData) { - return simpleMode - ? parseSimpleTreeData(treeData, { - id: 'id', - pId: 'pId', - rootPId: null, - ...(simpleMode !== true ? simpleMode : {}), - }) - : treeData; + if (simpleMode) { + const config: SimpleModeConfig = { + id: 'id', + pId: 'pId', + rootPId: null, + ...(typeof simpleMode === 'object' ? simpleMode : {}), + }; + return buildTreeStructure(treeData, config); + } + return treeData; } return convertChildrenToData(children); diff --git a/src/interface.ts b/src/interface.ts index 9f491eda..9eb41418 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,68 +1,42 @@ import type * as React from 'react'; +import type { SafeKey, Key, DataNode as TreeDataNode } from '@rc-component/tree/lib/interface'; -export type SelectSource = 'option' | 'selection' | 'input' | 'clear'; +export type { SafeKey, Key }; -export type Key = string | number; +export interface DataNode extends Record, Omit { + key?: Key; + value?: SafeKey; + children?: DataNode[]; +} -export type RawValueType = string | number; +export type SelectSource = 'option' | 'selection' | 'input' | 'clear'; -export interface LabelValueType { +export interface LabeledValueType { key?: Key; - value?: RawValueType; + value?: SafeKey; label?: React.ReactNode; /** Only works on `treeCheckStrictly` */ halfChecked?: boolean; } -export type DefaultValueType = RawValueType | RawValueType[] | LabelValueType | LabelValueType[]; - -export interface DataNode { - value?: RawValueType; - title?: React.ReactNode; - label?: React.ReactNode; - key?: Key; - disabled?: boolean; - disableCheckbox?: boolean; - checkable?: boolean; - children?: DataNode[]; - - /** Customize data info */ - [prop: string]: any; -} - -export interface InternalDataEntity { - key: Key; - value: RawValueType; - title?: React.ReactNode; - disableCheckbox?: boolean; - disabled?: boolean; - children?: InternalDataEntity[]; - - /** Origin DataNode */ - node: DataNode; -} +export type DefaultValueType = SafeKey | LabeledValueType | (SafeKey | LabeledValueType)[]; export interface LegacyDataNode extends DataNode { props: any; } -export interface TreeDataNode extends DataNode { - key: Key; - children?: TreeDataNode[]; -} - export interface FlattenDataNode { - data: InternalDataEntity; + data: DataNode; key: Key; - value: RawValueType; + value: SafeKey; level: number; parent?: FlattenDataNode; } export interface SimpleModeConfig { - id?: Key; - pId?: Key; - rootPId?: Key; + id?: SafeKey; + pId?: SafeKey; + rootPId?: SafeKey; } /** @deprecated This is only used for legacy compatible. Not works on new code. */ @@ -74,8 +48,8 @@ export interface LegacyCheckedNode { export interface ChangeEventExtra { /** @deprecated Please save prev value by control logic instead */ - preValue: LabelValueType[]; - triggerValue: RawValueType; + preValue: LabeledValueType[]; + triggerValue: SafeKey; /** @deprecated Use `onSelect` or `onDeselect` instead. */ selected?: boolean; /** @deprecated Use `onSelect` or `onDeselect` instead. */ @@ -92,4 +66,5 @@ export interface FieldNames { value?: string; label?: string; children?: string; + _title?: string[]; } diff --git a/src/utils/legacyUtil.tsx b/src/utils/legacyUtil.tsx index c300f5d8..d7deef09 100644 --- a/src/utils/legacyUtil.tsx +++ b/src/utils/legacyUtil.tsx @@ -1,9 +1,14 @@ import * as React from 'react'; -import toArray from 'rc-util/lib/Children/toArray'; -import warning from 'rc-util/lib/warning'; -import type { DataNode, ChangeEventExtra, RawValueType, LegacyCheckedNode } from '../interface'; +import toArray from '@rc-component/util/lib/Children/toArray'; +import warning from '@rc-component/util/lib/warning'; +import type { + DataNode, + ChangeEventExtra, + SafeKey, + LegacyCheckedNode, + FieldNames, +} from '../interface'; import TreeNode from '../TreeNode'; -import type { DefaultOptionType, FieldNames } from '../TreeSelect'; export function convertChildrenToData(nodes: React.ReactNode): DataNode[] { return toArray(nodes) @@ -33,7 +38,7 @@ export function convertChildrenToData(nodes: React.ReactNode): DataNode[] { .filter(data => data); } -export function fillLegacyProps(dataNode: DataNode): any { +export function fillLegacyProps(dataNode: DataNode) { if (!dataNode) { return dataNode; } @@ -57,9 +62,9 @@ export function fillLegacyProps(dataNode: DataNode): any { export function fillAdditionalInfo( extra: ChangeEventExtra, - triggerValue: RawValueType, - checkedValues: RawValueType[], - treeData: DefaultOptionType[], + triggerValue: SafeKey, + checkedValues: SafeKey[], + treeData: DataNode[], showPosition: boolean, fieldNames: FieldNames, ) { @@ -67,7 +72,7 @@ export function fillAdditionalInfo( let nodeList: LegacyCheckedNode[] = null; function generateMap() { - function dig(list: DefaultOptionType[], level = '0', parentIncluded = false) { + function dig(list: DataNode[], level = '0', parentIncluded = false) { return list .map((option, index) => { const pos = `${level}-${index}`; @@ -75,7 +80,7 @@ export function fillAdditionalInfo( const included = checkedValues.includes(value); const children = dig(option[fieldNames.children] || [], pos, included); const node = ( - )}> + )}> {children.map(child => child.node)} ); diff --git a/src/utils/strategyUtil.ts b/src/utils/strategyUtil.ts index cd08960a..4388e522 100644 --- a/src/utils/strategyUtil.ts +++ b/src/utils/strategyUtil.ts @@ -1,7 +1,5 @@ -import type * as React from 'react'; -import type { InternalFieldName } from '../TreeSelect'; -import type { DataEntity } from 'rc-tree/lib/interface'; -import type { RawValueType, Key } from '../interface'; +import type { DataEntity } from '@rc-component/tree/lib/interface'; +import type { SafeKey, FieldNames } from '../interface'; import { isCheckDisabled } from './valueUtil'; export const SHOW_ALL = 'SHOW_ALL'; @@ -11,39 +9,31 @@ export const SHOW_CHILD = 'SHOW_CHILD'; export type CheckedStrategy = typeof SHOW_ALL | typeof SHOW_PARENT | typeof SHOW_CHILD; export function formatStrategyValues( - values: React.Key[], + values: SafeKey[], strategy: CheckedStrategy, - keyEntities: Record, - fieldNames: InternalFieldName, -): RawValueType[] { + keyEntities: Record, + fieldNames: FieldNames, +): SafeKey[] { const valueSet = new Set(values); if (strategy === SHOW_CHILD) { return values.filter(key => { const entity = keyEntities[key]; - - if ( - entity && - entity.children && - entity.children.some(({ node }) => valueSet.has(node[fieldNames.value])) && - entity.children.every( + return ( + !entity || + !entity.children || + !entity.children.some(({ node }) => valueSet.has(node[fieldNames.value])) || + !entity.children.every( ({ node }) => isCheckDisabled(node) || valueSet.has(node[fieldNames.value]), ) - ) { - return false; - } - return true; + ); }); } if (strategy === SHOW_PARENT) { return values.filter(key => { const entity = keyEntities[key]; const parent = entity ? entity.parent : null; - - if (parent && !isCheckDisabled(parent.node) && valueSet.has(parent.key)) { - return false; - } - return true; + return !parent || isCheckDisabled(parent.node) || !valueSet.has(parent.key as SafeKey); }); } return values; diff --git a/src/utils/valueUtil.ts b/src/utils/valueUtil.ts index 0063ce82..caa44727 100644 --- a/src/utils/valueUtil.ts +++ b/src/utils/valueUtil.ts @@ -1,36 +1,25 @@ -import type * as React from 'react'; -import type { DataNode, FieldNames } from '../interface'; -import type { DefaultOptionType, InternalFieldName } from '../TreeSelect'; - -export function toArray(value: T | T[]): T[] { - if (Array.isArray(value)) { - return value; - } - return value !== undefined ? [value] : []; -} - -export function fillFieldNames(fieldNames?: FieldNames) { - const { label, value, children } = fieldNames || {}; +import type { DataNode, FieldNames, SafeKey } from '../interface'; - const mergedValue = value || 'value'; +export const toArray = (value: T | T[]): T[] => + Array.isArray(value) ? value : value !== undefined ? [value] : []; +export const fillFieldNames = (fieldNames?: FieldNames) => { + const { label, value, children } = fieldNames || {}; return { _title: label ? [label] : ['title', 'label'], - value: mergedValue, - key: mergedValue, + value: value || 'value', + key: value || 'value', children: children || 'children', }; -} +}; -export function isCheckDisabled(node: DataNode) { - return !node || node.disabled || node.disableCheckbox || node.checkable === false; -} +export const isCheckDisabled = (node: DataNode): boolean => + !node || node.disabled || node.disableCheckbox || node.checkable === false; -/** Loop fetch all the keys exist in the tree */ -export function getAllKeys(treeData: DefaultOptionType[], fieldNames: InternalFieldName) { - const keys: React.Key[] = []; +export const getAllKeys = (treeData: DataNode[], fieldNames: FieldNames): SafeKey[] => { + const keys: SafeKey[] = []; - function dig(list: DefaultOptionType[]) { + const dig = (list: DataNode[]): void => { list.forEach(item => { const children = item[fieldNames.children]; if (children) { @@ -38,13 +27,11 @@ export function getAllKeys(treeData: DefaultOptionType[], fieldNames: InternalFi dig(children); } }); - } + }; dig(treeData); return keys; -} +}; -export function isNil(val: any) { - return val === null || val === undefined; -} +export const isNil = (val: any): boolean => val === null || val === undefined; diff --git a/src/utils/warningPropsUtil.ts b/src/utils/warningPropsUtil.ts index 9743b946..7ca3050d 100644 --- a/src/utils/warningPropsUtil.ts +++ b/src/utils/warningPropsUtil.ts @@ -1,4 +1,4 @@ -import warning from 'rc-util/lib/warning'; +import warning from '@rc-component/util/lib/warning'; import type { TreeSelectProps } from '../TreeSelect'; import { toArray } from './valueUtil'; @@ -10,6 +10,8 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { labelInValue, value, multiple, + showCheckedStrategy, + maxCount, } = props; warning(!searchPlaceholder, '`searchPlaceholder` has been removed.'); @@ -20,7 +22,7 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { if (labelInValue || treeCheckStrictly) { warning( - toArray(value).every((val) => val && typeof val === 'object' && 'value' in val), + toArray(value).every(val => val && typeof val === 'object' && 'value' in val), 'Invalid prop `value` supplied to `TreeSelect`. You should use { label: string, value: string | number } or [{ label: string, value: string | number }] instead.', ); } @@ -33,6 +35,17 @@ function warningProps(props: TreeSelectProps & { searchPlaceholder?: string }) { } else { warning(!Array.isArray(value), '`value` should not be array when `TreeSelect` is single mode.'); } + + if ( + maxCount && + ((showCheckedStrategy === 'SHOW_ALL' && !treeCheckStrictly) || + showCheckedStrategy === 'SHOW_PARENT') + ) { + warning( + false, + '`maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', + ); + } } export default warningProps; diff --git a/tests/Select.SearchInput.spec.js b/tests/Select.SearchInput.spec.js index 2cb3e061..c2c1cc42 100644 --- a/tests/Select.SearchInput.spec.js +++ b/tests/Select.SearchInput.spec.js @@ -1,7 +1,9 @@ /* eslint-disable no-undef */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { mount } from 'enzyme'; +import { render, fireEvent } from '@testing-library/react'; import TreeSelect, { TreeNode } from '../src'; +import KeyCode from '@rc-component/util/lib/KeyCode'; describe('TreeSelect.SearchInput', () => { it('select item will clean searchInput', () => { @@ -19,12 +21,7 @@ describe('TreeSelect.SearchInput', () => { wrapper.selectNode(); expect(onSearch).not.toHaveBeenCalled(); - expect( - wrapper - .find('input') - .first() - .props().value, - ).toBeFalsy(); + expect(wrapper.find('input').first().props().value).toBeFalsy(); }); it('expandedKeys', () => { @@ -51,10 +48,7 @@ describe('TreeSelect.SearchInput', () => { expect(wrapper.find('NodeList').prop('expandedKeys')).toEqual(['bamboo', 'light']); function search(value) { - wrapper - .find('input') - .first() - .simulate('change', { target: { value } }); + wrapper.find('input').first().simulate('change', { target: { value } }); wrapper.update(); } @@ -85,8 +79,8 @@ describe('TreeSelect.SearchInput', () => { { id: 1, pId: 0, value: '1', title: 'Expand to load' }, { id: 2, pId: 0, value: '2', title: 'Expand to load' }, { id: 3, pId: 0, value: '3', title: 'Tree Node', isLeaf: true }, - ]) - } + ]); + }; const genTreeNode = (parentId, isLeaf = false) => { const random = Math.random().toString(36).substring(2, 6); @@ -100,22 +94,16 @@ describe('TreeSelect.SearchInput', () => { }; const onLoadData = ({ id, ...rest }) => - new Promise((resolve) => { - setTimeout(() => { - called += 1; - handleLoadData({ id, ...rest }); - setTreeData( - treeData.concat([ - genTreeNode(id, false), - genTreeNode(id, true), - genTreeNode(id, true), - ]) - ); - resolve(undefined); - }, 300); + new Promise(resolve => { + called += 1; + handleLoadData({ id, ...rest }); + setTreeData( + treeData.concat([genTreeNode(id, false), genTreeNode(id, true), genTreeNode(id, true)]), + ); + resolve(undefined); }); - const onChange = (newValue) => { + const onChange = newValue => { setValue(newValue); }; @@ -130,7 +118,6 @@ describe('TreeSelect.SearchInput', () => { treeData={treeData} treeNodeFilterProp="title" showSearch - filterTreeNode={false} /> @@ -141,10 +128,7 @@ describe('TreeSelect.SearchInput', () => { expect(handleLoadData).not.toHaveBeenCalled(); function search(value) { - wrapper - .find('input') - .first() - .simulate('change', { target: { value } }); + wrapper.find('input').first().simulate('change', { target: { value } }); wrapper.update(); } search('Tree Node'); @@ -165,5 +149,161 @@ describe('TreeSelect.SearchInput', () => { search(''); expect(handleLoadData).not.toHaveBeenCalled(); expect(called).toBe(0); + + search('ex'); + const nodes = wrapper.find(`[title="${'Expand to load'}"]`).hostNodes(); + nodes.first().simulate('click'); + expect(called).toBe(0); // should not trrigger all nodes to load data + }); + + it('should trrigger `loadData` when click node', () => { + let called = 0; + const Demo = () => { + const [value, setValue] = useState(); + const onLoadData = ({ id, ...rest }) => + new Promise(resolve => { + called += 1; + resolve(undefined); + }); + + const onChange = newValue => { + setValue(newValue); + }; + + return ( + + ); + }; + const wrapper = mount(); + + function search(value) { + wrapper.find('input').first().simulate('change', { target: { value } }); + wrapper.update(); + } + + search('ex'); + const nodes = wrapper.find(`[title="${'Expand to load'}"]`).hostNodes(); + nodes.first().simulate('click'); + expect(called).toBe(1); + }); + + describe('keyboard events', () => { + it('should select first matched node when press enter', () => { + const onSelect = jest.fn(); + const { getByRole } = render( + , + ); + + // Search and press enter, should select first matched non-disabled node + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: '1' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); + onSelect.mockReset(); + + // Search disabled node and press enter, should not select + fireEvent.change(input, { target: { value: '2' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + onSelect.mockReset(); + + // Search and press enter, should select first matched non-disabled node + fireEvent.change(input, { target: { value: '3' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + fireEvent.keyUp(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).toHaveBeenCalledWith('3', expect.anything()); + }); + + it('should not select node when no matches found', () => { + const onSelect = jest.fn(); + const { getByRole } = render( + , + ); + + // Search non-existent value and press enter, should not select any node + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: 'not-exist' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('should ignore enter press when all matched nodes are disabled', () => { + const onSelect = jest.fn(); + const { getByRole } = render( + , + ); + + // When all matched nodes are disabled, press enter should not select any node + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: '1' } }); + fireEvent.keyDown(input, { keyCode: KeyCode.ENTER }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('should activate first matched node when searching', () => { + const { getByRole, container } = render( + , + ); + + // When searching, first matched non-disabled node should be activated + const input = getByRole('combobox'); + fireEvent.change(input, { target: { value: '1' } }); + expect(container.querySelector('.rc-tree-select-tree-treenode-active')).toHaveTextContent( + '1', + ); + + // Should skip disabled nodes + fireEvent.change(input, { target: { value: '2' } }); + expect(container.querySelectorAll('.rc-tree-select-tree-treenode-active')).toHaveLength(0); + }); }); }); diff --git a/tests/Select.checkable.spec.tsx b/tests/Select.checkable.spec.tsx index 2da83c0e..e63d6b76 100644 --- a/tests/Select.checkable.spec.tsx +++ b/tests/Select.checkable.spec.tsx @@ -3,6 +3,7 @@ import { fireEvent, render } from '@testing-library/react'; import { mount } from 'enzyme'; import React from 'react'; import TreeSelect, { SHOW_ALL, SHOW_PARENT, TreeNode } from '../src'; +import { clearSelection, search, selectNode, triggerOpen } from './util'; describe('TreeSelect.checkable', () => { it('allow clear when controlled', () => { @@ -210,6 +211,8 @@ describe('TreeSelect.checkable', () => { }); it('clear selected value and input value', () => { + jest.useFakeTimers(); + const treeData = [ { key: '0', @@ -218,7 +221,7 @@ describe('TreeSelect.checkable', () => { }, ]; - const wrapper = mount( + const { container } = render( { showCheckedStrategy={SHOW_PARENT} />, ); - wrapper.openSelect(); - wrapper.selectNode(0); - wrapper.search('foo'); - wrapper.clearAll(); - expect(wrapper.getSelection()).toHaveLength(0); - expect(wrapper.find('input').first().props().value).toBe(''); + + triggerOpen(container); + selectNode(0); + search(container, 'foo'); + + // Clear all using mouseDown (same as wrapper.clearAll()) + const clearButton = container.querySelector('.rc-tree-select-clear')!; + fireEvent.mouseDown(clearButton); + + // Check that no items are selected + expect(container.querySelectorAll('.rc-tree-select-selection-item')).toHaveLength(0); + + // Check that input value is cleared + const input = container.querySelector('input') as HTMLInputElement; + expect(input.value).toBe(''); + + jest.useRealTimers(); }); describe('uncheck', () => { @@ -302,6 +316,8 @@ describe('TreeSelect.checkable', () => { }); it('check in filter', () => { + jest.useFakeTimers(); + const treeData = [ { key: 'P001', @@ -330,14 +346,20 @@ describe('TreeSelect.checkable', () => { ], }, ]; - const wrapper = mount(); - wrapper.search('58'); - wrapper.selectNode(2); - expect(wrapper.getSelection()).toHaveLength(1); - - wrapper.search('59'); - wrapper.selectNode(2); - expect(wrapper.getSelection()).toHaveLength(2); + + const { container } = render(); + + // Search for '58' and select the found node + search(container, '58'); + selectNode(2); + expect(container.querySelectorAll('.rc-tree-select-selection-item')).toHaveLength(1); + + // Search for '59' and select another node + search(container, '59'); + selectNode(2); + expect(container.querySelectorAll('.rc-tree-select-selection-item')).toHaveLength(2); + + jest.useRealTimers(); }); }); @@ -382,7 +404,7 @@ describe('TreeSelect.checkable', () => { }, ]; - const wrapper = mount( + const { container } = render( { />, ); - wrapper.search('0-0'); - wrapper.selectNode(0); + search(container, '0-0'); + selectNode(0); expect(onChange).toHaveBeenCalledWith(['0-1-0', '0-1-2'], expect.anything(), expect.anything()); }); @@ -420,7 +442,7 @@ describe('TreeSelect.checkable', () => { it('uncontrolled', () => { const onChange = jest.fn(); - const wrapper = mount( + const { container } = render( { />, ); - wrapper.search('0-0-1'); - wrapper.selectNode(1); + search(container, '0-0-1'); + selectNode(1); expect(onChange).toHaveBeenCalledWith(['0-0-1'], expect.anything(), expect.anything()); expect( - wrapper - .find('.rc-tree-select-tree-checkbox') - .at(0) - .hasClass('rc-tree-select-tree-checkbox-indeterminate'), + container + .querySelectorAll('.rc-tree-select-tree-checkbox')[0] + .classList.contains('rc-tree-select-tree-checkbox-indeterminate'), ).toBeTruthy(); }); @@ -471,17 +492,16 @@ describe('TreeSelect.checkable', () => { } } - const wrapper = mount(); + const { container } = render(); - wrapper.search('0-0-1'); - wrapper.selectNode(1); + search(container, '0-0-1'); + selectNode(1); expect(onChange).toHaveBeenCalled(); expect( - wrapper - .find('.rc-tree-select-tree-checkbox') - .at(0) - .hasClass('rc-tree-select-tree-checkbox-indeterminate'), + container + .querySelectorAll('.rc-tree-select-tree-checkbox')[0] + .classList.contains('rc-tree-select-tree-checkbox-indeterminate'), ).toBe(true); }); }); @@ -489,7 +509,12 @@ describe('TreeSelect.checkable', () => { describe('labelInValue', () => { it('basic', () => { const wrapper = mount( - + @@ -540,26 +565,26 @@ describe('TreeSelect.checkable', () => { it('extra.allCheckedNodes', () => { const onChange = jest.fn(); - const wrapper = mount( + const { container } = render( , ); // Just click - wrapper.selectNode(); + selectNode(); expect(onChange.mock.calls[0][2].allCheckedNodes).toEqual([ expect.objectContaining({ pos: '0-0', }), ]); - wrapper.clearSelection(0); + clearSelection(container, 0); onChange.mockReset(); // By search - wrapper.search('0'); - wrapper.selectNode(); + search(container, '0'); + selectNode(); expect(onChange.mock.calls[0][2].allCheckedNodes).toEqual([ expect.objectContaining({ pos: '0-0', @@ -608,7 +633,7 @@ describe('TreeSelect.checkable', () => { const onChange = jest.fn(); - const wrapper = mount( + const { container } = render( { />, ); - wrapper.search('0-0-0'); - wrapper.selectNode(1); + search(container, '0-0-0'); + selectNode(1); expect(onChange.mock.calls[0][0]).toEqual([ { label: 'Node2', value: '0-1' }, @@ -639,15 +664,17 @@ describe('TreeSelect.checkable', () => { }, ]; - const wrapper = mount(); + const { container } = render( + , + ); - expect(wrapper.getSelection().length).toBeTruthy(); - expect(wrapper.find('.rc-tree-select-selection-item-remove').length).toBeFalsy(); + expect(container.querySelectorAll('.rc-tree-select-selection-item').length).toBeTruthy(); + expect(container.querySelectorAll('.rc-tree-select-selection-item-remove').length).toBeFalsy(); }); it('treeCheckStrictly can set halfChecked', () => { const onChange = jest.fn(); - const wrapper = mount( + const { container } = render( { ); function getTreeNode(index) { - return wrapper.find('.rc-tree-select-tree-treenode').not('[aria-hidden]').at(index); + const treeNodes = container.querySelectorAll('.rc-tree-select-tree-treenode'); + const visibleNodes = Array.from(treeNodes).filter(node => !node.hasAttribute('aria-hidden')); + return visibleNodes[index]; } expect( - getTreeNode(0).hasClass('rc-tree-select-tree-treenode-checkbox-indeterminate'), + getTreeNode(0).classList.contains('rc-tree-select-tree-treenode-checkbox-indeterminate'), ).toBeTruthy(); expect( - getTreeNode(1).hasClass('rc-tree-select-tree-treenode-checkbox-indeterminate'), + getTreeNode(1).classList.contains('rc-tree-select-tree-treenode-checkbox-indeterminate'), ).toBeFalsy(); - wrapper.selectNode(1); + selectNode(1); expect(onChange).toHaveBeenCalledWith( [ { diff --git a/tests/Select.loadData.spec.tsx b/tests/Select.loadData.spec.tsx new file mode 100644 index 00000000..c938d95c --- /dev/null +++ b/tests/Select.loadData.spec.tsx @@ -0,0 +1,46 @@ +/* eslint-disable no-undef, react/no-multi-comp, no-console */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; + +import TreeSelect from '../src'; + +describe('TreeSelect.loadData', () => { + it('keep sync', async () => { + const Demo = () => { + const [treeData, setTreeData] = React.useState([ + { + title: '0', + value: 0, + isLeaf: false, + }, + ]); + + const loadData = async () => { + const nextId = treeData.length; + + setTreeData([ + ...treeData, + { + title: `${nextId}`, + value: nextId, + isLeaf: false, + }, + ]); + }; + + return ; + }; + + render(); + + for (let i = 0; i < 5; i += 1) { + fireEvent.click(document.querySelector('.rc-tree-select-tree-switcher_close')); + await act(async () => { + await Promise.resolve(); + }); + expect( + document.querySelectorAll('.rc-tree-select-tree-list .rc-tree-select-tree-treenode'), + ).toHaveLength(2 + i); + } + }); +}); diff --git a/tests/Select.maxCount.spec.tsx b/tests/Select.maxCount.spec.tsx new file mode 100644 index 00000000..74c8cec7 --- /dev/null +++ b/tests/Select.maxCount.spec.tsx @@ -0,0 +1,476 @@ +import { render, fireEvent, within } from '@testing-library/react'; +import KeyCode from '@rc-component/util/lib/KeyCode'; +import { keyDown, keyUp } from './util'; +import React from 'react'; +import TreeSelect from '../src'; + +describe('TreeSelect.maxCount', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label' }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + { key: '3', value: '3', title: '3 label' }, + ]; + + const renderTreeSelect = (props?: any) => { + return render(); + }; + + const selectOptions = (container, optionTexts) => { + const dropdownList = container.querySelector('.rc-tree-select-dropdown'); + optionTexts.forEach(text => { + fireEvent.click(within(dropdownList).getByText(text)); + }); + }; + + it('should disable unselected options when selection reaches maxCount', () => { + const { container } = renderTreeSelect(); + + selectOptions(container, ['0 label', '1 label']); + + // Check if third and fourth options are disabled + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + const option3 = within(dropdownList).getByText('2 label'); + const option4 = within(dropdownList).getByText('3 label'); + + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + expect(option4.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should allow deselecting options after reaching maxCount', () => { + const { container } = renderTreeSelect(); + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + + selectOptions(container, ['0 label', '1 label']); + + // Try selecting third option, should be disabled + const option3 = within(dropdownList).getByText('2 label'); + fireEvent.click(option3); + expect(option3.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + + // Deselect first option + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(within(dropdownList).queryByText('0 label')).toBeInTheDocument(); + + // Now should be able to select third option + fireEvent.click(option3); + expect(option3.closest('div')).not.toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should not trigger onChange when trying to select beyond maxCount', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try selecting third option + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(2); // Should not increase + }); + + it('should not affect deselection operations when maxCount is reached', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Deselect first option + const dropdownList = container.querySelector('.rc-tree-select-dropdown') as HTMLElement; + fireEvent.click(within(dropdownList).getByText('0 label')); + expect(handleChange).toHaveBeenCalledTimes(3); + + // Should be able to select third option + fireEvent.click(within(dropdownList).getByText('2 label')); + expect(handleChange).toHaveBeenCalledTimes(4); + }); + + it('should not allow any selection when maxCount is 0', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 0, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label']); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('should not limit selection when maxCount is greater than number of options', () => { + const handleChange = jest.fn(); + const { container } = renderTreeSelect({ maxCount: 5, onChange: handleChange }); + + selectOptions(container, ['0 label', '1 label', '2 label', '3 label']); + expect(handleChange).toHaveBeenCalledTimes(4); + }); + + it('should respect maxCount when checking parent node in treeCheckable mode', () => { + const data = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + { key: '0-2', value: '0-2', title: 'child 3' }, + ], + }, + ]; + + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Try to check parent node which would select all children + const checkbox = container.querySelector('.rc-tree-select-tree-checkbox'); + fireEvent.click(checkbox); + + // onChange should not be called since it would exceed maxCount + expect(handleChange).not.toHaveBeenCalled(); + + // Parent node should still be unchecked + expect(checkbox).not.toHaveClass('rc-tree-select-tree-checkbox-checked'); + }); +}); + +describe('TreeSelect.maxCount keyboard operations', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label' }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + ]; + + it('keyboard operations should not exceed maxCount limit', () => { + const onSelect = jest.fn(); + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + expect(onSelect).toHaveBeenCalledWith('0', expect.anything()); + + keyDown(input, KeyCode.DOWN); + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + expect(onSelect).toHaveBeenCalledWith('1', expect.anything()); + + keyDown(input, KeyCode.DOWN); + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + }); + + it('when maxCount is reached, the option should be disabled', () => { + const { container } = render( + , + ); + + // verify that the third option is disabled + expect(container.querySelector('.rc-tree-select-tree-treenode-disabled')?.textContent).toBe( + '2 label', + ); + }); + + it('should be able to unselect after reaching maxCount', () => { + const { container } = render( + , + ); + + const input = container.querySelector('input'); + + // cancel first selection + keyDown(input, KeyCode.ENTER); + keyUp(input, KeyCode.ENTER); + + // verify only two options are selected + expect(container.querySelectorAll('.rc-tree-select-tree-treenode-selected')).toHaveLength(2); + }); +}); + +describe('TreeSelect.maxCount with different strategies', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + { key: '0-2', value: '0-2', title: 'child 3' }, + ], + }, + ]; + + it('should respect maxCount with SHOW_PARENT strategy', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent node - should work as it only shows as one option + const parentCheckbox = within(container).getByText('parent'); + fireEvent.click(parentCheckbox); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('should respect maxCount with SHOW_CHILD strategy', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent node - should not work as it would show three children + const parentCheckbox = within(container).getByText('parent'); + fireEvent.click(parentCheckbox); + expect(handleChange).not.toHaveBeenCalled(); + + // Select individual children - should work until maxCount + const childCheckboxes = within(container).getAllByText(/child/); + fireEvent.click(childCheckboxes[0]); // first child + fireEvent.click(childCheckboxes[1]); // second child + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try to select third child - should not work + fireEvent.click(childCheckboxes[2]); + expect(handleChange).toHaveBeenCalledTimes(2); + }); +}); + +describe('TreeSelect.maxCount with treeCheckStrictly', () => { + const treeData = [ + { + key: '0', + value: '0', + title: 'parent', + children: [ + { key: '0-0', value: '0-0', title: 'child 1' }, + { key: '0-1', value: '0-1', title: 'child 2' }, + ], + }, + ]; + + it('should count parent and children separately when treeCheckStrictly is true', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + // Select parent and one child - should work as they are counted separately + const parentCheckbox = within(container).getByText('parent'); + const checkboxes = within(container).getAllByText(/child/); + fireEvent.click(parentCheckbox); + fireEvent.click(checkboxes[0]); // first child + expect(handleChange).toHaveBeenCalledTimes(2); + + // Try to select second child - should not work as maxCount is reached + fireEvent.click(checkboxes[1]); + expect(handleChange).toHaveBeenCalledTimes(2); + }); + + it('should allow deselecting when maxCount is reached', () => { + const handleChange = jest.fn(); + const { container } = render( + , + ); + + const parentCheckbox = within(container).getByText('parent'); + const checkboxes = within(container).getAllByText(/child/); + + // Select parent and first child + fireEvent.click(parentCheckbox); + fireEvent.click(checkboxes[0]); + expect(handleChange).toHaveBeenCalledTimes(2); + + // Deselect parent + fireEvent.click(parentCheckbox); + expect(handleChange).toHaveBeenCalledTimes(3); + + // Now should be able to select second child + fireEvent.click(checkboxes[1]); + expect(handleChange).toHaveBeenCalledTimes(4); + }); +}); + +describe('TreeSelect.maxCount with complex scenarios', () => { + const complexTreeData = [ + { + key: 'asia', + value: 'asia', + title: 'Asia', + children: [ + { + key: 'china', + value: 'china', + title: 'China', + children: [ + { key: 'beijing', value: 'beijing', title: 'Beijing' }, + { key: 'shanghai', value: 'shanghai', title: 'Shanghai' }, + { key: 'guangzhou', value: 'guangzhou', title: 'Guangzhou' }, + ], + }, + { + key: 'japan', + value: 'japan', + title: 'Japan', + children: [ + { key: 'tokyo', value: 'tokyo', title: 'Tokyo' }, + { key: 'osaka', value: 'osaka', title: 'Osaka' }, + ], + }, + ], + }, + { + key: 'europe', + value: 'europe', + title: 'Europe', + children: [ + { + key: 'uk', + value: 'uk', + title: 'United Kingdom', + children: [ + { key: 'london', value: 'london', title: 'London' }, + { key: 'manchester', value: 'manchester', title: 'Manchester' }, + ], + }, + { + key: 'france', + value: 'france', + title: 'France', + disabled: true, + children: [ + { key: 'paris', value: 'paris', title: 'Paris' }, + { key: 'lyon', value: 'lyon', title: 'Lyon' }, + ], + }, + ], + }, + ]; + + it('should handle complex tree structure with maxCount correctly', () => { + const handleChange = jest.fn(); + const { getByRole } = render( + , + ); + + const container = getByRole('tree'); + + // 选择一个顶层节点 + const asiaNode = within(container).getByText('Asia'); + fireEvent.click(asiaNode); + expect(handleChange).not.toHaveBeenCalled(); // 不应该触发,因为会超过 maxCount + + // 选择叶子节点 + const beijingNode = within(container).getByText('Beijing'); + const shanghaiNode = within(container).getByText('Shanghai'); + const tokyoNode = within(container).getByText('Tokyo'); + const londonNode = within(container).getByText('London'); + + fireEvent.click(beijingNode); + fireEvent.click(shanghaiNode); + fireEvent.click(tokyoNode); + expect(handleChange).toHaveBeenCalledTimes(3); + + // 尝试选择第四个节点,应该被阻止 + fireEvent.click(londonNode); + expect(handleChange).toHaveBeenCalledTimes(3); + + // 验证禁用状态 + expect(londonNode.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); + + it('should handle maxCount with mixed selection strategies', () => { + const handleChange = jest.fn(); + + const { getByRole } = render( + , + ); + + const container = getByRole('tree'); + + const tokyoNode = within(container).getByText('Tokyo'); + fireEvent.click(tokyoNode); + + // because UK node will show two children, so it will trigger one change + expect(handleChange).toHaveBeenCalledTimes(1); + + const beijingNode = within(container).getByText('Beijing'); + fireEvent.click(beijingNode); + + // should not trigger change + expect(handleChange).toHaveBeenCalledTimes(1); + expect(beijingNode.closest('div')).toHaveClass('rc-tree-select-tree-treenode-disabled'); + }); +}); diff --git a/tests/Select.multiple.spec.js b/tests/Select.multiple.spec.js index 93012bd2..a01dc393 100644 --- a/tests/Select.multiple.spec.js +++ b/tests/Select.multiple.spec.js @@ -1,12 +1,23 @@ /* eslint-disable no-undef */ -import React from 'react'; +import { render, fireEvent, within, screen } from '@testing-library/react'; import { mount } from 'enzyme'; -import KeyCode from 'rc-util/lib/KeyCode'; +import KeyCode from '@rc-component/util/lib/KeyCode'; +import React from 'react'; import TreeSelect, { TreeNode } from '../src'; import focusTest from './shared/focusTest'; +import { selectNode, clearSelection, search, expectOpen, triggerOpen } from './util'; describe('TreeSelect.multiple', () => { - focusTest('multiple'); + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + focusTest(true); const treeData = [ { key: '0', value: '0', title: 'label0' }, @@ -31,7 +42,10 @@ describe('TreeSelect.multiple', () => { it('remove by backspace key', () => { const wrapper = mount(createSelect({ defaultValue: ['0', '1'] })); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); expect(wrapper.getSelection()).toHaveLength(1); expect(wrapper.getSelection(0).text()).toBe('label0'); }); @@ -58,9 +72,15 @@ describe('TreeSelect.multiple', () => { } } const wrapper = mount(); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); wrapper.selectNode(1); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.BACKSPACE }); + wrapper + .find('input') + .first() + .simulate('keyDown', { which: KeyCode.BACKSPACE, key: 'Backspace' }); expect(wrapper.getSelection()).toHaveLength(1); expect(wrapper.getSelection(0).text()).toBe('label0'); }); @@ -119,14 +139,29 @@ describe('TreeSelect.multiple', () => { }); it('do not open tree when close button click', () => { - const wrapper = mount(createSelect()); - wrapper.openSelect(); - wrapper.selectNode(0); - wrapper.selectNode(1); - wrapper.openSelect(); - wrapper.clearSelection(0); - expect(wrapper.isOpen()).toBeFalsy(); - expect(wrapper.getSelection()).toHaveLength(1); + const { container } = render(createSelect()); + + // Open the select dropdown + triggerOpen(container); + + // Select two nodes + selectNode(0); + selectNode(1); + + // Check selections exist + expect(container.querySelectorAll('.rc-tree-select-selection-item')).toHaveLength(2); + + // Open again to ensure dropdown is open + triggerOpen(container); + + // Clear one selection - this should NOT open the dropdown + clearSelection(container, 0); + + // Check that only one selection remains + expect(container.querySelectorAll('.rc-tree-select-selection-item')).toHaveLength(1); + + // Check that dropdown is closed after clearing + expectOpen(container, false); }); describe('maxTagCount', () => { @@ -229,8 +264,8 @@ describe('TreeSelect.multiple', () => { // https://github.com/ant-design/ant-design/issues/12315 it('select searched node', () => { const onChange = jest.fn(); - const wrapper = mount( - + const { container } = render( + @@ -242,8 +277,12 @@ describe('TreeSelect.multiple', () => { , ); - wrapper.search('sss'); - wrapper.selectNode(2); + // Search for 'sss' + search(container, 'sss'); + + // Find and click on the searched node - use selectNode from util with correct index + selectNode(2); // The sss node should be at index 2 after search filtering + expect(onChange).toHaveBeenCalledWith(['leaf1', 'sss'], expect.anything(), expect.anything()); }); @@ -279,4 +318,69 @@ describe('TreeSelect.multiple', () => { expect(onChange).toHaveBeenCalledWith([], expect.anything(), expect.anything()); expect(onDeselect).toHaveBeenCalledWith('not-exist', undefined); }); + + it('should not omit value', () => { + const { container } = render( + , + ); + + const values = Array.from( + container.querySelectorAll('.rc-tree-select-selection-item-content'), + ).map(ele => ele.textContent); + expect(values).toEqual(['child1', 'child2', 'parent']); + }); + + // https://github.com/ant-design/ant-design/issues/50578#issuecomment-2312130715 + it('should not omit value when value is null', () => { + const { container } = render( + , + ); + + const values = Array.from(container.querySelectorAll('.rc-tree-select-selection-item-content')); //.map(ele => ele.textContent); + + expect(values).toHaveLength(0); + + const placeholder = container.querySelector('[class$=placeholder]'); + expect(placeholder).toBeTruthy(); + expect(placeholder.textContent).toBe('Fake placeholder'); + }); }); diff --git a/tests/Select.props.spec.js b/tests/Select.props.spec.js index b1fff014..5106831c 100644 --- a/tests/Select.props.spec.js +++ b/tests/Select.props.spec.js @@ -1,8 +1,11 @@ /* eslint-disable no-undef, react/no-multi-comp, no-console */ -import React from 'react'; import { mount } from 'enzyme'; -import Tree, { TreeNode } from 'rc-tree'; +import Tree, { TreeNode } from '@rc-component/tree'; +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + import TreeSelect, { SHOW_ALL, SHOW_CHILD, SHOW_PARENT, TreeNode as SelectNode } from '../src'; +import { search } from './util'; // Promisify timeout to let jest catch works function timeoutPromise(delay = 0) { @@ -28,13 +31,13 @@ describe('TreeSelect.props', () => { createSelect({ open: true, treeDefaultExpandAll: true, ...props }); it('className', () => { - const wrapper = mount(createOpenSelect({ className: 'test-class' })); - expect(wrapper.find('.rc-tree-select').hasClass('test-class')).toBeTruthy(); + const { container } = render(createOpenSelect({ className: 'test-class' })); + expect(container.querySelector('.rc-tree-select')).toHaveClass('test-class'); }); it('prefixCls', () => { - const wrapper = mount(createOpenSelect({ prefixCls: 'another-cls' })); - expect(wrapper.find('.another-cls').length).toBeTruthy(); + const { container } = render(createOpenSelect({ prefixCls: 'another-cls' })); + expect(container.querySelector('.another-cls')).toBeTruthy(); }); describe('filterTreeNode', () => { @@ -42,18 +45,24 @@ describe('TreeSelect.props', () => { function filterTreeNode(input, child) { return String(child.props.title).indexOf(input) !== -1; } - const wrapper = mount(createOpenSelect({ filterTreeNode, showSearch: true })); - wrapper.search('Title 1'); - expect(wrapper.find('List').props().data).toHaveLength(1); + const { container } = render(createOpenSelect({ filterTreeNode, showSearch: true })); + search(container, 'Title 1'); + expect( + container.querySelectorAll('.rc-tree-select-tree-treenode:not([aria-hidden="true"])'), + ).toHaveLength(1); - wrapper.search('0-0'); - expect(wrapper.find('List').props().data).toHaveLength(2); + search(container, '0-0'); + expect( + container.querySelectorAll('.rc-tree-select-tree-treenode:not([aria-hidden="true"])'), + ).toHaveLength(2); }); it('false', () => { - const wrapper = mount(createOpenSelect({ filterTreeNode: false, showSearch: true })); - wrapper.search('Title 1'); - expect(wrapper.find('List').props().data).toHaveLength(4); + const { container } = render(createOpenSelect({ filterTreeNode: false, showSearch: true })); + search(container, 'Title 1'); + expect( + container.querySelectorAll('.rc-tree-select-tree-treenode:not([aria-hidden="true"])'), + ).toHaveLength(4); }); }); @@ -108,28 +117,35 @@ describe('TreeSelect.props', () => { }); it('placeholder', () => { - const wrapper = mount( + const { container } = render( createSelect({ placeholder: 'RC Component', }), ); - expect(wrapper.find('.rc-tree-select-selection-placeholder').text()).toBe('RC Component'); + const placeholderElement = container.querySelector('.rc-tree-select-placeholder'); + expect(placeholderElement).not.toBeNull(); + expect(placeholderElement).toHaveTextContent('RC Component'); }); // https://github.com/ant-design/ant-design/issues/11746 it('async update treeData when has searchInput', () => { const treeData = [{ title: 'aaa', value: '111' }]; - const Wrapper = props => ( + const Wrapper = ({ treeData: propTreeData = treeData }) => (
- +
); - const wrapper = mount(); - expect(wrapper.find('List').props().data).toHaveLength(1); - wrapper.setProps({ - treeData: [{ title: 'bbb', value: '222' }], - }); - expect(wrapper.find('List').length).toBeFalsy(); + const { container, rerender } = render(); + // Filter out hidden nodes and only count visible ones + const visibleNodes = container.querySelectorAll( + '.rc-tree-select-tree-treenode:not([aria-hidden="true"])', + ); + expect(visibleNodes).toHaveLength(1); + rerender(); + const visibleNodesAfter = container.querySelectorAll( + '.rc-tree-select-tree-treenode:not([aria-hidden="true"])', + ); + expect(visibleNodesAfter).toHaveLength(0); }); describe('labelInValue', () => { @@ -156,53 +172,47 @@ describe('TreeSelect.props', () => { }); it('set illegal value', () => { - const wrapper = mount( + const { container } = render( createSelect({ placeholder: 'showMe', labelInValue: true, value: [null], }), ); - expect(wrapper.find('.rc-tree-select-selection-placeholder').text()).toBe('showMe'); + expect(container.querySelector('.rc-tree-select-placeholder')).toHaveTextContent('showMe'); }); it('use user provided one', () => { // Not exist - expect( - mount( - createSelect({ - labelInValue: true, - value: { value: 'not-exist-value', label: 'Bamboo' }, - }), - ) - .getSelection(0) - .text(), - ).toBe('Bamboo'); + const { container } = render( + createSelect({ + labelInValue: true, + value: { value: 'not-exist-value', label: 'Bamboo' }, + }), + ); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('Bamboo'); // Exist not same - expect( - mount( - createSelect({ - labelInValue: true, - value: { value: 'Value 1', label: 'Bamboo' }, - }), - ) - .getSelection(0) - .text(), - ).toBe('Bamboo'); + const { container: container2 } = render( + createSelect({ + labelInValue: true, + value: { value: 'Value 1', label: 'Bamboo' }, + }), + ); + expect(container2.querySelector('.rc-tree-select-content-value')).toHaveTextContent('Bamboo'); }); }); it('onClick', () => { const handleClick = jest.fn(); - const wrapper = mount( + const { container } = render( createSelect({ labelInValue: true, onClick: handleClick, }), ); // `onClick` depends on origin event trigger. Needn't test args - wrapper.find('.rc-tree-select').simulate('click'); + fireEvent.click(container.querySelector('.rc-tree-select')); expect(handleClick).toHaveBeenCalled(); }); @@ -232,52 +242,79 @@ describe('TreeSelect.props', () => { it('onSearch', () => { const handleSearch = jest.fn(); - const wrapper = mount( + const { container } = render( createOpenSelect({ onSearch: handleSearch, }), ); - wrapper.search('Search changed'); + search(container, 'Search changed'); expect(handleSearch).toHaveBeenCalledWith('Search changed'); }); + it('onPopupScroll', async () => { + const onPopupScroll = jest.fn(e => { + // Prevents React from resetting its properties: + e.persist(); + }); + render( + ({ + title: `Title ${index}`, + value: index, + }))} + />, + ); + + fireEvent.scroll(document.querySelector('.rc-tree-select-tree-list-holder'), { + scrollY: 100, + }); + + expect(onPopupScroll).toHaveBeenCalled(); + expect(onPopupScroll.mock.calls[0][0].target).toBe( + document.querySelector('.rc-tree-select-tree-list-holder'), + ); + }); + it('showArrow', () => { - const wrapper = mount(createOpenSelect({ showArrow: false })); + const wrapper = mount(createOpenSelect({ suffixIcon: null })); expect(wrapper.find('.rc-tree-select-arrow').length).toBeFalsy(); }); - it('dropdownClassName', () => { + it('popupClassName', () => { const wrapper = mount( createOpenSelect({ - dropdownClassName: 'test-dropdownClassName', + popupClassName: 'test-popupClassName', }), ); - expect(wrapper.find('.test-dropdownClassName').length).toBeTruthy(); + expect(wrapper.find('.test-popupClassName').length).toBeTruthy(); }); - it('dropdownStyle', () => { + it('popupStyle', () => { const style = { background: 'red', }; const wrapper = mount( createOpenSelect({ - dropdownClassName: 'test-dropdownClassName', - dropdownStyle: style, + popupClassName: 'test-popupClassName', + popupStyle: style, }), ); - expect(wrapper.find('.test-dropdownClassName').first().props().style).toEqual( + expect(wrapper.find('.test-popupClassName').first().props().style).toEqual( expect.objectContaining(style), ); }); it('notFoundContent', () => { - const wrapper = mount( + const { container } = render( createOpenSelect({ notFoundContent:
Noting Matched!
, treeData: [], }), ); - expect(wrapper.find('.not-match').text()).toEqual('Noting Matched!'); + expect(container.querySelector('.not-match')).toHaveTextContent('Noting Matched!'); }); describe('showCheckedStrategy', () => { @@ -401,12 +438,12 @@ describe('TreeSelect.props', () => { // inputValue - already tested in Select.spec.js it('defaultValue', () => { - const wrapper = mount( + const { container } = render( createSelect({ defaultValue: 'Value 0-0', }), ); - expect(wrapper.getSelection(0).text()).toBe('Title 0-0'); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('Title 0-0'); }); // labelInValue - already tested in Select.spec.js @@ -458,7 +495,7 @@ describe('TreeSelect.props', () => { mount( { expect(onSelect).toHaveBeenCalledWith('smart', nodeMatcher(0)); }); - it('dropdownMatchSelectWidth={false} should turn off virtual list', () => { + it('popupMatchSelectWidth={false} should turn off virtual list', () => { const wrapper = mount( @@ -554,7 +591,7 @@ describe('TreeSelect.props', () => { , ); expect(wrapper.find(Tree).props().virtual).toBe(true); - wrapper.setProps({ dropdownMatchSelectWidth: false }); + wrapper.setProps({ popupMatchSelectWidth: false }); expect(wrapper.find(Tree).props().virtual).toBe(false); }); }); @@ -612,5 +649,62 @@ describe('TreeSelect.props', () => { }); }); }); + + describe('title render', () => { + const treeData = [ + { label: 'Label 0-0', value: 'Value 0-0', key: 'key 0-0' }, + { label: 'Label 0-1', value: 'Value 0-1', key: 'key 0-1' }, + { label: 'Label 1-0', value: 'Value 1-0', key: 'key 1-0' }, + ]; + it('basic', () => { + const { container } = render( +
+ node.value} + treeData={treeData} + /> +
, + ); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent( + 'Value 0-0', + ); + }); + + it('with fieldNames', () => { + const { container } = render( +
+ node.myLabel} + fieldNames={{ + label: 'myLabel', + value: 'myValue', + children: 'myChildren', + }} + treeData={[ + { + myLabel: 'Parent', + myValue: 'parent', + myChildren: [ + { + myLabel: 'Sub 1', + myValue: 'sub_1', + }, + { + myLabel: 'Sub 2', + myValue: 'sub_2', + }, + ], + }, + ]} + /> +
, + ); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent( + 'Parent', + ); + }); + }); }); }); diff --git a/tests/Select.semantic.spec.tsx b/tests/Select.semantic.spec.tsx new file mode 100644 index 00000000..d3d75bbc --- /dev/null +++ b/tests/Select.semantic.spec.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import TreeSelect, { TreeNode } from '../src'; + +describe('TreeSelect.semantic', () => { + const createSingleSelect = (props = {}) => ( + + + + + + + + ); + + const createMultipleSelect = (props = {}) => ( + + + + + + + + ); + + it('should support semantic classNames and styles in single mode', () => { + const classNames = { + prefix: 'custom-prefix', + suffix: 'custom-suffix', + input: 'custom-input', + clear: 'custom-clear', + placeholder: 'custom-placeholder', + content: 'custom-content', + }; + + const styles = { + prefix: { color: 'red' }, + suffix: { color: 'blue' }, + input: { backgroundColor: 'yellow' }, + clear: { fontSize: '14px' }, + placeholder: { fontStyle: 'italic' }, + content: { fontWeight: 'bold' }, + }; + + const { container } = render( + createSingleSelect({ + value: '0', + prefix: Prefix, + suffix: Suffix, + allowClear: true, + placeholder: 'Please select', + classNames, + styles, + }), + ); + + // Test prefix + const prefixElement = container.querySelector(`.${classNames.prefix}`); + expect(prefixElement).toBeTruthy(); + expect(prefixElement).toHaveStyle(styles.prefix); + + // Test suffix + const suffixElement = container.querySelector(`.${classNames.suffix}`); + expect(suffixElement).toBeTruthy(); + expect(suffixElement).toHaveStyle(styles.suffix); + + // Test content + const contentElement = container.querySelector(`.${classNames.content}`); + expect(contentElement).toBeTruthy(); + expect(contentElement).toHaveStyle(styles.content); + + // Test clear + const clearElement = container.querySelector(`.${classNames.clear}`); + expect(clearElement).toBeTruthy(); + expect(clearElement).toHaveStyle(styles.clear); + }); + + it('should support semantic classNames and styles in multiple mode', () => { + const classNames = { + prefix: 'custom-prefix', + suffix: 'custom-suffix', + content: 'custom-content', + clear: 'custom-clear', + item: 'custom-item', + itemContent: 'custom-item-content', + itemRemove: 'custom-item-remove', + }; + + const styles = { + prefix: { color: 'red' }, + suffix: { color: 'blue' }, + content: { fontWeight: 'bold' }, + clear: { fontSize: '14px' }, + item: { border: '1px solid green' }, + itemContent: { padding: '4px' }, + itemRemove: { color: 'orange' }, + }; + + const { container } = render( + createMultipleSelect({ + value: ['0', '1'], + prefix: Prefix, + suffix: Suffix, + allowClear: true, + classNames, + styles, + }), + ); + + // Test prefix + const prefixElement = container.querySelector(`.${classNames.prefix}`); + expect(prefixElement).toBeTruthy(); + expect(prefixElement).toHaveStyle(styles.prefix); + + // Test suffix + const suffixElement = container.querySelector(`.${classNames.suffix}`); + expect(suffixElement).toBeTruthy(); + expect(suffixElement).toHaveStyle(styles.suffix); + + // Test content + const contentElement = container.querySelector(`.${classNames.content}`); + expect(contentElement).toBeTruthy(); + expect(contentElement).toHaveStyle(styles.content); + + // Test clear + const clearElement = container.querySelector(`.${classNames.clear}`); + expect(clearElement).toBeTruthy(); + expect(clearElement).toHaveStyle(styles.clear); + + // Test items (multiple mode specific) + const items = container.querySelectorAll(`.${classNames.item}`); + expect(items.length).toBeGreaterThan(0); + items.forEach(item => { + expect(item).toHaveStyle(styles.item); + }); + + // Test item contents (multiple mode specific) + const itemContents = container.querySelectorAll(`.${classNames.itemContent}`); + expect(itemContents.length).toBeGreaterThan(0); + itemContents.forEach(content => { + expect(content).toHaveStyle(styles.itemContent); + }); + + // Test item remove buttons (multiple mode specific) + const removeButtons = container.querySelectorAll(`.${classNames.itemRemove}`); + expect(removeButtons.length).toBeGreaterThan(0); + removeButtons.forEach(button => { + expect(button).toHaveStyle(styles.itemRemove); + }); + }); +}); diff --git a/tests/Select.spec.tsx b/tests/Select.spec.tsx index 74b9cc6a..b3768b7f 100644 --- a/tests/Select.spec.tsx +++ b/tests/Select.spec.tsx @@ -1,14 +1,23 @@ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { mount } from 'enzyme'; -import KeyCode from 'rc-util/lib/KeyCode'; +import KeyCode from '@rc-component/util/lib/KeyCode'; import React from 'react'; import TreeSelect, { TreeNode } from '../src'; import focusTest from './shared/focusTest'; -import { selectNode } from './util'; +import { expectOpen, selectNode, triggerOpen } from './util'; +import type { BaseSelectRef } from '@rc-component/select'; + +const mockScrollTo = jest.fn(); + +// Mock `useScrollTo` from `@rc-component/virtual-list/lib/hooks/useScrollTo` +jest.mock('@rc-component/virtual-list/lib/hooks/useScrollTo', () => { + return () => mockScrollTo; +}); describe('TreeSelect.basic', () => { beforeEach(() => { jest.useFakeTimers(); + mockScrollTo.mockReset(); }); beforeAll(() => { @@ -19,7 +28,7 @@ describe('TreeSelect.basic', () => { jest.useRealTimers(); }); - focusTest('single'); + focusTest(); describe('render', () => { const treeData = [ @@ -51,17 +60,18 @@ describe('TreeSelect.basic', () => { }); it('renders tree correctly', () => { - const wrapper = mount( + const { container } = render( , ); - expect(wrapper.render()).toMatchSnapshot(); + expect(container).toMatchSnapshot(); }); it('not crash if no children', () => { @@ -123,20 +133,20 @@ describe('TreeSelect.basic', () => { }); it('sets default value', () => { - const wrapper = mount( + const { container } = render( , ); - expect(wrapper.getSelection(0).text()).toEqual('label0'); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('label0'); }); it('sets default value(disabled)', () => { - const wrapper = mount( + const { container } = render( , ); - expect(wrapper.getSelection(0).text()).toEqual('label0'); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('label0'); }); it('select value twice', () => { @@ -163,11 +173,11 @@ describe('TreeSelect.basic', () => { { key: '0', value: '0', title: 'label0' }, { key: '1', value: '1', title: 'label1' }, ]; - const wrapper = mount(); - expect(wrapper.getSelection(0).text()).toEqual('label0'); + const { container, rerender } = render(); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('label0'); - wrapper.setProps({ value: '1' }); - expect(wrapper.getSelection(0).text()).toEqual('label1'); + rerender(); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('label1'); }); describe('select', () => { @@ -204,9 +214,9 @@ describe('TreeSelect.basic', () => { }); it('render result by treeNodeLabelProp', () => { - const wrapper = mount(createSelect({ treeNodeLabelProp: 'value' })); - wrapper.selectNode(); - expect(wrapper.getSelection(0).text()).toEqual('0'); + const { container } = render(createSelect({ treeNodeLabelProp: 'value' })); + selectNode(); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('0'); }); }); @@ -226,8 +236,11 @@ describe('TreeSelect.basic', () => { it('fires search event', () => { const onSearch = jest.fn(); - const wrapper = mount(createSelect({ onSearch })); - wrapper.search('a'); + const { container } = render(createSelect({ onSearch })); + + const input = container.querySelector('input')!; + fireEvent.change(input, { target: { value: 'a' } }); + expect(onSearch).toHaveBeenCalledWith('a'); }); @@ -241,17 +254,23 @@ describe('TreeSelect.basic', () => { it('search nodes by filterTreeNode', () => { const filter = (value, node) => node.props.value.toLowerCase() === value.toLowerCase(); - const wrapper = mount(createSelect({ filterTreeNode: filter })); - wrapper.search('A'); - expect(wrapper.find('TreeNode')).toHaveLength(1); - expect(wrapper.find('TreeNode').prop('value')).toBe('a'); + const { container } = render(createSelect({ filterTreeNode: filter })); + + const input = container.querySelector('input')!; + fireEvent.change(input, { target: { value: 'A' } }); + + expect(container.querySelectorAll('.rc-tree-select-tree-title')).toHaveLength(1); + expect(container.querySelector('.rc-tree-select-tree-title')?.textContent).toBe('labela'); }); it('search nodes by treeNodeFilterProp', () => { - const wrapper = mount(createSelect({ treeNodeFilterProp: 'title' })); - wrapper.search('labela'); - expect(wrapper.find('TreeNode')).toHaveLength(1); - expect(wrapper.find('TreeNode').prop('value')).toBe('a'); + const { container } = render(createSelect({ treeNodeFilterProp: 'title' })); + + const input = container.querySelector('input')!; + fireEvent.change(input, { target: { value: 'labela' } }); + + expect(container.querySelectorAll('.rc-tree-select-tree-title')).toHaveLength(1); + expect(container.querySelector('.rc-tree-select-tree-title')?.textContent).toBe('labela'); }); it('filter node but not remove then', () => { @@ -280,14 +299,19 @@ describe('TreeSelect.basic', () => { }); it('close tree when press ESC', () => { - const wrapper = mount( + const { container } = render( , ); - wrapper.openSelect(); - wrapper.find('input').first().simulate('keyDown', { which: KeyCode.ESC }); - expect(wrapper.isOpen()).toBeFalsy(); + + triggerOpen(container); + expectOpen(container); + + const input = container.querySelector('input')!; + fireEvent.keyDown(input, { keyCode: KeyCode.ESC }); + + expectOpen(container, false); }); // https://github.com/ant-design/ant-design/issues/4084 @@ -381,21 +405,20 @@ describe('TreeSelect.basic', () => { describe('scroll to view', () => { it('single mode should trigger scroll', () => { - const wrapper = mount( + const { container } = render( , ); - wrapper.openSelect(); - wrapper.openSelect(); - expect(wrapper.isOpen()).toBeFalsy(); + triggerOpen(container); + expectOpen(container); - const scrollTo = jest.fn(); - wrapper.find('List').instance().scrollTo = scrollTo; + triggerOpen(container); + expectOpen(container, false); - wrapper.openSelect(); - expect(scrollTo).toHaveBeenCalled(); + triggerOpen(container); + expect(mockScrollTo).toHaveBeenCalled(); }); }); @@ -432,13 +455,13 @@ describe('TreeSelect.basic', () => { keyUp(KeyCode.DOWN); keyDown(KeyCode.ENTER); keyUp(KeyCode.ENTER); - matchValue(['parent']); + matchValue(['child']); keyDown(KeyCode.UP); keyUp(KeyCode.UP); keyDown(KeyCode.ENTER); keyUp(KeyCode.ENTER); - matchValue(['parent', 'child']); + matchValue(['child', 'parent']); }); it('selectable works with keyboard operations', () => { @@ -461,12 +484,91 @@ describe('TreeSelect.basic', () => { keyDown(KeyCode.DOWN); keyDown(KeyCode.ENTER); + expect(onChange).not.toHaveBeenCalled(); + + keyDown(KeyCode.UP); + keyDown(KeyCode.ENTER); expect(onChange).toHaveBeenCalledWith(['parent'], expect.anything(), expect.anything()); onChange.mockReset(); + }); + + it('active index matches value', () => { + const wrapper = mount( + , + ); + wrapper.openSelect(); + expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('10 label'); + }); + + it('active index updates correctly with key operation', () => { + const wrapper = mount( + , + ); + + function keyDown(code) { + wrapper.find('input').first().simulate('keyDown', { which: code }); + wrapper.update(); + } + + wrapper.openSelect(); + expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('10 label'); + + keyDown(KeyCode.DOWN); + expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('11 label'); + + keyDown(KeyCode.DOWN); + expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('0 label'); keyDown(KeyCode.UP); - keyDown(KeyCode.ENTER); - expect(onChange).not.toHaveBeenCalled(); + expect(wrapper.find('.rc-tree-select-tree-treenode-active').text()).toBe('11 label'); + }); + + it('should active first un-disabled option when dropdown is opened', () => { + const treeData = [ + { key: '0', value: '0', title: '0 label', disabled: true }, + { key: '1', value: '1', title: '1 label' }, + { key: '2', value: '2', title: '2 label' }, + ]; + + const wrapper = mount(); + + expect(wrapper.find('.rc-tree-select-tree-treenode-active')).toHaveLength(0); + + wrapper.openSelect(); + + const activeNode = wrapper.find('.rc-tree-select-tree-treenode-active'); + expect(activeNode).toHaveLength(1); + expect(activeNode.text()).toBe('1 label'); }); }); @@ -497,14 +599,11 @@ describe('TreeSelect.basic', () => { }); it('update label', () => { - const wrapper = mount(); - expect(wrapper.find('.rc-tree-select-selection-item').text()).toEqual('light'); + const { container, rerender } = render(); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('light'); - wrapper.setProps({ - treeData: [{ value: 'light', title: 'bamboo' }], - }); - wrapper.update(); - expect(wrapper.find('.rc-tree-select-selection-item').text()).toEqual('bamboo'); + rerender(); + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent('bamboo'); }); it('should show parent if children were disabled', () => { @@ -521,23 +620,9 @@ describe('TreeSelect.basic', () => { selectNode(); expect(onSelect).toHaveBeenCalledWith('parent 1-0', expect.anything()); - expect(container.querySelector('.rc-tree-select-selector').textContent).toBe('parent 1-0'); - }); - - it('should not add new tag when key enter is pressed if nothing is active', () => { - const onSelect = jest.fn(); - - const wrapper = mount( - - - - - - , + expect(container.querySelector('.rc-tree-select-content-value')).toHaveTextContent( + 'parent 1-0', ); - - wrapper.find('input').first().simulate('keydown', { which: KeyCode.ENTER }); - expect(onSelect).not.toHaveBeenCalled(); }); it('should not select parent if some children is disabled', () => { @@ -562,4 +647,232 @@ describe('TreeSelect.basic', () => { wrapper.selectNode(1); expect(onChange).toHaveBeenCalledWith(['leaf1'], expect.anything(), expect.anything()); }); + + it('nativeElement', () => { + const treeSelectRef = React.createRef(); + const { container } = render(); + expect(treeSelectRef.current.nativeElement).toBe(container.querySelector('.rc-tree-select')); + }); + + it('support classNames and styles', () => { + const treeData = [ + { + value: 'parent 1', + title: 'parent 1', + children: [ + { + value: 'parent 1-0', + title: 'parent 1-0', + children: [ + { + value: 'leaf1', + title: 'my leaf', + }, + { + value: 'leaf2', + title: 'your leaf', + }, + ], + }, + ], + }, + ]; + const customClassNames = { + prefix: 'test-prefix', + input: 'test-input', + suffix: 'test-suffix', + popup: { + item: 'test-item', + itemTitle: 'test-item-title', + }, + }; + const customStyles = { + prefix: { color: 'green' }, + input: { color: 'blue' }, + suffix: { color: 'yellow' }, + popup: { + item: { color: 'black' }, + itemTitle: { color: 'purple' }, + }, + }; + const { container } = render( +
icon
} + placeholder="Please select" + treeDefaultExpandAll + treeData={treeData} + />, + ); + const prefix = container.querySelector('.rc-tree-select-prefix'); + const input = container.querySelector('.rc-tree-select-input'); + const suffix = + container.querySelector('.rc-tree-select-content-item-suffix') || + container.querySelector('[class*="suffix"]'); + const itemTitle = container.querySelector('.rc-tree-select-tree-title'); + const item = container.querySelector(`.${customClassNames.popup.item}`); + + // 如果suffix还是找不到,就跳过这个检查 + expect(prefix).toHaveClass(customClassNames.prefix); + expect(input).toHaveClass(customClassNames.input); + if (suffix) { + expect(suffix).toHaveClass(customClassNames.suffix); + } + expect(itemTitle).toHaveClass(customClassNames.popup.itemTitle); + + expect(prefix).toHaveStyle(customStyles.prefix); + expect(input).toHaveStyle(customStyles.input); + if (suffix) { + expect(suffix).toHaveStyle(customStyles.suffix); + } + expect(itemTitle).toHaveStyle(customStyles.popup.itemTitle); + expect(item).toHaveStyle(customStyles.popup.item); + }); + + describe('combine showSearch', () => { + const treeData = [ + { key: '0', value: 'a', title: '0 label' }, + { + key: '1', + value: 'b', + title: '1 label', + children: [ + { key: '10', value: 'ba', title: '10 label' }, + { key: '11', value: 'bb', title: '11 label' }, + ], + }, + ]; + it('searchValue and onSearch', () => { + const currentSearchFn = jest.fn(); + const legacySearchFn = jest.fn(); + const { container } = render( + <> +
+ +
+
+ +
+ , + ); + const legacyInput = container.querySelector('#test1 input'); + const currentInput = container.querySelector('#test2 input'); + fireEvent.change(legacyInput, { target: { value: '2' } }); + fireEvent.change(currentInput, { target: { value: '2' } }); + expect(currentSearchFn).toHaveBeenCalledWith('2'); + expect(legacySearchFn).toHaveBeenCalledWith('2'); + }); + it('treeNodeFilterProp and autoClearSearchValue', () => { + const { container } = render( + <> +
+ +
+
+ +
+ , + ); + const legacyInput = container.querySelector('#test1 input'); + const currentInput = container.querySelector('#test2 input'); + fireEvent.change(legacyInput, { target: { value: 'a' } }); + fireEvent.change(currentInput, { target: { value: 'a' } }); + const legacyItem = container.querySelector( + '#test1 .rc-tree-select-tree-title', + ); + const currentItem = container.querySelector( + '#test2 .rc-tree-select-tree-title', + ); + + expect(legacyInput).toHaveValue('a'); + expect(currentInput).toHaveValue('a'); + fireEvent.click(legacyItem); + fireEvent.click(currentItem); + const valueSpan = container.querySelectorAll( + ' .rc-tree-select-content-value', + ); + + expect(valueSpan[0]).toHaveTextContent('0 label'); + expect(valueSpan[1]).toHaveTextContent('0 label'); + }); + it('filterTreeNode', () => { + const { container } = render( + <> +
+ node.value === inputValue} + /> +
+
+ node.value === inputValue, + }} + open + treeDefaultExpandAll + treeData={treeData} + /> +
+ , + ); + const legacyInput = container.querySelector('#test1 input'); + const currentInput = container.querySelector('#test2 input'); + fireEvent.change(legacyInput, { target: { value: 'bb' } }); + fireEvent.change(currentInput, { target: { value: 'bb' } }); + const options = container.querySelectorAll(' .rc-tree-select-tree-title'); + + expect(options).toHaveLength(4); + }); + it.each([ + // [description, props, shouldExist] + ['showSearch=false ', { showSearch: false }, false], + ['showSearch=undefined ', {}, false], + ['showSearch=true', { showSearch: true }, true], + ])('%s', (_, props: { showSearch?: boolean; mode?: 'tags' }, shouldExist) => { + const { container } = render( + , + ); + const inputNode = container.querySelector('input'); + if (shouldExist) { + expect(inputNode).not.toHaveAttribute('readonly'); + } else { + expect(inputNode).toHaveAttribute('readonly'); + } + }); + }); }); diff --git a/tests/Select.tree.spec.js b/tests/Select.tree.spec.js index 914df004..e0b43609 100644 --- a/tests/Select.tree.spec.js +++ b/tests/Select.tree.spec.js @@ -1,8 +1,10 @@ /* eslint-disable no-undef, react/no-multi-comp, no-console */ import React from 'react'; -import { mount } from 'enzyme'; -import { resetWarned } from 'rc-util/lib/warning'; +import { render, fireEvent, act } from '@testing-library/react'; +import { resetWarned } from '@rc-component/util/lib/warning'; import TreeSelect, { TreeNode as SelectNode } from '../src'; +import { selectNode, triggerOpen, expectOpen } from './util'; +import { mount } from 'enzyme'; describe('TreeSelect.tree', () => { const createSelect = props => ( @@ -71,7 +73,7 @@ describe('TreeSelect.tree', () => { it('warning if node key are not same as value', () => { resetWarned(); const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount(); + render(); expect(spy).toHaveBeenCalledWith( 'Warning: `key` or `value` with TreeNode must be the same or you can remove one of them. key: little, value: ttt.', ); @@ -81,7 +83,7 @@ describe('TreeSelect.tree', () => { it('warning if node undefined value', () => { resetWarned(); const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount(); + render(); expect(spy).toHaveBeenCalledWith('Warning: TreeNode `value` is invalidate: undefined'); spy.mockRestore(); }); @@ -89,7 +91,7 @@ describe('TreeSelect.tree', () => { it('warning if node has same value', () => { resetWarned(); const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount( + render( { // https://github.com/ant-design/ant-design/issues/14597 it('empty string is also a value', () => { - const wrapper = mount( + const { container } = render( , ); - expect(wrapper.getSelection(0).text()).toEqual('empty string'); + const selectionContent = container.querySelector('.rc-tree-select-content-value'); + expect(selectionContent?.textContent).toEqual('empty string'); }); describe('treeNodeLabelProp', () => { @@ -121,7 +124,7 @@ describe('TreeSelect.tree', () => { }, ].forEach(({ name, ...restProps }) => { it(name, () => { - const wrapper = mount( + const { container } = render( { />, ); - expect(wrapper.find('.rc-tree-select-tree-title').text()).toEqual('a light'); - expect(wrapper.find('.rc-tree-select-selection-item').text()).toEqual('Light'); + expect(container.querySelector('.rc-tree-select-tree-title')?.textContent).toEqual( + 'a light', + ); + expect(container.querySelector('.rc-tree-select-content-value')?.textContent).toEqual( + 'Light', + ); }); }); }); it('Node icon', () => { - const wrapper = mount( + const { container } = render( } /> , ); - expect(wrapper.exists('.bamboo-light')).toBeTruthy(); + expect(container.querySelector('.bamboo-light')).toBeTruthy(); }); it('dynamic with filter should not show expand icon', () => { - const wrapper = mount( + const { container } = render( { />, ); - expect(wrapper.exists('.rc-tree-select-tree-icon__open')).toBeFalsy(); + expect(container.querySelector('.rc-tree-select-tree-icon__open')).toBeFalsy(); }); }); diff --git a/tests/Select.warning.spec.js b/tests/Select.warning.spec.js index 7bbde15e..8ab3f83b 100644 --- a/tests/Select.warning.spec.js +++ b/tests/Select.warning.spec.js @@ -1,6 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; -import { resetWarned } from 'rc-util/lib/warning'; +import { resetWarned } from '@rc-component/util/lib/warning'; import TreeSelect from '../src'; describe('TreeSelect.warning', () => { @@ -56,19 +56,24 @@ describe('TreeSelect.warning', () => { ); }); - it('documentClickClose should removed', () => { - const wrapper = mount( - { - expect(documentClickClose).toBeFalsy(); - }} - />, + it('warns on using maxCount with showCheckedStrategy=SHOW_ALL when treeCheckStrictly=false', () => { + mount(); + expect(spy).toHaveBeenCalledWith( + 'Warning: `maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', ); + }); - wrapper.openSelect(); - + it('warns on using maxCount with showCheckedStrategy=SHOW_PARENT', () => { + mount(); expect(spy).toHaveBeenCalledWith( - 'Warning: Second param of `onDropdownVisibleChange` has been removed.', + 'Warning: `maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', + ); + }); + + it('does not warn on using maxCount with showCheckedStrategy=SHOW_ALL when treeCheckStrictly=true', () => { + mount(); + expect(spy).not.toHaveBeenCalledWith( + 'Warning: `maxCount` not work with `showCheckedStrategy=SHOW_ALL` (when `treeCheckStrictly=false`) or `showCheckedStrategy=SHOW_PARENT`.', ); }); }); diff --git a/tests/__mocks__/@rc-component/virtual-list.js b/tests/__mocks__/@rc-component/virtual-list.js new file mode 100644 index 00000000..a8316213 --- /dev/null +++ b/tests/__mocks__/@rc-component/virtual-list.js @@ -0,0 +1,3 @@ +import List from '@rc-component/virtual-list/lib/mock'; + +export default List; diff --git a/tests/__mocks__/rc-virtual-list.js b/tests/__mocks__/rc-virtual-list.js deleted file mode 100644 index 21b6e92a..00000000 --- a/tests/__mocks__/rc-virtual-list.js +++ /dev/null @@ -1,3 +0,0 @@ -import List from 'rc-virtual-list/lib/mock'; - -export default List; diff --git a/tests/__snapshots__/Select.checkable.spec.tsx.snap b/tests/__snapshots__/Select.checkable.spec.tsx.snap index 63c51d47..198d4a14 100644 --- a/tests/__snapshots__/Select.checkable.spec.tsx.snap +++ b/tests/__snapshots__/Select.checkable.spec.tsx.snap @@ -3,161 +3,146 @@ exports[`TreeSelect.checkable uncheck remove by selector not treeCheckStrictly 1`] = `
+
+
+
+ + 0 +
- -
-
-
-
-